Chương 04 · Data Layer

Database Scaling & Data Patterns

Read replica routing, sharding decision tree, hot partition, CDC, CQRS, Event Sourcing, polyglot persistence, distributed transaction & Saga.

1. Recap & System View

Bạn đã học detail ở Database Ch10–11: replication mechanics, sharding strategy, consistent hashing, caching pattern. Ở đây ta nhìn từ kiến trúc hệ thống: data layer ghép thế nào với app, queue, cache, và các pattern phức tạp như CQRS / Event Sourcing.

1.1. Decision tree: scale data layer

  1. App chậm? → Đo trước. Đa số trường hợp là missing index hoặc N+1, không phải scale issue.
  2. Read-heavy? → Cache (Redis) trước, sau đó read replica.
  3. Write-heavy? → Tăng vertical (NVMe, RAM, CPU). Sau đó cân nhắc sharding.
  4. Specific access pattern (search, time-series, graph)? → Polyglot — bổ sung specialized DB.
  5. Cuối cùng: shard primary DB.

Sharding là bước cuối cùng, không phải bước đầu. Một Postgres tuned tốt có thể xử lý hàng terabyte data + 50k QPS.

2. Read Replica Routing

Pattern: write → primary; read → replica (ngẫu nhiên hoặc gần nhất).

┌──────────────┐ write │ PRIMARY │ ──────► │ (write) │ └──────┬───────┘ │ async WAL stream ┌────────┼────────┐ ▼ ▼ ▼ ┌────────┐┌────────┐┌────────┐ │REPLICA ││REPLICA ││REPLICA │ ← read traffic │ 1 ││ 2 ││ 3 │ └────────┘└────────┘└────────┘ ▲ ▲ ▲ └────────┴────────┘ read (LB or app router)

2.1. Implementation

// Hai connection pool riêng
const writeDb = new Pool({ host: 'primary', max: 50 });
const readDb  = new Pool({ host: 'replica-lb', max: 100 });   // LB to 3 replicas

// Helper
async function readQuery(sql: string, params: any[] = []) {
  return readDb.query(sql, params);
}

async function writeQuery(sql: string, params: any[] = []) {
  return writeDb.query(sql, params);
}

// Code:
const user = await readQuery('SELECT * FROM users WHERE id=$1', [userId]);
await writeQuery('UPDATE users SET name=$2 WHERE id=$1', [userId, name]);

2.2. Read-your-writes problem

User POST update profile → primary OK → response. User redirect → GET profile → replica chưa apply → user thấy data cũ.

2.3. Mitigations

2.3.a. Sticky write (route về primary trong N giây sau write)

async function read(userId: number) {
  // Nếu user vừa write trong 5 giây qua → đọc primary
  const recentWrite = await redis.get(`recent_write:${userId}`);
  if (recentWrite) return writeDb.query(...);
  return readDb.query(...);
}

async function write(userId: number, data: any) {
  await writeDb.query(...);
  await redis.set(`recent_write:${userId}`, '1', 'EX', 5);
}

2.3.b. Replication lag aware

async function readWithFreshness(query: string, maxLag = 100) {
  const lag = await getReplicationLag();   // ms
  if (lag > maxLag) {
    return writeDb.query(query);   // fallback primary
  }
  return readDb.query(query);
}

2.3.c. Synchronous replication

Postgres synchronous_standby_names. Đảm bảo primary chờ replica ack trước khi commit. Latency tăng nhưng RPO=0 và replica luôn fresh.

2.4. Routing strategies

  • Random — đơn giản, đều load.
  • Geographic — replica gần app.
  • Least-loaded — replica ít connection.
  • Read-only DB session — ProxySQL, PgBouncer route theo statement type (SELECT vs INSERT/UPDATE).

3. Sharding Decision Tree

3.1. Khi nào shard?

  • Bảng > 1TB và không thể vertical scale thêm.
  • Write QPS > khả năng 1 node (vài chục k QPS với Postgres tuned).
  • Hot data lớn hơn RAM lớn nhất có thể mua.

