Cache Stampede trong Backend Production: request coalescing, stale-while-revalidate và anti-dogpile thực chiến

Sơ đồ cache stampede trong backend production với request coalescing và stale fallback
Sơ đồ cache stampede trong backend production với request coalescing và stale fallback

Cache thường được giới thiệu như chiếc nút tăng tốc đơn giản: đặt Redis trước database, thêm TTL, rồi latency giảm ngay. Nhưng production không đi theo giáo trình đẹp như vậy. Khi một key nóng hết hạn đúng lúc traffic đang cao, hàng trăm hoặc hàng nghìn request có thể cùng xuyên qua cache và đập thẳng vào database hoặc upstream service. Kết quả là cache không còn là lớp bảo vệ nữa; nó trở thành công tắc gây sự cố hàng loạt.

Đó là cache stampede — còn được gọi là dogpile effect hoặc thundering herd sau cache miss. Team thường chỉ nhận ra khi dashboard có pattern rất quen:

  • cache hit ratio tụt mạnh trong vài phút;
  • p95/p99 latency nhảy vọt;
  • query vào bảng nóng tăng đột ngột;
  • CPU của Redis vẫn bình thường nhưng database hoặc downstream API cháy;
  • sau vài phút hệ thống tự hồi lại, rồi lặp lại ở key khác.

Bài này đi vào góc nhìn production cho backend engineer: cache stampede thực sự xảy ra như thế nào, vì sao chỉ tăng TTL thường không giải quyết tận gốc, cách dùng request coalescing, stale-while-revalidate, jitter TTL, negative caching, distributed lock, và cách instrument hệ thống để biết anti-dogpile của mình có thật sự hiệu quả hay không.

Cache stampede là failure mode của tải đồng thời, không chỉ là chuyện “cache miss nhiều”

Một cache miss đơn lẻ không nguy hiểm. Vấn đề bắt đầu khi nhiều request cùng chờ một dữ liệu giống nhau và cache của key đó hết hạn gần như cùng lúc.

Ví dụ một trang product detail đọc giá, tồn kho và promotion cho product:123. Trong giờ cao điểm, key này nhận 2.000 request/phút. Nếu TTL là 5 phút và toàn bộ traffic cùng dùng đúng key đó, tại thời điểm key hết hạn sẽ có một cửa sổ rất ngắn nơi hàng loạt request đều thấy MISS.

Nếu mỗi request đều tự xuống database để recompute, hệ thống bị nhân bản việc tính toán không cần thiết:

  1. request A miss cache;
  2. request B cũng miss trước khi A kịp ghi lại cache;
  3. request C, D, E... lặp lại y hệt;
  4. database bị đập bằng cùng một truy vấn nặng;
  5. response chậm hơn làm cửa sổ stampede dài hơn;
  6. nhiều request timeout và retry, khiến bão lớn hơn.

Đây là điểm cần nhớ: cache stampede là một dạng positive feedback loop. Latency càng tăng thì thời gian cửa sổ cache trống càng dài, và càng nhiều request chui xuyên qua.

Vì sao TTL đơn giản thường tạo ra đồng hồ hẹn giờ cho sự cố

Cách cache phổ biến nhất là:

  • key
  • value
  • TTL cố định, ví dụ 300 giây

Vấn đề của TTL cố định là nó đồng bộ hóa thời điểm chết của dữ liệu. Nếu key nóng được set một lần rồi phục vụ rất nhiều request, tất cả consumer đều phụ thuộc vào cùng một mốc hết hạn.

Những failure mode hay gặp:

1. Expiry đồng loạt

Nhiều key cùng được warm-up sau deploy hoặc sau batch job. 5 phút sau, chúng cũng chết gần như cùng lúc. Team thấy spike đúng chu kỳ nhưng dễ nhầm là “traffic ngẫu nhiên”.

2. TTL ngắn để giữ dữ liệu tươi, nhưng recompute quá đắt

Dữ liệu càng đắt để tạo lại thì càng không nên để mọi request cùng chịu chi phí đó khi TTL vừa chạm 0.

3. Retry từ client làm bão lớn hơn

Khi upstream chậm vì stampede, mobile app hoặc gateway retry. Hệ thống không còn đối mặt với 1.000 request gốc mà là 1.000 request gốc cộng retry fan-out.

