事故清单摊开看
十六篇的方案讲的是「正着建」,这一篇讲「怎么塌的」。老王把一年内真实发生过、且前文没写透的七个事故整理成清单——每个都是现象、根因、解法三段式。先说共性:七个坑没有一个出在「方案选错了」,全部出在方案没吃透。
坑一:消息乱序,注销比注册先到
现象:用户状态机错乱——「已注销」的用户居然有新建的会员权益。排查发现:先发生了「注册」消息消费失败进重试队列,「注销」消息反而先被消费;注册消息重试成功后到达,把已注销的用户又「激活」了。根因:MQ 重试机制天然会造成后发先至,普通消息只有到达序没有业务序。解法:同一用户的消息路由到同一队列用顺序消息(牺牲吞吐),或消息体带业务版本号/操作时间,消费端按序判断——旧消息到达时发现状态已领先,直接幂等丢弃。
坑二:缓存删失败,旧余额挂一天
现象:用户退款成功,APP 上余额却纹丝不动持续了一整天。根因:先更新数据库再删缓存,删除那次 Redis 调用恰好失败,没人重试——缓存里躺着旧余额,读请求持续命中脏数据。解法:缓存删除也要走可靠化——删除失败进重试队列,或者干脆订阅 binlog(Canal)由独立组件删缓存,与业务代码解耦。记住顺序:先更库后删缓存,删除失败要兜底。
坑三:大事务撑爆 undo_log
现象:MySQL 磁盘占用飙涨、全库查询变慢,高峰期大量锁等待。根因:一个跑了 20 分钟的大事务(批量导数据没分批),期间整个库的 undo 版本链不能清理,其他事务的旧版本全部堆着——一个大事务,全库陪葬。解法:批量处理分批提交(每批一千条),监控 information_schema 里的长事务并告警,超时主动 kill。长事务是数据库的公敌,不只是它自己的事。
坑四:异步线程里全局事务失踪
现象:@GlobalTransactional 包住的方法里提交线程池跑异步任务,任务里的数据改动完全游离在全局事务之外——主事务回滚了,异步任务写的数据还在。根因:Seata 的全局事务上下文(XID)靠 ThreadLocal 传递,线程池换线程 = 上下文蒸发。解法:要么把异步操作移出全局事务(用消息衔接),要么用支持上下文传递的装饰线程池(Seata 提供的包装器)显式传递 XID。团队规范写死:全局事务内禁止裸用线程池。
坑五:顺序消息的假象
现象:用了 RocketMQ 顺序消息,照样乱序。根因:顺序消息只保证单个队列内有序——生产端没按业务键选队列(随机负载均衡发到不同队列),或消费端上了多线程消费,顺序被自己拆了。解法:生产端按业务键(用户 ID)哈希选队列,消费端单线程顺序消费同一队列,两端缺一不可。
坑六:双击下两单
现象:促销当晚,同一用户同一商品两笔订单间隔 80 毫秒。根因:前端按钮没防抖,后端幂等键用的是「请求时间戳」而非业务键——两个请求时间戳不同,幂等形同虚设(呼应第 10 篇:幂等键必须用业务语义键)。解法:前端防抖 + 后端以「用户 + 商品 + 活动」生成幂等键 + 数据库唯一索引三层组合。
坑七:扫描任务扫爆业务库
现象:本地消息表的扫描任务高峰期把业务库 CPU 打满,正常交易跟着遭殃。根因:扫描 SQL 没建 status + create_time 复合索引(全表扫描),且扫描频率未与业务峰谷错峰。解法:复合索引、限制批量、错峰调度,量大的把扫描任务迁到只读从库或独立任务库。第 7 篇提过索引设计,这单事故证明:提过不等于做到。
小结
七坑一表收束:乱序靠版本判断,缓存删除要兜底,大事务分批提交,XID 传递看线程,顺序消息两端配,幂等键用业务键,扫描任务建索引错峰跑。每个坑都在提醒同一件事:方案是骨架,纪律是肉。十八篇走到最后,下一篇收官——把分布式事务的全部家当收进一张作战地图。
评论 (0)