Trước đó: index, partition (intra-DB), read replica, cache.

3.2. Chọn shard key

Đây là quyết định khó nhất trong system design. Sai shard key → migration đau đớn.

Tiêu chí:

  1. Distribute đều — không hot shard.
  2. Match access pattern — query thường lookup theo key này.
  3. Stable — key không đổi (vd: user_id).
  4. Co-located queries — JOIN/filter cùng shard key tránh cross-shard query.

3.3. Shard key examples

DomainTốtTệ
Social networkuser_id (tweet, message tự nhiên grouped)created_at (hot shard cuối)
E-commercecustomer_id hoặc tenant_idorder_id tăng dần
Multi-tenant SaaStenant_iduser_id vì cross-tenant query khó
Time-series IoT(device_id, time_bucket) compositetime alone (hot recent)

3.4. Composite shard key

Khi 1 cột không đủ, dùng nhiều cột:

// Range trên customer_id, hash trên order_id trong shard
shardId = customerId % numShards
recordKey = `${customerId}:${orderId}`

Cho phép locality (cùng customer cùng shard) + distribute trong shard.

3.5. Cross-shard query

Vấn đề lớn nhất sau shard. Vd: "top 10 customer chi nhiều nhất tháng này" — phải scan mọi shard.

Cách giải:

  • Scatter-gather — query mọi shard parallel, merge kết quả ở app.
  • Pre-computed aggregate — tính trước ở separate analytics DB.
  • Read model riêng (CQRS — section 6).
  • Avoid — thiết kế lại query để chỉ touch 1 shard.

3.6. Resharding

Đã đề cập Database Ch10. Recap pattern thực tế:

  • Pre-shard ngay từ đầu: 1024 logical shard trên 4 physical node, có thể tăng physical mà không re-shard logical.
  • Online migration: dual-write + backfill + verify + cut-over.
  • Citus, Vitess, MongoDB tự động hóa nhiều bước.

4. Hot Partition / Hot Shard Problem

Vài shard nhận traffic gấp nhiều lần shard khác → bottleneck dù tổng load OK.

4.1. Cause cases

  • Celebrity user — Twitter user 100M follower. Mọi follower fetch tweet → shard chứa user đó hot.
  • Skewed shard key — country='US' chiếm 80% data.
  • Time-based — ghi luôn vào shard "ngày hôm nay".
  • Auto-increment ID — mọi insert đến shard cuối.

4.2. Mitigations

4.2.a. Better shard key

Nếu phát hiện sớm, đổi shard key. Vd: hash thay vì range; composite key thay đơn.

4.2.b. Replicate hot key

Detect celebrity → replicate riêng sang nhiều node, route theo round robin.

4.2.c. Salt key — distribute write

// Counter cho celebrity tweet — split thành N counter
// Thay vì:
INCR tweet:celebrity:likes   // tất cả đụng 1 key

// Dùng N salt:
const shard = userId % 100;
INCR tweet:celebrity:likes:shard:${shard}

// Read: SUM tất cả N counter
const total = await Promise.all(
  Array.from({length: 100}, (_, i) => redis.get(`tweet:celebrity:likes:shard:${i}`))
).then(arr => arr.reduce((s, v) => s + Number(v ?? 0), 0));

4.2.d. Cache hot key

Detect hot key → cache ở mỗi app instance (multi-tier). Giảm load đến shard storage.

4.2.e. Shard splitting

Identify hot shard → split tiếp thành 2-4 sub-shard. DynamoDB, Cassandra tự làm. Postgres phải manual.

4.3. Detect hot shard

Monitor metrics per-shard:

  • QPS / shard.
  • CPU / shard.
  • Disk I/O / shard.

Khi 1 shard > 2× median → đã hot, cần action.

5. CDC — Change Data Capture

Đã đề cập Database Ch11. Đây ta đào sâu architecture pattern.

5.1. CDC là gì?

Streaming mọi thay đổi từ DB primary đến downstream consumer (search, analytics, cache, audit log) real-time.

