Chiến lược cache: cache-aside, invalidation và các sự cố kinh điển

4 câu hỏi~30 phútTừ khóa: cache-aside, @Cacheable, write-through, write-behind, TTL jitter, stampede, penetration, bloom filter, avalanche, hot key, consistency

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 đề

3. Giải pháp: các pattern cache

PatternĐọcGhi
Cache-aside (lazy loading)App đọc cache; miss thì đọc DB rồi set cacheApp ghi DB rồi xóa cache
Read-throughCache library tự load từ DB khi missNhư cache-aside
Write-throughĐọc cacheGhi cache và DB đồng bộ trong cùng request
Write-behind (write-back)Đọc cacheGhi 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 raCách xử lý
Cache stampede (breakdown)Một key nóng hết hạn, 1000 request cùng miss, cùng query DBsync=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 penetrationRequest id không tồn tại (/products/-1), luôn miss, luôn đi DBCache giá trị null TTL ngắn; Bloom filter chặn id chắc chắn không có; validate input
Cache avalancheNhiều key hết hạn cùng lúc, hoặc Redis chết, DB nhận toàn bộ tảiTTL + jitter; Redis HA (bài 7.4); circuit breaker / rate limit trước DB
Hot keyMột key (sản phẩm flash sale) nhận quá nhiều request, một node Redis quá tảiLocal 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ƯuNhược
Chỉ TTLĐơn giảnStale tối đa bằng TTL
Update DB rồi DEL cachePhổ biến, đơn giản, an toàn hơn update cacheVẫn có khe nhỏ race (xem câu sau)
Update DB rồi SET cacheKhông có missHai request ghi song song có thể để cache giữ bản cũ hơn
CDC (Debezium) / event xóa cacheKhông quên xóa, nhiều service cùng ngheThêm hạ tầng, có độ trễ

7. Khi nào nên dùng / không nên dùng

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:

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:

  1. 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ỳ.
  2. 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
    }
  3. 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 @Cacheable và @CacheEvict vớ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 @Cacheable and @CacheEvict with 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


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.