4. Warm cache nhưng cold dependency

Ngay cả khi Redis khỏe, dependency tạo ra value có thể không chịu nổi load burst. Stampede thật ra diễn ra ở database, search cluster hoặc third-party API phía sau cache.

Request coalescing: một request tính, phần còn lại chờ chung kết quả

Minh họa request coalescing singleflight cho cache miss của hot key
Minh họa request coalescing singleflight cho cache miss của hot key

Biện pháp hiệu quả nhất trong rất nhiều case là request coalescing: khi nhiều request cùng cần một key đang miss, chỉ cho một request đi tính lại value; các request còn lại chờ và dùng lại cùng kết quả.

Trong Go, pattern này thường được biết qua singleflight. Nhưng về bản chất, bất kỳ stack nào cũng triển khai được nếu có lớp coordination.

Cơ chế tối thiểu

  • request đầu tiên acquire quyền recompute cho key;
  • các request sau thấy key đang được recompute thì không đập tiếp vào source of truth;
  • chúng chờ promise/future/kết quả chung trong một khoảng bounded wait;
  • khi recompute xong, tất cả nhận cùng value từ cache mới hoặc từ shared result.

Lợi ích lớn nhất

  • giảm duplicate work trên dữ liệu nóng;
  • chặn query burst khi key hết hạn;
  • giảm write amplification lên cache;
  • giữ latency ổn định hơn trong cửa sổ expiry.

Pseudo-flow

GET key K
 -> cache miss
 -> try join inflight[K]
    -> if joined: wait up to 80ms for leader result
    -> if leader absent: become leader
leader:
 -> fetch from DB/upstream
 -> set cache K with TTL+jitter
 -> resolve inflight[K]
followers:
 -> return leader result or stale fallback

Điểm quan trọng là bounded wait. Nếu follower chờ vô hạn vào leader đang bị treo, hệ thống chỉ chuyển từ stampede sang dead queue. Vì vậy coalescing phải đi kèm timeout và fallback rõ ràng.

Stale-While-Revalidate: phục vụ dữ liệu hơi cũ để cứu hệ thống

Timeline stale while revalidate với fresh window stale window và background refresh
Timeline stale while revalidate với fresh window stale window và background refresh

Một tư duy rất hữu ích trong production là: nhiều dữ liệu không cần đúng từng mili giây. Với profile, ranking tương đối, config ít đổi, hoặc dashboard read-mostly, trả dữ liệu hơi cũ nhưng nhanh thường tốt hơn để toàn hệ thống sập vì cố tươi tuyệt đối.

Stale-While-Revalidate (SWR) hoạt động như sau:

  • key có fresh_ttlstale_ttl;
  • trong fresh_ttl, trả dữ liệu bình thường;
  • sau khi hết fresh nhưng chưa vượt stale window, request vẫn có thể nhận dữ liệu cũ;
  • chỉ một background refresh hoặc leader request đi làm mới dữ liệu;
  • nếu refresh thành công, cache được cập nhật;
  • nếu refresh fail tạm thời, hệ thống vẫn còn dữ liệu stale để phục vụ thêm một khoảng ngắn.

Đây là chiến lược cực hữu ích khi objective là service continuity chứ không phải freshness tuyệt đối.

Khi nào SWR rất hợp

  • homepage block, recommendation, trending list;
  • config/feature metadata ít đổi;
  • counts và analytics chấp nhận eventual consistency;
  • expensive aggregation query;
  • upstream third-party API có rate limiting hoặc latency dao động.

Khi nào phải cẩn thận

  • số dư, quyền truy cập, hạn mức tín dụng;
  • giá trị pháp lý/đơn hàng cần strong correctness;
  • dữ liệu mà stale vài phút cũng gây hậu quả nghiệp vụ rõ.

Nói cách khác, SWR là tradeoff có chủ đích giữa freshness và resilience. Đừng áp dụng đồng loạt chỉ vì “cache sẽ khỏe hơn”.

Jitter TTL: đừng cho mọi key chết cùng nhịp

So sánh TTL cố định với TTL jitter và negative caching để giảm dogpile
So sánh TTL cố định với TTL jitter và negative caching để giảm dogpile