Postgres (primary) │ │ logical replication / WAL stream ▼ ┌──────────┐ │ Debezium │ ← CDC tool, parse WAL → events └────┬─────┘ │ events ▼ ┌──────────┐ │ Kafka │ ← message broker, fan-out └────┬─────┘ │ ┌────┴───┬────────┬──────────┬──────────┐ ▼ ▼ ▼ ▼ ▼ Elastic Redis Snowflake Audit log Microservice (search) (cache) (analytics) consumer

5.2. Lợi ích pattern này

  • Single source of truth — DB primary; mọi consumer dùng cùng data.
  • Decouple — thêm consumer mới không động source.
  • Eventual consistency — downstream lag vài giây, OK với search/analytics.
  • Reliable — Kafka persist event, consumer fail recover được.

5.3. Tools

  • Debezium — open-source, support Postgres/MySQL/Mongo/Oracle.
  • AWS DMS — managed CDC.
  • Fivetran, Airbyte — SaaS connectors.
  • Postgres logical replication — built-in (publication/subscription).

5.4. Use case patterns

  • Sync DB → search: Postgres → Elastic. Search luôn fresh.
  • Cache invalidation: CDC detect update → invalidate cache key. Tránh "UPDATE → DEL cache" race.
  • Audit log: stream mọi change vào append-only log.
  • Materialized view: maintain read model riêng.
  • Cross-service event: order service publish "order.created" event qua CDC trên outbox table.

5.5. Outbox Pattern

Vấn đề: app cần atomic "save order + publish event". 2 hệ tách (DB + Kafka) → 2-phase commit khó.

Outbox: app save event vào bảng cùng DB transaction → CDC pull bảng đó → push Kafka. Atomic guarantees.

BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (event_type, payload) VALUES ('order.created', '{...}');
COMMIT;

-- Debezium đọc outbox table → publish Kafka
-- Nếu CDC fail, retry — không mất event
-- Nếu DB transaction rollback, outbox cũng rollback — không có event lạc

6. CQRS — Command Query Responsibility Segregation

6.1. Khái niệm

Tách read modelwrite model thành 2 cấu trúc data riêng. Write theo schema chuẩn (3NF, transactional), read theo schema tối ưu cho query (denormalized, indexed cho specific use case).

┌─ COMMAND SIDE ──────────────────────┐ │ │ write │ ┌─────────┐ ┌──────────────┐ │ ────► │ │ API │───►│ Write Model │ │ │ └─────────┘ │ Postgres 3NF │ │ │ └──────┬───────┘ │ │ │ │ └─────────────────────────┼────────────┘ │ CDC / events │ ┌─ QUERY SIDE ────────────┼───────────┐ │ ▼ │ read │ ┌─────────┐ ┌──────────────┐ │ ◄──── │ │ API │◄───│ Read Model │ │ │ └─────────┘ │ Elastic / Mongo │ │ │ denormalized │ │ └────────────────┘ └─────────────────────────────────────┘

6.2. Khi nào dùng CQRS?

  • Read và write workload khác nhau cực rõ — vd: 99% read, 1% write.
  • Read pattern phức tạp, không fit normalized schema.
  • Cần scale read và write độc lập.
  • Reporting/analytics khác hoàn toàn với transactional.

6.3. Khi KHÔNG dùng

  • App CRUD đơn giản — 2 model = 2× complexity không đáng.
  • Team nhỏ.
  • Read và write tương tự nhau.

6.4. Trade-off

  • (+) Read query cực nhanh (denormalized).
  • (+) Scale read/write độc lập.
  • (−) Eventual consistency giữa write → read model.
  • (−) Complexity 2× — code, deploy, test.
  • (−) Read model "out of sync" temporarily — UI có thể stale.

7. Event Sourcing

7.1. Khái niệm

Thay vì lưu state hiện tại, lưu chuỗi event. Current state = replay events.

// ❌ State-based:
account: { id: 1, balance: 1500 }

// ✓ Event-sourced:
events:
  { type: 'AccountOpened', id: 1, time: t0 }
  { type: 'Deposited',     amount: 1000, time: t1 }
  { type: 'Deposited',     amount: 500,  time: t2 }
  { type: 'Withdrawn',     amount: 200,  time: t3 }
  { type: 'Deposited',     amount: 200,  time: t4 }

