Thứ Hai đầu tuần, database PostgreSQL báo CPU 82%. Mọi request từ API đều query thẳng DB, product listing, user profile, order history, tất cả. Latency P95 đã lên 1.2 giây, user bắt đầu phàn nàn “trang chậm quá”. Team quyết định thêm Redis làm cache layer. Sau một ngày triển khai cache-aside cho các endpoint đọc nhiều nhất, latency P95 rơi xuống 40ms, DB CPU tụt còn 25%. Mọi người vui mừng, cho đến thứ Tư, khi khách hàng gọi support: “Tôi đổi số điện thoại rồi mà vẫn hiện số cũ”. Rồi thứ Năm: “Giá sản phẩm trên trang khác giá lúc thanh toán”. Stale data bắt đầu gây mất niềm tin.

Phil Karlton từng nói: “There are only two hard things in Computer Science: cache invalidation and naming things.” Đó không phải câu nói đùa, cache invalidation thực sự là bài toán khó vì nó nằm ở giao điểm giữa consistency, performance, và operational complexity. Bài này đi qua các cache pattern phổ biến, chiến lược invalidation, và những cạm bẫy mà hầu như team nào cũng phải trải qua ít nhất một lần.


Tại sao cần cache, và cái giá phải trả

Cache tồn tại vì một lý do rất thực dụng: đọc từ memory nhanh hơn đọc từ disk khoảng 100-1000 lần, và nhanh hơn network call tới database vài chục đến vài trăm lần. Khi 80% request của hệ thống là đọc (và con số này đúng với phần lớn web app), đặt một lớp cache phía trước database giảm tải đáng kể, cả latency lẫn throughput.

Nhưng cache không phải bữa ăn miễn phí. Khi bạn có hai nơi lưu cùng một dữ liệu, database là source of truth và cache là bản sao, bạn tạo ra bài toán consistency: làm sao đảm bảo cache luôn phản ánh đúng dữ liệu mới nhất trong DB? Câu trả lời là “không thể đảm bảo 100% mọi lúc”, và mọi cache strategy đều là sự đánh đổi giữa freshness (dữ liệu mới) và performance (tốc độ phản hồi).

Hiểu rõ các pattern cache và chọn đúng strategy cho từng use case là kỹ năng cốt lõi khi thiết kế backend. Không có “một strategy đúng cho mọi trường hợp”, product catalog có thể chịu stale 5 phút, nhưng số dư tài khoản thì không được stale 5 giây.


Cache-aside, pattern phổ biến nhất

Hiểu nôm na thì cache-aside giống như cái tủ lạnh ở nhà, khi đói thì mở tủ trước, có đồ thì lấy ngay, không có thì mới ra chợ mua rồi cất vào. Hết hạn thì vứt đi chứ không cập nhật tại chỗ.

Cache-aside (hay lazy loading) là pattern mà hầu hết developer gặp đầu tiên và dùng nhiều nhất. Logic rất đơn giản: application tự quản lý cả cache lẫn database, cache chỉ là kho lưu trữ thụ động.

