8 min read

Khi nào nên dùng cache (và khi nào không)

Khi nào nên dùng cache (và khi nào không)
Photo by Alexander Sinn / Unsplash

Mở bài

Cache giống như bình giữ nhiệt của quán cà phê: pha sẵn một mẻ bán chạy để phục vụ nhanh hơn. Nếu chọn đúng món và biết cách thay bình, khách nhận được ly cà phê ngay lập tức, quầy bar nhẹ nhàng. Nếu chọn sai, bạn giữ nóng thứ ít người gọi, tốn chỗ và còn dễ hết hạn. Với hệ thống phần mềm, cache cũng vậy: đúng nơi, đúng lúc, đúng chiến lược.

Bài này chia sẻ cách tôi quyết định khi nào nên dùng cache, khi nào không; các mẫu triển khai thực dụng; và những rủi ro thường gặp như cache stampede, lệch dữ liệu, nóng key. Ví dụ sẽ bám vào bối cảnh quen thuộc: app đặt đồ uống và hệ thống review.

Khi nào nên dùng cache

Bạn có thể mạnh dạn cân nhắc cache khi thỏa vài điều kiện sau.

1) Đọc nhiều hơn ghi, dữ liệu tương đối ổn định

Menu, cấu hình cửa hàng, tổng quan rating, bảng quy đổi, danh sách top bán chạy. Những dữ liệu này không đổi liên tục, trong khi người dùng đọc rất nhiều. Tỷ lệ đọc/ghi cao là dấu hiệu tốt.

2) Tồn tại điểm nóng và đường đi tốn kém

Có những API chậm vì truy vấn phức tạp, join nhiều bảng, gọi sang dịch vụ bên ngoài, hoặc tính toán tốn CPU. Nếu một phần nhỏ traffic lặp đi lặp lại cùng tham số, cache có ích rõ ràng.

3) Dung sai cho dữ liệu cũ trong một khung thời gian

Bạn chấp nhận rating tổng lệch vài giây đến vài phút sau khi có review mới; menu đổi giá không theo từng mili-giây. Nếu SLA cho phép độ trễ đồng bộ hóa, cache giúp giảm tải mạnh.

4) Lợi ích > chi phí

Lợi ích là độ trễ giảm và tài nguyên gốc được cứu; chi phí gồm bộ nhớ, vận hành Redis/Memcached, độ phức tạp invalidation. Nếu phần trăm hit * chênh lệch độ trễ * lưu lượng đủ lớn, bạn có lý do dùng cache.

Khi nào không nên dùng cache

1) Dữ liệu biến động liên tục, cần tính nhất quán mạnh

Số dư ví, tồn kho theo thời gian thực, trạng thái giao dịch. Bất kỳ sai lệch nào cũng nguy hiểm. Ở đây ưu tiên luồng ghi đúng, sau đó mới tính chuyện nhân bản hoặc mô hình khác.

2) Cardinality quá cao hoặc key khó đoán

Truy vấn có tham số cực đa dạng (ví dụ search full-text tự do) tạo ra vô số key, hit rate thấp và bộ nhớ phình to. Hãy tối ưu truy vấn hoặc dùng index phù hợp trước.

3) Upstream đã rất nhanh

Nếu DB trả về trong 1–2 ms và QPS thấp, thêm cache chỉ tăng độ phức tạp. Tập trung vào connection pooling, chuẩn hóa truy vấn và quan sát.

4) Không có chiến lược invalidation rõ ràng

Dùng cache mà không biết khi nào làm mới, ai chịu trách nhiệm xóa/ghi đè, thì sớm muộn cũng sinh lỗi khó truy vết. Khi team chưa thống nhất quy ước, hoãn lại.

Các mẫu cache thực dụng

Cache-aside (write-around)

Ứng dụng đọc: nếu miss → lấy từ nguồn → ghi vào cache → trả về. Ứng dụng ghi trực tiếp vào nguồn; cache để tự làm mới hoặc xóa key liên quan. Ưu điểm: đơn giản, linh hoạt. Nhược điểm: miss đầu tiên vẫn chậm.

// ví dụ Go + Redis (cache-aside)
func GetMerchantSummary(ctx context.Context, rdb *redis.Client, id string) (Summary, error) {
    key := fmt.Sprintf("m:sum:%s:v1", id)
    if s, err := rdb.Get(ctx, key).Bytes(); err == nil {
        var out Summary; _ = json.Unmarshal(s, &out); return out, nil
    }
    // miss → lấy từ DB
    sum, err := querySummaryFromDB(ctx, id)
    if err != nil { return Summary{}, err }
    b, _ := json.Marshal(sum)
    _ = rdb.Set(ctx, key, b, 5*time.Minute).Err() // TTL + version trong key
    return sum, nil
}

Read-through / Write-through

Một layer trung gian đứng trước nguồn dữ liệu, tự động điền cache khi miss (read-through) và ghi cả cache lẫn nguồn khi cập nhật (write-through). Phù hợp khi bạn muốn nhất quán hơn và chấp nhận độ phức tạp của tầng trung gian.

Write-back (write-behind)

Ghi vào cache trước rồi đồng bộ về nguồn sau. Cho độ trễ ghi thấp nhưng rủi ro mất dữ liệu nếu layer cache gặp sự cố. Chỉ dùng khi có log bền vững hoặc hàng đợi đảm bảo.

TTL, invalidation và tính nhất quán

Chọn TTL như chọn thời gian thay bình cà phê

