DEV Community

Cache Invalidation — Stale Data

Stale data và cache stampede: vì sao TTL một mình không đủ và vì sao origin sập khi key hết hạn

Stale data là dữ liệu trong cache đã lỗi thời so với source of truth. Nó xuất hiện vì cache và origin là hai bản sao, và bất kỳ cơ chế đồng bộ nào — TTL, event-based invalidation, versioning — đều có cửa sổ giữa lúc origin đổi và lúc cache biết chuyện. Cái giá phải trả trong production không chỉ là "user nhìn thấy giá cũ vài giây". Khi một key hot vừa hết hạn, hàng nghìn request cùng miss, cùng đâm xuống DB để tính lại — đó là cache stampede (dogpile, thundering herd), và nó đủ sức đưa origin xuống trong vài chục giây.

Cơ chế hoạt động

Có bốn cơ chế invalidation dùng thật:

TTL (time-to-live). Mỗi entry gắn một hạn dùng. Hết hạn coi như miss, đọc lại từ origin. Đơn giản, không cần coordination giữa writer và cache. Nhược điểm: staleness bounded bởi TTL, và tất cả replica của cùng một key hết hạn cùng lúc.

Event-based invalidation. Khi origin thay đổi, phát một event (thường qua pub/sub, CDC như Debezium, hoặc gọi trực tiếp DEL) để cache xoá hoặc cập nhật entry. Fresh gần như realtime, nhưng đòi hỏi coupling giữa write path và cache — writer phải biết mọi key phái sinh từ dữ liệu vừa đổi.

Versioning (cache key có version). Key gắn version của dữ liệu, ví dụ user:123:v42. Đổi dữ liệu thì tăng version, key cũ tự nhiên bị bỏ qua, không cần xoá gì. Kỹ thuật này còn được gọi là generational caching; Rails cache dùng cách tương tự với cache_key_with_version.

Single-flight (request coalescing). Không phải invalidation, mà là cách xử lý miss: khi N request cùng miss cùng một key, chỉ một trong số đó được phép gọi origin, các request còn lại đợi kết quả của nó. Go có golang.org/x/sync/singleflight implement sẵn pattern này; Facebook memcache dùng "leases" (paper của Nishtala et al., NSDI 2013) cho cùng ý tưởng ở scale phân tán.

Kết hợp điển hình: TTL để bounded staleness, single-flight để chặn stampede khi key hết hạn, event-based invalidation để cắt TTL sớm khi có write. Ví dụ single-flight với Redis SETNX làm distributed lock:

func GetOrLoad(ctx context.Context, key string, ttl time.Duration, load func() ([]byte, error)) ([]byte, error) {
    if v, err := rdb.Get(ctx, key).Bytes(); err == nil {
        return v, nil // cache hit
    }
    lockKey := "lock:" + key
    // SET NX PX = chỉ set nếu chưa có, kèm TTL để tránh deadlock nếu holder chết
    ok, _ := rdb.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
    if !ok {
        // request khác đang load; đợi ngắn rồi thử lại cache
        time.Sleep(50 * time.Millisecond)
        return rdb.Get(ctx, key).Bytes()
    }
    defer rdb.Del(ctx, lockKey)
    v, err := load()
    if err != nil {
        return nil, err
    }
    rdb.Set(ctx, key, v, ttl)
    return v, nil
}
Enter fullscreen mode Exit fullscreen mode

Vấn đề gặp trong production

Failure mode 1: cache stampede khi key hot hết hạn. Một product page top-traffic có TTL 60s. Đúng giây thứ 60, mọi replica cache đồng loạt miss; nếu QPS là 10k, thì 10k request cùng gọi cùng một query nặng xuống DB trong cùng một tick. DB connection pool cạn, latency của cả service tăng vọt, health check timeout, autoscaler kích hoạt nhưng không kịp. Code sai điển hình:

func GetProduct(id string) (*Product, error) {
    if v, ok := cache.Get(id); ok {
        return v.(*Product), nil
    }
    // N request cùng vào đây khi key vừa hết hạn
    p, err := db.QueryProduct(id) // origin ăn N query cùng lúc
    if err != nil {
        return nil, err
    }
    cache.Set(id, p, 60*time.Second)
    return p, nil
}
Enter fullscreen mode Exit fullscreen mode

Sửa bằng single-flight (in-process với singleflight.Group nếu chỉ một node; distributed lock qua Redis SETNX nếu nhiều node). Ngoài single-flight, kỹ thuật XFetch (Vattani et al., "Optimal Probabilistic Cache Stampede Prevention", 2015) refresh entry sớm hơn TTL với xác suất tăng dần khi tới gần hạn — trải đều tải refresh thay vì dồn vào một tick.

