MySQL 主从切换引发话务服务雪崩 9 小时:共享线程池 + 默认 JVM + WMB 重试连环引爆

MySQLJVM线程池消息队列事故复盘Java

MySQL 主从切换引发话务服务雪崩 9 小时:共享线程池 + 默认 JVM + WMB 重试连环引爆复盘

TL;DR 上游转码服务的 MySQL 主从切换,引发了话务服务长达 9 小时的推送延迟。表面看是接口超时,但真正致命的是:共享线程池 → 默认 JVM → WMB 自动重试 这三颗雷连环引爆,差点把整个服务炸穿。这篇文章是完整的复盘。


📌 本文要点

  • 一个外部接口超时,如何通过共享线程池锁死整个服务
  • 默认 JVM 参数在消息堆积场景下有多脆弱
  • WMB 自动重试配置如何让事情雪上加霜
  • 我们最后改了哪几处关键代码

🏗️ 背景与环境

项目信息
话务服务依赖wmedia(录音转码服务)
消息中间件WMB(内部消息总线)
线程池模型ESL 事件 + 录音上报共用一个线程池
JVM 参数默认(-Xms2g -Xmx2g -Xmn512m)
wmedia 超时设置3 秒
WMB 消费超时10 秒自动重投
影响时长9 小时

重点标记 共享线程池默认 JVM——事故链的核心环节。


🌪️ 一个普通的周一下午

下午四点。

我正盯着 IDE 写下一期的需求,突然手机震了——告警群:“调用 wmedia 转码接口超时”

wmedia 是我们依赖的录音转码服务,话务服务的核心链路依赖它:FreeSWITCH 产生的 ESL 事件和录音上报事件,由我们的线程池处理,录音上报时会调 wmedia 接口做转码。当时第一反应——wmedia 挂了,等他们恢复就好。

但没想到,这只是雪崩的第一片雪花。


🔗 五环连锁反应

第一环:上游 MySQL 主从切换 → 接口超时

wmedia 那边回复很快:MySQL 主从切换,导致接口响应变慢,一部分请求超时了。他们重启服务,自认为恢复了。

这个层面上看,问题好像已经解决了。但真正的地狱,从后面才开始。

第二环:共享线程池——最隐蔽的坑

告警又响了。这次是 ESL 事件堆积

我打开监控一看,头皮发麻——WMB 主题的消费进度基本不动了。

问题出在哪?话务服务的设计里,ESL 事件处理和录音上报处理共用一个线程池。为什么这么设计?因为同一个通话的 ESL 事件和录音事件需要保证有序,如果分两个线程池处理,可能会出现 ESL 事件先到、录音后到但被抢先消费的情况。

这个设计在正常情况下没问题。但录音上报会调用 wmedia 转码接口——接口设置了 3 秒超时。当 wmedia 变慢时,这 3 秒就成了一个移动路障:

录音上报 → 调 wmedia(等 3s 超时)→ 线程被占住 3s
ESL 事件 → 等线程池空 → 也排 3s
下一个录音 → 继续等 3s

一个线程池,两种任务,一种任务被上游卡住,另一种也跟着遭殃。这不叫”共享”,这叫”连坐”。

第三环:默认 JVM 配置——新节点反而成了累赘

发现问题后,我们立刻扩容,从 5 个节点扩到 10 个。

结果堆积不仅没消下去,反而更慢了。

排查日志,发现新节点疯狂 GC。原因很朴实:新机器用了默认 JVM 参数

-Xms2g -Xmx2g -Xmn512m

新生代只有 512MB。在正常流量下够用,但面对堆积的海量消息——每个消息都要反序列化、处理、再序列化——512MB 的新生代分分钟被填满,频繁 Full GC,CPU 全花在 GC 上了,消息一个都处理不完。

回头想想这是很典型的疏漏:我们评估了正常场景的并发量,但没有评估堆积场景的突发并发量。两者的差距不是一倍两倍,可能是几十倍。

第四环:WMB 自动重试——补刀最狠的一环

这还没完。

WMB 有一个配置:消息消费超时 10 秒就会自动重投,让别的节点再消费一次。

