缓存: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),应用程序自己管理缓存:
- 读取:先查询缓存。如果缺失,则从数据库读取,将结果写入缓存后返回。
- 写入:先更新数据库,然后删除缓存中的条目。下次读取时会重新加载新数据。
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,并始终有测试证明缓存确实减少了数据库访问次数。