SQL & Database — Thực chiến PostgreSQL/Hybrid schema — column cứng + JSONB, khi nào migrate, zero-downtime pattern
~20 phútJSONB, full-text & pgvector lượt xem

Hybrid schema — column cứng + JSONB, khi nào migrate, zero-downtime pattern

TaskFlow priority nên là column hay JSONB? Hybrid pattern: column cho common field, JSONB cho custom field. 8 bước migrate zero-downtime, audit key usage định kỳ, decision matrix.

TaskFlow đang review schema. Field priority — nên là column cứng priority TEXT CHECK (priority IN ('low','med','high','critical')) hay gom vào JSONB task_metadata->>'priority'? Cả hai đều "đúng kỹ thuật". Nhưng chọn sai bây giờ = pain trong 6–12 tháng tới khi dataset vượt 1 triệu row và 30% query đều touch priority.

Bài này giải thích hybrid pattern — column cứng cho field common-case, JSONB cho field linh hoạt per-tenant — và quy trình migrate JSONB key sang column mà không downtime.

1. Analogy — Tủ quần áo hai ngăn

Hình dung tủ quần áo thiết kế cho người đi làm hàng ngày:

Loại ngănTương đương DBĐặc điểm
Ngăn riêng cho áo sơ mi, quần tâyColumn cứng (status, priority, due_at)Xếp gọn, tìm nhanh, chỉ chứa đúng kiểu quần áo
Ngăn "linh tinh" cho phụ kiện theo mùaJSONB task_metadataLinh hoạt, thêm bớt tùy mùa, không cần đóng lại tủ
Thiết kế tủ với cả hai loại ngănHybrid schemaCommon item có ngăn riêng, item hiếm dùng vào ngăn linh tinh

Extreme thứ nhất — tất cả vào ngăn riêng: 50 ngăn, mỗi ngăn chứa 1 loại phụ kiện → tủ spaghetti, không ai tìm được gì. Extreme thứ hai — tất cả vào ngăn linh tinh: tìm áo mặc hàng ngày mất 5 phút đào bới → chậm và khó maintain. Hybrid = thiết kế thông minh: item dùng hàng ngày có ngăn riêng, item hiếm dùng vào ngăn chung.

💡 Quy tắc thiết kế

Field dùng trong WHERE clause, JOIN, hoặc ORDER BY thường xuyên → column cứng. Field chỉ một số tenant/project cần, không ai query trực tiếp → JSONB. Khi boundary này thay đổi, migrate.

2. Vấn đề của hai extreme

Trước khi chọn hybrid, cần hiểu tại sao pure column và pure JSONB đều có vấn đề:

AspectPure columnPure JSONB
Type safetyCó (NOT NULL, CHECK)Không — PG không validate value bên trong
IndexB-tree, nhanh cho equality và rangeGIN, chậm write hơn, không dùng được cho range scan
FK constraintCó (REFERENCES)Không — không thể enforce referential integrity
Query syntaxĐơn giản (WHERE priority = 'high')Verbose (WHERE task_metadata->>'priority' = 'high')
Schema migrationALTER TABLE — cần planFree — chỉ write key mới vào JSONB
Per-tenant customSchema explosion (50+ nullable column)Dễ — mỗi tenant tự thêm key
Planner selectivityChính xác — B-tree statisticsKém — planner không biết distribution bên trong JSONB

Pure JSONB cho tất cả là lỗi "NoSQL fanboy": PG trở thành document store nhưng mất hết type safety và optimization. Pure column cho tất cả là lỗi "over-normalize": schema explosion với 50+ nullable column không ai nhớ dùng để làm gì.

3. Hybrid pattern — best of both worlds

Schema TaskFlow chuẩn với hybrid pattern:

-- DDL canonical TaskFlow voi hybrid schema
CREATE TABLE tasks (
  id          BIGSERIAL PRIMARY KEY,
  project_id  BIGINT NOT NULL REFERENCES projects(id),

  -- Common field (>50% query touch) -> column cung
  -- Type safety, B-tree index, FK constraint, planner statistics tot
  status      TEXT NOT NULL CHECK (status IN ('todo','doing','done','archived')),
  priority    TEXT CHECK (priority IN ('low','med','high','critical')),
  assignee_id BIGINT REFERENCES users(id),
  due_at      TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- Per-tenant flexible field -> JSONB
  -- Moi tenant co the them key rieng ma khong can ALTER TABLE
  task_metadata JSONB NOT NULL DEFAULT '{}'
);

