Lập trình · 19/09/2026

Thiết kế cache Redis an toàn cho backend: TTL, invalidation và chống cache stampede

Cache có thể giảm latency và tải database rất mạnh, nhưng cũng có thể trả dữ liệu cũ, che lỗi và tạo một đợt truy vấn đồng thời khi key hết hạn. Thiết kế cache production cần xác định nguồn dữ liệu chuẩn, mức độ stale chấp nhận được, cách invalidation và hành vi của ứng dụng khi Redis không khả dụng.

Thiết kế cache Redis an toàn cho backend: TTL, invalidation và chống cache stampede

Cache có thể giảm latency và tải database rất mạnh, nhưng cũng có thể trả dữ liệu cũ, che lỗi và tạo một đợt truy vấn đồng thời khi key hết hạn. Thiết kế cache production cần xác định nguồn dữ liệu chuẩn, mức độ stale chấp nhận được, cách invalidation và hành vi của ứng dụng khi Redis không khả dụng.

Khi nào nên thêm cache?

Chỉ thêm cache sau khi đã đo bottleneck. Cache phù hợp với dữ liệu được đọc lặp lại, chi phí tính toán cao, ít thay đổi hơn tần suất đọc và có thể chấp nhận stale trong một khoảng xác định. Ví dụ: danh mục sản phẩm, cấu hình công khai, profile, kết quả truy vấn tổng hợp hoặc response từ dịch vụ chậm.

Không nên cache để che truy vấn sai, thiếu index hoặc API thiết kế kém. Dữ liệu như số dư, trạng thái thanh toán hay quyền truy cập cần đánh giá rất thận trọng; một cache hit nhanh nhưng sai vẫn là lỗi.

Cache-aside: mô hình dễ kiểm soát

Trong cache-aside, ứng dụng tự quản lý cache:

  1. Đọc Redis theo cache key.
  2. Nếu hit, deserialize và trả dữ liệu.
  3. Nếu miss, đọc nguồn chuẩn như database.
  4. Ghi kết quả vào Redis với TTL.
  5. Khi dữ liệu thay đổi, ghi database rồi xóa cache key.
async function getProduct(id) {
  const key = `shop:v3:product:${id}`;
  const cached = await redis.get(key);

  if (cached !== null) {
    return JSON.parse(cached);
  }

  const product = await database.products.findById(id);
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}

Ưu điểm là cache chỉ chứa dữ liệu thực sự được truy cập và database luôn là nguồn chuẩn. Nhược điểm là request miss chậm hơn, logic cache nằm trong ứng dụng và cần xử lý đồng thời.

Thiết kế key có namespace và version

Cache key nên mô tả ứng dụng, phiên bản schema, loại dữ liệu và định danh:

shop:v3:product:8421
shop:v3:category:18:page:2:sort:newest
billing:v1:customer-summary:991

Version trong key giúp triển khai schema cache mới mà không phải quét và sửa mọi entry cũ. Tránh nhúng dữ liệu nhạy cảm hoặc chuỗi người dùng chưa chuẩn hóa vào key. Với truy vấn có nhiều tham số, canonicalize và hash phần tham số để tránh hai key cho cùng một truy vấn.

TTL là giới hạn stale, không phải con số tùy ý

TTL nên xuất phát từ nghiệp vụ: dữ liệu được phép cũ bao lâu và database chịu được bao nhiêu miss. Giá sản phẩm có thể là vài phút; feature flag quan trọng có thể chỉ vài giây; nội dung tĩnh có thể lâu hơn.

Không đặt tất cả key hết hạn cùng một thời điểm. Thêm jitter ngẫu nhiên giúp phân tán expiration:

const baseTtl = 300;
const jitter = Math.floor(Math.random() * 60);
await redis.set(key, payload, { EX: baseTtl + jitter });

Mọi entry cache-aside nên có TTL, kể cả khi ứng dụng có invalidation. TTL là lớp an toàn khi event hoặc thao tác xóa key bị bỏ lỡ.

Invalidation: ghi database trước, xóa cache sau

await database.transaction(async (tx) => {
  await tx.products.update(productId, changes);
});

await redis.del(`shop:v3:product:${productId}`);

Xóa key thường an toàn hơn ghi giá trị mới vào cache vì lần đọc tiếp theo sẽ tải lại từ nguồn chuẩn. Nếu xóa cache trước rồi database update thất bại hoặc bị chậm, request khác có thể đọc dữ liệu cũ từ database và đưa nó trở lại cache.

Vẫn tồn tại cửa sổ lỗi giữa commit database và DEL. Với dữ liệu quan trọng, dùng transactional outbox hoặc change data capture để phát event invalidation có thể retry. TTL giới hạn thời gian stale nếu event cuối cùng vẫn thất bại.

Tránh race condition khi đọc và ghi

Một request miss đọc dữ liệu cũ, sau đó request khác update database và xóa key; request đầu tiên có thể ghi dữ liệu cũ trở lại cache. Cách giảm rủi ro:

  • Dùng TTL ngắn hơn cho dữ liệu thay đổi thường xuyên.
  • Kèm version hoặc updated_at trong value và chỉ ghi nếu version còn hợp lệ.
  • Thực hiện delayed double delete cho một số luồng có race đã được đo.
  • Dùng event invalidation sau commit và thiết kế consumer idempotent.
  • Không cache dữ liệu yêu cầu read-after-write nghiêm ngặt trên luồng đó.

Chống cache stampede

Khi một key nóng hết hạn, hàng trăm request có thể cùng miss và đánh vào database. Dùng single-flight hoặc mutex theo key để chỉ một request load dữ liệu:

const lockKey = `lock:${key}`;
const token = crypto.randomUUID();
const acquired = await redis.set(lockKey, token, { NX: true, PX: 5000 });

if (acquired) {
  try {
    const value = await loadFromDatabase();
    await redis.set(key, JSON.stringify(value), { EX: 300 });
    return value;
  } finally {
    await releaseOnlyIfTokenMatches(lockKey, token);
  }
}

return await waitBrieflyAndReadCacheAgain();

Lock phải có TTL dài hơn thời gian load xấu nhất, dùng token duy nhất và chỉ owner mới được release. Request không lấy được lock nên đợi ngắn, đọc lại cache hoặc dùng stale value; không quay vòng bận vô hạn.

Stale-while-revalidate cho dữ liệu đọc nhiều

Có thể lưu hai mốc: soft TTL và hard TTL. Sau soft TTL, ứng dụng vẫn trả dữ liệu cũ trong khi một worker refresh nền; sau hard TTL mới bắt buộc chờ nguồn chuẩn. Mô hình này giảm latency và stampede, nhưng chỉ phù hợp khi nghiệp vụ chấp nhận stale có giới hạn.

Không dùng stale-while-revalidate cho quyền, hạn mức hoặc dữ liệu mà giá trị cũ có thể gây giao dịch sai.

Negative caching và cache penetration

Request lặp cho ID không tồn tại vẫn có thể xuyên qua cache và làm database quá tải. Cache sentinel “not found” với TTL ngắn:

if (cached === '__NOT_FOUND__') return null;

const value = await loadFromDatabase(id);
if (value === null) {
  await redis.set(key, '__NOT_FOUND__', { EX: 30 });
  return null;
}

TTL negative phải ngắn hơn positive để bản ghi vừa tạo sớm xuất hiện. Luôn validate định dạng ID, rate-limit client và không để attacker tạo vô hạn key ngẫu nhiên.

Giới hạn bộ nhớ và eviction policy

Redis cần maxmemory và policy phù hợp. Với instance chỉ dành cho cache, allkeys-lru hoặc allkeys-lfu thường là điểm bắt đầu hợp lý tùy access pattern. noeviction sẽ trả lỗi cho write mới khi chạm giới hạn.

