CachingCaching
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Cache là một kho lưu trữ nhỏ và nhanh, giữ các bản sao của dữ liệu để những request sau có thể được phục vụ mà không phải làm lại công việc tốn kém đã tạo ra dữ liệu đó — một database query, một lời gọi API từ xa, một lần render template, resize ảnh, hay một phép tính mã hóa. Caching là kỹ thuật tối ưu hiệu năng có đòn bẩy lớn nhất trong backend: nó đánh đổi một chút memory và một chút phức tạp để giảm latency, tải và chi phí đi hàng chục, hàng trăm lần.
Trực giác đằng sau đến từ một thực tế khó chối cãi của máy tính: latency có tính phân cấp, và mỗi cấp thường chậm hơn cấp trên nó khoảng 10–100 lần.
| Loại truy cập | Latency xấp xỉ | So với L1 |
|---|---|---|
| CPU L1 cache | ~1 ns | 1× |
| Main memory (RAM) | ~100 ns | ~100× |
| In-memory cache qua mạng (Redis, cùng DC) | ~0.5 ms | ~500,000× |
| SSD đọc ngẫu nhiên | ~1 ms | ~1,000,000× |
| Database query (có index) | ~1–10 ms | hàng triệu× |
| Round trip mạng cross-region | ~50–150 ms | hàng chục triệu× |
Mỗi layer cache là một nỗ lực đưa dữ liệu lên cao hơn trong cấp bậc này — gần hơn với người tiêu thụ, vào storage nhanh hơn — để bạn chỉ trả cái giá chậm một lần rồi phân bổ (amortize) nó cho nhiều lần đọc.
Nhưng caching nổi tiếng là nguy hiểm. Câu nói của Phil Karlton — “Trong khoa học máy tính chỉ có hai thứ khó: cache invalidation và đặt tên biến” — là một câu đùa mà kỹ sư senior nào cũng từng thấm. Cache là bản sao thứ hai của sự thật, và ngay khi có hai bản sao thì bạn có một vấn đề về consistency. Note này bàn về việc cache nằm ở đâu, dùng nó cho đúng thế nào, và tránh những failure mode (staleness, stampede, memory phình vô hạn) biến cache từ tài sản thành sự cố.
Note liên quan: Web Servers (reverse proxy và edge caching), Database Design & Scaling (thứ mà bạn thường cache phía trước), và Scalability & Reliability (caching như một công cụ giảm tải và tăng tính sẵn sàng).
Kiến thức nền tảng
Vì sao cần cache — đánh đổi latency/chi phí
Mỗi cache là một canh bạc: chi phí lưu và phục vụ một bản sao rẻ hơn nhiều so với chi phí tính lại bản gốc, và bản sao sẽ được đọc đủ nhiều lần để có lời. Một cache tốt mang lại ba lợi ích:
- Giảm latency. Phục vụ từ memory thay vì query lại database biến một thao tác 10 ms thành 0.5 ms — thường là ranh giới giữa một sản phẩm mượt mà và một sản phẩm ì ạch.
- Tăng throughput / giảm tải. Mỗi cache hit là một request mà origin (database, upstream service) không bao giờ thấy. Một cache đạt hit rate 90% giảm tải origin đi 10 lần, thường có nghĩa bạn không cần mua thêm database replica hay scale out application server.
- Giảm chi phí. RAM rẻ gánh bớt cho database CPU đắt đỏ, hay một CDN edge hấp thụ băng thông lẽ ra đổ vào origin, đều rẻ hơn trực tiếp.
Phía chi phí của canh bạc:
- Memory / storage cho các bản sao.
- Độ phức tạp: logic invalidation, thêm một dependency phải vận hành và giám sát, những failure mode mới.
- Rủi ro staleness: cache có thể phục vụ dữ liệu đã cũ. Việc điều đó có chấp nhận được hay không là một quyết định sản phẩm, không chỉ kỹ thuật.
Điều gì làm nên một ứng viên cache tốt
Không phải dữ liệu nào cũng nên cache. Ứng viên lý tưởng là:
| Thuộc tính | Ứng viên tốt | Ứng viên kém |
|---|---|---|
| Tỉ lệ read/write | Đọc nhiều, ghi hiếm | Ghi nhiều |
| Chi phí tạo ra | Đắt (query phức tạp, gọi từ xa, render) | Rẻ tầm thường |
| Kích thước | Đủ nhỏ để lưu được nhiều entry | Blob khổng lồ mỗi entry |
| Chịu được staleness | Chấp nhận cũ vài giây/phút | Bắt buộc real-time (vd. số dư ngân hàng lúc chuyển tiền) |
| Độ lệch truy cập | Có hot key được đọc lặp lại (Pareto/Zipf) | Truy cập đều trên keyspace khổng lồ, không lặp (hit rate thấp) |
| Tính chia sẻ | Cùng một kết quả phục vụ nhiều user | Cá nhân hóa cao, dùng một lần |
Ví dụ tốt: trang catalog sản phẩm, profile của user, leaderboard đã tính sẵn, feature flag, session data, fragment đã render, kết quả của các phép aggregation đắt đỏ, tra cứu geocoding/API.
Ví dụ kém: giá cổ phiếu tại đúng thời điểm khớp lệnh, mã OTP dùng một lần, dữ liệu có key duy nhất cho mỗi request và không tái sử dụng.
Cache hit ratio — chỉ số quan trọng nhất
Hit ratio là hits / (hits + misses). Đây là chỉ số cache quan trọng nhất vì giá trị của một cache gần như hoàn toàn được quyết định bởi nó. Cache hit rate 50% giảm tải origin khoảng một nửa; hit rate 95% giảm 20 lần. Luôn đo nó. Một cache có hit ratio kém là thuần overhead — bạn trả memory và chi phí lookup mà hiếm khi thu lời. Nguyên nhân thường gặp của hit ratio thấp: TTL quá ngắn, keyspace quá lớn/duy nhất, eviction quá mạnh tay (cache quá nhỏ), hoặc cache dữ liệu thực ra không được đọc lại.
Khái niệm chính
Các layer của caching
Caching không phải một thứ ở một chỗ. Một request có thể đi qua nửa tá cache, mỗi cái gần origin hơn. Quy tắc kinh nghiệm: cache tốt nhất là cache gần user nhất, vì nó loại bỏ nhiều công việc và mạng nhất.
| Layer | Nằm ở đâu | Cache cái gì | TTL / điều khiển | Công nghệ ví dụ |
|---|---|---|---|---|
| Client / browser | Thiết bị của user | Response, asset, kết quả API | HTTP header (Cache-Control), logic trong app | HTTP cache của browser, DB trong app mobile, in-memory |
| CDN / edge | Các PoP gần user | Static asset, response cache được | HTTP header, cấu hình edge | Cloudflare, Fastly, CloudFront, Akamai |
| Reverse proxy | Trước application server | Response đầy đủ, fragment | Cấu hình proxy + HTTP header | Nginx, Varnish, HAProxy |
| Application | Trong process service hoặc store dùng chung | Kết quả query, giá trị đã tính, object | Code app + TTL tường minh | In-process (LRU map), Redis, Memcached |
| Database | Bên trong/cạnh DB | Query plan, page, result set | DB tự quản lý | DB buffer pool, query cache, materialized view |
- Client/browser cache là miễn phí và nhanh nhất có thể — một cache hit ở đây nghĩa là không có mạng. Bạn điều khiển nó bằng HTTP header (bên dưới).
- CDN/edge cache phục vụ nội dung từ vị trí gần user về mặt vật lý, cắt round-trip time và che chắn origin. Lý tưởng cho static asset, và ngày càng cho cả nội dung động-nhưng-cache-được.
- Reverse-proxy cache (Nginx/Varnish) đứng trước application và có thể phục vụ nguyên response mà không cần đánh thức code app.
- Application cache (Redis/Memcached, hay một map in-process) là nơi kỹ sư backend cache có chủ đích nhiều nhất — cache kết quả database query, session object, giá trị đã tính.
- Database cache phần lớn tự động (buffer pool giữ các page nóng trong RAM), nhưng bạn cũng chủ động dùng materialized view và result caching.
Chúng kết hợp với nhau. Một hệ thống thiết kế tốt cache ở nhiều layer sao cho phần lớn request được trả lời trước khi chạm tới origin đắt đỏ.
HTTP caching
HTTP có sẵn một mô hình caching chuẩn hóa, phong phú ngay trong protocol. Đặt đúng các header này thường là chiến thắng rẻ nhất và lớn nhất, vì nó để browser và CDN cache thay cho bạn.
Có hai cơ chế khái niệm:
- Freshness — một response được tái sử dụng bao lâu mà không cần liên hệ server (
Cache-Control,Expires). - Validation — cache kiểm tra một bản đã cũ còn dùng được không bằng một conditional request rẻ tiền (
ETag,Last-Modified).
Cache-Control
Header chính, hiện đại. Nó là một danh sách các directive.
Cache-Control: public, max-age=3600
Cache-Control: private, max-age=0, must-revalidate
Cache-Control: no-store
Cache-Control: public, max-age=31536000, immutable
Cache-Control: public, max-age=60, stale-while-revalidate=300
| Directive | Ý nghĩa |
|---|---|
max-age=N | Còn fresh trong N giây. |
s-maxage=N | Như max-age nhưng chỉ cho shared cache (CDN/proxy); ghi đè max-age ở đó. |
public | Cache nào cũng được lưu, kể cả shared/CDN. |
private | Chỉ browser được lưu (không phải shared cache) — dùng cho dữ liệu per-user. |
no-cache | Được lưu, nhưng PHẢI revalidate với origin trước mỗi lần tái dùng. (Không phải “đừng cache”.) |
no-store | Không lưu gì cả — cho dữ liệu nhạy cảm. |
must-revalidate | Khi đã cũ, phải revalidate trước khi dùng (không phục vụ bản cũ lúc lỗi). |
immutable | Nội dung không bao giờ đổi trong max-age; đừng revalidate ngay cả khi reload. Hoàn hảo cho asset gắn content-hash. |
stale-while-revalidate=N | Phục vụ bản cũ tối đa N giây trong khi revalidate ở background — giấu latency. |
stale-if-error=N | Phục vụ bản cũ tối đa N giây nếu origin lỗi — tăng availability. |
Một pattern phổ biến và mạnh mẽ là cache-busting bằng content hash: đặt tên asset app.9f3a1c.js và phục vụ với Cache-Control: public, max-age=31536000, immutable. Vì tên file đổi khi nội dung đổi, bạn vừa cache cực mạnh vừa cập nhật tức thì — HTML mới chỉ cần trỏ tới một tên file mới.
ETag và If-None-Match (validation)
ETag là một định danh phiên bản dạng opaque (thường là hash) mà server gắn vào response. Ở request sau, client gửi lại nó; nếu resource không đổi, server trả 304 Not Modified với body rỗng — tiết kiệm băng thông và chi phí render mà vẫn xác nhận được độ fresh.
# Response đầu tiên
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: no-cache
Content-Length: 24506
<body đầy đủ>
# Conditional request sau đó từ client
GET /api/products/42 HTTP/1.1
If-None-Match: "a1b2c3d4"
# Server: không đổi
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
# (không có body)
ETag có hai loại: strong ("abc", giống nhau từng byte) và weak (W/"abc", tương đương về mặt ngữ nghĩa). Dùng weak khi những khác biệt byte tầm thường (whitespace, chênh lệch do gzip) không nên bị tính là thay đổi.
Last-Modified và If-Modified-Since
Một validator cũ hơn, thô hơn, dựa trên timestamp (độ phân giải 1 giây). Server gửi Last-Modified; client trả lại If-Modified-Since; server đáp 200 với body mới hoặc 304 nếu không đổi. ETag được ưu tiên hơn (chính xác hơn), nhưng Last-Modified là phương án dự phòng tốt, đặc biệt với file.
HTTP/1.1 200 OK
Last-Modified: Wed, 15 Jul 2026 10:00:00 GMT
GET /page HTTP/1.1
If-Modified-Since: Wed, 15 Jul 2026 10:00:00 GMT
HTTP/1.1 304 Not Modified
Expires
Header freshness kiểu cũ — một ngày tuyệt đối (Expires: Wed, 21 Oct 2026 07:28:00 GMT). Đã bị Cache-Control: max-age thay thế, và max-age thắng khi cả hai cùng có mặt. Chỉ còn liên quan với client cũ.
Vary
Vary báo cho cache biết response phụ thuộc vào một số request header cụ thể, nên phải key cache entry theo các header đó nữa.
Vary: Accept-Encoding
Vary: Accept-Encoding, Accept-Language
Không có Vary: Accept-Encoding, một cache có thể phục vụ body gzip cho client không giải nén được. Cẩn thận: Vary: Cookie hay Vary: User-Agent có thể phá nát hit ratio bằng cách chia cache thành các entry gần như duy nhất. Chỉ vary theo thứ thực sự thay đổi representation.
Luồng revalidation 304
Client Cache (CDN/browser) Origin
|-- GET /resource ------------->|
| | còn fresh? (trong max-age)
| | có -> phục vụ từ cache (không chạm origin)
| | không -> revalidate:
| |-- GET /resource ----------------->|
| | If-None-Match: "etag" |
| | không đổi?
| |<-- 304 Not Modified --------------|
|<-- 200 (phục vụ từ cache) ----| (làm mới freshness, giữ body)
| | HOẶC
| |<-- 200 + body mới ----------------|
|<-- 200 (body mới) ------------|
Freshness né hẳn origin; validation vẫn liên hệ origin nhưng tránh gửi lại body. Cả hai đều lợi hơn một 200 không cache.
Các pattern application caching
Khi cache trong chính code của bạn (thường bằng Redis hoặc Memcached), có bốn pattern kinh điển. Khác biệt nằm ở ai ghi vào cache và khi nào.
Cache-aside (lazy loading) — mặc định
Application tự quản lý cache trực tiếp. Khi đọc: kiểm tra cache; nếu miss, load từ DB, đổ vào cache, trả về. Khi ghi: cập nhật DB, rồi invalidate (hoặc cập nhật) cache entry.
def get_user(user_id):
key = f"user:{user_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached) # cache hit
user = db.query("SELECT * FROM users WHERE id = %s", user_id) # miss
redis.set(key, json.dumps(user), ex=300) # đổ vào với TTL 5 phút
return user
def update_user(user_id, data):
db.update("users", user_id, data)
redis.delete(f"user:{user_id}") # invalidate; lần đọc sau tự đổ lại
- Ưu: đơn giản, chịu lỗi tốt (cache chết chỉ khiến DB tải nhiều hơn, không phải hỏng hẳn), chỉ cache thứ thực sự được đọc.
- Nhược: mỗi miss phải trả full latency; có một khoảng nhỏ sau khi ghi mà một reader song song có thể đổ lại dữ liệu cũ (giảm thiểu bằng cách delete sau khi DB commit, hoặc dùng versioned key).
Read-through
Bản thân cache đứng trước DB và tự load khi miss, thông qua một provider hoặc library. Application chỉ nói chuyện với cache; cache biết cách fetch từ origin.
- Ưu: code app đơn giản hơn (không cần logic load tường minh); logic load được tập trung.
- Nhược: cần một cache hỗ trợ điều này (hoặc một wrapper library); lần đọc đầu vẫn chậm.
Write-through
Khi ghi, application ghi vào cache và DB một cách đồng bộ (cache trước, rồi cache ghi xuống DB, hoặc ghi cả hai). Cache luôn nhất quán với DB cho các key đã ghi.
def set_config(key, value):
redis.set(f"config:{key}", value) # ghi cache
db.upsert("config", key, value) # và DB, đồng bộ
- Ưu: cache không bao giờ cũ với dữ liệu đã ghi; đọc ngay sau ghi thì nhanh.
- Nhược: mỗi lần ghi trả cả latency cache + DB; cache cả dữ liệu có thể không bao giờ được đọc (lãng phí nếu ghi nhiều hơn đọc).
Write-behind (write-back)
Khi ghi, cập nhật cache ngay và trả về; DB được cập nhật bất đồng bộ (gom batch, sau một độ trễ).
- Ưu: ghi nhanh nhất, hấp thụ được các đợt ghi dồn dập, có thể gom/coalesce các lần ghi DB.
- Nhược: rủi ro mất dữ liệu nếu cache chết trước khi flush; phức tạp; eventual consistency. Dùng cho workload ghi nhiều (metrics, counter, analytics) nơi mất một ít là chấp nhận được.
So sánh các pattern
| Pattern | Đường đọc | Đường ghi | Consistency | Hợp nhất với |
|---|---|---|---|---|
| Cache-aside | App kiểm tra cache, load DB khi miss | App ghi DB, invalidate cache | Eventual; cửa sổ cũ nhỏ | Đa dụng (phổ biến nhất) |
| Read-through | Cache load DB khi miss | (thường ghép với write-through) | Eventual | Đơn giản hóa code app |
| Write-through | Từ cache (luôn warm) | App/cache ghi cache + DB đồng bộ | Strong cho key đã ghi | Đúng đắn read-after-write |
| Write-behind | Từ cache | Cache ghi DB async | Weak / eventual; rủi ro mất | Ghi nhiều, chịu được mất mát |
Cache-aside cộng TTL là con ngựa thồ cho tuyệt đại đa số backend. Chỉ dùng các pattern khác khi bạn có lý do cụ thể.
Eviction policy và quản lý memory
Cache có memory hữu hạn. Khi đầy, nó phải quyết định bỏ cái gì. Đó là eviction policy.
| Policy | Loại bỏ | Tốt khi |
|---|---|---|
| LRU (Least Recently Used) | Entry lâu nhất chưa được truy cập | Đa dụng; recency dự đoán tái dùng (mặc định phổ biến nhất) |
| LFU (Least Frequently Used) | Entry được truy cập ít lần nhất | Tập nóng ổn định; frequency dự đoán tái dùng tốt hơn recency |
| FIFO (First In First Out) | Entry được chèn sớm nhất, bất kể có dùng hay không | Đơn giản; hiếm khi tối ưu |
| Random | Một entry ngẫu nhiên | Rẻ, tốt bất ngờ, tránh các trường hợp bệnh lý |
| TTL (theo thời gian) | Entry nào quá hạn | Dữ liệu trở nên vô hiệu sau một thời gian đã biết |
TTL (time-to-live) vuông góc với và kết hợp cùng những cái trên: mỗi entry có thể mang một hạn dùng, và entry hết hạn bị loại bỏ bất kể eviction policy. TTL là tuyến phòng thủ đầu tiên chống staleness — kể cả khi bạn không bao giờ invalidate tường minh, dữ liệu tự lành sau TTL. Chọn TTL theo mức staleness dữ liệu chịu được: vài giây cho dữ liệu biến động, hàng giờ hoặc hàng ngày cho dữ liệu tham chiếu ổn định.
Redis phơi bày eviction qua maxmemory + maxmemory-policy:
maxmemory 2gb
maxmemory-policy allkeys-lru # evict key bất kỳ theo LRU xấp xỉ
# Các policy khác:
# noeviction -> trả lỗi khi ghi lúc đầy (mặc định)
# allkeys-lru -> LRU trên mọi key
# allkeys-lfu -> LFU trên mọi key (Redis 4+)
# volatile-lru -> LRU chỉ trong các key có TTL
# volatile-ttl -> evict key gần hết hạn nhất trước
# allkeys-random -> ngẫu nhiên
Một quyết định vận hành quan trọng: với noeviction, cache đầy sẽ bắt đầu từ chối ghi (fail loud); với allkeys-lru, nó âm thầm dọn chỗ (fail soft). Hãy chọn có chủ đích. Cũng nên đặt TTL cho từng key ngay cả dưới LRU, để không gì sống mãi một cách vô tình.
Những vấn đề khó
Cache invalidation
Giữ cache nhất quán với nguồn sự thật (source of truth) là khó khăn trung tâm. Các chiến lược:
- TTL expiry — đơn giản nhất: để entry hết hạn rồi đổ lại. Chấp nhận staleness có giới hạn. Gần như luôn là baseline của bạn.
- Invalidation tường minh — khi ghi, delete/update các key bị ảnh hưởng (đường ghi của cache-aside). Chính xác nhưng bạn phải biết mọi key bị ảnh hưởng bởi một thay đổi, rất dễ sai (vd. cập nhật một sản phẩm cũng phải bust cache của trang listing category).
- Write-through — giữ cache và DB đồng bộ khi ghi (xem trên).
- Invalidation theo version / namespace key — nhúng version vào key (
user:42:v7) và tăng version để invalidate cả nhóm cùng lúc, để các entry cũ tự già đi qua TTL. Tuyệt cho “invalidate mọi thứ liên quan tới X” mà không phải theo dõi từng key. - Invalidation theo event — publish sự kiện thay đổi (vd. qua Redis pub/sub, message queue, hoặc DB change-data-capture) và cho các cache subscribe rồi invalidate. Scale được cho nhiều cache/instance.
Ưu tiên delete hơn update khi ghi: delete là idempotent và tránh race khi hai writer set cache sai thứ tự. Lần đọc sau sẽ đổ lại từ source of truth.
Cache stampede (thundering herd)
Khi một key nóng (phổ biến) hết hạn hoặc biến mất, nhiều request song song cùng miss một lúc và đồng loạt dội vào origin để tính lại cùng một giá trị — có thể làm database quá tải. Đây là một sự cố tự gây kinh điển: cache đang bảo vệ DB bỗng ngừng, và cả đám đông ùa vào.
Cách giảm thiểu:
- Lock / mutex (single-flight): request đầu tiên miss giành một lock (vd.
SET key:lock 1 NX EX 10) và tính lại; các request khác chờ ngắn rồi đọc giá trị vừa được đổ vào thay vì cũng tính lại. - Request coalescing / single-flight trong process: trong một process, khử trùng lặp các lần load giống nhau song song để chỉ một cái chạm origin và số còn lại chờ kết quả của nó (
singleflightcủa Go, hay một map in-flight theo key). - TTL có jitter / ngẫu nhiên: đừng để nhiều key liên quan hết hạn cùng một khoảnh khắc. Thêm ngẫu nhiên —
ttl = base + random(0, base*0.1)— để các lần hết hạn trải ra và không tạo thành một đợt sóng đồng bộ. - Tính lại sớm / theo xác suất: làm mới một key nóng trước khi nó hết hạn (vd. XFetch — tính lại theo xác suất khi gần hết hạn), để nó không bao giờ thực sự nguội.
- stale-while-revalidate: phục vụ ngay giá trị cũ và làm mới ở background, để user không bao giờ phải chờ một lần miss.
# Cache-aside với lock để chống stampede
def get_expensive(key):
val = redis.get(key)
if val is not None:
return val
# cố trở thành worker duy nhất tính lại
if redis.set(f"{key}:lock", "1", nx=True, ex=10):
try:
val = compute_expensive() # chỉ một caller làm việc này
# TTL có jitter để tránh hết hạn đồng bộ về sau
redis.set(key, val, ex=300 + random.randint(0, 30))
return val
finally:
redis.delete(f"{key}:lock")
else:
time.sleep(0.05) # người khác đang tính
return redis.get(key) or compute_expensive() # chờ ngắn, rồi đọc
Hai vấn đề lân cận đáng gọi tên:
- Cache penetration: request cho các key không tồn tại trong cache lẫn DB (vd. query các ID ngẫu nhiên không có thật) luôn miss và dội vào DB. Giảm thiểu bằng cách cache kết quả âm (một sentinel “not found” với TTL ngắn) hoặc Bloom filter để loại bỏ các key bất khả thi.
- Cache avalanche: một phần lớn cache hết hạn hoặc server cache chết cùng lúc, đổ toàn bộ tải lên origin. Giảm thiểu bằng TTL có jitter, high availability cho cache, và rate-limit / circuit breaker ở origin.
Staleness và consistency
Cache về bản chất là eventually consistent. Hãy quyết định tường minh mỗi tập dữ liệu chịu được bao nhiêu staleness, và mã hóa nó thành TTL. Với dữ liệu bắt buộc strongly consistent (không được phục vụ bản cũ), hoặc là đừng cache, dùng write-through, hoặc đọc từ source of truth trên critical path. Coi chừng dị thường read-after-write: user cập nhật profile, rồi đọc lại ngay và thấy giá trị cache cũ. Cách sửa gồm invalidate khi ghi, write-through, hoặc đọc từ primary một thời gian ngắn sau các lần ghi của chính user đó.
CDN caching: nội dung static vs dynamic
CDN cache các response tại các edge location khắp thế giới.
- Nội dung static (ảnh, CSS, JS, font, video) là use case kinh điển của CDN: bất biến, dùng chung cho mọi user, TTL dài, tên file gắn content-hash. Cache mạnh tay (
max-age=31536000, immutable). Đây gần như là hiệu năng miễn phí. - Nội dung dynamic khó hơn nhưng ngày càng cache được:
- Cache-được-nhưng-động: trang giống nhau cho nhiều user trong một khoảng ngắn (trang chủ tin tức, trang sản phẩm). Dùng
s-maxagengắn +stale-while-revalidate, và validation (ETag). - Cá nhân hóa: khác nhau theo user. Cache phần khung dùng chung và fetch riêng các fragment cá nhân hóa (edge-side include, hydration phía client), hoặc đừng cache phần cá nhân hóa.
- Purging: CDN cho phép purge/invalidate các path hoặc tag cụ thể khi nội dung đổi, cho bạn TTL dài và cập nhật nhanh.
- Edge compute (Cloudflare Workers, Lambda@Edge) có thể sinh hoặc lắp ráp response tại edge, làm mờ ranh giới static/dynamic.
- Cache-được-nhưng-động: trang giống nhau cho nhiều user trong một khoảng ngắn (trang chủ tin tức, trang sản phẩm). Dùng
Các nút chỉnh CDN quan trọng: tôn trọng Cache-Control/s-maxage, đặt mặc định hợp lý, dùng Vary tiết chế, và luôn version static asset để không phải chống lại cache bằng purge thủ công.
Redis vs Memcached
Hai store application-cache thống trị.
| Khía cạnh | Redis | Memcached |
|---|---|---|
| Mô hình dữ liệu | Phong phú: string, hash, list, set, sorted set, stream, bitmap, HyperLogLog, geo | Đơn giản, chỉ key→string |
| Persistence | Tùy chọn (RDB snapshot, AOF log) | Không (thuần in-memory) |
| Replication / HA | Có (replica, Sentinel, Cluster) | Không sẵn (sharding phía client) |
| Eviction | Nhiều policy gồm LRU/LFU, TTL theo key | LRU, TTL |
| Threading | Core chủ yếu single-thread (rất nhanh; có I/O thread từ 6+) | Đa luồng |
| Atomic ops / scripting | Phong phú (INCR, transaction, Lua script, functions) | Hạn chế (CAS, incr/decr) |
| Pub/Sub, stream | Có | Không |
| Mạnh nhất ở | Caching nhiều tính năng + data structure + hơn thế | Cache string cực đơn giản, throughput cao |
Với một cache thuần “để chuỗi/JSON này sau một key”, cả hai đều tuyệt và multithreading của Memcached có thể nhỉnh hơn ở throughput rất cao. Trên thực tế Redis là lựa chọn mặc định cho phần lớn team vì data structure và tính năng của nó cho phép một hệ thống làm nhiều việc.
Redis không chỉ là một cache
Data structure của Redis biến nó thành một bộ công cụ in-memory đa dụng, không chỉ là cache:
- String / counter — đếm lượt xem trang, counter rate-limit (
INCR,INCRBY). - Hash — lưu các field của một object gọn gàng (
HSET user:42 name Alice age 30). - List — queue, feed các mục gần đây (
LPUSH/RPOP). - Set — kiểm tra thành viên, tag, khách truy cập duy nhất (
SADD,SISMEMBER). - Sorted set (ZSET) — leaderboard, priority queue, time-series theo score (
ZADD,ZRANGE). - Stream — log append-only / event streaming nhẹ với consumer group.
- Pub/Sub — nhắn tin real-time, fan-out cache invalidation.
- HyperLogLog — đếm xấp xỉ số phần tử duy nhất với memory tí hon (
PFADD,PFCOUNT). - Geospatial — truy vấn theo bán kính (
GEOADD,GEOSEARCH). - TTL/expiry — distributed lock, session store, token tạm thời.
# Vài lệnh minh họa
SET session:abc123 "{...}" EX 1800 # session với TTL 30 phút
INCR page:home:views # counter atomic
HSET user:42 name "Alice" plan "pro" # object dạng hash
ZADD leaderboard 4200 "player:7" # thêm score
ZREVRANGE leaderboard 0 9 WITHSCORES # top 10
EXPIRE user:42 300 # đặt/làm mới TTL
TTL user:42 # xem TTL còn lại
SET lock:job "1" NX EX 10 # distributed lock đơn giản
Chính sự linh hoạt này khiến Redis thường đồng thời trở thành lớp caching, session store, rate limiter, job queue, và engine leaderboard.
Best Practices
- Cache-aside + TTL là mặc định. Bắt đầu từ đó; chỉ dùng write-through/write-behind khi có nhu cầu cụ thể, chính đáng.
- Luôn đặt TTL. Đừng bao giờ để entry sống mãi một cách vô tình. TTL là lưới an toàn chống staleness vô hạn và memory phình.
- Thêm jitter cho TTL của các key liên quan để tránh hết hạn đồng bộ và avalanche.
- Đo hit ratio (cùng latency, evictions, memory) cho mỗi cache. Hit ratio thấp nghĩa là cache đang là overhead — sửa TTL/keyspace hoặc bỏ đi.
- Ưu tiên delete hơn update khi ghi — idempotent và an toàn với race; để lần đọc sau đổ lại.
- Thiết kế key cẩn thận. Dùng scheme nhất quán, có namespace (
entity:id:field,user:42:profile). Kèm một đoạn version khi cần invalidate theo nhóm. - Không bao giờ cache dữ liệu nhạy cảm trong shared cache. Dùng
Cache-Control: private/no-storecho response cá nhân hoặc bí mật; cẩn thận vớiVary. - Chống stampede trên key nóng bằng lock/single-flight, và chống penetration bằng negative caching hoặc Bloom filter.
- Coi cache là tùy chọn, không bắt buộc. Hệ thống vẫn phải hoạt động (chậm hơn) khi cache chết — cache-aside cho bạn điều này một cách tự nhiên; tránh biến cache thành single point of failure.
- Cache ở layer cao nhất có thể. Browser/CDN caching hơn application caching, application caching hơn không cache. Một
304hơn một response đầy đủ; một response đầy đủ từ edge hơn một response từ origin. - Version static asset bằng content hash và phục vụ
immutable— cache mạnh tay với cập nhật tức thì. - Làm caching quan sát được. Log/emit hit/miss, và phơi bày liệu một response có được phục vụ từ cache hay không (vd. header
X-Cache: HIT/MISS) để dễ debug. - Invalidate có chủ đích, đừng cầu may. Biết chính xác một lần ghi ảnh hưởng những key nào; khi nghi ngờ, dùng versioned key hoặc TTL ngắn hơn thay vì đoán mò.
Tài liệu tham khảo
Part of the Backend Roadmap knowledge base.
Overview
A cache is a small, fast store that holds copies of data so that future requests for that data can be served without redoing the expensive work that produced it — a database query, a remote API call, a template render, an image resize, a cryptographic computation. Caching is the single highest-leverage performance technique in backend engineering: it trades a little memory and a little complexity for order-of-magnitude reductions in latency, load, and cost.
The intuition comes from a hard fact of computing: latency has a hierarchy, and each level is roughly 10–100× slower than the one above it.
| Access | Approximate latency | Relative to L1 |
|---|---|---|
| CPU L1 cache | ~1 ns | 1× |
| Main memory (RAM) | ~100 ns | ~100× |
| In-memory cache over network (Redis, same DC) | ~0.5 ms | ~500,000× |
| SSD random read | ~1 ms | ~1,000,000× |
| Database query (indexed) | ~1–10 ms | millions× |
| Cross-region network round trip | ~50–150 ms | tens of millions× |
Every layer of caching is an attempt to move data up this hierarchy — closer to the consumer, into faster storage — so you pay the slow cost once and amortize it over many reads.
But caching is famously treacherous. Phil Karlton’s line — “There are only two hard things in Computer Science: cache invalidation and naming things” — is a joke that every senior engineer has lived. A cache is a second copy of the truth, and the moment you have two copies you have a consistency problem. This note covers where caches live, how to use them correctly, and how to avoid the failure modes (staleness, stampedes, unbounded memory growth) that turn a cache from an asset into an outage.
Related notes: Web Servers (reverse proxies and edge caching), Database Design & Scaling (what you are usually caching in front of), and Scalability & Reliability (caching as a load-shedding and availability tool).
Fundamentals
Why cache — the latency/cost tradeoff
Every cache is a bet: the cost of storing and serving a copy is far less than the cost of recomputing the original, and the copy will be read enough times to pay off. Three benefits flow from a good cache:
- Lower latency. Serving from memory instead of re-querying a database turns a 10 ms operation into a 0.5 ms one — often the difference between a snappy and a sluggish product.
- Higher throughput / lower load. Every cache hit is a request the origin (database, upstream service) never sees. A cache with a 90% hit rate reduces origin load by 10×, which often means you avoid buying more database replicas or scaling out application servers.
- Lower cost. Cheap RAM offloading expensive database CPU, or a CDN edge absorbing bandwidth that would otherwise hit your origin, is directly cheaper.
The cost side of the bet:
- Memory / storage for the copies.
- Complexity: invalidation logic, an extra dependency to operate and monitor, new failure modes.
- Staleness risk: a cache can serve data that is out of date. Whether that is acceptable is a product decision, not just an engineering one.
What makes a good cache candidate
Not all data should be cached. The ideal candidate is:
| Property | Good candidate | Poor candidate |
|---|---|---|
| Read/write ratio | Read-heavy (read many, write rarely) | Write-heavy |
| Cost to produce | Expensive (complex query, remote call, render) | Trivially cheap |
| Size | Small enough to store many entries | Huge per-entry blobs |
| Staleness tolerance | Can tolerate seconds/minutes of staleness | Must be strictly real-time (e.g. bank balance at point of transfer) |
| Access skew | Hot keys accessed repeatedly (Pareto/Zipf) | Uniform access to a huge unique keyspace (low hit rate) |
| Shareability | Same result serves many users | Highly personalized, single-use |
Good examples: product catalog pages, a user’s profile, computed leaderboards, feature flags, session data, rendered fragments, results of expensive aggregations, geocoding/API lookups.
Poor examples: a stock-trading price at the instant of execution, a one-time password, data with a unique key per request and no reuse.
Cache hit ratio — the metric that matters
The hit ratio is hits / (hits + misses). It is the single most important cache metric because the value of a cache is almost entirely determined by it. A cache with a 50% hit rate roughly halves origin load; a 95% hit rate reduces it 20×. Always measure it. A cache with a poor hit ratio is pure overhead — you pay the memory and the lookup cost and rarely get the payoff. Common causes of low hit ratio: TTL too short, keyspace too large/unique, evictions too aggressive (cache too small), or caching data that is not actually re-read.
Key Concepts
The layers of caching
Caching is not one thing in one place. A single request can pass through half a dozen caches, each closer to the origin. The rule of thumb: the best cache is the one closest to the user, because it eliminates the most work and network.
| Layer | Where it lives | Caches | TTL / control | Example tech |
|---|---|---|---|---|
| Client / browser | User’s device | Responses, assets, API results | HTTP headers (Cache-Control), app-local logic | Browser HTTP cache, mobile app DB, in-memory |
| CDN / edge | Points of presence near users | Static assets, cacheable responses | HTTP headers, edge config | Cloudflare, Fastly, CloudFront, Akamai |
| Reverse proxy | In front of your app servers | Full responses, fragments | Proxy config + HTTP headers | Nginx, Varnish, HAProxy |
| Application | In your service process or a shared store | Query results, computed values, objects | App code + explicit TTLs | In-process (LRU map), Redis, Memcached |
| Database | Inside/next to the DB | Query plans, pages, result sets | DB-managed | DB buffer pool, query cache, materialized views |
- Client/browser cache is free and the fastest possible — a cache hit here means zero network. You control it with HTTP headers (below).
- CDN/edge cache serves content from a location physically near the user, cutting round-trip time and shielding your origin. Ideal for static assets, and increasingly for dynamic-but-cacheable content.
- Reverse-proxy cache (Nginx/Varnish) sits in front of your application and can serve whole responses without ever waking your app code.
- Application cache (Redis/Memcached, or an in-process map) is where backend engineers do most deliberate caching — caching database query results, session objects, and computed values.
- Database cache is mostly automatic (the buffer pool keeps hot pages in RAM), but you also opt into materialized views and result caching.
These compose. A well-architected system caches at multiple layers so that most requests are answered before they reach the expensive origin.
HTTP caching
HTTP has a rich, standardized caching model built into the protocol. Getting these headers right is often the cheapest, biggest win available, because it lets browsers and CDNs cache on your behalf.
There are two conceptual mechanisms:
- Freshness — how long a response may be reused without contacting the server (
Cache-Control,Expires). - Validation — how a cache checks whether a stale copy is still good with a cheap conditional request (
ETag,Last-Modified).
Cache-Control
The primary, modern header. It is a list of directives.
Cache-Control: public, max-age=3600
Cache-Control: private, max-age=0, must-revalidate
Cache-Control: no-store
Cache-Control: public, max-age=31536000, immutable
Cache-Control: public, max-age=60, stale-while-revalidate=300
| Directive | Meaning |
|---|---|
max-age=N | Fresh for N seconds. |
s-maxage=N | Like max-age but only for shared caches (CDN/proxy); overrides max-age there. |
public | May be stored by any cache, including shared/CDN. |
private | May be stored only by the browser (not shared caches) — use for per-user data. |
no-cache | May store, but MUST revalidate with the origin before each reuse. (Not “do not cache”.) |
no-store | Do not store at all — for sensitive data. |
must-revalidate | Once stale, must revalidate before use (no serving stale on error). |
immutable | Content will never change during its max-age; don’t even revalidate on reload. Perfect for content-hashed assets. |
stale-while-revalidate=N | Serve stale for up to N seconds while revalidating in the background — hides latency. |
stale-if-error=N | Serve stale for up to N seconds if the origin errors — improves availability. |
A common, powerful pattern is cache-busting via content hashes: name assets app.9f3a1c.js and serve them with Cache-Control: public, max-age=31536000, immutable. Because the filename changes when the content changes, you get aggressive caching and instant updates — the new HTML simply references a new filename.
ETag and If-None-Match (validation)
An ETag is an opaque version identifier (often a hash) the server attaches to a response. On a later request the client sends it back; if the resource is unchanged, the server replies 304 Not Modified with an empty body — saving bandwidth and render cost while still confirming freshness.
# First response
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: no-cache
Content-Length: 24506
<full body>
# Later conditional request from the client
GET /api/products/42 HTTP/1.1
If-None-Match: "a1b2c3d4"
# Server: unchanged
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
# (no body)
ETags come in two flavors: strong ("abc", byte-for-byte identical) and weak (W/"abc", semantically equivalent). Use weak when trivial byte differences (whitespace, gzip variance) shouldn’t count as a change.
Last-Modified and If-Modified-Since
An older, coarser validator based on timestamps (1-second resolution). The server sends Last-Modified; the client returns If-Modified-Since; the server answers 200 with a fresh body or 304 if unchanged. ETag is preferred (more precise), but Last-Modified is a fine fallback, especially for files.
HTTP/1.1 200 OK
Last-Modified: Wed, 15 Jul 2026 10:00:00 GMT
GET /page HTTP/1.1
If-Modified-Since: Wed, 15 Jul 2026 10:00:00 GMT
HTTP/1.1 304 Not Modified
Expires
The legacy freshness header — an absolute date (Expires: Wed, 21 Oct 2026 07:28:00 GMT). Superseded by Cache-Control: max-age, which wins when both are present. Only relevant for old clients.
Vary
Vary tells caches that the response depends on specific request headers, so they must key the cache entry by those headers too.
Vary: Accept-Encoding
Vary: Accept-Encoding, Accept-Language
Without Vary: Accept-Encoding, a cache might serve a gzipped body to a client that can’t decompress it. Be careful: Vary: Cookie or Vary: User-Agent can destroy your hit ratio by fragmenting the cache into near-unique entries. Vary only on what genuinely changes the representation.
The 304 revalidation flow
Client Cache (CDN/browser) Origin
|-- GET /resource ------------->|
| | fresh? (within max-age)
| | yes -> serve from cache (no origin hit)
| | no -> revalidate:
| |-- GET /resource ----------------->|
| | If-None-Match: "etag" |
| | unchanged?
| |<-- 304 Not Modified --------------|
|<-- 200 (served from cache) ---| (refresh freshness, keep body)
| | OR
| |<-- 200 + new body ----------------|
|<-- 200 (new body) ------------|
Freshness avoids the origin entirely; validation still contacts the origin but avoids re-sending the body. Both are wins over an uncached 200.
Application caching patterns
When you cache in your own code (typically with Redis or Memcached), there are four canonical patterns. The difference is who writes to the cache and when.
Cache-aside (lazy loading) — the default
The application manages the cache directly. On read: check cache; on miss, load from DB, populate cache, return. On write: update DB, then invalidate (or update) the cache entry.
def get_user(user_id):
key = f"user:{user_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached) # cache hit
user = db.query("SELECT * FROM users WHERE id = %s", user_id) # miss
redis.set(key, json.dumps(user), ex=300) # populate with 5-min TTL
return user
def update_user(user_id, data):
db.update("users", user_id, data)
redis.delete(f"user:{user_id}") # invalidate; next read repopulates
- Pros: simple, resilient (a cache outage just means more DB load, not failure), only caches what is actually read.
- Cons: each miss pays the full latency; there is a small window after a write where a concurrent reader can repopulate stale data (mitigate by deleting after the DB commit, or with versioned keys).
Read-through
The cache itself sits in front of the DB and loads on miss, via a provider or library. The application only ever talks to the cache; the cache knows how to fetch from the origin.
- Pros: application code is simpler (no explicit load logic); load logic is centralized.
- Cons: requires a cache that supports it (or a wrapper library); first read is still slow.
Write-through
On write, the application writes to the cache and the DB synchronously (cache first, which then writes to the DB, or both). The cache is always consistent with the DB for written keys.
def set_config(key, value):
redis.set(f"config:{key}", value) # write cache
db.upsert("config", key, value) # and DB, synchronously
- Pros: cache is never stale for written data; reads after writes are fast.
- Cons: every write pays cache + DB latency; caches data that may never be read (wasteful if writes outnumber reads).
Write-behind (write-back)
On write, update the cache immediately and return; the DB is updated asynchronously (batched, after a delay).
- Pros: fastest writes, absorbs write bursts, can batch/coalesce DB writes.
- Cons: risk of data loss if the cache dies before flushing; complex; eventual consistency. Used for high-write workloads (metrics, counters, analytics) where some loss is tolerable.
Patterns comparison
| Pattern | Read path | Write path | Consistency | Best for |
|---|---|---|---|---|
| Cache-aside | App checks cache, loads DB on miss | App writes DB, invalidates cache | Eventual; small stale window | General purpose (most common) |
| Read-through | Cache loads DB on miss | (paired with write-through usually) | Eventual | Simplifying app code |
| Write-through | From cache (always warm) | App/cache writes cache + DB sync | Strong for written keys | Read-after-write correctness |
| Write-behind | From cache | Cache writes DB async | Weak / eventual; loss risk | Write-heavy, tolerant workloads |
Cache-aside plus TTL is the workhorse for the vast majority of backends. Reach for the others only when you have a specific reason.
Eviction policies and memory management
A cache has finite memory. When it fills, it must decide what to remove. This is the eviction policy.
| Policy | Evicts | Good when |
|---|---|---|
| LRU (Least Recently Used) | The entry not accessed for the longest time | General purpose; recency predicts reuse (most common default) |
| LFU (Least Frequently Used) | The entry accessed the fewest times | Stable hot set; frequency predicts reuse better than recency |
| FIFO (First In First Out) | The oldest inserted entry, regardless of use | Simple; rarely optimal |
| Random | A random entry | Cheap, surprisingly decent, avoids pathological cases |
| TTL (time-based) | Any entry past its expiry | Data that becomes invalid after a known time |
TTL (time-to-live) is orthogonal to and combined with the above: every entry can carry an expiry, and expired entries are removed regardless of the eviction policy. TTL is your first line of defense against staleness — even if you never explicitly invalidate, data self-heals after the TTL. Choose TTLs by how much staleness the data can tolerate: seconds for volatile data, hours or days for stable reference data.
Redis exposes eviction via maxmemory + maxmemory-policy:
maxmemory 2gb
maxmemory-policy allkeys-lru # evict any key by approximated LRU
# Other policies:
# noeviction -> return errors on write when full (default)
# allkeys-lru -> LRU across all keys
# allkeys-lfu -> LFU across all keys (Redis 4+)
# volatile-lru -> LRU only among keys with a TTL set
# volatile-ttl -> evict keys with the nearest expiry first
# allkeys-random -> random
A key operational decision: with noeviction, a full cache starts rejecting writes (fail loud); with allkeys-lru, it silently makes room (fail soft). Choose deliberately. Also set per-key TTLs even under LRU, so nothing lives forever by accident.
The hard problems
Cache invalidation
Keeping the cache consistent with the source of truth is the central difficulty. Strategies:
- TTL expiry — the simplest: let entries expire and repopulate. Accepts bounded staleness. Almost always your baseline.
- Explicit invalidation — on a write, delete/update the affected keys (cache-aside write path). Precise but you must know every key affected by a change, which is easy to get wrong (e.g. a product update should also bust the category-listing cache).
- Write-through — keep cache and DB in lockstep on write (see above).
- Versioned / key-namespaced invalidation — embed a version in the key (
user:42:v7) and bump the version to invalidate whole groups at once, letting old entries age out via TTL. Great for “invalidate everything related to X” without tracking individual keys. - Event-driven invalidation — publish change events (e.g. via Redis pub/sub, a message queue, or DB change-data-capture) and have caches subscribe and invalidate. Scales to many caches/instances.
Prefer delete over update on writes: deleting is idempotent and avoids races where two writers set the cache in the wrong order. The next read repopulates from the source of truth.
Cache stampede (thundering herd)
When a popular (hot) key expires or is missing, many concurrent requests all miss at once and simultaneously hammer the origin to recompute the same value — potentially overwhelming the database. This is a classic self-inflicted outage: the cache that was protecting the DB suddenly stops, and the full crowd rushes in.
Mitigations:
- Locking / mutex (single-flight): the first request to miss acquires a lock (e.g.
SET key:lock 1 NX EX 10) and recomputes; others wait briefly and then read the freshly populated value instead of also recomputing. - Request coalescing / single-flight in-process: within one process, deduplicate concurrent identical loads so only one hits the origin and the rest await its result (Go’s
singleflight, or a per-key in-flight map). - Jittered / randomized TTL: never expire many related keys at the same instant. Add randomness —
ttl = base + random(0, base*0.1)— so expirations spread out and no synchronized wave forms. - Early / probabilistic recomputation: refresh a hot key before it expires (e.g. XFetch — probabilistically recompute as expiry nears), so it never actually goes cold.
- stale-while-revalidate: serve the stale value immediately and refresh in the background, so users never wait on a miss.
# Cache-aside with a lock to prevent stampede
def get_expensive(key):
val = redis.get(key)
if val is not None:
return val
# try to become the single recomputing worker
if redis.set(f"{key}:lock", "1", nx=True, ex=10):
try:
val = compute_expensive() # only one caller does this
# jittered TTL to avoid synchronized expiry later
redis.set(key, val, ex=300 + random.randint(0, 30))
return val
finally:
redis.delete(f"{key}:lock")
else:
time.sleep(0.05) # someone else is computing
return redis.get(key) or compute_expensive() # brief wait, then read
Two adjacent problems worth naming:
- Cache penetration: requests for keys that don’t exist in the cache or the DB (e.g. querying random non-existent IDs) always miss and hit the DB. Mitigate by caching the negative result (a “not found” sentinel with a short TTL) or a Bloom filter to reject impossible keys.
- Cache avalanche: a large fraction of the cache expires or the cache server dies all at once, dumping full load on the origin. Mitigate with jittered TTLs, high availability for the cache, and origin rate-limiting / circuit breakers.
Staleness and consistency
A cache is eventually consistent by nature. Decide explicitly how much staleness each dataset tolerates, and encode it as TTL. For data that must be strongly consistent (do not serve stale), either don’t cache it, use write-through, or read from the source of truth on the critical path. Beware the read-after-write anomaly: a user updates their profile, then immediately reads it and sees the old cached value. Fixes include invalidating on write, writing through, or briefly reading from the primary after a user’s own writes.
CDN caching: static vs dynamic content
A CDN caches responses at edge locations around the world.
- Static content (images, CSS, JS, fonts, videos) is the classic CDN use case: immutable, shared across all users, long TTLs, content-hashed filenames. Cache aggressively (
max-age=31536000, immutable). This is nearly free performance. - Dynamic content is trickier but increasingly cacheable:
- Cacheable-but-dynamic: pages that are the same for many users for a short window (a news homepage, a product page). Use short
s-maxage+stale-while-revalidate, and validation (ETag). - Personalized: differs per user. Cache the shared shell and fetch personalized fragments separately (edge-side includes, client-side hydration), or don’t cache the personalized parts.
- Purging: CDNs let you purge/invalidate specific paths or tags when content changes, giving you long TTLs and fast updates.
- Edge compute (Cloudflare Workers, Lambda@Edge) can generate or assemble responses at the edge, blurring the static/dynamic line.
- Cacheable-but-dynamic: pages that are the same for many users for a short window (a news homepage, a product page). Use short
Key CDN knobs: honor Cache-Control/s-maxage, set sensible defaults, use Vary sparingly, and always version static assets so you never fight the cache with manual purges.
Redis vs Memcached
The two dominant application-cache stores.
| Aspect | Redis | Memcached |
|---|---|---|
| Data model | Rich: strings, hashes, lists, sets, sorted sets, streams, bitmaps, HyperLogLog, geo | Simple key→string only |
| Persistence | Optional (RDB snapshots, AOF log) | None (pure in-memory) |
| Replication / HA | Yes (replicas, Sentinel, Cluster) | No built-in (client-side sharding) |
| Eviction | Many policies incl. LRU/LFU, per-key TTL | LRU, TTL |
| Threading | Mostly single-threaded core (very fast; I/O threads in 6+) | Multi-threaded |
| Atomic ops / scripting | Rich (INCR, transactions, Lua scripts, functions) | Limited (CAS, incr/decr) |
| Pub/Sub, streams | Yes | No |
| Best at | Feature-rich caching + data structures + more | Dead-simple, high-throughput string caching |
For a pure “put this string/JSON behind a key” cache, both are excellent and Memcached’s multithreading can edge ahead at very high throughput. In practice Redis is the default choice for most teams because its data structures and features let one system do many jobs.
Redis is more than a cache
Redis’s data structures make it a general-purpose in-memory toolkit, not just a cache:
- Strings / counters — page views, rate-limit counters (
INCR,INCRBY). - Hashes — store an object’s fields compactly (
HSET user:42 name Alice age 30). - Lists — queues, recent-items feeds (
LPUSH/RPOP). - Sets — membership, tags, unique visitors (
SADD,SISMEMBER). - Sorted sets (ZSET) — leaderboards, priority queues, time-series by score (
ZADD,ZRANGE). - Streams — append-only logs / lightweight event streaming with consumer groups.
- Pub/Sub — real-time messaging, cache invalidation fan-out.
- HyperLogLog — approximate unique counts in tiny memory (
PFADD,PFCOUNT). - Geospatial — radius queries (
GEOADD,GEOSEARCH). - TTL/expiry — distributed locks, session stores, ephemeral tokens.
# A few illustrative commands
SET session:abc123 "{...}" EX 1800 # session with 30-min TTL
INCR page:home:views # atomic counter
HSET user:42 name "Alice" plan "pro" # object as a hash
ZADD leaderboard 4200 "player:7" # add score
ZREVRANGE leaderboard 0 9 WITHSCORES # top 10
EXPIRE user:42 300 # set/refresh a TTL
TTL user:42 # inspect remaining TTL
SET lock:job "1" NX EX 10 # simple distributed lock
This versatility is why Redis often becomes a caching layer, a session store, a rate limiter, a job queue, and a leaderboard engine all at once.
Best Practices
- Cache-aside + TTL is your default. Start there; adopt write-through/write-behind only for a specific, justified need.
- Always set a TTL. Never let entries live forever by accident. TTL is your safety net against unbounded staleness and memory growth.
- Add jitter to TTLs for related keys to prevent synchronized expiry and avalanches.
- Measure the hit ratio (and latency, evictions, memory) per cache. A low hit ratio means the cache is overhead — fix the TTL/keyspace or remove it.
- Prefer delete over update on writes — it’s idempotent and race-safe; let the next read repopulate.
- Design keys carefully. Use a consistent, namespaced scheme (
entity:id:field,user:42:profile). Include a version segment when you need group invalidation. - Never cache sensitive data in shared caches. Use
Cache-Control: private/no-storefor personal or secret responses; be careful withVary. - Protect against stampedes on hot keys with locks/single-flight, and against penetration with negative caching or Bloom filters.
- Treat the cache as optional, not required. The system must still function (slower) if the cache is down — cache-aside naturally gives you this; guard against making the cache a single point of failure.
- Cache at the highest layer you can. Browser/CDN caching beats application caching, which beats no caching. A
304beats a full response; a full response from the edge beats one from your origin. - Version static assets by content hash and serve them
immutable— aggressive caching with instant updates. - Make caching observable. Log/emit hit/miss, and expose whether a response was served from cache (e.g. an
X-Cache: HIT/MISSheader) so you can debug. - Invalidate deliberately, not hopefully. Know exactly which keys a write affects; when in doubt, use versioned keys or shorter TTLs rather than guessing.