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.