Cache-Aside: Vì sao bọc Redis ngoài DB vẫn có thể chậm
Cache-Aside: Vì sao bọc Redis ngoài DB vẫn có thể chậm
Hầu hết mọi người bắt đầu tối ưu hiệu năng bằng cách bọc một lớp cache như Redis trước database. Cache-aside là pattern phổ biến nhất: app đọc cache trước, miss thì mới query DB rồi ghi lại vào cache. Nghe đơn giản, nhưng có vài cái bẫy đã làm sập cả một hệ thống mình từng giữ.
Cache-Aside pattern cơ bản
Vòng đời đúng của một read:
func getUser(id string) (*User, error) {
// 1. Đọc cache
if val, err := rdb.Get(ctx, "user:"+id).Result(); err == nil {
return unmarshal(val)
}
// 2. Miss → query DB
u, err := db.QueryRow(...).Scan(...)
if err != nil {
return nil, err
}
// 3. Ghi lại cache + set TTL
rdb.Set(ctx, "user:"+id, marshal(u), 10*time.Minute)
return u, nil
}
Đây là pattern ta hay dạy cho junior. Nhưng chỉ có thế này thì hệ thống vẫn dính cache stampede.
Bẫy 1: Cache Stampede (hiệu ứng đoàn ngựa)
Khi key vừa hết hạn và có 20.000 request cùng lúc đổ vào, tất cả đều miss, tất cả cùng query DB. DB chết vì 1 key hết hạn thôi. Giải pháp:
- Single-flight / lock: chỉ 1 request được phép đi DB, số còn lại chờ key được nạp lại.
// singleflight trong Go (golang.org/x/sync/singleflight)
var g singleflight.Group
val, err, _ := g.Do(id, func() (interface{}, error) {
// code đọc DB ở đây — chỉ 1 goroutine chạy
return db.QueryRow(...)..., nil
})
Hệ quả: 19.999 request còn lại đợi và nhận chung 1 kết quả. DB chỉ nhận 1 query. Đây là bài học máu khi mình từng để 1 key trending hết hạn và nhận alert DB 100% CPU trong 3 giây.
Bẫy 2: Thiếu TTL jitter
Nếu hết các key cùng lúc vì cùng TTL, bạn chính tay tạo ra stampede theo lịch. Giải pháp: thêm jitter ngẫu nhiên vào TTL.
ttl := 10*time.Minute + time.Duration(rand.Intn(60))*time.Second
Nhỏ vậy thôi nhưng trải đều được thời điểm expire, tránh tất cả cùng đổ DB.
Bẫy 3: Ghi DB trước hay ghi cache trước?
Khi update dữ liệu, nhiều người ghi cache luôn. Sai. Nên xoá (invalidate) cache trước, rồi để request sau tự nạp lại, hoặc dùng write-through cho dữ liệu hot phải nhất quán tuyệt đối. Ghi DB trước xong update cache sẽ gặp trường hợp 2 request đan xen làm cache sai vĩnh viễn.
Kinh nghiệm thực tế của mình
- Bắt đầu bằng việc đo, đừng đoán: dùng cache nhưng phải thấy rõ hit-rate giảm chưa tới 80% thì vấn đề nằm ở DB query, đừng đổ thêm cache che giấu.
- Không bao giờ cache thiếu TTL lên user login: cache session có chủ quyền rủi ro lớn nếu không xoá đúng lúc khi user đổi mật khẩu.
- Cache vô hạn (no eviction policy) = DB mới chờ chết: nhớ set
maxmemory+ policyallkeys-lrutrên Redis. - Demo metric truyền thống: theo dõi
cache_hits / cache_misses+stampede_saved(đếm request được single-flight chặn) — thấy được cache đang cứu hệ thống bao nhiêu.
Kết luận
Cache-aside chỉ là lớp vỏ. Sức mạnh thật nằm ở việc bạn có chống được stampede, đồng bộ TTL và invalidate đúng hay không. Lần tới khi anh em nói "cứ bọc Redis vô là nhanh", hãy nhắc họ kiểm tra 3 cái bẫy trên trước — vì cache chỉ là gia tốc, còn bug là cái phanh.