SQL & Database — Thực chiến PostgreSQL/JSONB vs JSON vs TEXT — lưu trữ dữ liệu bán cấu trúc trong PostgreSQL
~18 phútJSONB, full-text & pgvector lượt xem

JSONB vs JSON vs TEXT — lưu trữ dữ liệu bán cấu trúc trong PostgreSQL

TEXT, JSON, JSONB: khi nào dùng loại nào, tại sao JSONB là lựa chọn mặc định 2025. Binary decompose, GIN index, demo custom field multi-tenant TaskFlow.

TaskFlow cần "custom field per project" — project A có due_priority, project B có blocker_id, project C có compliance_tag. Schema cứng (một column cho mỗi field) không scale: mỗi tenant thêm field là ALTER TABLE toàn bộ production. Cần một giải pháp linh hoạt hơn.

Ba lựa chọn PostgreSQL hay gặp: lưu JSON như TEXT (opaque string), dùng type JSON (validate syntax nhưng giữ nguyên raw format), hoặc dùng JSONB (parse và lưu binary có thể index). Bài này giải thích tại sao JSONB thắng 99% trường hợp — và khi nào thì không.

1. Analogy — 3 cách lưu hồ sơ giấy

Hình dung bạn có hàng nghìn hồ sơ cần lưu trữ. Ba cách tiếp cận:

Cách lưu hồ sơTương đương SQLĐặc điểm
Nhét nguyên tờ giấy vào folder không phân loạiTEXTChỉ tìm được bằng tên folder; mở ra không biết nội dung là gì
Scan ảnh hồ sơ và lưu file ảnhJSONPreserve format gốc, nhưng phải đọc lại (OCR) mỗi khi cần tìm thông tin
OCR toàn bộ hồ sơ, index từng trường vào databaseJSONBMất format gốc (ảnh), nhưng search field nào cũng nhanh
💡 Cách nhớ nhanh

TEXT = nhét vào folder (PG không hiểu nội dung). JSON = scan ảnh (preserve gốc, tìm chậm). JSONB = OCR + index (mất format gốc, query nhanh). Với backend application thông thường, JSONB là lựa chọn đúng gần như luôn luôn.

2. TEXT — JSON-as-string (anti-pattern hầu hết trường hợp)

Lưu JSON như một chuỗi ký tự thông thường — PostgreSQL không biết đây là JSON hay bất kỳ cấu trúc nào khác.

-- TEXT: PG treat nhu opaque blob
ALTER TABLE tasks ADD COLUMN task_metadata TEXT;

INSERT INTO tasks (project_id, title, task_metadata)
VALUES (1, 'Deploy v2', '{"priority": "high", "labels": ["deploy"]}');

-- PG khong validate: insert string xau duoc
INSERT INTO tasks (project_id, title, task_metadata)
VALUES (2, 'Fix bug', 'this is NOT valid JSON at all');
-- Khong co loi! PG khong kiem tra noi dung

-- Khong the query field ben trong:
SELECT * FROM tasks WHERE task_metadata->>'priority' = 'high';
-- ERROR: operator does not exist: text ->> unknown

Vấn đề cốt lõi với TEXT:

  • Không validate JSON syntax — có thể insert string xấu, bug xuất hiện khi app đọc.
  • Không có operator JSON — không thể dùng ->, ->>, @> hay bất kỳ JSON operator nào.
  • Không thể index nội dung — chỉ có thể index toàn bộ string hoặc full-text; không thể index theo key/value cụ thể.
  • Write nhanh nhất — PG chỉ lưu bytes, không làm gì thêm — nhưng query tệ nhất.

Khi nào TEXT hợp lý: lưu payload mà bạn không bao giờ query theo field (vd: signed JWT cần verify chữ ký, protocol buffer raw bytes). Tuy nhiên với trường hợp đó BYTEA còn phù hợp hơn.

3. JSON — preserve original format

