Ngừng Dùng Float Lưu Tiền: Hãy Dùng Số Nguyên

Ngừng Dùng Float Lưu Tiền: Hãy Dùng Số Nguyên

Ngừng thất thoát tiền vì lỗi float. Tìm hiểu lý do 0.1 + 0.2 bị sai và cách dùng số nguyên (cents) hoặc Decimal để xử lý tiền tệ an toàn trong code.

Nếu bạn đang xây dựng nền tảng e-commerce, hệ thống billing hay một ứng dụng fintech, việc dùng số thực dấu phẩy động (float) để lưu trữ và tính tiền là cách nhanh nhất để làm thất thoát tài chính và rớt kiểm toán. Bài viết này sẽ mổ xẻ lý do float bị sai số và cách giải quyết triệt để bằng phương pháp dùng Số nguyên (Cents).

Gốc rễ vấn đề: Chuẩn IEEE 754

Nếu bạn đã code JavaScript (hoặc hầu hết các ngôn ngữ phổ biến khác) được một thời gian, chắc hẳn bạn đã gặp câu hỏi phỏng vấn kinh điển: Tại sao 0.1 + 0.2 lại bằng 0.30000000000000004?

Thường thì chúng ta chỉ cười xòa và coi đó là một "tính năng" kỳ cục của ngôn ngữ. Dưới engine, hầu hết các ngôn ngữ lập trình hiện đại (bao gồm JavaScript, float mặc định của Python, và double của Java) đều sử dụng chuẩn IEEE 754 cho các phép toán số thực. Chuẩn này biểu diễn các con số dưới dạng hệ nhị phân (base-2).

Dù hệ nhị phân cực kỳ tối ưu cho máy tính, nó lại gặp khó khăn với một số phân số trong hệ thập phân (base-10). Giống như việc phân số 1/3 không thể viết chính xác ở hệ thập phân (nó thành 0.3333...), các phân số như 1/10 (0.1) hay 1/5 (0.2) sẽ trở thành các chuỗi số nhị phân tuần hoàn vô hạn.

Khi máy tính cố gắng lưu trữ các số nhị phân tuần hoàn này vào một vùng nhớ giới hạn (thường là 64-bit cho double precision), nó buộc phải làm tròn (round off). Với một phép tính đơn lẻ, sai số này nhỏ đến mức vô hình. Nhưng tiền thì không bao giờ đứng yên; nó bị nhân với thuế suất, chia cho các bên liên quan, và cộng dồn qua hàng triệu giao dịch.

Khi những sai số hiển vi tích tụ

Máy tính kế toán cùng tài liệu tài chính

Hãy tưởng tượng bạn vận hành một nền tảng SaaS và cần tính thuế 8.25% cho một gói cước giá $19.99.

Nếu dùng float, phép toán có thể trả ra một con số với hàng tá chữ số thập phân. Bạn làm tròn nó xuống 2 chữ số để in ra hóa đơn. Bây giờ hãy tưởng tượng bạn xử lý 100.000 giao dịch như vậy mỗi tháng. Vì những sai số li ti trong các bước tính toán trung gian — đặc biệt là khi bạn dùng vòng lặp cộng dồn một mảng lớn các con số vốn đã không hoàn hảo này để làm báo cáo doanh thu — tổng số tiền của bạn có thể lệch đi vài đô la, thậm chí hàng trăm đô la.

Trong thế giới kế toán, sổ sách phải khớp nhau đến từng đồng cắc. Nếu database của bạn ghi nhận thu được $1,000,000.05 nhưng cổng thanh toán (payment gateway) lại báo $1,000,000.00, hệ thống của bạn về cơ bản là đang bị lỗi.

Giải pháp "chân ái": Dùng số nguyên (Cách tiếp cận Cents)

Kiến trúc máy chủ cơ sở dữ liệu

Cách an toàn, chống đạn và phổ quát nhất để xử lý tiền bạc là ngừng sử dụng số thập phân. Thay vì lưu Đô la (hoặc Euro, Bảng Anh) dưới dạng float, bạn hãy lưu đơn vị nhỏ nhất của loại tiền tệ đó dưới dạng một số nguyên (Integer).

Với USD, đơn vị nhỏ nhất là cent. Vậy $19.99 sẽ được lưu trong database và memory là 1999. Riêng với VNĐ, do chúng ta không xài xu nữa nên đơn vị cơ sở đã là Đồng, bản thân nó là số nguyên. Tuy nhiên, nếu bạn làm app đa quốc gia, tư duy "lưu đơn vị nhỏ nhất" vẫn luôn đúng.

Tại sao số nguyên lại hoạt động tốt?

Số nguyên không bị mắc kẹt vào vấn đề biểu diễn phân số của hệ nhị phân. 1999 + 100 chính xác tuyệt đối là 2099. Bạn có thể cộng hàng triệu giá trị nguyên và kết quả sẽ luôn luôn đúng.

Dưới đây là một ví dụ thực tế bằng TypeScript:

// ❌ BAD: Dùng float để tính tiền
function calculateTotalFloat(price: number, quantity: number, taxRate: number) {
  const subtotal = price * quantity;
  const tax = subtotal * taxRate;
  return subtotal + tax; 
}
// calculateTotalFloat(19.99, 3, 0.0825) -> dễ sinh ra sai số thập phân

