0

Stop Using Float for Money: A Practical Guide to Decimal Arithmetic in Python, JavaScript and PostgreSQL

Hồi mới đi làm, mình từng debug một bug khá ngớ ngẩn: report doanh thu cuối tháng lệch với số liệu bên kế toán đúng... 1 đồng. Không phải 1 triệu, mà là 1 đồng. Sếp bảo thôi kệ, nhưng bên kế toán thì không kệ. Sau 2 ngày đào bới, nguyên nhân là một dòng total += item.price * 0.08 chạy trên float. Từ đó mình có một nguyên tắc cứng: tiền không bao giờ được đi qua kiểu float. Hôm nay trên HN có bài về việc thêm floating-point decimal vào một ngôn ngữ, nên mình tranh thủ viết lại những gì mình đã học được về xử lý số thập phân trong thực tế: tại sao float sai, dùng gì thay thế, và làm sao để cả stack (backend, frontend, database) nói cùng một ngôn ngữ.

Tại sao float lại "sai"?

Thật ra float không sai, nó chỉ làm đúng việc của nó: biểu diễn số theo chuẩn IEEE 754 ở hệ nhị phân. Vấn đề là các số như 0.1 không thể biểu diễn chính xác trong hệ 2, giống như 1/3 không thể viết hữu hạn trong hệ 10. Nên máy lưu một giá trị xấp xỉ, và sai số sẽ tích lũy qua mỗi phép tính.

# Python 3.12
>>> 0.1 + 0.2
0.30000000000000004

>>> sum([0.1] * 10) == 1.0
False

>>> round(2.675, 2)   # mong đợi 2.68
2.67

>>> from decimal import Decimal
>>> Decimal(2.675)    # xem giá trị thật đang được lưu
Decimal('2.67499999999999982236431605997495353221893310546875')

Ví dụ round(2.675, 2) là cái bẫy kinh điển: bạn nghĩ Python làm tròn sai, nhưng thực ra giá trị trong bộ nhớ là 2.67499999..., nên làm tròn xuống là đúng. Với một app tính lương, tính thuế VAT hay chia bill, sai số kiểu này nhân lên hàng nghìn record là đủ để kế toán gọi bạn lên nói chuyện.

Có một chi tiết nữa: round() của Python 3 dùng banker's rounding (ROUND_HALF_EVEN), nên round(0.5) == 0 và round(2.5) == 2. Trong khi đó, hầu hết quy định kế toán ở Việt Nam dùng làm tròn "nửa lên" (ROUND_HALF_UP). Hai thứ này lệch nhau là chuyện thường.

Python: dùng decimal đúng cách

Module decimal có sẵn trong standard library, được implement bằng C (libmpdec) từ Python 3.3 nên tốc độ khá ổn. Nhưng dùng sai thì vẫn dính bug như thường. Ba quy tắc mình luôn tuân theo:

  1. Luôn khởi tạo từ string, không bao giờ từ float.
    1. Chỉ định rõ rounding mode khi quantize.
    1. Làm tròn ở bước cuối cùng, không làm tròn từng bước trung gian.
from decimal import Decimal, ROUND_HALF_UP, getcontext

getcontext().prec = 28  # mặc định là 28 chữ số, đủ cho hầu hết case

VAT_RATE = Decimal("0.08")   # ĐÚNG: từ string
# VAT_RATE = Decimal(0.08)   # SAI: mang theo sai số của float

