Chọn kiểu cho một cột nghe như chuyện vặt: một con số thì int hay bigint cũng chạy, một mã định danh thì uuid hay cứ để text cũng lưu được. Nhưng mỗi lựa chọn quyết định cột đó chiếm bao nhiêu byte trên đĩa, tính toán nhanh hay chậm, và so sánh có đúng không. Bài này tạo những bảng cùng dữ liệu logic nhưng khác kiểu, rồi đo pg_relation_size và tốc độ — và lần đo đầu tiên của tôi suýt cho ra một kết luận sai hoàn toàn.
Mỗi kiểu chiếm số byte khác nhau
Ở bài trang và tuple ta đã thấy một hàng không chỉ có dữ liệu: mỗi tuple mang một header 23 byte (thông tin MVCC, con trỏ) rồi mới tới các cột. Bản thân các cột thì mỗi kiểu một cỡ: int cố định 4 byte, bigint 8 byte, boolean 1 byte, uuid 16 byte. numeric lưu biến đổi tùy số chữ số. text tốn khoảng một byte mỗi ký tự cộng vài byte độ dài.
Nghe thì rõ ràng: bigint to gấp đôi int, text lưu số "1000000" tốn 7 ký tự thay vì 4 byte. Tôi bắt tay vào đo, kỳ vọng thấy ngay các con số này trên đĩa. Kết quả đầu tiên làm tôi khựng lại.
Một lần tôi đo hớ: bốn kiểu, cùng một dung lượng
Tôi tạo bốn bảng, mỗi bảng một cột, đổ vào một triệu hàng cùng một dãy số logic — chỉ khác kiểu cột:
CREATE TABLE t_int (n int);
CREATE TABLE t_bigint (n bigint);
CREATE TABLE t_numeric (n numeric);
CREATE TABLE t_text (n text);
-- mỗi bảng 1 triệu hàng cùng dãy số
Rồi so pg_relation_size:
int | 35 MB
bigint | 35 MB
numeric | 35 MB
text | 35 MB
Bốn kiểu, y hệt 35 MB. Tôi định chốt ngay một câu gọn gàng: "kiểu cột chẳng ảnh hưởng gì tới dung lượng, cứ dùng thoải mái". May là con số này quá đáng ngờ — int 4 byte mà bằng text lưu chuỗi 7 ký tự thì vô lý. Số bất khả này là dấu hiệu tôi đo nhầm đại lượng.
Nguyên nhân nằm ở chính cái header 23 byte. Một hàng chỉ có một cột int chiếm: 23 byte header + 4 byte dữ liệu, rồi PostgreSQL căn lề (padding) hàng cho thẳng bội số 8 byte, cộng con trỏ trong page. Cái phần cố định đó (~32 byte) trùm hẳn lên cột 4 byte con con. Đổi cột sang bigint 8 byte: phần dữ liệu nhích thêm 4 byte, nhưng vì padding vốn đã bù tới bội số 8, hàng không to thêm. text lưu "1000000" là 8 byte cả độ dài — cũng lọt trong padding. Nên bốn bảng bằng nhau không phải vì kiểu không quan trọng, mà vì tôi chọn đúng cái ca mà khác biệt bị header nuốt gọn. Tôi đo ở điều kiện suy biến.
Muốn thấy kiểu, phải cho nó thành phần đáng kể của hàng. pg_column_size cho từng giá trị đã nói thật ngay từ đầu, không dính header:
int 4 byte
bigint 8 byte
numeric 8 byte (với số cỡ này)
text 11 byte (cho '1000000')
Cho cột thành phần lớn của hàng, khác biệt hiện ra
Tôi làm lại với bảng tám cột cùng kiểu, một triệu hàng. Giờ dữ liệu là phần lớn của hàng, header 23 byte chỉ còn là phần nhỏ:
int(8 cột) | 57 MB
bigint(8 cột) | 89 MB
numeric(8 cột) | 80 MB
text(8 cột) | 80 MB
Bây giờ bigint mới lộ mặt: 89 MB so với 57 MB của int — to hơn hơn phân nửa, đúng như 8 byte so 4 byte trên tám cột. numeric và text nằm giữa. Cùng một dữ liệu logic, chọn bigint khi int là đủ khiến bảng phình thêm 32 MB trên mỗi triệu hàng — và cái giá đó nhân lên ở mọi chỉ mục trên cột đó, mọi bản sao lưu, mọi trang phải đọc từ đĩa vào bộ đệm.
Với uuid khác biệt còn rõ hơn vì mỗi giá trị rộng. Cùng một triệu định danh, lưu bằng kiểu uuid (16 byte nhị phân) so với lưu bằng text (chuỗi 36 ký tự, pg_column_size = 37 byte):
uuid | 42 MB (16 byte/giá trị)
text-uuid | 65 MB (37 byte/giá trị)
Lưu định danh dạng chuỗi tốn hơn phân nửa dung lượng so với kiểu uuid chuyên dụng — với cùng đúng những mã định danh ấy.
Thứ tự cột cũng tốn tiền
Có một khoản lãng phí tinh vi hơn: thứ tự khai cột. Vì mỗi trường phải căn lề tới ranh giới hợp với kích thước nó, xen một boolean 1 byte vào giữa các bigint 8 byte bắt PostgreSQL chèn 7 byte padding trống để trường bigint sau rơi đúng bội số 8. Tôi tạo hai bảng cùng y những cột đó, chỉ khác thứ tự:
-- xấu: xen kẽ bool và bigint
CREATE TABLE p_xau (b1 bool, n1 bigint, b2 bool, n2 bigint, b3 bool, n3 bigint);
-- tốt: dồn bigint trước, bool sau
CREATE TABLE p_tot (n1 bigint, n2 bigint, n3 bigint, b1 bool, b2 bool, b3 bool);
thứ tự xấu | 73 MB
thứ tự tốt | 57 MB
Cùng sáu cột, cùng dữ liệu, chỉ đổi thứ tự khai — chênh 16 MB, tức hơn một phần tư. Quy tắc: dồn các cột kích thước lớn lên trước, các cột nhỏ (bool, cờ) xuống cuối, để padding gom về một chỗ thay vì rải ra sau mỗi trường nhỏ. Đây là khoản tiết kiệm miễn phí, chỉ tốn công sắp lại lúc thiết kế bảng.
numeric an toàn nhưng chậm, text so sánh sai
Dung lượng mới là nửa câu chuyện; kiểu còn quyết định tốc độ tính toán. Tôi đo SUM(n*n+n) trên các bảng cùng cỡ (mỗi bảng 35 MB, một triệu giá trị) để I/O bằng nhau, chỉ còn chi phí số học khác biệt:
bigint | 15 ms
numeric | 42 ms (~2,7 lần chậm hơn)
text | 23 ms (phải ép kiểu mới cộng được)
numeric chậm gần ba lần bigint. Lý do: bigint là số nguyên phần cứng, CPU cộng nhân trong một nhịp; numeric là số thập phân chính xác tùy ý, mỗi phép tính chạy qua một thư viện phần mềm xử lý từng chữ số. numeric mang tiếng "an toàn" vì không tràn số và không sai số tròn — đúng cho tiền bạc — nhưng cái an toàn đó có giá bằng tốc độ. Dùng numeric cho một cột chỉ là số nguyên đếm được là trả tiền cho thứ mình không cần.
text cho các con số còn nguy hơn: nó so sánh sai. Vì text so theo thứ tự từ điển chứ không theo giá trị, chuỗi '10' đứng trước '9':
SELECT ('10' < '9'); -- true (so từ điển!)
SELECT (10 < 9); -- false (so số)
Sắp một cột số lưu dạng text cho ra 10, 100, 2, 21, 9 thay vì 2, 9, 10, 21, 100. Một cột WHERE tuoi > '18' lọc text sẽ bỏ sót và nhận nhầm hàng loạt. Cùng lý do, lưu ngày tháng dạng text mất khả năng cộng trừ khoảng thời gian và so sánh đúng, lưu boolean dạng text mở cửa cho 'true', 'True', '1', 'yes' lẫn lộn. Kiểu đúng không chỉ tiết kiệm byte — nó bắt CSDL kiểm tra và hiểu đúng dữ liệu.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chọn kiểu hẹp nhất còn đủ chứa. int chứa tới hơn hai tỉ — thừa cho hầu hết khóa và bộ đếm; nhảy lên bigint "cho chắc" là tự nhân đôi cột đó cùng mọi chỉ mục trên nó, như bài quét chỉ mục nhắc: chỉ mục nhỏ hơn thì nhiều nút hơn lọt vào mỗi trang, cây thấp hơn, tra nhanh hơn. Chỉ dùng bigint khi thật sự có thể vượt hai tỉ.
Hệ quả thứ hai: kiểu chuyên dụng gần như luôn thắng text. Lưu uuid bằng kiểu uuid, ngày bằng timestamptz, số bằng kiểu số, cờ bằng boolean — vừa gọn hơn, vừa để CSDL kiểm tra và so sánh đúng. Cái tiện "cứ nhét text cho linh hoạt" đổi lấy dung lượng gấp bội, so sánh sai lặng lẽ, và mất mọi kiểm tra kiểu mà CSDL vốn cho không.
Hệ quả thứ ba, cũng là bài học đo lường của bài: một con số bằng nhau đáng ngờ thường là dấu bạn đang đo nhầm đại lượng. Bốn kiểu cùng 35 MB không chứng minh "kiểu không quan trọng" — nó chứng minh tôi đã chọn cái ca mà header hàng che mất khác biệt. Con số mang theo: kiểu cột quyết định dung lượng (bigint 89MB so int 57MB trên tám cột, uuid 42MB so text-uuid 65MB), tốc độ (numeric chậm ~2,7 lần bigint), và tính đúng (text so số sai: '10' < '9'); nhưng đo bằng bảng một cột nhỏ thì header 23 byte trùm hết — phải cho kiểu thành phần đáng kể của hàng mới thấy nó. Trước khi kết luận một yếu tố "không ảnh hưởng", kiểm xem mình có đang đo ở điều kiện khiến nó vô hình không.
Thử ba mươi giây
Mở psql và chạy SELECT pg_column_size(1::int), pg_column_size(1::bigint), pg_column_size('1'::text), pg_column_size(gen_random_uuid()), pg_column_size(gen_random_uuid()::text); — bạn sẽ thấy ngay 4, 8, 5, 16, 40 byte, kích thước thật của một giá trị mỗi kiểu, không dính header hàng. Rồi thử SELECT '10' < '9', 10 < 9; để tận mắt thấy text so số cho true còn số thật cho false. Hai câu lệnh này, trong nửa phút, cho bạn cả bức tranh: kiểu quyết định byte, và kiểu quyết định đúng sai — trước khi bạn lỡ khai một cột id là text rồi phải sửa cả bảng về sau.