Redis 与数据库双写一致性为什么没有银弹?
数据库是权威数据,Redis 是副本。一次业务更新要跨越两个独立系统,在没有分布式事务时总会存在失败和并发窗口。所谓“最佳方案”只能在一致性、可用性、复杂度和成本之间取舍。
1. 先淘汰错误直觉:先更新数据库,再更新缓存
两个并发写可能乱序:
请求 A 更新数据库为 1(暂停)
请求 B 更新数据库为 2 → 更新缓存为 2
请求 A 恢复 → 更新缓存为 1
数据库是 2,缓存却回退为 1。更新缓存还会为无人读取的数据做无效工作,并需要精确复制数据库事务后的最终值。
2. Cache Aside:写库后删缓存
最常见模式:
@Transactional
public void updateProduct(Product product) {
repository.update(product);
// 注意:这里若事务尚未提交,删除时机仍需设计
}
// 提交成功后删除 product:<id>
读请求缓存 miss 时查库并回填。删除比更新更稳健,因为下一次读取会从权威源重建;但仍有窗口:数据库提交成功、删除 Redis 失败,旧缓存会继续存在。
因此删除必须可重试,且重试信息不能只存在当前进程内存。
3. 读写并发仍可能产生旧值回填
读请求 R:缓存 miss,读到数据库旧值 1(暂停)
写请求 W:数据库改为 2,删除缓存
读请求 R:恢复,把旧值 1 写入缓存
这个窗口通常较窄,却真实存在。缓解手段:
- 缩短缓存 TTL,限制不一致时间;
- 延迟一小段时间再次删除(延迟双删);
- 缓存值携带数据库版本,拒绝旧版本覆盖新版本;
- 对极关键读直接访问权威库或使用一致性更强的架构。
延迟双删不是数学证明。延迟多久取决于读路径耗时,进程崩溃也可能让第二次删除丢失,必须通过可靠任务执行。
4. 事务提交之后再失效
若在数据库事务提交前删除缓存,其他线程可能立即 miss、读到尚未提交的旧数据并回填。Spring 中可注册事务提交后的回调,或使用事务事件:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductChanged(ProductChanged event) {
cache.delete(event.key());
}
但应用在提交后、发送事件前崩溃仍会丢失操作。进程内 after-commit 只解决时序,不解决可靠投递。
5. Outbox:把“需要失效”写入同一事务
BEGIN;
UPDATE product SET price = ?, version = version + 1 WHERE id = ?;
INSERT INTO outbox(event_id, aggregate_id, type, payload)
VALUES (?, ?, 'PRODUCT_CHANGED', ?);
COMMIT;
后台发布器读取 outbox,删除缓存或发送消息,成功后标记完成。数据库更新和事件记录在同一事务,避免提交成功却完全丢失失效意图。发布可能重复,所以删除操作和消费者必须幂等。
另一种方式是订阅数据库变更日志(CDC/binlog)并异步失效缓存。它减少业务侵入,但增加基础设施、延迟和事件顺序治理。
6. TTL 是最后保险,不是主要协议
所有缓存设置有限 TTL,可以让偶发失效失败最终自愈。但 TTL 太长会延长脏读,太短则降低命中率并增加数据库压力。应根据业务可接受陈旧时间定义,而不是全站统一 30 分钟。
金融余额、权限撤销等数据可能无法接受缓存旧值,应该改变读取架构,不能仅把 TTL 调成一秒后宣称一致。
7. 监控与验证
- 缓存删除失败次数和重试队列积压;
- outbox 最老未处理事件年龄;
- 抽样比较缓存版本与数据库版本;
- 缓存命中率、回源率和重建延迟;
- 故障注入:Redis 超时、应用在提交后崩溃、消息重复与乱序;
- 修复工具:按 ID 或版本批量失效,而非线上
FLUSHDB。
8. 检查清单
- 是否明确数据库才是权威源?
- 写路径是否在提交成功后删除缓存?
- 删除失败是否有持久化重试?
- 是否处理旧读回填,可否使用版本号?
- 所有缓存是否有符合业务的 TTL?
- 极关键数据是否应该绕开普通 Cache Aside?
缓存一致性没有银弹,因为跨系统原子性没有凭空出现。多数业务可采用“提交后删除 + 可靠重试/Outbox + TTL 兜底 + 版本保护”,并明确允许多长时间的最终一致。