想象一下这个场景:

  1. 节点 A 拿到消息,处理到第 8 秒,GC 了
  2. WMB 看没在 10 秒内 ack,超时了,把消息重投
  3. 节点 B 拿到,处理到第 5 秒,线程池满了
  4. WMB 超时,再重投给节点 C

结果就是:一条原始消息变成了三四条重复消息,全塞在一个队列里。原始堆积还没消化完,重复消息又翻倍了。

第五环:重试主题也来凑热闹

wmedia 的超时时间我们设置的是 10 分钟重试——也就是说,调 wmedia 超时后,这条消息扔进重试队列,10 分钟后再试。

这本身没什么问题。但重试主题的消息也走同一个线程池!结果就是:正常消息、重试消息、重复消息……全挤在一个池子里,谁也跑不掉。


📊 事故时间线

时间事件
16:00wmedia MySQL 主从切换,接口超时
16:12wmedia 重启,自认恢复
16:40ESL 事件堆积告警,共享线程池阻塞
16:50扩容 5→10 节点,新节点默认 JVM 疯狂 GC
17:00WMB 超时重试,重复消息翻倍
18:30堆积逐步消化完
凌晨录音文件恢复完成

每一步单独看都是小问题,串在一起就成了灾难。


🛠️ 怎么修的

事故之后,主要改了这几点:

① 去掉了 WMB 超时重试配置

如果服务真的处理不过来,重试一百次也没用,只会让系统更堵。去掉后,重复消息的问题直接消失。

② 调低外部 SCF 接口超时时间

3 秒太长?改成更短的超时,并且要考虑超时后的降级策略——比如跳过转码、先处理 ESL 事件,录音后面再补。

③ 调大 JVM 参数 + 固化部署脚本

根据堆积场景重新评估,把新生代调大。而且要在部署脚本里固定好,防止新节点用了默认配置

④ 客户端消费限速

在代码里加了一个滑动窗口限速,当堆积量超过阈值时主动降速,给下游和自己喘息的空间。

⑤ 拆分线程池

把 ESL 事件和录音事件拆成不同的线程池,中间用有序队列做缓冲。保持有序,但不互相阻塞。

⑥ 坐席属性服务也跟着改了

和话务服务共用类似的架构模式,同样的坑不能再踩一次。


💭 复盘反思

第一,没有孤立的上游故障。 任何一个依赖方出问题,最终都会以某种形式传导到你的服务。不做好隔离,就在给别人背锅。

第二,“共享”要考虑故障场景。 共享线程池在设计时只考虑了”正常时序”的对称性,没有考虑”一个任务拖慢全体”的不对称性。

第三,默认配置是给 Demo 用的。 生产环境永远不要用默认 JVM 参数。这一点我本来就知道,但还是被现实教育了一次。

第四,重试要有上限,更要有退避。 WMB 的 10 秒超时重试,本质是”再试一次就好了”的乐观假设。但对堆积场景来说,再试一次大概率还是不行。重试策略应该是:快速失败 → 记录 → 异步补偿,而不是在同一根绳子上反复勒。


✅ 复盘 Checklist:消息消费服务上线前过一遍

  • 线程池是共享的还是隔离的? 故障场景下一个任务会不会拖慢全体
  • JVM 参数是固定的还是默认的? 部署脚本里写死,防止新节点用默认值
  • 消息重试策略合理吗? 有上限、有退避、有死信队列,而不是无限重投
  • 外部接口超时后有没有降级? 跳过非核心逻辑,先保核心链路
  • 堆积场景的突发并发量评估过吗? 不是正常流量的 1-2 倍,可能是几十倍
  • 限速/背压机制有了吗? 堆积超阈值时主动降速,给系统喘息空间

说到底,这次事故没有一个”神仙 Bug”——没有诡异的并发问题,没有复杂的数据竞态。只是一连串普通的配置和设计,在特定的压力场景下被放大了。

但正因为普通,才更值得记录下来。每个”正常情况”下看似合理的决定,都可能在”异常情况”下变成致命的一环。