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.