Nếu team chỉ nhớ một anti-dogpile rẻ và đáng làm ngay, đó là TTL jitter.

Thay vì mọi key sống đúng 300 giây, hãy random hóa quanh một khoảng:

  • 300 giây ± 15%
  • hoặc base_ttl + random(0..60s)

Mục tiêu không phải kéo dài tuổi thọ trung bình quá nhiều, mà là phá đồng bộ expiry. Điều này đặc biệt hữu ích cho:

  • batch warm-up sau deploy;
  • key được set cùng lúc do cron;
  • danh sách item có pattern truy cập tương tự.

Ví dụ Node.js pseudo-code:

function ttlWithJitter(baseSec, percent = 0.15) {
  const delta = Math.floor(baseSec * percent);
  const jitter = Math.floor(Math.random() * (delta * 2 + 1)) - delta;
  return baseSec + jitter;
}

Jitter không thay thế request coalescing. Nó chỉ làm giảm xác suất nhiều key chết đồng pha. Với key cực nóng, vẫn cần coalescing hoặc SWR.

Negative caching: cache cả trạng thái “không có dữ liệu” để chặn miss lặp vô ích

Một tình huống stampede khác ít được để ý là key không tồn tại hoặc upstream trả 404/not found, nhưng hệ thống lại không cache kết quả âm này. Khi đó mỗi request lại xuống database hoặc API để xác nhận “vẫn chưa có”.

Ví dụ:

  • user ID lạ bị bot quét liên tục;
  • product đã archive;
  • profile chưa được sync từ hệ thống khác;
  • lookup theo slug sai được truy cập lặp đi lặp lại.

Giải pháp là negative caching với TTL ngắn hơn dữ liệu dương, ví dụ 15–60 giây. Mục tiêu là giảm repeated miss chứ không giữ lỗi quá lâu.

Cần lưu ý:

  • phân biệt not found thật với lỗi tạm thời từ upstream;
  • không negative cache các lỗi 500/timeout như thể đó là dữ liệu thật;
  • TTL cho negative cache nên ngắn và có metric riêng.

Distributed lock: hữu ích nhưng dễ tự biến thành điểm nghẽn nếu lạm dụng

Distributed lock cho cache recompute với stale fallback và bounded wait
Distributed lock cho cache recompute với stale fallback và bounded wait

Khi recompute không chỉ xảy ra trong một process mà trên nhiều instance service, team thường nghĩ tới distributed lock bằng Redis.

Pattern điển hình:

  • SET lock:key value NX PX 3000
  • ai lấy lock thì recompute;
  • ai không lấy được lock thì chờ, trả stale hoặc poll lại cache.

Pattern này có ích, nhưng có vài bẫy production:

1. Lock TTL quá ngắn

Leader chưa tính xong mà lock hết hạn, follower khác nhảy vào recompute tiếp. Stampede quay lại dưới hình thức mới.

2. Lock TTL quá dài

Nếu leader chết giữa chừng, key bị kẹt quá lâu. Follower phải đợi dù thực tế không ai đang xử lý.

3. Poll cache quá hăng

Follower không vào DB nhưng lại spam Redis 5ms/lần để hỏi “xong chưa?”. Redis thành nút nghẽn mới.

4. Không có stale fallback

Nếu lock đã tồn tại và follower chỉ biết chờ hoặc fail, user experience sẽ tệ hơn nhiều so với trả dữ liệu stale trong vài chục giây.

Distributed lock nên được xem là cơ chế coordination, không phải giải pháp duy nhất. Nó mạnh nhất khi kết hợp với SWR, bounded wait và observability tốt.

Local coalescing + global cache thường là điểm cân bằng tốt hơn lock toàn cục cho mọi thứ

Không phải mọi hệ thống đều cần distributed lock cho mọi key. Trong nhiều service stateless scale ngang, một chiến lược thực dụng hơn là:

  • local in-process coalescing cho request cùng instance;
  • global Redis cache để chia sẻ kết quả;
  • chỉ dùng distributed lock cho các key recompute rất đắt hoặc fan-out rất lớn.