// State = sum: 1000 + 500 - 200 + 200 = 1500

7.2. Lợi ích

  • Audit log built-in — mọi thay đổi đều ghi.
  • Time travel — replay đến thời điểm bất kỳ.
  • Multiple read model — derive từ event stream.
  • Debug — xem chuỗi event để hiểu state hiện tại.
  • Integration — event là natural unit để publish.

7.3. Trade-off

  • (−) Query state cần replay → phải có snapshot định kỳ.
  • (−) Schema event evolution khó (event là immutable).
  • (−) Event store lớn theo thời gian.
  • (−) Eventual consistency — read model lag.
  • (−) Learning curve cao.

7.4. Khi nào dùng?

  • Audit là yêu cầu pháp lý (banking, healthcare).
  • Workflow phức tạp với nhiều state transition (booking, order pipeline).
  • Cần derive nhiều read model khác nhau.
  • Domain phù hợp event-driven (DDD).

7.5. CQRS + Event Sourcing

Hai pattern thường đi cùng:

  • Write side: lưu event vào event store.
  • Read side: subscribe event, build read model (Postgres view, Elastic, cache).

Nhưng: không bắt buộc. CQRS dùng được không Event Sourcing. Event Sourcing dùng được không CQRS.

8. Polyglot Persistence

"Right tool for right job" — dùng nhiều DB khác nhau cho các phần khác nhau của hệ.

8.1. Ví dụ kiến trúc thực tế (e-commerce)

┌──────────────────────────────────────────────────────┐ │ POLYGLOT PERSISTENCE │ └──────────────────────────────────────────────────────┘ ┌─────────────────┐ ┌─────────────────┐ │ User profile │ │ Catalog (read) │ │ Postgres │ │ MongoDB │ │ (ACID, 3NF) │ │ (flexible doc) │ └─────────────────┘ └─────────────────┘ ┌─────────────────┐ ┌─────────────────┐ │ Search │ │ Recommendations │ │ Elasticsearch │ │ Neo4j (graph) │ │ (full-text) │ │ (similar users) │ └─────────────────┘ └─────────────────┘ ┌─────────────────┐ ┌─────────────────┐ │ Cart, session │ │ Analytics │ │ Redis │ │ ClickHouse │ │ (TTL, fast) │ │ (columnar OLAP) │ └─────────────────┘ └─────────────────┘ ┌─────────────────┐ ┌─────────────────┐ │ Image, video │ │ Time-series │ │ S3 (object) │ │ TimescaleDB │ └─────────────────┘ └─────────────────┘

8.2. Trade-off

  • (+) Mỗi DB tối ưu cho 1 use case → performance tốt.
  • (+) Scale độc lập.
  • (−) Operational complexity — backup, monitor, upgrade nhiều system.
  • (−) Data consistency cross-DB khó.
  • (−) Learning curve cho team.

8.3. Quy tắc thực dụng

  • Bắt đầu với Postgres làm "everything" — JSONB, tsvector, geometry, ltree đủ cho hầu hết case.
  • Thêm Redis khi cần cache/session/queue.
  • Thêm Elasticsearch khi search là core feature, Postgres tsvector không đủ.
  • Thêm Cassandra/ClickHouse cho specific scale (write throughput, OLAP).
  • Mỗi DB mới = 1 production responsibility — chỉ thêm khi profile chỉ rõ cần.

9. Distributed Transactions & Saga Pattern

9.1. Vấn đề

Transaction trong 1 DB = ACID. Cross-DB hoặc cross-service?

Ví dụ booking: trừ tiền (payment service) + giảm inventory (inventory service) + tạo booking (booking service). Nếu 1 fail thì sao?

9.2. Two-Phase Commit (2PC)

Solution cổ điển — coordinator hỏi mọi participant "ready?" → tất cả OK thì commit, không thì rollback.

