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.

Dung lượng, tốc độ đọc, chi phí ghi, và trường hợp JSONB thắng

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'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 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ụ.