jsonb là một trong những tính năng được yêu thích nhất của PostgreSQL — nó cho phép lưu dữ liệu có cấu trúc linh hoạt mà không cần khai báo lược đồ cứng. Cám dỗ rất lớn: "cứ nhét mọi thứ vào một cột jsonb, khỏi phải ALTER TABLE mỗi lần thêm trường". Nhưng sự linh hoạt đó không miễn phí. Bài này đo thật cái giá của jsonb so với cột riêng — dung lượng, tốc độ, kích thước index — và chỉ ra khi nào mỗi lựa chọn là đúng.

Hai cách lưu cùng một dữ liệu

Cùng bốn trường (ten, tuoi, thanh_pho, active), hai thiết kế:

-- cột riêng: mỗi trường một cột có kiểu
CREATE TABLE c_col (id int8, ten text, tuoi int4, thanh_pho text, active bool);

-- jsonb: gom mọi trường vào một cột linh hoạt
CREATE TABLE c_json (id int8, data jsonb);
-- data = {"ten":"user1","tuoi":25,"thanh_pho":"HCM","active":true}

Điểm mấu chốt về lưu trữ: jsonb lặp tên khoá ở mỗi dòng. Cột riêng lưu tên cột một lần trong catalog; jsonb lưu chuỗi "thanh_pho", "tuoi"... trong từng bản ghi. Với 3 triệu dòng, đó là 3 triệu lần lặp mỗi tên khoá.

Ảnh chụp đoạn mã SQL nền tối minh hoạ JSONB vs cột riêng linh hoạt đổi lấy gì, hai cách lưu cùng dữ liệu cột riêng mỗi trường một cột có kiểu CREATE TABLE c_col id int8 ten text tuoi int4 thanh_pho text active bool, jsonb gom mọi trường vào một cột linh hoạt CREATE TABLE c_json id int8 data jsonb data bằng ten user1 tuoi 25 thanh_pho HCM active true, truy vấn trích trường bằng mũi tên kép hoặc chứa cột riêng dùng thẳng kiểu sẵn có WHERE thanh_pho bằng HCM AND tuoi lớn hơn 50 jsonb trích rồi ép kiểu mỗi lần WHERE data trích thanh_pho bằng HCM AND data trích tuoi ép int lớn hơn 50 WHERE data chứa thanh_pho HCM containment GIN, index cho jsonb hai lựa chọn 1 index biểu thức nhanh nhưng phải biết trường trước CREATE INDEX ON c_json data trích thanh_pho data trích tuoi ép int 2 GIN linh hoạt mọi trường nhưng lớn và chậm hơn CREATE INDEX ON c_json USING gin data WHERE data chứa thanh_pho HCM, khi nào chọn cái nào cột riêng lược đồ ổn định trường hay truy vấn cần kiểu và ràng buộc jsonb trường thay đổi thưa thớt theo dòng ít truy vấn metadata lai cột riêng cho trường lõi cộng một cột jsonb extra cho phần biến thiên

Hình 1: Cột riêng dùng kiểu sẵn có; jsonb trích trường bằng ->> rồi ép kiểu mỗi lần, hoặc @> cho containment. Index jsonb có hai lựa chọn: biểu thức (nhanh, cứng) hoặc GIN (linh hoạt, lớn).

Đo thật: dung lượng, tốc độ, index

Trên 3 triệu dòng, đo cả ba khía cạnh:

Ảnh chụp bảng kết quả đo thật nền tối JSONB vs cột riêng cùng dữ liệu 3 triệu dòng PostgreSQL 16 4 trường ten tuoi thanh_pho active pg_relation_size EXPLAIN ANALYZE shared_buffers 128MB, kích thước bảng cột riêng 172 MB kiểu gọn không lặp tên trường jsonb 357 MB khoảng 2 lần lặp tên khoá ở mỗi dòng, lọc thanh_pho bằng HCM AND tuoi lớn hơn 50 cột riêng cộng btree thanh_pho tuoi Bitmap Index Scan 51 mili giây jsonb không index Seq Scan trích mũi tên kép mỗi dòng 127 mili giây jsonb cộng index biểu thức Bitmap Index Scan 70 mili giây jsonb cộng GIN data chứa Bitmap Index Scan linh hoạt 119 mili giây, kích thước index btree cột thanh_pho tuoi 20 MB GIN jsonb data 225 MB GIN jsonb lớn gấp khoảng 11 lần btree cột đổi lấy sự linh hoạt truy vấn mọi trường, cốt lõi cột riêng nhỏ hơn 2 lần nhanh hơn index gọn có kiểu và ràng buộc chọn khi lược đồ ổn định và trường hay truy vấn JSONB linh hoạt thêm trường không ALTER TABLE nhưng tốn gấp đôi chỗ chậm hơn index GIN khổng lồ không ép kiểu thực tế hay dùng lai cột lõi cộng một cột jsonb extra

