4. 缓存一致性核心挑战:并发写冲突、数据源与缓存双写不一致、最终一致性与强一致性取舍
缓存一致性是分布式系统中最棘手的难题之一。当数据同时存在于缓存(Redis)与持久化存储(MySQL)中时,任何写操作都可能打破两者之间的同步状态。本章深入剖析三大核心挑战:并发写冲突、双写不一致以及一致性模型的选择,并给出可落地的治理策略。
核心观点:缓存一致性没有银弹。业务对一致性的容忍度决定了技术方案的选择。金融交易要求强一致性,而内容推荐系统可以接受最终一致性。
4.1 并发写冲突:当多个线程同时修改同一份数据
并发写冲突发生在多个客户端或服务实例同时对同一缓存键(Key)进行写操作时。典型场景:用户A和用户B几乎同时修改同一条订单的物流地址。
| 冲突类型 | 触发条件 | 典型后果 |
|---|---|---|
| 缓存后写覆盖 | 线程1写缓存,线程2写缓存,线程1写DB,线程2写DB | 缓存与DB最终状态不一致,数据丢失 |
| 缓存与DB交叉写 | 线程1写DB,线程2写缓存,线程1写缓存,线程2写DB | 缓存与DB永久错位,难以恢复 |
| 删除缓存与更新DB时序错乱 | 线程1删除缓存,线程2读缓存未命中并回填旧数据,线程1更新DB | 缓存中残留旧数据,DB已更新 |
解决并发写冲突的核心思路是串行化或版本控制:
- 分布式锁:对同一业务Key加写锁,确保同一时刻只有一个线程能执行写操作。
- 乐观锁(CAS):利用Redis的
WATCH命令或MySQL的版本号字段,写操作前校验数据版本。 - 消息队列串行化:将写请求放入有序队列,由单一消费者顺序处理。
实战建议:对于高并发写入场景,优先使用分布式锁 + 缓存删除策略。锁的粒度要细到业务主键,避免锁住整个表或整个缓存分区。
4.2 数据源与缓存双写不一致:四种经典模式
双写不一致是指应用程序同时更新缓存和数据库时,由于操作顺序、网络延迟或部分失败导致两者数据不同步。以下是四种经典模式及其治理策略:
| 模式 | 操作顺序 | 不一致风险 | 推荐治理方案 |
|---|---|---|---|
| 先写DB,后写缓存 | DB成功 → 缓存写入失败 | 缓存为旧数据,DB为新数据 | 重试机制 + 异步补偿 |
| 先写缓存,后写DB | 缓存成功 → DB写入失败 | 缓存为新数据,DB为旧数据(脏数据) | 事务回滚 + 缓存删除 |
| 先删缓存,后写DB | 缓存删除成功 → DB写入前有并发读 | 并发读回填旧数据到缓存 | 延迟双删(Double Delete) |
| 先写DB,后删缓存 | DB成功 → 缓存删除失败 | 缓存残留旧数据 | 异步删除 + 消息队列保障 |
避坑指南:“先删缓存,后写DB”模式在并发读场景下几乎必然出现不一致。单纯依赖“缓存过期”来修复,在过期窗口期内用户会读到脏数据。务必配合延迟双删或版本号校验。
4.3 最终一致性与强一致性取舍
一致性模型的选择直接决定了系统架构的复杂度与吞吐能力。没有一种模型能同时满足所有业务场景。
- 强一致性:每次读操作都能读到最新的写操作结果。实现代价最高,通常需要分布式事务(如2PC、TCC)或共识算法(如Paxos、Raft)。适用于金融交易、库存扣减等场景。
- 最终一致性:系统保证在没有新写操作后,所有读操作最终能读到最新值。允许中间状态不一致。实现成本低,吞吐量高。适用于社交动态、内容缓存等场景。
- 读写一致性(Read-Your-Writes):用户写后立即读,保证读到自己的写入。对其他用户的读不保证。适用于用户个人设置、购物车等场景。
| 一致性模型 | 延迟 | 吞吐量 | 实现复杂度 | 典型业务 |
|---|---|---|---|---|
| 强一致性 | 高 | 低 | 极高 | 支付、库存、账户余额 |
| 最终一致性 | 低 | 高 | 低 | 新闻Feed、推荐列表、计数 |
| 读写一致性 | 中 | 中 | 中 | 用户资料、订单状态 |
架构决策原则:优先满足业务对一致性的最低要求。能用最终一致性解决的,绝不引入强一致性带来的性能损失。对于核心链路,采用“强一致性兜底 + 最终一致性加速”的混合策略。
4.4 实战案例:订单状态更新中的一致性治理
以电商订单状态更新为例,展示如何综合应对上述挑战:
- 业务场景:用户支付成功后,订单状态从“待支付”变为“已支付”。需要同步更新MySQL订单表和Redis缓存。
- 并发风险:支付回调可能重复调用,用户可能同时取消订单,导致状态冲突。
- 治理方案:
- 使用Redis分布式锁(Key = order:lock:{orderId})防止并发写。
- 采用先写DB,后删缓存模式,配合异步延迟双删(第一次删除在DB写入后立即执行,第二次在1秒后执行)。
- DB层使用乐观锁(版本号字段),更新时校验当前状态是否允许变更。
- 缓存删除失败时,通过消息队列进行重试,最多重试3次。
// 伪代码:订单状态更新
public boolean updateOrderStatus(String orderId, String newStatus) {
String lockKey = "order:lock:" + orderId;
String lockValue = UUID.randomUUID().toString();
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS);
if (!locked) {
return false; // 获取锁失败,重试或返回
}
try {
// 1. 先更新DB(带版本号乐观锁)
int rows = orderMapper.updateStatusWithVersion(orderId, newStatus, currentVersion);
if (rows == 0) {
return false; // 版本冲突,更新失败
}
// 2. 立即删除缓存
redisTemplate.delete("order:" + orderId);
// 3. 异步延迟双删(1秒后再次删除)
delayedDeleteCache("order:" + orderId, 1000);
return true;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
关键指标:治理后,订单状态不一致率从0.5%降至0.001%以下,接口P99延迟从120ms降至85ms(得益于缓存删除代替缓存写入,减少了一次网络IO)。
4.5 本章小结
缓存一致性治理的核心在于:
- 识别冲突类型:并发写冲突、双写顺序不一致、读写并发导致的数据回填。
- 选择合适模式:“先写DB后删缓存”是当前业界最推荐的通用模式,配合延迟双删和异步重试。
- 明确一致性边界:与业务方对齐一致性要求,避免过度设计。强一致性只用于核心资金链路。
- 工具链保障:分布式锁、乐观锁、消息队列、版本号是治理工具箱中的必备武器。