sequenceDiagram participant Client participant App participant Cache as Cache (Redis) participant DB as Database ````text Client->>App: GET /api/products/42 App->>Cache: GET product:42 alt Cache HIT Cache-->>App: data App-->>Client: 200 OK (từ cache) else Cache MISS Cache-->>App: null App->>DB: SELECT * FROM products WHERE id=42 DB-->>App: data App->>Cache: SET product:42 data TTL=300s App-->>Client: 200 OK (từ DB) end ```text

Khi đọc, app kiểm tra cache trước. Cache hit thì trả ngay, nhanh, không tốn DB resource. Cache miss thì đọc DB, ghi kết quả vào cache với TTL, rồi trả cho client. Lần request sau sẽ hit cache.

Khi ghi, app ghi vào DB trước, rồi xoá cache entry tương ứng (invalidate). Không phải ghi vào cache, xoá. Lần đọc tiếp theo sẽ miss cache, đọc DB lấy data mới, rồi populate cache lại. Pattern “invalidate on write” này đơn giản và ít bug hơn so với “update cache on write”, vì update cache đòi hỏi bạn phải tái tạo chính xác giá trị cache từ write operation, dễ sai khi data có transform hoặc join.

Ưu điểm của cache-aside là application có toàn quyền kiểm soát, biết chính xác khi nào đọc cache, khi nào đọc DB, khi nào invalidate. Không cần cache layer thông minh, Redis hay Memcached thuần đều chạy được. Phù hợp với hầu hết read-heavy workload.

Nhược điểm là mỗi cache miss gây thêm một lần ghi cache (latency tăng nhẹ so với chỉ đọc DB). Và quan trọng hơn: giữa lúc ghi DB và xoá cache, có một cửa sổ thời gian nhỏ mà dữ liệu trong cache cũ hơn DB, stale window. Với hầu hết ứng dụng, stale window vài chục millisecond này chấp nhận được. Nhưng nếu không cẩn thận, ví dụ xoá cache trước khi ghi DB, bạn có thể tạo race condition khiến cache chứa data cũ vô thời hạn cho đến khi TTL hết.

Thứ tự đúng khi ghi: ghi DB trước, xoá cache sau. Nếu ghi DB fail, cache vẫn chứa data cũ nhưng ít nhất là consistent với DB. Nếu xoá cache fail, worst case là cache stale cho đến khi TTL hết, vẫn tốt hơn mất data.


Read-through, cache tự biết đọc DB

Read-through nhìn bề ngoài giống cache-aside, nhưng khác ở chỗ: application không tự đọc DB khi cache miss. Thay vào đó, cache layer (hoặc một abstraction phía trước cache) tự động đọc DB, populate cache, rồi trả kết quả cho app. Application chỉ nói chuyện với cache, không biết và không cần biết DB ở đâu.

Lợi ích chính là đơn giản hoá application code. Mọi read đều qua một interface duy nhất: cache.get(key). Logic “miss thì đọc DB rồi populate” nằm trong cache layer, không rải khắp business logic. Khi có 20 endpoint đều đọc cùng pattern, bạn không phải copy-paste logic cache-aside 20 lần.

Nhưng read-through đòi hỏi cache layer phải “biết” cách đọc DB, cần cấu hình data loader hoặc callback. Không phải mọi cache engine đều hỗ trợ native, Redis thuần không có read-through built-in, bạn phải tự xây abstraction hoặc dùng framework (ví dụ Spring Cache với @Cacheable annotation thực chất là read-through pattern).

Nhược điểm tương tự cache-aside: lần đầu đọc (cold cache) vẫn chậm vì phải đọc DB. Data model phải đơn giản, nếu cache value cần join 3 bảng hoặc transform phức tạp, data loader trở nên phức tạp và khó maintain.


Write-through, ghi cache và DB cùng lúc

Write-through đảo ngược hướng: mỗi write operation ghi vào cache trước (hoặc đồng thời), rồi cache layer ghi xuống DB. Application không ghi trực tiếp DB, mọi write đi qua cache.

Ưu điểm rõ ràng nhất: cache luôn có data mới nhất sau mỗi write. Không có stale window giữa DB và cache vì cả hai được cập nhật trong cùng operation. Read sau write luôn thấy data mới, consistency mạnh hơn cache-aside.

Nhưng cái giá là write latency tăng. Mỗi write phải chờ cả cache lẫn DB ghi xong mới trả response. Nếu cache ở region khác DB, latency cộng dồn đáng kể. Và nếu DB ghi chậm (do lock contention, disk IO), write-through không giúp gì, nó chỉ thêm overhead cache write lên trên.

Write-through cũng lãng phí cache space nếu data vừa ghi ít khi được đọc lại. Ví dụ: audit log ghi xong hiếm khi ai query, populate cache cho data này là phí memory. Write-through phù hợp khi data vừa ghi rất hay được đọc ngay sau đó, ví dụ user profile sau khi update, session data sau login.

Trong thực tế, write-through thuần ít được dùng một mình. Thường kết hợp với read-through để tạo một cache layer hoàn chỉnh: read-through cho đọc, write-through cho ghi, application chỉ nói chuyện với cache.


Write-behind (write-back), nhanh nhưng nguy hiểm

Write-behind là bản “bất đồng bộ” của write-through. Application ghi vào cache, trả response cho client ngay. Cache layer ghi xuống DB sau đó, async, có thể batch nhiều write lại rồi flush một lần.

Ưu điểm: write latency cực thấp vì client không phải chờ DB. Throughput cao vì batch write giảm số round-trip tới DB. Phù hợp cho workload write-heavy mà có thể chấp nhận eventual consistency, ví dụ view count, analytics event, hoặc shopping cart state.

Nhưng rủi ro lớn nhất là data loss. Nếu cache server crash trước khi flush xuống DB, data trong cache mất vĩnh viễn, DB không bao giờ nhận được write đó. Với Redis thì AOF persistence giảm thiểu rủi ro nhưng không loại bỏ hoàn toàn (vẫn có thể mất data trong khoảng giữa hai lần fsync).

Write-behind cũng phức tạp hơn về vận hành: cần monitor queue size (write chưa flush), cần retry logic khi DB fail, cần xử lý conflict khi cùng key được write nhiều lần trước khi flush. Nếu DB constraint bị vi phạm khi flush (ví dụ foreign key fail), bạn phải xử lý lỗi async, không thể trả lỗi cho client vì client đã nhận success response.

Mình rất ít khuyên dùng write-behind cho business-critical data, order, payment, inventory. Với non-critical data mà mất một ít chấp nhận được, page view count, click tracking, thì write-behind hợp lý.


Bảng so sánh nhanh các pattern

Bốn pattern trên giải quyết các bài toán khác nhau, và việc hiểu trade-off giúp chọn đúng.

Cache-aside cho application kiểm soát hoàn toàn, dễ implement, phù hợp hầu hết read-heavy workload, nhưng application code phải xử lý cả cache lẫn DB logic. Read-through đơn giản hoá application nhưng cần infrastructure hỗ trợ. Write-through cho consistency mạnh nhưng tốn latency ghi. Write-behind cho write nhanh nhưng rủi ro mất data.

Trong thực tế, cache-aside là lựa chọn mặc định cho phần lớn ứng dụng. Chuyển sang pattern khác khi có lý do rõ ràng, không phải vì “pattern kia nghe hay hơn”.


Cache invalidation, bài toán trung tâm

Invalidation là câu hỏi “khi nào cache nên bỏ data cũ đi?”, và mọi câu trả lời đều có trade-off.

TTL-based invalidation

Đây là cách đơn giản nhất: gán thời gian sống (Time To Live) cho mỗi cache entry. Hết TTL thì cache tự xoá, request tiếp theo miss cache và đọc DB lấy data mới.

TTL dễ implement, dễ hiểu, và quan trọng nhất, tự chữa lành. Dù có bug gì trong invalidation logic, TTL đảm bảo data cũ sẽ bị xoá sau một khoảng thời gian hữu hạn. Đây là lý do mình luôn khuyên đặt TTL cho mọi cache entry, kể cả khi đã có invalidation chủ động, TTL là safety net.

Nhược điểm là stale window. Nếu TTL = 5 phút, user update profile xong vẫn thấy data cũ trong tối đa 5 phút. TTL ngắn giảm stale window nhưng tăng cache miss rate (tăng tải DB). TTL dài giảm tải DB nhưng data cũ lâu hơn.

Chọn TTL phù thuộc vào tolerance of staleness của từng use case. Product catalog hiếm khi thay đổi, TTL 10-30 phút hợp lý. User session nên TTL ngắn hơn (vài phút) hoặc invalidate chủ động. Inventory count (số lượng tồn kho) gần như không nên cache với TTL dài vì stale inventory dẫn đến overselling.

Event-based invalidation

Thay vì chờ TTL hết, invalidate ngay khi data thay đổi. Khi service ghi DB, phát event (qua message queue, pub/sub, hoặc CDC stream): “product 42 đã update”. Consumer nhận event, xoá cache key product:42. Request tiếp theo miss cache, đọc DB lấy data mới.

Event-based invalidation cho stale window gần bằng 0, thời gian từ lúc ghi DB đến lúc cache bị xoá chỉ vài chục millisecond (tuỳ latency message queue). Consistency tốt hơn TTL rất nhiều.

Nhưng phức tạp hơn đáng kể. Cần message infrastructure (Kafka, RabbitMQ, Redis Pub/Sub). Cần đảm bảo event không bị mất, nếu event mất, cache không bị invalidate, data stale vô thời hạn (trừ khi có TTL backup). Cần xử lý event ordering, nếu event “update product 42 lần 1” đến sau event “update product 42 lần 2”, bạn có thể invalidate sai thứ tự.

Mình luôn kết hợp event-based invalidation với TTL, event invalidate chủ động cho đa số trường hợp, TTL làm safety net cho trường hợp event bị mất hoặc delay. Hai lớp phòng thủ tốt hơn một.

Version-based invalidation

Thay vì xoá cache entry, đổi version trong cache key. Khi product 42 update, cache key chuyển từ product:42:v3 sang product:42:v4. Request mới dùng key v4, miss cache, đọc DB. Key v3 cũ vẫn tồn tại nhưng không ai dùng nữa, tự bị evict khi hết TTL hoặc memory full.

Ưu điểm: không cần xoá cache key chủ động, tránh race condition khi nhiều process cùng xoá. Rollback dễ dàng, quay version về v3 thì cache cũ vẫn còn. Nhược điểm: cần lưu version number ở đâu đó (DB, Redis counter), mỗi read phải lấy version trước rồi mới build cache key, thêm một round-trip.

Version-based hữu ích khi bạn cần invalidate cả nhóm cache entry liên quan, ví dụ khi schema thay đổi, tăng version prefix là invalidate toàn bộ. Nhưng cho single-key invalidation thông thường, event-based + TTL đơn giản hơn.


Cache stampede và thundering herd

Đây là vấn đề mà mọi hệ thống có cache đủ lớn đều sẽ gặp, và nếu không xử lý trước thì nó sẽ đến vào lúc bất tiện nhất, thường là khi traffic đang cao.

Cache stampede xảy ra khi một cache key phổ biến hết hạn, và hàng trăm request đồng thời đều miss cache, đều query DB cùng lúc cho cùng dữ liệu. DB nhận spike query đột ngột, có thể overload, latency tăng vọt cho toàn hệ thống. Query xong, tất cả đều ghi kết quả vào cache, 100 lần ghi cùng data, 99 lần thừa.

Thundering herd là biến thể tương tự nhưng ở quy mô lớn hơn, khi cache server restart hoặc flush toàn bộ, mọi key đều miss cùng lúc, DB nhận toàn bộ traffic mà cache lẽ ra phải chặn.

Singleflight / request coalescing

Giải pháp phổ biến nhất cho stampede là singleflight: khi nhiều goroutine/thread cùng request key đang miss, chỉ một request thực sự đi DB, các request còn lại chờ kết quả từ request đầu tiên.

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

var group singleflight.Group

func GetProduct(ctx context.Context, id string) (*Product, error) {
    key := "product:" + id

```text
// Kiểm tra cache trước
cached, err := redis.Get(ctx, key).Result()
if err == nil {
    return deserialize(cached)
}