Không nên dùng cùng một Redis instance cho cache có thể bị eviction và dữ liệu cần bền như queue hoặc session quan trọng nếu có thể tách. Policy volatile-* chỉ loại key có TTL và có thể hành xử như noeviction nếu các key khác không có expiry.

Ứng dụng phải làm gì khi Redis lỗi?

Cache thường là lớp tối ưu, không phải nguồn chuẩn. Khi Redis timeout:

  • Đặt connect/read timeout ngắn, không để request treo lâu hơn database.
  • Circuit-break cache sau chuỗi lỗi để tránh retry storm.
  • Fallback database có rate limit và concurrency cap để không làm sập nguồn chuẩn.
  • Không fail request chỉ vì thao tác ghi cache thất bại, nếu nghiệp vụ cho phép.
  • Không swallow lỗi hoàn toàn; ghi metrics và cảnh báo theo tỷ lệ.

Nếu Redis lưu session, lock hoặc rate-limit state, nó không còn là “cache thuần túy”; chính sách availability và failure phải được thiết kế riêng.

Serialization, nén và payload

Cache object nhỏ, ổn định và chỉ chứa trường cần dùng. Payload lớn tăng network, CPU serialize và memory fragmentation. Gắn schema version, xử lý deserialize failure như cache miss và xóa entry hỏng.

Chỉ nén khi số liệu chứng minh tiết kiệm băng thông/bộ nhớ lớn hơn chi phí CPU. Không cache object chứa secret hoặc dữ liệu cá nhân nếu chưa đánh giá mã hóa, retention và quyền truy cập Redis.

Những chỉ số cần theo dõi

  • Hit rate và miss rate theo nhóm key.
  • P50, p95, p99 latency của Redis và loader database.
  • Evicted keys, expired keys, memory used và fragmentation.
  • Connection count, rejected connections và timeout.
  • Số lock contention, thời gian chờ và loader concurrency.
  • Tỷ lệ fallback database khi Redis lỗi.
  • Độ trễ invalidation và số event retry/dead-letter.

Hit rate cao không tự động tốt. Cache có thể hit dữ liệu sai hoặc giữ object ít giá trị. Đo thêm database load, end-to-end latency và độ mới dữ liệu.

Kiểm thử production behavior

  1. Cache hit, miss, expiry và invalidation sau update.
  2. Nhiều request đồng thời vào một cold key chỉ tạo một database load.
  3. Redis timeout, mất kết nối và đạt maxmemory.
  4. Database chậm trong lúc lock gần hết TTL.
  5. Event invalidation đến trùng hoặc sai thứ tự.
  6. Negative cache không che bản ghi mới quá lâu.
  7. Deploy schema value mới vẫn đọc an toàn hoặc miss có kiểm soát.

Checklist trước khi phát hành

  1. Đã đo bottleneck và xác định cache thực sự cần thiết.
  2. Database hoặc service gốc vẫn là nguồn dữ liệu chuẩn.
  3. Key có namespace, version và không chứa secret.
  4. Mọi entry có TTL dựa trên mức stale chấp nhận được.
  5. Update ghi nguồn chuẩn trước rồi invalidation sau commit.
  6. Hot key có jitter và stampede protection.
  7. Redis có maxmemory, eviction policy và giám sát.
  8. Fallback được giới hạn để bảo vệ database.
  9. Cache failure không làm sai nghiệp vụ.

Kết luận

Cache Redis an toàn không chỉ là thêm GET trước query. Thiết kế tốt coi cache là dữ liệu có thể mất, có thể stale và có thể không truy cập được. Cache-aside, TTL có chủ đích, invalidation sau commit, single-flight và giới hạn bộ nhớ giúp tăng tốc backend mà không biến cache thành nguồn lỗi khó kiểm soát.

Nguồn tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.