马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
缓存与数据库一致性实战:从脏读到延迟双删与 binlog 订阅
一、具体的问题
业务上最常见的缓存事故不是缓存雪崩,而是"改了但没变"。典型场景:管理后台把商品价格从 199 改成 129,提交成功,页面刷新还是 199;过几分钟自己变过来了。用户改了昵称,好友列表里仍旧显示旧昵称。这类问题有三个共同特征:
- 读接口走了缓存,写接口"看起来"也处理了缓存,但两个动作之间没有原子性;
- 脏数据不是永久性的,靠缓存 TTL 兜底"过一会儿就好",于是没人把它当 P0 处理;
- 复现极其困难——本地压测正常,线上偶发,因为它是两个并发线程的时序问题,不是代码逻辑错误。
本文以一个「商品详情页 + Redis 缓存 + MySQL 主库」的最小架构为靶子,把脏读的产生机制讲透,给出从延迟双删到 binlog 订阅的完整治理路径,每一步都可以照做。
二、核心原理
主流方案是 Cache Aside(旁路缓存):读时先查缓存,未命中查库并回填;写时先更新数据库,再删除缓存。问题恰恰出在"再删除"这三个字上,存在一个经典竞态:
- 时刻 读线程 A 写线程 B
- T1 缓存 miss
- T2 更新 DB(价格=129)
- T3 从 DB 读到旧值(199)
- T4 删除缓存
- T5 把旧值 199 回填缓存 ← 脏数据落地
复制代码
T3 发生在 B 更新 DB 之后、T5 发生在 B 删缓存之后,这个交叉一旦出现,旧值就在缓存里住到 TTL 过期为止。写库加锁解决不了它——锁只能保证 B 自己的原子性,管不住 A 读的是 B 提交前的快照。
由此得出三条工程结论:
- "先更新 DB 再删缓存"仍是最优默认。对比"先删缓存再更新 DB":后者在删除后、提交前,任何读请求都会把旧值回填,脏读窗口更大且更高频;而上面的竞态要求"读 DB 在写 DB 之后、回填在删缓存之后"这个极窄的时序恰好凑齐,概率低但非零。
- TTL 是兜底不是机制。它只承诺"脏数据有限期",不承诺"立即一致"。一致性目标要先分级:能接受秒级延迟的业务,TTL + 偶发对账就够了;价格、库存这类资损敏感字段,必须有主动补偿。
- 终局一致靠异步订阅。延迟双删缩小脏读窗口,binlog 订阅保证"只要 DB 改了,缓存最终一定被清",两者叠加才是完整方案。
三、实例参考(动手步骤)
步骤 0:确认现状基线
先量化不一致有多严重,再谈治理。在压测环境用脚本比对缓存与 DB:
- # cache_check.py:抽样 1000 个热点 key,比对缓存值与 DB 值
- python3 cache_check.py --keys-pattern "product:detail:*" --sample 1000
- # 输出示例:
- # checked=1000 mismatch=37 (3.7%) max_age_of_dirty=278s
复制代码
mismatch 比例和脏数据最长存活时间是治理前后的核心对照指标,先记录下来。
步骤 1:复现竞态
用两个线程交叉执行,稳定复现脏回填:
- # race_repro.py:A 线程在读 DB 后 sleep,B 线程趁机完成写库+删缓存
- import threading, time, redis, pymysql
- r = redis.Redis()
- db = pymysql.connect(**conf)
- def reader():
- if not r.exists("p:1"):
- row = query(db, "select price from product where id=1") # 读到旧值 199
- time.sleep(0.5) # 制造窗口
- r.set("p:1", row["price"]) # 旧值回填
- def writer():
- time.sleep(0.2)
- exec_sql(db, "update product set price=129 where id=1")
- r.delete("p:1")
- threading.Thread(target=reader).start()
- threading.Thread(target=writer).start()
- # 结束后 r.get("p:1") == b"199" 即复现成功
复制代码
能稳定复现,后面的每一步优化才有验证手段。
步骤 2:延迟双删
写路径改为:更新 DB → 删缓存 → 延迟 N 毫秒再删一次。第二次删除负责清掉竞态窗口里被回填的旧值:
- def update_product(pid, new_price):
- exec_sql(db, "update product set price=%s where id=%s", (new_price, pid))
- r.delete(f"p:{pid}")
- delay_queue.submit(delay_ms=500, task=lambda: r.delete(f"p:{pid}"))
复制代码
延迟时间怎么定:取"读 DB + 回填缓存"的 P99 耗时再加余量,一般 300~1000ms。本例压测 P99 为 180ms,取 500ms。延迟任务要进可靠队列(Redis Stream / RocketMQ 延迟消息),不要起线程 sleep——进程重启就丢了。
步骤 3:binlog 订阅做终局补偿
双删只降概率,binlog 订阅保证不漏。以 canal 为例:
- # canal server 端 instance 配置(conf/example/instance.properties)
- canal.instance.master.address=127.0.0.1:3306
- canal.instance.filter.regex=shop\\.product # 只订阅关心的表
复制代码- // 客户端:收到变更事件就删缓存,幂等
- if (eventType == UPDATE || eventType == DELETE) {
- String key = "p:" + rowId;
- redis.delete(key); // 删不到(key 不存在)也无害,天然幂等
- }
复制代码
三个部署要点:MySQL 开 log-bin=ROW 格式且 binlog_row_image=FULL;订阅端记录位点并持久化,重启从位点续传,不丢事件;删除动作必须幂等,重复消费无副作用。
步骤 4:对账兜底
无论方案多完备,保留一道对账防线,每 5 分钟抽样比对缓存与 DB:
- # crontab:*/5 * * * * 抽样 500 个热点 key 对账,mismatch>0 告警
- */5 * * * * /usr/bin/python3 /opt/tools/cache_check.py --sample 500 --alert-on-mismatch
复制代码
对账发现脏 key 时主动删除并打点,这条数据同时就是治理效果的长期观测曲线。
步骤 5:删除大 key 用 UNLINK
商品详情这类聚合对象可能膨胀成大 key,删除时改用异步:
- redis-cli UNLINK p:1 # O(1) 摘链,后台线程回收内存,不阻塞主线程
复制代码
四、实操检查清单
- [ ] 治理前已用脚本量化 mismatch 比例与脏数据最长存活时间,形成基线
- [ ] 竞态复现脚本可稳定跑通,作为后续每步优化的验证手段
- [ ] 写路径统一为「先更新 DB → 删缓存」,代码评审禁掉"先删缓存再更新 DB"的写法
- [ ] 延迟双删已上线,延迟值按读回填 P99 + 余量设定(300~1000ms 区间内)
- [ ] 延迟删除任务走可靠队列(Redis Stream / 延迟消息),不依赖进程内 sleep
- [ ] MySQL 已确认 log-bin=ROW、binlog_row_image=FULL
- [ ] canal 订阅端位点持久化,杀进程重启后可从位点续传
- [ ] 缓存删除操作全部幂等(重复删无害),消费端可安全重试
- [ ] 对账任务已上 crontab,mismatch>0 触发告警并打点
- [ ] 大 key 删除统一走 UNLINK,禁止阻塞式 DEL
- [ ] 缓存 TTL 加随机抖动(如 1800s ± 300s),避免集中过期雪崩
几个容易踩的坑
- 双删延迟拍脑袋定成 100ms。读回填 P99 都不止 100ms,窗口根本没盖住。延迟值必须来自自己系统的压测数据,不是抄来的。
- 主从延迟造成二次脏读。写走主库、读走从库时,延迟双删后读请求可能从从库读到旧值又回填。治本是把订阅端挂在主库 binlog 上(canal 伪装 slave 读主库),双删的第二次删除放在主从延迟 P99 之后。
- 订阅端丢位点等于没做。canal 客户端默认内存位点,重启回退到 meta 里最后一次提交点,中间的变更全丢。位点必须持久化到 ZooKeeper 或本地文件并定期 flush。
- "更新缓存"替代"删缓存"是伪优化。有人想省一次 cache miss,改成写缓存。并发下两个写线程交叉,后提交的可能是旧值,且无法靠删除自愈。缓存值应当只由读路径回填。
- 对账脚本本身把缓存写坏了。对账只读比对,发现不一致时删 key 让业务回填,绝不要把 DB 值直接 set 进缓存——那会绕过所有业务态字段(比如标记位)。
治理前后对比
以压测环境 1000 QPS 读写混合流量、10% 写比例为观测窗口,治理前后指标:
| 指标 | 治理前(仅 TTL 1800s) | 延迟双删 | 双删 + canal 订阅 | | 抽样 mismatch 率 | 3.7% | 0.2% | 0%(对账 7 天零命中) | | 脏数据最长存活 | 278s | 0.9s | 无 | | 价格类客诉 | 每周 4~6 起 | 每周 <1 起 | 0 起 | | 写路径 P99 增加 | — | +0.5ms(入队) | +0.5ms | | 缓存 miss 率 | 8.1% | 8.3% | 8.3% |
结论:TTL 只能"止血不治本";延迟双删把不一致压缩到亚秒级,成本几乎为零;binlog 订阅补上最后的竞态缝隙,把一致性从"尽力而为"变成"可证明"。三类方案不是互斥选项,而是同一套分层防御——先分级业务要求,再决定做到哪一层。 |