4. 缓存一致性难题:最终一致性、强一致性、分布式事务与缓存同步的权衡
在数据库与缓存构成的混合存储架构中,一致性是最核心也最棘手的权衡点。强一致性保证数据实时准确,但会牺牲可用性与吞吐;最终一致性换取高性能,却可能让用户读到过期数据。本章从工程视角拆解四种主流一致性模型及其对应的缓存同步策略,并给出选型决策框架。
4.1 一致性模型对比
| 模型 | 核心承诺 | 典型延迟窗口 | 适用场景 |
|---|---|---|---|
| 强一致性 | 写入后立即对所有读可见 | 0(同步阻塞) | 金融交易、库存扣减、分布式锁 |
| 最终一致性 | 无更新后,副本最终收敛到一致 | 毫秒~秒级 | 用户资料、内容Feed、商品详情 |
| 读写一致性 | 写后读自己的写一定可见 | 会话级别 | 用户个人中心、订单状态 |
| 单调读一致性 | 不会读到比之前更旧的数据 | 无回退 | 分页列表、消息时间线 |
工程铁律: 强一致性 ≠ 必须使用分布式事务。通过"缓存穿透写 + 数据库主键版本号"可以在单行场景下实现近似强一致,而无需引入XA或TCC。
4.2 缓存同步的三种典型策略
4.2.1 同步双写(强一致倾向)
应用在同一个事务内同时更新数据库和缓存。若缓存写入失败,事务回滚。该策略能保证数据绝对一致,但会显著增加写入延迟,且缓存故障直接导致写入失败。
// 伪代码:同步双写
@Transactional
public void updateUserBalance(Long userId, BigDecimal amount) {
// 1. 更新数据库
userBalanceDao.update(userId, amount);
// 2. 同步更新缓存(若失败则抛异常触发回滚)
cacheClient.set("balance:" + userId, amount.toString());
}
4.2.2 异步补偿(最终一致倾向)
先更新数据库,再通过消息队列异步删除或更新缓存。允许短暂的不一致窗口,但吞吐量高、对缓存故障容忍度强。
// 伪代码:异步补偿
public void updateProductStock(Long productId, Integer delta) {
// 1. 更新数据库
stockDao.updateStock(productId, delta);
// 2. 发送缓存失效消息
mqProducer.send(new CacheInvalidateMsg(productId, "stock"));
}
// 消费者
@RabbitListener(queues = "cache.invalidate")
public void handleInvalidate(CacheInvalidateMsg msg) {
cacheClient.del("stock:" + msg.getProductId());
}
4.2.3 基于Binlog的监听同步
通过Canal等组件监听数据库Binlog变更,异步同步到缓存。对业务代码无侵入,适合异构系统或遗留系统改造。
推荐实践: 对于大多数互联网业务,异步补偿 + 定期全量对账是性价比最高的方案。同步双写仅用于资金、库存等核心链路。
4.3 缓存一致性架构图
下图展示了从强一致到最终一致的四种同步策略及其适用边界:
4.4 分布式事务与缓存同步的冲突
当引入分布式事务(如TCC、Saga)时,缓存同步面临新的挑战:
- TCC 的 Try-Confirm 阶段:Try阶段预留资源时,缓存应标记为"中间态";Confirm后才能真正更新缓存。若直接更新,Cancel时缓存回滚困难。
- Saga 的补偿操作:补偿操作需要反向更新缓存,但缓存中可能已混入其他并发写入,导致补偿后的缓存值与数据库不一致。
- 本地消息表 + 缓存:事务消息投递成功后,消费者更新缓存。若消费者重复消费,需保证缓存操作的幂等性。
避坑提示: 不要试图在分布式事务内部同步操作缓存。事务的ACID与缓存的最终一致性本质冲突。正确做法是:事务只负责数据库状态,缓存通过异步事件或Binlog独立同步。
4.5 选型决策矩阵
| 业务场景 | 推荐策略 | 一致性等级 | 容忍延迟 |
|---|---|---|---|
| 账户余额、库存扣减 | 同步双写 + 本地事务 | 强一致 | < 10ms |
| 订单状态流转 | 异步补偿 + 会话一致性 | 读写一致 | < 500ms |
| 商品详情、用户资料 | Cache Aside + 定期对账 | 最终一致 | 秒级 |
| 搜索索引、推荐特征 | Binlog监听 + 批量刷新 | 最终一致 | 分钟级 |
4.6 一致性保障的工程手段
- 版本号乐观锁:缓存中存储数据版本号,更新时对比版本,防止旧数据覆盖新数据。
- 延迟双删:先删除缓存,再更新数据库,延迟几百毫秒后再删一次缓存,解决并发读请求导致的脏缓存。
- 缓存失效时间兜底:所有缓存设置合理的TTL,即使同步失败,也能在TTL过期后自动恢复一致。
- 全量对账任务:定期扫描数据库与缓存,发现不一致后自动修复,作为最终防线。
核心结论: 缓存一致性没有银弹。强一致用同步双写 + 事务,最终一致用异步补偿 + 对账。关键在于识别业务对一致性的真实需求,避免为"理论上的强一致"付出过高的性能代价。