中间件挂了怎么办:兜底的边界与取舍
讨论在我们的票务系统中 Redis 和 RabbitMQ 服务出现问题如何补救
书接上文
一、系统的底座:请求流转与本地消息表
要保证业务一致性的最佳选择,首先想到的必然是本地消息表:一种用本地数据库事务保证业务操作和消息记录同时成功或失败的最终一致性方案(在我的单体项目中也可以用就是了)。
不过问题在于,Lua 脚本无法保证 Redis 的预扣减操作和数据库操作的原子性,如果强行插入,会带来一致性问题:Redis 预扣成功了,消息表却写入失败,这时候以谁为准?写入消息表也存在并发瓶颈怎么办?
这里其实有一个严格的请求流转流程:
- 第一阶段: 用户抢票,100% 以 Redis 扣减为准,如果预扣失败,直接返回“已售罄”,流程结束;如果预扣成功,马上同步去写 MySQL 本地消息表。
- 第二阶段: 本地消息表写入成功,接下来就以消息表为准,哪怕 Redis 服务挂了也不担心;如果写入失败,此时绝对不能回滚 Redis,必须触发本地内存重试,保证数据全部写入消息表。如果重试多次还是失败,就触发告警由人工介入。
而针对并发瓶颈,我的选择是:串行输入本地消息表。原因是 Redis 预扣已经过滤掉了大部分无效请求,真正进入消息表写入环节的消息量是可控的。但代价是串行环节会成为吞吐上限,只是在这个场景里,Redis 已经做了第一层过滤,所以我认为这个取舍还是比较合理的。
有了这个底座,我们再接着思考各个层级的中间件挂掉时,会发生什么。
二、决策层故障:Redis 服务挂机了
在我的设想中,这是最灾难的情况,Redis 被作为抢票胜负的依据,却在抢票时承担了大量的流量冲击(通过预先的票档预热和预扣减来缓解了数据库的压力)。
假设 Redis 服务挂机了,那已经写进消息表的消息是安全的,所以接下来正常执行就可以了;而挂机之后来的用户会因为 MQ1 消息的堆积而被抛弃。
那应该采用什么样的兜底机制来保证 Redis 的健壮性呢?我的答案是:主从 + 哨兵 + AOF持久化。 因为我的项目只是一个普通的单体项目,并没有上微服务,体量也并没有那么大,所以没有必要加上 Redis Cluster。不过这里同样存在一些问题:
问题一:异步复制导致数据丢失
Q: 在主节点往从节点写数据的过程中,主节点挂了,而 AOF 的 everysec 策略也有 1 秒的异步刷盘窗口,如果卡在这 1 秒内主节点宕机,那数据还来不及同步就全部丢失了,根本无法找回。
A: 即使 Redis 丢了这 1 秒的扣减数据,只要消息表落盘了,后续的落库就是安全的。同时配合总量对账,通过补偿机制(比如发点代金券?)给用户兜底。
问题二:经典的脑裂风险
Q: 主节点和哨兵集群之间的网络断了(但主节点自己还活着,还能连客户端)。哨兵以为主节点死了,提拔了新主。此时系统里出现了两个主节点。旧主还在接收用户的预扣减请求,等网络恢复后,旧主被降级为从节点,它的数据会被直接清空,去全量同步新主的数据。在此期间旧主接收的所有抢票请求,全部白抢。
A: 配置 min-replicas-to-write(比如至少要有 1 个从节点才能写),防止旧主在孤立状态下继续接收请求,同时配置 WAIT 命令,通过牺牲一点性能来换取主从同步。
三、应用层故障:整个应用宕机了
还有一个最极端的情况:如果连 Java 应用都重启了,内存队列直接丢了怎么办?我内心的答案其实是,既然应用都沦落到重启了,这个抢票还不如换个时间重新开票重新抢。
实际上如果 Redis 仍存活或通过 AOF/RDB 恢复了数据,可能残留“预扣成功但消息表未落盘”的预扣。这时候就要绕回上文所说的请求流转阶段:
- 已落消息表的继续投递;
- 同时从 MySQL 订单和消息表重算已售库存,重新预热覆盖 Redis;
- 未落表的预扣直接放弃,用户重新抢票。
我没有要求每一张票都绝对不丢,因为在系统性能和数据可靠度上,我觉得这个做法是我这个小体量最合适的。强行去捞残留消息,还容易引发脏数据二次雪崩。
四、通道层故障:MQ 消息丢失与挂机
对于 MQ 的异常,其实逻辑很简单:
1. 消息丢失了怎么办?
- 对于 MQ1 来说,消息丢失了就丢失了,我并没有必要做太多无用功来保证每个用户都能进入 Redis 预扣环节,它作为抢票的第一道防线,分流掉部分用户正是它应该做的。
- 而对 MQ2 来说,本地消息表的加持保证了消息丢失也能被妥善处理。只要消息表里还有记录,后台定时任务就会重新把消息捞出来投递,问题迎刃而解。同时消费端必须按业务唯一键做幂等,避免重复投递造成重复落库。
2. MQ 服务挂机了怎么办?
- MQ1 挂机时: 网关需要直接熔断,拦截新请求,绝不能把流量传给后面的 Redis 和数据库,防止打崩下游。
- MQ2 挂机时: 也不着急,消息已经安全躺在本地消息表里了,发不出去就重试,等 MQ2 重启恢复后,再慢慢同步过去即可。
五、隐藏彩蛋:MySQL 自己挂了
MQ2 落库失败时,不能直接ack,但也不建议立即 basicNack 回原队列,否则恢复中的 MySQL 容易被热循环再次打穿,可以进入延迟重试队列或死信队列,按退避策略重试;同时网关层必须马上熔断,拦截所有的抢票请求,避免把重启后的 MySQL 直接打穿。
另外,第一段消费者写本地消息表此时也会失败,同样会触发本地内存重试。
实际上,MySQL 必须做主从架构,准备好故障时的随时切换。MySQL 优先级最高,不管怎么样都必须保证它的一致性和可靠性。
六、总结与反思:架构是权衡的艺术
在写到本地消息表时,我犹豫过是不是应该把这段放在 Redis 分块下,因为本地消息表兜底的其实是核心业务的变更,因为在我的项目中 Redis 是决策层,所以在我的项目里它确实就是在给 Redis 兜底。后来为了使博客条理更清晰些,我还是选择单拎出来开一个分块了。
在学习和写作的过程中,我重新了解了其他的消息队列,横向对比和取舍了 RabbitMQ、Kafka、RocketMQ 三个中间件,相比高吞吐却缺少原生死信队列和延迟队列的 Kafka 与 提供事务消息和较完整的重试机制却非常重型的 RocketMQ,在这里我还是选择 RabbitMQ。
我发现,在这种极端的并发问题下,最重要的是权衡(Trade-off)。其实很多时候根本没有最完美的解法,如果我要保证绝对的强一致性,我就必须扩大架构体量,搭建更复杂更庞大的架构。而有时候为了 100% 的一致性,可能会做太多无用功,白白增加运维难度。在我看来,或许这反而是一种负担。根据业务场景对架构做出合适的取舍,这才是权衡的艺术。