engineering

Vì sao không lưu tiền bằng double — và dùng BigDecimal sao cho đúng?

Báo cáo tổng lãi lệch 9 đồng dù từng chi nhánh khớp: cộng 10 triệu dòng bằng double, sai số mỗi phép cộng gom thành tiền thật. Cơ chế IEEE 754 và BigDecimal.

OLHub Team2 tháng 7, 2026 · 10 phút đọc

Bạn tester trong team nhắn tôi lúc gần trưa, đính kèm hai con số: "Job tổng hợp lãi sắp release lệch anh ạ. Em chạy trên bộ dữ liệu giả lập cỡ production — batch Java ra 61.643.835.607 đồng, còn SQL cộng thẳng trên bảng lãi ra 61.643.835.616. Lệch 9 đồng." Ở dự án khác, 9 đồng trên tổng 61 tỷ có khi là chuyện cười trong daily. Trong một hệ thống banking, nơi đối soát phải khớp đến từng đồng, câu đó nghĩa là: chặn release, tìm cho ra.

Bạn ấy đã làm sẵn phần việc của một tester giỏi: thu hẹp phạm vi. Chạy lại batch — vẫn lệch đúng 9 đồng, không phải race condition. Kỳ lạ hơn: bạn ấy bổ tổng theo chi nhánh, đối chiếu từng chi nhánh một — chi nhánh nào cũng khớp tuyệt đối. Từng dòng lãi trong bảng cũng khớp từng dòng. Chỉ khi gộp cả chục triệu dòng của toàn hàng vào một con số, 9 đồng mới hiện ra. "Từng phần đều đúng mà tổng lại sai — nghe vô lý kiểu gì ấy anh."

Tôi mở code job tổng hợp — module vừa viết ở sprint trước: unit test xanh, code review hai người approve, mọi bộ test data từ đầu dự án đều pass. Logic không sai một dòng: đọc từng dòng lãi ngày, cộng dồn vào một biến, in báo cáo. Cái sai nằm ở chỗ chẳng ai buồn nhìn đến: biến cộng dồn khai báo double. Và với double, mỗi phép cộng đều nộp một khoản "thuế làm tròn" bé đến mức vô hình — vấn đề chỉ là bao nhiêu phép cộng thì gom đủ thành một đồng thật.

Tiết lộ. double lưu số theo hệ nhị phân với 52 bit định trị, nên số tiền lẻ như lãi ngày 6.164,3835... đồng chỉ được lưu xấp xỉ, và kết quả của mỗi phép cộng cũng bị làm tròn tiếp về số gần nhất máy biểu diễn được. Sai số mỗi phép chỉ cỡ một phần triệu đồng khi tổng đã lên hàng chục tỷ — cộng 1.000 dòng không ai thấy gì, nhưng cộng 10 triệu dòng thì các mẩu thuế đó gom thành 9,64 đồng. Bug không nằm ở dòng dữ liệu nào cả: nó rải đều trên từng phép cộng, và chỉ lộ khi bài toán thực hiện đủ nhiều phép tính. Với tiền, Java có công cụ thập phân chính xác là BigDecimal — nhưng phải dùng đúng cách, vì chính nó cũng có bẫy riêng.

Vì sao double không lưu nổi số tiền lẻ — cơ chế bên dưới

Hãy thử viết 1/3 dưới dạng thập phân: 0.3333... — vô hạn chữ số, viết đến đâu cũng chỉ là xấp xỉ. Hệ nhị phân gặp đúng vấn đề đó với những số thập phân rất đỗi bình thường: 0.1 chuyển sang nhị phân thành 0.000110011001100... — tuần hoàn vô hạn. Không phải lỗi của Java hay CPU, đây là giới hạn toán học: nhị phân không biểu diễn chính xác được mọi số thập phân, giống như thập phân không biểu diễn chính xác được 1/3.

double theo chuẩn IEEE 754 có 64 bit, trong đó chỉ 52 bit dành cho phần định trị (mantissa) — nên chuỗi tuần hoàn kia bị cắt tại bit 52 và làm tròn về số gần nhất máy lưu được. Số double gần 0.1 nhất thực chất là:

0.1000000000000000055511151231257827021181583404541015625

Phần đuôi ...055511... chính là loại sai số nằm sẵn trong từng dòng lãi của batch đêm đó — 0,045 của lãi suất hay 6.164,3835... của lãi ngày đều chịu chung số phận với 0.1: không có biểu diễn nhị phân hữu hạn. Toàn bộ cơ chế dấu/mũ/định trị và vì sao phải cắt ở bit 52, chúng tôi đã mổ từng bit trong bài Số thực IEEE 754 — vì sao 0.1 + 0.2 không bằng 0.3 — ở đây tôi chỉ giữ phần đủ để phá án.

Vì sao ít phép tính thì khớp, nhiều phép tính mới lòi ra?

