这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
缓存一致性
数据不一致的发现
Redis 方案上线一周后,用户开始反馈问题。
【用户反馈】用户ID: 5678
问题:点赞后又取消,但点赞数没有变化
时间:2023-08-22 14:30:15
【用户反馈】用户ID: 1234
问题:刷新页面后,点赞数和之前不一样
时间:2023-08-22 14:35:22
我赶紧查看数据:
数据不一致!Redis 显示 152,实际只有 150。
问题分析
我分析了代码,找到了问题原因:
问题在哪里?
场景:用户点赞后立即取消
时间线:
T1: 用户点击点赞
T2: Redis INCR → 151
T3: 数据库 INSERT → 成功
T4: 数据库 UPDATE → 成功
T5: 用户点击取消点赞
T6: Redis DECR → 150
T7: 数据库 DELETE → 成功
T8: 数据库 UPDATE → 成功
看起来没问题?
但如果 T7 失败了呢?
T7: 数据库 DELETE → 失败(网络错误)
T8: 数据库 UPDATE → 未执行
结果:
- Redis:150(已取消)
- 数据库:151(未取消)
→ 数据不一致!
核心问题:Redis 和数据库的更新不是原子操作。
一致性方案
我研究了各种缓存一致性方案。
方案 1:Cache Aside Pattern(旁路缓存)
读操作:
写操作:
为什么删除而不是更新?
更新缓存的问题:
- 并发写时,可能导致缓存覆盖
- 多个线程同时更新,缓存可能不正确
删除缓存的优势:
- 下次读取时,会从数据库加载最新值
- 避免并发写导致的缓存不一致
示例:
线程 A:更新数据库 → like_count = 101
线程 A:更新缓存 → cache = 101
线程 B:更新数据库 → like_count = 102
线程 B:更新缓存 → cache = 102
看起来没问题?
如果线程 A 先更新数据库,后更新缓存:
线程 A:更新数据库 → like_count = 101
线程 B:更新数据库 → like_count = 102
线程 B:更新缓存 → cache = 102
线程 A:更新缓存 → cache = 101 ← 覆盖了新值!
如果使用删除缓存:
线程 A:更新数据库 → like_count = 101
线程 A:删除缓存
线程 B:更新数据库 → like_count = 102
线程 B:删除缓存
下次读取:从数据库加载 → 102(正确)
Cache Aside Pattern 的问题:
问题 1:并发读写
时间线:
T1: 线程 A 读取缓存 → 未命中
T2: 线程 B 更新数据库
T3: 线程 B 删除缓存
T4: 线程 A 从数据库读取(旧值)
T5: 线程 A 写入缓存(旧值)
结果:缓存中是旧数据
问题 2:双写不一致
如果有两个服务都更新缓存:
服务 A:更新数据库 → 更新 Redis
服务 B:更新数据库 → 更新 Redis
可能顺序错乱,导致不一致
方案 2:Write Through(写穿透)
原理:
- 写操作同时更新缓存和数据库
- 缓存和数据库同步更新
实现:
问题:
- 缓存和数据库仍然是两个操作
- 无法保证原子性
- 性能较差(每次都要等数据库)
方案 3:Write Behind(异步写)
原理:
- 写操作只更新缓存
- 异步批量写入数据库
- 性能最好
实现:
问题:
- 数据库可能丢失数据(Redis 宕机)
- 数据库和缓存不一致(写入延迟)
- 复杂度高
方案 4:延迟双删(推荐)
原理:
- 更新数据库前删除缓存
- 更新数据库
- 延迟一段时间后再删除缓存
实现:
为什么延迟双删有效?
场景:并发读写
时间线:
T1: 线程 A 删除缓存
T2: 线程 B 读取缓存 → 未命中
T3: 线程 B 读取数据库 → 100(旧值)
T4: 线程 A 更新数据库 → 101
T5: 线程 B 写入缓存 → 100(旧值)
T6: 线程 A 延迟删除缓存
T7: 线程 C 读取缓存 → 未命中
T8: 线程 C 读取数据库 → 101(新值)
T9: 线程 C 写入缓存 → 101(正确)
延迟删除的作用:
- 删除线程 B 写入的旧缓存
- 保证最终一致性
延迟时间如何设置?
延迟时间 = 读取数据库时间 + 写入缓存时间 + 安全余量
示例:
- 读取数据库:50ms
- 写入缓存:5ms
- 安全余量:100ms
- 延迟时间:155ms(可设置为 200ms)
建议:
- 一般场景:200ms ~ 500ms
- 高并发场景:500ms ~ 1s
- 可以根据实际情况调整
我的最终方案
结合点赞场景的特点,我选择了延迟双删 + 数据对账的方案。
完整方案
数据对账
为了进一步保证一致性,我添加了定时对账任务。
监控告警
效果评估
一致性提升
优化前:
- 数据不一致率:约 5%
- 用户投诉:每天 3-5 条
优化后:
- 数据不一致率:约 0.1%
- 用户投诉:几乎为 0
性能影响
延迟双删的影响:
- 点赞接口响应时间:从 0.5ms 增加到 2ms
- 仍然远快于数据库方案(50ms)
- 用户无感知
对账任务的影响:
- 每小时运行一次
- 运行时间:约 30 秒
- 对数据库压力小
最终效果
系统运行一周:
- 处理点赞:约 350,000 次
- 数据不一致:约 350 次(0.1%)
- 自动修复:350 次
- 用户投诉:0 条
虽然不能保证 100% 一致,但通过延迟双删 + 定期对账,实现了最终一致性。
课后练习
练习 1
如何选择合适的一致性策略?
参考答案(3 个标签)
缓存一致性系统设计架构
一致性策略对比:
| 策略 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致 | 高 | 低 | 一般业务 |
| Write Through | 强一致 | 中 | 中 | 读多写少 |
| Write Behind | 最终一致 | 最高 | 高 | 写多读少 |
| 延迟双删 | 最终一致 | 高 | 中 | 高并发读写 |
选择决策树:
1. 数据是否绝对不能丢?
是 → Write Through / 延迟双删 + 对账
否 → 继续
2. 写入频率如何?
高 → Write Behind / 延迟双删
低 → Cache Aside / Write Through
3. 读写比例如何?
读多写少 → Cache Aside
写多读少 → Write Behind
4. 性能要求如何?
极高 → Write Behind
一般 → Cache Aside / 延迟双删
5. 复杂度要求如何?
简单 → Cache Aside
可接受复杂 → 延迟双删 + 对账点赞场景分析:
特点:
- 写入频繁(用户点赞)
- 读取频繁(查看点赞数)
- 性能要求高
- 可容忍短暂不一致
推荐:延迟双删 + 定期对账
- 性能好(异步删除)
- 一致性好(延迟删除 + 对账)
- 复杂度可接受练习 2
延迟双删中的延迟时间如何设置?
参考答案(3 个标签)
缓存一致性性能调优延迟时间
延迟时间计算公式:
延迟时间 = 读取数据库时间 + 写入缓存时间 + 网络延迟 + 安全余量
详细计算:
1. 读取数据库时间
- SELECT 查询时间
- 索引扫描时间
- 数据返回时间
- 一般:10ms ~ 50ms
2. 写入缓存时间
- Redis SET 操作时间
- 一般:1ms ~ 5ms
3. 网络延迟
- 应用到数据库:1ms ~ 5ms
- 应用到 Redis:1ms ~ 2ms
- 总计:2ms ~ 7ms
4. 安全余量
- 考虑并发情况
- 考虑 GC 暂停
- 考虑网络抖动
- 建议:100ms ~ 200ms
总延迟时间:10 + 5 + 7 + 100 = 122ms(可设置为 200ms)动态调整策略:
不同场景的推荐值:
低并发场景(< 100 ops/s):
- 延迟时间:200ms ~ 300ms
- 原因:并发少,简单的延迟即可
中并发场景(100 ~ 1000 ops/s):
- 延迟时间:300ms ~ 500ms
- 原因:并发增多,需要更长延迟
高并发场景(> 1000 ops/s):
- 延迟时间:500ms ~ 1000ms
- 原因:并发高,需要更长的安全窗口测试验证:
练习 3
如何处理分布式系统中的缓存一致性问题?
参考答案(3 个标签)
分布式系统缓存一致性分布式锁
分布式环境的一致性挑战:
单机环境:
- 一个 Redis 实例
- 一个数据库实例
- 相对简单
分布式环境:
- 多个 Redis 实例(分片)
- 多个数据库实例(分库分表)
- 数据分布在多台服务器
- 一致性更难保证方案 1:分布式锁
方案 2:消息队列(最终一致性)
方案 3:版本号(乐观锁)
练习 4
如何监控缓存一致性,及时发现问题?
参考答案(3 个标签)
监控告警运维
监控指标设计:
实时监控面板:
配置要点
- 配置表达的是环境差异和运行参数,不是业务规则本身。
- 队列、缓存、存储和服务参数决定系统在高峰期的缓冲能力。
练习 5
在读写都很多的情况下,如何优化缓存一致性方案?
参考答案(3 个标签)
高并发缓存优化性能
高并发读写优化方案:
思考题
-
如果数据库是分库分表的,如何保证缓存一致性?
-
如何实现”强一致性”的缓存方案(不使用分布式锁)?
-
缓存一致性和性能之间如何平衡?
💡 提示:这些问题没有标准答案,建议结合实际情况深入思考。