// Singleflight: chỉ 1 request đi DB, các request khác chờ
v, err, _ := group.Do(key, func() (interface{}, error) {
    product, err := db.GetProduct(ctx, id)
    if err != nil {
        return nil, err
    }
    redis.Set(ctx, key, serialize(product), 5*time.Minute)
    return product, nil
})
if err != nil {
    return nil, err
}
return v.(*Product), nil

```text
}

```text
Pattern Go ở trên dùng `singleflight.Group`, chỉ request đầu tiên thực sự gọi DB, các request khác cùng key chờ kết quả. Trong Node.js, pattern tương tự là dùng Promise dedup, lưu Promise đang pending cho mỗi key, request mới await Promise đó thay vì tạo Promise mới.

### Probabilistic early expiration

Thay vì chờ TTL hết rồi mới miss, mỗi request khi đọc cache sẽ tính xác suất "expire sớm" dựa trên thời gian còn lại của TTL. Khi TTL gần hết, xác suất tăng, một request may mắn sẽ trigger refresh cache **trước khi key hết hạn**, trong khi các request khác vẫn đọc cache cũ bình thường.

Thuật toán XFetch là ví dụ điển hình: `shouldRefresh = (currentTime - fetchTime) > TTL * β * log(random())` với β thường chọn khoảng 1.0. Khi thời gian gần hết TTL, xác suất shouldRefresh tăng dần, đảm bảo cache được refresh trước khi expire thật.