Lý do:

  • local coalescing rẻ và ít phức tạp hơn;
  • nhiều stampede thực tế đã được giảm đáng kể chỉ bằng việc chặn duplicate work trong từng instance;
  • distributed coordination cho mọi key làm tăng network round-trip và độ phức tạp failure handling.

Nói cách khác, đừng nhảy ngay vào khóa phân tán khắp nơi nếu vấn đề chính mới chỉ là duplicate work trong cùng node.

Anti-stampede phải đi kèm timeout, backpressure, load shedding và rate limit

Một sai lầm phổ biến là nghĩ anti-dogpile chỉ là bài toán cache. Thực ra nó chạm thẳng vào resilience của toàn request path.

Nếu recompute path không có guardrail, leader request vẫn có thể giết downstream.

Những control nên có:

1. Timeout budget cho recompute

Recompute của key nóng không nên kéo dài tùy hứng. Nếu source of truth chậm quá, hãy fallback sang stale hoặc degrade response thay vì giữ hàng đợi follower dài mãi.

2. Concurrency cap cho cache fill

Giới hạn số lượng key đang được fill đồng thời. Nếu hàng nghìn key cùng cold sau deploy, hệ thống cần backpressure thay vì cho tất cả cùng đập xuống database.

3. Circuit breaker cho upstream

Nếu dependency nguồn đang lỗi hàng loạt, tiếp tục fill cache chỉ làm tình hình tệ hơn. Breaker nên mở để service trả stale, partial response hoặc degraded mode.

4. Rate limit cho client retry

Nhiều stampede bị khuếch đại bởi retry từ API gateway hoặc mobile client. Hãy đảm bảo retry policy nhận biết stale served, upstream overloaded, hoặc please retry later rõ ràng.

Code sketch: request coalescing với stale fallback trong Node.js

Ví dụ tối giản dưới đây minh họa tư duy, không phải production-ready hoàn chỉnh:

const inflight = new Map();

async function getWithAntiStampede(key, { freshTtlSec = 60, staleTtlSec = 300 }) {
  const cached = await redis.hgetall(key);
  const now = Date.now();

  if (cached?.value) {
    const freshUntil = Number(cached.fresh_until || 0);
    const staleUntil = Number(cached.stale_until || 0);

    if (now < freshUntil) {
      return { value: JSON.parse(cached.value), source: 'fresh-cache' };
    }

    if (now < staleUntil) {
      triggerBackgroundRefresh(key, { freshTtlSec, staleTtlSec }).catch(() => {});
      return { value: JSON.parse(cached.value), source: 'stale-cache' };
    }
  }

  if (inflight.has(key)) {
    return inflight.get(key);
  }

  const p = (async () => {
    try {
      const value = await loadFromSource(key);
      const freshUntil = Date.now() + freshTtlSec * 1000;
      const staleUntil = Date.now() + staleTtlSec * 1000;
      await redis.hset(key, {
        value: JSON.stringify(value),
        fresh_until: String(freshUntil),
        stale_until: String(staleUntil),
      });
      await redis.expire(key, staleTtlSec + ttlJitter(30));
      return { value, source: 'origin' };
    } finally {
      inflight.delete(key);
    }
  })();

  inflight.set(key, p);
  return p;
}

Điểm đáng chú ý:

  • cache object giữ cả fresh_untilstale_until;
  • stale path phục vụ response ngay thay vì block user;
  • local in-flight map chặn duplicate recompute trong cùng process;
  • expiration thực tế có jitter để tránh đồng pha.

Production thật sẽ cần thêm lock liên process, metrics, bounded wait và error classification.

Observability: nếu không đo đúng, anh sẽ không biết stampede đã giảm hay chỉ đổi hình dạng

Rất nhiều team triển khai anti-dogpile rồi thấy “có vẻ ổn”. Nhưng nếu không đo, có thể họ chỉ chuyển tải từ database sang Redis hoặc từ synchronous path sang queue chờ.

Các metric nên có tối thiểu:

Cache layer

  • hit ratio theo endpoint và key namespace;
  • miss rate theo key nóng;
  • stale served count;
  • negative cache hit count;
  • average TTL remaining khi được đọc.

Coalescing layer

  • inflight join count;
  • leader count;
  • follower wait time;
  • recompute duration;
  • bounded-wait timeout count.

