Chương 11 · Caching & Analytics

Caching, Search, OLTP vs OLAP

Cache patterns + invalidation, search inverted index, Elasticsearch vs Postgres tsvector, data warehouse columnar storage, ETL/ELT.

1. Vì sao caching?

Latency hierarchy (đại khái 2026):

L1 cache ~ 1 ns L2 cache ~ 4 ns L3 cache ~ 12 ns RAM ~ 100 ns (1000× chậm hơn L1) SSD random ~ 50 µs (50 000× chậm hơn RAM) SSD sequential ~ 1 µs/MB Network LAN ~ 100 µs (cùng datacenter) Network cross-region ~ 50 ms (500 000× chậm hơn RAM) HDD seek ~ 10 ms (cổ điển)

Đọc Postgres qua mạng đã ~1ms (network + parse + plan + exec). Cùng query cache vào Redis: ~50µs. Cache vào RAM app (in-memory): ~10ns.

Cache giảm latency và giảm tải DB — nếu cache hit 95%, DB chỉ phải xử lý 5% traffic.

1.1. Cache đặt ở đâu?

Browser ──► CDN ──► Reverse Proxy ──► App ──► Redis ──► DB HTTP nginx local cache remote cache cache (LRU map) cache

Mỗi tầng đều có cache: browser HTTP cache (Cache-Control), CDN edge cache, reverse proxy cache (nginx proxy_cache), app in-memory (Map/LRU), distributed cache (Redis/Memcached).

2. Cache Patterns — 4 chiến lược

2.1. Cache-Aside (Lazy Loading)

Phổ biến nhất. App quản lý cache, DB không biết về cache.

