Redis cache stampede trong flash sale – cách ngăn hệ thống sập · Pattern Quest
#redis
#caching
#high-concurrency
#spring-boot
Redis cache stampede trong flash sale – cách ngăn hệ thống sậpRedis cache stampede in flash sales – how to keep the system alive
Giải thích cache stampede khi traffic bùng nổ và đưa ra các giải pháp thực tiễn cho Java Spring Boot và Go.Explain cache stampede under traffic spikes and give practical fixes for Java Spring Boot and Go.
Bạn đang chuẩn bị cho đợt flash sale của một nền tảng thương mại điện tử. Khi đồng hồ đếm ngược tới 0, hàng ngàn người dùng cùng truy cập một API /product/{id} để lấy thông tin khuyến mãi. Độ trễ tăng đột biến, DB PostgreSQL bị quá tải và cuối cùng hệ thống sập.
Cache stampede xảy ra khi nhiều request đồng thời phát hiện một khóa cache (key) đã hết hạn và đồng thời đánh vào DB để tải lại dữ liệu. Nếu không có cơ chế kiểm soát, DB sẽ nhận một “cơn lốc” query – chính là nguyên nhân khiến flash sale sụp.
Tip: Cache stampede thường xuất hiện cùng với TTL ngắn (ví dụ 30s) và traffic cao (≥10k RPS).
@Servicepublic class ProductService { private final ProductRepository repo; private final RedisTemplate<String, ProductDto> redis; private static final String KEY_PREFIX = "product:"; public ProductDto getProduct(Long id) { String key = KEY_PREFIX + id; // 1. Kiểm tra cache ProductDto cached = redis.opsForValue().get(key); if (cached != null) return cached; // 2. Cache miss → query DB ProductDto fresh = repo.findById(id) .map(p -> new ProductDto(p)) .orElseThrow(); // 3. Ghi lại cache (TTL 30s) redis.opsForValue().set(key, fresh, Duration.ofSeconds(30)); return fresh; }}
Khi TTL hết, ngàn request sẽ cùng vào đoạn if (cached != null) return cached; và ngay lập tức chạy tới query DB. Kết quả: DB bão hòa, latency tăng, timeout, thậm chí circuit breaker bật.
Cách truyền thống là dùng một lock để chỉ một request duy nhất được phép load lại dữ liệu. Các request còn lại sẽ đợi hoặc lấy lại cache sau khi lock được giải phóng.
package serviceimport ( "context" "time" "github.com/go-redis/redis/v8" "golang.org/x/sync/singleflight")type Product struct{ ID int64; Name string; Price int64 }var sf singleflight.Groupvar rdb *redis.Client // init elsewherefunc GetProduct(ctx context.Context, id int64) (*Product, error) { key := fmt.Sprintf("product:%d", id) // 1. Try cache if data, err := rdb.Get(ctx, key).Result(); err == nil { var p Product if err := json.Unmarshal([]byte(data), &p); err == nil { return &p, nil } } // 2. Use singleflight to coalesce DB load v, err, _ := sf.Do(key, func() (interface{}, error) { // This block runs only once per key per burst p, err := loadFromDB(ctx, id) if err != nil { return nil, err } // Write cache (TTL 30s) bytes, _ := json.Marshal(p) rdb.Set(ctx, key, bytes, 30*time.Second) return p, nil }) if err != nil { return nil, err } return v.(*Product), nil}func loadFromDB(ctx context.Context, id int64) (*Product, error) { // Simulate DB query time.Sleep(50 * time.Millisecond) return &Product{ID: id, Name: "Demo", Price: 1000}, nil}
singleflight.Group tự động đợi các goroutine cùng key và trả về cùng một kết quả, do đó DB chỉ chịu một query duy nhất.
Giải pháp 3: Cache pre‑warming & probabilistic TTL#
Nếu bạn biết thời gian flash sale trước, pre‑warm cache bằng cách load toàn bộ sản phẩm hot vào Redis trước khi đợt sale bắt đầu. Ngoài ra, sử dụng jitter (random offset) cho TTL giúp tránh đồng thời hết hạn:
Java
long ttl = 30 + ThreadLocalRandom.current().nextInt(5); // 30‑35sredis.opsForValue().set(key, fresh, Duration.ofSeconds(ttl));
Nếu hệ thống đã bảo vệ bằng lock hoặc singleflight nhưng DB vẫn có nguy cơ quá tải (ví dụ khi lock không được cấp), bạn có thể bảo vệ downstream bằng circuit breaker (Hystrix, Resilience4j). Khi lỗi vượt ngưỡng, breaker mở, các request nhận được fallback (ví dụ trả về “sản phẩm đang cập nhật, vui lòng thử lại”).
Cache stampede là “kẻ thù vô hình” của mọi flash sale. Một vài dòng code bảo vệ lock hoặc singleflight có thể cứu cả một hệ thống. Hãy áp dụng ngay vào dịch vụ ProductService của bạn và đo lường latency trước‑sau để thấy hiệu quả.
You are gearing up for a flash‑sale event on an e‑commerce platform. When the countdown hits zero, thousands of users hammer the endpoint /product/{id} to see the discounted price. Latency spikes, the PostgreSQL backend swells, and eventually the whole service crashes.
A cache stampede happens when many concurrent requests notice that a cached entry has expired and all rush to the database to reload it. Without a throttling mechanism, the DB receives a sudden wave of identical queries – the classic cause of a flash‑sale outage.
Tip: Stampedes are common with short TTLs (e.g., 30 s) under high traffic (≥10k RPS).
@Servicepublic class ProductService { private final ProductRepository repo; private final RedisTemplate<String, ProductDto> redis; private static final String KEY_PREFIX = "product:"; public ProductDto getProduct(Long id) { String key = KEY_PREFIX + id; // 1. Check cache first ProductDto cached = redis.opsForValue().get(key); if (cached != null) return cached; // 2. Cache miss → hit DB ProductDto fresh = repo.findById(id) .map(p -> new ProductDto(p)) .orElseThrow(); // 3. Write back to cache (TTL 30s) redis.opsForValue().set(key, fresh, Duration.ofSeconds(30)); return fresh; }}
When the TTL expires, thousands of requests will simultaneously pass the if (cached != null) guard and immediately execute the DB query. The result is DB saturation, soaring latency, time‑outs, and often a circuit‑breaker trip.
package serviceimport ( "context" "encoding/json" "fmt" "math/rand" "time" "github.com/go-redis/redis/v8" "golang.org/x/sync/singleflight")type Product struct{ ID int64; Name string; Price int64 }var sf singleflight.Groupvar rdb *redis.Client // initialise elsewherefunc GetProduct(ctx context.Context, id int64) (*Product, error) { key := fmt.Sprintf("product:%d", id) // 1. Try cache first if data, err := rdb.Get(ctx, key).Result(); err == nil { var p Product if err := json.Unmarshal([]byte(data), &p); err == nil { return &p, nil } } // 2. Coalesce DB loads via singleflight v, err, _ := sf.Do(key, func() (interface{}, error) { // This block runs only once per burst p, err := loadFromDB(ctx, id) if err != nil { return nil, err } // Write cache with jittered TTL bytes, _ := json.Marshal(p) ttl := 30 + rand.Intn(5) // 30‑35 s rdb.Set(ctx, key, bytes, time.Duration(ttl)*time.Second) return p, nil }) if err != nil { return nil, err } return v.(*Product), nil}func loadFromDB(ctx context.Context, id int64) (*Product, error) { // Simulated DB latency time.Sleep(50 * time.Millisecond) return &Product{ID: id, Name: "Demo", Price: 1000}, nil}
singleflight.Group guarantees that only one DB query runs for the given key while other goroutines wait for the result, effectively eliminating the stampede.
If you know the flash‑sale window in advance, pre‑warm the hot keys by loading them into Redis before traffic spikes. Additionally, add a small random offset (jitter) to the TTL so that keys do not expire simultaneously.
Even with locks or singleflight, the downstream DB can still become a bottleneck (e.g., lock acquisition fails). A circuit breaker (Resilience4j, Hystrix) can protect your service by short‑circuiting calls and returning a graceful fallback (e.g., “product data is temporarily unavailable, please retry”).
Cache stampede is the invisible enemy of every flash‑sale. A few lines of lock or singleflight code can save an entire system. Apply the patterns to your ProductService today, measure latency before and after, and you’ll see the impact instantly.