Redis 缓存一致性方案
以 Cache-Aside 为基线,梳理数据库与缓存的写入顺序、并发旧值回填、热点失效和一致性边界。
缓存是可丢弃的副本,不是第二个权威数据库。Redis 缓存与关系数据库通常没有共同事务,因此“写库成功、写缓存失败”“缓存刚失效又被旧读请求填回”等场景无法靠一条 DEL 命令消失。设计前先定义能接受的旧值窗口、读写比例、数据影响范围和故障行为,再挑模式。
Cache-Aside 的基线流程
Cache-Aside(旁路缓存)由应用显式查询和失效缓存:读时先查 Redis,未命中再回源数据库并回填;写时先更新数据库,提交成功后删除缓存。TTL 是兜底,让错误或漏掉的缓存副本最终过期。
sequenceDiagram
participant U as 调用方
participant S as Spring 服务
participant R as Redis
participant D as 主数据库
U->>S: 查询 product:42
S->>R: GET product:42
R-->>S: miss
S->>D: SELECT product 42
D-->>S: Product(v7)
S->>R: SET product:42 v7 EX 300
S-->>U: Product(v7)
U->>S: 更新 product 为 v8
S->>D: UPDATE + COMMIT
S->>R: DEL product:42
S-->>U: 更新成功
写后删除通常比写后覆盖缓存简单:若同时有多个服务写同一实体,直接 SET 可能让旧写覆盖新值;删除后由下一次读重新加载数据库中的当前值。但“数据库提交”和“删除 Redis”仍然不是原子提交,这是一致性边界而非实现细节。
Spring Cache 注解示例
@Service
class ProductQueryService {
private final ProductRepository repository;
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public ProductView findById(long id) {
return repository.findViewById(id).orElse(null);
}
}
@Service
class ProductCommandService {
private final ProductRepository repository;
private final ApplicationEventPublisher events;
@Transactional
public void rename(long id, String name) {
Product product = repository.findByIdForUpdate(id).orElseThrow();
product.rename(name);
repository.save(product);
events.publishEvent(new ProductChanged(id));
}
}
@Component
class ProductCacheInvalidator {
private final CacheManager cacheManager;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void invalidate(ProductChanged event) {
Cache cache = cacheManager.getCache("product");
if (cache != null) cache.evict(event.id());
}
}
这个例子表达“数据库提交后再失效”,但 Spring 进程若在 commit 后、listener 执行前崩溃,事件可能丢失,旧缓存要等 TTL 到期。若这个窗口不可接受,把 ProductChanged 写进同一数据库事务的 outbox,再由后台消费者重试删除缓存;消费者要幂等,TTL 仍保留作为兜底。
Spring Cache 使用代理拦截方法调用:同一个 bean 内部的 this.findById() 自调用通常不会经过缓存代理。缓存方法应放在独立 bean 或通过合适的代理边界调用。还要明确序列化格式、key 前缀、TTL、null 语义和 schema 版本。
并发旧值回填:删除缓存不等于解决竞态
考虑如下交错:读请求 A 在数据库读到 v7 后暂停;写请求 B 把数据库提交为 v8 并删除 key;A 随后把手里的 v7 回填到 Redis。缓存又变旧了。
sequenceDiagram
participant A as 读请求 A
participant B as 写请求 B
participant D as 数据库
participant R as Redis
A->>R: GET key -> miss
A->>D: SELECT -> v7
B->>D: UPDATE -> v8 / COMMIT
B->>R: DEL key
A->>R: SET key = v7
Note over R: 旧版本再次可读,直到 TTL 或下一次失效
大多数业务接受一个由 TTL 和失效可靠性共同限定的短暂最终一致窗口。对需要更强读后写一致性的实体,可以比较数据库版本号、使用带版本的缓存写入脚本,或在请求中绕过缓存读主库;这些方案都需要定义原子比较、缓存淘汰和版本迁移,不能只在 Java 里“先 GET 版本再 SET”,因为两个 Redis 命令之间仍有竞态。
热点过期、穿透和雪崩
- 缓存击穿/热点 key 同时过期:同一热门 key 的大量 miss 同时回源。可以做请求合并(single-flight),或短租约锁让一个请求加载、其他请求短暂等待/返回旧值。
- 缓存穿透:不存在的 ID 被反复请求。可对“确实不存在”缓存短 TTL 的 sentinel,或做 Bloom filter;注意创建新记录后要同步清除负缓存。
- 缓存雪崩:大量 key 同时过期或 Redis 故障。TTL 加随机抖动,分批预热;设置回源并发上限、超时、熔断和降级策略。
用 Redis 锁做热点保护时,获取锁应原子执行 SET lockKey token NX PX leaseMillis,释放锁必须比较 token 后再删除,防止过期锁的旧持有者删掉新持有者的锁。锁的租期需覆盖正常回源时间,等待请求也必须有上限;锁不能代替数据版本校验。
TTL、Key 与缓存粒度
product:v2:42 # 实体视图,约 5 分钟 + 随机抖动
product:missing:404 # 负缓存,较短 TTL
catalog:category:books # 列表缓存,变更时按类别失效
key 需包含业务命名空间和必要的 schema/version,避免多应用共享 Redis 时冲突。缓存对象宜面向查询场景,不要无控制地把整张 ORM 聚合对象图序列化进 Redis。列表缓存的失效更复杂:一个商品变更可能影响多个筛选、排序和分页结果;可以选短 TTL、按标签/版本分组或主动失效索引,但要把复杂度和实际命中收益一起评估。
Redis 故障时也要明确行为:对非关键读可以回源,但必须有并发上限保护数据库;对强依赖缓存的昂贵查询可快速失败或返回受控降级。不要让缓存不可用自动触发无界回源。
上线时观察什么
按 cache name 和 key 类型看命中率、miss 数、平均/尾延迟、淘汰、过期、连接池等待、回源并发和数据库 QPS。监控 outbox backlog、重试次数和失效延迟。只看 Redis CPU 低并不能证明缓存有效;如果 miss 激增却没看到数据库保护,短时故障就可能升级成数据库雪崩。
常见踩坑
- 先删缓存再写数据库:若数据库回滚或更新失败,后续读会将旧库值重新填入缓存。一般先提交数据库,再删缓存,并配置 TTL 兜底。
- 写数据库后只发一个内存事件:进程崩溃可能丢失失效。对重要数据使用 outbox/可靠事件重试。
- 认为
@CacheEvict和数据库事务天然原子:Redis 与关系库通常不共享事务;明确提交时机和失败补偿。 - 缓存 null 或空列表过久:新创建的数据会被负缓存遮住。对不存在结果用短 TTL,并在创建操作中失效。
- 全站统一 TTL:不同数据的陈旧容忍度不同;TTL 应按业务语义设定,并加入随机抖动。
- 自调用绕过 Spring 代理:缓存注解可能没有执行。用独立服务 bean 并写集成测试确认行为。
- Redis 故障时全量回源:缓存本来保护数据库,失败路径必须有限流、超时与降级。