DEV Community

EME GUG
EME GUG

Posted on

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')
Enter fullscreen mode Exit fullscreen mode

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.
  2. Chỉ định rõ rounding mode khi quantize.
  3. 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')}
Enter fullscreen mode Exit fullscreen mode

Để ý 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.

// 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 ₫"
Enter fullscreen mode Exit fullscreen mode

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:

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.

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
Enter fullscreen mode Exit fullscreen mode

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:

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.

Top comments (0)