Redis, invalidation, cache stampede, TTLRedis, invalidation, cache stampede, TTL
Product API đọc theo thứ tự L1 trong process (Caffeine/ristretto, 30 giây) → Redis (10 phút ± 10%) → PostgreSQL. Khi ghi, service commit DB, xoá key Redis và phát tin để mọi instance bỏ L1. Bật “Key hết hạn, 1.000 request” để xem single-flight; bật “Sửa giá sản phẩm” để xem invalidation sau commit.The product API reads in order: in-process L1 (Caffeine/ristretto, 30 seconds) → Redis (10 minutes ± 10%) → PostgreSQL. On writes the service commits the DB, deletes the Redis key and broadcasts so every instance drops its L1 entry. Turn on “Key expires, 1,000 requests” to see single-flight; turn on “Price update” to see invalidation after commit.
1Người mua mở trang sản phẩm 42.A buyer opens the page for product 42.
Single-flight trong mỗi instance (ConcurrentHashMap + CompletableFuture, golang.org/x/sync/singleflight). Cần chặn giữa các instance thì thêm khoá SET lock:key NX PX 3000, ai không lấy được khoá thì chờ ngắn rồi đọc lại. Key rất nóng nên làm mới sớm: còn dưới 10% TTL thì một request nạp lại ở nền trong khi vẫn trả bản cũ.Single-flight inside each instance (ConcurrentHashMap + CompletableFuture, golang.org/x/sync/singleflight). To coordinate across instances add a lock with SET lock:key NX PX 3000; whoever misses the lock waits briefly and re-reads. For very hot keys refresh early: when less than 10% of the TTL remains, one request reloads in the background while the old value is still served.
Commit DB trước, xoá key sau (@TransactionalEventListener AFTER_COMMIT). Xoá trước commit thì một request đọc chen vào sẽ nạp lại giá cũ. Vẫn còn khe hở nhỏ khi một lượt đọc chậm ghi đè sau lượt xoá: dùng versioned key product:42:v8 hoặc xoá lần hai sau 500 ms. Muốn không bao giờ sót lần xoá thì phát sự kiện qua outbox hoặc CDC (Debezium).Commit the DB first, delete the key afterwards (@TransactionalEventListener AFTER_COMMIT). Deleting before commit lets a concurrent read reload the old price. A small window remains when a slow read writes back after the delete: use versioned keys such as product:42:v8 or a second delete 500 ms later. To never miss a delete, emit it through an outbox or CDC (Debezium).
Nạp cache hàng loạt lúc 9:00 với TTL 600 giây thì 9:10 tất cả cùng hết hạn. Cộng jitter ±10% vào TTL để thời điểm hết hạn trải đều.Warm the cache at 9:00 with a 600-second TTL and everything expires at 9:10. Add ±10% jitter to the TTL so expiry times spread out.
Một key nằm trên một shard của Redis Cluster nên 50k lượt đọc mỗi giây dồn vào một node. L1 trong process hấp thụ phần lớn lưu lượng; nếu vẫn chưa đủ thì nhân bản key thành product:42#0..7 và đọc ngẫu nhiên một bản.A key lives on one Redis Cluster shard, so 50k reads per second land on one node. The in-process L1 absorbs most of the traffic; if that is not enough, replicate the key as product:42#0..7 and read a random copy.
Bot quét /products/999999 thì mỗi lần đều miss và xuống DB. Negative caching: lưu giá trị MISSING với TTL ngắn (60 giây). Với không gian id rất lớn có thể thêm Bloom filter trước cache.A bot scanning /products/999999 misses every time and reaches the DB. Negative caching: store a MISSING marker with a short TTL (60 seconds). For a very large id space, put a Bloom filter in front of the cache.
Ước lượng 1,2 triệu × 2 KB ≈ 2,4 GB, nhân 1,5 cho overhead nên đặt maxmemory 4gb với allkeys-lfu: key nóng ở lại, key ít dùng bị loại. Caffeine dùng W-TinyLFU, ristretto dùng TinyLFU với giới hạn theo cost. Không đặt TTL và không có maxmemory là cách nhanh nhất để Redis bị OOM.Estimate 1.2M × 2 KB ≈ 2.4 GB, times 1.5 for overhead, so set maxmemory 4gb with allkeys-lfu: hot keys stay, rarely used keys go. Caffeine uses W-TinyLFU and ristretto uses TinyLFU with cost-based limits. No TTL and no maxmemory is the fastest way to an OOM Redis.
Số dư ví, tồn kho dùng để trừ khi đặt hàng, quyền truy cập vừa bị thu hồi, OTP đã dùng: những dữ liệu cần đúng tuyệt đối thì đọc thẳng DB qua repository. Cũng không cache phản hồi có dữ liệu cá nhân dưới key chung.Wallet balances, stock used for reservations, permissions that were just revoked, OTPs already used: data that must be exactly right is read straight from the DB through the repository. Never cache responses with personal data under a shared key.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
redis product:{id} | value = JSON ProductView (id, name, price, stock_label, version) · EX 600 ± 60 | Cache-aside, xoá sau commit. MISSING với EX 60 cho id không tồn tạiCache-aside, deleted after commit. MISSING with EX 60 for ids that do not exist |
redis lock:product:{id} | value = owner token · SET NX PX 3000 | Tuỳ chọn: chỉ một instance nạp DB khi cần chặn stampede giữa các instanceOptional: only one instance loads the DB when the stampede must be blocked across instances |
redis channel cache.inv | message = product id | Pub/sub, không lưu lại: mất tin thì L1 tự hết hạn sau 30 giâyPub/sub, not persisted: a lost message is covered by the 30-second L1 expiry |
product | id, name, price, stock, version, updated_at | Nguồn dữ liệu gốc; version tăng mỗi lần ghi, dùng được cho versioned keyThe source of truth; version grows on every write and can drive versioned keys |
L1 config | Caffeine maximumSize=10_000, expireAfterWrite=30s · ristretto MaxCost=64MB | Mỗi instance một bản; TTL ngắn là giới hạn trên của độ cũOne copy per instance; the short TTL caps staleness |
Đọc qua L1 rồi Redis; miss thì chỉ một lời gọi nạp DB, ghi lại với TTL có jitter, lưu cả kết quả không tồn tại. Khi dữ liệu đổi, xoá key sau commit.Read through L1, then Redis; on a miss only one call loads the DB and writes back with a jittered TTL, caching missing results too. When data changes, delete the key after commit.
@Service
@RequiredArgsConstructor
public class ProductCache {
private static final Duration TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
private final ProductRepository repo;
private final ProductCodec codec;
private final Map<Long, CompletableFuture<ProductView>> inFlight = new ConcurrentHashMap<>();
private final Cache<Long, ProductView> local = Caffeine.newBuilder()
.maximumSize(10_000).expireAfterWrite(Duration.ofSeconds(30)).build();
public ProductView get(long id) {
ProductView v = local.getIfPresent(id); // L1, ~100 ns
if (v != null) return v;
String raw = redis.opsForValue().get(key(id)); // L2, ~0.5 ms
if (raw != null) return remember(id, codec.read(raw));
CompletableFuture<ProductView> mine = new CompletableFuture<>();
CompletableFuture<ProductView> leader = inFlight.putIfAbsent(id, mine);
if (leader != null) return leader.join(); // wait for the loader
try {
ProductView loaded = repo.findView(id).orElse(ProductView.MISSING);
Duration ttl = loaded.isMissing() ? Duration.ofSeconds(60) : jitter(TTL);
redis.opsForValue().set(key(id), codec.write(loaded), ttl);
mine.complete(loaded);
return remember(id, loaded);
} catch (RuntimeException ex) {
mine.completeExceptionally(ex);
throw ex;
} finally {
inFlight.remove(id);
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onChanged(ProductChanged e) {
redis.delete(key(e.id())); // delete, never SET
redis.convertAndSend("cache.inv", String.valueOf(e.id()));
}
public void evictLocal(long id) { local.invalidate(id); } // cache.inv subscriber
private ProductView remember(long id, ProductView v) { local.put(id, v); return v; }
private static Duration jitter(Duration base) {
long s = base.toSeconds();
return Duration.ofSeconds(s + ThreadLocalRandom.current().nextLong(-s / 10, s / 10 + 1));
}
private static String key(long id) { return "product:" + id; }
}type ProductCache struct {
rdb *redis.Client
repo ProductRepo
local *ristretto.Cache[int64, Product]
group singleflight.Group
}
const baseTTL = 10 * time.Minute
func (c *ProductCache) Get(ctx context.Context, id int64) (Product, error) {
if p, ok := c.local.Get(id); ok {
return p, nil
}
key := "product:" + strconv.FormatInt(id, 10)
if raw, err := c.rdb.Get(ctx, key).Bytes(); err == nil {
p, err := decode(raw)
if err == nil {
c.local.SetWithTTL(id, p, 1, 30*time.Second)
}
return p, err
}
v, err, _ := c.group.Do(key, func() (any, error) {
loadCtx := context.WithoutCancel(ctx)
p, found, err := c.repo.FindByID(loadCtx, id)
if err != nil {
return Product{}, err
}
ttl := jitter(baseTTL)
if !found {
p, ttl = Missing, time.Minute // negative cache
}
c.rdb.Set(loadCtx, key, encode(p), ttl)
c.local.SetWithTTL(id, p, 1, 30*time.Second)
return p, nil
})
if err != nil {
return Product{}, err
}
return v.(Product), nil
}
// Called by the service after tx.Commit returns nil.
func (c *ProductCache) Invalidate(ctx context.Context, id int64) error {
c.local.Del(id)
key := "product:" + strconv.FormatInt(id, 10)
if err := c.rdb.Del(ctx, key).Err(); err != nil {
return err
}
return c.rdb.Publish(ctx, "cache.inv", id).Err()
}
func jitter(d time.Duration) time.Duration {
return d + time.Duration(rand.Int64N(int64(d)/5)) - d/10
}| Cache-aside (lazy loading)Cache-aside (lazy loading) | Write-through / write-behindWrite-through / write-behind | |
|---|---|---|
| Cách chạyHow it works | App đọc cache, miss thì đọc DB rồi ghi cache; khi ghi thì xoá keyThe app reads the cache, loads the DB on a miss and fills the cache; writes delete the key | Mọi lần ghi đi qua cache; write-through ghi DB đồng bộ, write-behind ghi DB sau theo lôEvery write goes through the cache; write-through writes the DB synchronously, write-behind writes it later in batches |
| Nhất quánConsistency | Cũ tối đa bằng TTL nếu sót lần xoá; lần đọc đầu sau khi ghi luôn missStale for at most one TTL if a delete is missed; the first read after a write always misses | Cache luôn có bản mới; write-behind có thể mất dữ liệu nếu cache chết trước khi xả xuống DBThe cache always holds the latest value; write-behind can lose data if the cache dies before flushing |
| Chi phíCost | Chỉ cache dữ liệu thực sự được đọcCaches only data that is actually read | Cache cả dữ liệu không ai đọc; cần lớp cache hiểu được DB (read-through provider)Caches data nobody reads; needs a cache layer that knows the DB (a read-through provider) |
| Chọn khiPick it when | Đọc nhiều ghi ít, chấp nhận cũ vài giây: catalog, profile, cấu hìnhRead-heavy, a few seconds of staleness is fine: catalogs, profiles, configuration | Ghi dồn dập cần gom (đếm view, leaderboard), hoặc đọc ngay sau ghi phải trúng cacheBursty writes worth batching (view counters, leaderboards), or reads right after writes must hit |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Xoá. Hai request ghi song song có thể commit theo thứ tự A, B nhưng SET cache theo thứ tự B, A, để lại giá trị của A mãi tới khi hết TTL. Xoá thì lần đọc sau luôn nạp bản đã commit. Và phải xoá sau commit, không phải trước.Delete. Two concurrent writes can commit as A, B but SET the cache as B, A, leaving A's value until the TTL runs out. A delete makes the next read load the committed version. And delete after commit, not before.
Thường là đủ: DB nhận tối đa 50 query cho một key thay vì hàng chục nghìn. Nếu query nặng (vài giây) thì thêm khoá Redis SET NX PX có thời hạn, hoặc làm mới sớm ở nền để key không bao giờ thực sự hết hạn khi còn nóng.Usually: the DB gets at most 50 queries for a key instead of tens of thousands. If the query is heavy (seconds), add a Redis lock with SET NX PX, or refresh early in the background so a hot key never actually expires.
Client Redis phải có timeout ngắn (50–100 ms) và circuit breaker; lỗi cache được coi là miss, không phải lỗi request. L1 và single-flight giảm tải, nhưng DB vẫn phải được tính để chịu một phần lưu lượng, nếu không thì cache đã trở thành dependency bắt buộc.The Redis client needs a short timeout (50–100 ms) and a circuit breaker; a cache error counts as a miss, not a failed request. L1 and single-flight reduce load, but the DB must be sized to take part of the traffic, otherwise the cache has become a hard dependency.
Mỗi instance có một bản L1 riêng nên độ cũ khác nhau giữa các instance. Phải giữ TTL L1 ngắn (vài chục giây), phát invalidation qua pub/sub và chấp nhận rằng pub/sub có thể mất tin.Each instance has its own L1, so staleness differs between instances. Keep the L1 TTL short (tens of seconds), broadcast invalidations over pub/sub and accept that pub/sub can drop messages.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.