← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202626 phút đọc23 min read

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ậpLatency xấp xỉSo với L1
CPU L1 cache~1 ns
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 mshàng triệu×
Round trip mạng cross-region~50–150 mshà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:

Phía chi phí của canh bạc:

Đ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ếmGhi 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 entryBlob khổng lồ mỗi entry
Chịu được stalenessChấp nhận cũ vài giây/phútBắt buộc real-time (vd. số dư ngân hàng lúc chuyển tiền)
Độ lệch truy cậpCó 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 userCá 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 ratiohits / (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.

LayerNằm ở đâuCache cái gìTTL / điều khiểnCông nghệ ví dụ
Client / browserThiết bị của userResponse, asset, kết quả APIHTTP header (Cache-Control), logic trong appHTTP cache của browser, DB trong app mobile, in-memory
CDN / edgeCác PoP gần userStatic asset, response cache đượcHTTP header, cấu hình edgeCloudflare, Fastly, CloudFront, Akamai
Reverse proxyTrước application serverResponse đầy đủ, fragmentCấu hình proxy + HTTP headerNginx, Varnish, HAProxy
ApplicationTrong process service hoặc store dùng chungKết quả query, giá trị đã tính, objectCode app + TTL tường minhIn-process (LRU map), Redis, Memcached
DatabaseBên trong/cạnh DBQuery plan, page, result setDB tự quản lýDB buffer pool, query cache, materialized view

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:

  1. 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).
  2. 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=NCòn fresh trong N giây.
s-maxage=NNhư max-age nhưng chỉ cho shared cache (CDN/proxy); ghi đè max-age ở đó.
publicCache nào cũng được lưu, kể cả shared/CDN.
privateChỉ 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-storeKhông lưu gì cả — cho dữ liệu nhạy cảm.
must-revalidateKhi đã cũ, phải revalidate trước khi dùng (không phục vụ bản cũ lúc lỗi).
immutableNộ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=NPhục vụ bản cũ tối đa N giây trong khi revalidate ở background — giấu latency.
stale-if-error=NPhụ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

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.

Write-through

Khi ghi, application ghi vào cache 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ộ

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ễ).

So sánh các pattern

PatternĐường đọcĐường ghiConsistencyHợp nhất với
Cache-asideApp kiểm tra cache, load DB khi missApp ghi DB, invalidate cacheEventual; cửa sổ cũ nhỏĐa dụng (phổ biến nhất)
Read-throughCache load DB khi miss(thường ghép với write-through)EventualĐơn giản hóa code app
Write-throughTừ cache (luôn warm)App/cache ghi cache + DB đồng bộStrong cho key đã ghiĐúng đắn read-after-write
Write-behindTừ cacheCache ghi DB asyncWeak / eventual; rủi ro mấtGhi 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.

PolicyLoạ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ấtTậ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
RandomMột entry ngẫu nhiênRẻ, 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ạnDữ 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:

Ư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:

# 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:

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.

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ạnhRedisMemcached
Mô hình dữ liệuPhong phú: string, hash, list, set, sorted set, stream, bitmap, HyperLogLog, geoĐơn giản, chỉ key→string
PersistenceTùy chọn (RDB snapshot, AOF log)Không (thuần in-memory)
Replication / HACó (replica, Sentinel, Cluster)Không sẵn (sharding phía client)
EvictionNhiều policy gồm LRU/LFU, TTL theo keyLRU, TTL
ThreadingCore chủ yếu single-thread (rất nhanh; có I/O thread từ 6+)Đa luồng
Atomic ops / scriptingPhong phú (INCR, transaction, Lua script, functions)Hạn chế (CAS, incr/decr)
Pub/Sub, streamKhô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:

# 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

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.

AccessApproximate latencyRelative to L1
CPU L1 cache~1 ns
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 msmillions×
Cross-region network round trip~50–150 mstens 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:

The cost side of the bet:

What makes a good cache candidate

Not all data should be cached. The ideal candidate is:

