Trung bình Database

Chiến Lược Cache Redis: Cache-Aside Với Write-Through, Chọn Sao Cho Đúng

So sánh cache-aside với write-through khi xài Redis - trade-off về consistency, cách đặt TTL, với khi nào nên chọn cái nào.

22 Th07, 2026 7 phút 285 Lượt xem 4 Khối code
Sơ đồ
graph TD A[Application] -->|Read| B{Cache-Aside} B -->|Hit| C[Return from Redis] B -->|Miss| D[Query Database] D --> E[Populate Redis] E --> C F[Application] -->|Write| G{Write-Through} G --> H[Write to Redis] H --> I[Write to Database] I --> J[Confirm to App]

Redis giúp gắn thêm cache vô hệ thống dễ ợt, nhưng pattern mình chọn mới là thứ quyết định hệ thống consistent, chịu lỗi tốt với chạy nhanh cỡ nào. Hai pattern phổ biến nhất - cache-aside với write-through - giải quyết cùng một bài toán theo hai cách hoàn toàn khác nhau.

Cache-Aside (Lazy Loading)

Ở cache-aside, ứng dụng nói chuyện trực tiếp với cache, còn cache thì cứ nằm im chờ. Lúc đọc, app check Redis trước; miss thì load từ database rồi đổ vô cache. Lúc ghi, app ghi thẳng vô database, xong mới invalidate (hoặc update) cache entry.

def get_user(user_id):
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)

    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    return user

def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.delete(f"user:{user_id}")  # invalidate, không update

Write-Through

Ở write-through, cache đứng chắn trước database và làm chủ luôn đường ghi. Mọi lệnh ghi đều qua cache trước, cache ghi đồng bộ (synchronous) xuống database rồi mới trả kết quả về. Đọc thì luôn lấy từ cache vì nó chắc chắn luôn cập nhật mới nhất.

def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))  # update, không invalidate
    return data

Hai pattern khác nhau chỗ nào

graph TD A[Application] -->|Đọc| B{Cache-Aside} B -->|Hit| C[Trả về từ Redis] B -->|Miss| D[Query Database] D --> E[Đổ dữ liệu vô Redis] E --> C F[Application] -->|Ghi| G{Write-Through} G --> H[Ghi vô Redis] H --> I[Ghi vô Database] I --> J[Xác nhận cho App]

Trade-off về consistency

Cache-aside có một khoảng hở nhỏ mà cache có thể trả dữ liệu cũ (stale): nếu hai request race nhau giữa lúc ghi database với lúc invalidate cache, có request đọc trúng giá trị cũ. Write-through tránh được vụ này vì cache với database được update cùng lúc trong một thao tác - nhưng đổi lại mỗi lệnh ghi giờ phải chờ hai hệ thống thay vì một.

Cache-aside tối ưu cho throughput đọc với sự đơn giản. Write-through tối ưu cho consistency, đánh đổi bằng độ trễ khi ghi.

Chiến lược TTL

Dù đã xài write-through, vẫn nên đặt TTL làm lưới an toàn - bug, race condition, hay mấy lệnh ghi database ngoài luồng (migration, admin script) đều có thể khiến cache giữ dữ liệu cũ vô thời hạn nếu không có TTL.

CACHE_TTL_SECONDS = 3600  # 1 giờ

redis.setex(key, CACHE_TTL_SECONDS, value)

TTL ngắn (vài phút) hợp với dữ liệu hay đổi như số lượng tồn kho hay session state. TTL dài (vài giờ) hợp với dữ liệu ít đổi, kiểu profile người dùng hay danh mục sản phẩm.

Xử lý Cache Stampede

Khi một key "hot" hết hạn, hàng chục request đồng thời có thể cùng miss cache một lúc rồi dồn hết xuống database. Cách xử lý đơn giản là dùng lock ngắn hạn, để chỉ một request đi load lại cache, mấy request còn lại đợi:

def get_user(user_id):
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)

    lock = redis.set(f"lock:user:{user_id}", "1", nx=True, ex=5)
    if lock:
        user = db.query("SELECT * FROM users WHERE id = %s", user_id)
        redis.setex(f"user:{user_id}", 3600, json.dumps(user))
        redis.delete(f"lock:user:{user_id}")
        return user
    else:
        time.sleep(0.05)
        return get_user(user_id)  # retry, chắc giờ hit rồi

Write-Behind: Lựa chọn thứ ba

Đáng biết thêm, dù không nằm trong phần so sánh chính của tip này: write-behind (write-back) cache ghi vô Redis ngay lập tức rồi flush xuống database sau, bất đồng bộ. Cách này cho độ trễ ghi thấp nhất nhưng có rủi ro mất dữ liệu nếu cache crash trước khi kịp flush - thường chỉ dùng cho pipeline analytics hay logging throughput cao, không dùng cho hệ thống lưu dữ liệu gốc (system of record).

Khi nào dùng cái nào

Xài cache-aside khi số lượt đọc áp đảo số lượt ghi và chấp nhận được độ trễ dữ liệu (staleness) ngắn - danh sách sản phẩm, profile người dùng, dữ liệu config. Xài write-through khi độ chính xác quan trọng hơn tốc độ ghi, và không chấp nhận được cache với database lệch nhau - dữ liệu giá, tồn kho, hay bất cứ gì hệ thống downstream cần chính xác tuyệt đối.

Mấy cái cần tránh

Quên invalidate cache lúc xóa (delete), chứ không chỉ lúc update, để lại mấy entry "ma" tồn tại lâu hơn cả dữ liệu gốc. Dùng một TTL chung cho nhiều loại dữ liệu khác nhau hoàn toàn thì vừa phí bộ nhớ cho dữ liệu ít đổi, vừa gây stampede cho dữ liệu hay đổi vì cùng chung một mốc hết hạn. Với coi Redis như nguồn dữ liệu gốc thay vì chỉ là cache - đây là dấu hiệu thiết kế có vấn đề: nếu mất cache mà mất luôn dữ liệu thì nó đâu còn là cache nữa.

Chiến lược cache đúng là chiến lược mà mình chịu được kiểu lỗi của nó: cache-aside lỗi theo hướng dữ liệu cũ (stale), write-through lỗi theo hướng độ trễ. Chọn dựa theo hệ thống của mình chịu đựng được cái nào.

Cùng chủ đề Database