def calc_invoice(items: list[dict], currency_exp: int = 0) -> dict:
    """currency_exp: số chữ số thập phân của tiền tệ (VND = 0, USD = 2)"""
        quantum = Decimal(1).scaleb(-currency_exp)  # VND -> 1, USD -> 0.01
        
            subtotal = sum(
                    (Decimal(i["price"]) * i["qty"] for i in items),
                            start=Decimal(0),
                                )
                                    vat = subtotal * VAT_RATE
                                        total = subtotal + vat
                                        
                                            # chỉ làm tròn ở đây
                                                return {
                                                        "subtotal": subtotal.quantize(quantum, rounding=ROUND_HALF_UP),
                                                                "vat": vat.quantize(quantum, rounding=ROUND_HALF_UP),
                                                                        "total": total.quantize(quantum, rounding=ROUND_HALF_UP),
                                                                            }
                                                                            
                                                                            items = [{"price": "19990", "qty": 3}, {"price": "45500", "qty": 1}]
                                                                            print(calc_invoice(items))
                                                                            # {'subtotal': Decimal('105470'), 'vat': Decimal('8438'), 'total': Decimal('113908')}
                                                                            ```
                                                                            
                                                                            Để ý tham số `currency_exp`: theo ISO 4217, VND có exponent là 0 (không có đơn vị "xu" trong thực tế), USD và EUR là 2, còn JPY là 0. Đừng hardcode `0.01` khắp nơi, vì ngày nào đó app của bạn phải hỗ trợ thêm tiền tệ khác là phải sửa cả trăm chỗ.
                                                                            
                                                                            Nếu dùng Django thì có `DecimalField`, còn Pydantic v2 parse `Decimal` từ JSON string rất ngon. Với FastAPI, mình hay khai báo `price: Decimal` trong model và yêu cầu client gửi giá trị dạng string.
                                                                            
                                                                            ## JavaScript: không có Decimal native, vậy làm sao?
                                                                            
                                                                            Đây là phần đau đầu nhất. JavaScript chỉ có một kiểu `number` (IEEE 754 double), và proposal `Decimal` của TC39 vẫn đang nằm ở giai đoạn proposal, chưa dùng được trong production. Có hai hướng thực tế:
                                                                            
                                                                            **Cách 1: Lưu ở đơn vị nhỏ nhất (minor units) dưới dạng integer.** Ví dụ USD lưu bằng cents, `$19.99` thành `1999`. Integer trong khoảng `Number.MAX_SAFE_INTEGER` (khoảng 9 triệu tỷ) là chính xác tuyệt đối. Với VND số lớn thì cân nhắc `BigInt`.
                                                                            
                                                                            **Cách 2: Dùng thư viện** như `decimal.js` (v10.4.x) hoặc `big.js` (nhẹ hơn, khoảng 6KB). Phù hợp khi cần nhân chia với tỷ lệ phần trăm, lãi suất.
                                                                            
                                                                            ```javascript
                                                                            // npm i decimal.js@10
                                                                            import Decimal from "decimal.js";
                                                                            
                                                                            Decimal.set({ precision: 28, rounding: Decimal.ROUND_HALF_UP });
                                                                            
                                                                            console.log(0.1 + 0.2);                                  // 0.30000000000000004
                                                                            console.log(new Decimal("0.1").plus("0.2").toString());  // "0.3"
                                                                            
                                                                            // Chia bill cho 3 người mà không mất đồng nào
                                                                            function splitBill(totalStr, people) {
                                                                              const total = new Decimal(totalStr);
                                                                                const base = total.dividedToIntegerBy(people);      // phần chia đều
                                                                                  const remainder = total.minus(base.times(people)); // phần dư
                                                                                  
                                                                                    return Array.from({ length: people }, (_, i) =>
                                                                                        // dồn phần dư cho người đầu tiên, tổng luôn khớp
                                                                                            (i === 0 ? base.plus(remainder) : base).toFixed(0)
                                                                                              );
                                                                                              }
                                                                                              
                                                                                              console.log(splitBill("100000", 3)); // [ '33334', '33333', '33333' ]
                                                                                              
                                                                                              // Hiển thị: chỉ format ở tầng UI
                                                                                              const fmt = new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND" });
                                                                                              console.log(fmt.format(Number("113908"))); // "113.908 ₫"
                                                                                              ```
                                                                                              
                                                                                              Ví dụ chia bill là case mình gặp rất nhiều: `100000 / 3` nhân lại 3 không bao giờ ra `100000`. Giải pháp không nằm ở thư viện mà nằm ở **business logic**: phải quyết định ai chịu phần dư. Đây là lỗi mà dùng Decimal cũng không tự cứu bạn được.
                                                                                              
                                                                                              ## Giữ chính xác xuyên suốt cả stack
                                                                                              
                                                                                              Dùng Decimal ở backend mà để JSON serialize thành number thì công cốc, vì `JSON.parse` ở client sẽ biến nó lại thành float. Đây là flow mình áp dụng:
                                                                                              
                                                                                              ```mermaid
                                                                                              flowchart LR
                                                                                                  A[PostgreSQL NUMERIC] -->|driver trả về Decimal| B[Backend Python Decimal]
                                                                                                      B -->|serialize thành string| C[JSON API]
                                                                                                          C -->|parse bằng decimal.js| D[Frontend logic]
                                                                                                              D -->|Intl.NumberFormat| E[UI hiển thị]
                                                                                                                  D -->|gửi lại dạng string| C
                                                                                                                  ```
                                                                                                                  
                                                                                                                  Ở tầng database, PostgreSQL có kiểu `NUMERIC(precision, scale)` lưu chính xác tuyệt đối. Tránh dùng `REAL`, `DOUBLE PRECISION`, và mình cũng khuyên tránh kiểu `MONEY` của Postgres vì nó phụ thuộc setting `lc_monetary`, rất dễ gây rắc rối khi đổi server.
                                                                                                                  
                                                                                                                  ```sql
                                                                                                                  CREATE TABLE invoices (
                                                                                                                      id          BIGSERIAL PRIMARY KEY,
                                                                                                                          currency    CHAR(3)        NOT NULL DEFAULT 'VND',
                                                                                                                              subtotal    NUMERIC(18, 2) NOT NULL,
                                                                                                                                  vat_amount  NUMERIC(18, 2) NOT NULL,
                                                                                                                                      total       NUMERIC(18, 2) NOT NULL,
                                                                                                                                          CHECK (total = subtotal + vat_amount)
                                                                                                                                          );
                                                                                                                                          
                                                                                                                                          -- Kiểm tra nhanh sự khác biệt
                                                                                                                                          SELECT 0.1::float8 + 0.2::float8 AS float_sum,   -- 0.30000000000000004
                                                                                                                                                 0.1::numeric + 0.2::numeric AS num_sum;   -- 0.3
                                                                                                                                                 ```
                                                                                                                                                 
                                                                                                                                                 Cái `CHECK` constraint ở trên nhìn đơn giản nhưng là lớp phòng thủ cuối cùng: nếu code ở đâu đó lỡ tính sai, database sẽ từ chối ghi luôn thay vì để dữ liệu bẩn lọt vào.
                                                                                                                                                 
                                                                                                                                                 Với driver: `psycopg` 3 tự động map `NUMERIC` sang `Decimal` của Python. Còn `node-postgres` (pg) mặc định trả `NUMERIC` về dạng **string**, đây là thiết kế cố ý, đừng vội `parseFloat` nó.
                                                                                                                                                 
                                                                                                                                                 Về quyết định lúc nào làm tròn, mình tóm tắt thế này:
                                                                                                                                                 
                                                                                                                                                 ```mermaid
                                                                                                                                                 flowchart TD
                                                                                                                                                     A[Có phép tính tiền] --> B{Là bước cuối cùng?}
                                                                                                                                                         B -->|Không| C[Giữ full precision]
                                                                                                                                                             C --> A
                                                                                                                                                                 B -->|Có| D[quantize theo exponent của currency]
                                                                                                                                                                     D --> E{Có chia cho nhiều phần?}
                                                                                                                                                                         E -->|Có| F[Phân bổ phần dư theo business rule]
                                                                                                                                                                             E -->|Không| G[Lưu DB]
                                                                                                                                                                                 F --> G
                                                                                                                                                                                 ```
                                                                                                                                                                                 
                                                                                                                                                                                 ## Kết luận
                                                                                                                                                                                 
                                                                                                                                                                                 Bug tiền nong hiếm khi làm app crash, nó chỉ âm thầm lệch vài đồng cho tới khi có người đối soát. Một vài việc bạn có thể làm ngay hôm nay:
                                                                                                                                                                                 
                                                                                                                                                                                 - **Grep codebase** tìm `float(`, `parseFloat`, `toFixed` ở những chỗ đụng tới tiền: `rg -n "parseFloat|toFixed|float\(" src/ | rg -i "price|amount|total"`.
                                                                                                                                                                                 - **Database**: chuyển các cột tiền sang `NUMERIC(18, 2)` hoặc lưu integer theo minor units, thêm `CHECK` constraint cho các quan hệ tổng.
                                                                                                                                                                                 - **API contract**: quy ước mọi giá trị tiền được gửi dạng **string** trong JSON, ghi rõ trong OpenAPI spec.
                                                                                                                                                                                 - **Python**: luôn `Decimal("...")` từ string, `quantize` với `ROUND_HALF_UP` một lần ở bước cuối.
                                                                                                                                                                                 - **JavaScript**: dùng integer minor units cho case đơn giản, `decimal.js` hoặc `big.js` khi có nhân chia tỷ lệ; chỉ format bằng `Intl.NumberFormat` ở tầng UI.
                                                                                                                                                                                 - **Viết test** cho các case kinh điển: `0.1 + 0.2`, `2.675` làm tròn, chia `100000` cho 3. Mấy test này chạy chưa tới 1ms nhưng cứu bạn khỏi những buổi họp rất dài với kế toán.
                                                                                                                                                                                 
                                                                                                                                                                                 Float sinh ra để tính vật lý, đồ họa, machine learning, những nơi sai số nhỏ chấp nhận được. Tiền thì không như vậy.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí