Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn vận hành một sàn thương mại điện tử vào ngày "Sale 12.12". Lượng truy cập tăng gấp 20 lần so với ngày thường. Nếu mỗi lần người dùng mở trang chủ mà hệ thống đều phải hỏi database "giá sản phẩm này bao nhiêu?", "danh mục này có gì?", "banner nào đang chạy?" — thì chỉ trong vài phút, database của bạn sẽ quá tải, thời gian phản hồi tăng vọt, và cả sàn "sập" ngay giữa cao điểm. Đây chính xác là kịch bản mà nhiều đội kỹ thuật ở Việt Nam đã trải qua.
Cache (bộ nhớ đệm) là một trong những vũ khí mạnh nhất — và cũng dễ gây rắc rối nhất — trong kho công cụ vận hành. Làm đúng, cache giúp bạn giảm 90% tải database, cắt latency từ 300ms xuống 5ms, và tiết kiệm hàng chục nghìn đô chi phí hạ tầng mỗi tháng. Làm sai, cache lại phục vụ dữ liệu cũ (stale), gây ra những bug "ma" khó tái hiện, hoặc tệ hơn là làm sập cả hệ thống khi cache "chết" đồng loạt.
Bài học này tập trung vào hai trụ cột của chiến lược cache: các tầng cache (layers) — dữ liệu được lưu đệm ở đâu trên đường đi từ database tới người dùng, và invalidation — nghệ thuật quyết định khi nào dữ liệu đệm không còn hợp lệ và phải làm mới. Có một câu nói nổi tiếng trong ngành: "Chỉ có hai vấn đề khó trong khoa học máy tính: cache invalidation và đặt tên biến." Bài này sẽ giúp bạn xử lý cái đầu tiên.
Khái niệm cốt lõi
Cache là gì và vì sao nó hiệu quả
Cache là một bản sao dữ liệu được lưu ở nơi truy cập nhanh và/hoặc gần người dùng hơn so với nguồn gốc (source of truth, thường là database). Nguyên lý cốt lõi khiến cache hoạt động là locality — trong thực tế, một số ít dữ liệu được truy cập rất nhiều lần (nguyên tắc 80/20). Ví dụ: 100 sản phẩm hot chiếm 80% lượt xem, trang chủ được mở hàng triệu lần nhưng nội dung gần như giống nhau với mọi người.
Hai chỉ số quan trọng nhất khi đánh giá cache:
- Cache hit rate: tỷ lệ yêu cầu được phục vụ từ cache (không phải đi tới nguồn gốc). Hit rate 95% nghĩa là 95 trên 100 request được trả lời ngay từ cache. Đây là chỉ số bạn phải theo dõi hàng ngày.
- TTL (Time To Live): thời gian một mục dữ liệu được coi là "còn tươi" trước khi bị coi là hết hạn. TTL 60 giây nghĩa là sau 60 giây, mục đó phải được làm mới từ nguồn.
Các tầng cache (Cache Layers)
Dữ liệu đi từ database tới màn hình người dùng phải qua nhiều chặng, và mỗi chặng đều có thể đặt một tầng cache. Đi từ gần người dùng nhất ra ngoài:
1. Browser cache (cache tại trình duyệt). Đây là tầng gần người dùng nhất và "rẻ" nhất — dữ liệu nằm ngay trên máy của họ, không tốn một byte băng thông nào của bạn. Được điều khiển bởi các HTTP header như Cache-Control, Expires, ETag, Last-Modified. Ví dụ, với file tĩnh như logo, CSS, JS đã có hash trong tên (app.a3f2.js), bạn nên đặt Cache-Control: public, max-age=31536000, immutable — tức lưu một năm, vì nội dung không bao giờ đổi (tên file đổi khi nội dung đổi). Với dữ liệu động như trang cá nhân, thường dùng Cache-Control: private, no-cache hoặc dựa vào ETag để trình duyệt hỏi lại "cái này có đổi không?" và server trả 304 Not Modified nếu chưa đổi.
2. CDN / Edge cache. CDN (Content Delivery Network) như Cloudflare, AWS CloudFront, Akamai, Fastly đặt các máy chủ đệm ở hàng trăm điểm trên thế giới (gọi là edge/PoP). Người dùng ở TP.HCM sẽ được phục vụ từ edge tại Singapore hoặc ngay Việt Nam thay vì phải chạy tới server gốc ở Mỹ. CDN không chỉ cache file tĩnh mà ngày nay còn cache được cả HTML, API response, thậm chí chạy logic tại edge. Tầng này gánh phần lớn traffic đọc và bảo vệ hạ tầng gốc của bạn.
3. Application cache (cache trong tầng ứng dụng). Đây là nơi diễn ra nhiều quyết định thú vị nhất, chia làm hai loại:
- In-memory cache cục bộ: lưu ngay trong bộ nhớ tiến trình ứng dụng (ví dụ Caffeine trong Java, một
Mapvới chính sách LRU, hoặc Guava Cache). Nhanh nhất vì không qua mạng (đơn vị nano/micro giây), nhưng mỗi instance có bản sao riêng — khó đồng bộ khi bạn chạy 20 pod. - Distributed cache (cache phân tán): một cụm riêng như Redis hoặc Memcached mà tất cả instance ứng dụng cùng dùng chung. Chậm hơn cache cục bộ một chút (qua mạng, cỡ 1ms) nhưng nhất quán giữa các instance và dung lượng lớn. Đây là "ngựa thồ" của hầu hết hệ thống hiện đại.
Một nguyên tắc vàng: request lý tưởng nên được chặn lại càng sớm càng tốt trên đường đi. Phục vụ từ browser cache rẻ hơn CDN, CDN rẻ hơn Redis, Redis rẻ hơn database.
Các mẫu ghi/đọc cache (Caching Patterns)
Cách ứng dụng tương tác với cache và database quyết định độ phức tạp của invalidation:
- Cache-Aside (Lazy Loading): phổ biến nhất. Ứng dụng đọc cache trước; nếu miss thì đọc database, rồi ghi kết quả vào cache. Khi ghi dữ liệu, ứng dụng cập nhật database rồi xóa mục cache (không cập nhật cache trực tiếp). Đơn giản, chịu lỗi tốt, nhưng lần đọc đầu sau khi xóa sẽ chậm.
- Read-Through / Write-Through: cache tự lo việc đọc/ghi database, ứng dụng chỉ nói chuyện với cache. Write-Through ghi đồng thời cache và DB nên dữ liệu luôn tươi nhưng ghi chậm hơn.
- Write-Behind (Write-Back): ghi vào cache trước, sau đó đẩy xuống DB theo lô một cách bất đồng bộ. Rất nhanh khi ghi nhưng có rủi ro mất dữ liệu nếu cache chết trước khi kịp ghi xuống.
Cache Invalidation — trái tim của vấn đề
Invalidation là việc quyết định khi nào một mục cache không còn hợp lệ. Có ba chiến lược chính, và thực tế bạn sẽ kết hợp cả ba:
- TTL-based (hết hạn theo thời gian): đơn giản nhất — đặt thời gian sống, hết giờ thì tự bay. Chấp nhận dữ liệu cũ trong khoảng TTL. Phù hợp khi "hơi cũ một chút" là chấp nhận được (ví dụ số lượt xem bài viết).
- Event-based / Explicit (chủ động xóa): khi dữ liệu thay đổi, code chủ động xóa hoặc cập nhật mục cache liên quan. Chính xác nhất nhưng dễ sót — bạn phải nhớ mọi nơi có thể thay đổi dữ liệu.
- Key-based versioning: nhúng phiên bản vào key, ví dụ
product:123:v5. Khi dữ liệu đổi, tăng version thànhv6; key cũ tự nhiên không ai dùng và hết hạn dần.
Tình huống thực tế
Tình huống 1 — Sàn TMĐT Việt Nam và thảm họa "cache stampede" đêm Sale
Một sàn thương mại điện tử tại Việt Nam (giả định, quy mô tương tự các sàn lớn trong nước) đặt TTL cho cache trang chi tiết sản phẩm là 5 phút trên Redis. Bình thường mọi thứ chạy êm với hit rate 97%. Nhưng vào 0h đêm 12.12, khi họ vừa "warm up" toàn bộ cache sản phẩm hot cùng một lúc, tất cả các key này đều hết hạn cùng một thời điểm 5 phút sau đó.
Kết quả: hàng nghìn request cho cùng một sản phẩm hot đồng loạt miss cache, cùng lao xuống database để "xây lại" cache. Database nhận 50.000 query trùng lặp trong 2 giây, CPU lên 100%, thời gian phản hồi tăng từ 20ms lên 8 giây, và checkout bắt đầu timeout. Hiện tượng này gọi là cache stampede (hay thundering herd — đàn trâu húc cửa).
Bài học rút ra: đừng để nhiều key hết hạn cùng lúc. Giải pháp gồm (1) thêm jitter — cộng ngẫu nhiên vào TTL, ví dụ 300 giây + random(0..60) để các key hết hạn rải rác; (2) dùng lock/single-flight — chỉ cho phép một request đi xuống DB xây lại cache, các request khác chờ hoặc nhận giá trị cũ; (3) refresh-ahead — làm mới cache trước khi nó hết hạn với các key hot. Sau khi áp dụng jitter và single-flight, sự cố không lặp lại vào các đợt Sale sau.
Tình huống 2 — Grab và bài toán giá động qua nhiều tầng cache
Một nền tảng gọi xe kiểu Grab tại Đông Nam Á hiển thị giá chuyến đi. Giá này thay đổi liên tục theo cung-cầu (surge pricing). Đội kỹ thuật ban đầu vô tình đặt CDN cache cho endpoint báo giá với Cache-Control: public, max-age=120. Hậu quả: người dùng ở cùng khu vực thấy cùng một mức giá trong 2 phút dù thực tế giá đã tăng do trời mưa và nhu cầu tăng vọt — dẫn tới tài xế không nhận đủ tiền, và có lúc hệ thống nhận chuyến ở giá cũ thấp hơn.
Vấn đề là dữ liệu giá mang tính cá nhân hóa và biến động cao, không được phép nằm ở tầng CDN dùng chung. Họ sửa lại: endpoint giá dùng Cache-Control: private, no-store (không cache ở CDN), nhưng tận dụng in-memory cache rất ngắn (2 giây) tại tầng application để chống việc cùng một user bấm nhiều lần dội xuống service tính giá.
Bài học rút ra: chọn tầng cache phải khớp với đặc tính dữ liệu. Dữ liệu tĩnh, dùng chung → CDN. Dữ liệu cá nhân hóa, biến động → cache ngắn ở tầng app hoặc không cache. Nhầm tầng là nguyên nhân số một của bug "giá sai", "thấy đơn của người khác".
Tình huống 3 — Blog tin tức và "ETag cứu băng thông"
Một trang tin tức Việt Nam phục vụ ảnh và trang chủ cho hàng triệu lượt truy cập mỗi ngày. Trang chủ cập nhật mỗi khi có tin mới (trung bình 3–5 phút một lần). Đội vận hành đặt trước CDN (Cloudflare) với chiến lược: HTML trang chủ có Cache-Control: public, max-age=60, stale-while-revalidate=30. Khi biên tập viên đăng tin nóng cần lên ngay, hệ thống CMS gọi API purge của Cloudflare để xóa cache đúng URL trang chủ thay vì chờ 60 giây.
Với ảnh, họ dùng ETag: trình duyệt gửi lại If-None-Match với ETag đã lưu, nếu ảnh chưa đổi server trả 304 Not Modified chỉ vài chục byte thay vì gửi lại file 500KB. Nhờ đó băng thông gốc giảm hơn 85%, và chi phí egress hàng tháng giảm đáng kể.
Bài học rút ra: kết hợp TTL ngắn (max-age) cho độ tươi cơ bản, stale-while-revalidate để phục vụ nhanh trong lúc làm mới ngầm, và purge chủ động cho các sự kiện quan trọng. Đây là mô hình lai (TTL + event-based) rất thực dụng.
Hướng dẫn từng bước
Đây là quy trình thiết kế chiến lược cache cho một endpoint mới:
- Xác định đặc tính dữ liệu. Trả lời: dữ liệu này đọc nhiều hay ghi nhiều? Dùng chung hay cá nhân hóa? Chấp nhận cũ được bao lâu (staleness tolerance)? Đây là bước quan trọng nhất — mọi quyết định sau đều phụ thuộc câu trả lời.
- Chọn tầng cache phù hợp. Tĩnh + dùng chung → đẩy lên CDN và browser cache. Động + dùng chung → Redis/CDN với TTL. Cá nhân hóa → cache ngắn ở app hoặc
private. Không bao giờ cache dữ liệu nhạy cảm (token, thông tin thanh toán) ở tầng dùng chung.
- Chọn caching pattern. Đa số trường hợp bắt đầu bằng Cache-Aside vì đơn giản và chịu lỗi tốt. Chỉ cân nhắc Write-Through/Write-Behind khi có nhu cầu đặc thù.
- Thiết kế cache key rõ ràng. Đặt key có cấu trúc và namespace:
product:detail:123:v2,user:456:cart. Ghi rõ quy ước để cả đội hiểu và tránh trùng.
- Đặt TTL + jitter. Chọn TTL theo mức chấp nhận cũ, luôn thêm jitter ngẫu nhiên để tránh stampede. Với key hot, cân nhắc refresh-ahead.
- Thiết kế invalidation. Xác định mọi điểm ghi có thể làm dữ liệu này đổi, và đảm bảo mỗi điểm đó xóa/cập nhật key tương ứng. Với dữ liệu quan trọng, thêm cơ chế purge chủ động.
- Đặt cơ chế bảo vệ khi cache miss. Thêm single-flight/lock để chống stampede, và xử lý cache penetration (query cho dữ liệu không tồn tại) bằng cách cache cả kết quả rỗng với TTL ngắn.
- Đo lường. Dựng dashboard theo dõi hit rate, latency p99, số lần đi xuống DB, và bộ nhớ Redis. Không đo thì không biết cache có hiệu quả hay đang âm thầm phục vụ dữ liệu cũ.
Lỗi thường gặp & mẹo
- Quên invalidation ở một nhánh code. Bạn xóa cache khi update qua API chính, nhưng quên nhánh admin hoặc job batch cũng sửa dữ liệu đó. Mẹo: tập trung logic ghi + invalidation vào một chỗ (repository/service layer), đừng rải rác.
- TTL đồng loạt gây stampede. Như tình huống 1 — luôn thêm jitter. Đây là lỗi kinh điển của người mới.
- Cache penetration. Kẻ xấu (hoặc bug) liên tục hỏi ID không tồn tại, mọi request đều miss và dội xuống DB. Mẹo: cache cả giá trị null với TTL ngắn, hoặc dùng Bloom filter cho các key hợp lệ.
- Cache dữ liệu nhạy cảm ở CDN. Đặt nhầm
publiccho response chứa thông tin cá nhân → người dùng A thấy dữ liệu người dùng B. Đây là sự cố bảo mật nghiêm trọng. Luôn dùngprivate/no-storecho nội dung cá nhân hóa.
- Không có kế hoạch khi cache "chết". Nếu Redis sập, toàn bộ traffic dội xuống DB và làm sập DB luôn. Mẹo: dùng circuit breaker, có in-memory cache dự phòng ở tầng app, và đảm bảo DB có đủ headroom để chịu tải khi cache tạm mất.
- Cache quá nhiều thứ. Không phải mọi thứ đều đáng cache. Cache dữ liệu ghi-nhiều-đọc-ít chỉ tốn công invalidate mà chẳng lợi bao nhiêu. Ưu tiên đúng chỗ 80/20.
- Mẹo warm-up thông minh. Khi deploy hoặc mở Sale, hãy nạp trước (pre-warm) cache cho các key hot nhưng nạp rải rác với TTL có jitter, đừng nạp đồng loạt cùng TTL.
Bài tập thực hành
- Thiết kế cache cho trang chi tiết sản phẩm. Cho một sàn TMĐT, hãy viết ra: dữ liệu nào cache ở CDN, ở Redis, ở browser? TTL bao nhiêu cho mỗi tầng? Khi giá sản phẩm đổi, những key nào cần invalidate và ở tầng nào?
- Mô phỏng cache stampede. Viết một script bắn 1000 request đồng thời cho một key vừa hết hạn (dùng Redis + một endpoint giả). Quan sát số lần đi xuống "DB". Sau đó thêm cơ chế single-flight/lock và đo lại. So sánh số query DB trước và sau.
- Tính hit rate mục tiêu. Một API nhận 10.000 request/giây, mỗi query DB tốn 15ms và DB chịu tối đa 2.000 query/giây. Hỏi: hit rate tối thiểu cần đạt bao nhiêu để DB không quá tải? (Gợi ý: request tới DB = tổng request × (1 − hit rate) phải ≤ 2.000).
- Audit HTTP header. Mở DevTools trên một website bạn hay dùng, xem tab Network và ghi lại
Cache-Control,ETag,Agecủa vài tài nguyên. Phân biệt cái nào được phục vụ từ CDN (headercf-cache-status: HITchẳng hạn), cái nào từ browser cache.
Tóm tắt
Cache là đòn bẩy mạnh nhất để tăng tốc và giảm chi phí hệ thống, nhưng đi kèm cái giá là độ phức tạp của invalidation. Hãy nhớ những điểm cốt lõi:
- Dữ liệu đi qua nhiều tầng cache: browser → CDN/edge → application cache (in-memory cục bộ và distributed như Redis) → database cache. Chặn request càng sớm trên đường đi càng rẻ.
- Chọn tầng phải khớp đặc tính dữ liệu: tĩnh/dùng chung lên CDN; động/dùng chung vào Redis với TTL; cá nhân hóa thì cache ngắn ở app hoặc không cache; nhạy cảm thì không bao giờ cache ở tầng dùng chung.
- Invalidation kết hợp ba chiến lược: TTL (theo thời gian), event-based (chủ động xóa/purge), và versioning. Thực tế bạn dùng cả ba.
- Luôn phòng ngừa các cạm bẫy vận hành: stampede (thêm jitter + single-flight), penetration (cache null), cache chết (circuit breaker + headroom DB), và rò rỉ dữ liệu nhạy cảm.
- Cuối cùng: đo lường. Theo dõi hit rate, latency, tải DB mỗi ngày. Cache không đo được là cache đang âm thầm gây rắc rối.