Chiến lược cache: cache-aside, invalidation và các sự cố kinh điển
Tiếp đi: cache product detail bằng Redis trong Spring Boot.
Ở bài 7.1 shop-app đã có Redis. Giờ áp vào API nóng nhất: GET /products/{id}.
1. Bài toán
GET /products/100 → 100.000 lần/phút
ProductService.findById → SELECT ... JOIN category JOIN price (~20 ms)
Giá đổi vài lần một ngày
99% request đọc lại cùng một dữ liệu. DB làm cùng việc 100.000 lần.
2. Vấn đề
- DB CPU cao, connection pool cạn khi có traffic spike.
- Latency p99 tăng theo tải DB.
- Thêm cache thì lại có câu hỏi: dữ liệu cũ (stale) thì sao, cache hết hạn cùng lúc thì sao?
3. Giải pháp: các pattern cache
| Pattern | Đọc | Ghi |
|---|---|---|
| Cache-aside (lazy loading) | App đọc cache; miss thì đọc DB rồi set cache | App ghi DB rồi xóa cache |
| Read-through | Cache library tự load từ DB khi miss | Như cache-aside |
| Write-through | Đọc cache | Ghi cache và DB đồng bộ trong cùng request |
| Write-behind (write-back) | Đọc cache | Ghi cache trước, flush DB bất đồng bộ sau (nhanh, có rủi ro mất data) |
Hầu hết hệ thống Spring dùng cache-aside. Hai thuật ngữ: cache hit (Redis có, trả luôn) và cache miss (Redis không có, đi DB).
4. Code từng bước
Bước 1: Dependency: spring-boot-starter-data-redis + spring-boot-starter-cache.
Bước 2: Config cache với TTL và JSON serializer
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory cf) {
var json = RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer());
var defaults = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(json)
.entryTtl(Duration.ofMinutes(10));
return RedisCacheManager.builder(cf)
.cacheDefaults(defaults)
.withCacheConfiguration("product", defaults.entryTtl(Duration.ofMinutes(30)))
.build();
}
}
Bước 3a: Cách khai báo: @Cacheable / @CacheEvict
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repo;
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public ProductDto findById(Long id) {
return repo.findById(id).map(ProductDto::from)
.orElseThrow(() -> new NotFoundException(id));
}
@Transactional
@CacheEvict(cacheNames = "product", key = "#id")
public void updatePrice(Long id, BigDecimal price) {
repo.findById(id).orElseThrow().setPrice(price);
}
}
sync = true: trong cùng một instance, chỉ một thread load khi miss, các thread khác chờ. Nhớ: annotation chạy qua proxy, gọi nội bộ this.findById() sẽ không qua cache.
Bước 3b: Cách tự viết bằng RedisTemplate (khi cần TTL jitter, null caching):
@Service
@RequiredArgsConstructor
public class ProductCache {
private static final String NULL = "__NULL__";
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final ProductRepository repo;
public Optional<ProductDto> get(Long id) throws JsonProcessingException {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (NULL.equals(cached)) return Optional.empty(); // penetration guard
if (cached != null) return Optional.of(mapper.readValue(cached, ProductDto.class));
Optional<ProductDto> fromDb = repo.findById(id).map(ProductDto::from);
if (fromDb.isEmpty()) {
redis.opsForValue().set(key, NULL, Duration.ofSeconds(60));
} else {
long jitter = ThreadLocalRandom.current().nextLong(0, 300);
redis.opsForValue().set(key, mapper.writeValueAsString(fromDb.get()),
Duration.ofSeconds(1800 + jitter)); // TTL + jitter
}
return fromDb;
}
}
Bước 4: Test
curl -w " %{time_total}s\n" localhost:8080/products/100 # miss: 0.025s
curl -w " %{time_total}s\n" localhost:8080/products/100 # hit : 0.002s
redis-cli TTL product::100 # @Cacheable dùng prefix "product::"
curl -X PUT localhost:8080/products/100/price -d '{"price":900}' -H 'Content-Type: application/json'
redis-cli EXISTS product::100 # 0, đã bị evict
5. Cách hoạt động: cache-aside và các sự cố
READ WRITE
GET product:100 UPDATE products SET price=900
| |
hit? ──yes──> return commit
| no |
SELECT DB DEL product:100
| |
SET product:100 EX 1800+jitter request sau: miss → load bản mới
|
return
| Sự cố | Chuyện gì xảy ra | Cách xử lý |
|---|---|---|
| Cache stampede (breakdown) | Một key nóng hết hạn, 1000 request cùng miss, cùng query DB | sync=true, mutex SET lock:product:100 NX PX để một request load, hoặc refresh sớm trước khi hết hạn |
| Cache penetration | Request id không tồn tại (/products/-1), luôn miss, luôn đi DB | Cache giá trị null TTL ngắn; Bloom filter chặn id chắc chắn không có; validate input |
| Cache avalanche | Nhiều key hết hạn cùng lúc, hoặc Redis chết, DB nhận toàn bộ tải | TTL + jitter; Redis HA (bài 7.4); circuit breaker / rate limit trước DB |
| Hot key | Một key (sản phẩm flash sale) nhận quá nhiều request, một node Redis quá tải | Local cache (Caffeine) vài giây phía trước Redis; nhân bản key product:100:{1..N} |
6. So sánh cách invalidation
| Cách | Ưu | Nhược |
|---|---|---|
| Chỉ TTL | Đơn giản | Stale tối đa bằng TTL |
| Update DB rồi DEL cache | Phổ biến, đơn giản, an toàn hơn update cache | Vẫn có khe nhỏ race (xem câu sau) |
| Update DB rồi SET cache | Không có miss | Hai request ghi song song có thể để cache giữ bản cũ hơn |
| CDC (Debezium) / event xóa cache | Không quên xóa, nhiều service cùng nghe | Thêm hạ tầng, có độ trễ |
7. Khi nào nên dùng / không nên dùng
- Nên cache: đọc nhiều ghi ít, chấp nhận stale vài giây/phút (product detail, category, config).
- Không nên: số dư, tồn kho cần chính xác tuyệt đối khi ghi; dữ liệu mỗi user chỉ đọc một lần (hit rate thấp).
Nếu product thay đổi trước khi TTL hết thì sao? Mình update DB rồi xóa cache là xong, đúng không?
Đúng cho phần lớn trường hợp. Nhưng vẫn còn một khe nhỏ:
Request đọc (R) Request ghi (W)
cache miss
SELECT price → 1000 (bản cũ)
UPDATE price = 900, commit
DEL product:100
SET product:100 = 1000 ← cache giữ bản cũ tới hết TTL
Xác suất thấp (R phải đọc DB trước W và set cache sau W), nhưng có. Các cách giảm:
- Luôn có TTL: stale tối đa cũng chỉ bằng TTL.
- Delayed double delete: DEL, đợi vài trăm ms, DEL lần nữa (gửi qua message delay hoặc scheduler).
- Xóa cache sau khi commit (
@TransactionalEventListener(phase = AFTER_COMMIT)), không xóa trong transaction. - Dữ liệu cần chính xác tuyệt đối (giá lúc đặt hàng) thì đọc DB khi checkout, cache chỉ để hiển thị.
Cache và DB là hai hệ thống, không có transaction chung, nên chỉ đạt eventual consistency.
Lúc 12:00 flash sale, key product:100 vừa hết hạn đúng lúc 50.000 request vào. sync = true có đủ không?
Chưa đủ. sync = true chỉ khóa trong một JVM. 3 instance thì vẫn có 3 query, chấp nhận được. Nhưng 30 pod thì 30 query cùng lúc, và mỗi query nặng. Thêm các lớp:
- Không để key nóng hết hạn: sản phẩm flash sale được warm-up trước 11:55 và không đặt TTL ngắn; job refresh định kỳ.
- Mutex phân tán khi miss:
Boolean got = redis.opsForValue() .setIfAbsent("lock:product:100", token, Duration.ofSeconds(3)); if (Boolean.TRUE.equals(got)) { // load DB, set cache, rồi xóa lock (compare-and-delete, bài 7.5) } else { Thread.sleep(50); // hoặc trả bản stale nếu có // đọc lại cache } - Local cache 1–5 giây (Caffeine) trước Redis để giảm hot key.
Phỏng vấn hỏi về cache strategy, stampede, penetration, avalanche thì trả lời sao?
Q1. Em implement cache thế nào?
🇻🇳 Tiếng Việt:
Em dùng cache-aside: đọc Redis trước, miss thì query DB rồi set cache với TTL. Khi update thì ghi DB, commit xong mới xóa cache để request sau load bản mới. Trong Spring em dùng
@Cacheablevà@CacheEvictvới RedisCacheManager, serializer JSON, TTL theo từng cache.
🇬🇧 English:
I use cache-aside: read from Redis first, and on a miss query the database and set the cache with a TTL. On update I write to the database and, after commit, delete the cache entry so the next request loads fresh data. In Spring I use
@Cacheableand@CacheEvictwith a RedisCacheManager, a JSON serializer and per-cache TTLs.
Q2. Stampede, penetration, avalanche khác nhau thế nào?
🇻🇳 Tiếng Việt:
Stampede là một key nóng hết hạn, nhiều request cùng đi DB; xử lý bằng mutex hoặc refresh sớm. Penetration là request key không tồn tại nên luôn miss; xử lý bằng cache null TTL ngắn hoặc Bloom filter. Avalanche là nhiều key hết hạn cùng lúc hoặc Redis chết; xử lý bằng TTL cộng jitter, Redis HA và bảo vệ DB bằng rate limit.
🇬🇧 English:
A stampede is when one hot key expires and many requests hit the database at once; I handle it with a mutex or early refresh. Penetration is requests for keys that do not exist, so they always miss; I cache nulls with a short TTL or use a Bloom filter. An avalanche is when many keys expire together or Redis goes down; I add random jitter to TTLs, run Redis with high availability and protect the database with rate limiting.
Q3. Cache và DB có nhất quán không?
🇻🇳 Tiếng Việt:
Chỉ eventual consistency, vì không có transaction chung. Em xóa cache thay vì update cache, xóa sau commit, luôn có TTL làm giới hạn stale. Dữ liệu cần chính xác như giá lúc thanh toán thì đọc DB.
🇬🇧 English:
Only eventual consistency, because there is no shared transaction. I delete rather than update the cache, do it after commit, and always keep a TTL as an upper bound on staleness. For data that must be exact, such as the price at checkout, I read from the database.
Cách nhớ ngắn gọn
- Cache-aside: đọc cache → miss → DB → set TTL; ghi DB → commit → DEL.
- Stampede = 1 key nóng; Penetration = key không tồn tại; Avalanche = nhiều key / Redis chết.
- Mutex · null cache / Bloom · TTL jitter + HA.
- Cache chỉ eventual consistency; TTL là lưới an toàn.
Tự kiểm tra
Vì sao nên DEL cache thay vì SET giá trị mới sau khi update DB?
Hai request ghi song song có thể set cache theo thứ tự ngược, để lại bản cũ. DEL thì request đọc sau sẽ load bản mới từ DB.
sync = true trong @Cacheable bảo vệ tới đâu?
Chỉ trong một JVM. Nhiều instance cần mutex phân tán hoặc warm-up.
Tại sao cộng jitter vào TTL?
Tránh nhiều key được set cùng lúc hết hạn cùng lúc (avalanche).
Cache penetration xử lý thế nào?
Cache giá trị null TTL ngắn, Bloom filter, validate input.
Write-behind có rủi ro gì?
Ghi cache trước, flush DB sau: Redis chết trước khi flush là mất dữ liệu.