Khi nào nên dùng cache (và khi nào không)
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.
- 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.
- 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.
- 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.
- 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
- 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ủ.
- Chuẩn hóa key, thêm version và metric cơ bản (hit/miss, latency, size).
- Ngăn stampede bằng single-flight, thêm jitter cho TTL.
- 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.
- 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.