📦 Cache✍️ Khoa📅 19/04/2026☕ 11 phút đọc

Cache: Strategies và Patterns

1. Tổng quan — 5 Cache Patterns

Có 5 pattern cơ bản, khác nhau ở câu hỏi: ai chịu trách nhiệm populate cache và khi nào?

Pattern            Populate by    Read flow         Write flow
─────────────────────────────────────────────────────────────────
Cache-Aside        Application    App checks cache  App writes DB, invalidates cache
Read-Through       Cache layer    Cache fetches DB  App writes DB only
Write-Through      Cache layer    App reads cache   App writes cache → cache writes DB
Write-Behind       Cache layer    App reads cache   App writes cache → async write DB
Write-Around       Application    App reads cache   App writes DB directly, skip cache

2. Cache-Aside (Lazy Loading)

Pattern phổ biến nhất, còn gọi là Lazy Loading. Application tự quản lý cache — DB và cache không biết nhau.

flowchart TB
  subgraph R["READ"]
    C["Client"] --> A["App Server"]
    A --> CR["Cache (Redis)"]
    CR --> H{"HIT / MISS"}
    H -->|HIT| RD["Return data"]
    H -->|MISS| DB["Database (query)"]
    DB --> SET["Cache.SET(key, val, TTL)"]
    SET --> RD
  end

  subgraph W["WRITE"]
    CW["Client"] --> AW["App Server"]
    AW --> DBW["Database.WRITE(data)"]
    AW --> DEL["Cache.DELETE(key)"]
  end
READ:

  Client ──► App Server
                │
                ▼
          Cache (Redis)
                │
         ┌──HIT─┴──MISS──┐
         │                │
         ▼                ▼
      Return          Database
      data            (query)
                          │
                          ▼
                      Cache.SET(key, val, TTL)
                          │
                          ▼
                      Return data

WRITE:

  Client ──► App Server
                │
                ├──► Database.WRITE(data)
                │
                └──► Cache.DELETE(key)   ← invalidate, không ghi vào cache

Go implementation

type UserService struct {
    redis *redis.Client
    db    *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, userID int64) (*User, error) {
    cacheKey := fmt.Sprintf("user:%d", userID)

    // 1. Thử đọc từ cache
    data, err := s.redis.Get(ctx, cacheKey).Bytes()
    if err == nil {
        var user User
        if err := json.Unmarshal(data, &user); err == nil {
            return &user, nil // cache hit
        }
    }

    // 2. Cache miss → đọc từ DB
    user, err := s.queryUserFromDB(ctx, userID)
    if err != nil {
        return nil, err
    }

    // 3. Populate cache (fire-and-forget hoặc với error handling)
    if data, err := json.Marshal(user); err == nil {
        s.redis.Set(ctx, cacheKey, data, 5*time.Minute)
    }

    return user, nil
}

func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
    // 1. Ghi vào DB trước
    if err := s.writeUserToDB(ctx, user); err != nil {
        return err
    }
    // 2. Xóa cache (không ghi vào cache — tránh race condition)
    s.redis.Del(ctx, fmt.Sprintf("user:%d", user.ID))
    return nil
}

Trade-offs

Ưu điểm:
  + Cache chỉ chứa data được request thực sự → ít wasted memory
  + DB và cache độc lập — cache có thể restart mà không mất data
  + Flexible: application quyết định caching logic

Nhược điểm:
  - Cold start: cache trống, 3 requests đầu tiên đều hit DB
  - Race condition: nếu 2 writes xảy ra đồng thời, cache có thể stale
  - Cache miss penalty: user phải chờ lâu hơn cho lần đầu

Race condition ví dụ:
  Thread A: đọc DB → nhận val=1
  Thread B: ghi DB val=2 → DELETE cache
  Thread A: SET cache val=1   ← stale! Cache chứa val cũ

💡 Interview: "Tại sao delete cache thay vì update cache khi write?" — Vì update cache có thể gây race condition (write 1 xảy ra sau write 2 nhưng set cache trước → stale). Delete là safer: worst case là cache miss, không phải stale data.


3. Read-Through

Cache layer đứng giữa app và DB. Khi cache miss, cache tự fetch từ DB, không phải application.

flowchart TB
  subgraph RT["READ"]
    A["App"] --> C["Cache Layer"]
    C --> HM{"HIT / MISS"}
    HM -->|HIT| R["Return data"]
    HM -->|MISS| F["Cache fetches from Database"]
    F --> S["Cache stores + returns data"]
  end

  subgraph WT["WRITE (giống Cache-Aside)"]
    AW["App"] --> DB["Database (trực tiếp)"]
    AW --> INV["Cache invalidated/updated"]
  end
READ:

  App ──► Cache Layer
               │
         ┌─HIT─┴─MISS─┐
         │              │
         ▼              ▼
      Return        Cache fetches
      data          from Database
                        │
                        ▼
                    Cache stores
                    + returns data

WRITE (giống Cache-Aside):
  App ──► Database (trực tiếp)
  Cache ──► invalidated hoặc updated

Khác Cache-Aside ở chỗ nào?

Cache-Aside:
  App code:
    val = redis.GET(key)
    if miss:
        val = db.query(...)
        redis.SET(key, val)

Read-Through:
  App code:
    val = cacheLayer.GET(key)  // một dòng duy nhất
    // cache layer tự lo việc fetch DB nếu miss

Với Go, thường implement bằng wrapper hoặc library như groupcache (từ Brad Fitzpatrick, author của memcached):

// groupcache tự động deduplicate concurrent requests cho cùng key
// (giải quyết thundering herd tự động)
var userCache = groupcache.NewGroup("users", 64<<20, // 64MB
    groupcache.GetterFunc(func(ctx context.Context, key string, dest groupcache.Sink) error {
        userID, _ := strconv.ParseInt(key, 10, 64)
        user, err := db.QueryUser(ctx, userID)
        if err != nil {
            return err
        }
        data, _ := json.Marshal(user)
        return dest.SetBytes(data)
    }),
)

func GetUser(ctx context.Context, userID int64) (*User, error) {
    var data []byte
    err := userCache.Get(ctx, strconv.FormatInt(userID, 10),
        groupcache.AllocatingByteSliceSink(&data))
    if err != nil {
        return nil, err
    }
    var user User
    json.Unmarshal(data, &user)
    return &user, nil
}

Trade-offs

Ưu điểm:
  + Application code gọn hơn (không cần viết cache logic)
  + Cache layer có thể tối ưu (request coalescing, prefetching)
  + Consistent: mọi read đều qua một điểm

Nhược điểm:
  - Coupling: cache layer phải biết cách query DB
  - Khó customize: không dễ implement complex caching logic
  - Thêm một tầng trừu tượng → khó debug

4. Write-Through

Mọi write đều đi qua cache → cache đồng bộ ghi vào DB. Cache và DB luôn nhất quán.

flowchart TB
  subgraph WTT["WRITE"]
    A["App"] --> C["Cache Layer"]
    C --> D["Database"]
    C --> RS["Return success (sau khi cả 2 ghi xong)"]
  end

  subgraph RTT["READ"]
    AR["App"] --> CR["Cache Layer (đã warm)"]
    CR --> HIT["Always HIT (trừ cold start)"]
    HIT --> RD["Return data"]
  end
WRITE:

  App ──► Cache Layer ──► Database
               │
               └──► return success (sau khi cả 2 ghi xong)

READ:
  App ──► Cache Layer (luôn có data, vì write đã populate)
               │
            Always HIT (trừ cold start)
               │
               ▼
           Return data
type WriteThroughCache struct {
    redis *redis.Client
    db    *sql.DB
}

func (c *WriteThroughCache) Set(ctx context.Context, key string, user *User) error {
    // 1. Ghi vào DB trước
    if err := c.db.WriteUser(ctx, user); err != nil {
        return fmt.Errorf("db write failed: %w", err)
    }

    // 2. Ghi vào cache (synchronous)
    data, _ := json.Marshal(user)
    if err := c.redis.Set(ctx, key, data, 24*time.Hour).Err(); err != nil {
        // Cache write failed — không rollback DB, chấp nhận inconsistency tạm thời
        // Background job sẽ reconcile sau
        log.Warnf("cache write failed for key %s: %v", key, err)
    }
    return nil
}

Trade-offs

Ưu điểm:
  + Cache luôn có data mới nhất → ít cache miss
  + Read rất nhanh (cache luôn warm)
  + Đơn giản về consistency

Nhược điểm:
  - Write latency cao hơn (phải chờ cả DB và cache)
  - Cache bị fill với data ít được đọc (write nhiều, read ít)
  - Nếu cache write fail → inconsistency

Use case tốt nhất:
  - Write và Read đều frequent cho cùng data
  - User profile, session data, inventory count

💡 Interview: "Write-through vs Cache-aside khi nào dùng cái nào?" — Write-through khi không muốn cache miss trên read (luôn cần fresh data, e.g. user session). Cache-aside khi write nhiều nhưng chỉ một số keys được đọc lại (e.g. product catalog — chỉ popular products cần cache).


5. Write-Behind (Write-Back)

App ghi vào cache, cache ghi vào DB sau (bất đồng bộ). Write latency thấp nhất, nhưng có risk mất data.

flowchart TB
  subgraph WBW["WRITE"]
    A["App"] --> C["Cache"]
    C --> RS["Return success ngay (~1ms)"]
    C -->|async| D["Database"]
  end

  subgraph WBR["READ"]
    AR["App"] --> CR["Cache (primary)"]
  end
WRITE:

  App ──► Cache ──► return success (ngay lập tức, ~1ms)
               │
               │  (async, sau vài giây)
               ▼
           Database

READ:
  App ──► Cache (luôn có data mới nhất, vì cache là primary)
type WriteBehindCache struct {
    redis    *redis.Client
    db       *sql.DB
    writeQ   chan writeJob
}

type writeJob struct {
    key  string
    data []byte
}

func NewWriteBehindCache(redis *redis.Client, db *sql.DB) *WriteBehindCache {
    c := &WriteBehindCache{
        redis:  redis,
        db:     db,
        writeQ: make(chan writeJob, 10000), // buffered queue
    }
    // Background goroutine flush to DB
    go c.flushWorker()
    return c
}

func (c *WriteBehindCache) Set(ctx context.Context, key string, user *User) error {
    data, _ := json.Marshal(user)
    // Ghi vào cache ngay
    if err := c.redis.Set(ctx, key, data, 1*time.Hour).Err(); err != nil {
        return err
    }
    // Enqueue để ghi DB async
    select {
    case c.writeQ <- writeJob{key: key, data: data}:
    default:
        // Queue full — ghi DB synchronously như fallback
        return c.db.WriteUser(ctx, user)
    }
    return nil
}

func (c *WriteBehindCache) flushWorker() {
    ticker := time.NewTicker(100 * time.Millisecond)
    batch := make([]writeJob, 0, 100)
    for {
        select {
        case job := <-c.writeQ:
            batch = append(batch, job)
            if len(batch) >= 100 {
                c.flushBatch(batch)
                batch = batch[:0]
            }
        case <-ticker.C:
            if len(batch) > 0 {
                c.flushBatch(batch)
                batch = batch[:0]
            }
        }
    }
}

Trade-offs

Ưu điểm:
  + Write latency cực thấp (~1ms thay vì 10-100ms)
  + Batching DB writes → throughput cao hơn
  + Absorb write spikes tốt

Nhược điểm:
  - Risk mất data nếu cache crash trước khi flush DB
  - Complexity cao: phải handle retry, ordering, crash recovery
  - Inconsistency window: DB có thể stale trong vài giây/phút

Use case:
  - Metrics counter (view count, like count) — mất vài count không sao
  - Session activity timestamp
  - Game score updates
  - Logging aggregation

KHÔNG dùng cho:
  - Financial transactions
  - Inventory/stock deduction (overselling!)
  - Bất kỳ thứ gì cần ACID guarantees

