Redis 缓存:TTL、Eviction 和 Cache Stampede

1. 概述

Redis 是缓存数据的常见选择,可以减轻数据库的负载并加快响应时间。但使用 Redis 不只是简单的 set 和 get — 你需要理解 TTL (Time To Live) 如何工作、Redis 在内存不足时如何删除 key (eviction),最重要的是:如何避免 cache stampede — 这是一种情况,当一个热 key 过期时,数百个请求同时涌入数据库。

本文从基本 TTL 配置、Redis 的 eviction policy,到防止 stampede 的实战技术:mutex lock、logical expiration 和 request coalescing。读完这篇文章,你将知道如何设计一个能够处理高负载且不会击垮后端数据库的缓存层。

2. 使用 Spring Boot 设置 Redis

首先,添加依赖:

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

配置连接:

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

RedisTemplate Bean 用于对 key/value 执行操作,支持自定义数据类型:

@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 将对象存储为 JSON 格式并包含类信息,这样在读取时可以正确反序列化 — 比 JdkSerializationRedisSerializer 更方便,因为后者难以用 redis-cli 读取。

3. TTL — Time To Live

3.1. 写入 key 时设置 TTL

@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));
    }
}

set(key, product, Duration.ofMinutes(10)) 这一行写入带有 10 分钟 TTL 的 key。在这个时间之后,Redis 会自动删除该 key — 这称为被动过期 (passive expiration) 结合 主动过期 (active expiration)(Redis 每 100ms 随机扫描有 TTL 的 key 以提前清理,避免过期的 key 仍留在 RAM 中等待被读取)。

检查剩余的 TTL:

redis-cli TTL product:123
(integer) 542

值 -1 表示 key 存在但没有 TTL(永久存在)。值 -2 表示 key 不存在。

3.2. 固定 TTL vs 随机 TTL (jitter)

如果你同时缓存数千个 key 且使用相同的 TTL,它们会同时过期 — 导致一波密集的 ‘cache miss’。解决方案是添加 jitter:在 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));
}

每个 key 会在略微不同的时间点过期(600–660 秒),避免“雷鸣羊群”效应,即许多 key 同时过期。

注意: Jitter 不能解决单个热 key 的 cache stampede(例如主页、热销产品)。那个问题需要第 5 节中的技术。

4. Redis 内存不足时的 Eviction 策略

TTL 处理 key 随时间自动过期。但当 Redis 达到 maxmemory 限制时,它需要根据 eviction policy 删除一些 key,不管该 key 是否有 TTL。

在 redis.conf 中配置:

maxmemory 512mb
maxmemory-policy allkeys-lru

常见的 policy:

Policy 含义
noeviction 不删除任何内容,当写入更多数据时返回 OOM 错误。这是 Redis 的默认值。
allkeys-lru 删除最近最少使用的 key (Least Recently Used),适用于所有 key。
volatile-lru 同上,但仅删除有 TTL 的 key。
allkeys-lfu 删除访问频率最低的 key (Least Frequently Used)。
volatile-ttl 优先删除即将过期的 key(TTL 最短的)。
volatile-random 从有 TTL 的 key 中随机删除。

对于纯缓存用例,allkeys-lru 或 allkeys-lfu 是安全的选择,因为 Redis 会自动释放内存而不是返回错误。如果你同时使用 Redis 作为缓存和存储重要数据的地方(例如 session token),应该分离实例或使用 volatile-lru 仅删除已标记 TTL 的 key。

注意: noeviction(默认值)会导致应用在 Redis 内存满时收到 OOM command not allowed 错误。如果使用 Redis 作为缓存,始终设置适当的 maxmemory-policy,不要使用默认值。

检查当前配置:

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

5. 什么是 Cache Stampede

Cache stampede(也称为 dogpile effect)发生在一个热 key — 每秒被数百/数千个请求读取 — 突然过期时。所有请求几乎同时 miss 缓存,一起涌向数据库重建数据,导致数据库在几百毫秒内过载。

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // Key 刚刚过期 -> 成百上千的线程同时进入这里
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

对于热销产品 key,上面的代码在正常情况下完全合理,但在 TTL 恰好过期的时刻,productRepository.findById 可能会对同一个 id 被调用数百次。

5.1. 解决方案:分布式锁 (mutex)

只允许一个请求重建缓存,其他请求等待或使用旧值。

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 {
        // 没有获取到锁 -> 等待一段时间后尝试重新读取缓存
        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;
        }
    }
    // 等待超时后,降级直接调用 DB
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent 等同于 Redis 中的 SET lockKey 1 NX EX 5 命令 — 原子操作,只有一个线程能成功创建锁。没有获取到锁的线程将每 100ms 轮询一次缓存,而不是自己调用数据库。

5.2. 解决方案:逻辑过期(不使用真实 TTL)

与其让 Redis 自动删除 key,不如在 value 中存储逻辑过期时间,并让 key 永远不会真正消失。当检测到数据“逻辑过期”时,一个线程将后台重建数据,其他线程仍然立即返回旧数据(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)) {
            // 异步重建,立即为当前请求返回旧数据
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // 始终返回 data,尽管可能有点过期
}

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 长于逻辑 TTL 以避免重建期间 key 消失
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

关键点:Redis 上的物理 TTL(15 分钟)总是长于逻辑 TTL(10 分钟)。Key 永远不会被 Redis 突然删除 — 它只是被标记为“旧的”并在后台重建。请求永远不必等待数据库,即使在数据逻辑过期后也是如此。

6. 测试防 stampede 行为

编写测试以确认当多个线程同时 miss 缓存时,只有一次数据库调用:

<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, "机械键盘");
        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, "机械键盘");
    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); // 证明存在 stampede
}

第一个测试使用 getProductSafely(有锁)并期望 findById 即使 50 个线程并发运行也只被调用 1 次。第二个测试使用无锁版本来演示相反的情况 — 你会看到 findById 被调用多次,证明如果不处理就会发生真实的 stampede。

7. 常见错误

  • 忘记设置 TTL:key 永久存在,Redis 逐渐填满 RAM,只能完全依赖 eviction policy — 容易误删需要的 key。
  • 热 key 的 TTL 过短:增加缓存未命中频率,更容易导致 stampede。对于高流量 key,应该优先使用逻辑过期而不是短物理 TTL。
  • 锁没有 TTL:如果持有锁的实例中途崩溃,锁永久存在,其他线程永远等待。始终为锁设置 TTL(setIfAbsent 与 Duration)。
  • 对 Redis 缓存使用 noeviction:当 Redis 满时应用会抛出错误,而不是自动清理。只有在 Redis 用于存储不能丢失的数据时才合理。
  • 不一致的对象序列化:在没有处理版本控制的情况下更改类 Product 中的字段可能导致从旧缓存反序列化失败。应该在更改模式时添加缓存清理机制(更改 key 前缀)。

8. 结论

TTL 帮助缓存数据随时间自动刷新,但需要添加 jitter 以避免大量 key 同时过期。Eviction policy 决定 Redis 在内存不足时的处理方式 — 对于纯缓存选择 allkeys-lru/allkeys-lfu,避免默认的 noeviction。对于热 key,仅有 TTL 是不够的:你需要 mutex 锁或逻辑过期来确保只有一个线程重建缓存,其余的读取旧数据而不是一起涌向数据库。正确应用这三种技术,你的缓存层将能够处理比简单 set/get 更好得多的负载。

rediscachespring-bootperformancejava