Ưu điểm là không cần lock, không cần coordination, mỗi instance tự quyết định có refresh không. Nhược điểm là vẫn có thể có 2-3 request trùng refresh cùng lúc (xác suất thấp nhưng không bằng 0), và logic phức tạp hơn TTL thuần.

### Background refresh

Cách tiếp cận khác: cache entry có TTL dài (hoặc không expire), nhưng một background job định kỳ refresh data từ DB vào cache. Client luôn đọc từ cache, không bao giờ miss, cache luôn nóng, không có stampede.

Phù hợp cho data thay đổi ít nhưng đọc rất nhiều, ví dụ product catalog, site config, exchange rate. Không phù hợp cho data per-user vì số key quá lớn để refresh toàn bộ.

Nhược điểm: stale window bằng khoảng cách giữa hai lần refresh. Và nếu background job fail, cache có thể chứa data cũ rất lâu, cần monitoring job health.

---

## Cache warming, khởi động nóng

Cold start là lúc cache trống rỗng, sau deploy mới, sau cache flush, hoặc khi scale thêm instance. Mọi request đều miss cache, DB nhận toàn bộ traffic. Nếu hệ thống đã depend vào cache cho performance (và hầu hết hệ thống có cache đều vậy), cold start có thể là mini-outage.

Cache warming là pre-populate cache trước khi traffic đến. Cách đơn giản nhất: chạy script đọc top N key phổ biến nhất từ DB và ghi vào cache. Có thể dựa trên access log phân tích key nào được query nhiều nhất, hoặc đơn giản là warm toàn bộ bảng nếu data nhỏ.

