Nền tảng Caching trong Hệ thống Backend
Trước hết: Đây là bài viết mình tổng hợp bằng AI trong quá trình tìm hiểu nhằm mục đích ôn tập nên sẽ có nhiều chỗ sai sót và chưa đúng nghiệp vụ chuẩn. Bài viết chỉ mang tính chất tham khảo, luôn sẵn sàng nhận được sự góp ý của mọi người.
Phân tích nền tảng về Caching
Caching (bộ nhớ đệm) là kỹ thuật nền tảng trong thiết kế hệ thống, đóng vai trò là "vùng đệm" hiệu suất cao giữa ứng dụng và nguồn dữ liệu (thường là database). Việc áp dụng caching đúng cách là yếu tố then chốt quyết định khả năng chịu tải (scalability) và độ trễ (latency) của toàn bộ hệ thống.
Bài viết này sẽ đi sâu vào các khía cạnh lý thuyết và ứng dụng thực tiễn của caching, đặc biệt tập trung vào các chiến lược vận hành trong môi trường production.
1. Lý thuyết Cốt lõi: "Tại sao" và "Khi nào"
Mục tiêu cơ bản của cache là giảm tải cho các tài nguyên "chậm" và "đắt đỏ" (như DB, external API) bằng cách phục vụ các yêu cầu lặp lại từ một lớp lưu trữ "nhanh" và "rẻ" (như RAM).
1.1. Phân cấp Bộ nhớ (Memory Hierarchy)
Lý do cache hiệu quả nằm ở sự khác biệt vật lý về tốc độ:
- CPU L1/L2 Cache: ~nano giây
- RAM (Application Cache): ~micro giây
- SSD (Distributed Cache - qua mạng): ~mili giây
- Database (Đọc từ Disk): Hàng chục đến hàng trăm mili giây
Một truy vấn cache (RAM) nhanh hơn hàng nghìn lần so với một truy vấn DB (Disk + Network).
1.2. Khi nào nên dùng Cache?
Cache là một sự đánh đổi: chúng ta đánh đổi tính nhất quán (consistency) để lấy hiệu suất (performance).
- Nên dùng (Dữ liệu "Nóng"):
- Đọc nhiều, ghi ít: Dữ liệu cấu hình, thông tin sản phẩm, bài viết.
- Kết quả tính toán phức tạp: Báo cáo, trang dashboard.
- External API call chậm: Dữ liệu tỷ giá, thời tiết.
- Không nên dùng:
- Ghi nhiều, đọc ít: Hệ thống logging, analytics sự kiện.
- Dữ liệu nhạy cảm, phải mới 100%: Số dư tài khoản ngân hàng, thông tin OTP.
2. Kiến trúc Cache: "Ở đâu"
Cache không phải là một thực thể duy nhất mà là một hệ thống phân tầng.
2.1. L1 - Application Cache (Local Cache)
- Là gì: Cache nằm ngay trong bộ nhớ RAM của tiến trình (process) ứng dụng.
- Công nghệ:
ConcurrentHashMap, Google Guava, Caffeine. - Ưu điểm: Tốc độ nhanh nhất (không tốn chi phí network).
- Nhược điểm:
- Không chia sẻ được giữa các server (node) khác nhau.
- Gây ra vấn đề không nhất quán (Node A cập nhật data, Node B không biết và vẫn giữ cache cũ).
- Bị giới hạn bởi dung lượng RAM của server.
2.2. L2 - Distributed Cache (Remote Cache)
- Là gì: Một hệ thống cache riêng biệt (thường là một cụm server), được chia sẻ bởi tất cả các node ứng dụng.
- Công nghệ: Redis, Memcached.
- Ưu điểm:
- Chia sẻ chung: Tất cả các node đều thấy cùng một dữ liệu.
- Dung lượng lưu trữ lớn, có thể mở rộng độc lập.
- Nhược điểm: Chậm hơn L1 (phải đi qua mạng).
2.3. Áp dụng: Multi-Level Caching (L1 + L2)
Đây là kiến trúc tiêu chuẩn trong các hệ thống hiệu suất cao, kết hợp điểm mạnh của cả hai.
Luồng truy vấn (Flow): Request -> L1 (Caffeine) -> L2 (Redis) -> DB
- Ứng dụng kiểm tra L1 (Caffeine).
- L1 Hit? -> Trả về (Nhanh nhất).
- L1 Miss? -> Ứng dụng kiểm tra L2 (Redis).
- L2 Hit?
- Ghi lại vào L1 (để lần sau L1 hit).
- Trả về.
- L2 Miss?
- Truy vấn Database.
- Ghi vào L2 (Redis).
- Ghi vào L1 (Caffeine).
- Trả về.
Vấn đề: Làm sao để L1 ở Node B biết khi Node A cập nhật dữ liệu? -> Cần một cơ chế Cache Invalidation (ví dụ: dùng Redis Pub/Sub để "thông báo" cho các node khác xóa L1 cache tương ứng).
3. Cơ chế Vận hành: "Như thế nào"
Việc cache hoạt động ra sao quyết định tính nhất quán và hiệu suất của hệ thống.
3.1. Cache-Aside (Lazy Loading)
- Lý thuyết: Đây là cơ chế phổ biến nhất. Ứng dụng "lười biếng" chỉ tải dữ liệu vào cache khi cần.
- Luồng (Read):
- Ứng dụng tìm trong Cache.
- Cache Hit -> Trả về.
- Cache Miss -> Ứng dụng tìm trong DB.
- Ứng dụng ghi kết quả DB vào Cache.
- Trả về.
- Áp dụng: Hầu hết các kịch bản đọc dữ liệu (ví dụ:
GET /product/{id}). Spring@Cacheablechính là triển khai của cơ chế này. - Nhược điểm: Request đầu tiên (cold start) luôn chậm. Dữ liệu có thể bị lỗi thời (stale) nếu DB bị cập nhật mà cache không được xóa.
3.2. Write-Through (Ghi xuyên qua)
- Lý thuyết: Ứng dụng luôn ghi vào cache trước. Sau đó, cache sẽ đồng bộ (synchronously) ghi tiếp xuống DB. Request chỉ hoàn tất khi cả hai đều ghi xong.
- Áp dụng: Các hệ thống yêu cầu tính nhất quán dữ liệu cực cao. Ví dụ: hệ thống quản lý số lượng tồn kho (inventory), giao dịch tài chính.
- Nhược điểm: Thao tác ghi bị chậm (phải đợi cả 2 hệ thống).
3.3. Write-Behind (Ghi phía sau)
- Lý thuyết: Ứng dụng ghi vào cache và trả về thành công ngay lập tức. Một luồng (thread) hoặc hàng đợi (queue) khác sẽ bất đồng bộ (asynchronously) ghi dữ liệu từ cache xuống DB sau đó.
- Áp dụng: Các hệ thống cần hiệu năng ghi (write throughput) cực cao, chấp nhận một rủi ro mất dữ liệu nhỏ. Ví dụ: hệ thống đếm lượt xem (view count), ghi log, analytics.
- Nhược điểm: Rủi ro mất dữ liệu nếu cache sập trước khi kịp ghi xuống DB.
3.4. Áp dụng: Triển khai với Spring Cache Abstraction
Spring Cache là một lớp trừu tượng (Abstraction). Nó cho phép bạn viết logic nghiệp vụ mà không cần phụ thuộc vào một công nghệ cache cụ thể (Caffeine hay Redis).
@Cacheable: Triển khai Cache-Aside. Dùng cho các hàmGET.- Logic: "Kiểm tra cache. Nếu có, trả về. Nếu không, chạy hàm, rồi lưu kết quả vào cache."
@CachePut: Triển khai cập nhật. Dùng cho các hàmUPDATE(PUT/PATCH).- Logic: "Luôn chạy hàm. Sau đó, buộc cập nhật cache bằng kết quả trả về của hàm."
@CacheEvict: Triển khai vô hiệu hóa. Dùng cho các hàmDELETE.- Logic: "Chạy hàm. Sau đó, xóa key khỏi cache."
4. Chiến lược Vận hành: Các "Thảm họa" Cache Thực tế
Đây là phần quan trọng nhất, phân biệt một hệ thống cache nghiệp dư và một hệ thống cấp độ production.
4.1. Cache Penetration (Thủng Cache)
- Vấn đề: Kẻ tấn công liên tục truy vấn các key không tồn tại.
- Hậu quả: Vì key không tồn tại -> Luôn Cache Miss. Toàn bộ các truy vấn "rác" này vượt qua (penetrate) lớp cache và đánh thẳng vào Database, gây sập DB.
- Giải pháp 1 (Đơn giản): Cache Giá trị NULL
- Lý thuyết: Khi DB trả về "không tìm thấy" (null), chúng ta vẫn cache giá trị "null" này.
- Áp dụng:
cache.set("user:abcxyz", "NULL", 5_MINUTES). Request tấn công tiếp theo sẽ "hit" vào giá trị "NULL" này và bị chặn lại. - Nhược điểm: Tốn bộ nhớ cache để lưu key rác.
- Giải pháp 2 (Nâng cao): Bloom Filter
- Lý thuyết: Bloom Filter là một cấu trúc dữ liệu xác suất, siêu tiết kiệm bộ nhớ, dùng để kiểm tra xem một phần tử "có thể có" trong một tập hợp hay không.
- Nó không bao giờ nói "Sai" (False Negative): Nếu Bloom Filter nói "key này không tồn tại", nó chắc chắn 100% không tồn tại.
- Nó có thể nói "Sai" (False Positive): Nếu Bloom Filter nói "key này có thể tồn tại", nó chỉ là có thể (có một xác suất nhỏ là nó nói dối).
- Áp dụng:
- Khi hệ thống khởi động, tải tất cả các
product_idhợp lệ từ DB vào Bloom Filter. - Luồng truy vấn mới:
Request -> Bloom Filter Check -> Cache Check -> DB Check. - Nếu Bloom Filter nói "key không tồn tại", ứng dụng trả về 404 ngay lập tức, bảo vệ hoàn toàn cho cả cache và DB.
- Khi hệ thống khởi động, tải tất cả các
- Lý thuyết: Bloom Filter là một cấu trúc dữ liệu xác suất, siêu tiết kiệm bộ nhớ, dùng để kiểm tra xem một phần tử "có thể có" trong một tập hợp hay không.
4.2. Cache Breakdown (Sập Cache)
- Vấn đề: Một "hot key" duy nhất (ví dụ: sản phẩm Flash Sale) bị hết hạn.
- Hậu quả: Hàng ngàn request cùng đến một lúc, cùng bị Cache Miss cho một key duy nhất này. Tất cả cùng lao xuống DB để tính toán lại. Hiện tượng này gọi là Thundering Herd (Bầy đàn).
- Giải pháp (Lý thuyết): Phải đảm bảo chỉ một request/thread được phép đi lấy dữ liệu từ DB. Các request khác phải chờ.
- Giải pháp (Áp dụng): Distributed Lock (Khóa phân tán)
- Công cụ: Sử dụng Redis với lệnh
SETNX(SET if Not eXists). - Luồng (Flow) kinh điển:
- Cache Miss
hot_key. - Tất cả các thread cùng cố gắng chiếm khóa:
SET lock:hot_key "server_id" NX EX 10NX: Chỉ SET thành công nếu key (cái khóa) chưa tồn tại.EX 10: Khóa tự hết hạn sau 10s (để chống deadlock nếu server giữ khóa bị crash).
- Chỉ một thread "thắng" (lệnh
SETNXtrả về OK). - Thread "thắng": Đi xuống DB, lấy dữ liệu, ghi lại vào cache, và cuối cùng là xóa khóa (
DEL lock:hot_key). - Các thread "thua": Chờ (ví dụ:
sleep 50ms) và thử lại từ Bước 1 (lần này sẽ là Cache Hit vì thread "thắng" đã ghi vào cache).
- Cache Miss
- Công cụ: Sử dụng Redis với lệnh
4.3. Cache Avalanche (Lũ Cache)
- Vấn đề: Hàng loạt key (hàng ngàn, hàng triệu) bị hết hạn cùng một lúc.
- Nguyên nhân 1: Đặt TTL cố định. Ví dụ: Chạy job lúc 00:00 AM để "làm nóng" 1 triệu sản phẩm, và đặt
TTL = 4 giờcho TẤT CẢ. Đúng 04:00 AM, 1 triệu key cùng hết hạn. - Nguyên nhân 2: Cụm server cache (Redis) bị sập.
- Hậu quả: Toàn bộ traffic của hệ thống bị Cache Miss và đổ "lũ" (avalanche) xuống DB, gây sập DB.
- Giải pháp 1 (Cho Nguyên nhân 1): Random TTL (Jitter)
- Lý thuyết: "Rải đều" thời điểm hết hạn ra.
- Áp dụng: Thay vì đặt
TTL = 3600s, hãy đặtTTL = 3600 + random(0, 300). Tức là TTL sẽ ngẫu nhiên từ 1 giờ đến 1 giờ 5 phút. Điều này ngăn chặn chúng hết hạn cùng một khoảnh khắc.
- Giải pháp 2 (Cho Nguyên nhân 2): Phân cụm (HA)
- Áp dụng: Sử dụng Redis Sentinel (Master-Slave, tự động failover) hoặc Redis Cluster (Sharding + HA). Nếu một node cache sập, node khác sẽ thay thế, hệ thống không bị mất toàn bộ cache.
- Giải pháp 3 (Lớp bảo vệ cuối): Circuit Breaker (Bộ ngắt mạch)
- Lý thuyết: Khi DB có dấu hiệu quá tải (ví dụ: tỷ lệ lỗi > 50% hoặc thời gian phản hồi > 2 giây), ứng dụng sẽ tự động "ngắt mạch".
- Áp dụng: Ngừng gửi request xuống DB trong 30 giây. Trả về lỗi nhanh (fail-fast) hoặc dữ liệu dự phòng (fallback data). Điều này cho phép DB có thời gian "hồi phục" thay vì bị "đánh bồi" cho đến chết.
All rights reserved