Mục lục
- Mục Tiêu Bài Học
- Bài Toán: Vì Sao Cần Cache
- Cache-Aside Là Gì & Luồng Đọc
- Code getUser Với ioredis (TypeScript)
- Code getUser Với redis-py (Python)
- Hit, Miss, Hit Ratio, Cold Cache
- Cập Nhật Dữ Liệu & Vai Trò Của TTL
- Vì Sao Cache-Aside Phổ Biến Nhất
- Trade-off & Race Condition Cơ Bản
- Pitfalls & Anti-patterns
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Hiểu rõ bài toán mà caching giải quyết: giảm tải database, giảm latency đọc, tăng throughput cho các truy vấn lặp lại.
- Nắm chính xác luồng đọc của Cache-Aside (lazy loading): check cache trước, xử lý hit và miss, ghi cache kèm TTL.
- Viết được hàm
getUsertheo cache-aside bằng cả ioredis (TypeScript) và redis-py (Python), có serialize JSON và set TTL. - Phân biệt cache hit / miss, tính được hit ratio, hiểu cache miss penalty, cold cache và warm-up.
- Biết nguyên tắc invalidate (delete) cache khi update DB và lý do vẫn cần TTL phòng hờ.
- Giải thích được vì sao Cache-Aside là pattern phổ biến nhất, so sánh nhanh với Read-Through.
- Nhận diện các trade-off (stale data window, request đầu luôn miss, race condition cơ bản) và các pitfall thường gặp.
Bài Toán: Vì Sao Cần Cache
Trong một ứng dụng thực tế, cùng một dữ liệu thường bị đọc đi đọc lại rất nhiều lần. Trang hồ sơ người dùng, danh mục sản phẩm, cấu hình hệ thống, bảng giá — tất cả đều được render cho hàng nghìn request mỗi giây nhưng nội dung thay đổi rất ít. Mỗi lần như vậy mà đều đánh thẳng vào database thì hậu quả là:
- DB phải làm việc lặp lại tốn kém: parse SQL, lập kế hoạch truy vấn, đọc index, đọc disk hoặc buffer pool, trả kết quả — cho cùng một câu trả lời.
- Latency cao: một truy vấn join hoặc quét nhiều bản ghi có thể mất hàng chục mili-giây, trong khi đọc từ Redis chỉ tốn cỡ một phần triệu tới một phần nghìn giây (sub-millisecond) vì dữ liệu nằm trong RAM.
- DB trở thành bottleneck: số kết nối và CPU của database có giới hạn. Khi traffic tăng, database là thứ sập trước tiên.
Cache giải bài toán này bằng cách đặt một lớp lưu trữ nhanh (Redis) ở phía trước database. Những dữ liệu được đọc nhiều (hot data) sẽ được phục vụ trực tiếp từ RAM, database chỉ phải xử lý phần nhỏ còn lại. Câu hỏi tiếp theo là: ai chịu trách nhiệm nạp dữ liệu vào cache và khi nào? Cache-Aside trả lời câu hỏi đó.
# Cảm nhận khác biệt latency (con số minh hoạ, tuỳ hệ thống)
# Đọc 1 user từ PostgreSQL (có join): ~15-40 ms
# Đọc 1 user (đã serialize) từ Redis: ~0.2-1 ms
#
# Với 10.000 req/s đọc cùng 1 tập hot data:
# - Không cache: ~10.000 query/s đập vào DB
# - Hit ratio 95%: chỉ còn ~500 query/s xuống DB
Cache-Aside Là Gì & Luồng Đọc
Cache-Aside (đọc bên cạnh cache), còn gọi là lazy loading (nạp lười), là pattern trong đó application code trực tiếp điều phối việc đọc cache và database. Cache không tự biết về database; nó chỉ là một kho key-value đơn giản. Logic "khi nào đọc DB, khi nào ghi cache" nằm hoàn toàn trong tay ứng dụng.
Gọi là "lazy loading" vì dữ liệu chỉ được nạp vào cache khi có người yêu cầu (lúc xảy ra miss), chứ không nạp trước. Cache vì thế chỉ chứa đúng những gì thực sự được đọc.
Luồng đọc gồm các bước:
- Application nhận yêu cầu đọc một đối tượng (ví dụ user theo id).
- Application check cache trước bằng
GETtheo key. - Nếu HIT (cache có dữ liệu): deserialize và trả về ngay, không chạm tới DB.
- Nếu MISS (cache rỗng): đọc dữ liệu từ database.
- Ghi kết quả vừa đọc vào cache bằng
SETkèm TTL (ví dụSET key value EX 300). - Trả dữ liệu về cho caller.
Sơ đồ luồng dạng text:
┌─────────────┐
request │ │ 1. GET key
───────► │ Application │ ───────────────► ┌─────────┐
│ │ ◄─────────────── │ Redis │
└─────┬───────┘ 2. value | nil └─────────┘
│
┌───────┴────────┐
│ value != nil? │
└───┬────────┬───┘
HIT│ │MISS
│ │
trả về ◄─┘ ▼ 3. SELECT ... FROM users ┌──────────┐
đọc DB ───────────────────────► │ Database │
│ ◄──────────────────────── └──────────┘
│ 4. SET key value EX ttl ┌─────────┐
├──────────────────────────► │ Redis │
│ └─────────┘
▼
trả về
Điểm mấu chốt: ghi cache là tác dụng phụ của một lần đọc trượt. Database luôn là nguồn sự thật (source of truth); cache chỉ là bản sao tạm thời để tăng tốc.
Code getUser Với ioredis (TypeScript)
Triển khai cache-aside bằng ioredis. Lưu ý: serialize bằng JSON.stringify, set TTL bằng option EX (giây), và luôn để DB là nguồn dữ liệu khi miss.
import Redis from "ioredis";
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const TTL_SECONDS = 300; // 5 phút
interface User {
id: number;
name: string;
email: string;
}
// Giả lập tầng truy cập DB (thực tế là PostgreSQL/MySQL...)
async function readUserFromDb(id: number): Promise<User | null> {
// SELECT id, name, email FROM users WHERE id = $1
const row = await db.query("SELECT id, name, email FROM users WHERE id = $1", [id]);
return row ?? null;
}
function userKey(id: number): string {
return `user:${id}`;
}
export async function getUser(id: number): Promise<User | null> {
const key = userKey(id);
// 1. Check cache trước
const cached = await redis.get(key);
if (cached !== null) {
// 2. HIT: deserialize và trả về ngay
return JSON.parse(cached) as User;
}
// 3. MISS: đọc DB
const user = await readUserFromDb(id);
if (user === null) {
// Không có trong DB: trả null (cân nhắc negative caching, xem bài sau)
return null;
}
// 4. Ghi cache kèm TTL rồi trả về
await redis.set(key, JSON.stringify(user), "EX", TTL_SECONDS);
return user;
}
// Khi UPDATE dữ liệu: ghi DB trước, sau đó invalidate (delete) cache.
// Chi tiết invalidation ở bài sau, ở đây chỉ giới thiệu nguyên tắc.
export async function updateUserEmail(id: number, email: string): Promise<void> {
await db.query("UPDATE users SET email = $1 WHERE id = $2", [email, id]);
await redis.del(userKey(id)); // xoá cache để lần đọc sau nạp lại bản mới
}
Một biến thể an toàn hơn cho biểu thức set TTL là dùng API có kiểu rõ ràng: redis.set(key, value, "EX", ttl). Tránh dùng SETEX cũ rồi quên đổi khi thêm option khác.
Code getUser Với redis-py (Python)
Phiên bản tương đương bằng redis-py. Đặt decode_responses=True để client trả về str thay vì bytes, set TTL bằng tham số ex của set.
import json
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
TTL_SECONDS = 300 # 5 phút
def read_user_from_db(user_id: int) -> dict | None:
# SELECT id, name, email FROM users WHERE id = %s
row = db.fetchone("SELECT id, name, email FROM users WHERE id = %s", (user_id,))
return dict(row) if row else None
def user_key(user_id: int) -> str:
return f"user:{user_id}"
def get_user(user_id: int) -> dict | None:
key = user_key(user_id)
# 1. Check cache trước
cached = r.get(key)
if cached is not None:
# 2. HIT: deserialize và trả về ngay
return json.loads(cached)
# 3. MISS: đọc DB
user = read_user_from_db(user_id)
if user is None:
return None # cân nhắc negative caching (xem bài sau)
# 4. Ghi cache kèm TTL rồi trả về
r.set(key, json.dumps(user), ex=TTL_SECONDS)
return user
def update_user_email(user_id: int, email: str) -> None:
# Ghi DB trước, sau đó invalidate (delete) cache.
db.execute("UPDATE users SET email = %s WHERE id = %s", (email, user_id))
r.delete(user_key(user_id)) # xoá cache để lần đọc sau nạp lại bản mới
Hai bản code (TS và Python) có cấu trúc giống hệt nhau — đó chính là điểm mạnh của cache-aside: logic nằm ở application và độc lập với ngôn ngữ, client chỉ cung cấp GET/SET/DEL kèm TTL.
Hit, Miss, Hit Ratio, Cold Cache
- Cache hit: yêu cầu đọc tìm thấy dữ liệu trong cache, trả về ngay mà không chạm DB.
- Cache miss: cache không có dữ liệu, phải đọc DB rồi ghi cache.
- Hit ratio (tỉ lệ trúng):
hits / (hits + misses). Hit ratio càng cao thì DB càng được giảm tải. Hệ thống tốt thường đạt 90-99% cho hot data. - Cache miss penalty: chi phí phụ trội của một lần miss — gồm thời gian đọc DB cộng với thời gian ghi cache. Một miss luôn chậm hơn cả khi không có cache (vì có thêm bước GET + SET), nhưng các hit sau đó bù lại rất nhiều.
- Cold cache: trạng thái cache rỗng (vừa khởi động, vừa bị flush, hoặc TTL vừa hết hàng loạt). Lúc này mọi request đều miss, DB chịu tải đột biến.
- Warm-up: hành động nạp trước dữ liệu hot vào cache để tránh cold cache đập DB. Có thể warm-up chủ động (chạy job nạp trước) hoặc để traffic tự làm ấm cache dần. Với cache-aside thuần, cache ấm lên một cách tự nhiên theo các lần miss đầu tiên.
Bạn có thể quan sát hit/miss ở mức server bằng INFO stats:
redis-cli INFO stats | grep keyspace
# keyspace_hits:184523
# keyspace_misses:9210
#
# hit ratio = 184523 / (184523 + 9210) ≈ 0.952 (95,2%)
Lưu ý: keyspace_hits/misses là chỉ số toàn server cho thao tác truy cập key, hữu ích để theo dõi xu hướng. Để đo hit ratio theo từng loại dữ liệu, nên tự đếm trong application (metrics riêng cho getUser...).
Cập Nhật Dữ Liệu & Vai Trò Của TTL
Cache-Aside chỉ định nghĩa luồng đọc. Khi update dữ liệu trong database, cache có thể trở nên cũ (stale). Cách xử lý phổ biến và đơn giản nhất với cache-aside là invalidate (delete) cache:
- Ghi thay đổi vào DB (nguồn sự thật).
- Xoá key tương ứng trong cache bằng
DEL. - Lần đọc kế tiếp sẽ miss và tự nạp lại bản mới từ DB.
Vì sao chọn delete chứ không update cache trực tiếp? Vì delete đơn giản và an toàn hơn: tránh việc tự dựng lại object trong cache có thể sai khác với bản DB, và tránh ghi cache những dữ liệu không chắc sẽ được đọc lại. Chi tiết invalidation (kể cả thứ tự ghi DB/xoá cache, các edge case) sẽ ở bài về invalidation.
Vì sao vẫn cần TTL kể cả khi đã có invalidation?
TTL (time-to-live) là "lưới an toàn" (safety net) cho cache-aside vì những lý do sau:
- Phòng hờ khi invalidation thất bại: tiến trình update có thể crash ngay sau khi ghi DB nhưng trước khi
DELcache; hoặc lệnhDELbị lỗi mạng. Nếu không có TTL, key đó sẽ stale vĩnh viễn. Có TTL, dữ liệu sai cũng chỉ tồn tại tối đa bằng TTL rồi tự được làm mới. - Bắt các đường update không đi qua cache: dữ liệu có thể bị sửa bởi job batch, migration, hoặc service khác mà không gọi tới đoạn invalidate. TTL đảm bảo cuối cùng cache cũng đồng bộ lại.
- Giới hạn memory: TTL giúp các key ít dùng tự hết hạn, tránh cache phình to vô hạn (kết hợp với eviction policy).
Chọn TTL theo mức chấp nhận stale của từng loại dữ liệu: cấu hình ít đổi có thể TTL dài (giờ), dữ liệu nhạy cảm về độ tươi nên TTL ngắn (giây tới phút). Nguyên tắc: TTL là cận trên của stale window khi invalidation lỗi.
Vì Sao Cache-Aside Phổ Biến Nhất
Cache-Aside là pattern caching được dùng rộng rãi nhất, vì:
- Đơn giản: chỉ cần
GET,SET ... EX,DEL. Không cần thư viện đặc biệt hay tích hợp cache vào tầng DB. - Resilient (chịu lỗi tốt): nếu Redis chết, application vẫn đọc được trực tiếp từ database — chỉ chậm hơn chứ không sập. Cache là lớp tăng tốc tuỳ chọn, không phải đường đọc bắt buộc.
- Chỉ cache cái thực sự được đọc: dữ liệu vào cache theo nhu cầu thực tế (lazy), nên cache tự nhiên chứa hot data, tiết kiệm bộ nhớ.
- Kiểm soát hoàn toàn ở application: bạn quyết định key, format serialize, TTL, và khi nào invalidate — không bị "magic" ẩn dưới một lớp framework.
So sánh nhanh với Read-Through
Trong Read-Through, application chỉ đọc qua cache; chính cache (hoặc một lớp cache provider) chịu trách nhiệm tự đọc DB khi miss và tự nạp lại. Application không trực tiếp thấy database.
- Cache-Aside: application điều phối DB ↔ cache. Linh hoạt, resilient, nhưng logic lặp lại ở nhiều nơi.
- Read-Through: cache layer điều phối. Code gọi gọn gàng hơn, nhưng phụ thuộc provider và nếu cache layer chết thì đường đọc cũng đứt.
Chi tiết Read-Through (cùng Write-Through, Write-Behind) sẽ được phân tích sâu ở bài 10.
Trade-off & Race Condition Cơ Bản
Cache-Aside không miễn phí. Các trade-off cần nhớ:
- Stale data window: giữa lúc DB thay đổi và lúc cache được invalidate (hoặc TTL hết hạn), client có thể đọc dữ liệu cũ. Độ rộng cửa sổ này phụ thuộc vào tốc độ invalidation và độ dài TTL.
- Request đầu luôn miss: vì lazy loading, lần đọc đầu tiên cho mỗi key luôn phải xuống DB. Với cold cache (vừa restart, vừa flush) thì hàng loạt key cùng miss, gây tải đột biến lên DB.
- Race condition cơ bản: hai luồng đọc/ghi xen kẽ có thể khiến một giá trị DB cũ bị ghi đè lên giá trị mới trong cache (stale set). Ví dụ minh hoạ thứ tự thời gian:
t0 Thread A (đọc): MISS -> đọc DB, lấy được email = "[email protected]"
t1 Thread B (update): UPDATE DB email = "[email protected]"; DEL cache
t2 Thread A (đọc): SET cache email = "[email protected]" (ghi đè bằng bản CŨ!)
=> cache giờ chứa "[email protected]" cho tới khi TTL hết hoặc DEL lần sau
Đây là kẽ hở kinh điển của cache-aside. Ở đây ta chỉ nêu để bạn nhận biết; các kỹ thuật xử lý (TTL ngắn, double-delete, versioning, đọc-rồi-set có điều kiện, và chống cache stampede khi nhiều request cùng miss một lúc) sẽ được phân tích sâu ở các bài về invalidation và stampede.
Một trade-off phụ: vì DB là nguồn sự thật và cache chỉ là bản sao, bạn phải chấp nhận mô hình eventual consistency giữa cache và DB. Nếu hệ thống cần đọc luôn tuyệt đối tươi, hãy đọc thẳng DB cho đường đó.
Pitfalls & Anti-patterns
- Cache không TTL:
SETmà quênEXkhiến key tồn tại mãi. Khi invalidation lỡ sót một lần, dữ liệu stale vĩnh viễn và memory phình to. Luôn đặt TTL mặc định. - Cache toàn bộ thay vì hot data: cố cache mọi truy vấn (kể cả dữ liệu hiếm khi đọc lại) làm tốn RAM mà hit ratio thấp. Cache-aside vốn lazy, hãy để nó tự lọc hot data thay vì nạp bừa.
- Lưu value quá lớn: nhét cả response khổng lồ hoặc danh sách hàng nghìn phần tử vào một key làm tăng băng thông mạng và thời gian serialize, có thể chặn event loop của Redis. Cache phần tử nhỏ, hoặc tách nhỏ key.
- Quên invalidate khi update DB: ghi DB mà không
DELcache khiến dữ liệu stale tới tận khi TTL hết. Đây là bug phổ biến nhất; hãy đặt invalidation ngay cạnh mọi lệnh ghi DB. - Cache cả lỗi hoặc empty không kiểm soát: nếu DB tạm lỗi mà bạn vẫn ghi kết quả rỗng vào cache với TTL dài, hệ thống sẽ "khoá" trạng thái lỗi đó. Phân biệt rõ "không có dữ liệu" và "đọc thất bại"; negative caching (cache có kiểm soát cho kết quả không tồn tại) sẽ được bàn ở bài sau.
- Key không có namespace / không version: dùng key trùng nhau giữa các phiên bản schema gây đọc nhầm format cũ. Đặt tiền tố rõ ràng như
user:v2:{id}khi cần đổi format. - Coi cache là nguồn sự thật: ghi thẳng vào cache rồi mới (hoặc không) đồng bộ xuống DB trong khi đang dùng cache-aside. Trong cache-aside, DB luôn là source of truth; cache chỉ là bản sao có thể xoá bất cứ lúc nào.
Tổng Kết & Quiz
Tổng kết
- Cache-Aside (lazy loading): application check cache trước; HIT trả ngay; MISS đọc DB, ghi cache kèm TTL rồi trả về.
- DB là nguồn sự thật; cache chỉ là bản sao tạm thời. Khi update DB thì invalidate (delete) cache.
- TTL là lưới an toàn: giới hạn stale window khi invalidation lỗi và tránh memory phình to.
- Theo dõi hit/miss và hit ratio để biết cache có hiệu quả; chú ý cold cache và warm-up.
- Phổ biến vì đơn giản, resilient (cache chết vẫn đọc được DB) và chỉ cache cái thực sự được đọc.
- Trade-off chính: stale data window, request đầu luôn miss, race condition stale-set; chấp nhận eventual consistency.
Quiz 5 câu
- Mô tả đầy đủ luồng đọc của Cache-Aside khi xảy ra cache miss, kể cả việc xử lý TTL.
- Vì sao trong cache-aside người ta thường delete cache khi update DB thay vì update trực tiếp giá trị trong cache?
- Đã có invalidation rồi thì tại sao vẫn nên đặt TTL cho mọi key? Nêu ít nhất hai lý do.
- Hit ratio là gì và tính bằng công thức nào? Cold cache ảnh hưởng tới hit ratio và tải DB ra sao?
- Mô tả một race condition khiến cache chứa dữ liệu cũ trong cache-aside. Yếu tố nào giới hạn thời gian dữ liệu cũ tồn tại?
Đáp án gợi ý
- App
GETkey; nếu có giá trị (HIT) deserialize và trả ngay. Nếunil(MISS) đọc DB; nếu DB có dữ liệu thìSET key value EX ttlrồi trả về; nếu DB không có thì trả null (cân nhắc negative caching). TTL được đặt ngay lúc ghi cache để dữ liệu tự hết hạn về sau. - Delete đơn giản và an toàn hơn: tránh tự dựng lại object có thể sai khác bản DB, tránh ghi cache dữ liệu chưa chắc được đọc lại, và để lần miss kế tiếp nạp lại bản chuẩn từ DB.
- (1) Phòng hờ khi invalidation thất bại (process crash sau khi ghi DB nhưng trước
DEL, hoặcDELlỗi mạng) — không có TTL thì stale vĩnh viễn. (2) Bắt các đường update không đi qua cache (job batch, migration, service khác). Thêm: giới hạn memory cho key ít dùng. - Hit ratio =
hits / (hits + misses). Cold cache (cache rỗng) khiến mọi request đều miss, hit ratio tụt và DB chịu tải đột biến vì phải phục vụ tất cả truy vấn cho tới khi cache ấm lại. - Thread A miss và đọc được bản DB cũ; trước khi A kịp
SET, Thread B update DB vàDELcache; sau đó A mớiSETcache bằng bản cũ, ghi đè bản mới (stale set). TTL (và lầnDELkế tiếp) giới hạn thời gian dữ liệu cũ tồn tại.
Bài tiếp theo
Bài 10 đi sâu vào ba chiến lược còn lại: Read-Through (cache tự đọc DB khi miss), Write-Through (ghi đồng bộ qua cache xuống DB) và Write-Behind (ghi cache trước, đẩy xuống DB bất đồng bộ) — cùng so sánh khi nào nên dùng cái nào.