Type JSON validate syntax khi INSERT và cho phép dùng các JSON operator — nhưng lưu trữ dưới dạng text với toàn bộ format gốc được giữ nguyên.

-- JSON: validate syntax + preserve raw text
ALTER TABLE tasks ADD COLUMN task_metadata JSON;

-- PG validate JSON khi INSERT
INSERT INTO tasks (project_id, title, task_metadata)
VALUES (1, 'Deploy v2', '{"priority": "high", "labels": ["deploy"]}');
-- OK

INSERT INTO tasks (project_id, title, task_metadata)
VALUES (2, 'Fix bug', 'NOT valid JSON');
-- ERROR: invalid input syntax for type json

-- Preserve EVERYTHING: whitespace, key order, duplicate key
SELECT '{"a": 1,   "b": 2}'::json;
-- {"a": 1,   "b": 2}  -- whitespace giu nguyen

SELECT '{"z": 1, "a": 2}'::json;
-- {"z": 1, "a": 2}    -- key order giu nguyen

SELECT '{"a": 1, "a": 2}'::json;
-- {"a": 1, "a": 2}    -- duplicate key giu nguyen! (lay gia tri nao phu thuoc app)

Behavior "preserve" của JSON tưởng là lợi thế nhưng thực tế là vấn đề:

  • Parse mỗi query — mỗi lần đọc field JSON, PG phải parse lại toàn bộ string từ đầu.
  • Không có GIN index — không thể tạo index GIN trên JSON column (chỉ JSONB hỗ trợ).
  • Duplicate key nguy hiểm{"a": 1, "a": 2} là JSON hợp lệ nhưng behavior khi đọc a không xác định (tuỳ implementation).

Use case thực sự cần JSON (không phải JSONB):

  • Bạn cần preserve byte-exact raw format (vd: signed payload mà việc thêm khoảng trắng sẽ invalid chữ ký — nhưng trường hợp này nên dùng TEXT hoặc BYTEA).
  • Audit log cần lưu chính xác byte payload gốc từ client, kể cả whitespace và duplicate key — và bạn không bao giờ query field bên trong.

Thực tế: JSON ít được dùng trong application hiện đại. JSONB phủ hầu hết trường hợp tốt hơn.

4. JSONB — binary decomposed (lựa chọn mặc định 2025)

Type JSONB validate và parse JSON khi INSERT, sau đó lưu trữ dưới dạng binary decomposed — một cấu trúc cây đã được sort key, dedupe và có offset table để truy cập nhanh.

-- JSONB: parse 1 lan khi INSERT, luu binary
ALTER TABLE tasks ADD COLUMN task_metadata JSONB DEFAULT '{}';

-- Validate tuong tu JSON
INSERT INTO tasks (project_id, title, task_metadata)
VALUES (1, 'Deploy v2', '{"priority": "high", "labels": ["deploy"]}');

-- JSONB KHONG preserve: whitespace, key order, duplicate key
SELECT '{"a": 1,   "b": 2}'::jsonb;
-- {"a": 1, "b": 2}    -- whitespace bi bo

SELECT '{"z": 1, "a": 2}'::jsonb;
-- {"a": 2, "z": 1}    -- KEY DUOC SORT ALPHABET

SELECT '{"a": 1, "a": 2}'::jsonb;
-- {"a": 2}            -- DUPLICATE BI DEDUPE, giu gia tri cuoi cung

Quá trình decompose của JSONB:

  1. Parse — validate JSON syntax (giống JSON type).
  2. Decompose — tách object thành cặp key-value.
  3. Sort key — sort theo alphabet để binary search nhanh.
  4. Dedupe duplicate key — giữ value của key xuất hiện cuối cùng.
  5. Build binary tree — tạo offset table cho random access.
  6. Lưu binary — không phải text.

Kết quả: mỗi lần query field, PG không cần parse lại — chỉ cần nhảy đến offset của key trong binary tree. Nhanh hơn JSON 2–10x tùy độ sâu của object.

5. So sánh TEXT / JSON / JSONB

