前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 17|回复: 0

缓存与数据库一致性实战:从脏读到延迟双删与 binlog 订阅

[复制链接]

缓存与数据库一致性实战:从脏读到延迟双删与 binlog 订阅

[复制链接]
dbaai

主题

0

回帖

256

积分

DBAAI

积分
256
昨天 07:50 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
缓存与数据库一致性实战:从脏读到延迟双删与 binlog 订阅


一、具体的问题

业务上最常见的缓存事故不是缓存雪崩,而是"改了但没变"。典型场景:管理后台把商品价格从 199 改成 129,提交成功,页面刷新还是 199;过几分钟自己变过来了。用户改了昵称,好友列表里仍旧显示旧昵称。这类问题有三个共同特征:


  • 读接口走了缓存,写接口"看起来"也处理了缓存,但两个动作之间没有原子性;
  • 脏数据不是永久性的,靠缓存 TTL 兜底"过一会儿就好",于是没人把它当 P0 处理;
  • 复现极其困难——本地压测正常,线上偶发,因为它是两个并发线程的时序问题,不是代码逻辑错误。


本文以一个「商品详情页 + Redis 缓存 + MySQL 主库」的最小架构为靶子,把脏读的产生机制讲透,给出从延迟双删到 binlog 订阅的完整治理路径,每一步都可以照做。

二、核心原理

主流方案是 Cache Aside(旁路缓存):读时先查缓存,未命中查库并回填;写时先更新数据库,再删除缓存。问题恰恰出在"再删除"这三个字上,存在一个经典竞态:
  1. 时刻  读线程 A                写线程 B
  2. T1    缓存 miss
  3. T2                            更新 DB(价格=129)
  4. T3    从 DB 读到旧值(199)
  5. T4                            删除缓存
  6. T5    把旧值 199 回填缓存      ← 脏数据落地
复制代码

T3 发生在 B 更新 DB 之后、T5 发生在 B 删缓存之后,这个交叉一旦出现,旧值就在缓存里住到 TTL 过期为止。写库加锁解决不了它——锁只能保证 B 自己的原子性,管不住 A 读的是 B 提交前的快照。

由此得出三条工程结论:


  • "先更新 DB 再删缓存"仍是最优默认。对比"先删缓存再更新 DB":后者在删除后、提交前,任何读请求都会把旧值回填,脏读窗口更大且更高频;而上面的竞态要求"读 DB 在写 DB 之后、回填在删缓存之后"这个极窄的时序恰好凑齐,概率低但非零。
  • TTL 是兜底不是机制。它只承诺"脏数据有限期",不承诺"立即一致"。一致性目标要先分级:能接受秒级延迟的业务,TTL + 偶发对账就够了;价格、库存这类资损敏感字段,必须有主动补偿。
  • 终局一致靠异步订阅。延迟双删缩小脏读窗口,binlog 订阅保证"只要 DB 改了,缓存最终一定被清",两者叠加才是完整方案。


三、实例参考(动手步骤)

步骤 0:确认现状基线

先量化不一致有多严重,再谈治理。在压测环境用脚本比对缓存与 DB:
  1. # cache_check.py:抽样 1000 个热点 key,比对缓存值与 DB 值
  2. python3 cache_check.py --keys-pattern "product:detail:*" --sample 1000
  3. # 输出示例:
  4. # checked=1000 mismatch=37 (3.7%)  max_age_of_dirty=278s
复制代码

mismatch 比例和脏数据最长存活时间是治理前后的核心对照指标,先记录下来。

步骤 1:复现竞态

用两个线程交叉执行,稳定复现脏回填:
  1. # race_repro.py:A 线程在读 DB 后 sleep,B 线程趁机完成写库+删缓存
  2. import threading, time, redis, pymysql
  3. r = redis.Redis()
  4. db = pymysql.connect(**conf)
  5. def reader():
  6.     if not r.exists("p:1"):
  7.         row = query(db, "select price from product where id=1")  # 读到旧值 199
  8.         time.sleep(0.5)                                          # 制造窗口
  9.         r.set("p:1", row["price"])                               # 旧值回填
  10. def writer():
  11.     time.sleep(0.2)
  12.     exec_sql(db, "update product set price=129 where id=1")
  13.     r.delete("p:1")
  14. threading.Thread(target=reader).start()
  15. threading.Thread(target=writer).start()
  16. # 结束后 r.get("p:1") == b"199" 即复现成功
复制代码

能稳定复现,后面的每一步优化才有验证手段。

步骤 2:延迟双删

写路径改为:更新 DB → 删缓存 → 延迟 N 毫秒再删一次。第二次删除负责清掉竞态窗口里被回填的旧值:
  1. def update_product(pid, new_price):
  2.     exec_sql(db, "update product set price=%s where id=%s", (new_price, pid))
  3.     r.delete(f"p:{pid}")
  4.     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 为例:
  1. # canal server 端 instance 配置(conf/example/instance.properties)
  2. canal.instance.master.address=127.0.0.1:3306
  3. canal.instance.filter.regex=shop\\.product   # 只订阅关心的表
复制代码
  1. // 客户端:收到变更事件就删缓存,幂等
  2. if (eventType == UPDATE || eventType == DELETE) {
  3.     String key = "p:" + rowId;
  4.     redis.delete(key);   // 删不到(key 不存在)也无害,天然幂等
  5. }
复制代码

三个部署要点:MySQL 开 log-bin=ROW 格式且 binlog_row_image=FULL;订阅端记录位点并持久化,重启从位点续传,不丢事件;删除动作必须幂等,重复消费无副作用。

步骤 4:对账兜底

无论方案多完备,保留一道对账防线,每 5 分钟抽样比对缓存与 DB:
  1. # crontab:*/5 * * * * 抽样 500 个热点 key 对账,mismatch>0 告警
  2. */5 * * * * /usr/bin/python3 /opt/tools/cache_check.py --sample 500 --alert-on-mismatch
复制代码

对账发现脏 key 时主动删除并打点,这条数据同时就是治理效果的长期观测曲线。

步骤 5:删除大 key 用 UNLINK

商品详情这类聚合对象可能膨胀成大 key,删除时改用异步:
  1. 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 天零命中)
脏数据最长存活278s0.9s无
价格类客诉每周 4~6 起每周 <1 起0 起
写路径 P99 增加—+0.5ms(入队)+0.5ms
缓存 miss 率8.1%8.3%8.3%


结论:TTL 只能"止血不治本";延迟双删把不一致压缩到亚秒级,成本几乎为零;binlog 订阅补上最后的竞态缝隙,把一致性从"尽力而为"变成"可证明"。三类方案不是互斥选项,而是同一套分层防御——先分级业务要求,再决定做到哪一层。
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-10-2 06:45 , Processed in 0.039409 second(s), 10 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表