缓存一致性窗口:数据库提交后副本如何分叉并收敛
先更新数据库再删缓存仍可能读到旧值,先删缓存再更新数据库也可能被并发回源重新写旧值。缓存一致性没有一句固定顺序就能消灭的竞态;需要把每个窗口、版本和补偿动作画出来。
Redis keyspace notification 基于 Pub/Sub,官方明确说明断线期间事件会丢失,且过期事件在实际删除时产生而非 TTL 理论归零时刻。因此它可做快速失效信号,不能单独承担可靠变更日志。
Cache Aside:多数业务的默认缓存模式
最常见、也最可控的模式是 Cache Aside。
读写路径可以合在一张图里看:
Cache Aside 的核心不是“先查缓存再查库”这么简单,而是读路径、写路径、空值、TTL 和删除失败补偿必须成套出现。少一块,线上就会变成穿透、旧值或回源风暴。
读路径:
先查 Redis。命中直接返回。未命中查数据库。
数据存在则写入缓存。数据不存在则写短 TTL 空值。
写路径:
先更新数据库。再删除缓存。删除失败进入补偿。
伪代码:
public OrderDTO getOrder(String orderNo) {
String key = "shop:prod:order:detail:" + orderNo + ":v1";
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if ("__NULL__".equals(cached)) {
return null;
}
return json.readValue(cached, OrderDTO.class);
}
OrderDTO order = orderRepository.findByOrderNo(orderNo);
if (order == null) {
redisTemplate.opsForValue().set(key, "__NULL__", Duration.ofMinutes(1));
return null;
}
redisTemplate.opsForValue().set(key, json.writeValueAsString(order), randomTtl());
return order;
}
public void updateOrderStatus(String orderNo, int status) {
orderRepository.updateStatus(orderNo, status);
redisTemplate.delete("shop:prod:order:detail:" + orderNo + ":v1");
}验证方式:
GET shop:prod:order:detail:ORD001:v1
TTL shop:prod:order:detail:ORD001:v1常见坑:
只写读路径,不写删除缓存。更新数据库后直接更新缓存,旧请求晚到时把缓存覆盖回旧值。删除缓存失败没有重试、消息或巡检补偿。
空值缓存没有区分真实空字符串和不存在标记。
生产建议:缓存写路径要和数据库写路径一起评审。凡是更新数据库但没有缓存失效动作的需求,都不能直接上线。
Spring Data Redis:不要把默认配置直接带进核心链路
很多 Spring Boot 项目会从 RedisTemplate 或 @Cacheable 开始接入 Redis。入口简单,但默认行为必须看清楚:
一个更稳的基础配置长这样:
@Bean
RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
RedisCacheWriter writer = RedisCacheWriter.nonLockingRedisCacheWriter(
connectionFactory,
BatchStrategies.scan(1000)
);
return RedisCacheManager.builder(writer)
.cacheDefaults(defaults)
.enableStatistics()
.build();
}验证方式:
SCAN 0 MATCH "orderCache::*" COUNT 100
TTL "orderCache::100001"
MEMORY USAGE "orderCache::100001"常见坑:
clear() 在大 keyspace 下触发大范围扫描或删除,业务高峰抖动。
生产建议:核心缓存不要只靠注解默认值。每个 cacheName 都要映射到一份缓存契约,写清 TTL、序列化、是否允许 null、清理策略和统计指标。
缓存一致性:没有银弹,只有边界和补偿
数据库和 Redis 是两个系统,不可能靠一条普通代码语句获得天然强一致。多数业务采用“先写数据库,再删除缓存”。
@Transactional
public void updateUserProfile(String userNo, UpdateProfileCommand command) {
userRepository.updateProfile(userNo, command);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete("shop:prod:user:profile:" + userNo + ":v2");
}
});
}这里要注意:删除缓存最好在事务提交后执行。如果事务回滚了,缓存却被删了,虽然通常还能回源修复,但会制造不必要的抖动。
常见一致性方案:
| 方案 | 适用场景 | 风险 |
|---|---|---|
| 先写库再删缓存 | 多数 Cache Aside 业务 | 删除失败会读旧值 |
| 延迟双删 | 并发读写窗口明显的场景 | 延迟时间难精确,不能滥用 |
| 版本化 key | 配置、规则、字典 | 旧 key 清理成本 |
| 消息补偿 | 更新频繁且可最终一致 | 消息丢失或重复要处理 |
| 定时巡检 | 低频重要数据 | 延迟修复,不适合强实时 |
| CDC 失效 | 数据变更多入口 | 链路复杂,归部署和数据平台治理 |
生产建议:一致性不要只说“允许最终一致”。要写清楚最长可接受旧值时间、触发失效的入口、删除失败怎么补偿、用户看到旧值怎么办。
用状态模型验证缓存窗口
两个 Java 17 模型不连接真实 Redis,也不复刻 Caffeine 内部结构。它们把本篇必须守住的状态、版本和容量不变量变成确定输出;真实客户端与 Redis 集成测试继续验证协议、超时、拓扑和服务器行为。
javac --release 17 -Xlint:all -Werror examples/backend-development/cache/cache-consistency/VersionedValueDemo.java examples/backend-development/cache/cache-consistency/InvalidationWindowDemo.java
java -cp examples/backend-development/cache/cache-consistency VersionedValueDemo
java -cp examples/backend-development/cache/cache-consistency InvalidationWindowDemocachedVersion=8 delayedVersion=7 acceptedVersion=8 staleOverwrite=false
phases=[db-commit:v9, cache-still:v8, invalidate, reload:v9] divergenceWindowMs=37 converged=true缓存正确性不是“命中返回值”,而是命中值仍在允许的陈旧窗口内、加载不会放大到权威存储、旧版本不能覆盖新版本、失败能够在预算内退化。模型输出任一项改变,都应触发对应集成用例,而不是只调整命中率告警。