// ✅ GOOD: Dùng số nguyên (cents)
function calculateTotalCents(priceInCents: number, quantity: number, taxRate: number): number {
  const subtotal = priceInCents * quantity;
  // Vẫn dùng float cho taxRate, nhưng phải dùng Math.round (hoặc Math.floor tùy luật) 
  // ngay lập tức để ép tiền thuế về lại dạng số nguyên an toàn
  const tax = Math.round(subtotal * taxRate); 
  return subtotal + tax;
}

Lưu ý rằng chúng ta vẫn dùng float cho taxRate (như 0.0825), nhưng bước sống còn là dùng Math.round() ngay sau phép nhân để đưa số tiền thuế về lại dạng số nguyên trước khi thực hiện bất kỳ phép cộng nào tiếp theo.

Cấu trúc Database

Trong database, bạn chỉ cần dùng cột kiểu INTEGER hoặc BIGINT.

CREATE TABLE products (
    id UUID PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    price_cents BIGINT NOT NULL,
    currency VARCHAR(3) DEFAULT 'USD'
);

Tôi đặc biệt khuyên dùng BIGINT thay vì integer thông thường. Nhất là khi bạn xử lý các loại tiền tệ có mệnh giá lớn (như VNĐ hay IDR), các giao dịch rất dễ làm tràn giới hạn của số nguyên 32-bit (khoảng 2.1 tỷ). Cứ BIGINT mà dùng cho an tâm.

Pattern "Money" của Martin Fowler

Lưu mỗi price_cents thì tốt rồi, nhưng chuyện gì xảy ra nếu app của bạn hỗ trợ nhiều loại tiền tệ? Vô tình lấy 1000 cents USD cộng với 1000 cents EUR là một thảm họa.

Martin Fowler từng giới thiệu một pattern tên là "Money", trong đó đóng gói cả giá trị và loại tiền tệ vào chung một object.

class Money {
  constructor(
    public readonly amount: number, // lưu bằng đơn vị nhỏ nhất
    public readonly currency: string // chuẩn ISO 4217, ví dụ: 'USD', 'VND'
  ) {
    if (!Number.isInteger(amount)) {
      throw new Error("Amount phải là số nguyên");
    }
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Không thể cộng 2 loại tiền tệ khác nhau");
    }
    return new Money(this.amount + other.amount, this.currency);
  }
}

const price = new Money(1999, "USD");

Pattern này ngăn chặn việc làm toán cộng trừ loạn xạ giữa các loại tiền và ép các dev trong team phải luôn dùng số nguyên ngay từ đầu.

Phương án thay thế: Kiểu Decimal chính xác

Nếu bạn kiên quyết không muốn dùng số nguyên, giải pháp còn lại là dùng các kiểu Decimal chính xác. Rất nhiều database hỗ trợ cột kiểu DECIMAL hoặc NUMERIC (ví dụ DECIMAL(10, 2) nghĩa là tổng cộng 10 chữ số, trong đó 2 chữ số sau dấu phẩy).

Các kiểu dữ liệu database này tính toán hệ thập phân (base-10) hoàn toàn chính xác. Tuy nhiên, bạn bắt buộc phải kết hợp chúng với một thư viện Decimal tương ứng ở tầng application code. Trong JavaScript/TypeScript, kiểu Number native là vô dụng trong bài toán này. Bạn phải cài thêm thư viện như decimal.js, big.js, hoặc bignumber.js.

import Decimal from "decimal.js";

const price = new Decimal("19.99");
const taxRate = new Decimal("0.0825");
const tax = price.times(taxRate).toDecimalPlaces(2, Decimal.ROUND_HALF_UP);
const total = price.plus(tax);

Ưu điểm: Viết code thuận mắt, gần với cách con người suy nghĩ hơn (nhìn 19.99 vẫn dễ hiểu hơn là 1999).
Nhược điểm: Phải import thư viện bên ngoài, tốc độ xử lý chậm hơn nhiều so với số nguyên native, và rủi ro cao vì một dev nào đó có thể lỡ tay ép cái object Decimal kia về lại float của JS (làm bug quay lại).

Hiển thị ở lớp vỏ ngoài (Presentation Edge)

Nếu bạn theo đuổi cách dùng Số nguyên, hãy nhớ nguyên tắc: chỉ chuyển đổi nó về dạng thập phân ở ngay rìa ngoài cùng của ứng dụng — tức là khoảnh khắc ngay trước khi render ra màn hình cho người dùng xem. Các trình duyệt hiện đại và Node.js đều đã có sẵn API Intl.NumberFormat lo việc này.

function formatCentsToUSD(cents: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(cents / 100); // Chia 100 ở lúc hiển thị
}

console.log(formatCentsToUSD(1999)); // kết quả: "$19.99"

Tóm lại

Số thực dấu phẩy động (float) là một phát minh tuyệt vời cho mô phỏng vật lý, xử lý đồ họa, và tính toán khoa học. Nhưng nó hoàn toàn vô duyên và đối nghịch với tài chính.

Trong dự án tiếp theo, hãy làm ơn tạo phước cho chính bạn ở tương lai (và cả phòng kế toán của công ty bạn): hãy xem tiền tệ như một thực thể rời rạc, đếm được. Dùng số nguyên để biểu diễn đơn vị nhỏ nhất, bọc nó trong một class hoặc type an toàn để kiểm soát loại tiền tệ, và bạn sẽ có những giấc ngủ ngon khi biết rằng sổ sách luôn luôn khớp đến từng xu.

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo