Điều kiện tiên quyết: Đây là Phần 3 của Khóa Học System Design. Hãy đọc Phần 2: Cân Bằng Tải L4/L7 để nắm vững các khái niệm định tuyến tầng lưu lượng trước khi đi sâu vào hệ thống caching.

Answer-first: Trong các hệ thống phân tán AI-Native quy mô lớn năm 2026, việc lựa chọn chiến lược caching tối ưu phụ thuộc vào hai yếu tố: khoảng thời gian chấp nhận độ lệch dữ liệu (consistency window) và đặc tính truy cập đọc/ghi (read/write access pattern) của dữ liệu. Chiến lược Write-Through phù hợp cho các dữ liệu giao dịch tài chính yêu cầu nhất quán cao; Write-Behind tối ưu cho các luồng ghi lưu lượng lớn như đếm lượt xem hay phân tích dữ liệu; trong khi Cache-Aside là mô hình mặc định được sử dụng phổ biến nhất cho các dịch vụ ứng dụng đọc nhiều (read-heavy APIs).


Hội Chứng Cache Stampede (Thundering Herd) Và Giải Pháp

Answer-first: Sự cố Cache Stampede (hoặc Thundering Herd) xảy ra khi một cache key có lưu lượng truy cập lớn bị hết hạn (expired). Đồng thời, hàng loạt goroutines gặp phải tình trạng cache miss và đồng thời gửi truy vấn tới cơ sở dữ liệu. Lưu lượng hàng ngàn truy vấn trùng lặp trong thời gian ngắn gây quá tải bể kết nối (connection pool) và có thể dẫn tới sự cố sụp đổ dây chuyền (cascading failure).

Cơ Chế Phát Sinh Sự Cố

sequenceDiagram
    participant G1 as Goroutine 1
    participant G2 as Goroutine 2
    participant G3 as Goroutine 3
    participant Cache as Redis Cache
    participant DB as PostgreSQL

    G1->>Cache: Đọc product:123 (GET)
    Cache-->>G1: MISS (Không có dữ liệu)
    G2->>Cache: Đọc product:123
    Cache-->>G2: MISS
    G3->>Cache: Đọc product:123
    Cache-->>G3: MISS

    Note over G1,DB: ❌ Cả 3 goroutines đồng thời truy vấn cơ sở dữ liệu
    G1->>DB: SELECT * FROM products WHERE id=123
    G2->>DB: SELECT * FROM products WHERE id=123
    G3->>DB: SELECT * FROM products WHERE id=123
    Note over DB: Cơ sở dữ liệu chịu tải N câu truy vấn trùng lặp cùng lúc

Trong các sự kiện có lượng truy cập tăng vọt, chỉ cần một khóa cache bị quá hạn cũng có thể kích hoạt hàng ngàn truy vấn song song xuống cơ sở dữ liệu, khiến tài nguyên DB bị cạn kiệt.

Giải Pháp 1: Chống Trùng Lặp Truy Vấn Với Singleflight

Thư viện golang.org/x/sync/singleflight đảm bảo rằng với cùng một cache key, chỉ có duy nhất một hàm thực thi truy vấn tới cơ sở dữ liệu; các goroutines khác gọi cùng key sẽ tạm dừng và sử dụng chung kết quả trả về:

package cache

import (
    "context"
    "errors"
    "fmt"
    "time"

    "golang.org/x/sync/singleflight"
)

type ProductCacheService struct {
    sfGroup singleflight.Group
    store   map[string]string // Trong môi trường production, thay thế bằng Redis client
}

// GetOrFetch ngăn chặn truy vấn DB trùng lặp bằng singleflight kết hợp cơ chế xử lý timeout
func (c *ProductCacheService) GetOrFetch(
    ctx context.Context,
    productID string,
    timeout time.Duration,
    fetchDB func() (string, error),
) (string, error) {
    cacheKey := fmt.Sprintf("product:%s", productID)

    // Trường hợp Cache Hit
    if val, ok := c.store[cacheKey]; ok {
        return val, nil
    }

    // Trường hợp Cache Miss: gộp các truy vấn trùng lặp bằng singleflight
    resultChan := c.sfGroup.DoChan(cacheKey, func() (interface{}, error) {
        return fetchDB()
    })

    // Xử lý timeout và context cancellation
    select {
    case <-ctx.Done():
        return "", fmt.Errorf("context bị hủy (context cancelled): %w", ctx.Err())
    case <-time.After(timeout):
        return "", errors.New("truy vấn database vượt quá thời gian cho phép (timeout exceeded)")
    case result := <-resultChan:
        if result.Err != nil {
            return "", result.Err
        }
        data := result.Val.(string)
        c.store[cacheKey] = data
        // result.Shared == true cho biết kết quả này được chia sẻ từ một goroutine khác
        return data, nil
    }
}