Một pattern mình thấy hiệu quả: khi deploy instance mới, instance đó nhận traffic dần dần (canary hoặc weight-based routing). Traffic ít ban đầu gây cache miss ở tốc độ DB chịu được, cache nóng dần, rồi mới nhận full traffic. Kubernetes rolling update tự nhiên có hành vi tương tự, pod mới nhận traffic từ từ khi readiness probe pass.

Cẩn thận với cache warming quá aggressive, warm 10 triệu key cùng lúc có thể gây spike load lên DB tương tự stampede. Warm dần dần, throttle write, hoặc warm từ replica DB thay vì primary.

---

## Multi-level cache, L1 và L2

Một lớp cache Redis đã nhanh, latency khoảng 0.5-1ms qua network. Nhưng khi cần nhanh hơn nữa, hoặc khi Redis trở thành bottleneck (throughput hoặc bandwidth), thêm cache cấp hai: in-process cache (L1) ngay trong memory của application.

L1 là cache nằm trong process, Caffeine (Java), lru-cache (Node.js), groupcache (Go). Latency gần bằng 0 (memory access, không qua network). Nhưng capacity nhỏ (bị giới hạn bởi heap size), và mỗi instance có L1 riêng, không chia sẻ giữa instances.

L2 là cache distributed, Redis, Memcached. Capacity lớn hơn nhiều, chia sẻ giữa mọi instance. Nhưng có network latency.

Flow: app đọc L1 trước. L1 miss → đọc L2 (Redis). L2 miss → đọc DB, ghi L2, ghi L1. Khi data thay đổi: invalidate L2 (xoá key Redis), nhưng **L1 trên các instance khác vẫn chứa data cũ**, đây là bài toán consistency giữa hai lớp cache.

Giải pháp phổ biến: L1 có TTL rất ngắn (vài giây đến vài chục giây) để tự hết hạn nhanh. Hoặc dùng Redis Pub/Sub để broadcast invalidation event, khi một instance invalidate L2, nó publish message, các instance khác nhận message và xoá L1 local. Pattern thứ hai cho consistency tốt hơn nhưng phức tạp hơn.

Multi-level cache hữu ích khi read QPS rất cao (hàng chục nghìn trở lên) và data thay đổi chậm. Với hầu hết ứng dụng, chỉ L2 Redis là đủ, thêm L1 khi profiling cho thấy Redis latency là bottleneck thực sự, không phải thêm vì "nghe hay".

---

## Cache key design

Cache key tưởng đơn giản, `product:42`, nhưng key design tệ gây collision, gây khó debug, và gây khó invalidation hàng loạt.

Namespace prefix là bắt buộc. `product:42` có thể collision với key của service khác dùng chung Redis. Dùng `order-svc:product:42` hoặc `v1:product:42` để tránh. Prefix theo version (`v1:`, `v2:`) cho phép invalidate toàn bộ cache khi schema thay đổi, deploy version mới dùng prefix `v2:`, toàn bộ key `v1:` tự hết hạn theo TTL.

Key phải **deterministic**, cùng input luôn sinh cùng key. Nếu key chứa query parameter, phải sort parameter trước khi hash: `?page=1&sort=name` và `?sort=name&page=1` phải sinh cùng key. Quên sort là tạo hai cache entry cho cùng data, tốn memory và invalidation phải xoá cả hai.