6. Write-Around

App ghi thẳng vào DB, bỏ qua cache hoàn toàn. Cache chỉ được populate khi read (lazy).

flowchart TB
  subgraph WAW["WRITE"]
    A["App"] --> D["Database (trực tiếp, bỏ qua cache)"]
  end
  subgraph WAR["READ"]
    AR["App"] --> C["Cache"]
    C --> HM{"HIT / MISS"}
    HM -->|HIT| R["Return data"]
    HM -->|MISS| DB["Database"] --> SET["Cache.SET"] --> R
  end
WRITE:
  App ──► Database (trực tiếp, cache không được update)

READ:
  App ──► Cache ──MISS──► Database ──► Cache.SET ──► Return
               │
            HIT (nếu đã từng đọc)
               │
               ▼
           Return data

Khi nào dùng Write-Around?

Phù hợp khi:
  - Data được ghi một lần, ít khi đọc lại ngay
  - Muốn tránh cache bị "polluted" bởi data ít đọc
  - Write là bulk operation (ETL, batch import)

Ví dụ: Upload log files, batch user import, data migration
  → Không cần cache những records này ngay sau khi ghi
  → Cache sẽ tự populate khi có user query sau

7. So sánh tổng hợp

Pattern          Read latency   Write latency  Consistency   Data loss risk  Complexity
────────────────────────────────────────────────────────────────────────────────────────
Cache-Aside      Fast (hit)     Fast           Eventual      None            Low
                 Slow (miss)
Read-Through     Fast (hit)     Fast           Eventual      None            Medium
                 Slow (miss)
Write-Through    Always fast    Slow           Strong        None            Medium
Write-Behind     Always fast    Very fast      Eventual      HIGH            High
Write-Around     Slow (1st)     Fast           Eventual      None            Low
                 Fast (repeat)

Decision tree

flowchart TD
  Q1{"Cần write latency thấp nhất?"}
  Q1 -->|YES| WB["Write-Behind (chấp nhận data loss risk)"]
  Q1 -->|NO| Q2{"Cache cần luôn warm?"}
  Q2 -->|YES| WT["Write-Through"]
  Q2 -->|NO| Q3{"Write nhiều, ít key đọc lại?"}
  Q3 -->|YES| WA["Write-Around"]
  Q3 -->|NO| CA["Default: Cache-Aside"]
Cần write latency thấp nhất có thể?
    YES → Write-Behind (nếu chấp nhận data loss risk)
    NO  ↓

Cache cần luôn warm (không muốn cache miss)?
    YES → Write-Through
    NO  ↓

Data được write nhiều nhưng chỉ ít key được đọc lại?
    YES → Write-Around (tránh pollute cache)
    NO  ↓

Default: Cache-Aside (đơn giản, linh hoạt, phổ biến nhất)

💡 Interview: "Hệ thống của bạn đang dùng pattern nào và tại sao?" — Hầu hết production systems dùng Cache-Aside vì đơn giản và flexible. Write-Through cho session data. Write-Behind cho analytics counters. Biết trade-off là điều quan trọng hơn biết tên pattern.


8. Một số lỗi phổ biến

1. Cache everything blindly
   Vấn đề: cache fill với data ít dùng, evict data quan trọng
   Fix: chỉ cache hot data, monitor hit rate theo key pattern

2. TTL quá dài
   Vấn đề: user thấy stale data lâu sau khi data đổi
   Fix: kết hợp TTL hợp lý + event-based invalidation

3. TTL quá ngắn
   Vấn đề: cache miss rate cao, DB bị hammer
   Fix: analyze read/write ratio, set TTL >= average time between writes

4. Cache key không đủ specific
   Bug kinh điển:
     GET /users?page=1 → cache key "users"
     GET /users?page=2 → cache key "users" → trả về page 1!
   Fix: include tất cả query params vào cache key
     key = "users:page=1:limit=20:sort=created_at"

5. Không có cache stampede protection
   Vấn đề: 10,000 concurrent requests, cache expire → tất cả hit DB
   Fix: xem file invalidation-and-consistency.md