Hình dung hai cái cân. Một là cân tiểu ly của thợ kim hoàn: đặt vật lên, kim đung đưa một lúc rồi dừng đúng miligam, cân đi cân lại nghìn lần vẫn ra đúng con số ấy. Cái kia là cân lò xo ngoài chợ: liếc phát ra ngay, nhưng mỗi lần lệch một tí, và nếu bạn cân cùng một thứ cả nghìn lần rồi cộng lại, mấy cái lệch tí đó không triệt tiêu nhau mà dồn thành một khoản sai. numeric là cái cân tiểu ly, float là cái cân lò xo. Lời khuyên chuẩn — dùng numeric cho tiền, float cho mọi thứ khác, vì numeric chính xác nhưng chậm và tốn chỗ hơn — gói đúng trực giác đó. Nửa đầu đúng. Nửa sau thì tuỳ dữ liệu, và tôi đo được cả hai chiều.
Sai số tích luỹ là thật, và nó cộng dồn
Cộng 0.01 một triệu lần — đúng như một hệ thống cộng dồn giao dịch trong ngày:
| Kết quả | |
|---|---|
double precision |
10000.000000171856 |
numeric |
10000.00 |
| Đúng ra | 10000.00 |
Lệch 0,00000017 sau một triệu phép cộng. Nghe nhỏ, nhưng đó là số tiền không cân được sổ, và nó lớn dần theo số phép tính.
Cộng 0.1 một nghìn lần cho thấy rõ hơn khác biệt giữa ba kiểu:
| Kết quả | |
|---|---|
double precision |
99.9999999999986 |
real |
99.99905 |
numeric |
100.0 |
real (4 byte) lệch tới chữ số thứ năm chỉ sau một nghìn phép cộng. Đừng dùng nó cho bất cứ thứ gì cần cộng dồn.
Và phép so sánh kinh điển:
SELECT (0.1::float8 + 0.2::float8) = 0.3::float8; -- false
SELECT (0.1::numeric + 0.2::numeric) = 0.3::numeric; -- true
SELECT 0.1::float8 + 0.2::float8; -- 0.30000000000000004
Đây không phải lỗi PostgreSQL — đó là cách IEEE 754 biểu diễn số thập phân bằng nhị phân. Một phần mười không viết được chính xác trong hệ nhị phân, giống như một phần ba không viết được chính xác trong hệ thập phân.
Cái giá về tốc độ là thật
| Phép đo | numeric |
double precision |
|---|---|---|
sum() một cột, 2 triệu dòng |
33 ms | 23 ms (1,4× nhanh hơn) |
sum(a*b+c), 8 cột |
45–51 ms | 19–22 ms (2,4× nhanh hơn) |
Phép cộng đơn giản chỉ chậm 1,4 lần. Nhưng khi có nhân và cộng lồng nhau, khoảng cách giãn ra 2,4 lần — vì numeric được cài đặt bằng phần mềm, mỗi phép tính là một hàm C xử lý mảng chữ số, còn float8 là một lệnh CPU.
Nếu bạn chạy phân tích trên hàng trăm triệu dòng, khác biệt đó thành phút. Với truy vấn nghiệp vụ thông thường thì nó nằm dưới mức bạn nhận ra.
Cái giá về dung lượng thì đi cả hai chiều
Đây là chỗ lời khuyên quen thuộc sai. Tám cột, một triệu dòng:
| Kích thước | Byte/dòng | |
|---|---|---|
8 × numeric(12,2), giá trị 0,00–999,99 |
81 MB | 84 |
8 × double precision |
89 MB | 93 |
8 × bigint |
89 MB | 93 |
numeric nhỏ hơn double 9,3%.
Hai lý do, cả hai đo được:
Giá trị của tôi trung bình chỉ 6,98 byte (nhỏ nhất 3, lớn nhất 7), trong khi float8 luôn đúng 8 byte. numeric là kiểu độ dài thay đổi:
| Giá trị | Byte |
|---|---|
0 |
6 |
1.00 |
8 |
12345.67 |
12 |
| 20 chữ số | 16 |
| 40 chữ số | 26 |
Và yêu cầu căn lề khác nhau:
float8 typalign=d (can le 8 byte)
int8 typalign=d (can le 8 byte)
numeric typalign=i (can le 4 byte)
numeric chỉ cần căn lề 4 byte nên nó ít gây khoảng đệm hơn — đúng cơ chế mà phần 3 đã đo khi đổi thứ tự cột.
Đổi sang giá trị nhiều chữ số thì chiều ngược lại:
| Kích thước | Trung bình mỗi giá trị | |
|---|---|---|
8 × numeric nhiều chữ số |
126 MB | 12,78 byte |
8 × double precision |
89 MB | 8,00 byte |
Giờ numeric to hơn 42%.
Kết luận đúng: kích thước numeric phụ thuộc số chữ số bạn thật sự lưu, không phải phụ thuộc kiểu. Với dữ liệu kiểu tiền — vài chữ số phần nguyên, hai chữ số thập phân — nó rẻ hơn float8.
Một chi tiết về phép chia
SELECT 1::numeric / 3; -- 0.33333333333333333333 (20 chu so)
SELECT 1::float8 / 3; -- 0.3333333333333333 (16 chu so)
numeric không giữ vô hạn chữ số — phép chia mặc định cho 20 chữ số sau dấu phẩy. Ép ra nhiều hơn chỉ được số 0:
SELECT round(1::numeric/3, 30); -- 0.333333333333333333330000000000
Nghĩa là numeric chính xác với cộng, trừ, nhân, nhưng phép chia vẫn phải làm tròn ở đâu đó. Với tiền thì hiếm khi thành vấn đề vì bạn làm tròn về hai chữ số ngay; với công thức nhiều bước chia thì cần để ý thứ tự phép tính.
Chọn thế nào
| Dữ liệu | Kiểu |
|---|---|
| Tiền, số lượng, bất cứ thứ gì người ta đối chiếu sổ sách | numeric(p,s) |
| Đo lường khoa học, toạ độ, thống kê, tỉ lệ | double precision |
| Cần cả chính xác lẫn nhanh | bigint tính bằng đơn vị nhỏ nhất (xu) |
Cách thứ ba đáng cân nhắc hơn nhiều người nghĩ: lưu 1050 thay vì 10.50, chia cho 100 khi hiển thị. Phép đo ở trên cho thấy bigint nhanh ngang float8 (23–24 ms) và chính xác tuyệt đối như numeric. Đổi lại bạn phải nhớ quy ước ở mọi chỗ đọc cột đó — và đó là chỗ nó hỏng, khi một người mới vào đội đọc 1050 là mười nghìn năm trăm.
Với numeric, hãy luôn khai độ chính xác: numeric(12,2) chứ không phải numeric trần. Không khai thì PostgreSQL cho phép mọi số chữ số, và bạn mất luôn khả năng bắt lỗi dữ liệu bẩn.
Tự đo cột số của bạn
Xem cột số của bạn thật sự tốn bao nhiêu:
SELECT avg(pg_column_size(cot_cua_ban))::numeric(6,2) AS byte_trung_binh,
min(pg_column_size(cot_cua_ban)) AS nho_nhat,
max(pg_column_size(cot_cua_ban)) AS lon_nhat
FROM bang_cua_ban;
Dưới 8 byte thì numeric đang rẻ hơn float8 với dữ liệu của bạn. Trên 8 thì bạn đang trả thêm — và câu hỏi tiếp theo là có thật sự cần từng ấy chữ số không.
Và kiểm xem có cột tiền nào đang là float không:
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE data_type IN ('real', 'double precision')
ORDER BY table_name;
Mẫu số chung
Bài học đầu tiên, chính là hai cái cân: cái cách biểu diễn bạn chọn quyết định thứ gì diễn tả được chính xác — một hệ cơ số không viết nổi phân số mà mẫu của nó không chia hết, và những sai số làm tròn trông không đáng kể lại cộng dồn chứ không triệt tiêu nhau. Một phần mười không có biểu diễn nhị phân hữu hạn, y như một phần ba không có biểu diễn thập phân hữu hạn; nên 0.1+0.2 ra 0.30000000000000004 ở mọi ngôn ngữ, không riêng PostgreSQL. Cùng chuyện đó ở khắp nơi: double của Java, số của JavaScript, float của Python đều là IEEE 754; đó là lý do tiền dùng BigDecimal, decimal.Decimal, hay tính bằng xu, và lý do không bao giờ so hai số thực bằng ==. Nguyên tắc: chọn cách biểu diễn khớp với mức chính xác mà bài toán đòi hỏi — và đừng bao giờ tin rằng sai số nhỏ sẽ tự triệt tiêu, vì qua nhiều phép tính chúng dồn về một hướng.
Điều thứ hai, đọc từ "lời khuyên numeric-chậm-và-to chỉ đúng một nửa": một quy tắc bỏ túi luôn gói kèm một giả định về trường hợp điển hình — khi dữ liệu của bạn lệch khỏi cái điển hình đó, quy tắc lật ngược, nên hãy đo dữ liệu của mình thay vì tin lời đồn. numeric nhỏ hơn float8 với dữ liệu kiểu tiền (ít chữ số, căn lề 4 byte), nhưng to hơn 42% với dữ liệu nhiều chữ số — cùng một kiểu, hai kết luận trái ngược tuỳ dữ liệu. Cùng cái bẫy "câu châm ngôn giấu một giả định" ở khắp nơi: "linked list chèn O(1)" bỏ qua bộ nhớ đệm nên mảng thường thắng, "chuẩn hoá hết đi" bỏ qua giá JOIN, "đánh index mọi cột" bỏ qua giá ghi, "microservice co giãn tốt hơn" bỏ qua chi phí mạng. Nguyên tắc: mọi kinh nghiệm rút gọn đều mang một mệnh đề ẩn "với đầu vào điển hình" — hãy biết mệnh đề đó và đo phân bố dữ liệu thật của mình trước khi áp dụng, vì lời khuyên đúng trên trung bình có thể sai ngược hẳn với chính bạn.
Phần sau đo khoá chính: serial, identity hay UUID, và cái nào làm chỉ mục phân mảnh.