Tránh key quá dài, Redis key tốn memory, key 500 bytes cho hàng triệu entry là đáng kể. Nhưng cũng tránh key quá ngắn đến mức không debug được, `p:42` không ai hiểu khi grep Redis. Cân bằng hợp lý: `svc:product:42` là đủ rõ.

Với cache cho query phức tạp (filter, sort, pagination), hash toàn bộ query parameter thành digest ngắn: `svc:products:list:sha256(sortBy=name&category=phone&page=2)`. Nhưng cẩn thận: invalidation trở nên khó vì bạn không biết tất cả key nào chứa product 42 khi product 42 thay đổi. Đây là lý do nhiều team chọn **invalidate theo prefix** (xoá tất cả key `svc:products:list:*`) thay vì per-key, đơn giản hơn dù tốn cache miss nhiều hơn.

---

## Redis vs Memcached

Hai engine cache phổ biến nhất, và câu hỏi "nên dùng cái nào" xuất hiện trong mọi design review.

Memcached đơn giản, chỉ hỗ trợ key-value string, không có data structure phức tạp. Multi-threaded by design nên tận dụng nhiều core tốt. Không có persistence, restart là mất hết data. Phù hợp khi bạn chỉ cần cache thuần, không cần data structure, và chấp nhận cold start sau restart.

Redis mạnh hơn nhiều, hỗ trợ hash, list, set, sorted set, stream, pub/sub. Có persistence (RDB snapshot + AOF log) nên restart không mất data hoàn toàn. Hỗ trợ Lua scripting cho atomic operation phức tạp. Clustering với Redis Cluster cho horizontal scaling. Pub/Sub cho event broadcasting (dùng cho cache invalidation giữa instances).

Trong thực tế, Redis thắng áp đảo cho hầu hết use case vì versatility, bạn dùng Redis cho cache, cho session store, cho rate limiter, cho pub/sub, cho queue nhẹ (nói thật: 80% case chọn Redis vì nó đã có sẵn trong stack, không phải vì đã ngồi benchmark kỹ với Memcached). Memcached chỉ hợp khi bạn cần cache thuần với throughput cực cao và không cần bất kỳ feature nào khác.

Một điểm ít người để ý: Redis single-threaded cho command execution (từ v6 có IO threading nhưng command vẫn single-threaded). Với workload mà mỗi command nhanh (GET/SET key nhỏ), Redis đạt hàng trăm nghìn ops/giây trên một instance, đủ cho phần lớn ứng dụng. Nhưng nếu bạn chạy command chậm (KEYS *, SORT trên set lớn, Lua script dài), nó block toàn bộ instance. Tuyệt đối không chạy `KEYS *` trên production Redis, dùng `SCAN` thay thế.

---

## Eviction policies, khi cache đầy

Cache có capacity hữu hạn. Khi memory full, Redis cần quyết định xoá key nào để nhường chỗ cho key mới. Eviction policy quyết định key nào bị "hi sinh".

**LRU (Least Recently Used)** xoá key ít được truy cập gần đây nhất. Logic: nếu lâu rồi không ai đọc thì chắc không cần nữa. Redis implement approximated LRU (sample N key random, xoá key có last access cũ nhất trong sample) thay vì exact LRU, tiết kiệm memory cho metadata. LRU là policy mặc định hợp lý cho hầu hết use case.

**LFU (Least Frequently Used)** xoá key ít được truy cập nhất (tính theo tổng số lần). Khác LRU ở chỗ: key được truy cập rất nhiều trong quá khứ nhưng gần đây không ai dùng vẫn được giữ (LRU sẽ xoá). LFU tốt hơn LRU khi access pattern có "popular items" rõ ràng, top 100 product được xem liên tục, còn lại long tail. Redis 4.0+ hỗ trợ LFU.

**TTL-based eviction** (`volatile-ttl`) ưu tiên xoá key có TTL sắp hết, hợp lý vì key sắp hết hạn anyway. Chỉ áp dụng cho key có TTL; key không set TTL không bao giờ bị evict. Cẩn thận: nếu bạn có key quan trọng không set TTL và policy là `volatile-*`, những key đó sẽ tồn tại mãi, có thể chiếm hết memory.

**allkeys-random** xoá key random, đơn giản, unpredictable, hiếm khi là lựa chọn tốt trừ khi access pattern hoàn toàn uniform.

**noeviction**, Redis từ chối write mới khi memory full. Application nhận lỗi. Phù hợp khi bạn dùng Redis như data store (không chỉ cache) và không muốn mất data bất ngờ.