Đây là câu hỏi bạn tester hỏi tôi, và nó trúng đúng mấu chốt của vụ án. Điểm mấu chốt: 52 bit định trị nghĩa là double luôn chính xác khoảng 15–16 chữ số có nghĩa — nhưng khoảng cách giữa hai số double liền kề (một ULP) thì co giãn theo độ lớn của số:

  • Quanh 6.164 đồng (một dòng lãi ngày), hai double liền kề cách nhau khoảng một phần nghìn tỷ đồng.
  • Quanh 61,6 tỷ đồng (tổng toàn hàng), hai double liền kề đã cách nhau khoảng 8 phần triệu đồng.

Mỗi phép cộng phải làm tròn kết quả về double gần nhất, tức có thể lệch tới nửa ULP. Cộng 1.000 dòng, tổng còn nhỏ, số phép ít — sai số đo được cỡ một phần trăm triệu đồng, làm tròn về đồng nguyên thì khớp tuyệt đối. Đó là lý do mọi báo cáo chi nhánh đều sạch: chúng không đủ dày để gom thuế làm tròn thành tiền thật. Nhưng cộng 10 triệu dòng, mỗi phép có thể đóng góp vài phần triệu đồng — và tổng thuế đo được là 9,64 đồng.

Còn vì sao cả unit test lẫn mấy vòng test trước đó đều xanh? Ba lớp nguỵ trang. Thứ nhất, Java in double bằng chuỗi ngắn nhất vẫn phân biệt được giá trị — System.out.println(0.1) in ra 0.1 sạch bong, sai số bị giấu. Thứ hai, mọi bộ test data đều bé — vài chục dòng cho unit test, vài nghìn dòng cho integration — bug chỉ sống ở quy mô mà không test case nào chạm tới. Thứ ba, expected value trong test cũng được sinh từ code tính bằng double, nên hai bên sai giống hệt nhau và so vẫn khớp. Bạn tester là người đầu tiên làm đủ hai điều cùng lúc: chạy trên dữ liệu cỡ thật và dựng con số đối chiếu bằng một con đường độc lập (SQL cộng thẳng trên DB) — và 9 đồng lộ ra.

Tự kiểm chứng trong 30 giây

Mở jshell hoặc một file Java trống và chạy — khỏi cần tin tôi, cứ để con số tự nói. Cho mọi dòng lãi cùng một giá trị để dễ đối chiếu (tổng đúng chỉ là phép nhân):

double daily = 50_000_000 * 0.045 / 365;    // lai ngay so 50 trieu: 6164.3835616438355

double total1k = 0.0;
for (int i = 0; i < 1_000; i++) total1k += daily;
System.out.printf("%.2f%n", total1k);       // 6164383.56 -- khop: sai so ~5e-8, vo hinh

double total10m = 0.0;
for (int i = 0; i < 10_000_000; i++) total10m += daily;
System.out.printf("%.2f%n", total10m);      // 61643835606.79
// tong dung = daily x 10 trieu = 61643835616.44 -- THIEU 9.64 dong

double total30m = 0.0;
for (int i = 0; i < 30_000_000; i++) total30m += daily;
System.out.printf("%.2f%n", total30m);      // 184931506927.97
// tong dung = 184931506849.32 -- lan nay lai THUA 78.65 dong

Cùng một phép cộng, chỉ khác số lần lặp: 1 nghìn dòng khớp, 10 triệu dòng thiếu gần 10 đồng, 30 triệu dòng lại thừa gần 80 đồng. Sai số hai chiều và không tuyến tính — bạn không thể "trừ hao" một hằng số, không thể đoán trước chiều lệch. Và kết quả tái lập chính xác từng bit qua mỗi lần chạy, nên nó vượt qua mọi bài test chạy-lại-xem-có-đổi-không.

Kết quả trên không riêng gì Java: Python, JavaScript, C đều dùng chung IEEE 754 double và cho cùng những con số này.

Sửa bằng BigDecimal — và cái bẫy thứ hai

Dev trong team sửa rất nhanh: đổi double sang BigDecimal. Nhưng bản vá đầu tiên trông thế này:

// SAI: constructor nhan double -- sai so da chui vao TRUOC khi BigDecimal kip lam gi
BigDecimal r = new BigDecimal(0.1);
System.out.println(r);
// 0.1000000000000000055511151231257827021181583404541015625

// DUNG: constructor String -- parse truc tiep tu chuoi thap phan, khong qua double
BigDecimal balance = new BigDecimal("50000000");
BigDecimal rate    = new BigDecimal("0.045");
BigDecimal yearly  = balance.multiply(rate);
System.out.println(yearly);                 // 2250000.000 -- chinh xac tuyet doi