Vấn đề:

  • Coordinator chết giữa phase 2 → participant kẹt (locked).
  • Latency cao (2 round-trip + lock).
  • Không scale ngang.

Đa số system hiện đại tránh 2PC.

9.3. Saga Pattern

Chuỗi local transaction. Mỗi bước có compensating action để undo nếu sau đó fail.

Booking saga (success): ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Payment │───►│ Inventory │───►│ Booking │ ✓ │ deduct │ │ decrement │ │ create │ └─────────┘ └──────────┘ └──────────┘ Booking saga (fail at booking): ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Payment │───►│ Inventory │───►│ Booking │ ✗ FAIL │ deduct │ │ decrement │ │ create │ └─────────┘ └──────────┘ └─────┬────┘ │ ▼ trigger compensating ┌─────────┐ ┌──────────┐ │ Payment │◄───│ Inventory │ │ refund │ │ increment │ ← undo bằng "compensating" │(undo) │ │ (undo) │ └─────────┘ └──────────┘

9.4. Saga implementation styles

9.4.a. Choreography

Mỗi service publish event sau khi xong, service tiếp theo subscribe và react. Không có coordinator central.

PaymentService → publish "payment.completed"
                       ▼
InventoryService subscribe → decrement → publish "inventory.reserved"
                       ▼
BookingService subscribe → create → publish "booking.created"

Khi fail: service publish "*.failed" event, các service trước subscribe và compensate.

  • (+) Decentralized, loosely coupled.
  • (−) Khó visualize toàn flow; debug khó.

9.4.b. Orchestration

1 orchestrator service điều phối từng bước.

async function bookingSaga(bookingData) {
  const completed = [];
  try {
    await paymentService.charge(bookingData);
    completed.push('payment');

    await inventoryService.reserve(bookingData);
    completed.push('inventory');

    await bookingService.create(bookingData);
    completed.push('booking');

    return { success: true };
  } catch (err) {
    // Compensate ngược lại
    if (completed.includes('inventory')) await inventoryService.release(bookingData);
    if (completed.includes('payment'))   await paymentService.refund(bookingData);
    return { success: false, error: err.message };
  }
}
  • (+) Flow rõ, dễ debug.
  • (−) Orchestrator = central → có thể trở thành SPOF/bottleneck.

Tools: AWS Step Functions, Temporal, Camunda. Hỗ trợ retry, timeout, compensation tự động.

9.5. Idempotency — yêu cầu bắt buộc

Saga compensate có thể chạy lại nhiều lần do retry. Mọi action phải idempotent:

// ❌ Không idempotent
function refund(orderId, amount) {
  account.balance += amount;
}

// ✓ Idempotent qua idempotency key
async function refund(orderId, amount) {
  const exists = await db.query('SELECT 1 FROM refunds WHERE order_id=$1', [orderId]);
  if (exists.rowCount > 0) return;   // đã refund
  await db.query('INSERT INTO refunds (order_id, amount) VALUES ($1, $2)', [orderId, amount]);
  account.balance += amount;
}

10. Bài tập

  1. Thiết kế read replica routing cho app:
    • 3 replica + 1 primary.
    • "Read your writes" cho profile update (5 giây).
    • Background reporting đọc từ replica có lag < 30s.
  2. Cho domain Twitter, đề xuất shard key cho 3 entity: User, Tweet, Follow. Lý giải.
  3. Phát hiện hot shard: shard 5 có QPS 50k, 11 shard khác mỗi cái 5k. Đề xuất 3 cách fix.
  4. Implement Outbox pattern cho service Order: save order + publish event "order.created" atomic. Mock CDC reader.
  5. So sánh CQRS vs đơn giản 1 model với index tốt: cho 2 use case, mỗi cái phân tích trade-off.
  6. Implement Saga orchestration cho booking flow (payment → inventory → booking) với compensation. Test scenario fail ở booking step.
  7. Polyglot persistence: design e-commerce mini cho Twitter clone — chọn DB nào cho user/tweet/timeline/search/notification.

11. Quiz

Quiz cuối Chương 4