AspectTEXTJSONJSONB
Validate syntaxKhông
Preserve whitespace / key order / duplicateKhông
Storage sizeNhỏ nhất~10% lớn hơn TEXT~20% lớn hơn JSON
Insert speedNhanh nhấtTrung bìnhChậm nhất (decompose)
Query speedChậm (không tìm field được)Chậm (parse mỗi lần)Nhanh nhất (no parse)
GIN indexKhôngKhông
JSON operator (->, ->>, @>, ?)KhôngCó (nhưng chậm)Tất cả, tối ưu

Storage JSONB lớn hơn vì binary header và offset table. Trong thực chiến, overhead này nhỏ hơn nhiều so với gain từ query speed và GIN index — đặc biệt khi data thường được đọc nhiều hơn viết.

💡 Convention 2025

Dùng JSONB cho 99% trường hợp. TEXT chỉ khi thực sự cần opaque blob. JSON chỉ khi cần preserve byte-exact format — và trong trường hợp đó hãy cân nhắc TEXT hoặc BYTEA thay vào đó.

6. Demo — TaskFlow custom field per project

Schema TaskFlow hiện tại có schema cứng. Để hỗ trợ custom field per project mà không cần ALTER TABLE mỗi khi tenant thêm field, thêm JSONB column:

-- Extend TaskFlow schema voi JSONB
ALTER TABLE tasks ADD COLUMN task_metadata JSONB DEFAULT '{}';
ALTER TABLE projects ADD COLUMN settings JSONB DEFAULT '{}';

-- Insert voi custom field khac nhau per project
INSERT INTO tasks (project_id, title, task_metadata) VALUES
  (1, 'Deploy v2',
   '{"priority": "high", "labels": ["deploy", "prod"], "estimate_hours": 4}'),
  (1, 'Fix critical bug',
   '{"priority": "low", "blocker_task_id": 100}'),
  (2, 'Write privacy doc',
   '{"compliance_tag": "GDPR", "reviewer_count": 3}'),
  (3, 'Refactor auth',
   '{"sprint": 12, "story_points": 8, "epic": "security"}');
-- Moi project co field rieng -- khong can ALTER TABLE
-- Demo: JSONB dedupe duplicate key
SELECT '{"a": 1, "a": 2}'::jsonb;
-- {"a": 2}  -- chi giu gia tri cuoi cung

-- JSON giu nguyen duplicate:
SELECT '{"a": 1, "a": 2}'::json;
-- {"a": 1, "a": 2}  -- giu nguyen (behavior khi doc 'a' khong xac dinh)

-- JSONB sort key alphabet:
SELECT '{"z": 1, "b": 3, "a": 2}'::jsonb;
-- {"a": 2, "b": 3, "z": 1}  -- key sorted

-- Query field ben trong: chi duoc voi JSON / JSONB, khong duoc voi TEXT
SELECT title, task_metadata->>'priority' AS priority
FROM tasks
WHERE task_metadata->>'priority' = 'high';
-- Deploy v2 | high

-- Kiem tra key ton tai (JSONB only)
SELECT title FROM tasks
WHERE task_metadata ? 'compliance_tag';
-- Write privacy doc

-- Containment check (JSONB only, nhanh voi GIN index)
SELECT title FROM tasks
WHERE task_metadata @> '{"labels": ["prod"]}';
-- Deploy v2

7. Pitfall — JSONB không phải NoSQL, lạm dụng mất type safety

Pitfall — 3 hiểu lầm phổ biến về JSONB

Pitfall 1 — "JSONB = NoSQL document store":

JSONB chỉ là một column type trong PostgreSQL relational. Bảng vẫn có đầy đủ tính năng relational:

  • NOT NULLCHECK constraint trên column JSONB (validate toàn bộ object).
  • Foreign key trên các column khác trong cùng row.
  • Transaction ACID đầy đủ — JSONB write được bảo vệ bởi WAL và MVCC.
  • Điểm khác biệt: nội dung bên trong JSONB không có B-tree index riêng từng field — chỉ có GIN index (toàn bộ document) hoặc expression index (một field cụ thể).

