Caching: khái niệm và chiến lược cốt lõi
Xem thử8.000 lần đọc mỗi giây cho một thứ gần như không bao giờ đổi
Trang chi tiết sản phẩm của gdev-shop nhận 8.000 lượt đọc mỗi giây (giả định). Thông tin mỗi sản phẩm (tên, mô tả, giá) chỉ đổi vài lần mỗi ngày. Mỗi lượt đọc vẫn chạy một truy vấn đến PostgreSQL, cùng một kết quả hàng nghìn lần liên tiếp. Database dành gần hết sức để trả lời đi trả lời lại câu hỏi đã biết đáp án.
Hãy dự đoán trước: nếu cất lời giải của truy vấn vào một nơi nhanh hơn và dùng lại, độ trễ trung bình đổi thế nào, và điều gì có thể sai?
Ý tưởng đó là caching. Nhanh hơn rõ ràng. Còn điều có thể sai là chủ đề chính của bài: dữ liệu trong cache có thể không còn đúng.
Cache là gì?
Cache (bộ nhớ đệm) là một nơi lưu bản sao tạm của dữ liệu để các lần truy cập sau không phải tính toán hoặc lấy lại từ nguồn gốc chậm hơn. Nguồn gốc (database, dịch vụ ngoài) vẫn là nơi giữ dữ liệu đúng (source of truth). Cache chỉ là bản sao tiện lợi, có thể bị mất hoặc cũ mà không phải thảm họa.
Cache hiệu quả nhờ tính cục bộ truy cập: một nhóm nhỏ dữ liệu nhận phần lớn lượt truy cập (vài sản phẩm hot được xem hàng nghìn lần, phần lớn sản phẩm hiếm khi được xem). Cất đúng nhóm nhỏ đó đã giảm phần lớn tải.
Ba khái niệm cốt lõi:
- Cache hit: dữ liệu có trong cache, trả ngay.
- Cache miss: dữ liệu không có, phải lấy từ nguồn gốc (chậm hơn) và thường cất vào cache cho lần sau.
- Hit ratio (tỷ lệ hit): số hit chia tổng số lượt truy cập. Đây là chỉ số quan trọng nhất của một cache.
Cache hiệu quả đến mức nào? Một phép tính
Độ trễ trung bình bằng tỷ lệ hit nhân thời gian đọc cache cộng tỷ lệ miss nhân thời gian đọc nguồn gốc. Giả định đọc cache mất 1 ms, đọc database mất 20 ms:
| Hit ratio | Độ trễ trung bình |
|---|---|
| 50% | 0,5 nhân 1 cộng 0,5 nhân 20 = 10,5 ms |
| 90% | 0,9 nhân 1 cộng 0,1 nhân 20 = 2,9 ms |
| 99% | 0,99 nhân 1 cộng 0,01 nhân 20 = 1,19 ms |
Tải database còn lại cũng tỷ lệ với tỷ lệ miss: từ 8.000 lượt mỗi giây xuống còn 800 ở hit 90% và 80 ở hit 99%. Hit ratio thấp (50%) gần như không đáng công sức vận hành cache. Vì vậy hit ratio phải được đo và theo dõi, không giả định.
Cache nằm ở đâu?
Cache có thể ở nhiều tầng của hệ thống, mỗi tầng gần người dùng hơn tầng sau:
| Tầng | Ví dụ | Đặc điểm |
|---|---|---|
| Trình duyệt | HTTP cache, localStorage | Gần nhất, nhanh nhất, nhưng bạn khó kiểm soát |
| CDN và edge | Cache file tĩnh gần người dùng | Giảm độ trễ địa lý, giảm tải origin |
| Bộ nhớ ứng dụng (local) | Map trong tiến trình | Rất nhanh, nhưng mỗi instance một bản riêng, dễ lệch nhau |
| Cache phân tán | Redis, Memcached | Dùng chung giữa các instance, cần gọi qua mạng |
| Database | Buffer cache, query cache | Tự động, bạn ít kiểm soát |
Bài này tập trung vào cache nằm giữa ứng dụng và database, các lesson sau đi vào cache phân tán, CDN và edge.
Mẫu cache-aside: luồng đọc
Cách dùng phổ biến nhất gọi là cache-aside (còn gọi lazy loading): ứng dụng tự quản lý việc đọc cache và nạp cache.
Đang tải Animation…
Đang tải Animation…
Cách đọc hình
Đọc theo các số trên hình:
- Client gửi
GET /products/7đến API. - API hỏi cache trước bằng khóa
product:7(bước 1 trên hình). - Hai nhánh:
- Cache hit: cache có dữ liệu và trả ngay. Database không bị chạm đến. Đây là đường nhanh và là mục tiêu của cache.
- Cache miss: cache trả
nil. API truy vấn database (bước 2), nhận dữ liệu, ghi vào cache kèm TTL (thời gian sống, bước 3) để lần sau là hit.
- API trả response cho client.
Khi ghi, cập nhật database trước rồi xóa khóa cache để lần đọc kế tiếp lấy dữ liệu mới. Hiệu quả phụ thuộc vào hit ratio, và cái giá chính là dữ liệu có thể cũ trong khoảng TTL.
Điểm quan trọng: dữ liệu vào cache theo nhu cầu (khi bị hỏi và miss), nên cache chỉ chứa thứ thực sự được dùng. Lần đầu tiên của mỗi khóa luôn chậm (miss).
Bottleneck: một key quá nóng (sản phẩm đang giảm giá hàng nghìn request mỗi giây) và tải lớn khi nhiều khóa cùng miss (ví dụ cache vừa khởi động lại). Khi cache hỏng, API đọc thẳng database nên chậm hơn nhưng vẫn đúng, miễn là database chịu được tải. Đó là lý do cache là tối ưu chứ không phải điều kiện sống còn, và database vẫn phải được thiết kế để chịu được tải khi cache trống.
Đang tải Animation…
Đang tải Animation…
Mã minh họa cho mẫu cache-aside (giao diện cache và repository là giả định, không thuộc thư viện cụ thể):
public Product getProduct(long id) {
String key = "product:" + id;
Product cached = cache.get(key);
if (cached != null) {
return cached;
}
Product product = repository.findById(id);
if (product != null) {
cache.set(key, product, Duration.ofMinutes(5));
}
return product;
}
public void updateProduct(Product product) {
repository.save(product);
cache.delete("product:" + product.id());
}
Hàm đọc làm đúng như hình: thử cache, miss thì đọc database rồi nạp cache với TTL 5 phút. Hàm ghi cập nhật database trước rồi xóa khóa cache, để lần đọc sau miss và nạp lại dữ liệu mới. Xóa thường an toàn hơn là cố ghi đè cache bằng giá trị mới, vì ghi đè dễ gây lệch khi hai thao tác ghi chạy đồng thời.
Vấn đề khó nhất: dữ liệu cũ và cách làm mới
"Chỉ có hai thứ khó trong khoa học máy tính: làm mất hiệu lực cache và đặt tên." (câu nói đùa quen thuộc trong giới lập trình)
Cache chứa bản sao, và bản sao có thể lệch so với nguồn gốc. Hai cơ chế chính:
- TTL (Time To Live): mỗi khóa tự hết hạn sau một khoảng thời gian. Đơn giản, đảm bảo dữ liệu cũ tối đa TTL giây. Chọn TTL là đánh đổi giữa độ tươi và hit ratio.
- Invalidation (làm mất hiệu lực): chủ động xóa hoặc cập nhật khóa cache khi dữ liệu gốc đổi, như hàm
updateProductở trên. Dữ liệu tươi hơn nhưng bạn phải bảo đảm mọi đường ghi đều xóa cache, nếu sót một chỗ thì dữ liệu cũ tồn tại đến hết TTL.
Thực tế thường dùng cả hai: invalidation cho độ tươi, TTL làm lưới an toàn khi invalidation sót.
Các chiến lược cache khác
| Chiến lược | Cách hoạt động | Ưu điểm | Nhược điểm |
|---|---|---|---|
| Cache-aside | Ứng dụng đọc cache, miss thì tự nạp | Đơn giản, chỉ cache thứ cần, chịu được cache hỏng | Miss đầu tiên chậm, ứng dụng tự lo nhất quán |
| Read-through | Chính cache tự lấy từ nguồn khi miss | Code ứng dụng gọn hơn | Cần cache hỗ trợ, ít linh hoạt |
| Write-through | Ghi vào cache và database cùng lúc | Cache luôn khớp sau khi ghi | Ghi chậm hơn vì làm hai việc |
| Write-back (write-behind) | Ghi vào cache trước, ghi database sau theo lô | Ghi rất nhanh | Nguy cơ mất dữ liệu nếu cache chết trước khi ghi xuống |
Cache-aside là điểm xuất phát hợp lý cho đa số ứng dụng web.
Ba vấn đề cần biết ở mức giới thiệu
- Cache stampede (đàn voi dẫm): một khóa phổ biến hết hạn, hàng nghìn request cùng miss và cùng đổ về database một lúc. Giảm bằng cách gộp các request cùng khóa thành một lần nạp, hoặc đặt TTL ngẫu nhiên lệch nhau (jitter) để các khóa không cùng hết hạn.
- Cache penetration: truy vấn khóa không tồn tại liên tục (ví dụ id sản phẩm giả) luôn miss và luôn chạm database. Giảm bằng cách cache cả kết quả "không có" trong thời gian ngắn.
- Cache lạnh (cold cache): ngay sau khi khởi động hoặc thay đổi lớn, cache trống nên mọi thứ miss. Cần database chịu được tải đó, hoặc làm nóng trước, như lesson sau sẽ nói.
Khi nào chưa cần, hoặc không nên cache?
- Hit ratio dự kiến thấp: dữ liệu mỗi lần một khác, ít truy cập lặp lại.
- Dữ liệu cần luôn tươi tuyệt đối: số dư thanh toán, tồn kho ở bước xác nhận mua.
- Truy vấn vốn đã rẻ: chi phí thêm cache lớn hơn lợi ích.
- Đường ghi nặng hơn đọc: cache liên tục bị mất hiệu lực nên ít tác dụng.
Những hiểu lầm phổ biến
- "Cache luôn làm hệ thống nhanh hơn." Chỉ khi hit ratio đủ cao. Hit ratio thấp chỉ thêm độ phức tạp và độ trễ của lần gọi cache.
- "Cache là nguồn dữ liệu." Không. Nguồn gốc là database, cache chỉ là bản sao có thể mất.
- "Đặt TTL là đủ." TTL giới hạn độ cũ tối đa nhưng không bảo đảm tươi. Dữ liệu quan trọng cần invalidation.
- "Cache hỏng thì hệ thống hỏng." Với cache-aside, hệ thống vẫn đúng nhưng chậm hơn. Rủi ro là database bị quá tải khi cache trống.
Must Know, Good to Know, Advanced
- Must Know: định nghĩa cache, hit, miss, hit ratio; công thức độ trễ trung bình; luồng cache-aside và code đọc ghi; TTL và invalidation; dữ liệu cũ là cái giá chính.
- Good to Know: các tầng cache; read-through, write-through, write-back; cache stampede, penetration, cold cache.
- Advanced: cache nhiều tầng nhất quán, thuật toán loại bỏ thích ứng. Một phần được giới thiệu ở các lesson sau.
Tự kiểm tra
- Hit ratio 80%, đọc cache 1 ms, đọc database 25 ms. Độ trễ trung bình bao nhiêu? Tải database còn lại là bao nhiêu phần trăm so với không cache?
- Vì sao hàm cập nhật nên xóa khóa cache thay vì ghi đè giá trị mới?
- Nêu một loại dữ liệu của
gdev-shopkhông nên cache, và một loại nên cache. Lập luận.
Đáp án gợi ý
- Độ trễ trung bình = 0,8 × 1 ms + 0,2 × 25 ms = 0,8 + 5 = 5,8 ms (so với 25 ms khi không cache, nhanh hơn khoảng 4,3 lần). Tải database còn lại chỉ bằng 20% so với không cache, vì chỉ các lượt miss (20%) mới chạm tới database.
- Nếu ghi đè giá trị mới vào cache, hai request cập nhật đồng thời có thể ghi vào cache theo thứ tự khác với thứ tự ghi vào database, khiến cache giữ giá trị cũ vĩnh viễn (đến hết TTL). Xóa khóa thì lần đọc kế tiếp sẽ miss và nạp lại giá trị mới nhất từ database, nên luôn khớp nguồn dữ liệu gốc và đơn giản hơn.
- Nên cache: danh mục sản phẩm, thông tin hiển thị ít đổi (đọc nhiều, ghi ít, chấp nhận trễ vài giây). Không nên cache: số dư thanh toán hoặc tồn kho tại bước trừ kho thật, vì cần chính xác tuyệt đối và đọc dữ liệu cũ gây quyết định sai (bán quá, tính sai tiền). Tiêu chí là chi phí khi đọc dữ liệu cũ.
Nếu chỉ nhớ 3 điều
- Cache là bản sao tạm để đọc nhanh; hiệu quả phụ thuộc hit ratio, và nguồn dữ liệu đúng vẫn là database.
- Cache-aside: đọc cache trước, miss thì đọc database rồi nạp cache kèm TTL; ghi thì cập nhật database rồi xóa khóa cache.
- Cái giá chính là dữ liệu cũ: dùng TTL và invalidation, và đừng cache dữ liệu cần luôn đúng tuyệt đối.