C# 金額為什麼用 decimal,SQL Server 又為什麼不一定用 MONEY?

double 的問題不是溢位,而是近似值

double 使用二進位浮點數。它很適合科學計算、座標、統計資料,以及可以接受些微誤差的數值。

但十進位的 0.1 沒辦法用有限的二進位位數完整表示。程式實際保存的,只是非常接近 0.1 的值。

用一個最常見的例子就能看出差異:

double first = 0.1;
double second = 0.2;
double result = first + second;

Console.WriteLine(result.ToString("R"));
Console.WriteLine(result == 0.3);

結果是:

0.30000000000000004
False

人看起來明明就是 0.1 + 0.2 = 0.3,程式比較後卻得到 false

單次誤差很小,小到一般畫面顯示時可能根本看不出來。可是付款系統還會計算折扣、稅額、手續費,也可能累加大量交易。這時候「差一點點」就不再只是顯示問題。

再看一個累加範例:

double total = 0;

for (var index = 0; index < 10; index++)
{
    total += 0.1;
}

Console.WriteLine(total.ToString("R"));

結果可能是:

0.9999999999999999

如果這個數值被拿去判斷餘額是否等於 1,程式就可能走到錯誤的分支。

C# 金額通常使用 decimal

decimal 採用十進位方式表示數值,比較符合我們日常使用的金額格式。

同樣的計算改成 decimal

decimal first = 0.1m;
decimal second = 0.2m;
decimal result = first + second;

Console.WriteLine(result);
Console.WriteLine(result == 0.3m);

結果是:

0.3
True

程式中的 m 不能省略。它表示這個常值是 decimal

decimal amount = 1200.50m;

如果是訂單的 API Request,目前可以先這樣定義:

public sealed class CreateOrderRequest
{
    public decimal Amount { get; init; }

    public string Currency { get; init; } = string.Empty;
}

AmountCurrency 要一起出現。只有數字,並不能說明它是新臺幣、美元還是日圓。

等到 Domain 模型逐漸完整後,也可以把兩者包成一個 Money

public sealed record Money(
    decimal Amount,
    string Currency);

不過 decimal 只解決數值表示的問題,沒有替我們決定四捨五入規則。

例如:

decimal average = 100m / 3m;

Console.WriteLine(average);

會得到很多位小數:

33.333333333333333333333333333

最後保留幾位、在哪個步驟取捨,仍然要由業務規則決定:

decimal rounded = decimal.Round(
    average,
    2,
    MidpointRounding.AwayFromZero);

有些系統使用 AwayFromZero,有些帳務規則使用 ToEven。這不能只憑工程師習慣決定。

SQL Server 的 MONEY 並不是浮點數

MONEYdouble 的問題不一樣。

SQL Server 的 MONEY 是固定四位小數的貨幣型別,不會發生 double 那種二進位浮點誤差。像下面這個數值可以正確保存:

DECLARE @Amount MONEY = 1200.1234;

既然如此,為什麼很多系統仍然使用 DECIMAL

因為 MONEY 的小數位固定為四位。這個設定不一定符合實際需求。

訂單金額可能只需要兩位小數,匯率可能需要八位,稅率和手續費也可能各有不同精度。全部塞進 MONEY,資料表看不出每個欄位真正允許的範圍。

使用 DECIMAL 時,可以直接把規則寫進欄位定義:

Amount DECIMAL(19, 2)

19 是總位數,2 是小數位數。換句話說,整數部分最多有 17 位。

匯率則可以使用不同的精度:

ExchangeRate DECIMAL(19, 8)

看 Schema 就知道金額與匯率不是同一種精度需求。

MONEY 進行計算時也可能太早失去小數

假設要把 10 元平均分成三份:

DECLARE @Amount MONEY = 10.0000;

SELECT @Amount / 3;

MONEY 只能保留四位小數,因此得到的結果類似:

3.3333

如果這只是最後顯示的金額,可能沒有問題。但若後面還要乘上匯率、計算稅額或手續費,小數在中間步驟就已經被縮減。

改用較高精度的 DECIMAL

DECLARE @Amount DECIMAL(19, 8) = 10;

SELECT @Amount / 3;

中間運算可以保留更多位數,等到真正產生應收金額時,再按照幣別及帳務規則處理。

所以 MONEY 不是不能用。當系統確定只需要四位小數,而且不會進行複雜的中間計算時,它仍然能正常工作。只是付款系統通常希望精度規則更明確,DECIMAL(p, s) 會比較容易控制。

在應用程式與資料庫之間怎麼搭配?

如果系統要保存一般訂單金額,可能會看到這樣的組合。

C#:

public decimal Amount { get; init; }

public string Currency { get; init; } = string.Empty;

SQL Server:

Amount   DECIMAL(19, 4) NOT NULL,
Currency CHAR(3)        NOT NULL,
CONSTRAINT CK_Order_Amount_Positive CHECK (Amount > 0)

這裡的 DECIMAL(19, 4) 只是範例,不是付款系統的固定答案。

如果只接受新臺幣整數金額,也可能使用零位小數;若要支援匯率或更細的計價單位,則可能需要四位以上。真正的型別應該根據支援的幣別與計算方式決定。

還有一種做法,是把金額換算成最小貨幣單位後使用整數儲存:

long amountInMinorUnits = 1050;

這可能代表 10.50 美元,也就是 1050 cents。但日圓、新臺幣和其他幣別的小數規則不同,API 契約必須先把單位講清楚,否則整數一樣會造成誤解。

以目前的練習來說,C# 先使用 decimal,並讓金額永遠伴隨 Currency。資料庫還沒有進入實作階段,不需要現在就決定使用 MONEYDECIMAL(19, 2) 或最小貨幣單位。先把 API 的輸入、驗證與回應定義清楚,之後才有足夠資訊決定資料要怎麼存。

Author image
關於 Richard Zheng
About me 喜歡爬山,瑜伽,溜冰,喜歡新奇的事,最喜歡的還是寫程式帶來的成就感,對於資訊會不斷的出現新事物也能抱持好奇與熱忱。近期開始將學習的心得寫在Blog,發現思路更清晰也加深了記憶。 紙上得來終覺淺,絕知此事要躬行