[!NOTE] Thuộc tính result.Shared = true xác định câu truy vấn hiện tại đã tái sử dụng kết quả từ một tiến trình thực thi khác. Giá trị này có thể trích xuất để theo dõi chỉ số hiệu quả gộp truy vấn (deduplication metrics).

[!WARNING] Giới hạn của Singleflight: Cơ chế chống trùng lặp của singleflight chỉ có hiệu lực trong phạm vi một tiến trình (in-process). Khi triển khai trên cụm gồm nhiều replicas, từng Pod vẫn gửi các câu truy vấn độc lập tới DB. Cần kết hợp với khóa phân tán Redis SETNX hoặc thuật toán XFetch để bảo vệ trên toàn bộ cụm.


So Sánh Write-Through và Write-Behind

Answer-first: Mô hình Write-Through thực hiện ghi dữ liệu đồng thời vào cả Cache và Database trong cùng một thao tác — đảm bảo tính nhất quán mạnh (strong consistency) nhưng chi phí thời gian ghi (write latency) cao hơn. Mô hình Write-Behind thực hiện ghi trước vào Cache và cập nhật bất đồng bộ (asynchronously) xuống Database sau — tối ưu độ trễ thao tác ghi, nhưng chấp nhận rủi ro mất dữ liệu (data loss risk) nếu máy chủ Cache gặp sự cố trước khi dữ liệu được đồng bộ thành công.

So Sánh 5 Pattern Caching Bổ Biến

PatternLuồng Ghi (Write Flow)Luồng Đọc (Read Flow)Tính Nhất QuánĐộ Trễ GhiRủi Ro Mất Dữ LiệuThích Hợp Cho
Cache-AsideApp → Ghi trực tiếp DBApp → Cache → Truy vấn DB nếu MissNhất quán chung cuộcThấpThấpMặc định — Dịch vụ ứng dụng đọc nhiều (Read-heavy)
Read-ThroughApp → Ghi trực tiếp DBCache tự động nạp dữ liệu khi MissNhất quán chung cuộcThấpThấpTích hợp trong ORM / Caching Library
Write-ThroughApp → Cache → DB (Ghi đồng bộ)App → Đọc từ CacheNhất quán mạnhCaoRất thấpGiao dịch tài chính, hóa đơn, số dư
Write-BehindApp → Cache → DB (Ghi bất đồng bộ)App → Đọc từ CacheNhất quán chung cuộcRất thấpCaoĐếm lượt xem, log sự kiện, analytics
Write-AroundApp → Ghi DB (Bỏ qua Cache)App → Cache → DBNhất quán mạnhThấpThấpDữ liệu ghi một lần ít khi đọc lại

Triển Khai Pattern Cache-Aside

