Redis Caching — Cache Aside, Read Through & Write Through trong production

Phong Hy

Redis Caching — Cache Aside, Read Through & Write Through trong production

Cache là một trong những kỹ thuật quan trọng nhất để tối ưu performance cho hệ thống backend. Trong bài này, mình sẽ đi sâu vào 3 pattern caching phổ biến với Redis — kèm code mẫu và kinh nghiệm thực tế từ các hệ thống đang chạy production.

1. Cache Aside (Lazy Loading)

Đây là pattern phổ biến và dễ implement nhất. Application tự quản lý cache — khi cần dữ liệu, kiểm tra cache trước, nếu miss thì query DB rồi ghi vào cache.

func GetUser(ctx context.Context, id string) (*User, error) {
    // 1. Check cache
    val, err := redis.Get(ctx, "user:"+id).Bytes()
    if err == nil {
        var user User
        json.Unmarshal(val, &user)
        return &user, nil  // Cache hit
    }

    // 2. Cache miss — query DB
    user, err := db.QueryUser(ctx, id)
    if err != nil {
        return nil, err
    }

    // 3. Ghi vào cache (TTL 5 phút)
    data, _ := json.Marshal(user)
    redis.Set(ctx, "user:"+id, data, 5*time.Minute)

    return user, nil
}

Ưu điểm: Dễ implement, chỉ cache những gì thực sự được request, nếu cache chết thì DB vẫn hoạt động.

Nhược điểm: Cache miss có penalty (3 network round trips), dễ bị "thundering herd" khi nhiều request cùng cache miss một key.

2. Read Through Cache

Cache nằm giữa application và DB — application chỉ nói chuyện với cache, cache tự lo việc load dữ liệu từ DB khi miss. Pattern này thường được implement như một abstraction layer:

type ReadThroughCache struct {
    redis  *redis.Client
    loader func(ctx context.Context, key string) ([]byte, error)
}

func (c *ReadThroughCache) Get(ctx context.Context, key string) ([]byte, error) {
    val, err := c.redis.Get(ctx, key).Bytes()
    if err == nil {
        return val, nil
    }

    // Cache miss — loader tự động query DB
    data, err := c.loader(ctx, key)
    if err != nil {
        return nil, err
    }

    // Tự động ghi cache
    c.redis.Set(ctx, key, data, 5*time.Minute)
    return data, nil
}

Ưu điểm: Application code sạch hơn, không bị lẫn logic cache với business logic.

Nhược điểm: Cache layer phải highly available. Nếu Redis chết, toàn bộ request đổ dồn xuống DB (cache stampede). Cần thêm circuit breaker hoặc rate limiter bảo vệ.

3. Write Through — Ghi đồng bộ

Dữ liệu luôn được ghi vào cache trước, rồi cache mới ghi xuống DB. Đảm bảo cache luôn consistent với DB:

func WriteThroughSet(ctx context.Context, key string, value interface{}) error {
    data, _ := json.Marshal(value)

    pipe := redis.Pipeline()
    // 1. Ghi cache trước
    pipe.Set(ctx, "cache:"+key, data, 5*time.Minute)
    // 2. Ghi DB sau — trong cùng pipeline
    pipe.HSet(ctx, "users:hash", key, data)

    _, err := pipe.Exec(ctx)
    return err
}

Kinh nghiệm thực tế: Write Through thích hợp cho dữ liệu ít thay đổi, cần strong consistency (config, categories). Tránh dùng cho write-heavy workloads vì mỗi lần ghi đều tốn 2 round trips.

Chọn pattern nào?

Mình thường áp dụng rule sau:

  • Read nhiều hơn Write → Cache Aside. Đơn giản, dễ debug, dễ migrate.
  • Cần consistency cao → Write Through. Dùng cho master data.
  • API traffic siêu cao → Read Through + Write Behind (async). Nhưng cẩn thận với dirty reads.

Lưu ý khi lên production

  1. Set TTL hợp lý: 5–15 phút là khoảng an toàn cho đa số use case. Đừng để cache sống mãi.
  2. Monitor cache hit ratio: Nếu dưới 80%, coi lại strategy. Dùng Redis INFO stats để theo dõi.
  3. Cache warming: Khi deploy, warm cache trước — đừng để user "lãnh" cache miss đầu tiên.
  4. Eviction policy: Mặc định Redis dùng noeviction. Chuyển sang allkeys-lru hoặc volatile-lru cho hầu hết trường hợp.
  5. Phân biệt cache type: Cache dữ liệu người dùng (user profile) khác với cache computed data (aggregations, report). Đặt TTL và policy riêng.

Cache là con dao hai lưỡi — xài đúng pattern thì system bay, xài sai thì debug không thấy đường về. Hy vọng bài này giúp anh em có thêm góc nhìn để chọn pattern phù hợp cho hệ thống của mình.