Failure mode 2: stale data vô hạn khi event-based invalidation rớt event. Writer phát event DEL cache:user:123 qua Kafka, cache consumer xoá key. Nếu consumer bị lag, chết, hoặc event bị drop (Kafka retention hết trước khi consumer kịp đọc), key stale mãi mãi vì không có TTL backstop. Rule: event-based invalidation luôn đi kèm TTL — event là fast-path, TTL là safety net. Cấu hình TTL vừa đủ dài để chấp nhận cost, đủ ngắn để bound staleness khi event rớt.

Failure mode 3: thundering herd sau khi flush cache toàn cục. Deploy mới, ai đó chạy FLUSHDB để bust cache. Mọi key biến mất cùng lúc, mọi request tiếp theo miss, origin nhận toàn bộ traffic trực tiếp. Không phải stampede trên một key, mà stampede trên cả cache. Cách xử lý: không flush toàn cục ở giờ cao điểm; nếu cần bust, dùng versioning (đổi prefix key) để cache cũ hết dần theo TTL thay vì biến mất tức thì; hoặc warm cache trước khi cắt traffic.

Failure mode 4: write-through mà không invalidate downstream cache. App có L1 (in-process LRU) và L2 (Redis). Write đi qua và update Redis, nhưng L1 trên từng node vẫn giữ giá trị cũ tới khi TTL local hết. User hit node A thấy dữ liệu mới, hit node B thấy dữ liệu cũ — read-your-write bị phá. Sửa bằng cách hoặc bỏ L1 cho những entity cần consistency, hoặc phát invalidation message qua pub/sub tới tất cả node để mọi L1 evict đồng thời.

Failure mode 5: negative caching quên set TTL ngắn. Cache cả kết quả "không tồn tại" để chống cache penetration, nhưng dùng cùng TTL với hit thật. Khi entity thực sự được tạo sau đó, cache vẫn trả not found cho tới khi TTL hết. TTL của negative entry phải ngắn hơn nhiều so với positive entry.

Cách debug và monitor

Triệu chứng cache stampede: origin QPS/latency đột ngột spike theo bậc thang mỗi khi TTL của key hot hết hạn (đồ thị hình răng cưa); cache hit ratio đột nhiên tụt trong vài giây; DB connection pool queue depth tăng, một số request timeout. Triệu chứng stale data: user report thấy dữ liệu cũ sau khi write; log write path thành công nhưng read path vẫn trả giá trị trước đó; phân tán không đều giữa các node (L1 chưa evict).

Metric cần có:

  • Cache hit ratio — hit / (hit + miss). Thấp bất thường hoặc dip theo pattern là dấu hiệu invalidation quá aggressive hoặc TTL quá ngắn.
  • Origin request rate cho các key đi qua cache — spike khi TTL hết là dấu hiệu stampede.
  • Staleness distribution — nếu event-based, đo độ trễ từ lúc origin commit tới lúc cache reflect (cần timestamp trong payload).
  • Lock contention cho single-flight — số request phải đợi lock, thời gian đợi trung bình.
  • Redis INFO stats cho keyspace_hits, keyspace_misses, evicted_keys; redis-cli MONITOR (rất nặng, chỉ dùng ngắn) để xem pattern thực tế.

Trong Prometheus/Grafana, alert khi origin request rate cho một key spike gấp nhiều lần baseline trong vài giây. Rule phòng ngừa: mỗi entry phải có TTL (không entry sống mãi), mọi query nặng đi qua cache phải có single-flight, và mọi event-based invalidation phải có TTL backstop.

Tradeoff

TTL ngắn giảm staleness nhưng tăng origin load (miss nhiều hơn) và tăng nguy cơ stampede. TTL dài giảm origin load nhưng user thấy dữ liệu cũ lâu hơn. Event-based invalidation cho freshness gần realtime nhưng đòi hỏi coupling và infrastructure (message bus, CDC) — mỗi kênh event là một điểm có thể lỗi. Versioning tránh phải xoá gì cả và cho immutable cache entry (dễ CDN), nhưng tốn thêm indirection để nhìn ra version hiện hành, và cache cũ chiếm bộ nhớ tới khi TTL evict.

Single-flight loại bỏ stampede gần như hoàn toàn với cost một round-trip lock; nhược là nếu holder của lock chết mà không release, các request đợi bị block tới khi lock TTL hết — nên lock TTL phải ngắn và có fallback (đợi timeout rồi tự gọi origin). XFetch tránh cần lock, phân tán refresh theo xác suất, nhưng một số request phải chịu latency của refresh sớm dù cache còn hạn.

Rule of thumb: mọi entry cache dùng chung có TTL (không có ngoại lệ); mọi query có thể sinh stampede (query nặng, traffic cao) bọc single-flight; dùng event-based invalidation cho freshness nhưng luôn giữ TTL làm safety net; versioning cho immutable snapshot hoặc khi có CDN.

Câu hỏi phỏng vấn

Cache stampede là gì, xảy ra khi nào, và phòng thế nào trong một hệ có nhiều node cache?

Cache stampede (dogpile / thundering herd) xảy ra khi một key hot vừa expire hoặc bị invalidate; N request đang inflight cùng miss, cùng gọi origin tính lại cùng một giá trị. Origin (thường là DB hoặc service downstream) bất ngờ nhận N lần công việc trùng lặp, connection pool cạn, latency tăng, và có thể sập kéo theo cả service phụ thuộc. Nguyên nhân gốc là cache miss không có coordination giữa các request. Phòng bằng single-flight: chỉ một request được phép gọi origin cho một key tại một thời điểm, các request khác đợi kết quả — in-process dùng singleflight.Group (Go) hoặc tương đương, distributed dùng Redis SET NX EX làm lock với TTL ngắn để tránh deadlock nếu holder chết. Bổ sung XFetch (probabilistic early refresh) để refresh key sớm hơn TTL thật, trải đều tải refresh thay vì dồn vào một tick. Điểm ăn điểm là gọi tên hậu quả production cụ thể (DB connection pool cạn, cascade failure, autoscaler không kịp), phân biệt in-process vs distributed single-flight, và nêu TTL backstop cho event-based invalidation để tránh stale vô hạn khi event rớt.

Hands-on

Dựng một Redis local và một origin giả chậm, tái hiện stampede, đo, sửa bằng single-flight, đo lại.

docker run -d --name redis-stampede -p 6379:6379 redis:7
Enter fullscreen mode Exit fullscreen mode

Tạo file stampede.go:

package main

import (
    "context"
    "fmt"
    "sync"
    "sync/atomic"
    "time"

    "github.com/redis/go-redis/v9"
    "golang.org/x/sync/singleflight"
)

var (
    rdb         = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    originCalls int64
    sf          singleflight.Group
)

func slowOrigin() ([]byte, error) {
    atomic.AddInt64(&originCalls, 1)
    time.Sleep(200 * time.Millisecond) // giả query DB nặng
    return []byte("payload"), nil
}

func GetNaive(ctx context.Context, key string) ([]byte, error) {
    if v, err := rdb.Get(ctx, key).Bytes(); err == nil {
        return v, nil
    }
    v, err := slowOrigin()
    if err != nil {
        return nil, err
    }
    rdb.Set(ctx, key, v, 2*time.Second)
    return v, nil
}

func GetSingleFlight(ctx context.Context, key string) ([]byte, error) {
    if v, err := rdb.Get(ctx, key).Bytes(); err == nil {
        return v, nil
    }
    v, err, _ := sf.Do(key, func() (interface{}, error) {
        // double-check sau khi giành lock: có thể request khác đã set xong
        if v, err := rdb.Get(ctx, key).Bytes(); err == nil {
            return v, nil
        }
        v, err := slowOrigin()
        if err != nil {
            return nil, err
        }
        rdb.Set(ctx, key, v, 2*time.Second)
        return v, nil
    })
    if err != nil {
        return nil, err
    }
    return v.([]byte), nil
}

func run(name string, fn func(context.Context, string) ([]byte, error)) {
    atomic.StoreInt64(&originCalls, 0)
    rdb.Del(context.Background(), "hot")
    var wg sync.WaitGroup
    start := time.Now()
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fn(context.Background(), "hot")
        }()
    }
    wg.Wait()
    fmt.Printf("%s: 1000 requests, %d origin calls, took %v\n",
        name, atomic.LoadInt64(&originCalls), time.Since(start))
}

func main() {
    run("naive", GetNaive)
    run("single-flight", GetSingleFlight)
}
Enter fullscreen mode Exit fullscreen mode

Chạy:

go mod init stampede-lab
go get github.com/redis/go-redis/v9 golang.org/x/sync/singleflight
go run stampede.go
Enter fullscreen mode Exit fullscreen mode

Output kỳ vọng: bản naive báo cỡ vài trăm origin call cho 1000 request (mỗi request miss trước khi request đầu kịp set) — đúng là stampede. Bản single-flight báo đúng 1 origin call cho 1000 request cùng key. Chạy lại nhiều lần với sleep 2 giữa các lần để ép key expire và xem stampede tái xuất trong bản naive mỗi vòng TTL, trong khi bản single-flight vẫn giữ 1 origin call/vòng.

Bước tiếp: mở rộng bằng Redis SET NX EX để single-flight xuyên node (spawn hai process Go song song, xác nhận chỉ một trong hai gọi slowOrigin khi key expire). Thêm Prometheus counter cho origin_calls_totalcache_hits_total, scrape bằng prometheus local, vẽ chart hình răng cưa của bản naive rồi flat của bản fix — đó là artefact đưa vào postmortem hay design doc thật.

Top comments (0)