Pitfall 2 — Lạm dụng JSONB cho structured data đã biết schema:

-- Sai: task_metadata chua cac field luon co, schema co dinh
task_metadata = '{"status": "todo", "assignee_id": 5, "priority": "high"}'

-- Nen extract ra column rieng:
-- status TEXT CHECK (status IN ('todo', 'doing', 'done'))   -- enum check
-- assignee_id BIGINT REFERENCES users(id)                   -- FK + B-tree index
-- priority TEXT CHECK (priority IN ('low', 'medium', 'high'))

Khi field đã biết schema: column riêng có type safety, CHECK constraint, FK constraint, và B-tree index — tất cả đều tốt hơn JSONB. Bài 4 của module này sẽ deep dive migration JSONB → column khi schema ổn định.

Pitfall 3 — JSON vs JSONB confusion:

Dev mới hay nghĩ "JSON tốt hơn vì preserve format gốc". Thực tế:

  • 99% backend application không cần preserve key order hay whitespace.
  • Preserve duplicate key ({"a":1,"a":2}) là hành vi nguy hiểm — không xác định được value nào sẽ được đọc.
  • JSONB mất whitespace/key order nhưng đổi lại: query nhanh 2–10x, GIN index, toàn bộ operator set hoạt động tối ưu.

Dùng JSON khi: audit log cần byte-exact payload gốc, signed JWT cần check tampering — nhưng khi đó TEXT hoặc BYTEA thường là lựa chọn tốt hơn cả.

8. Applied — 3 use case JSONB thực chiến

1. Multi-tenant custom field (TaskFlow):

-- Moi project dinh nghia custom field rieng trong project.settings
UPDATE projects
SET settings = '{"custom_fields": ["compliance_tag", "reviewer_count"]}'
WHERE id = 2;

-- Task cua project do co the co bat ky field nao duoc khai bao trong settings
-- Khong can ALTER TABLE khi tenant them field moi

Giải quyết "schema-per-customer hell": thay vì ALTER TABLE cho mỗi tenant, mỗi tenant tự mô tả field của họ trong JSONB.

2. Audit log metadata không có schema cứng:

CREATE TABLE audit_logs (
  id         BIGSERIAL PRIMARY KEY,
  event_type TEXT NOT NULL,
  user_id    BIGINT REFERENCES users(id),
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload    JSONB NOT NULL DEFAULT '{}'
  -- payload: request headers, body, response status, duration, IP...
  -- Schema khac nhau tuy loai event -- JSONB la lua chon dung
);

INSERT INTO audit_logs (event_type, user_id, payload) VALUES
  ('task.update', 7,
   '{"task_id": 42, "changed_fields": ["status", "assignee_id"],
     "old": {"status": "todo"}, "new": {"status": "done"},
     "ip": "192.168.1.1", "user_agent": "Mozilla/5.0"}');

3. Webhook payload storage cho replay/debug:

CREATE TABLE webhook_deliveries (
  id          BIGSERIAL PRIMARY KEY,
  webhook_id  BIGINT NOT NULL,
  delivered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  status_code INT,
  request_payload  JSONB NOT NULL,  -- payload gui di
  response_payload JSONB            -- response nhan duoc
);

-- Query: tim tat ca delivery that bai trong 24h
SELECT id, webhook_id, status_code,
       request_payload->>'event_type' AS event_type
FROM webhook_deliveries
WHERE delivered_at > now() - INTERVAL '24 hours'
  AND status_code != 200
ORDER BY delivered_at DESC;

9. Deep Dive

