Thundering Herd — Khi cache hết hạn là cả hệ thống sụp

Phong Hy

Cache tồn tại để đỡ đạn cho database. Nhưng có một kịch bản mà chính cache lại là cái bẫy: một key phổ biến hết hạn (TTL) đúng lúc traffic cao điểm, và cả "bầy đàn" request cùng lúc miss cache, cùng lúc lao thẳng vào database. Đó là thundering herd (hay cache stampede) — một trong những lỗi kiến trúc gây sập hệ thống đúng lúc tệ nhất: giờ cao điểm.

Vấn đề nghiêm trọng hơn bạn nghĩ

Giả sử cache phục vụ 100.000 request/giây cho một key "nóng" — ví dụ trending:today, config:global, hay user:123:profile. Bình thường chỉ 1-2 request miss cache mỗi giây, database thở phào. Nhưng đúng cái giây key hết hạn, cả 100.000 request cùng miss, cùng lúc gọi query nặng về database. Không database nào trụ nổi kiểu đó — connection pool cạn, CPU chạm trần, latency nhảy từ 5ms lên 5 giây, rồi timeout dây chuyền lan sang cả hệ thống.

Kinh nghiệm thực tế

Hồi làm API trending cho ứng dụng di động, mình từng thấy database CPU nhảy từ 20% lên 100% trong đúng 1 phút, đúng lúc 20:00 — giờ cao điểm. Lý do? Cache key trending:today TTL 60 giây, hết hạn lúc 20:00:01, và 50.000 request cùng lúc đi query lại. Bài học đầu tiên: chỗ nào cache miss mà query nặng, bắt buộc phải chặn bầy đàn, không được để "ai miss thì người đó tự đi hỏi database".

Giải pháp 1: Singleflight (Go)

Nếu app chạy một instance (hoặc nhiều instance nhưng cache chia sẻ trong process), golang.org/x/sync/singleflight là vũ khí rẻ nhất: chỉ một goroutine được đi query database, số còn lại chờ nhận chung kết quả.

import "golang.org/x/sync/singleflight"

var sf singleflight.Group

func getTrending(ctx context.Context) ([]Item, error) {
    if cached, ok := cache.Get("trending:today"); ok {
        return cached.([]Item), nil
    }

    v, err, _ := sf.Do("trending:today", func() (any, error) {
        items, err := db.QueryTrending(ctx) // chỉ 1 request tới DB
        if err != nil {
            return nil, err
        }
        cache.Set("trending:today", items, 60*time.Second)
        return items, nil
    })
    if err != nil {
        return nil, err
    }
    return v.([]Item), nil
}

Đẹp ở chỗ: zero chi phí khi cache còn sống, và khi key hết hạn thì chỉ đúng 1 request chạm database.

Giải pháp 2: Redis distributed lock

Singleflight chỉ chặn được trong một process. App chạy 10 instance mà không có cơ chế chung, thì vẫn 10 request đổ vào database. Lúc đó dùng Redis lock kiểu SETNX trước khi query:

ok, err := redis.SetNX(ctx, "lock:trending:today", "1", 5*time.Second).Result()
if err == nil && ok {
    defer redis.Del(ctx, "lock:trending:today")
    items, err := db.QueryTrending(ctx) // chỉ instance thắng lock được query
    cache.Set("trending:today", items, 60*time.Second)
    return items, nil
}
// thua lock → chờ cache được rebuild, hoặc đọc lại cache
for i := 0; i < 20; i++ {
    time.Sleep(50 * time.Millisecond)
    if cached, ok := cache.Get("trending:today"); ok {
        return cached.([]Item), nil
    }
}

TTL của lock phải ngắn hơn thời gian query tối đa, kẻo lock chết mà không ai unlock.

Giải pháp 3: Jitter TTL — phòng bệnh từ gốc

Nhiều key cùng hết hạn một lúc cũng là thundering herd nhỏ. Thêm chút ngẫu nhiên vào TTL để các key "rụng" dần chứ không rụng đồng loạt:

ttl := 300*time.Second + time.Duration(rand.Intn(60))*time.Second

Trường hợp key được rebuild theo lịch (ví dụ cron 00:00 refresh danh sách), jitter giúp tránh tình trạng cả nghìn key đua nhau hết hạn lúc nửa đêm.

Bonus: stale-while-revalidate

Với dữ liệu không cần realtime tuyệt đối, đừng xóa cache khi hết hạn — cứ trả dữ liệu cũ cho user, rồi refresh nền. User không bao giờ thấy latency, database chỉ nhận 1 query refresh định kỳ. Đơn giản mà hiệu quả bất ngờ.

Chọn cái nào?

  • 1 instance, cache in-process: singleflight là đủ, gần như free.
  • Nhiều instance, cache dùng chung (Redis): cần Redis lock, singleflight không ngăn được bầy đàn xuyên process.
  • Phòng ngừa từ gốc: luôn thêm jitter vào TTL, hạn chế hết hạn đồng loạt.
  • Dữ liệu ít thay đổi: stale-while-revalidate, đổi độ trễ lấy sự an toàn.

Thundering herd không phải lỗi hiếm — nó xảy ra đúng thời điểm hệ thống nhạy cảm nhất, đúng key quan trọng nhất. Giờ mở code ra xem: chỗ nào miss cache là query thẳng database? Đừng để bầy đàn quật ngã hệ thống của bạn.