缓存:cache-aside、write-through 与 TTL

1. 概述

缓存可以减轻数据库的负担,缩短响应时间。但代价是你必须接受数据可能过期。本文介绍两个常见的缓存读写策略:cache-aside 和 write-through。同时解释如何使用 TTL(time-to-live) 来限制数据的过期程度。

读完本文,你将能够用纯 Java 实现这两个策略,学会使用 Spring Cache 的注解,配置 Caffeine 和 Redis 的 TTL,并避免一些常见的陷阱。

2. 基本概念

在深入代码之前,让我们统一几个术语:

  • Cache hit: 缓存中有数据,立即返回。
  • Cache miss: 缓存中没有数据,必须从原始源读取(数据库、其他服务)。
  • Stale data: 缓存中的数据与数据库中的数据不同。
  • TTL: 条目的存活时间。TTL 过期后,条目被视为无效。

所有缓存策略都围绕一个问题展开:当数据变化时,谁负责更新缓存,什么时候更新?

本文始终使用一个简单的域模型:

public record Product(long id, String name, BigDecimal price) {}

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. 手写一个带 TTL 的缓存

为了看清机制,我们手写一个简单的 TTL 缓存。时间通过 Supplier<Instant> 获取,这样测试可以“快进”时间。

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

这个类的工作方式:

  • put 保存值及其过期时间 expiresAt = now + ttl。
  • get 检查过期时间。如果条目已过期,它将被删除,函数返回 Optional.empty(),即一个 cache miss。
  • store.remove(key, entry) 只删除刚刚读取的条目。这样,如果另一个线程刚好 put 了新值,新值就不会被误删。

这个类足以演示原理。在生产环境中,你应该使用 Caffeine 或 Redis(第 6 节)。

4. Cache-aside

4.1. 原理

使用 cache-aside(也称为 lazy loading),应用程序自己管理缓存:

  1. 读取:先查询缓存。如果缺失,则从数据库读取,将结果写入缓存后返回。
  2. 写入:先更新数据库,然后删除缓存中的条目。下次读取时会重新加载新数据。
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // 缓存命中
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // 加载到缓存
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // 数据库写入后删除
        return saved;
    }
}

解释:

  • findById 只在 cache miss 时调用 repository。如果产品不存在,我们不缓存任何东西。
  • updatePrice 先写数据库,然后再 evict。这个顺序很重要,第 8 节将解释原因。
  • 我们选择删除条目而不是覆盖值。这样更简单,因为缓存只从一个地方加载——读取路径。

4.2. 何时使用

Cache-aside 适合于以下情况:

  • 读取远多于写入,例如产品目录或用户档案。
  • 你只想缓存被实际访问的数据。
  • 当缓存宕机时系统仍能运行。此时所有请求直接访问数据库:更慢但不出错。

缺点:每次缺失后的第一个请求总是很慢。另外,如果有其他进程直接写入数据库而不删除缓存,数据将保持陈旧直到 TTL 过期。

5. Write-through

5.1. 原理

使用 write-through,所有写操作都经过缓存层。这一层同步写入数据库,然后用新值更新缓存。当写命令返回时,缓存和数据库已经一致。

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. 写数据库(同步)
        cache.put(saved.id(), saved);             // 2. 更新缓存
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

与 cache-aside 的区别:

  • save 覆盖缓存而不是删除。之后立即的读取会得到 cache hit。
  • 我们只在 repository.save 成功时写入缓存。如果数据库抛出异常,缓存不会被修改,所以不会出现缓存包含未保存数据的情况。
  • findById 仍然需要 fallback 分支,因为条目可能已过期或从未通过 save 写入。

5.2. 何时使用

Write-through 适合刚写完的数据立即被读取的情况,例如购物车或用户设置。代价是:

  • 每次写入都稍微慢一点,因为要写两个地方。
  • 缓存可能包含很多没人读的数据。你应该结合 TTL 和大小限制。

注意:不要将 write-through 与 write-behind(写回)混淆。Write-behind 先写缓存,然后异步写数据库。写入更快,但如果缓存节点在刷新前宕机,你会丢失数据。只有在接受这种风险时才使用 write-behind。

6. 使用 Spring Cache

手写有助于理解机制,但在实际项目中你应该使用 Spring Cache 配合 Caffeine(本地缓存)或 Redis(分布式缓存)。添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