package cache

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type Product struct {
    ID    string  `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

type ProductRepository struct {
    rdb *redis.Client
    db  interface{ QueryProduct(id string) (*Product, error) }
    ttl time.Duration
}

// GetProduct — Thực thi quy trình Cache-Aside
func (r *ProductRepository) GetProduct(ctx context.Context, id string) (*Product, error) {
    key := fmt.Sprintf("product:%s", id)

    // 1. Kiểm tra dữ liệu trong Cache
    cached, err := r.rdb.Get(ctx, key).Result()
    if err == nil {
        var product Product
        if unmarshalErr := json.Unmarshal([]byte(cached), &product); unmarshalErr == nil {
            return &product, nil // Cache HIT
        }
    }

    // 2. Cache MISS — Truy vấn dữ liệu từ cơ sở dữ liệu
    product, err := r.db.QueryProduct(id)
    if err != nil {
        return nil, fmt.Errorf("lỗi truy vấn cơ sở dữ liệu: %w", err)
    }

    // 3. Cập nhật dữ liệu vào Cache kèm thời gian sống (TTL)
    data, _ := json.Marshal(product)
    r.rdb.Set(ctx, key, data, r.ttl)

    return product, nil
}

// UpdateProduct — Xóa cache key khi cập nhật dữ liệu (Invalidation)
func (r *ProductRepository) UpdateProduct(ctx context.Context, product *Product) error {
    key := fmt.Sprintf("product:%s", product.ID)

    if err := r.db.(interface{ UpdateProduct(p *Product) error }).UpdateProduct(product); err != nil {
        return fmt.Errorf("lỗi ghi vào cơ sở dữ liệu: %w", err)
    }

    // Xóa khóa cache thay vì ghi đè giá trị mới để tránh xung đột dữ liệu (Write race condition)
    r.rdb.Del(ctx, key)
    return nil
}

[!TIP] Chiến lược Invalidate thay vì Update: Sau thao tác ghi cơ sở dữ liệu thành công, ứng dụng nên xóa (Delete) khóa cache tương ứng thay vì ghi đè giá trị mới. Việc cập nhật trực tiếp có nguy cơ gây bất đồng bộ dữ liệu giữa hai hệ thống nếu tiến trình ghi cache gặp sự cố. Việc xóa khóa giúp đảm bảo tính an toàn — trường hợp xấu nhất chỉ dẫn tới một sự cố cache miss ở lần đọc tiếp theo.


Cơ Chế Hoạt Động Của Redis LRU và LFU

Answer-first: Redis không áp dụng thuật toán LRU hay LFU hoàn chỉnh theo lý thuyết mà triển khai mô hình ước lượng xác suất (probabilistic approximations) dựa trên cơ chế lấy mẫu ngẫu nhiên (sampling) để đạt hiệu năng giải phóng bộ nhớ O(1). Thuật toán LRU phù hợp với các truy vấn dựa trên thời gian truy cập gần nhất (recency); trong khi LFU bảo vệ các khóa có tần suất truy cập cao (hot keys) không bị xóa nhầm trong các đợt biến động lưu lượng.

Thuật Toán Redis LRU (Probabilistic Sampling)

Redis loại bỏ chi phí quản lý danh sách liên kết đôi (doubly-linked list) của thuật toán LRU chuẩn. Thay vào đó, khi bộ nhớ chạm ngưỡng giới hạn (eviction triggered):

  1. Hệ thống lấy mẫu ngẫu nhiên $N$ khóa (mặc định maxmemory-samples = 5).
  2. Tiến hành xóa khóa có thời gian không truy cập (idle time) lớn nhất trong số các khóa được lấy mẫu.
  3. Chỉ số idle time được lưu trữ trong trường Redis Object clock (24-bit, độ phân giải 1 giây).

Tăng tham số maxmemory-samples lên 10–20 giúp tăng độ chính xác của thuật toán LRU nhưng sẽ làm tăng chi phí xử lý CPU theo tỉ lệ tuyến tính.

Thuật Toán Redis LFU (Morris Counter)

Redis LFU ứng dụng thuật toán Morris Counter để ước lượng xác suất tần suất truy cập, giúp tiết kiệm dung lượng bộ nhớ:

$$P_{\text{increment}} = \frac{1}{(\text{bộ_đếm} - \text{LFU_INIT_VAL}) \times \text{lfu_log_factor} + 1}$$

  • Giá trị bộ đếm tăng theo xác suất giảm dần dựa trên thang đo logarit (logarithmic scaling).
  • Giá trị bộ đếm tự động giảm theo thời gian dựa trên tham số lfu-decay-time.
# redis.conf — Cấu hình LFU tối ưu cho hệ thống
maxmemory-policy allkeys-lfu    # Áp dụng cơ chế LFU cho toàn bộ các khóa
lfu-log-factor 10               # Hệ số logarit phân định tần suất truy cập
lfu-decay-time 1                # Thời gian giảm chỉ số bộ đếm (tính theo phút không truy cập)
Tiêu Chí So SánhLRULFU
Căn cứ giải phóng bộ nhớThời gian truy cập gần nhất (Recency)Tần suất truy cập (Frequency)
Bảo vệ Hot KeyCó nguy cơ bị xóa nếu tạm thời không truy cậpBảo vệ tốt nhờ chỉ số tần suất cao
Xử lý khóa mới thêmĐược ưu tiên giữ lại do có recency caoCó nguy cơ bị xóa sớm nếu chỉ số tần suất bằng 0
Kịch bản tối ưuLuồng dữ liệu tổng quát (General-purpose)Luồng dữ liệu có Hot Key rõ rệt (Flash Sale)
Cấu hình Redisallkeys-lruallkeys-lfu

Thuật Toán XFetch: Probabilistic Early Expiration

Answer-first: XFetch loại bỏ nguy cơ Cache Stampede bằng cách chủ động nạp lại dữ liệu trong tiến trình chạy ngầm (background pre-computation) trước khi cache key thực sự quá hạn. Thuật toán sử dụng hàm xác suất dựa trên thời gian sống còn lại (TTL) và chi phí truy vấn cơ sở dữ liệu để tính toán thời điểm nạp lại dữ liệu mà không cần sử dụng cơ chế khóa (locking mechanism).

Công Thức Toán Học XFetch

$$\text{ShouldRefresh} = \left[\text{currentTime} - \left(\beta \times \delta \times \ln(\text{rand}())\right) > \text{expiryTime}\right]$$

Trong đó:

  • $\beta$: Hằng số điều chỉnh tỷ lệ (Scale constant, mặc định $\beta = 1.0$) — giá trị càng lớn thì dữ liệu càng được chủ động nạp lại sớm.
  • $\delta$: Thời gian thực thi truy vấn cơ sở dữ liệu ở lần gần nhất (tính theo milisecond).
  • $\ln(\text{rand}())$: Giá trị ngẫu nhiên tạo ra biến ngẫu nhiên theo phân phối xác suất.

Khi thời gian sống TTL còn nhiều (currentTime ≪ expiryTime), điều kiện trả về false. Khi TTL tiến dần về 0, xác suất điều kiện trả về true tăng dần.

package cache

import (
    "math"
    "math/rand"
    "time"
)

type XFetchEntry struct {
    Value      string
    ExpiryTime time.Time
    Delta      time.Duration // Thời gian thực thi truy vấn DB gần nhất
    Beta       float64       // Giá trị mặc định 1.0
}

// ShouldRefresh xác định thời điểm cần chủ động làm mới cache
func (xf *XFetchEntry) ShouldRefresh() bool {
    if xf.Value == "" {
        return true // Chưa có dữ liệu — thực hiện truy vấn mới
    }

    randVal := rand.Float64()
    if randVal <= 0 {
        randVal = 1e-9 // Tránh lỗi ln(0)
    }

    deltaMs := float64(xf.Delta.Milliseconds())
    adjustedNow := time.Now().Add(
        time.Duration(-xf.Beta*deltaMs*math.Log(randVal)) * time.Millisecond,
    )

    return adjustedNow.After(xf.ExpiryTime)
}

// GetOrRefresh quyết định làm mới cache theo XFetch
func GetOrRefresh(entry *XFetchEntry, fetchFn func() (string, time.Duration, error)) (string, error) {
    if !entry.ShouldRefresh() {
        return entry.Value, nil // Dữ liệu cache vẫn còn hiệu lực
    }

    start := time.Now()
    value, ttl, err := fetchFn()
    if err != nil {
        if entry.Value != "" {
            return entry.Value, nil // Phản hồi dữ liệu cũ khi gặp lỗi (Serve stale on error)
        }
        return "", err
    }

    entry.Value = value
    entry.ExpiryTime = time.Now().Add(ttl)
    entry.Delta = time.Since(start) // Lưu thời gian thực thi để sử dụng cho lần tính toán tiếp theo
    return value, nil
}

Kiến Trúc Multi-Tiered Cache (Local Cache -> Redis -> Database)

Đối với các ứng dụng thương mại điện tử lớn, một máy chủ Redis đơn lẻ có thể trở thành điểm nghẽn về lưu lượng khi phải chịu tải hàng triệu request/giây tới cùng một khóa dữ liệu (Hot Key). Giải pháp cho vấn đề này là xây dựng kiến trúc Multi-Tiered Cache kết hợp giữa bộ nhớ cục bộ (Local In-Process Cache) và bộ nhớ tập trung (Redis Cluster).

[!TIP] Khi phân bổ dữ liệu trên cụm Redis Shards, thuật toán định tuyến giữ chức năng chính. Việc sử dụng phép chia lấy dư Modulo (key_hash % shard_count) gây ra sự cố di chuyển dữ liệu lớn (key migrations) khi thay đổi số lượng shard. Đối với môi trường production, thuật toán Băm Nhất Quán (Consistent Hashing) là giải pháp tối ưu giúp giảm thiểu việc tính toán lại vị trí dữ liệu — xem chi tiết tại Phần 9: Băm Nhất Quán & Các Nốt Ảo (Consistent Hashing & Virtual Nodes).

package cache

import (
    "sync"
    "time"
)

type localEntry struct {
    value     string
    expiresAt time.Time
}

// TieredCache triển khai mô hình 3 tầng: L1 (Local Cache sync.Map) -> L2 (Redis) -> L3 (Database)
type TieredCache struct {
    localCache sync.Map
    localTTL   time.Duration // TTL ngắn cho Local Cache (1–5 giây)
}

func (t *TieredCache) Get(key string) (string, bool) {
    if raw, ok := t.localCache.Load(key); ok {
        entry := raw.(*localEntry)
        if time.Now().Before(entry.expiresAt) {
            return entry.value, true
        }
        t.localCache.Delete(key)
    }
    return "", false
}

func (t *TieredCache) Set(key, value string) {
    t.localCache.Store(key, &localEntry{
        value:     value,
        expiresAt: time.Now().Add(t.localTTL),
    })
}

🔥 [Case Study Production: Bảo Vệ Hệ Thống Trước Thundering Herd] Thách thức: Trong các sự kiện Flash Sale cao điểm, hàng trăm ngàn lượt truy cập đồng thời gửi tới cùng một sản phẩm (product:flash-item-999). Tình trạng hết hạn cache key gây áp lực cực lớn lên hệ thống. Nguyên nhân: Cụm Redis chịu tải quá mức do phải tiếp nhận toàn bộ lưu lượng truy vấn trên cùng một khóa dữ liệu trong thời gian ngắn. Giải pháp: Tích hợp bộ nhớ cục bộ (sync.Map) ngay trên từng ứng dụng Pod với thời gian sống TTL ngắn (1 giây). Nhờ đó, mỗi Pod chỉ gửi tối đa 1 truy vấn/giây tới Redis để cập nhật dữ liệu. Kết quả: Giảm 99.98% lưu lượng truy vấn trùng lặp tới Redis và ngăn chặn hoàn toàn nguy cơ quá tải cơ sở dữ liệu.


Câu Hỏi Thường Gặp (FAQ)

Sự cố Cache Stampede là gì và giải quyết như thế nào?

Cache Stampede xảy ra khi một khóa cache phổ biến hết hạn, khiến hàng ngàn tiến trình đồng thời gặp sự cố cache miss và đồng thời truy vấn cơ sở dữ liệu. Các giải pháp chính gồm: (1) Gộp truy vấn (Singleflight) — nạp lại dữ liệu một lần duy nhất trong phạm vi tiến trình; (2) Làm mới chủ động (XFetch) — tính toán xác suất để làm mới dữ liệu trước khi khóa hết hạn; (3) Khóa phân tán (Redis SETNX lock) — đảm bảo chỉ một tiến trình được phép truy vấn DB trên toàn hệ thống.

Nên chọn Write-Through hay Write-Behind?

Write-Through: Ghi dữ liệu đồng thời vào cả Cache và Database. Ưu điểm là đảm bảo an toàn dữ liệu tuyệt đối (không mất dữ liệu), nhược điểm là độ trễ thao tác ghi phụ thuộc vào tốc độ ghi DB. Phù hợp cho dữ liệu tài chính, số dư và hóa đơn.

Write-Behind: Ghi vào Cache trước và cập nhật bất đồng bộ xuống DB sau. Ưu điểm là thời gian phản hồi cực nhanh, nhược điểm là có rủi ro mất dữ liệu nếu máy chủ Cache gặp sự cố trước khi hoàn tất đồng bộ. Phù hợp cho dữ liệu thống kê, lượt xem và log sự kiện.

Khi nào nên sử dụng Redis LRU và LFU?

Sử dụng LFU (allkeys-lfu) khi dữ liệu có sự chênh lệch lớn về tần suất truy cập (như các sản phẩm Hot Key trong Flash Sale) để bảo vệ các khóa phổ biến không bị giải phóng khỏi bộ nhớ. Sử dụng LRU (allkeys-lru) cho các kịch bản lưu trữ tổng quát với mẫu truy cập đồng đều hơn.


🔗 Bay Sang Bài Tới: Phần 4: Mở Rộng Cơ Sở Dữ Liệu & Rèn Giũa Tối Ưu Bể Kết Nối (Connection Pool) Trong Go — Phanh Phui Ngõ Phân Khúc Bãi Rác PostgreSQL (Range Partitioning), Chiêu Thức Nhào Lặn 2PC Percolator TiDB, và đòn gõ mỏ chắp vá mài dũa database/sql pool.