Mục lục
- Mục Tiêu Bài Học
- Vấn Đề: Khi Nào Redis Round-trip Trở Thành Overhead
- Mô Hình 3-tier: L1 → L2 → L3
- Triển Khai L1 In-process (Python)
- Read Pattern Hoàn Chỉnh: L1 → L2 → DB
- Chiến Lược TTL: L1 Ngắn Hơn L2
- Invalidation Trong Môi Trường Nhiều Instance
- Pub/Sub Invalidation Pattern
- RESP3 Client-side Caching (Redis 6+)
- Sizing L1 & Monitoring
- Trade-off & Khi Nào Không Cần L1
- Pitfalls & Anti-patterns
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Hiểu rõ vì sao Redis round-trip có thể trở thành bottleneck khi một request gọi Redis nhiều lần liên tiếp.
- Nắm mô hình 3-tier (L1 in-process → L2 Redis → L3 DB): vai trò của từng tầng và luồng đọc.
- Triển khai được L1 cache bằng Python (
cachetools.TTLCache) với bounded size và TTL. - Hiểu và áp dụng chiến lược TTL: L1 ngắn hơn L2 và lý do đứng sau.
- Giải thích được thách thức invalidation L1 trong môi trường nhiều service instance, và ba chiến lược xử lý: TTL-based, Pub/Sub, RESP3 client-side caching.
- Triển khai được Pub/Sub invalidation pattern: producer xoá L2 và publish message; mỗi instance subscriber xoá L1 local.
- Biết cách sizing L1 (memory limit, eviction, monitoring) và nhận diện các trường hợp không cần L1.
Vấn Đề: Khi Nào Redis Round-trip Trở Thành Overhead
Redis đọc một key mất khoảng 0.5–2ms trên mạng nội bộ. Con số này nhỏ đến mức hầu hết bài toán caching không cần bận tâm. Vấn đề bắt đầu xuất hiện ở hai tình huống:
- Nhiều Redis call trong một request: một trang chi tiết sản phẩm có thể cần đọc thông tin sản phẩm, danh mục, đánh giá, tồn kho, giá khuyến mãi, cấu hình hiển thị — mỗi thứ một key riêng. Nếu không pipeline được toàn bộ (vì phụ thuộc nhau hoặc đến từ nhiều tầng code khác nhau), 50 lần GET Redis × 1ms = 50ms chỉ riêng network I/O.
- Hot key với traffic rất cao: dữ liệu được 80% request cùng đọc (ví dụ cấu hình hệ thống, danh sách category, tỉ giá) — mỗi request vẫn phải đi qua Redis, gây áp lực lên Redis server và tiêu tốn băng thông mạng.
Giải pháp tự nhiên: nếu nhiều request trong cùng một process đọc cùng dữ liệu, hãy cache lại ngay trong bộ nhớ của process đó. Lần sau đọc không cần ra mạng — latency xuống còn vài micro-giây.
# Ước tính latency theo tầng (số minh hoạ, phụ thuộc hardware)
#
# L1 in-process (dict lookup, TTLCache): ~1–10 µs
# L2 Redis (TCP, localhost): ~0.2–0.5 ms
# L2 Redis (TCP, same DC network): ~0.5–2 ms
# L3 Database (query đơn giản): ~1–10 ms
# L3 Database (query phức tạp, join): ~10–100 ms
#
# 50 lần đọc cùng dữ liệu trong 1 request:
# Chỉ L2 Redis: 50 × 1ms = ~50ms overhead
# L1 hit (sau lần 1): 50 × 5µs = ~0.25ms overhead
Đây là bài toán L1 in-process cache giải quyết. Tầng L1 không thay thế Redis — nó đứng trước Redis như một lớp lọc để giảm số lượng network call.
Mô Hình 3-tier: L1 → L2 → L3
Mô hình 3-tier đặt ba tầng theo thứ tự ưu tiên: hỏi tầng gần nhất trước, chỉ xuống tầng sâu hơn khi tầng trên không có.
Request
│
▼
┌─────────────────────────────────────────┐
│ L1: In-process cache (RAM của process) │ ~1-10 µs
│ TTLCache, dict, LRU bounded │
└───────────────────┬─────────────────────┘
│ MISS
▼
┌─────────────────────────────────────────┐
│ L2: Redis (shared cache, all instances) │ ~0.5-2 ms
│ Dùng chung cho mọi service instance │
└───────────────────┬─────────────────────┘
│ MISS
▼
┌─────────────────────────────────────────┐
│ L3: Database (nguồn sự thật) │ ~1-100 ms
│ PostgreSQL, MySQL, MongoDB... │
└─────────────────────────────────────────┘
Đặc điểm từng tầng:
- L1: nằm trong bộ nhớ của từng process/instance. Truy cập bằng dict lookup hoặc LRU cache — không có network hop. Dữ liệu là bản sao riêng của từng instance, không chia sẻ. Kích thước giới hạn (thường vài nghìn entry) để tránh chiếm quá nhiều RAM của process.
- L2: Redis là cache chia sẻ chung cho tất cả instance. Mọi instance đọc từ cùng một Redis, nên một instance ghi vào L2 thì instance khác đọc được ngay. Kích thước lớn hơn nhiều so với L1 (thường tính bằng GB).
- L3: nguồn sự thật. Chỉ bị đánh trúng khi cả L1 và L2 đều miss. Dữ liệu đọc từ L3 được ghi ngược lên L2 và L1 cho các lần tiếp theo.
Điểm khác biệt quan trọng giữa L1 và L2: L2 là centralized (dùng chung), L1 là per-instance (mỗi instance có bản riêng). Đây chính là nguyên nhân khiến invalidation L1 phức tạp hơn nhiều so với invalidation L2.
Triển Khai L1 In-process (Python)
Thư viện cachetools cung cấp TTLCache — dictionary với giới hạn số entry (LRU eviction khi đầy) và TTL tự động. Đây là lựa chọn phổ biến cho L1 Python vì thread-safe với threading.Lock và không cần dependency nặng.
pip install cachetools redis
import threading
from cachetools import TTLCache
# L1 cache: tối đa 1000 entry, TTL 30 giây mỗi entry
# maxsize giới hạn memory: khi đầy, entry ít dùng nhất (LRU) bị evict
L1_MAX_SIZE = 1_000
L1_TTL_SECONDS = 30
l1_cache: TTLCache = TTLCache(maxsize=L1_MAX_SIZE, ttl=L1_TTL_SECONDS)
l1_lock = threading.Lock() # TTLCache không thread-safe mặc định
Một số lựa chọn L1 cache theo ngôn ngữ:
- Python:
cachetools.TTLCache(có TTL + LRU),functools.lru_cache(không có TTL — phù hợp dữ liệu gần như bất biến). - Node.js:
lru-cache(package npm, có TTL và maxSize),node-cache. - Java: Caffeine — in-process cache mạnh nhất cho JVM, hỗ trợ TTL, maximum weight, async loading.
- Go:
ristretto(Dgraph),bigcache.
Điểm mấu chốt khi chọn thư viện L1: phải có bounded size (giới hạn số entry hoặc memory) và eviction policy (LRU hoặc tương đương). Dùng plain dict không giới hạn là anti-pattern — memory của process sẽ phình không kiểm soát.
Read Pattern Hoàn Chỉnh: L1 → L2 → DB
Triển khai hàm get_user với đầy đủ 3 tầng. Mỗi lần miss ở tầng trên, kết quả được backfill lên tầng trên để các lần đọc sau không phải xuống tầng sâu.
import json
import threading
from cachetools import TTLCache
import redis
# -- Cấu hình tầng --
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
L1_MAX_SIZE = 1_000
L1_TTL_SECONDS = 30 # giây: L1 ngắn để giảm stale window nội bộ
L2_TTL_SECONDS = 300 # giây: L2 dài hơn, là nguồn fallback khi L1 miss
l1_cache: TTLCache = TTLCache(maxsize=L1_MAX_SIZE, ttl=L1_TTL_SECONDS)
l1_lock = threading.Lock()
def db_fetch_user(user_id: int) -> dict | None:
"""Đọc từ database (nguồn sự thật)."""
row = db.fetchone("SELECT id, name, email FROM users WHERE id = %s", (user_id,))
return dict(row) if row else None
def get_user(user_id: int) -> dict | None:
# ── Tầng L1: in-process ──────────────────────────────
with l1_lock:
if user_id in l1_cache:
return l1_cache[user_id] # hit L1: ~1-5 µs, không qua mạng
# ── Tầng L2: Redis ───────────────────────────────────
raw = r.get(f"user:{user_id}")
if raw is not None:
parsed = json.loads(raw)
# Backfill lên L1 cho các lần sau trong cùng process
with l1_lock:
l1_cache[user_id] = parsed
return parsed # hit L2: ~0.5-2 ms
# ── Tầng L3: Database ────────────────────────────────
user = db_fetch_user(user_id)
if user is None:
return None # không có trong DB, không cache
# Backfill lên cả L2 và L1
r.set(f"user:{user_id}", json.dumps(user), ex=L2_TTL_SECONDS)
with l1_lock:
l1_cache[user_id] = user
return user # hit L3: ~1-100 ms
Luồng backfill quan trọng: khi data được kéo từ tầng sâu hơn, nó được ghi ngược lên tất cả các tầng phía trên. Nhờ vậy, một request miss L1 nhưng hit L2 sẽ nạp lại vào L1; lần sau trong cùng process sẽ hit L1 ngay. Sau một thời gian warm-up tự nhiên, phần lớn request hot sẽ hit L1 trước khi cần hỏi Redis.
Chiến Lược TTL: L1 Ngắn Hơn L2
Quy tắc cơ bản: L1 TTL phải ngắn hơn L2 TTL. Lý do:
- Khi L1 expire, process bắt buộc hỏi L2. Nếu L2 vẫn còn dữ liệu (L2 TTL chưa hết), process nhận được bản tương đối mới và backfill vào L1. Quá trình này diễn ra trong suốt và không cần xuống DB.
- Nếu L1 TTL dài hơn hoặc bằng L2 TTL, thì khi L1 expire thì L2 cũng có thể đã expire → mọi miss L1 đều phải xuống DB. Phần lợi ích của L1 bị triệt tiêu.
Khuyến nghị thực tế:
- L1 TTL: 5–60 giây. Ngắn đủ để dữ liệu không quá cũ trong một process, nhưng đủ dài để giảm đáng kể số Redis call trong window đó. Với dữ liệu rất hot (config hệ thống, danh sách category), L1 TTL 60 giây thường hợp lý.
- L2 TTL: 5–30 phút (tùy tính chất dữ liệu). Vai trò của L2 vẫn là cache chính; TTL này giống như TTL thông thường của cache-aside.
# Ví dụ cấu hình theo loại dữ liệu
#
# Cấu hình hệ thống (ít đổi):
# L1 TTL = 60s, L2 TTL = 30m
#
# Thông tin user (đổi vừa phải):
# L1 TTL = 30s, L2 TTL = 5m
#
# Giá sản phẩm (có thể đổi nhanh):
# L1 TTL = 5s, L2 TTL = 60s
#
# Quy tắc: L1 TTL < L2 TTL (bắt buộc)
# Khi L1 expire → hỏi L2 → L2 vẫn fresh → backfill L1 mà không xuống DB
Một hệ quả của quan hệ này: khi data thay đổi và bạn invalidate L2 (xoá key Redis), L1 sẽ vẫn còn bản cũ tối đa L1_TTL giây nữa. Đây là stale window chấp nhận được của L1. Nếu yêu cầu consistency nghiêm ngặt hơn, cần cơ chế invalidation chủ động ở mục sau.
Invalidation Trong Môi Trường Nhiều Instance
L2 Redis là cache tập trung — invalidate một lần, tất cả instance đều thấy hiệu quả ngay. L1 là per-instance — mỗi instance giữ bản riêng, và không instance nào có thể trực tiếp xoá L1 của instance khác.
Giả sử bạn có 10 instance đang chạy. User A cập nhật profile. Bạn gọi:
db.update_user(user_id, new_data)
r.delete(f"user:{user_id}") # xoá L2 — đơn giản
L2 đã được xoá. Nhưng 10 instance vẫn giữ bản cũ trong L1 của mình. Chúng sẽ tiếp tục serve bản cũ cho đến khi L1 TTL hết hạn (tối đa L1_TTL giây). Với L1 TTL = 30 giây, mỗi instance có thể serve dữ liệu cũ trong tối đa 30 giây nữa.
Ba chiến lược xử lý, tùy mức độ yêu cầu consistency:
| Chiến lược | Cơ chế | Stale window L1 | Độ phức tạp |
|---|---|---|---|
| TTL-based | Chỉ dùng TTL ngắn ở L1 | 0 → L1_TTL giây | Thấp |
| Pub/Sub invalidation | Publish message → mỗi instance subscribe → xoá L1 local | ~vài ms (độ trễ pub/sub) | Trung bình |
| RESP3 client-side caching | Redis tự track + push notification khi key thay đổi | ~vài ms (native protocol) | Trung bình (cần RESP3) |
Chiến lược nào phù hợp phụ thuộc vào mức stale chấp nhận được: nếu vài chục giây stale không thành vấn đề, TTL-based đủ rồi. Nếu cần invalidation gần real-time, cần Pub/Sub hoặc client-side caching.
Pub/Sub Invalidation Pattern
Pub/Sub invalidation dùng chính Redis Pub/Sub để broadcast "hãy xoá key này khỏi L1" tới tất cả instance đang lắng nghe. Phía producer (khi update data) publish một message; phía mỗi instance có một subscriber thread chạy nền, nhận message và xoá entry tương ứng trong L1 local.
Phía producer (khi update dữ liệu)
def update_user(user_id: int, new_data: dict) -> None:
# 1. Ghi DB trước (nguồn sự thật)
db.execute(
"UPDATE users SET name=%s, email=%s WHERE id=%s",
(new_data["name"], new_data["email"], user_id),
)
# 2. Xoá L2 Redis
r.delete(f"user:{user_id}")
# 3. Broadcast invalidation cho tất cả instance (xoá L1 của chúng)
r.publish("cache:invalidate", f"user:{user_id}")
Phía mỗi instance: subscriber thread chạy nền
import threading
def _run_invalidation_subscriber() -> None:
"""Thread chạy nền, lắng nghe invalidation message và xoá L1 local."""
# Dùng connection riêng cho subscriber (SUBSCRIBE blocklist connection)
sub_client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
pubsub = sub_client.pubsub()
pubsub.subscribe("cache:invalidate")
for msg in pubsub.listen():
if msg["type"] != "message":
continue
redis_key = msg["data"] # e.g. "user:42"
# Parse user_id từ key "user:"
if redis_key.startswith("user:"):
try:
uid = int(redis_key.split(":")[1])
with l1_lock:
l1_cache.pop(uid, None) # xoá L1 local của instance này
except (ValueError, IndexError):
pass
# Khởi động khi ứng dụng start (daemon=True: tự tắt khi main thread kết thúc)
_subscriber_thread = threading.Thread(
target=_run_invalidation_subscriber,
daemon=True,
name="cache-invalidation-subscriber",
)
_subscriber_thread.start()
Giới hạn cần biết của Pub/Sub: Redis Pub/Sub là fire-and-forget — message không được lưu lại. Nếu một subscriber đang disconnect (network glitch, restart), các message publish trong thời gian đó bị mất hoàn toàn. Instance đó sẽ giữ bản cũ trong L1 cho đến khi L1 TTL tự hết hạn.
Hệ quả: Pub/Sub invalidation cần TTL làm fallback. Không nên tắt L1 TTL khi đã có Pub/Sub. Pub/Sub giảm stale window xuống vài ms trong trường hợp bình thường; TTL là lưới an toàn khi subscriber miss message. Cả hai cùng hoạt động song song.
RESP3 Client-side Caching (Redis 6+)
Redis 6 giới thiệu RESP3 (Redis Serialization Protocol v3) và client-side caching tích hợp vào protocol. Thay vì application tự track key và tự broadcast invalidation, Redis server chủ động làm điều đó:
- Client bật tracking:
CLIENT TRACKING ON. - Mỗi khi client đọc một key, Redis ghi nhớ "client này đang cache key đó".
- Khi key bị sửa (bởi bất kỳ client nào), Redis tự động push một invalidation message về client đang track key đó qua kết nối hiện tại.
- Client nhận message và xoá entry đó khỏi local cache (L1).
Lợi thế so với Pub/Sub tự làm: notification đến qua kênh riêng của protocol, không cần subscription channel riêng, và Redis server tự quản lý danh sách key-per-client. Độ chính xác cao hơn vì Redis biết chính xác key nào mỗi client đã đọc.
redis-py 4.x hỗ trợ client-side caching qua client_tracking:
# redis-py >= 4.0 với RESP3 / client tracking
# Lưu ý: cần kết nối RESP3, hiện redis-py hỗ trợ qua hiredis hoặc cấu hình riêng
# Đây là pseudocode minh hoạ API — xem docs redis-py 4.x để cấu hình đầy đủ
import redis
# Mở kết nối RESP3 có tracking
r3 = redis.Redis(host="127.0.0.1", port=6379, protocol=3)
r3.execute_command("CLIENT", "TRACKING", "ON")
# Đăng ký callback nhận invalidation notification
def on_invalidate(keys: list[str]) -> None:
for key in keys:
if key.startswith("user:"):
try:
uid = int(key.split(":")[1])
with l1_lock:
l1_cache.pop(uid, None)
except (ValueError, IndexError):
pass
# redis-py 4.x cung cấp interface để handle push messages từ server
# Chi tiết triển khai đầy đủ sẽ được đào sâu ở Module 9
Client-side caching nhanh hơn Pub/Sub vì dùng native protocol, nhưng yêu cầu Redis 6+ và thư viện client hỗ trợ RESP3. Với redis-py, cấu hình đầy đủ có độ phức tạp nhất định. Bài này giới thiệu khái niệm; Module 9 (Client-side Caching Advanced) sẽ đi vào triển khai production-ready.
Sizing L1 & Monitoring
L1 chiếm RAM của process, cần cân bằng giữa hit rate và memory footprint:
- Kích thước tối đa (maxsize): bắt đầu với 1.000–10.000 entry tuỳ kích thước object. Với object 1KB và maxsize 10.000, L1 tốn tối đa ~10MB — chấp nhận được. Với object lớn (1MB mỗi entry), maxsize 1.000 đã là 1GB — cần cân nhắc kỹ.
- Eviction:
TTLCachecủa cachetools dùng LRU khi đầy. Entry ít được đọc nhất bị đẩy ra trước khi entry hot. Không cần can thiệp thủ công. - Phân tách L1 theo loại dữ liệu: không nên dùng chung một TTLCache cho tất cả. Mỗi loại dữ liệu (user, product, config) nên có L1 riêng với maxsize và TTL phù hợp với tính chất của nó.
Metrics cần theo dõi để đánh giá hiệu quả:
import threading
from dataclasses import dataclass, field
@dataclass
class CacheMetrics:
l1_hits: int = 0
l1_misses: int = 0
l2_hits: int = 0
l2_misses: int = 0
l3_hits: int = 0
_lock: threading.Lock = field(default_factory=threading.Lock)
def l1_hit_rate(self) -> float:
total = self.l1_hits + self.l1_misses
return self.l1_hits / total if total else 0.0
def l2_hit_rate_of_l1_miss(self) -> float:
"""Hit rate của L2 tính trên số lần L1 miss."""
total = self.l2_hits + self.l2_misses
return self.l2_hits / total if total else 0.0
metrics = CacheMetrics()
Mục tiêu tham khảo:
- L1 hit rate: 50–80% (nếu thấp hơn, xem xét tăng maxsize hoặc TTL).
- L2 hit rate (tính trên L1 miss): 90–95% (L2 vẫn là tầng cache chính).
- L3 (DB) rate (tính trên tổng request): dưới 5–10%.
Nếu L1 hit rate thấp hơn mong đợi dù maxsize đủ lớn, nguyên nhân thường là dữ liệu truy cập không đủ hot (uniform random access) hoặc L1 TTL quá ngắn. Tăng TTL (trong giới hạn chấp nhận stale) hoặc đánh giá lại xem L1 có thực sự cần cho use case đó không.
Trade-off & Khi Nào Không Cần L1
Trade-off
- Pros: latency giảm xuống microsecond cho hot data; giảm số lượng Redis call và băng thông mạng; giảm tải Redis server.
- Cons: thêm complexity invalidation (L1 per-instance không share); mỗi process tốn thêm RAM riêng cho L1; debug khó hơn khi instance có bản cũ khác nhau; cần xử lý thread safety cho L1.
Khi nào không cần L1
- Read traffic thấp: nếu service nhận dưới ~100 RPS, Redis round-trip không phải bottleneck. Thêm L1 chỉ tăng complexity mà không giải quyết vấn đề thực sự.
- Access pattern không hot: nếu mỗi request đọc key khác nhau (uniform random, không có hot key), L1 hit rate sẽ rất thấp — cache không có gì để giữ lại. L1 lúc này chỉ waste memory.
- Consistency nghiêm ngặt: dữ liệu như số dư tài khoản, tồn kho real-time, trạng thái giao dịch — không được phép phục vụ bản cũ dù chỉ vài giây. Với loại dữ liệu này không nên dùng L1 (và thậm chí phải cân nhắc có nên cache L2 hay không).
- Số lượng Redis call mỗi request thấp: nếu mỗi request chỉ gọi Redis 1–3 lần, tổng overhead không đáng kể và không cần L1 để giảm thêm.
Nguyên tắc: chỉ thêm L1 khi đo được (hoặc có lý do rõ ràng để dự đoán) rằng Redis round-trip đang là bottleneck của latency, hoặc khi hot key traffic đủ cao để L1 hit rate đạt ngưỡng có ý nghĩa.
Pitfalls & Anti-patterns
- L1 không có size limit: dùng plain dict hoặc cache không có maxsize. Process sẽ phình memory không kiểm soát theo thời gian. Luôn đặt
maxsizecho L1. - Lưu object quá lớn trong L1: cache cả response lớn (list hàng nghìn item, blob nhị phân) làm RAM của process tăng vọt và GC pressure tăng. L1 nên chứa các object nhỏ; object lớn nên để L2 Redis xử lý.
- L1 TTL lớn hơn hoặc bằng L2 TTL: phá vỡ cơ chế backfill — khi L1 expire thì L2 cũng đã expire, buộc phải xuống DB. Luôn đảm bảo L1_TTL < L2_TTL.
- Dùng Pub/Sub invalidation mà không có fallback TTL: khi subscriber disconnect và miss message, L1 sẽ giữ bản cũ mãi mãi (nếu không có TTL). TTL là lưới an toàn bắt buộc khi dùng Pub/Sub.
- Không xử lý thread safety của L1: nhiều worker thread đọc ghi
TTLCachecùng lúc mà không có lock có thể gây race condition. Dùngthreading.Lockhoặc chọn thư viện có thread-safe option. - Quên invalidate L1 khi update: ghi DB → xoá L2 → nhưng quên xoá L1 local của process hiện tại. Process vẫn serve bản cũ cho tới khi L1 TTL hết. Hàm update nên xoá L1 cùng lúc xoá L2.
def update_user(user_id: int, new_data: dict) -> None:
db.execute("UPDATE users SET name=%s, email=%s WHERE id=%s",
(new_data["name"], new_data["email"], user_id))
# Invalidate L2
r.delete(f"user:{user_id}")
# Invalidate L1 của process hiện tại (đừng quên)
with l1_lock:
l1_cache.pop(user_id, None)
# Notify các instance khác (nếu dùng Pub/Sub)
r.publish("cache:invalidate", f"user:{user_id}")
Tổng Kết & Quiz
Tổng kết
- Multi-layer cache thêm L1 in-process (microsecond) trước L2 Redis để giảm network round-trip khi một request gọi Redis nhiều lần hoặc khi hot key được đọc với traffic cao.
- Luồng đọc: check L1 trước; miss → check L2; miss → đọc L3 DB; kết quả backfill lên các tầng phía trên.
- L1 TTL phải ngắn hơn L2 TTL để đảm bảo khi L1 expire, L2 vẫn còn dữ liệu để backfill.
- L1 là per-instance: invalidation L1 trong môi trường nhiều instance cần chiến lược riêng — TTL-based (đơn giản nhất), Pub/Sub (gần real-time nhưng fire-and-forget), hoặc RESP3 client-side caching (native Redis 6+).
- Pub/Sub invalidation cần TTL làm fallback — message có thể bị mất khi subscriber disconnect.
- L1 cần bounded size và eviction policy. Không dùng plain dict không giới hạn.
- Không cần L1 khi traffic thấp, access pattern không hot, hoặc consistency strict (tài khoản, tồn kho real-time).
Quiz 5 câu
- Tại sao Redis round-trip 0.5–2ms có thể trở thành bottleneck đáng kể? Cho ví dụ cụ thể với con số.
- Giải thích tại sao L1 TTL bắt buộc phải ngắn hơn L2 TTL. Điều gì xảy ra nếu L1_TTL ≥ L2_TTL?
- Mô tả thách thức invalidation L1 trong môi trường 10 instance. Vì sao invalidate L2 không đủ?
- Pub/Sub invalidation pattern hoạt động như thế nào (producer làm gì, subscriber làm gì)? Giải thích vì sao vẫn cần fallback TTL dù đã có Pub/Sub.
- Nêu ba tình huống không nên thêm L1 cache vào hệ thống. Với mỗi tình huống, lý do là gì?
Đáp án gợi ý
- Redis round-trip tốn khoảng 0.5–2ms mỗi lần. Nếu một request gọi Redis 50 lần (đọc nhiều key khác nhau, không pipeline được), tổng network overhead là 25–100ms — chiếm phần lớn latency ngân sách của request. Thêm L1 giảm 49/50 lần gọi đó xuống ~5µs.
- Khi L1 expire, process phải hỏi L2 để lấy dữ liệu mới. Nếu L2 vẫn còn (L2 TTL chưa hết), backfill L1 mà không cần xuống DB — đây là cơ chế hiệu quả. Nếu L1_TTL ≥ L2_TTL: khi L1 expire thì L2 cũng đã expire, mọi L1 miss đều bắt buộc xuống DB, mất hoàn toàn lợi ích của L1.
- L2 Redis là centralized — invalidate một lần, hiệu lực ngay. L1 là per-instance — mỗi trong 10 instance giữ bản riêng trong RAM của mình. Khi invalidate L2, 10 bản L1 vẫn còn dữ liệu cũ cho đến khi L1 TTL hết hạn. Không có cơ chế built-in để xoá L1 của instance khác.
- Producer: ghi DB → xoá L2 (
DEL) →PUBLISH "cache:invalidate" "user:42". Mỗi instance có một subscriber thread chạy nền lắng nghe channel đó; khi nhận message, thread xoá entry tương ứng trong L1 local. Cần fallback TTL vì Pub/Sub là fire-and-forget: nếu subscriber đang disconnect (restart, network glitch), message publish trong thời gian đó bị mất hoàn toàn, L1 của instance đó sẽ giữ bản cũ mãi nếu không có TTL. - (1) Traffic thấp (<~100 RPS): Redis round-trip không phải bottleneck, thêm L1 chỉ tăng complexity vô ích. (2) Access pattern không hot (uniform random): L1 hit rate thấp, cache không giữ được gì giữa các request, waste memory. (3) Consistency strict (số dư, tồn kho real-time): không được phép serve dữ liệu cũ dù chỉ vài giây, L1 stale window là rủi ro không chấp nhận được.
Bài tiếp theo
Bài 17 phân tích Cache Invalidation & Bài Toán Consistency — các pattern invalidation đúng thứ tự (write-through, delete-then-write, write-then-delete), race condition trong invalidation, và khi nào cần strong consistency thay vì eventual consistency.