TTL quá ngắn → nhiều miss, stampede. TTL quá dài → stale. Hãy dựa vào nhu cầu nghiệp vụ và tần suất thay đổi thực tế. Đôi khi dùng TTL ngắn kết hợp stale-while-revalidate: trả dữ liệu cũ trong giới hạn cho phép, đồng thời làm mới ở nền.

Event-driven invalidation

Khi có sự kiện ghi (ví dụ khách để lại review mới), phát thông điệp xóa/ghi đè những key ảnh hưởng: tổng rating, số review theo sao, trang đầu danh sách.

func InvalidateOnNewReview(ctx context.Context, rdb *redis.Client, merchantID string) {
    keys := []string{
        fmt.Sprintf("m:sum:%s:v1", merchantID),
        fmt.Sprintf("m:list:%s:page:1:v1", merchantID),
    }
    rdb.Del(ctx, keys...) // xóa để lần sau đọc lại nguồn
}

Versioned keys

Thay vì tìm và xóa hàng loạt, tăng version trong prefix key khi có thay đổi lớn (schema/logic). Key cũ tự hết hạn.

Negative caching

Khi biết chắc “không tồn tại” (ví dụ merchant không có), lưu tạm trạng thái âm với TTL ngắn để tránh đập DB liên tục.

Tránh cache stampede và hot key

Single-flight và lock nhẹ

Nhiều request cùng lúc cùng một key miss sẽ cùng truy vấn nguồn và ghi đè nhau. Dùng single-flight để chỉ cho phép một truy vấn gốc chạy, những request khác chờ kết quả.

var g singleflight.Group

func GetWithSF(ctx context.Context, key string) ([]byte, error) {
    v, err, _ := g.Do(key, func() (any, error) {
        // logic miss: lấy từ nguồn và set vào cache
        b := fetchOrigin(ctx, key)
        _ = rdb.Set(ctx, key, b, 5*time.Minute).Err()
        return b, nil
    })
    if err != nil { return nil, err }
    return v.([]byte), nil
}

Jitter và phân tán thời điểm hết hạn

Nếu hàng loạt key có cùng TTL, tới giờ sẽ hết hạn đồng loạt. Thêm ngẫu nhiên nhỏ vào TTL hoặc dùng TTL = base + rand(0..delta) để phân tán.

Xử lý hot key

Một số key bị đọc cực nhiều (trang chủ, config toàn hệ thống). Cân nhắc nhân bản key theo shard, đẩy ra CDN, hoặc cache gần client để giảm áp lực lên Redis duy nhất.

Thiết kế key và payload

Namespacing rõ ràng

Dùng prefix ngắn gọn và version: m:sum:{id}:v1. Tránh dấu cách, ký tự khó đọc. Nên có quy ước chung trong team.

Giới hạn kích thước

Đừng cache payload quá lớn. Nếu cần danh sách dài, hãy cache theo page. Với JSON, cân nhắc nén hoặc chỉ lưu field thực sự cần trả về.

Tính idempotent khi ghi

Ghi đè hay xóa key phải an toàn khi chạy lại. Kịch bản retry không nên gây tình trạng lạ.

Đo lường hiệu quả

Bạn không thể tối ưu thứ mình không đo.

  1. Hit rate: số request phục vụ từ cache / tổng request. Nhưng cũng theo dõi byte hit rate để hiểu lưu lượng thực sự giảm bao nhiêu.
  2. Latency trước và sau khi bật cache, nhất là p95/p99. Nhiều hệ thống có p50 đẹp nhưng p99 vẫn tệ vì stampede.
  3. Eviction, memory used, keyspace size. Nếu eviction tăng đột biến, TTL hoặc dung lượng cần điều chỉnh.
  4. Tải nguồn sau khi bật cache. Đích đến là giảm truy vấn tốn kém, không phải làm đẹp dashboard.

Một cách gần đúng để kiểm tra ROI: L_saved ≈ hit_rate × (latency_origin − latency_cache) × QPS. Nếu L_saved lớn hơn chi phí vận hành/độ phức tạp bạn phải gánh, tiếp tục đầu tư.

Bắt đầu nhỏ: lộ trình triển khai

  1. Chọn một API có tỷ lệ đọc cao và truy vấn nặng. Thêm cache-aside với TTL bảo thủ.
  2. Chuẩn hóa key, thêm version và metric cơ bản (hit/miss, latency, size).
  3. Ngăn stampede bằng single-flight, thêm jitter cho TTL.
  4. Xác định luồng invalidation từ sự kiện ghi. Tối thiểu: xóa những key trực tiếp chịu ảnh hưởng.
  5. Sau khi số liệu ổn định, cân nhắc read-through hoặc write-through nếu cần nhất quán hơn.

Những mùi hôi thường gặp

Dùng cache để che lỗi thiết kế dữ liệu; biến mọi thứ thành JSON to đùng trong một key; TTL bằng 0 hoặc bằng 1 ngày mà không lý do; xóa key theo pattern rộng và vô tình bắn hạ cả cụm; phụ thuộc vào cache cho tính đúng đắn nghiệp vụ; quên quan sát và dọn dẹp.

Kết luận

Cache là công cụ tối ưu phản hồi và chi phí, không phải thuốc tiên. Hãy dùng khi đọc nhiều hơn ghi, có điểm nóng tốn kém, và chấp nhận dữ liệu cũ trong một ngưỡng hợp lý. Chọn mẫu phù hợp (cache-aside, read-through, write-through), đặt TTL có chủ đích, tránh stampede, đo lường hiệu quả. Khi những nguyên tắc này được áp dụng, trải nghiệm người dùng sẽ giống một quán cà phê vận hành tốt: món quen luôn sẵn, ly ra nhanh, và barista không bao giờ kiệt sức.