Khuyến nghị: dùng `allkeys-lru` hoặc `allkeys-lfu` cho cache use case. Monitor `evicted_keys` metric, nếu eviction rate cao liên tục, cache đang thiếu memory, cần scale up hoặc review xem có key nào không cần cache.

---

## Anti-pattern phổ biến

### Cache everything

Không phải data nào cũng nên cache. Data thay đổi liên tục (inventory count realtime, stock price) cache xong bị invalidate ngay, tốn effort mà không giảm tải DB đáng kể. Data ít khi đọc (audit log, report cũ) cache là phí memory. Chỉ cache data **đọc nhiều, thay đổi ít**, sweet spot của caching.

### Không set TTL

Mọi cache entry phải có TTL. Key không có TTL tồn tại vĩnh viễn (hoặc cho đến khi bị evict), nếu invalidation logic có bug, data cũ sống mãi. TTL là safety net cuối cùng chống stale data. Dù đã có event-based invalidation, vẫn set TTL, defense in depth.

### Cache as primary data store

Cache là bản sao, không phải nguồn sự thật. Nếu bạn ghi vào Redis mà không ghi DB, và Redis crash, data mất. Mình từng thấy team lưu shopping cart chỉ trong Redis, user add item, Redis chết, cart trống, user tức giận. Shopping cart là data có giá trị, lưu DB, cache Redis cho performance.

### Ignoring cold start

Deploy service mới, cache trống, 100% request đi DB, DB overload, cascade failure. Đã có team phải rollback deploy chỉ vì cold cache gây DB spike. Plan cache warming hoặc gradual traffic ramp-up trước khi nhận full load.

### Cache key không có namespace

Hai service cùng dùng Redis cluster, cùng dùng key `user:42`, service A ghi profile, service B đọc ra nhận session data. Collision thầm lặng, bug khó tìm vì data "có" nhưng sai format. Namespace mọi key bằng service name hoặc domain prefix.

---

## Monitoring cache, những con số cần theo dõi

Cache không phải setup xong rồi quên. Một số metric cần trên dashboard và có alert đi kèm.

**Hit rate** là metric quan trọng nhất, tỷ lệ request được phục vụ từ cache. Hit rate 95% nghĩa là chỉ 5% request đi DB. Hit rate giảm đột ngột là dấu hiệu có vấn đề: TTL quá ngắn, cache bị flush, hoặc access pattern thay đổi. Hit rate dưới 80% cho cache-aside thì cần xem lại cache strategy, có thể data không phù hợp để cache.

**Miss rate** tương ứng ngược với hit rate. Miss rate tăng spike thường correlate với DB latency tăng, vì mỗi miss là một DB query.

**Eviction rate**, số key bị evict mỗi giây vì memory full. Eviction rate cao liên tục nghĩa là cache thiếu memory, key vừa populate xong đã bị evict nhường chỗ, hit rate giảm. Scale up Redis memory hoặc review key nào chiếm nhiều space nhất.

**Memory usage**, bao nhiêu phần trăm maxmemory đã dùng. Đặt alert ở 80% để có thời gian scale trước khi đầy.

**Latency**, Redis command latency. Bình thường dưới 1ms cho GET/SET key nhỏ. Nếu latency tăng, kiểm tra slow log (`SLOWLOG GET`), kiểm tra key lớn (`DEBUG OBJECT key`), kiểm tra network giữa app và Redis.

**Connection count**, số connection đang active. Redis single-threaded nên nhiều connection không giúp performance, nhưng quá nhiều connection idle tốn memory. Dùng connection pooling với size hợp lý (thường 10-50 per instance tuỳ workload).

```bash
# Redis CLI: kiểm tra nhanh cache health
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys"
redis-cli INFO memory | grep used_memory_human
redis-cli SLOWLOG GET 10

```text
Metric này nên được scrape bởi Prometheus (dùng redis-exporter)  hiển thị trên Grafana. Đặt alert cho hit rate giảm quá ngưỡng, eviction rate spike,  latency P99 tăng bất thường.

---

Cache không phải bữa ăn miễn phí, mọi cache entry cần TTL, mọi invalidation cần ít nhất hai lớp phòng thủ (event-based + TTL làm safety net). Bài toán thực sự không phải  cache nhanh như thế nào,   khi nào data  gây hại đủ để người dùng mất niềm tin, câu trả lời đó quyết định toàn bộ chiến lược.