Hình 2: Cột riêng 172 MB so với jsonb 357 MB (~2×). Lọc thanh_pho='HCM' AND tuoi>50: cột riêng 51 ms, jsonb không index 127 ms, jsonb index biểu thức 70 ms, jsonb GIN 119 ms. Index GIN jsonb 225 MB so với btree cột 20 MB.

  • Dung lượng: cột riêng 172 MB so với jsonb 357 MB — gấp ~2 lần, do lặp tên khoá.
  • Tốc độ lọc thanh_pho='HCM' AND tuoi>50:
    • Cột riêng + btree: 51 ms.
    • jsonb không index: Seq Scan, 127 ms (phải trích ->> cho mỗi dòng).
    • jsonb + index biểu thức: 70 ms — chậm hơn cột riêng.
    • jsonb + GIN, data @> '{...}': 119 ms — linh hoạt nhưng chậm.
  • Kích thước index: btree cột 20 MB so với GIN jsonb 225 MB — gấp ~11 lần.

Cột riêng thắng trên mọi trục khi lược đồ đã biết: nhỏ hơn, nhanh hơn, index gọn hơn nhiều.

Index cho jsonb: hai lựa chọn, hai đánh đổi

Nếu dùng jsonb và cần truy vấn theo trường, có hai cách index:

Index biểu thức trên trường cụ thể: CREATE INDEX ON c_json ((data->>'thanh_pho')). Nhanh (70 ms, gần cột riêng) nhưng bạn phải biết trước truy vấn theo trường nào — đúng cái linh hoạt mà jsonb hứa hẹn bị mất. Mỗi trường muốn index nhanh phải khai riêng.

Index GIN trên cả cột: CREATE INDEX ON c_json USING gin(data). Linh hoạt — hỗ trợ truy vấn containment @> trên bất kỳ trường mà không cần khai trước. Nhưng nó lớn (225 MB, gấp 11 lần) và chậm hơn (119 ms). Đây là cái giá thật của "truy vấn mọi trường linh hoạt".

Đánh đổi cần cân nhắc

Cột riêng cho trường đã biết, ổn định, hay truy vấn. Nếu bạn biết cấu trúc dữ liệu và sẽ lọc/sắp theo các trường đó thường xuyên, cột riêng là lựa chọn đúng: nhanh, gọn, và có kiểu + ràng buộc (NOT NULL, CHECK, khóa ngoại) mà jsonb không ép được. tuoi int4 từ chối chuỗi rác; data->>'tuoi' thì nhận mọi thứ.

JSONB cho dữ liệu thật sự biến thiên hoặc thưa thớt. Khi mỗi bản ghi có tập trường khác nhau (thuộc tính sản phẩm đa dạng, cấu hình tùy biến, payload webhook), hoặc trường xuất hiện thưa (99% dòng không có), jsonb tránh được một bảng đầy cột NULL hoặc hàng chục ALTER TABLE. Đây là chỗ nó tỏa sáng — linh hoạt là tính năng, không phải khuyết điểm, khi dữ liệu vốn không có lược đồ cố định.

Mô hình lai thường là tốt nhất. Thực tế phổ biến: đặt các trường lõi hay truy vấn thành cột riêng (id, ngày tạo, trạng thái, khóa ngoại), và một cột jsonb tên extra/metadata cho phần biến thiên. Bạn được tốc độ và ràng buộc cho phần quan trọng, và linh hoạt cho phần đuôi dài. Đừng coi đây là lựa chọn nhị phân toàn-hoặc-không.

Ba ý mang về

  1. JSONB tốn gấp đôi dung lượng vì lặp tên khoá mỗi dòng: đo thật cùng dữ liệu, cột riêng 172 MB so với jsonb 357 MB, và index GIN jsonb 225 MB so với btree cột 20 MB (gấp ~11 lần) — linh hoạt có giá thật về lưu trữ.
  2. Cột riêng truy vấn nhanh hơn và có kiểu/ràng buộc: đo thật lọc hai trường chạy 51 ms với cột riêng so với 70–127 ms của jsonb; cột riêng ép được NOT NULL, CHECK, khóa ngoại mà jsonb không làm được.
  3. Chọn theo tính biến thiên của dữ liệu, và cân nhắc mô hình lai: cột riêng cho trường ổn định hay truy vấn, jsonb cho trường thật sự thay đổi/thưa thớt theo dòng — thực tế hay dùng cột lõi + một cột jsonb extra để được cả hai.

Phần sau ta xét một lựa chọn thiết kế tương tự cho dữ liệu nhiều giá trị: Phần sau đo kiểu mảng của PostgreSQL so với bảng con chuẩn hóa — khi nào một cột mảng gọn và nhanh, khi nào bảng con với khóa ngoại là đúng.