4. 缓存与数据库双写一致性问题
在读写缓存 + 数据库的架构中,双写一致性 是指对同一份数据的数据库写入操作与缓存更新操作之间,最终或强一致地反映相同的数据状态。由于缓存和数据库是两个独立的存储系统,无法通过单机事务保证原子性,因此必须通过特定的更新顺序和补偿策略来降低不一致窗口。
4.1 核心矛盾:更新顺序的选择
当业务执行写操作时,通常涉及两个步骤:修改数据库(DB)和操作缓存(Cache)。根据操作顺序的不同,存在两种经典方案,各有优劣。
| 方案 | 执行顺序 | 核心风险 | 不一致窗口 |
|---|---|---|---|
| 先写 DB 后删缓存 | ① 更新 DB → ② 删除 Cache | 删除缓存失败导致数据脏读 | 极小(删除前瞬间) |
| 先删缓存后写 DB | ① 删除 Cache → ② 更新 DB | 并发读请求回填旧数据 | 较大(写 DB 完成前) |
关键结论: 先写 DB 后删缓存(Cache-Aside 模式的标准做法)在绝大多数场景下优于先删缓存后写 DB,因为前者仅在删除缓存失败时产生不一致,而后者在并发读请求下极易回填旧数据,导致长时间脏读。
4.2 先写 DB 后删缓存(推荐方案)
这是业界最广泛采用的缓存更新策略,也是 Cache-Aside Pattern 的标准写路径。其流程如下:
- 应用程序直接更新数据库中的记录。
- 更新成功后,立即删除缓存中对应的 key。
- 后续读请求发现缓存缺失,从数据库加载最新数据并回填缓存。
为什么是删除而不是更新? 删除操作是幂等的,且避免了一次无意义的缓存写入。如果更新缓存,可能因并发写顺序导致缓存与数据库不一致。删除缓存后,下次读请求自然会拉取最新数据。
唯一风险: 步骤 ② 删除缓存可能失败(如网络抖动、Redis 超时)。此时缓存中残留旧数据,后续读请求会命中脏数据。解决方案见下文 延迟双删策略。
4.3 先删缓存后写 DB(不推荐但需理解)
部分早期设计采用此顺序,意图是让写操作立即失效缓存,避免后续读请求读到旧数据。然而,该方案存在严重的并发一致性问题:
- 线程 A 删除缓存 key。
- 线程 B(读请求)发现缓存缺失,从 DB 读取旧数据并回填缓存。
- 线程 A 完成 DB 更新,但缓存中已被线程 B 写入了旧数据。
结果:缓存中长期存储旧数据,直到下一次写操作或缓存过期才能修复。这种不一致窗口远大于先写 DB 后删缓存方案。
避坑提醒: 除非你能保证在删除缓存到更新 DB 的极短窗口内没有任何读请求(例如通过分布式锁或队列串行化),否则不要使用先删缓存后写 DB。在大多数高并发业务中,该方案会导致频繁的缓存与数据库不一致。
4.4 延迟双删策略(Delayed Double Delete)
延迟双删是针对 先写 DB 后删缓存 方案中删除失败风险的补偿机制。其核心思想是:在第一次删除缓存后,等待一段时间再次删除,确保即使第一次删除失败,也能通过第二次删除修复。
标准执行流程:
- 更新数据库。
- 第一次删除缓存 key。
- 休眠一段时间(通常为 500ms ~ 2s,取决于业务读延迟)。
- 第二次删除同一个缓存 key。
延迟时间如何设定? 延迟时间应大于 从数据库读取数据 + 回填缓存 的最大耗时。通常建议设置为 1 秒左右。如果业务对一致性要求极高,可适当延长至 2~3 秒,但需权衡写入延迟。
延迟双删并不能保证 100% 强一致,但它将不一致窗口从 永久(删除失败) 缩小到 延迟时间窗口内。配合缓存过期时间,最终一致性可以得到保障。
4.5 最终一致性原理
在缓存与数据库架构中,强一致性 通常难以实现(除非引入分布式事务或 2PC,但会严重牺牲性能)。实际生产环境普遍接受 最终一致性,即允许在短时间内存在不一致,但保证经过一定时间后数据最终一致。
最终一致性的实现依赖以下机制:
| 机制 | 作用 | 典型配置 |
|---|---|---|
| 缓存过期时间(TTL) | 强制缓存失效,触发重新加载 | 业务可接受的最大不一致时间,如 5~30 分钟 |
| 延迟双删 | 补偿删除失败或并发回填旧数据 | 延迟 1~3 秒,异步执行 |
| 异步消息补偿 | 通过 MQ 消息确保删除操作最终被执行 | 删除失败后发送重试消息 |
| Binlog 监听(Canal) | 从数据库变更日志捕获变更,异步刷新缓存 | 监听 MySQL binlog,推送到 Redis |
最终一致性的时间窗口: 在延迟双删 + TTL 的配合下,不一致窗口通常被控制在 1~3 秒 内。对于绝大多数互联网业务(如商品详情、用户信息),这个窗口是可接受的。对于金融、库存等对一致性要求极高的场景,应使用分布式锁或数据库强一致方案。
4.6 方案对比总结
| 方案 | 一致性保证 | 并发性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 先写 DB 后删缓存 | 最终一致(删除失败时需补偿) | 高 | 低 | 通用场景,推荐默认使用 |
| 先删缓存后写 DB | 弱一致(并发下易脏读) | 中 | 低 | 不推荐,除非写串行化 |
| 延迟双删 | 最终一致(窗口可控) | 中(增加一次删除延迟) | 中 | 对一致性要求较高的业务 |
| Binlog 监听异步刷新 | 最终一致(秒级延迟) | 高(异步解耦) | 高 | 大型系统,需要解耦写路径 |
重要提醒: 没有任何一种缓存更新策略能保证 100% 强一致且高性能。架构师需要根据业务对一致性的容忍度、并发量、运维成本来选择合适的方案。对于绝大多数场景,先写 DB 后删缓存 + 延迟双删 + 合理 TTL 是性价比最高的组合。