Cache-Aside với Redis — Giảm tải database cực kỳ đơn giản
Khi app mình bắt đầu chậm, cái đầu tiên người ta nghĩ tới là "up RAM", "thêm server". Nhưng điều mình học được sau nhiều năm làm backend là: rất nhiều vấn đề hiệu năng chỉ vì đọc database quá nhiều. Một pattern cực kỳ hiệu quả mà ít phức tạp đó là Cache-Aside — đọc từ cache trước, miss thì mới đụng database.
Cache-Aside là gì?
Đúng như tên gọi, Cache-Aside (còn gọi là lazy loading) là chiến thuật: cache đứng sang một bên, database mới là nguồn sự thật. Luồng xử lý cực kỳ đơn giản:
- Client request tới → server check Redis trước.
- Cache hit → trả data ngay, khỏi đụng database.
- Cache miss → đọc từ database, ghi ngược vào Redis, rồi trả về.
func getUser(ctx context.Context, id string) (*User, error) {
// 1. Đọc cache trước
if data, err := rdb.Get(ctx, "user:"+id).Bytes(); err == nil {
var u User
json.Unmarshal(data, &u)
return &u, nil
}
// 2. Miss thì đọc database
u, err := db.GetUser(ctx, id)
if err != nil {
return nil, err
}
// 3. Ghi lại cache cho lần sau (TTL khoảng chừng 5-10 phút)
if b, err := json.Marshal(u); err == nil {
rdb.Set(ctx, "user:"+id, b, 5*time.Minute)
}
return u, nil
}
Nghe đơn giản nhưng đây chính là điểm mạnh lớn nhất: logic không rối, dễ hiểu, dễ debug, và database luôn là nguồn chuẩn nhất — nếu cache sai thì TTL hết hạn là tự lành.
Ghi dữ liệu thì sao?
Có một cạm bẫy kinh điển mà ai mới học cache cũng dính: update database rồi update luôn cache. Nghe hợp lý nhưng nó gây inconsistent state cực kỳ nguy hiểm. Lý do: giữa lúc đọc DB và ghi cache, một request khác có thể đã ghi giá trị mới vào cache — thế là bạn ghi đè data cũ lên cache mới.
Cách chuẩn trong Cache-Aside: update database, rồi xoá (delete) key cache luôn. Lần đọc tới sẽ miss và tự load lại data mới từ DB.
func updateUser(ctx context.Context, id string, u *User) error {
if err := db.UpdateUser(ctx, u); err != nil {
return err
}
// Đừng SET cache — cứ xoá để lần sau tự load lại
rdb.Del(ctx, "user:"+id)
return nil
}
Cách này ăn chắc hơn hẳn, vì bạn không bao giờ có 2 nguồn cùng giữ một giá trị — xoá rồi load lại thì data luôn từ DB mà ra.
Cạm bẫy số 2: Cache Stampede (đàn cache đổ bộ)
Khi TTL của một hot key hết hạn vào đúng lúc cao điểm, rồi hàng chục ngàn request đồng loạt miss và cùng lúc đổ xô vô database. Kết quả: DB sụp, cache không giúp được gì. Mình từng chứng kiến con DB "trả giá đắt" vì chính cái này.
Giải pháp đơn giản nhất: single-flight — chỉ cho 1 request đi vô DB, còn lại chờ kết quả đó.
var single g.Group
func getUser(ctx context.Context, id string) (*User, error) {
// Đọc cache trước... (như trên)
// Miss: dồn hết vào 1 request đi DB
v, err := single.Do("user:"+id, func() (interface{}, error) {
return db.GetUser(ctx, id)
})
return v.(*User), err
}
Kinh nghiệm thực tế
- Chỉ cache data đọc nhiều, ít thay đổi (profile user, banner, config...). Đừng cache bảng dân số hay số liệu realtime.
- TTL chừng mực — 5-10 phút là chuẩn cho đa số. Quá ngắn thì cache vô nghĩa, quá dài thì data cũ.
- Key đặt có tiền tố
user:123,post:456để dễ debug trên Redis bằng lệnhKEYS user:*. - Đừng cache
nullkhi miss không có trong DB — nếu không bạn sẽ cache cả "không có" lại, khi user tạo sau đó thì vẫn thấy 404 cả ngày.
Cache-Aside không phải thứ cao siêu gì, nhưng nó là nền tảng cho mấy pattern phức tạp hơn sau này. Cứ nắm chắc cái này trước, hệ thống của bạn đã nhẹ đi rất nhiều rồi. 😎