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ốc độ và dung lượng của numeric so với float

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.