PropertyGood candidatePoor candidate
Read/write ratioRead-heavy (read many, write rarely)Write-heavy
Cost to produceExpensive (complex query, remote call, render)Trivially cheap
SizeSmall enough to store many entriesHuge per-entry blobs
Staleness toleranceCan tolerate seconds/minutes of stalenessMust be strictly real-time (e.g. bank balance at point of transfer)
Access skewHot keys accessed repeatedly (Pareto/Zipf)Uniform access to a huge unique keyspace (low hit rate)
ShareabilitySame result serves many usersHighly 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.

LayerWhere it livesCachesTTL / controlExample tech
Client / browserUser’s deviceResponses, assets, API resultsHTTP headers (Cache-Control), app-local logicBrowser HTTP cache, mobile app DB, in-memory
CDN / edgePoints of presence near usersStatic assets, cacheable responsesHTTP headers, edge configCloudflare, Fastly, CloudFront, Akamai
Reverse proxyIn front of your app serversFull responses, fragmentsProxy config + HTTP headersNginx, Varnish, HAProxy
ApplicationIn your service process or a shared storeQuery results, computed values, objectsApp code + explicit TTLsIn-process (LRU map), Redis, Memcached
DatabaseInside/next to the DBQuery plans, pages, result setsDB-managedDB buffer pool, query cache, materialized views

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:

  1. Freshness — how long a response may be reused without contacting the server (Cache-Control, Expires).
  2. 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
DirectiveMeaning
max-age=NFresh for N seconds.
s-maxage=NLike max-age but only for shared caches (CDN/proxy); overrides max-age there.
publicMay be stored by any cache, including shared/CDN.
privateMay be stored only by the browser (not shared caches) — use for per-user data.
no-cacheMay store, but MUST revalidate with the origin before each reuse. (Not “do not cache”.)
no-storeDo not store at all — for sensitive data.
must-revalidateOnce stale, must revalidate before use (no serving stale on error).
immutableContent will never change during its max-age; don’t even revalidate on reload. Perfect for content-hashed assets.
stale-while-revalidate=NServe stale for up to N seconds while revalidating in the background — hides latency.
stale-if-error=NServe 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

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.

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

Write-behind (write-back)

On write, update the cache immediately and return; the DB is updated asynchronously (batched, after a delay).

Patterns comparison

PatternRead pathWrite pathConsistencyBest for
Cache-asideApp checks cache, loads DB on missApp writes DB, invalidates cacheEventual; small stale windowGeneral purpose (most common)
Read-throughCache loads DB on miss(paired with write-through usually)EventualSimplifying app code
Write-throughFrom cache (always warm)App/cache writes cache + DB syncStrong for written keysRead-after-write correctness
Write-behindFrom cacheCache writes DB asyncWeak / eventual; loss riskWrite-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.

PolicyEvictsGood when
LRU (Least Recently Used)The entry not accessed for the longest timeGeneral purpose; recency predicts reuse (most common default)
LFU (Least Frequently Used)The entry accessed the fewest timesStable hot set; frequency predicts reuse better than recency
FIFO (First In First Out)The oldest inserted entry, regardless of useSimple; rarely optimal
RandomA random entryCheap, surprisingly decent, avoids pathological cases
TTL (time-based)Any entry past its expiryData 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:

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:

# 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:

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.

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.

AspectRedisMemcached
Data modelRich: strings, hashes, lists, sets, sorted sets, streams, bitmaps, HyperLogLog, geoSimple key→string only
PersistenceOptional (RDB snapshots, AOF log)None (pure in-memory)
Replication / HAYes (replicas, Sentinel, Cluster)No built-in (client-side sharding)
EvictionMany policies incl. LRU/LFU, per-key TTLLRU, TTL
ThreadingMostly single-threaded core (very fast; I/O threads in 6+)Multi-threaded
Atomic ops / scriptingRich (INCR, transactions, Lua scripts, functions)Limited (CAS, incr/decr)
Pub/Sub, streamsYesNo
Best atFeature-rich caching + data structures + moreDead-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:

# 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

References