Cache với Redis: TTL, Eviction và Cache Stampede

1. Tổng quan

Redis là lựa chọn phổ biến để cache dữ liệu, giảm tải cho database và tăng tốc response time. Nhưng dùng Redis không đơn giản chỉ là set và get — bạn cần hiểu TTL (Time To Live) hoạt động thế nào, Redis xóa key theo cơ chế nào khi hết bộ nhớ (eviction), và quan trọng nhất: làm sao tránh được cache stampede — tình huống hàng trăm request cùng lúc đập vào database khi một key hot bị hết hạn.

Bài này đi từ cấu hình TTL cơ bản, các policy eviction của Redis, đến các kỹ thuật thực chiến để chống stampede: mutex lock, logical expiration và request coalescing. Đọc xong, bạn sẽ biết cách thiết kế cache layer chịu được tải cao mà không làm sập database phía sau.

2. Thiết lập Redis với Spring Boot

Trước tiên, thêm dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Cấu hình kết nối:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

Bean RedisTemplate dùng để thao tác key/value với kiểu dữ liệu tùy chỉnh:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

GenericJackson2JsonRedisSerializer lưu object dưới dạng JSON kèm thông tin class, giúp deserialize đúng kiểu khi đọc lại — tiện hơn JdkSerializationRedisSerializer vốn khó đọc bằng redis-cli.

3. TTL — Time To Live

3.1. Set TTL khi ghi key

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

Dòng set(key, product, Duration.ofMinutes(10)) ghi key kèm TTL 10 phút. Sau thời gian này, Redis tự xóa key — gọi là passive expiration kết hợp active expiration (Redis quét ngẫu nhiên các key có TTL mỗi 100ms để dọn sớm, tránh để key hết hạn vẫn nằm trong RAM chờ bị đọc tới).

Kiểm tra TTL còn lại:

redis-cli TTL product:123
(integer) 542

Giá trị -1 nghĩa là key tồn tại nhưng không có TTL (tồn tại vĩnh viễn). Giá trị -2 nghĩa là key không tồn tại.

3.2. TTL cố định vs TTL ngẫu nhiên (jitter)

Nếu bạn cache hàng nghìn key cùng lúc với cùng một TTL, chúng sẽ hết hạn đồng loạt — gây ra một đợt “cache miss” dồn dập. Giải pháp là thêm jitter: random thêm một khoảng nhỏ vào TTL.

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

Mỗi key sẽ hết hạn ở một thời điểm hơi lệch nhau (600–660 giây), tránh hiệu ứng “thundering herd” khi hàng loạt key cùng expire tại một thời điểm.

Lưu ý: Jitter không giải quyết cache stampede cho một key hot duy nhất (ví dụ trang chủ, sản phẩm bán chạy). Vấn đề đó cần kỹ thuật ở mục 5.

4. Eviction policy khi Redis hết bộ nhớ

TTL xử lý việc key tự hết hạn theo thời gian. Nhưng khi Redis đạt giới hạn maxmemory, nó cần xóa bớt key theo eviction policy, bất kể key đó có TTL hay không.

Cấu hình trong redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

Các policy phổ biến:

Policy Ý nghĩa
noeviction Không xóa gì, trả lỗi OOM khi ghi thêm. Mặc định của Redis.
allkeys-lru Xóa key ít được dùng gần đây nhất (Least Recently Used), áp dụng cho mọi key.
volatile-lru Giống trên nhưng chỉ xóa key có TTL.
allkeys-lfu Xóa key có tần suất truy cập thấp nhất (Least Frequently Used).
volatile-ttl Ưu tiên xóa key sắp hết hạn nhất (TTL ngắn nhất).
volatile-random Xóa ngẫu nhiên trong tập key có TTL.

Với use case cache thuần túy, allkeys-lru hoặc allkeys-lfu là lựa chọn an toàn vì Redis sẽ tự giải phóng bộ nhớ thay vì trả lỗi. Nếu bạn dùng Redis vừa làm cache vừa làm nơi lưu dữ liệu quan trọng không được mất (session token chẳng hạn), hãy tách instance hoặc dùng volatile-lru để chỉ xóa key đã đánh dấu TTL.

Lưu ý: noeviction (mặc định) sẽ khiến ứng dụng nhận lỗi OOM command not allowed khi Redis đầy bộ nhớ. Nếu dùng Redis làm cache, luôn set maxmemory-policy phù hợp, đừng để mặc định.

Kiểm tra cấu hình hiện tại:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. Cache Stampede là gì