使用 @EnableCaching 启用缓存:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. 使用 @Cacheable 和 @CacheEvict 的 Cache-aside

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheable 是声明式 cache-aside。Spring 先检查缓存,缺失时调用方法然后保存结果。
  • 对于 Optional 返回类型,Spring 缓存内部值。unless = "#result == null" 阻止缓存未找到的情况。
  • @CacheEvict 默认在方法成功返回之后运行。这正是“先写数据库再删除缓存”的顺序。

6.2. 使用 @CachePut 的 Write-through

@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);
}

@CachePut 总是运行方法,然后将返回值写入缓存。这等同于第 5 节中的 WriteThroughProductStore.save。

注意:注解缓存通过代理工作。如果你从同一类的另一个方法调用 this.findById(...),缓存会被完全绕过。应该通过另一个 bean 来调用。

7. 配置 TTL

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m:条目在最后一次写入后 10 分钟过期。
  • maximumSize=10000:超过限制时,Caffeine 会驱逐很少使用的条目。始终设置此限制以防止缓存耗尽堆。

7.2. Redis

添加 spring-boot-starter-data-redis,然后配置:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis 在 TTL 过期时自动删除 key。此缓存在应用程序的多个实例之间共享。

7.3. 如何选择 TTL?

没有通用的数字。问问自己:数据最多可以陈旧多久?

汇率、库存              : 几秒 - 1 分钟
产品价格、目录          : 5 - 30 分钟
配置、省份列表          : 几小时 - 1 天

较短的 TTL 让数据更新鲜但命中率更低。较长的 TTL 则相反。即使做了主动驱逐,你也应该设置 TTL 作为网络,以防遗漏的驱逐。

7.4. 添加 jitter 以避免同时过期

假设你同时加载 10,000 个产品,TTL 为 10 分钟。它们会同时过期,数据库会突然收到大量请求。这个现象叫 cache stampede。解决方法是给 TTL 添加随机延迟:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

对于 Spring + Caffeine,你可以添加 @Cacheable(..., sync = true)。当多个线程同时缺失一个 key 时,只有一个线程被调用数据库,其他线程等待结果。

8. 编写测试

我们使用一个虚假的 repository 来计数数据库访问次数,以及一个可控制的时钟:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache:第二次读取不会访问数据库。
  • entryExpiresAfterTtl:在快进时钟超过 10 分钟后,缓存缺失,数据库再次被调用。
  • updateEvictsStaleValue:更新后,读取者看到的是新价格,而不是缓存中的旧价格。

9. 常见错误

先删除缓存再写数据库。 考虑这样的场景:线程 A 删除缓存。线程 B 读取,缺失,从数据库获取旧值然后写入缓存。之后线程 A 才写入新值到数据库。结果是缓存持有旧值直到 TTL 过期。应该先写数据库,后删除缓存。

事务未提交时缓存。 如果你在 @Transactional 方法中 @CachePut,然后事务回滚,缓存将包含数据库中不存在的数据。应该将缓存操作放在事务外,或使用 TransactionAwareCacheManagerProxy。

多实例上的本地缓存。 Caffeine 位于每个实例的 JVM 中。instance A 上的 @CacheEvict 无法删除 instance B 的缓存。当运行多个实例且需要一致性时,使用 Redis 或接受较短 TTL 的陈旧性。

可变缓存对象。 Caffeine 返回缓存中引用的确切对象。如果代码修改该对象,你也修改了缓存。使用 record 或不可变对象,如本文示例。

注意:当数据频繁变化且需要绝对一致性时不应该缓存,例如账户余额或结账时的空座位数。这些情况下,用适当的锁直接读数据库更安全。

10. 结论

本文比较了两个主要的缓存策略:

  • Cache-aside:应用程序在缺失时加载缓存,写入后删除缓存。这是一种简单且容错性好的方法,适合大多数读多的情况。
  • Write-through:所有写操作同步更新数据库和缓存,代价是写入后立即获得新鲜数据。

无论选择哪个策略,你仍然需要 TTL 作为网络,应该限制缓存大小,并应该添加 jitter 来避免 stampede。从 @Cacheable + Caffeine 开始。当应用程序扩展到多个实例时,切换到 Redis,并始终有测试证明缓存确实减少了数据库访问次数。

system-designcachingspring-bootcaffeineredis