Deep Dive — JSONB internals và best practices
  • PostgreSQL Documentation Ch.8.14 "JSON Types" — official reference: toàn bộ behavior của JSON và JSONB, containment semantics, existence operators, jsonpath, và lý do tại sao duplicate key trong JSON type có behavior không xác định. Đọc phần "jsonb Indexing" để hiểu GIN index hoạt động như thế nào.
  • Crunchy Data — "When to use JSONB in PostgreSQL" — practical guide từ PG experts: benchmark TEXT vs JSON vs JSONB, khi nào nên và không nên dùng JSONB, migration pattern khi schema ổn định.
  • PostgreSQL Wiki — "Don't Do This" — community-curated anti-pattern list; xem phần về JSON/JSONB misuse và "when not to use JSONB" để tránh các pitfall phổ biến nhất.

10. Tóm tắt

  • TEXT lưu JSON như opaque string: không validate, không operator, không index field — chỉ hợp lý cho byte-exact blob không bao giờ query field.
  • JSON validate syntax và preserve whitespace/key order/duplicate — nhưng parse lại mỗi query, không có GIN index. Dùng khi cần byte-exact preservation (hiếm).
  • JSONB parse và lưu binary decomposed: sort key, dedupe duplicate (giữ value cuối), mất whitespace/key order — query nhanh 2–10x, hỗ trợ GIN index và toàn bộ JSON operator.
  • Storage: TEXT < JSON < JSONB (~20% lớn hơn JSON) — nhưng performance gain thường outweigh storage cost.
  • JSONB không phải NoSQL: vẫn có constraint, FK, transaction ACID đầy đủ. Nội dung bên trong không có B-tree index riêng từng field — chỉ GIN hoặc expression index.
  • Lạm dụng JSONB cho structured data: nếu field đã biết schema cố định, dùng column riêng với type safety, CHECK constraint, FK, và B-tree index tốt hơn nhiều. Bài 4 của module này deep dive migration JSONB → column.
  • Convention 2025: JSONB mặc định cho semi-structured data — 3 use case chính: custom field multi-tenant, audit log metadata, webhook payload storage.

11. Tự kiểm tra

Tự kiểm tra
Q1
Tại sao JSONB mất whitespace và key order nhưng lại được coi là 'tốt hơn' JSON? Có trường hợp nào mà việc mất key order là vấn đề nghiêm trọng không?

JSONB mất whitespace và key order vì quá trình decompose: PG parse JSON, sort key alphabet, dedupe duplicate, rồi lưu binary tree. Đây là đánh đổi có chủ đích — đổi format preservation để lấy query speed (2–10x) và GIN index support.

Với 99% backend application, whitespace và key order không có semantic — {"a":1,"b":2}{"b":2,"a":1} là cùng một document. Application layer (JSON parser của ngôn ngữ lập trình) cũng không guarantee key order khi deserialize.

Trường hợp key order thực sự quan trọng: canonical JSON cho cryptographic signing (vd: JSON Web Signature) — key order là một phần của signed payload. Trong trường hợp đó dùng TEXT hoặc BYTEA để preserve byte-exact, hoặc normalize về canonical form trước khi store. Tuy nhiên kiến trúc này thường lưu signature riêng thay vì dựa vào key order.

Q2
JSONB dedupe duplicate key — giữ value của key xuất hiện cuối cùng. Điều này có thể gây bug gì trong production? Ví dụ cụ thể.

JSON type (không phải JSONB) giữ nguyên duplicate key — nhưng behavior khi đọc không xác định: tùy implementation có thể trả value đầu tiên, cuối cùng, hoặc random. JSONB giải quyết bằng cách dedupe và giữ value cuối.

Bug tiềm ẩn với JSON type: client gửi {"role": "admin", "role": "viewer"}. PG JSON lưu nguyên. App đọc key role — tùy library trả "admin" hay "viewer" là undefined. Security bug nếu logic phân quyền dựa vào value này.

JSONB tránh được vấn đề này: {"role": "admin", "role": "viewer"}::jsonb{"role": "viewer"} — luôn nhất quán (value cuối). Tuy nhiên vẫn là bug nếu client gửi duplicate key không cố ý — PG không báo lỗi, chỉ silently dedupe. Nên validate ở application layer để reject payload có duplicate key.

