Redis Caching — Cache Aside, Read Through và chiến lược cho production

Phong Hy

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:

  1. 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
  1. Singleflightgolang.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.