Denormalization tradeoff — khi nào break 3NF có chủ đích
Read vs write amplification. 3 pattern: cached column (denormalized FK label), counter table (precomputed COUNT), materialized view. Sync mechanism trigger/app/CDC.
Dashboard TaskFlow cần hiển thị 200 task mỗi trang, mỗi task kèm tên project và số comment. Query chuẩn 3NF join 8 bảng, chạy 800ms. Người dùng rời trang sau 2 giây. Sau khi denormalize tasks.project_name (copy từ projects.name) và tasks.comment_count (precomputed), cùng query chạy 50ms — nhanh hơn 16 lần. Nhưng đêm hôm đó, một project đổi tên. Mọi task của project đó vẫn hiển thị tên cũ — cho đến khi sync chạy.
Đây là tradeoff cốt lõi của denormalization: đánh đổi write cost (phải sync nhiều nơi) để lấy read speed (không cần JOIN, không cần aggregate). Bài này map 3 pattern denormalize phổ biến, cơ chế sync, và decision matrix để biết khi nào nên — và khi nào không nên — break 3NF.
1. Analogy — Cache local vs nguồn chính
Denormalization giống cache: bạn lưu một bản sao dữ liệu gần hơn với người dùng, để đọc nhanh hơn. Nguồn chính (bảng gốc, normalized) vẫn tồn tại — bản sao chỉ để tăng tốc read. Nhưng mỗi khi nguồn chính thay đổi, bản sao phải được sync — đây là write overhead.
| Cache concept | DB denormalization |
|---|---|
| Cache server (Redis, Memcached) | Column denormalized (project_name trong tasks) |
| Nguồn chính (database gốc) | Bảng normalize gốc (projects.name) |
| Cache miss → fetch từ nguồn | JOIN → fetch từ bảng gốc |
| Cache invalidation khi nguồn thay đổi | Sync column/counter khi parent update |
| Stale cache = dữ liệu cũ được đọc | Stale column = label cũ hiển thị trên UI |
| TTL: chấp nhận lag nhất định | Refresh interval: materialized view lag |
| Cache stampede nếu không có lock | Counter drift nếu sync bỏ sót event |
Denormalize = cache ở tầng schema. Mọi rule của cache đều áp dụng: nhanh hơn khi đọc, đắt hơn khi write, và bạn phải chủ động handle invalidation — DB không làm thay bạn trừ khi bạn setup trigger.
2. Read vs write amplification — tradeoff cơ bản
| Pattern | Read cost | Write cost | Consistency risk |
|---|---|---|---|
| Pure normalize | Cao (multi-JOIN) | Thấp (1 source of truth) | Thấp |
| Cached column | Thấp (no JOIN) | Trung bình (sync khi parent thay đổi) | Trung bình (stale window) |
| Counter table | Thấp (single row) | Cao (mỗi event update counter) | Trung bình |
| Materialized view | Thấp (precomputed) | Schedule cost (refresh) | Cao (refresh lag) |
Decision criteria:
- Read-heavy (analytics dashboard, public-facing): denormalize
- Write-heavy OLTP: normalize
- Real-time consistency bắt buộc (financial, audit): normalize
- Eventual consistency chấp nhận được: denormalize tier
Nguyên tắc thực chiến: bắt đầu normalize hoàn toàn, đo đạc query chậm bằng EXPLAIN ANALYZE, rồi mới denormalize có mục tiêu. Premature denormalization = tech debt không có measurement để justify.
3. Pattern 1 — Cached column (denormalized FK label)
Use case: dashboard cần hiển thị label của parent entity (project name, user display name) kèm mỗi row child — thay vì JOIN mỗi request.
-- Normalize: phai JOIN moi query
SELECT t.id, t.title, p.name AS project_name
FROM tasks t
INNER JOIN projects p ON t.project_id = p.id;
-- Denormalize: them cached column
ALTER TABLE tasks ADD COLUMN project_name TEXT;
UPDATE tasks
SET project_name = (SELECT name FROM projects WHERE id = tasks.project_id);
-- Read: khong JOIN
SELECT id, title, project_name FROM tasks;
Ba cơ chế sync khi projects.name thay đổi:
-- A. Trigger tren projects: automatic, atomic
CREATE OR REPLACE FUNCTION sync_project_name() RETURNS TRIGGER AS $$
BEGIN
UPDATE tasks
SET project_name = NEW.name
WHERE project_id = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_project_name
AFTER UPDATE OF name ON projects
FOR EACH ROW EXECUTE FUNCTION sync_project_name();
-- B. App-level: explicit, de debug, de miss
BEGIN;
UPDATE projects SET name = $1 WHERE id = $2;
UPDATE tasks SET project_name = $1 WHERE project_id = $2;
COMMIT;
-- Nho them: bat ky noi nao update projects.name deu phai sync
-- C. CDC (Change Data Capture): Debezium, Kafka Connect
-- Doc change log cua PG (WAL) -> push event -> consumer update tasks
-- Eventual consistency: co lag, khong atomic voi update goc
-- Scale tot nhat nhung infra phuc tap nhat
Tradeoff mỗi cơ chế sync:
| Cơ chế | Ưu | Nhược |
|---|---|---|
| Trigger | Atomic với parent update, tự động | Overhead mỗi write, logic ẩn khó debug |
| App-level | Explicit, dễ test | Dev phải nhớ sync mọi nơi update parent |
| CDC | Scale tốt, không coupling | Eventual consistency, infra phức tạp |
4. Pattern 2 — Counter table (precomputed COUNT)
Use case: profile page hiển thị "user X có 42 task" — không cần COUNT(*) mỗi request.
ALTER TABLE users ADD COLUMN task_count INT NOT NULL DEFAULT 0;
-- Backfill: tinh lai tu dau
UPDATE users u
SET task_count = (SELECT COUNT(*) FROM tasks WHERE assignee_id = u.id);
Trigger sync khi tasks thay đổi:
CREATE OR REPLACE FUNCTION update_user_task_count() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE users SET task_count = task_count + 1 WHERE id = NEW.assignee_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE users SET task_count = task_count - 1 WHERE id = OLD.assignee_id;
ELSIF TG_OP = 'UPDATE' AND OLD.assignee_id IS DISTINCT FROM NEW.assignee_id THEN
-- assignee thay doi: giam counter assignee cu, tang counter assignee moi
UPDATE users SET task_count = task_count - 1 WHERE id = OLD.assignee_id;
UPDATE users SET task_count = task_count + 1 WHERE id = NEW.assignee_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_user_task_count
AFTER INSERT OR UPDATE OR DELETE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_user_task_count();
Counter drift là rủi ro thực tế: bug trong trigger, bulk import không qua trigger, hoặc direct SQL update bypass trigger — counter lệch so với thực tế. Cần periodic reconciliation:
-- Reconcile: recompute counter tu source of truth
UPDATE users u
SET task_count = (SELECT COUNT(*) FROM tasks WHERE assignee_id = u.id);
-- Chay via cron job (hang dem hoac hang tuan tuy muc do chap nhan drift)
Counter table không tự heal. Nếu một event bị bỏ sót (trigger disable tạm thời để bulk import, bulk DELETE trực tiếp không qua app), counter lệch dần — không có cơ chế auto-detect. Production pattern: chạy reconciliation script định kỳ + alert khi counter lệch vượt ngưỡng so với COUNT(*) thực tế.
Real-world reference: Wikipedia denormalize page view counter, Twitter denormalize follower count, Reddit denormalize karma score — tất cả đều dùng periodic reconciliation để correct drift.
5. Pattern 3 — Materialized view (precomputed aggregate)
Cho complex aggregate cần nhiều bảng và tính toán phức tạp — analytics dashboard chấp nhận staleness.
-- "Moi project: tong task, so task done, completion %"
CREATE MATERIALIZED VIEW mv_project_stats AS
SELECT
p.id AS project_id,
p.name,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE t.status = 'done') AS done,
ROUND(
COUNT(*) FILTER (WHERE t.status = 'done') * 100.0
/ NULLIF(COUNT(*), 0),
2
) AS completion_pct
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name;
-- Index bat buoc de dung REFRESH CONCURRENTLY
CREATE UNIQUE INDEX ON mv_project_stats(project_id);
-- Read: instant, khong JOIN, khong aggregate per request
SELECT * FROM mv_project_stats WHERE project_id = $1;
Refresh schedule:
-- CONCURRENTLY: khong block read trong khi refresh (can unique index)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_project_stats;
-- Khong CONCURRENTLY: block read, nhung khong can unique index
REFRESH MATERIALIZED VIEW mv_project_stats;
-- Chay dinh ky via pg_cron extension (Module 11 cua khoa nay deep dive):
-- SELECT cron.schedule('refresh-project-stats', '*/5 * * * *',
-- 'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_project_stats');
Tradeoff materialized view:
- Freshness: stale theo refresh interval (5 phút, 1 giờ, 1 ngày)
- Cost refresh: linear với data size, không incremental (PG native)
- Storage: tăng theo size aggregate
- CONCURRENTLY: cần unique index, chạy lâu hơn nhưng không block read
Use case phù hợp: analytics dashboard chấp nhận 1 giờ lag, OLAP report. Không phù hợp: OLTP cần data real-time (dùng normalize + index thay thế).
Forward: Module 11 của khoá này đi sâu về materialized view production — pg_cron schedule, monitoring refresh lag, incremental refresh pattern với logical replication.
6. Pitfall — denormalize không có sync
-- BUG: denormalize nhung quen setup sync
ALTER TABLE tasks ADD COLUMN project_name TEXT;
-- Backfill 1 lan duy nhat
UPDATE tasks
SET project_name = (SELECT name FROM projects WHERE id = tasks.project_id);
-- 6 thang sau: project "Alpha" doi ten thanh "Alpha v2"
UPDATE projects SET name = 'Alpha v2' WHERE id = 5;
-- tasks.project_name van la 'Alpha' voi moi task thuoc project 5
-- Dashboard show 'Alpha' trong 6 thang -> silent wrong data
-- Fix options:
-- 1. Them trigger sync ngay khi alter (truoc khi deploy)
-- 2. Chap nhan stale va document ro trong code + README
-- 3. Periodic reconciliation script + monitoring alert
Silent wrong data nguy hiểm hơn error rõ ràng. Nếu denormalize mà không setup sync, column sẽ drift dần mà không có error nào báo — chỉ phát hiện khi user report hoặc audit. Nguyên tắc: không bao giờ để denormalized column mà không có ít nhất một trong ba: trigger sync, reconciliation job định kỳ, hoặc comment code rõ ràng về intentional staleness và refresh policy.
7. Decision matrix — khi nào denormalize
Read frequency
Thấp Cao
Write Thấp | Normalize | Cached column (label ít thay đổi)
freq Cao | Normalize | Counter table + Materialized view
Rules of thumb:
- Read nhiều hơn write khoảng 100 lần và query chậm có bằng chứng đo được? Cân nhắc denormalize.
- Real-time consistency bắt buộc (financial, security, audit)? Normalize, không exception.
- Parent label thay đổi hiếm (project name, category name)? Cached column với trigger OK.
- Event-driven counter (page view, comment count, task count)? Counter table với trigger + reconciliation.
- Aggregate phức tạp nhiều bảng, dashboard analytics chấp nhận lag? Materialized view.
- Chưa đo được query chậm? Normalize trước, đo sau, denormalize khi có evidence.
8. Applied — TaskFlow denormalization candidates
| Candidate | Pattern | Lý do |
|---|---|---|
tasks.project_name | Cached column | Dashboard mỗi task cần project name — JOIN tốn kém khi 10k task/trang |
users.task_count | Counter table | Profile page show "42 task" — COUNT toàn bảng tasks chậm |
users.last_activity_at | Cached column | Sort user by recency — không cần aggregate, cập nhật khi user action |
projects.completion_pct | Materialized view | Analytics dashboard, refresh hourly — phức tạp quá cho trigger |
tasks.comment_count | Counter table | Hiển thị "5 comment" mà không COUNT(*) per task per request |
Best practice áp dụng ngay: bắt đầu với schema fully normalized từ Module 4 bài 3 của khoá này. Khi dashboard TaskFlow chậm, chạy EXPLAIN ANALYZE (Module 7 của khoá này), tìm sequential scan + expensive hash join, rồi chọn đúng pattern denormalize cho bottleneck đó.
9. Deep Dive — Denormalization
- Designing Data-Intensive Applications (Kleppmann) Ch.3 + Ch.11 — Ch.3: storage engine và data model, denormalization qua read-optimized structure. Ch.11: stream processing và CDC — cơ chế sync denormalized data ở scale lớn.
- Martin Fowler — "Denormalization" — short canonical reference cho decision criteria: khi nào denormalize là intentional design, khi nào là anti-pattern.
Ghi chú: DDIA Ch.3 cho foundation lý thuyết (tại sao read-optimized structure tồn tại), Ch.11 cho production sync pattern qua event stream. Fowler cho decision framework nhanh khi đang code review và thấy denormalized column — liệu có intentional hay không.
10. Tóm tắt
- Denormalize = đánh đổi write cost (sync overhead) để lấy read speed (no JOIN, no aggregate).
- 3 pattern: cached column (FK label copy), counter table (precomputed COUNT), materialized view (precomputed aggregate).
- Sync mechanism: trigger (atomic, ẩn), app-level (explicit, dễ miss), CDC (scale tốt, eventual consistency).
- Counter table cần periodic reconciliation tránh drift sau bug hoặc bulk operation bypass trigger.
- Materialized view dùng
REFRESH CONCURRENTLY(cần unique index) để không block read trong khi refresh. - Decision: normalize trước, denormalize có measurement — premature denormalize là tech debt không justify được.
- Forward: Module 5 của khoá này (index trên denormalized column để query nhanh hơn), Module 7 của khoá này (EXPLAIN ANALYZE để tìm bottleneck trước khi denormalize), Module 11 của khoá này (materialized view production với pg_cron).
11. Tự kiểm tra
Q1Vì sao denormalize tăng write cost? Cho ví dụ cụ thể với tasks.project_name cached column trong TaskFlow.▸
Normalize: update projects.name là 1 write duy nhất — source of truth. Không có gì khác cần thay đổi.
Denormalize với tasks.project_name: update projects.name phải kèm thêm update tất cả tasks thuộc project đó. Nếu project có 10.000 task, một operation đổi tên project thành 1 write vào projects cộng 10.000 write vào tasks. Đây là write amplification — một event gốc khuếch đại thành nhiều write.
Write amplification còn tăng khi trigger chạy: mỗi row trong tasks phải được lock + update riêng, tốn thêm I/O và lock contention. Với bảng task lớn, update tên project có thể hold lock đủ lâu để gây timeout cho các write khác đang chờ.
Q2Phân biệt 3 sync mechanism (trigger, app-level, CDC). Khi nào nên chọn cái nào?▸
Trigger: atomic với parent update — trong cùng một transaction, bảo đảm cached column luôn nhất quán sau commit. Nhưng logic ẩn trong DB, khó debug khi có vấn đề, thêm overhead mỗi write kể cả khi không cần sync. Phù hợp khi team nhỏ, schema ổn định, cần strong consistency.
App-level: explicit trong code — dễ test, dễ trace, dễ audit. Nhưng dev phải nhớ sync ở mọi nơi update parent. Nếu có nhiều service hoặc nhiều code path update projects.name, dễ bỏ sót. Phù hợp khi một service duy nhất sở hữu parent entity.
CDC: read WAL của PG, publish event, consumer update denormalized data — scale tốt nhất, không coupling giữa producer và consumer. Nhưng eventual consistency (có lag), infra phức tạp (Debezium + Kafka), và cần handle redelivery. Phù hợp khi scale lớn, nhiều consumer cần react với cùng event, hoặc khi sync span nhiều service.
Q3Counter table drift sau bug. Cách reconcile? Tradeoff giữa precomputed counter và recompute mỗi request?▸
Reconcile bằng cách recompute từ source of truth và overwrite: UPDATE users u SET task_count = (SELECT COUNT(*) FROM tasks WHERE assignee_id = u.id). Chạy định kỳ (cron job hàng đêm) hoặc khi phát hiện drift qua monitoring (so sánh counter với COUNT thực tế, alert khi lệch vượt ngưỡng).
Precomputed counter: read O(1) — một row lookup. Write phức tạp hơn (trigger, reconciliation). Risk: drift khi sync bị bỏ sót. Phù hợp khi read nhiều hơn write rất nhiều, và staleness ngắn chấp nhận được.
Recompute mỗi request: luôn chính xác — không có stale risk. Nhưng tốn query mỗi request, tăng theo data size (COUNT trên bảng lớn chậm). Phù hợp khi write nhiều hoặc khi count thường xuyên thay đổi và accuracy quan trọng hơn speed.
Q4Materialized view REFRESH CONCURRENTLY cần unique index — vì sao? Hậu quả nếu không có unique index?▸
REFRESH CONCURRENTLY hoạt động bằng cách build một bản copy mới của materialized view song song với bản cũ, rồi diff hai bản để biết row nào thay đổi (insert/update/delete). Để diff chính xác, PG cần identify mỗi row duy nhất — đây là lý do cần unique index. Không có unique index, PG không biết row nào trong bản cũ tương ứng với row nào trong bản mới.
Nếu không có unique index: REFRESH CONCURRENTLY sẽ báo lỗi ERROR: cannot refresh materialized view concurrently without a unique index. Chỉ còn cách dùng REFRESH MATERIALIZED VIEW không có CONCURRENTLY — block tất cả read trong thời gian refresh. Trên analytics dashboard có traffic cao, đây là downtime ngắn mỗi lần refresh.
Q5"Premature denormalize" là anti-pattern. Khi nào "measured denormalize" worth?▸
Premature denormalize là anti-pattern vì: thêm sync complexity mà chưa biết có cần thiết không, tạo risk stale data ngay từ đầu, và làm schema khó maintain hơn mà không có evidence performance gain xứng đáng.
Measured denormalize worth khi có đủ 3 điều kiện: (1) đo được query chậm bằng EXPLAIN ANALYZE — có evidence bottleneck thực sự, không phải giả định; (2) bottleneck đến từ JOIN hoặc aggregate có thể loại bỏ bằng denormalize; (3) team chấp nhận và có plan cho sync complexity (trigger, reconciliation, hoặc CDC).
Rule đơn giản: nếu bạn không có benchmark trước/sau thể hiện improvement cụ thể (vd "800ms xuống 50ms ở p95"), denormalization đó chưa "measured" — dừng lại và đo trước.
Bài tiếp theo: Surrogate vs natural key — UUID vs serial vs UUIDv7
Bài này có giúp bạn hiểu bản chất không?