Cache stampede (còn gọi là dogpile effect) xảy ra khi một key hot — được hàng trăm/nghìn request đọc mỗi giây — bất ngờ hết hạn. Tất cả request cùng miss cache gần như đồng thời, cùng đổ xô vào database để rebuild dữ liệu, khiến database bị quá tải trong vài trăm mili-giây.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // Key vừa hết hạn -> hàng trăm thread cùng chạy vào đây
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

Với key sản phẩm bán chạy, đoạn code trên hoàn toàn hợp lý lúc bình thường, nhưng tại đúng thời điểm TTL hết hạn, productRepository.findById có thể bị gọi hàng trăm lần cùng lúc cho cùng một id.

5.1. Giải pháp: Distributed lock (mutex)

Chỉ cho một request rebuild cache, các request khác chờ hoặc dùng giá trị cũ.

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // Không giành được lock -> đợi ngắn rồi thử đọc lại cache
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // Hết thời gian chờ, fallback gọi thẳng DB
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent tương đương lệnh SET lockKey 1 NX EX 5 trong Redis — atomic, chỉ một thread duy nhất tạo được lock thành công. Thread nào không giành được lock sẽ poll lại cache mỗi 100ms thay vì tự gọi database.

5.2. Giải pháp: Logical expiration (không dùng TTL thật)

Thay vì để Redis tự xóa key, bạn lưu kèm thời điểm hết hạn logic trong value, và không bao giờ để key thật sự biến mất. Khi phát hiện dữ liệu “quá hạn logic”, một thread sẽ rebuild nền (background), các thread khác vẫn trả về dữ liệu cũ ngay lập tức (stale-while-revalidate).

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // Rebuild bất đồng bộ, trả dữ liệu cũ ngay cho request hiện tại
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // luôn trả data, dù có thể hơi cũ
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // TTL vật lý dài hơn TTL logic để tránh key biến mất trong lúc rebuild
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

Điểm mấu chốt: TTL vật lý trên Redis (15 phút) luôn dài hơn TTL logic (10 phút). Key không bao giờ bị Redis xóa đột ngột — nó chỉ được đánh dấu “cũ” và rebuild nền. Request không bao giờ phải chờ database, kể cả ngay sau khi dữ liệu hết hạn logic.

6. Kiểm thử hành vi chống stampede

Viết test xác nhận chỉ có một lời gọi database khi nhiều thread cùng miss cache:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void chiMotThreadDuocPhepGoiDatabaseKhiCacheMiss() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "Ban phim co");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void khongCoLockThiDatabaseBiGoiNhieuLan() {
    Long productId = 99L;
    Product product = new Product(productId, "Ban phim co");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // chứng minh có stampede
}

Test đầu tiên dùng getProductSafely (có lock) kỳ vọng findById chỉ được gọi đúng 1 lần dù 50 thread cùng chạy. Test thứ hai dùng phiên bản không lock để minh họa ngược lại — bạn sẽ thấy findById bị gọi nhiều lần, chứng minh stampede thật sự xảy ra nếu không xử lý.

7. Một số lỗi thường gặp

  • Quên set TTL: key tồn tại mãi mãi, Redis dần đầy RAM, buộc phải dựa hoàn toàn vào eviction policy — dễ xóa nhầm key đang cần.
  • TTL quá ngắn cho key hot: tăng tần suất cache miss, dễ gây stampede hơn. Với key có traffic cao, nên ưu tiên logical expiration thay vì TTL vật lý ngắn.
  • Lock không có TTL: nếu instance giữ lock bị crash giữa chừng, lock tồn tại vĩnh viễn, các thread khác chờ mãi. Luôn set TTL cho lock (setIfAbsent với Duration).
  • Dùng noeviction cho Redis cache: ứng dụng sẽ ném lỗi khi Redis đầy thay vì tự dọn dẹp. Chỉ hợp lý nếu Redis dùng để lưu dữ liệu không được mất.
  • Serialize object không nhất quán: đổi field trong class Product mà không xử lý versioning có thể khiến deserialize từ cache cũ bị lỗi. Nên thêm cơ chế cache-busting (đổi prefix key) khi đổi schema.

8. Kết luận

TTL giúp dữ liệu cache tự làm mới theo thời gian, nhưng cần thêm jitter để tránh hàng loạt key cùng hết hạn một lúc. Eviction policy quyết định Redis xử lý thế nào khi hết bộ nhớ — chọn allkeys-lru/allkeys-lfu cho cache thuần, tránh noeviction mặc định. Với key hot, TTL đơn thuần không đủ: bạn cần mutex lock hoặc logical expiration để đảm bảo chỉ một thread rebuild cache, phần còn lại đọc dữ liệu cũ thay vì cùng dồn vào database. Áp dụng đúng ba kỹ thuật này, cache layer của bạn sẽ chịu tải tốt hơn nhiều so với một set/get đơn giản.

rediscachespring-bootperformancejava