Literal 0.1 trong Java là một double — nó đã bị làm tròn thành số 0.1000...0625 ở trên trước khi đi vào constructor. BigDecimal chỉ trung thành lưu lại chính xác cái giá trị đã sai đó. Javadoc của BigDecimal nói thẳng: nên ưu tiên constructor String; còn khi trong tay đã lỡ có một biến double, dùng BigDecimal.valueOf(...) — nó đi qua chuỗi in ngắn nhất của double nên cho ra 0.1 như bạn kỳ vọng.

Dùng BigDecimal cho tiền còn hai điểm cần thuộc lòng, đều lấy từ javadoc chứ không phải kinh nghiệm dân gian:

equals so cả scale, không chỉ giá trị. new BigDecimal("2.0")new BigDecimal("2.00") cùng giá trị nhưng khác scale — equals trả false, còn compareTo trả 0. Hệ quả thực tế: test assertEquals fail khó hiểu sau một phép tính đổi scale, hay hai số tiền "bằng nhau" mà nằm ở hai entry khác nhau trong HashSet. So sánh giá trị tiền, luôn dùng compareTo.

Chia phải khai báo cách làm tròn. Thương 2.250.000/365 = 6.164,383561643835616... không có biểu diễn thập phân hữu hạn, và BigDecimal từ chối tự ý làm tròn thay bạn — yearly.divide(new BigDecimal("365")) ném thẳng ArithmeticException. Phải nói rõ giữ mấy chữ số và làm tròn kiểu gì:

BigDecimal daily = yearly.divide(new BigDecimal("365"), 4, RoundingMode.HALF_EVEN);
System.out.println(daily);                  // 6164.3836

Giữ bao nhiêu chữ số lẻ khi tích luỹ, chốt kỳ làm tròn kiểu gì (HALF_UP, HALF_EVEN...), phần lẻ dồn về đâu — trong banking đó là quy tắc nghiệp vụ phải chốt bằng văn bản với bên kế toán/nghiệp vụ, không phải chi tiết kỹ thuật tuỳ dev chọn. BigDecimal không trả lời hộ bạn câu hỏi đó; nó chỉ đảm bảo một điều: làm tròn xảy ra đúng chỗ bạn chỉ định, thay vì rơi vãi vô hình trên từng phép cộng như double.

Còn cột tiền trong database?

Nguyên tắc y hệt áp xuống tận tầng lưu trữ: cột tiền dùng NUMERIC(p,s) (đồng nghĩa DECIMAL), tuyệt đối không REAL hay DOUBLE PRECISION — chúng tôi đã dạy kỹ khi nào chọn kiểu nào trong bài Data types — chọn đúng kiểu từ đầu. Java BigDecimal ghép với SQL NUMERIC là cặp khớp nhau tự nhiên qua JDBC.

Bản fix cuối cùng của chúng tôi cũng đi đúng con dao đã mổ vụ án: con số đối chiếu của bạn tester đúng ngay từ đầu vì SUM của database chạy trên cột NUMERIC — số học thập phân chính xác. Nên job tổng hợp thôi không kéo 10 triệu dòng về Java để cộng bằng double nữa: phần gộp đẩy xuống SUM trong DB, phần tính toán còn lại trong app chuyển sang BigDecimal, và các cột số dư giao dịch VND — vốn không có xu lẻ — chuyển dần về BIGINT đếm đồng, cộng trừ số nguyên không bao giờ lệch. Với USD thì đếm cents.

Chín đồng đó chưa bao giờ kịp chạm vào tiền thật của khách — job được sửa trước khi release, và bài test "dữ liệu cỡ production + con số đối chiếu độc lập" được thêm hẳn vào quy trình. Còn tôi giữ lại hai con số 61.643.835.607 và 61.643.835.616 làm ví dụ mở đầu mỗi khi onboard thành viên mới — vì bài học của nó gọn hơn mọi tài liệu: từng dòng đều đúng, từng chi nhánh đều khớp, không có nghĩa là tổng đúng. Sai số của double không nằm ở dữ liệu nào cả; nó là thuế làm tròn thu trên từng phép tính, và chỉ hoá đơn đủ dày mới nhìn thấy. Người nhìn thấy nó đầu tiên thường không phải compiler, cũng không phải dev — mà là một tester chịu khó cộng lại bằng một con đường độc lập. Nếu bạn muốn hiểu tận từng bit vì sao — dấu, mũ, định trị, và cả hiện tượng số dư lớn "nuốt" giao dịch nhỏ — bài Số thực IEEE 754 trong khoá CS nền tảng — Dữ liệu & CPU dành trọn 18 phút cho đúng câu hỏi đó.

Sẵn sàng học sâu hơn?

Biến những gì vừa đọc thành kỹ năng thật với khoá học của OLHub, hoặc mang câu hỏi của bạn ra thảo luận cùng cộng đồng.

Đọc tiếp

Bài viết liên quan