Redis Caching — Cache Aside, Read Through và chiến lược cho production
Caching không chỉ là throw dữ liệu vào Redis rồi quên. Chọn sai pattern, bạn sẽ gặp cache stampede, dirty read, hoặc memory leak. Bài này mình đi qua 3 chiến lược cache phổ biến nhất trong production — kèm code Go thực tế và kinh nghiệm xương máu từ những lần "dính chưởng".
Cache Aside (Lazy Loading)
Pattern cơ bản nhất — application tự quản lý cache lifecycle:
func GetUser(ctx context.Context, id string) (*User, error) {
key := fmt.Sprintf("user:%s", id)
var user User
err := cache.Get(ctx, key, &user)
if err == nil {
return &user, nil // Cache hit 🎯
}
// Cache miss → query database
user, err = db.GetUser(ctx, id)
if err != nil {
return nil, err
}
// Ghi cache + set TTL — luôn nhớ TTL!
cache.Set(ctx, key, user, 5*time.Minute)
return &user, nil
}
Kinh nghiệm: Luôn set TTL — cache không TTL là memory leak chờ xảy ra. Đừng cache null value trừ khi bạn có lý do chính đáng (dễ gây N+1 cache write). Và nhớ: không cache object quá lớn — Redis là single-threaded, một key 10MB sẽ block toàn bộ instance.
Read Through
Cache tự động load khi miss — application không cần code thủ công:
type UserCache struct {
client *redis.Client
loader func(ctx context.Context, id string) (*User, error)
}
func (c *UserCache) Get(ctx context.Context, id string) (*User, error) {
key := fmt.Sprintf("user:%s", id)
val, err := c.client.Get(ctx, key).Result()
if errors.Is(err, redis.Nil) {
user, err := c.loader(ctx, id) // Auto loader
if err != nil {
return nil, err
}
c.client.Set(ctx, key, user, 5*time.Minute)
return user, nil
}
return unmarshalUser(val)
}
Ưu điểm: Code gọn, cache layer trong suốt với business logic. Nhược điểm: Cache stampede — 10 request cùng miss thì 10 request cùng đổ vào database.
Write Through & Write Behind
Write Through: Ghi DB → ghi cache đồng bộ:
func UpdateUser(ctx context.Context, user *User) error {
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
if err := tx.UpdateUser(ctx, user); err != nil {
return err
}
key := fmt.Sprintf("user:%s", user.ID)
cache.Set(ctx, key, user, 5*time.Minute)
return tx.Commit()
}
Write Behind: Ghi cache trước, DB sau (async). Dùng channel hoặc message queue:
func (w *Worker) ProcessUpdates() {
for update := range w.updateChan {
key := fmt.Sprintf("user:%s", update.User.ID)
w.cache.Set(ctx, key, update.User, 5*time.Minute)
w.db.UpdateUser(ctx, update.User) // Best effort
}
}
Pro tip: Trong production mình ưu tiên Write Behind — không làm chậm request, chấp nhận thêm tí latency. Nhưng cẩn thận data loss window nếu worker crash.
Chống Cache Stampede — Kẻ thù số 1
Đây là cái bẫy kinh điển: cache vừa expire, 100 request cùng đổ vào DB. Hậu quả: DB chết, timeout, cascading failure.
Giải pháp mình dùng:
- TTL Jitter — random ±20% vào TTL để các key không cùng expire:
ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
- Singleflight —
golang.org/x/sync/singleflight:
func GetUserSafe(ctx context.Context, id string) (*User, error) {
key := fmt.Sprintf("user:%s", id)
v, err, _ := group.Do(key, func() (interface{}, error) {
return loadUserFromDB(ctx, id)
})
return v.(*User), err
}
Chỉ 1 request query DB, những request còn lại chờ kết quả. Cực kỳ hiệu quả trong hệ thống traffic cao.
Tổng kết
| Pattern | Khi nào dùng | Rủi ro chính |
|---|---|---|
| Cache Aside | Đa số API server | Cache stampede |
| Read Through | Cache layer tự động | Stampede nặng hơn |
| Write Through | Cần consistency | Slow writes |
| Write Behind | Performance critical | Data loss window |
Rule của mình: Bắt đầu với Cache Aside + Singleflight. Đừng over-engineer caching ngay từ đầu — performance issue thật sự mới cần cache strategy phức tạp. Và luôn monitor cache hit ratio, nếu dưới 80% thì coi lại TTL hoặc cache key strategy.