Lời khuyên chuẩn là: 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. 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.
Thử ba mươi giây
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;
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.