JSONB rất tiện: không cần ALTER TABLE, thêm trường lúc nào cũng được, cấu trúc lồng nhau thoải mái. Phần này đo cái giá của sự tiện đó bằng cách dựng hai bảng chứa cùng một dữ liệu — một bảng dùng cột thường, một bảng dùng JSONB.
Bố trí
2.000.000 sản phẩm, mỗi sản phẩm có 7 thuộc tính:
create table sp_cot(id bigserial primary key, ma text, ten text,
gia numeric(12,2), ton int, mau text, size text, hang text);
create table sp_json(id bigserial primary key, d jsonb);
Cùng giá trị, cùng số dòng, cùng thứ tự chèn.
Kết quả
| Cột thường | JSONB | Chênh | |
|---|---|---|---|
| Kích thước bảng | 180 MB | 359 MB | 2,0 lần |
| Byte mỗi dòng | 94 | 188 | 2,0 lần |
| Quét toàn bảng, lọc 1 thuộc tính | 55,08 ms | 66,00 ms | 1,2 lần |
| Đếm với điều kiện chọn lọc 0,5% | 0,84 ms | 32,20 ms | 38 lần |
| Đọc dữ liệu thật (bộ đệm ấm) | 9,4–9,7 ms | 9,7 ms | bằng nhau |
| Cập nhật 200.000 dòng, một trường | 0,92 s | 4,01 s | 4,4 lần |
| WAL sinh ra khi cập nhật | 78 MB | 512 MB | 6,6 lần |
Bốn điều đọc ra được.
Gấp đôi dung lượng vì tên khoá lặp lại ở mọi dòng
94 byte thành 188 byte, đúng gấp đôi. Bảng cột thường lưu tên cột một lần trong catalog; bảng JSONB lưu chuỗi "ma", "ten", "gia", "ton", "mau", "size", "hang" trong từng dòng một, hai triệu lần.
Với tên khoá dài — "so_luong_ton_kho" thay vì "ton" — tỷ lệ này còn tệ hơn. Đây là lý do người ta hay thấy khuyên "dùng tên khoá ngắn trong JSONB", và lời khuyên đó có cơ sở thật, dù nó đánh đổi bằng khả năng đọc hiểu.
Gấp đôi dung lượng kéo theo gấp đôi số trang phải đọc khi quét bảng: 45.912 so với 22.983 trang.
Đọc dữ liệu thật thì ngang nhau
Đây là kết quả tôi suýt báo cáo sai. Lần đo đầu tiên:
cột thường sum(gia) 33,53 ms
jsonb sum(gia) 22,94 ms
JSONB nhanh hơn? Tôi lặp lại ba lần cho mỗi bên:
cột thường: 48,1 9,4 9,7 ms
jsonb : 46,2 23,8 9,7 ms
Lần chạy đầu của mỗi bên là bộ đệm lạnh. Khi ấm, cả hai đều 9,7 ms — bằng nhau.
Điều này hợp lý: khi truy vấn cần lấy giá trị thật ra, cả hai đều phải đọc dòng từ heap. Chi phí phân tích JSONB để lấy một trường là có, nhưng nhỏ so với chi phí đọc trang.
Bài học về cách đo: một phép đo cho kết quả ngược trực giác thì phải lặp lại trước khi tin. Ở đây tôi đã suýt viết ra một kết luận sai hoàn toàn dựa trên đúng một lần chạy.
Đếm chênh 38 lần vì Index Only Scan
| Cách | Thời gian | Trang đọc | Kế hoạch | Chỉ mục nặng |
|---|---|---|---|---|
Cột thường, btree trên hang |
0,84 ms | 12 | Index Only Scan |
13 MB |
JSONB, btree trên (d->>'hang') |
32,20 ms | 10.022 | Bitmap Heap Scan |
13 MB |
JSONB, GIN trên cả d |
20,98 ms | 11.084 | Bitmap Heap Scan |
427 MB |
Cột thường trả lời count(*) where hang = 'Hang 7' mà không chạm vào bảng lần nào — chỉ mục chứa đủ thông tin (phần 15). 12 trang thay vì 10.022.
JSONB không làm được vậy. Chỉ mục biểu thức lưu giá trị của d->>'hang', nhưng bộ lập lịch vẫn chọn Bitmap Heap Scan, nên nó phải đọc 10.022 trang từ heap.
Chỉ mục GIN thì có ưu điểm khác hẳn: một chỉ mục tra được mọi khoá. Bạn không cần biết trước sẽ lọc theo trường nào. Đổi lại nó nặng 427 MB — gấp 33 lần chỉ mục btree, và hơn cả bảng dữ liệu gốc của phiên bản cột thường.
Đó là đánh đổi thật: 427 MB đổi lấy việc không phải dự đoán truy vấn tương lai.
Ghi đắt hơn nhiều, và lý do nằm ở MVCC
| Thời gian | WAL | |
|---|---|---|
update sp_cot set ton = ton + 1 |
0,92 s | 78 MB |
update sp_json set d = jsonb_set(d, '{ton}', ...) |
4,01 s | 512 MB |
Chậm 4,4 lần, WAL gấp 6,6 lần — cho việc sửa đúng một số nguyên.
Phần 21 đã đo rằng UPDATE trong PostgreSQL ghi lại cả dòng, không ghi delta. Dòng JSONB to gấp đôi nên WAL đáng lẽ chỉ gấp đôi; con số 6,6 lần cho thấy còn chi phí khác: jsonb_set phải phân tích tài liệu, thay một trường, rồi dựng lại toàn bộ cấu trúc nhị phân.
Đây là chỗ JSONB đau nhất, và cũng là chỗ dễ bị bỏ qua nhất khi thiết kế — vì lúc thử nghiệm với vài nghìn dòng thì không thấy gì.
Chỗ JSONB thật sự thắng
Không phải "vì nó tiện". Mà là khi lược đồ thưa thớt: nhiều thuộc tính khác nhau, mỗi dòng chỉ dùng một phần nhỏ.
Bảng sự kiện 2.000.000 dòng, bốn loại sự kiện với thuộc tính khác hẳn nhau:
click -> nut, x, y
mua -> sp_id, so_luong, tien
xem -> trang, thoi_gian
loi -> ma_loi, stack, url
Đo được: 11 khoá khác nhau, mỗi dòng chỉ dùng 3.
Làm bằng cột thường thì bảng phải có 11 cột, và 8 trong số đó luôn NULL ở mỗi dòng. Thêm một loại sự kiện mới là thêm 3 cột nữa và một lần ALTER TABLE trên bảng hai triệu dòng.
Với JSONB, một chỉ mục GIN phục vụ mọi kiểu truy vấn:
select count(*) from ev_json where d @> '{"ma_loi":"E7"}'; -- 94,28 ms
select count(*) from ev_json where d ? 'sp_id'; -- 72,27 ms
Câu thứ hai — "tìm mọi sự kiện có khoá sp_id" — là thứ không viết được bằng cột thường mà không liệt kê tên cột.
Cách dùng thực dụng: trộn cả hai
Trên thực tế, thiết kế tốt nhất thường không phải chọn một trong hai:
create table su_kien(
id bigserial primary key,
loai text not null, -- luôn có, luôn lọc theo -> cột thường
nguoi bigint not null, -- luôn có, có khoá ngoại -> cột thường
luc timestamptz not null, -- luôn có, sắp xếp theo -> cột thường
chi_tiet jsonb -- phần thay đổi theo loại
);
create index on su_kien(loai, luc desc);
create index on su_kien using gin(chi_tiet);
Quy tắc phân chia:
| Đưa vào cột thường | Đưa vào JSONB |
|---|---|
| Thuộc tính mọi dòng đều có | Thuộc tính chỉ một số dòng có |
| Cột thường xuyên lọc hoặc sắp xếp | Cột chỉ đọc ra hiển thị |
Cột cần ràng buộc (not null, khoá ngoại, unique) |
Dữ liệu từ hệ thống ngoài, cấu trúc không kiểm soát được |
Cột thường xuyên bị UPDATE |
Dữ liệu ghi một lần rồi hầu như không sửa |
Dòng cuối đáng nhấn mạnh: nếu một trường bị cập nhật thường xuyên thì đừng để nó trong JSONB — 4,01 s so với 0,92 s là lý do đủ.
Ba điều JSONB không cho bạn
Không có ràng buộc kiểu. {"gia": "mien phi"} được chấp nhận y như {"gia": 150000}. Một dữ liệu sai kiểu sẽ nằm im tới khi có ai đó ::numeric nó và câu truy vấn nổ.
Không có NOT NULL, không có khoá ngoại. Bạn có thể mô phỏng bằng CHECK trên biểu thức, nhưng mỗi ràng buộc như vậy phải viết tay và phải nhớ.
Thống kê kém hơn. Bộ lập lịch có histogram và n_distinct cho từng cột thường (phần 20). Với d->>'mau' nó chỉ có thống kê nếu bạn dựng chỉ mục biểu thức — không thì nó đoán, và đoán sai dẫn tới kế hoạch sai (phần 29 đã đo: chậm 800 lần).
Thử ba mươi giây
Nếu bạn đang có bảng dùng JSONB, kiểm xem có trường nào đáng tách ra thành cột:
select k as khoa,
count(*) as so_dong_co,
round(100.0 * count(*) / (select count(*) from <bảng>), 1) as phan_tram
from <bảng>, jsonb_object_keys(<cột jsonb>) k
group by k
order by count(*) desc;
Khoá nào xuất hiện ở gần 100% số dòng là ứng viên tách ra thành cột thường — bạn đang trả tiền lưu tên khoá đó ở mọi dòng mà không được gì đổi lại. Nhất là nếu bạn cũng đang lọc hoặc sắp xếp theo nó.
Phần sau đo mảng và các kiểu dữ liệu tổng hợp: array, hstore, kiểu do người dùng định nghĩa, và khi nào chúng rẻ hơn một bảng phụ.