Sharding nên là bước:

  • Đầu tiên
  • Cuối cùng — sau khi cache, read replica đã không đủ
  • Mọi project đều cần
  • Chỉ NoSQL có
Postgres tuned tốt: 50k+ QPS, hàng terabyte data. Trước sharding, hãy: (1) index, (2) cache, (3) read replica, (4) partition intra-DB. Sharding cực phức tạp — JOIN cross-shard, distributed tx, resharding pain.

"Read your writes" với async replica:

  • Không có vấn đề
  • Chỉ xảy ra ở MongoDB
  • Sticky session
  • User vừa POST update profile, GET kế tiếp đọc replica chưa apply → thấy data cũ. Sửa: route về primary trong N giây sau write
Phổ biến với multi-replica setup. Mitigation: sticky write (Redis flag "recent_write:userId" với TTL 5s, route về primary nếu có), hoặc lag-aware read (kiểm tra replica lag, fallback primary nếu cao), hoặc sync replication.

Hot shard "celebrity user" trên Twitter, 1 cách fix là:

  • Tăng RAM
  • Đổi DB
  • Replicate hot key sang nhiều node + cache aggressive ở app layer
  • Bỏ user celebrity
User 100M follower → mọi follower fetch tweet đụng cùng shard → bottleneck. Cách: detect celebrity tự động, replicate tweets của họ sang nhiều node, cache aggressive ở app/CDN. Twitter dùng "fan-out on write" để pre-compute timeline.

Outbox pattern giải quyết:

  • Atomic "save data + publish event" qua DB transaction; CDC pull bảng outbox push vào Kafka
  • Caching
  • Sharding
  • Replication
App save data + publish event là 2 hệ tách → 2PC khó. Outbox: insert event vào bảng cùng DB transaction → atomic. CDC reader pull bảng outbox push Kafka. Nếu CDC fail, retry không mất event. Pattern phổ biến trong microservice.

CQRS phù hợp khi:

  • App CRUD đơn giản
  • Read và write workload khác nhau cực rõ; read pattern phức tạp không fit normalized schema
  • Team 2 người
  • Mọi project
CQRS = 2 model riêng cho read và write. Read model denormalized, indexed cho specific query. Phù hợp khi: 99% read 1% write, query phức tạp aggregate, scale read/write riêng. KHÔNG phù hợp app CRUD nhỏ — overhead 2× không đáng.

Event Sourcing nghĩa là:

  • Lưu state hiện tại
  • Pub/Sub
  • Lưu chuỗi event thay state; current state = replay events; có audit log built-in, time travel, multiple read model
  • CDC
Account state = không lưu trực tiếp. Lưu chuỗi events: opened, deposited 1000, withdrawn 200. State = sum. Lợi: audit, time travel, replay. Trade-off: query state cần replay (snapshot định kỳ); event schema evolution khó.

Saga vs 2PC:

  • 2PC nhanh hơn
  • Saga không cần compensation
  • 2PC scale tốt hơn
  • 2PC: lock + atomic commit, nhưng coordinator chết = participant kẹt; latency cao. Saga: chuỗi local tx + compensation; eventual consistency, scale tốt hơn — pattern phổ biến hơn ở microservice
2PC strong consistency nhưng kém HA và latency. Saga sacrifice strong consistency cho availability + scale. Mỗi step là local transaction; fail → compensating action undo. Cần idempotency cho mọi action vì retry.

Polyglot persistence quy tắc thực dụng:

  • Bắt đầu Postgres "everything"; thêm specialized DB khi profile chỉ rõ cần — mỗi DB mới là 1 production responsibility
  • Dùng càng nhiều DB càng tốt
  • Mỗi service 1 DB riêng
  • Tránh polyglot
Postgres làm được 95% — JSONB, tsvector, geometry, time-series qua TimescaleDB. Thêm Redis cho cache/queue, Elastic khi search là core. Mỗi DB mới: backup, monitor, expertise, on-call. Đừng "polyglot for the sake of polyglot".

Hoàn thành Chương 4. Tiếp theo: Chương 5 — Message Queue & Async Communication →