-- Index strategy: B-tree cho common field, GIN cho JSONB
CREATE INDEX idx_tasks_status_priority ON tasks(status, priority);
CREATE INDEX idx_tasks_assignee      ON tasks(assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_due_at        ON tasks(due_at)      WHERE due_at IS NOT NULL;
CREATE INDEX idx_tasks_metadata      ON tasks USING gin(task_metadata jsonb_path_ops);

Với schema này:

  • Query filter theo statuspriority dùng B-tree — nhanh, planner có đủ statistics.
  • Tenant A thêm {"compliance_tag": "GDPR"} vào task_metadata — không cần ALTER TABLE.
  • Tenant B thêm {"blocker_id": 42, "client_code": "ACME-001"} — cũng không cần ALTER TABLE.
  • FK assignee_id REFERENCES users(id) được enforce — JSONB không làm được điều này.

4. Decision criteria — khi nào migrate JSONB key sang column

Không phải mọi JSONB key đều cần migrate. Dùng decision matrix này:

Yếu tốGiữ trong JSONBMigrate sang column
Query frequencyDưới 5% query dùng field nàyHơn 10% query touch field này
Type validationLoose string OKCần CHECK enum, range, hoặc type cast
FK referenceKhông cầnField là ID tham chiếu bảng khác
Dataset sizeDưới 100k rowHơn 1 triệu row (B-tree giúp rõ hơn GIN)
Field presenceDưới 30% row có field nàyHơn 50% row có field này
AggregationHiếm khi SUM, AVG, GROUP BYCần aggregate thường xuyên
Distinct valuesNhiều, khó enumerateÍt (dưới 10) — candidate cho CHECK enum

Trong TaskFlow: priority ban đầu có trong task_metadata vì schema chưa ổn định. Sau 3 tháng, data cho thấy 80% query đều filter theo priority, 75% task có field này, chỉ có 4 giá trị phân biệt. Tất cả tiêu chí đều chỉ sang migrate sang column.

5. Migration zero-downtime — 8 bước

Migration priority từ task_metadata sang column riêng — không downtime, không lock bảng:

-- BUOC 1: Add column NULLABLE (khong NOT NULL -> khong block write)
-- ALTER TABLE ADD COLUMN tren PG 11+ la instant cho column NULLABLE co DEFAULT
ALTER TABLE tasks ADD COLUMN priority_v2 TEXT;

-- BUOC 2: Backfill bat dong bo (chunk nho de tranh lock lau)
-- Trong production: chia chunk theo id de tranh exclusive lock toan bang
UPDATE tasks
SET priority_v2 = task_metadata->>'priority'
WHERE task_metadata ? 'priority'
  AND priority_v2 IS NULL;
-- Lap lai voi chunk nho hon neu bang co >100k row:
-- WHERE id BETWEEN 1 AND 50000 AND task_metadata ? 'priority'

-- BUOC 3: Verify spot-check (ket qua phai la 0)
SELECT count(*)
FROM tasks
WHERE task_metadata ? 'priority'
  AND priority_v2 IS DISTINCT FROM task_metadata->>'priority';
-- Expect: 0 -- moi row co priority trong metadata deu match column moi
-- BUOC 4: App switch READ: doc tu priority_v2 thay vi task_metadata->>'priority'
-- Deploy app version moi doc tu column (van ghi ca 2)

-- BUOC 5: Dual-write window (vai ngay)
-- App ghi ca task_metadata->>'priority' va priority_v2 dong thoi
-- Dam bao backward compat neu can rollback ve app version cu

-- BUOC 6: App stop write JSONB key (sau khi confirm stable)
-- Deploy app version khong con ghi priority vao task_metadata

-- BUOC 7: Drop JSONB key (optional, sau khi confirm app stable)
UPDATE tasks
SET task_metadata = task_metadata - 'priority'
WHERE task_metadata ? 'priority';
-- Giam storage + tranh confusion doc 2 nguon data

-- BUOC 8: Rename + add constraint
ALTER TABLE tasks RENAME COLUMN priority_v2 TO priority;

-- NOT NULL chi add sau khi chac chan moi row co gia tri:
-- SELECT count(*) FROM tasks WHERE priority IS NULL; -- phai = 0
ALTER TABLE tasks ALTER COLUMN priority SET NOT NULL;

-- CHECK enum sau khi verify tat ca gia tri deu hop le:
-- SELECT DISTINCT priority FROM tasks; -- xem truoc khi add constraint
ALTER TABLE tasks ADD CONSTRAINT chk_tasks_priority
  CHECK (priority IN ('low','med','high','critical'));

-- Index B-tree cho column moi (neu chua co)
CREATE INDEX CONCURRENTLY idx_tasks_priority ON tasks(priority);

Tại sao 8 bước thay vì ALTER TABLE trực tiếp?

Trực tiếp thêm column NOT NULL không có DEFAULT → lock toàn bảng trong khi PG verify mọi row. Với bảng triệu row → downtime hàng phút. Pattern 8 bước: column luôn NULLABLE trong giai đoạn migration, không lock, app chuyển đổi dần. Constraint thêm cuối sau khi data đã clean.

6. Audit JSONB key usage định kỳ

Chạy audit này mỗi 3–6 tháng để phát hiện JSONB key candidate migrate sang column:

-- Dem so row co moi key trong task_metadata
SELECT
  key,
  count(*)                                   AS occurrence,
  round(count(*) * 100.0 / (SELECT count(*) FROM tasks), 1) AS pct_rows
FROM tasks,
     jsonb_object_keys(task_metadata) AS key
GROUP BY key
ORDER BY occurrence DESC
LIMIT 20;
-- Key co pct_rows > 50% -> candidate column
-- Key co pct_rows < 5%  -> giup trong JSONB, khong can migrate
-- Dem distinct value per key (gio candidate CHECK enum)
SELECT
  key,
  count(DISTINCT task_metadata ->> key) AS distinct_values,
  array_agg(DISTINCT task_metadata ->> key
            ORDER BY task_metadata ->> key) AS sample_values
FROM tasks,
     jsonb_object_keys(task_metadata) AS key
GROUP BY key
HAVING count(DISTINCT task_metadata ->> key) < 20
ORDER BY distinct_values;
-- distinct_values < 10 voi sample_values ro rang -> candidate CHECK enum
-- Dem query frequency tu pg_stat_statements (neu extension bat)
-- Tim cac query dung task_metadata->>'key' trong WHERE clause
SELECT query, calls, total_exec_time / calls AS avg_ms
FROM pg_stat_statements
WHERE query LIKE '%task_metadata%'
  AND query LIKE '%priority%'
ORDER BY calls DESC
LIMIT 10;
-- Nhieu calls -> field duoc query thuong xuyen -> migrate sang column

Kết quả audit cho phép quyết định có bằng chứng thay vì cảm tính.

7. Pitfall — JSONB-as-everything và column-explosion

Pitfall — 3 anti-pattern phổ biến với hybrid schema

Pitfall 1 — JSONB-as-everything (NoSQL fanboy):

-- Sai: moi thing deu vao task_metadata
-- task_metadata = '{"status":"todo","priority":"high","assignee_id":5}'

-- Mat type safety: priority co the la 'high', 'High', 'HIGH', 'urgent', 'URGENT'
-- PG khong validate gia tri ben trong JSONB
SELECT DISTINCT task_metadata->>'priority' FROM tasks;
-- "high", "High", "HIGH", "urgent", "medium", "med"
-- 6 gia tri khi thuc te chi muon 4

-- Mat CHECK constraint va FK:
-- task_metadata->>'assignee_id' = '99999' -- user khong ton tai, PG khong biet
-- Column: assignee_id BIGINT REFERENCES users(id) -> PG enforce ngay

-- Mat planner optimization: planner khong biet distribution cua JSONB field
-- Fix: migrate field schema xac dinh sang column voi CHECK + FK

Pitfall 2 — Column-explosion (over-normalize):

-- Sai: ADD COLUMN cho moi possible custom field cua moi tenant
ALTER TABLE tasks ADD COLUMN gdpr_compliance_level TEXT;
ALTER TABLE tasks ADD COLUMN client_code TEXT;
ALTER TABLE tasks ADD COLUMN sla_hours INT;
ALTER TABLE tasks ADD COLUMN sprint_number INT;
ALTER TABLE tasks ADD COLUMN story_points INT;
-- ... 50 column nullable, 90% NULL voi moi tenant

-- Ket qua: schema spaghetti, khong ai biet column nao con dung
-- SELECT * FROM tasks: tra ve 60 column, 50 column la NULL
-- Fix: giu field per-tenant trong JSONB, chi migrate field "common" cross-tenant

Pitfall 3 — Khong audit JSONB usage:

-- Khong audit 6 thang -> task_metadata tro thanh "Wild West"
-- Moi team member tu y them key voi ten khac nhau:
-- "priority", "Priority", "task_priority", "pri", "urgency"
-- Cung la khai niem nhung 5 key khac nhau

-- Fix: audit 3-6 thang mot lan bang 2 query o buoc 6
-- Standardize key naming (lowercase, underscore) khi them moi
-- Document expected schema trong code comment hoac migration file

8. Applied — 3 pattern thực chiến

SaaS multi-tenant (TaskFlow):

-- Common field cross tenant -> column
-- Custom field per tenant -> JSONB
CREATE TABLE tasks (
  id         BIGSERIAL PRIMARY KEY,
  tenant_id  BIGINT NOT NULL,
  project_id BIGINT NOT NULL,
  status     TEXT NOT NULL CHECK (status IN ('todo','doing','done','archived')),
  priority   TEXT CHECK (priority IN ('low','med','high','critical')),
  assignee_id BIGINT REFERENCES users(id),
  due_at     TIMESTAMPTZ,
  -- Tenant A: {"compliance_tag":"GDPR","reviewer_count":3}
  -- Tenant B: {"client_code":"ACME-001","sla_hours":48}
  -- Tenant C: {"sprint":12,"story_points":8,"epic":"security"}
  custom_fields JSONB NOT NULL DEFAULT '{}'
);

-- Khi 1 custom field tro thanh common cross-tenant (vu du "sla_hours"):
-- Audit thay 60% tenant dung key nay -> migrate len column theo 8 buoc

E-commerce product:

-- name/price/stock -> column (luon co, query thuong xuyen)
-- attributes -> JSONB (khac nhau tuy loai san pham)
CREATE TABLE products (
  id       BIGSERIAL PRIMARY KEY,
  name     TEXT NOT NULL,
  price    NUMERIC(12,2) NOT NULL CHECK (price >= 0),
  stock    INT NOT NULL DEFAULT 0,
  -- Quan ao: {"color":"red","size":"M","material":"cotton"}
  -- Dien tu: {"brand":"Apple","storage":"256GB","warranty_months":12}
  -- Sach:    {"isbn":"978-...", "author":"Nguyen Van A","pages":320}
  attributes JSONB NOT NULL DEFAULT '{}'
);
-- Khong can 50 nullable column cho moi loai san pham

Event log:

-- event_type/user_id/occurred_at -> column (query, filter, partition)
-- payload -> JSONB (schema khac nhau tuy event)
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(),
  -- task.update: {"task_id":42,"old":{"status":"todo"},"new":{"status":"done"}}
  -- user.login:  {"ip":"1.2.3.4","user_agent":"Mozilla/5.0","method":"oauth"}
  -- file.upload: {"filename":"report.pdf","size_bytes":1048576}
  payload     JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_audit_event_type ON audit_logs(event_type, occurred_at DESC);

9. Deep Dive

Deep Dive — Hybrid schema và schema evolution
  • Crunchy Data — "Postgres for the Application Developer" — practical guide từ PG experts bao gồm hybrid schema pattern, khi nào dùng JSONB vs column riêng, và ví dụ thực chiến với multi-tenant SaaS. Đây là starting point tốt nhất để hiểu tradeoff từ góc nhìn application developer.
  • PostgreSQL Documentation Ch.5.4 "Constraints" — reference đầy đủ cho CHECK, NOT NULL, UNIQUE, FOREIGN KEY, và EXCLUDE constraint — những gì JSONB không thể làm nhưng column cứng làm được. Đọc phần "Check Constraints" để hiểu tại sao CHECK trên JSONB expression có hạn chế.
  • Heap Engineering — "Running 10 Million PostgreSQL Indexes in Production" — case study từ Heap Analytics về schema design decision ở scale lớn: tại sao họ dùng wide column schema thay vì EAV hay JSONB, và khi nào JSONB phù hợp với data model của họ. Đọc để hiểu decision ở real production scale.

10. Tóm tắt

  • Hybrid pattern: column cứng cho field common-case (query thường xuyên, cần type safety, FK, CHECK), JSONB cho field linh hoạt per-tenant (không biết trước schema, mỗi tenant khác nhau).
  • Pure JSONB mất type safety, CHECK constraint, FK, và B-tree optimization. Pure column gây schema explosion với 50+ nullable column không ai biết dùng để làm gì.
  • Decision migrate JSONB key sang column khi: hơn 10% query touch field đó, cần FK hoặc CHECK constraint, dataset hơn 1 triệu row, field xuất hiện hơn 50% row, hoặc cần aggregate thường xuyên.
  • Migration zero-downtime 8 bước: add NULLABLE column → backfill → verify → switch read → dual-write window → stop write JSONB key → drop JSONB key → add NOT NULL và CHECK constraint.
  • Audit định kỳ (3–6 tháng) bằng jsonb_object_keys để đếm occurrence và distinct value per key — phát hiện hot key candidate migrate trước khi thành performance bottleneck.
  • 3 applied pattern: multi-tenant SaaS (common field là column, custom field là JSONB), e-commerce product (name/price/stock là column, attributes là JSONB), event log (event_type/timestamp là column, payload là JSONB).
  • Không bao giờ để JSONB metadata trở thành "Wild West" không ai audit — standardize key naming và review 3–6 tháng một lần.

11. Tự kiểm tra

Tự kiểm tra
Q1
TaskFlow đang lưu assignee_id bên trong task_metadata JSONB dưới dạng string thay vì column riêng. Tại sao đây là thiết kế sai? Liệt kê ít nhất 3 vấn đề cụ thể so với column `assignee_id BIGINT REFERENCES users(id)`.

Vấn đề 1 — Không có FK constraint: task_metadata->>'assignee_id' = '99999' insert thành công dù user 99999 không tồn tại. Column assignee_id BIGINT REFERENCES users(id) sẽ raise lỗi ngay lập tức. Bug assignment đến user không tồn tại chỉ phát hiện khi app đọc và join thất bại — silent data corruption.

Vấn đề 2 — Không có type safety: JSONB lưu value như string "42". App phải nhớ cast: (task_metadata->>'assignee_id')::bigint. Nếu một nơi quên cast, comparison assignee_id = 8 vs assignee_id = 42 cho kết quả sai vì so sánh text. Column BIGINT enforce type ở tầng DB.

Vấn đề 3 — Không có B-tree index hiệu quả: Query "tất cả task của user 42" cần expression index ((task_metadata->>'assignee_id')::bigint) — awkward và dễ bị missed khi optimize. Column assignee_id tự nhiên có B-tree index, planner biết selectivity chính xác.

Vấn đề 4 — Không cascade: Khi user bị xóa, column với FK có thể dùng ON DELETE SET NULL hoặc ON DELETE CASCADE. JSONB key không có cơ chế tương đương — orphan reference tồn tại vĩnh viễn.

Q2
Tại sao bước migration không thể làm đơn giản là `ALTER TABLE tasks ADD COLUMN priority TEXT NOT NULL DEFAULT 'low'`? Vấn đề gì xảy ra với bảng production 2 triệu row?

Trên PostgreSQL, ALTER TABLE ADD COLUMN NOT NULL DEFAULT constant từ PG 11+ là nhanh (metadata-only change) — PG không rewrite toàn bảng ngay. Tuy nhiên có một số vấn đề thực chiến quan trọng:

Vấn đề 1 — Default không phản ánh data thực: DEFAULT 'low' gán cùng value cho 2 triệu row — kể cả row đang có task_metadata->>'priority' = 'high'. Data column mới sai hoàn toàn so với source of truth trong JSONB. Cần backfill step riêng để map đúng.

Vấn đề 2 — Constraint thêm sau khi verify data: Nếu trong task_metadata có giá trị không thuộc enum (ví dụ urgent, HIGH), ALTER TABLE ADD CONSTRAINT CHECK sẽ fail. Pattern 8 bước kiểm tra trước khi add constraint — SELECT DISTINCT priority FROM tasks để verify data sạch.

Vấn đề 3 — Migration không reversible dễ dàng: Thêm NOT NULL + constraint ngay lập tức khóa khả năng rollback app code về version cũ (vẫn ghi vào JSONB). Dual-write window cho phép rollback bất cứ lúc nào trong 8 bước.

Q3
Sau khi audit, bạn thấy key `compliance_tag` xuất hiện trong 8% task (chủ yếu của tenant có khách hàng ở EU). Key `status` xuất hiện trong 98% task. Quyết định migrate cái nào? Tại sao?

Migrate `status` lên column — tất cả tiêu chí đều chỉ sang migrate:

  • 98% row có field → field gần như bắt buộc, null overhead lớn nếu để trong JSONB.
  • `status` chắc chắn xuất hiện trong WHERE clause của hầu hết query (filter task theo trạng thái là behavior cơ bản nhất).
  • Chỉ có vài giá trị phân biệt ('todo', 'doing', 'done', 'archived') → CHECK enum rõ ràng, planner selectivity tốt với B-tree.
  • Field này có thể cần FK hoặc trigger theo status (ví dụ: khi status = 'done', cập nhật completed_at).

Giữ `compliance_tag` trong JSONB:

  • Chỉ 8% row — 92% row không có field này → column sẽ là NULL hầu hết. Partial index giảm overhead nhưng column vẫn chiếm schema space.
  • Chỉ EU tenant cần → per-tenant custom field, đúng use case của JSONB.
  • Nếu query filter theo compliance_tag thường xuyên chỉ với EU tenant: GIN index + WHERE task_metadata @> '{"compliance_tag":"GDPR"}' vẫn hiệu quả với 8% subset.

Quy tắc: field cross-tenant, query thường xuyên → column. Field per-tenant subset → JSONB.

Q4
Trong bước dual-write (bước 5), app ghi cả `priority_v2` column lẫn `task_metadata->>'priority'` JSONB key đồng thời. Điều gì xảy ra nếu một write thành công nhưng write kia fail trong cùng transaction?

Nếu cả hai write nằm trong cùng một transaction: khi transaction rollback (do bất kỳ lỗi nào), cả hai write đều bị undone — không có partial state. ACID guarantee của PostgreSQL bảo đảm điều này. Đây là lý do dual-write trong cùng transaction là an toàn về consistency.

Vấn đề xảy ra khi hai write ở hai transaction riêng biệt (ví dụ: app ghi column trước, rồi update JSONB sau trong request khác): nếu process crash giữa chừng, column và JSONB có thể diverge. Trong dual-write window, đây là risk cần monitor.

Giảm risk: luôn wrap cả hai write trong cùng transaction. Khi verify (bước 3), chạy lại query SELECT count(*) FROM tasks WHERE priority_v2 IS DISTINCT FROM task_metadata->>'priority' — kết quả 0 xác nhận hai nguồn nhất quán. Dual-write window kéo dài vài ngày chứ không phải vĩnh viễn — khi xác nhận stable, stop write JSONB key ngay.

Q5
E-commerce platform có bảng `products` với JSONB `attributes`. Sau 1 năm, field `color` xuất hiện ở 70% sản phẩm và team muốn thêm filter màu sắc trên trang tìm kiếm. Ngoài migrate `color` lên column, còn có giải pháp nào khác? Khi nào chọn giải pháp đó thay vì migrate?

Giải pháp thay thế: Expression index trên JSONB field:

CREATE INDEX idx_products_color
  ON products ((attributes->>'color'))
  WHERE attributes ? 'color';

Index này tạo B-tree trên extracted value của color field — không cần migrate lên column. Query WHERE attributes->>'color' = 'red' sẽ dùng index này, nhanh tương đương B-tree trên column.

Khi nào chọn expression index thay vì migrate:

  • Field có nhiều giá trị phân biệt (màu sắc có thể có hàng trăm giá trị) — CHECK enum không practical.
  • Schema đang trong giai đoạn chưa ổn định — có thể thêm màu mới bất kỳ lúc nào.
  • Không cần FK hoặc NOT NULL constraint trên field.
  • Team muốn tránh migration risk và dual-write complexity.

Khi nào vẫn nên migrate sang column: khi cần NOT NULL (mọi sản phẩm bắt buộc có màu), khi cần FK (màu tham chiếu bảng color_catalog), hoặc khi dataset hơn 5 triệu row và query cần range scan kết hợp nhiều field — planner với B-tree column cho query plan tốt hơn expression index.

Bài tiếp theo: Full-text search tiếng Việt — unaccent + GIN + ts_rank

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