Q3
TaskFlow có column `tasks.status TEXT CHECK (status IN ('todo', 'doing', 'done'))`. Tại sao không nên chuyển status vào task_metadata JSONB? Khi nào nên làm ngược lại — extract field từ JSONB ra column riêng?

Tại sao status không nên vào JSONB:

  • CHECK constraint trên JSONB field phức tạp: CHECK ((task_metadata->>'status') IN ('todo', 'doing', 'done')) — không enforce khi field absent (NULL).
  • Không có B-tree index tự nhiên — index tasks(status) là B-tree, rất nhanh cho equality và range. JSONB field cần expression index: CREATE INDEX ON tasks ((task_metadata->>'status')) — awkward và dễ bị missed.
  • Không thể dùng FK — assignee_id BIGINT REFERENCES users(id) enforce referential integrity; JSONB field không có cơ chế tương đương.
  • ORM và type safety: column riêng có type mapping rõ ràng trong application code.

Khi nên extract field từ JSONB ra column: khi field đó xuất hiện trong WHERE clause thường xuyên, cần JOIN với bảng khác, cần CHECK/FK constraint, hoặc cần aggregate (COUNT, GROUP BY). Bài 4 của module này deep dive migration pattern cụ thể.

Q4
Giải thích tại sao GIN index chỉ hoạt động với JSONB mà không phải JSON hoặc TEXT. GIN index lưu gì bên trong?

GIN (Generalized Inverted Index) là loại index lưu mapping ngược: từ mỗi element (key, value, path) → danh sách row chứa element đó. Để build GIN, PG cần "decompose" document thành các element nhỏ — đây chính xác là những gì JSONB đã làm khi INSERT.

JSONB đã lưu sẵn binary decomposed structure — GIN chỉ cần index các key và value đã được tách ra. Ví dụ document {"priority": "high", "labels": ["deploy", "prod"]} được decompose thành: key "priority", value "high", key "labels", value "deploy", value "prod". GIN index mỗi element này.

JSON (text-based) và TEXT không có cấu trúc decomposed — chỉ là chuỗi ký tự. Để build GIN, PG phải parse toàn bộ string cho mỗi row khi build index, và không có cách nào duy trì consistency vì storage là text. Vì lý do đó, PG không hỗ trợ GIN trên JSON hoặc TEXT column.

Kết quả: query WHERE task_metadata @> '{"priority": "high"}' với GIN index chỉ cần lookup element "priority=high" trong inverted index — không scan toàn bảng.

Q5
Trong 3 use case Applied (custom field, audit log, webhook), use case nào thực sự cần JSONB thay vì schema cứng? Use case nào có thể dùng schema cứng nhưng JSONB vẫn hợp lý?

Thực sự cần JSONB (schema không cố định):

Multi-tenant custom field: mỗi project/tenant có tập field hoàn toàn khác nhau, không biết trước. Schema cứng yêu cầu ALTER TABLE mỗi khi thêm field — không scale với nhiều tenant. JSONB là lựa chọn đúng.

Audit log payload: event type khác nhau có payload khác nhau hoàn toàn — "task.update" có changed_fields, "user.login" có ipuser_agent. Schema cứng cần nullable column cho mỗi field của mỗi event type — waste storage và confusing. JSONB là lựa chọn đúng.

Schema cứng cũng được, nhưng JSONB vẫn hợp lý:

Webhook payload: nếu webhook format cố định (cùng provider, cùng schema), schema cứng có type safety tốt hơn. Nhưng nếu cần support nhiều provider với format khác nhau, hoặc cần lưu raw payload để replay/debug mà không muốn maintain migration mỗi khi provider thay đổi format — JSONB linh hoạt hơn và trade-off hợp lý.

Bài tiếp theo: JSONB operators & path — ->, ->>, @>, ?, jsonb_path_query

Bài này có giúp bạn hiểu bản chất không?