Mở đầu — vì sao bài này quan trọng
Có một sự thật mà bất kỳ kiến trúc sư hệ thống nào cũng học được rất sớm: cache là con dao hai lưỡi sắc nhất trong toàn bộ kho vũ khí của bạn. Dùng đúng, nó biến một trang chi tiết sản phẩm mất 800ms thành 20ms, giảm tải database từ 10.000 query/giây xuống còn 500, và cứu bạn khỏi việc phải nâng cấp cụm máy chủ đắt tiền. Dùng sai, nó phục vụ dữ liệu cũ cho khách hàng, gây ra những bug "chỉ xuất hiện lúc 9 giờ sáng thứ Hai" mà không ai debug nổi, và tệ hơn cả, nó khiến hệ thống của bạn phụ thuộc vào một thứ mà khi nó "trống rỗng" thì toàn bộ backend sụp đổ.
Trong vai trò một người ra quyết định kiến trúc, bạn không được hỏi "có nên cache không" — gần như luôn luôn là có. Câu hỏi thật sự bạn phải trả lời là: cache ở tầng nào, dùng pattern nào, invalidate ra sao, và ai chịu trách nhiệm khi dữ liệu lệch nhau? Đó chính là nội dung của bài này. Chúng ta sẽ không bàn về việc chọn Redis hay Memcached (đó là chuyện của Bài 36), cũng không đi sâu vào CDN như một hạ tầng riêng (Bài 51). Ở đây, ta tập trung vào các tầng cache và các pattern cache — bộ khung tư duy để bạn thiết kế một chiến lược cache mạch lạc từ trình duyệt cho tới tận cùng database.
Khái niệm cốt lõi
Cache là gì và tại sao nó hiệu quả
Cache là một lớp lưu trữ tạm thời, đặt gần nơi tiêu thụ dữ liệu hơn so với nguồn gốc thật (source of truth). Nó hoạt động dựa trên hai nguyên lý về tính cục bộ (locality): temporal locality — dữ liệu vừa được truy cập rất có thể sẽ được truy cập lại ngay sau đó; và spatial locality — dữ liệu gần với dữ liệu vừa truy cập cũng có khả năng được cần đến.
Điều quan trọng cần khắc cốt: cache là một bản sao. Mà đã là bản sao thì luôn có nguy cơ lệch với bản gốc. Toàn bộ nghệ thuật của caching nằm ở việc chấp nhận một mức độ lệch (staleness) vừa đủ để đổi lấy tốc độ, mà không phá vỡ tính đúng đắn nghiệp vụ.
Các tầng cache — cache đặt ở đâu
Một request điển hình từ người dùng đi qua rất nhiều chặng, và mỗi chặng đều là một điểm có thể đặt cache. Ta duyệt từ ngoài vào trong:
1. Browser cache (cache phía trình duyệt). Đây là tầng gần người dùng nhất và cũng "rẻ" nhất — request thậm chí không rời khỏi máy họ. Trình duyệt cache các asset tĩnh (CSS, JS, hình ảnh, font) và cả phản hồi API nếu bạn cho phép. Cơ chế điều khiển chính là các HTTP header: Cache-Control (ví dụ max-age=3600, no-cache, private), ETag (dấu vân tay của nội dung để trình duyệt hỏi "cái này còn mới không?"), và Last-Modified. Khi ETag khớp, server trả về 304 Not Modified — tiết kiệm băng thông mà vẫn đảm bảo tính mới.
2. CDN (Content Delivery Network). Là một mạng lưới máy chủ đặt rải rác ở nhiều vị trí địa lý (edge locations). CDN cache asset tĩnh, và ngày càng phổ biến là cache cả phản hồi API ở edge. Với một người dùng ở TP.HCM truy cập một trang có CDN edge tại Singapore, độ trễ giảm từ hàng trăm ms (nếu origin đặt ở Mỹ) xuống còn vài chục ms. CDN dùng khái niệm TTL (time-to-live) và các cơ chế purge để kiểm soát tính mới.
3. API gateway / reverse proxy cache. Đây là tầng cache trước ứng dụng, thường là Varnish, Nginx (với proxy_cache), hoặc các API gateway như Kong. Nó bắt các phản hồi HTTP hoàn chỉnh và phục vụ lại mà không cần chạm tới application server. Đặc biệt hữu ích cho các endpoint đọc nhiều, chung cho mọi người dùng (ví dụ danh mục sản phẩm, trang chủ).
4. Application cache (in-process / local). Cache nằm ngay trong bộ nhớ của tiến trình ứng dụng — ví dụ một ConcurrentHashMap trong Java, hay Caffeine, hoặc một dictionary trong Python có TTL. Cực nhanh vì không có network hop, nhưng không chia sẻ được giữa các instance và mất khi restart. Phù hợp cho dữ liệu nhỏ, ít đổi, đọc cực nhiều (config, feature flag, bảng lookup).
5. Distributed cache (cache phân tán). Một cụm cache tách rời như Redis hay Memcached, chia sẻ giữa mọi instance ứng dụng. Đây là "trái tim" của phần lớn chiến lược cache backend. Chi tiết công cụ để Bài 36, ở đây ta chỉ cần biết nó là tầng cache dùng chung, có network hop nhưng vẫn nhanh hơn database rất nhiều.
6. Database cache. Ngay cả database cũng có cache của riêng nó — buffer pool của PostgreSQL/MySQL giữ các trang dữ liệu nóng trong RAM, và query cache (tuy MySQL 8.0 đã bỏ). Đây là tầng bạn ít điều khiển trực tiếp nhưng cần biết nó tồn tại.
Nguyên tắc vàng: cache càng gần người dùng thì càng nhanh và rẻ, nhưng càng khó invalidate. Bạn không thể "xóa" cache trong trình duyệt của một triệu người ngay lập tức. Vì vậy dữ liệu càng biến động, bạn càng nên cache nó ở tầng sâu bên trong (nơi bạn kiểm soát được), và dùng TTL ngắn ở tầng ngoài.
Các pattern cache — cache đọc ghi thế nào
Đây là phần cốt lõi về mặt tư duy thiết kế. Có bốn pattern kinh điển:
Cache-Aside (Lazy Loading). Ứng dụng chịu trách nhiệm quản lý cache. Khi đọc: kiểm tra cache trước; nếu trúng (hit) thì trả về; nếu trượt (miss) thì đọc từ database, ghi vào cache, rồi trả về. Khi ghi: cập nhật database và xóa (invalidate) key trong cache. Đây là pattern phổ biến nhất, linh hoạt nhất, và là lựa chọn mặc định cho hầu hết hệ thống. Điểm yếu: có một cửa sổ race condition khi ghi, và lần đọc đầu tiên sau miss luôn chậm.
Read-Through. Giống cache-aside nhưng logic đọc-từ-database được đẩy vào chính lớp cache (thông qua một provider/library). Ứng dụng chỉ nói chuyện với cache, cache tự lo việc nạp từ nguồn khi miss. Code ứng dụng sạch hơn, nhưng bạn phụ thuộc vào thư viện cache hỗ trợ.
Write-Through. Mỗi lần ghi, dữ liệu được ghi đồng thời vào cache và database trong cùng một thao tác đồng bộ. Ưu điểm: cache luôn nhất quán với database, không bao giờ stale. Nhược điểm: mỗi lần ghi chậm hơn (phải ghi hai nơi), và bạn cache cả những dữ liệu có thể chẳng bao giờ được đọc lại.
Write-Behind (Write-Back). Ghi vào cache trước, trả về ngay cho người dùng, rồi ghi xuống database một cách bất đồng bộ sau đó (theo batch hoặc hàng đợi). Cho throughput ghi cực cao và độ trễ ghi thấp. Nhưng đây là pattern nguy hiểm nhất: nếu cache chết trước khi kịp flush, bạn mất dữ liệu. Chỉ dùng khi mất một chút dữ liệu là chấp nhận được (ví dụ đếm lượt xem, log analytics).
Invalidation và TTL — bài toán khó nhất
Có 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, đặt tên biến, và lỗi off-by-one." Đùa vui, nhưng cache invalidation thật sự là phần khó nhất.
Ba chiến lược làm mới cache:
- TTL (time-based): đặt thời gian sống cho key, hết hạn thì tự xóa. Đơn giản, nhưng luôn có cửa sổ dữ liệu stale bằng đúng độ dài TTL.
- Event-based invalidation: khi dữ liệu gốc thay đổi, chủ động xóa/cập nhật key liên quan. Chính xác hơn nhưng phức tạp hơn — bạn phải biết chính xác key nào bị ảnh hưởng.
- Versioned keys: nhúng version vào key (ví dụ
product:v3:123). Khi cần "xóa toàn bộ", chỉ cần tăng version — cache cũ tự trở nên vô dụng và bị dọn dần.
Ba con quái vật của caching
Bạn phải biết mặt ba vấn đề kinh điển:
- Cache stampede (thundering herd): một key nóng hết hạn, hàng nghìn request đồng thời cùng miss và cùng đập vào database. Giải pháp: lock/single-flight (chỉ một request được đi nạp, số còn lại chờ), hoặc làm mới sớm trước khi hết hạn.
- Cache penetration: liên tục query những key không tồn tại (thường do tấn công hoặc bug), cache luôn miss và database luôn bị hỏi. Giải pháp: cache cả giá trị "null", hoặc dùng Bloom filter.
- Hot key: một key được truy cập với tần suất áp đảo, làm nghẽn một node cache. Giải pháp: nhân bản key ra nhiều node, hoặc thêm một lớp local cache trước distributed cache.
Tình huống thực tế
Ví dụ 1: Sàn thương mại điện tử Việt Nam trong ngày sale 12/12
Một sàn TMĐT lớn ở Việt Nam (hình dung quy mô như Tiki hay Sendo) chuẩn bị cho đợt sale 12/12. Trang chi tiết sản phẩm là điểm nóng nhất — mỗi giây có hàng chục nghìn lượt xem, và trong lúc flash sale, cùng một sản phẩm "iPhone giảm giá" bị hàng trăm nghìn người mở cùng lúc.
Nhóm kiến trúc thiết kế cache theo tầng: thông tin sản phẩm tương đối tĩnh (tên, mô tả, hình ảnh) được cache ở CDN với TTL 5 phút và trong Redis với cache-aside. Nhưng tồn kho và giá flash sale thì không được cache dài — chúng đổi từng giây. Họ tách riêng: dữ liệu tĩnh cache mạnh tay ở edge, còn số lượng tồn kho được đọc trực tiếp qua một endpoint riêng với TTL chỉ 1–2 giây, có single-flight để chống stampede.
Đúng ngày sale, một "hot key" xuất hiện: sản phẩm iPhone flash sale nhận 90% lưu lượng, làm một node Redis quá tải. Họ xử lý bằng cách thêm một lớp local cache (Caffeine) 500ms trong mỗi application instance trước Redis — đủ để hấp thụ đợt bùng nổ mà độ stale 500ms không ảnh hưởng trải nghiệm mua hàng.
Bài học: Không có TTL "một cỡ vừa tất cả". Hãy phân loại dữ liệu theo độ biến động và giá trị nghiệp vụ, rồi gán chiến lược cache riêng cho từng loại. Dữ liệu tĩnh cache dài ở edge; dữ liệu nóng-nhạy cảm cache ngắn và có bảo vệ chống stampede.
Ví dụ 2: Ứng dụng gọi xe và bài toán write-behind
Một startup gọi xe ở Đông Nam Á (kiểu Grab/Gojek quy mô nhỏ) cần ghi vị trí tài xế liên tục — mỗi tài xế gửi tọa độ mỗi 3–4 giây, với hàng chục nghìn tài xế online. Nếu mỗi cập nhật vị trí đều ghi thẳng xuống database (write-through), database sẽ gục ngã dưới hàng chục nghìn write mỗi giây.
Họ chọn write-behind: vị trí mới nhất được ghi vào Redis ngay lập tức (phục vụ tính năng "xem tài xế gần bạn" theo thời gian thực), còn việc ghi lịch sử di chuyển xuống database được gom batch và flush mỗi vài giây qua một hàng đợi. Vì lịch sử vị trí không phải dữ liệu "sống chết" — mất vài giây cuối khi sự cố xảy ra là chấp nhận được — nên rủi ro mất dữ liệu của write-behind ở đây được cân nhắc là hợp lý.
Ngược lại, với giao dịch thanh toán chuyến đi, họ tuyệt đối không dùng write-behind. Tiền bạc phải write-through, đồng bộ, có transaction. Cùng một hệ thống nhưng hai loại dữ liệu, hai pattern khác nhau.
Bài học: Pattern cache là quyết định theo từng loại dữ liệu, không phải theo toàn hệ thống. Hãy hỏi: "Mất bản ghi mới nhất của dữ liệu này có gây hậu quả nghiêm trọng không?" Nếu có, tránh xa write-behind.
Ví dụ 3: Trang tin tức và sự cố cache penetration
Một trang báo điện tử Việt Nam cache các bài viết theo ID trong Redis bằng cache-aside, TTL 10 phút. Một ngày nọ, hệ thống bị chậm bất thường, database CPU tăng vọt dù lượng truy cập không tăng. Điều tra ra: một bot đang quét URL với các ID bài viết ngẫu nhiên không tồn tại (/bai-viet/99999999). Mỗi request như vậy đều miss cache (vì key không có), rơi xuống database, database trả về "không tìm thấy" — nhưng ứng dụng không cache kết quả rỗng, nên request tiếp theo lại tiếp tục hỏi database. Đây chính là cache penetration.
Nhóm kỹ thuật vá bằng hai lớp: (1) cache cả kết quả "không tồn tại" với TTL ngắn (30 giây) và một marker đặc biệt, để lần hỏi sau được trả lời ngay từ cache; (2) thêm Bloom filter chứa tập ID bài viết hợp lệ, chặn ngay các ID chắc chắn không tồn tại trước khi chạm cache hay database.
Bài học: Miss cache không phải lúc nào cũng "vô hại rồi query database là xong". Kẻ tấn công hoặc bug có thể biến chính cơ chế miss thành lỗ hổng. Luôn nghĩ tới cache penetration khi thiết kế cache-aside cho dữ liệu tra cứu theo khóa từ bên ngoài.
Hướng dẫn từng bước
Khi thiết kế chiến lược cache cho một tính năng hoặc hệ thống, hãy đi theo trình tự sau:
- Xác định dữ liệu và đặc tính đọc/ghi. Với mỗi loại dữ liệu, ghi rõ: tần suất đọc so với ghi (read-heavy hay write-heavy?), mức độ biến động (đổi mỗi giây hay mỗi tuần?), và mức chấp nhận stale (người dùng thấy dữ liệu cũ 30 giây có sao không?).
- Chọn tầng cache phù hợp. Dữ liệu tĩnh, dùng chung, ít riêng tư → đẩy ra CDN/edge. Dữ liệu backend dùng chung giữa các instance → distributed cache. Dữ liệu nhỏ, siêu nóng, ít đổi → cân nhắc thêm local cache. Càng biến động thì càng cache ở tầng sâu và TTL càng ngắn.
- Chọn pattern cho từng loại. Mặc định là cache-aside. Nếu cần cache luôn khớp database và ghi không quá nhiều → write-through. Nếu ghi cực nhiều và chấp nhận mất chút dữ liệu → write-behind (thận trọng).
- Thiết kế key và invalidation. Đặt quy ước key rõ ràng, có namespace và version (
product:v2:{id}). Quyết định: TTL bao nhiêu? Có cần event-based invalidation khi dữ liệu gốc đổi không? Ai phát sự kiện đó?
- Bảo vệ chống ba con quái vật. Thêm single-flight/lock cho key nóng (chống stampede). Cache giá trị null (chống penetration). Cân nhắc local cache hoặc key replication cho hot key.
- Đo lường. Không có cache hit ratio thì bạn đang bay mù. Theo dõi hit/miss ratio, độ trễ p99, và tải database. Cache hit ratio dưới 80% cho dữ liệu read-heavy thường là dấu hiệu TTL quá ngắn hoặc key không được thiết kế tốt.
- Lập kế hoạch cho tình huống cache chết. Hệ thống của bạn có sống được khi cache trống hoặc down không? Nếu không, bạn đã tạo ra một điểm hỏng đơn (single point of failure) trá hình. Cân nhắc graceful degradation và giới hạn tải xuống database khi cache lạnh.
Lỗi thường gặp & mẹo
Lỗi 1 — Cache mọi thứ với cùng một TTL. Đây là lỗi kinh điển của người mới. Đặt max-age=3600 cho tất cả rồi ngạc nhiên khi giá sản phẩm hiển thị sai một giờ. Mẹo: phân loại dữ liệu và gán TTL theo độ biến động.
Lỗi 2 — Quên invalidate khi ghi. Với cache-aside, nếu bạn cập nhật database mà quên xóa key cache, người dùng sẽ thấy dữ liệu cũ cho tới khi TTL hết. Mẹo: đưa việc invalidate vào cùng đường code với việc ghi, và viết test cho nó.
Lỗi 3 — Cache dữ liệu riêng tư ở tầng dùng chung. Cache phản hồi có thông tin cá nhân của user A ở CDN, rồi phục vụ nó cho user B. Đây là lỗ hổng bảo mật nghiêm trọng. Mẹo: đặt Cache-Control: private cho dữ liệu cá nhân, và không bao giờ cache theo key thiếu định danh người dùng khi nội dung phụ thuộc người dùng.
Lỗi 4 — Dùng cache làm nguồn sự thật. Cache là bản sao, có thể bốc hơi bất cứ lúc nào. Nếu logic của bạn giả định dữ liệu "luôn có trong cache", một lần restart Redis sẽ khiến bạn nhận ra sai lầm. Mẹo: cache luôn phải có đường lùi về source of truth.
Lỗi 5 — Không xử lý stampede. Một key nóng hết hạn giữa giờ cao điểm có thể tạo ra một cơn "sóng thần" request xuống database. Mẹo: dùng single-flight, hoặc thêm jitter (ngẫu nhiên hóa) vào TTL để các key không hết hạn đồng loạt.
Mẹo vàng: Luôn thêm chút ngẫu nhiên vào TTL. Thay vì TTL cố định 300 giây, dùng 300 + random(0, 60). Điều này tránh việc hàng loạt key được tạo cùng lúc cũng hết hạn cùng lúc — một nguyên nhân stampede âm thầm mà nhiều người bỏ qua.
Bài tập thực hành
- Phân loại và gán chiến lược. Lấy một hệ thống bạn quen thuộc (hoặc một app TMĐT giả định) và liệt kê 6–8 loại dữ liệu (thông tin sản phẩm, tồn kho, giỏ hàng, profile user, đánh giá, giá...). Với mỗi loại, điền vào bảng: read/write ratio, độ biến động, mức chấp nhận stale, tầng cache đề xuất, pattern đề xuất, TTL đề xuất. Giải thích lý do cho ít nhất 3 lựa chọn.
- Thiết kế chống ba con quái vật. Cho một endpoint đọc thông tin sản phẩm theo ID (public, bot có thể quét). Viết mô tả (pseudo-code hoặc sơ đồ) cách bạn phòng cả cache stampede, cache penetration và hot key cho endpoint này.
- Chọn pattern. Cho ba tình huống sau, chọn pattern cache (cache-aside / write-through / write-behind) và giải thích: (a) số dư ví điện tử của người dùng; (b) bộ đếm lượt xem video; (c) danh mục ngành hàng hiển thị ở menu, đổi vài lần mỗi tháng. Nêu rõ rủi ro của lựa chọn.
- Viết mini-ADR. Dựa trên Bài 4, viết một ADR ngắn (khoảng 1 trang) cho quyết định: "Chọn chiến lược cache cho trang chi tiết sản phẩm". Nêu bối cảnh, các phương án cân nhắc, quyết định, và hệ quả (bao gồm cả rủi ro stale và kế hoạch invalidation).
Tóm tắt
Caching không phải là một công tắc bật/tắt, mà là một chiến lược nhiều tầng, nhiều pattern, quyết định theo từng loại dữ liệu. Bạn đã học sáu tầng cache — từ browser, CDN, API gateway, application, distributed cho tới database — với nguyên tắc cốt lõi: càng gần người dùng thì càng nhanh nhưng càng khó invalidate. Bạn cũng nắm được bốn pattern kinh điển: cache-aside (mặc định linh hoạt), read-through (code sạch), write-through (luôn nhất quán, ghi chậm), và write-behind (throughput cao, rủi ro mất dữ liệu).
Phần khó nhất luôn là invalidation — hãy chọn giữa TTL, event-based, và versioned keys tùy độ chính xác cần thiết. Và đừng bao giờ quên ba con quái vật: stampede, penetration, và hot key — mỗi con đều có cách phòng thủ riêng.
Điều đọng lại quan trọng nhất cho một người ra quyết định kiến trúc: cache là bản sao, không phải nguồn sự thật; và mọi quyết định cache đều là một sự đánh đổi giữa tốc độ và tính tươi mới của dữ liệu. Khi bạn hiểu rõ mình đang đánh đổi cái gì, lấy cái gì, và ai gánh hậu quả khi lệch — bạn đã thiết kế cache như một chuyên gia thực thụ, chứ không phải chỉ "ném thêm Redis vào cho nhanh".