Origin layer

  • request count đi xuyên cache;
  • DB query rate cho truy vấn nóng;
  • upstream API rate;
  • p95/p99 latency khi cache miss;
  • error rate trong recompute path.

Outcome metric quan trọng nhất

  • số origin call cho mỗi logical key miss event.

Nếu anti-stampede hoạt động tốt, một cache miss cho key nóng phải tạo ra xấp xỉ 1 origin fetch, không phải 30 hoặc 300.

Case production rất hay gặp: deploy xong cache ấm lại theo kiểu phá database

Một incident cực phổ biến:

  • deploy mới làm restart app;
  • local cache mất sạch, hoặc key prefix đổi;
  • traffic vào lại rất nhanh;
  • hàng loạt homepage/product/category key cùng cold;
  • recompute path đập xuống database/search cluster;
  • autoscaling app tăng thêm instance, vô tình làm số worker cold nhiều hơn.

Nếu không có control, autoscaling tầng app còn có thể khuếch đại stampede.

Các biện pháp thường hữu ích hơn “mong hệ thống tự ổn”:

  • pre-warm một số key nóng sau deploy;
  • gradual traffic shifting/canary;
  • concurrency cap cho warm-up job;
  • TTL jitter để đám key warm-up không cùng chết nhịp sau đó.

Cache stampede trong microservices còn nguy hiểm hơn vì fan-out nhân theo mỗi hop

Trong monolith, một key miss có thể chỉ làm tăng tải lên database. Trong microservices, một cache miss ở service A có thể fan-out sang:

  • service B lấy inventory;
  • service C lấy pricing;
  • service D lấy campaign;
  • service E lấy recommendation.

Nếu từng service lại có cache riêng và anti-stampede yếu, một burst nhỏ ở edge có thể phình thành hàng chục burst nhỏ ở các tầng dưới. Vì vậy khi audit stampede, đừng chỉ nhìn service có cache miss đầu tiên; hãy nhìn toàn chuỗi dependency.

Checklist production để chống cache stampede bền hơn

Thiết kế

  • Xác định key nào là hot key thật sự.
  • Phân loại dữ liệu nào được phép stale và stale trong bao lâu.
  • Tách read-mostly flow với correctness-critical flow.

Implementation

  • Có request coalescing cho hot key.
  • TTL có jitter, không đồng pha cứng.
  • Có negative caching cho not found hợp lệ.
  • Recompute path có timeout budget rõ.
  • Có stale fallback trước khi fail cứng trong các luồng cho phép.
  • Chỉ dùng distributed lock ở nơi thật sự cần.

Operations

  • Đo leader/follower ratio của coalescing.
  • Theo dõi origin fetch per cache-miss event.
  • Alert khi stale served tăng bất thường hoặc inflight wait timeout tăng.
  • Kiểm tra deploy/warm-up có tạo cold-start burst không.
  • Review retry policy của gateway/client để tránh khuếch đại overload.

Kết luận

Cache không làm hệ thống nhanh một cách miễn phí. Nó chỉ dời bài toán hiệu năng sang bài toán đồng bộ hóa, freshness và overload control. Khi key nóng hết hạn đúng lúc traffic cao, một implementation cache ngây thơ có thể biến Redis thành cánh cửa mở toang cho stampede đập thẳng vào database.

Muốn cache thực sự bảo vệ production, anh cần nghĩ vượt qua GET/SET + TTL:

  • request coalescing để một request tính, nhiều request dùng chung;
  • stale-while-revalidate để cứu trải nghiệm và giữ dependency sống;
  • TTL jitter để phá đồng hồ hẹn giờ của expiry đồng loạt;
  • negative caching để chặn miss vô ích;
  • distributed lock dùng đúng chỗ, không lạm dụng;
  • và quan trọng không kém, observability để biết anti-dogpile có hiệu quả thật hay không.

Nếu phải chọn thứ đáng làm đầu tiên trên một backend đang bị stampede, tôi sẽ bắt đầu bằng: đo hot key, thêm coalescing, bật stale fallback cho luồng chấp nhận eventual consistency, rồi mới bàn sâu tới lock phân tán. Phần lớn thắng lợi đến từ những bước này trước khi cần kiến trúc quá cầu kỳ.