READ: 1. App đọc cache 2. Hit → trả ngay 3. Miss → đọc DB → set cache → trả WRITE: 1. Ghi DB 2. Invalidate cache (DEL key) hoặc set lại
async function getUser(id: number) {
  const key = `user:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const user = await db.query('SELECT * FROM users WHERE id=$1', [id]);
  await redis.set(key, JSON.stringify(user), 'EX', 600);  // TTL 10 phút
  return user;
}

async function updateUser(id: number, data: any) {
  await db.query('UPDATE users SET ... WHERE id=$1', [id]);
  await redis.del(`user:${id}`);   // invalidate
}

Ưu: đơn giản, fail-open (cache chết app vẫn chạy). Nhược: data có thể stale (giữa write và invalidate).

2.2. Write-Through

Cache là "front" của DB. App ghi cache, cache ghi DB đồng bộ.

WRITE: 1. App ghi cache 2. Cache ghi DB ngay 3. Trả ack khi DB ghi xong READ: từ cache (luôn fresh nếu hit).

Ưu: read luôn fresh. Nhược: latency write tăng (chờ cả 2 ghi). Cache miss đầu vẫn phải đọc DB.

2.3. Write-Behind (Write-Back)

App ghi cache, cache async flush DB sau.

Ưu: write rất nhanh. Nhược: nguy cơ mất data nếu cache crash trước khi flush. Phức tạp với consistency.

Dùng cho counter, log, analytics — chấp nhận mất 1-vài giây cuối.

2.4. Refresh-Ahead

Trước khi key sắp expire, cache tự refresh từ DB. Người dùng không bao giờ thấy cache miss.

Phức tạp triển khai, ít dùng. Cloudflare và một số CDN làm dạng này.

3. Cache Invalidation — vấn đề khó nhất

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

3.1. Khi nào invalidate?

  • TTL (Time-to-Live) — set TTL khi cache, key tự xóa. Đơn giản nhất, an toàn nhất. Nhược: data có thể stale đến TTL.
  • Event-based — khi UPDATE/DELETE → DEL key. Cần biết tất cả key liên quan.
  • Versioning — key có version: user:5:v123. Update → tăng version → key cũ bị orphan, tự expire qua TTL.

3.2. Cascade invalidation

1 record có thể được cache ở nhiều key/view:

user:5
user:5:profile
home_feed:user:5
search_result:term:"alice"   ← chứa user 5
team:42:members              ← chứa user 5

Update user 5 → phải invalidate cả 5 key. Cách:

  • Set TTL ngắn (chấp nhận stale).
  • Pattern key + scan + del (chậm).
  • Tag system (Redis 7+ hash tags) hoặc Memcached "cache groups".
  • Pub/Sub event → mọi cache subscriber tự invalidate.

3.3. Stale-while-revalidate

Khi cache miss, trả ngay stale value, đồng thời trigger background refresh. User không phải chờ.

async function get(key: string) {
  const cached = await redis.get(key);
  if (cached) {
    const { value, expires_at } = JSON.parse(cached);
    if (Date.now() > expires_at - 30_000) {
      // sắp hết hạn, trigger refresh background:
      void refresh(key);
    }
    return value;
  }
  return await refresh(key);  // miss thật, blocking
}

4. Cache Stampede / Thundering Herd

Khi 1 hot key expire cùng lúc 1000 request đến → 1000 request đồng thời đập vào DB.

t=0: cache key="hot" hết hạn t=0: ┌──── 1000 concurrent requests ────┐ ▼ ▼ ▼ ▼ miss miss miss miss │ │ │ │ ▼ ▼ ▼ ▼ DB query DB query DB query DB query ← DB bị 1000 query đồng thời ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ DB chết!

4.1. Giải pháp

  • Lock / single-flight — chỉ 1 request rebuild cache, các request khác chờ kết quả.
  • Stale-while-revalidate — trả stale, background refresh.
  • Probabilistic early expiration — refresh trước khi expire, theo xác suất tăng dần khi gần expire.
  • TTL jitter — TTL không phải con số cố định mà ± 10% random → tránh expire đồng loạt.
// Single-flight pattern với Redis SETNX:
async function get(key: string) {
  const cached = await redis.get(key);
  if (cached) return cached;

  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
  if (!acquired) {
    await sleep(50);
    return get(key);  // request khác đang rebuild, chờ rồi đọc
  }

  try {
    const value = await fetchFromDb();
    await redis.set(key, value, 'EX', 600);
    return value;
  } finally {
    await redis.del(lockKey);
  }
}

5. Redis làm cache — best practice

5.1. TTL chiến lược

# Hot data, đổi nhanh: 1-5 phút
SET user:5:online 1 EX 300

# Static-ish: 1 giờ
SET product:1 "{...}" EX 3600

# Reference data hiếm đổi: 1 ngày
SET country:VN "Việt Nam" EX 86400

5.2. Eviction policies

Khi Redis hết RAM (vượt maxmemory), eviction policy quyết định xóa key nào:

  • noeviction — báo lỗi khi full (default).
  • allkeys-lru — least recently used trên mọi key. Phổ biến nhất cho cache.
  • allkeys-lfu — least frequently used.
  • volatile-lru — LRU chỉ trên key có TTL.
  • allkeys-random — xóa ngẫu nhiên.
# redis.conf:
maxmemory 4gb
maxmemory-policy allkeys-lru

5.3. Cache key naming

{namespace}:{type}:{id}:{detail}

user:profile:5
user:settings:5
post:42:comments:page:1
search:result:hash(query)
session:abc123

5.4. Pipeline & batch

// ❌ N round-trip:
for (const id of ids) {
  await redis.get(`user:${id}`);
}

// ✓ 1 round-trip với MGET:
const values = await redis.mget(ids.map(id => `user:${id}`));

// ✓ Pipeline cho mixed commands:
const pipe = redis.pipeline();
for (const id of ids) pipe.get(`user:${id}`);
const results = await pipe.exec();

7. Elasticsearch / OpenSearch

Search engine mặc định cho web modern. Built trên Apache Lucene (Java). Distributed bằng cách shard inverted index.

7.1. Cấu trúc cơ bản

  • Index — tương đương "table" — lưu một loại document.
  • Document — JSON.
  • Field — column trong document, có type (text, keyword, date, geo, ...).
  • Mapping — schema cho field (kiểu, analyzer).
  • Shard — index chia thành shard, replicate.

7.2. text vs keyword

{
  "mappings": {
    "properties": {
      "title": { "type": "text" },         // analyze → tokens, dùng full-text search
      "tags":  { "type": "keyword" },      // không analyze, exact match (filter)
      "name":  {
        "type": "text",
        "fields": { "raw": { "type": "keyword" } }   // dual: search + sort
      }
    }
  }
}

7.3. Query DSL

// Index document:
PUT /posts/_doc/1
{ "title": "Postgres database tutorial", "tags": ["sql","db"] }

// Full-text:
POST /posts/_search
{
  "query": {
    "match": { "title": "database" }
  }
}

// Multi-field + filter:
POST /posts/_search
{
  "query": {
    "bool": {
      "must":   [{ "match": { "title": "tutorial" }}],
      "filter": [{ "term":  { "tags": "sql" }}]
    }
  },
  "sort": [{ "_score": "desc" }],
  "from": 0, "size": 20
}

7.4. Khi nào dùng Elasticsearch?

  • Full-text search yêu cầu cao (relevance, ranking, highlighting).
  • Log aggregation (Elastic Stack: Logstash, Kibana).
  • Geo search.
  • Analytics realtime.

Sync với primary DB qua CDC (Debezium đọc Postgres WAL → Kafka → Elastic) hoặc app tự index khi write.

9. OLTP vs OLAP — hai thế giới khác nhau

Tiêu chíOLTPOLAP
Tên đầy đủOnline Transaction ProcessingOnline Analytical Processing
WorkloadRead/Write nhỏ, nhiều, nhanhAggregate trên hàng tỷ dòng
Latency<100msVài giây — vài phút OK
Throughput10k–100k QPS1–100 query/giờ
SchemaNormalized (3NF)Star / Snowflake (denormalized)
StorageRow-orientedColumn-oriented
CompressionTrung bìnhCực cao (10–50×)
UpdateUPDATE in-place phổ biếnAppend-only, ít UPDATE
Concurrent users1000+ end-userVài chục data analyst
DBsPostgres, MySQL, SQL Server, OracleBigQuery, Snowflake, Redshift, ClickHouse, DuckDB
Use caseApp backend, e-commerceBI, dashboard, ML, ad-hoc analysis

9.1. Row vs Column storage

ROW (OLTP): mỗi row lưu liên tục ┌───┬──────────────┬─────┬─────┐ │ 1 │ alice@x.com │ Alice│ 30 │ ← row 1 trên disk ├───┼──────────────┼─────┼─────┤ │ 2 │ bob@x.com │ Bob │ 25 │ ← row 2 └───┴──────────────┴─────┴─────┘ COLUMN (OLAP): mỗi column lưu liên tục email: alice@x.com, bob@x.com, carol@x.com, ... name: Alice, Bob, Carol, ... age: 30, 25, 35, ... Query: "AVG(age)" trong OLAP chỉ đọc cột age, bỏ qua email/name → 50× nhanh hơn. Query: "SELECT * WHERE id=5" trong OLTP chỉ đọc 1 row liên tục.

9.2. Vì sao columnar nén tốt?

Cùng cột → giá trị tương tự (toàn bộ là email format, age từ 18-100, ...). Run-length encoding, dictionary encoding, delta encoding đạt 10-50× compression. Disk đọc ít → query nhanh.

10. Data Warehouse vs Data Lake vs Lakehouse

10.1. Data Warehouse

DB chuyên cho analytics. Schema chặt, ETL trước khi load.

  • BigQuery (GCP) — serverless, columnar, query-as-a-service.
  • Snowflake — multi-cloud, separation of compute and storage.
  • Redshift (AWS) — cluster-based.
  • ClickHouse — open-source, cực nhanh, dùng nội bộ Yandex.
  • DuckDB — embedded OLAP "SQLite cho analytics".

10.2. Data Lake

Lưu raw file (CSV, JSON, Parquet) trên S3/GCS. Query qua Spark, Trino, Athena. Linh hoạt nhưng query không nhanh bằng warehouse.

10.3. Lakehouse

Kết hợp: lưu Parquet trên object storage + thêm metadata layer (Delta Lake, Apache Iceberg, Hudi) để có ACID, schema, time-travel.

Databricks, Snowflake Iceberg là các sản phẩm Lakehouse phổ biến.

11. Star Schema & Snowflake Schema

11.1. Fact table & Dimension table

OLAP schema có 2 loại bảng:

  • Fact table — sự kiện đo được. Mỗi row = 1 sự kiện. Cột = (foreign keys to dimensions, measures).
  • Dimension table — đối tượng mô tả: thời gian, khách hàng, sản phẩm, địa lý.
┌────────────────┐ │ dim_time │ │ date_id (PK) │ │ date, month, │ │ quarter, year │ └────────┬────────┘ │ ┌─────────────────┐ ┌───────▼───────┐ ┌──────────────────┐ │ dim_customer │ │ fact_sales │ │ dim_product │ │ customer_id (PK)├────│ customer_id FK │────┤ product_id (PK) │ │ name, segment, │ │ product_id FK │ │ name, category, │ │ country │ │ store_id FK │ │ brand, price │ └─────────────────┘ │ date_id FK │ └──────────────────┘ │ qty, revenue, │ │ profit │ ┌──────────────────┐ └───────┬───────┘ │ dim_store │ │ │ store_id (PK) │ └────────────┤ name, city, region │ └──────────────────┘

Hình như ngôi sao → "Star schema". Fact ở giữa, dimension xung quanh.

11.2. Star vs Snowflake

  • Star — dimension flat (denormalized). Đơn giản, query nhanh.
  • Snowflake — dimension được normalize tiếp (vd: city là bảng riêng, FK tới dim_geography). Đỡ trùng nhưng query chậm hơn.

Đa số chọn Star — vì OLAP thiên về read, denormalized OK.

11.3. Slowly Changing Dimensions (SCD)

Thuộc tính dimension thay đổi theo thời gian (customer đổi địa chỉ). Cách handle:

  • SCD Type 1 — overwrite, mất history.
  • SCD Type 2 — thêm row mới với valid_from/valid_to. Giữ history đầy đủ.
  • SCD Type 3 — thêm cột "previous_value".

SCD Type 2 phổ biến nhất cho fact-based analytics — báo cáo phải reflect state tại thời điểm sự kiện.

12. ETL vs ELT — đưa data vào warehouse

ETL — Extract, Transform, Load

  1. Extract từ nguồn (Postgres, API, file).
  2. Transform ở staging server (clean, join, aggregate).
  3. Load vào warehouse.

Tools: Airflow, dbt, Talend.

ELT — Extract, Load, Transform

  1. Extract.
  2. Load raw vào warehouse.
  3. Transform trong warehouse (SQL).

Tools: dbt + warehouse SQL. Tận dụng compute warehouse.

ELT hiện đại hơn — warehouse như BigQuery, Snowflake compute mạnh, transform bằng SQL dễ debug, version qua git.

12.1. CDC — Change Data Capture

Streaming thay batch. Đọc Postgres WAL (logical replication) → Kafka → consumer (Elastic, Snowflake, Redshift).

  • Debezium — open-source CDC từ Postgres/MySQL/Oracle.
  • AWS DMS — managed.
  • Fivetran, Airbyte — SaaS connector.

Pattern hiện đại: source DB (Postgres OLTP) → CDC → Kafka → analytics warehouse + search engine + cache. Cùng 1 source-of-truth, nhiều consumer.

13. Bài tập

  1. Implement cache-aside cho endpoint GET /user/:id với Redis. Đo cache hit rate và latency.
  2. Mô phỏng cache stampede: tắt key hot, gửi 1000 request đồng thời. Quan sát DB. Áp dụng single-flight, đo lại.
  3. Tạo bảng posts trong Postgres + tsvector + GIN. Test full-text search vs LIKE '%term%' trên 1M dòng.
  4. Thiết kế star schema cho analytics e-commerce: fact_orders + dim_customer + dim_product + dim_date.
  5. Phân biệt: khi nào dùng Postgres tsvector, khi nào cần Elasticsearch riêng?
  6. So sánh row vs column storage qua ví dụ SELECT AVG(price) FROM products với 100M dòng — tại sao columnar nhanh hơn 50×?

14. Quiz

Quiz cuối Chương 11

Cache-aside pattern:

  • App đọc cache trước; miss → đọc DB → set cache; write → ghi DB → invalidate cache
  • Cache tự ghi DB
  • DB ghi cache
  • Không có cache
Cache-aside (lazy loading) phổ biến nhất — app chủ động quản lý cache. Đơn giản, fail-open. Nhược: data có thể stale giữa lúc DB write và cache invalidate.

Cache stampede xảy ra khi:

  • Cache đầy
  • Network slow
  • DB chết
  • Hot key expire đồng loạt → 1000 request miss đồng thời đập vào DB
Khi key nóng (homepage, top product) hết hạn, mọi request đến cùng lúc đều miss → DB nhận N query đồng thời. Giải: single-flight (chỉ 1 request rebuild), TTL jitter, stale-while-revalidate.

Inverted index lưu:

  • Document → từ chứa trong document
  • User → permissions
  • Từ → list document chứa từ đó (giống index sách)
  • Bảng phân vùng
"Inverted" so với "forward" (doc → words). Lookup từ → doc cực nhanh. Cơ sở của full-text search. Postgres GIN dùng cấu trúc này cho tsvector.

Column-oriented storage tốt cho:

  • SELECT * WHERE id=5
  • SELECT AVG(price), SUM(qty) — chỉ đọc vài cột trên hàng tỷ row
  • UPDATE 1 row
  • INSERT 1 row
Columnar lưu mỗi column liên tục → query aggregate chỉ cần đọc các cột liên quan, bỏ qua cột khác. Compression cao. Tệ cho row-based access (UPDATE/INSERT 1 row).

Star schema:

  • Fact table ở giữa + dimension tables xung quanh
  • Cây phân cấp
  • Bảng nối many-to-many
  • Bảng đệ quy
Fact (sự kiện đo được) + dimension (mô tả). Hình ngôi sao. Đặc trưng OLAP/data warehouse. Snowflake schema = Star + dimension được normalize tiếp.

ELT hiện đại được ưa chuộng hơn ETL vì:

  • ELT mới hơn nên hot
  • ELT đơn giản hơn
  • Warehouse hiện đại (BigQuery/Snowflake) compute mạnh, transform bằng SQL dễ debug + version git
  • ELT bảo mật hơn
ELT load raw vào warehouse trước, transform bằng SQL trong warehouse (dùng dbt). Lợi: SQL dễ debug, version qua git, transform ở compute mạnh. ETL truyền thống dùng staging Python/Java khó maintain hơn.

CDC (Change Data Capture) trong Postgres dùng:

  • Trigger AFTER UPDATE
  • Cron job SELECT WHERE updated_at > last
  • Long polling
  • Logical replication / WAL stream — đọc thay đổi gần real-time qua Debezium hoặc tương tự
CDC tận dụng WAL/binlog. Postgres logical replication tạo "publication" và replication slot, consumer subscribe nhận change real-time. Debezium là implementation phổ biến → Kafka → downstream (Elastic, warehouse).

Khi nào nên dùng Postgres tsvector thay vì Elasticsearch?

  • Khi search là feature core của app (e-commerce, content site)
  • Khi search là feature phụ, cùng DB tránh sync, đủ cho moderate scale
  • Khi data > 10TB
  • Khi cần log aggregation
Postgres tsvector + GIN đủ cho moderate search (post tìm kiếm trong app, comment search). Tránh thêm dependency. Khi search là core (Algolia-level relevance, multi-language, log/metric analytics) → Elastic riêng.

Hoàn thành Chương 11. Tiếp theo: Chương 12 — Backup, Recovery & Online Migrations →