欢迎光临
我们一直在努力

记一次线上事故:一个“不起眼“的线程池配置-无界队列,如何拖垮整个微服务系统

一次自建医院 HIS 接口故障,怎么一步步演变成全系统瘫痪?本文完整复盘这次事故的排查过程,重点讲清楚一个 90% 的人都踩过的线程池隐藏坑——ThreadUtil.newExecutor(n) 根本不是你以为的"固定 n 线程池"。 Java 线程池核心参数,以及无界队列如何把服务拖垮:从 OOM 到 Tomcat 线程耗尽

一、事故现场

那天上午 10 点 20 左右,运营突然在群里喊:系统挂了,患者端页面全部转圈,医生端也登不上。

打开监控一看,场面相当壮观:

  • 网关日志里 502、504 刷屏
  • patient、portal、mpc 几个服务同时报 Lock wait timeout exceeded
  • portal 调 patient 的 Feign 接口大量 Read timed out

有意思的是,这几个症状不是同时出现的。翻日志发现,其实从 10 点开始,系统就已经"带病运行"了——只是 10 点 20 那一刻,压死骆驼的最后一根稻草落下来了。

我们先把时间线捋一遍,再一层层往下挖。

先把整条故障传导链画出来——后面每一节,其实都是在拆解这条链上的一环:

#mermaid-svg-3xs34s0AAiIe6aAD{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3xs34s0AAiIe6aAD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3xs34s0AAiIe6aAD .error-icon{fill:#552222;}#mermaid-svg-3xs34s0AAiIe6aAD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3xs34s0AAiIe6aAD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3xs34s0AAiIe6aAD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3xs34s0AAiIe6aAD .marker.cross{stroke:#333333;}#mermaid-svg-3xs34s0AAiIe6aAD svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3xs34s0AAiIe6aAD p{margin:0;}#mermaid-svg-3xs34s0AAiIe6aAD .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-3xs34s0AAiIe6aAD .cluster-label text{fill:#333;}#mermaid-svg-3xs34s0AAiIe6aAD .cluster-label span{color:#333;}#mermaid-svg-3xs34s0AAiIe6aAD .cluster-label span p{background-color:transparent;}#mermaid-svg-3xs34s0AAiIe6aAD .label text,#mermaid-svg-3xs34s0AAiIe6aAD span{fill:#333;color:#333;}#mermaid-svg-3xs34s0AAiIe6aAD .node rect,#mermaid-svg-3xs34s0AAiIe6aAD .node circle,#mermaid-svg-3xs34s0AAiIe6aAD .node ellipse,#mermaid-svg-3xs34s0AAiIe6aAD .node polygon,#mermaid-svg-3xs34s0AAiIe6aAD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3xs34s0AAiIe6aAD .rough-node .label text,#mermaid-svg-3xs34s0AAiIe6aAD .node .label text,#mermaid-svg-3xs34s0AAiIe6aAD .image-shape .label,#mermaid-svg-3xs34s0AAiIe6aAD .icon-shape .label{text-anchor:middle;}#mermaid-svg-3xs34s0AAiIe6aAD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3xs34s0AAiIe6aAD .rough-node .label,#mermaid-svg-3xs34s0AAiIe6aAD .node .label,#mermaid-svg-3xs34s0AAiIe6aAD .image-shape .label,#mermaid-svg-3xs34s0AAiIe6aAD .icon-shape .label{text-align:center;}#mermaid-svg-3xs34s0AAiIe6aAD .node.clickable{cursor:pointer;}#mermaid-svg-3xs34s0AAiIe6aAD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3xs34s0AAiIe6aAD .arrowheadPath{fill:#333333;}#mermaid-svg-3xs34s0AAiIe6aAD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3xs34s0AAiIe6aAD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3xs34s0AAiIe6aAD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3xs34s0AAiIe6aAD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3xs34s0AAiIe6aAD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3xs34s0AAiIe6aAD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3xs34s0AAiIe6aAD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3xs34s0AAiIe6aAD .cluster text{fill:#333;}#mermaid-svg-3xs34s0AAiIe6aAD .cluster span{color:#333;}#mermaid-svg-3xs34s0AAiIe6aAD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3xs34s0AAiIe6aAD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3xs34s0AAiIe6aAD rect.text{fill:none;stroke-width:0;}#mermaid-svg-3xs34s0AAiIe6aAD .icon-shape,#mermaid-svg-3xs34s0AAiIe6aAD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3xs34s0AAiIe6aAD .icon-shape p,#mermaid-svg-3xs34s0AAiIe6aAD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3xs34s0AAiIe6aAD .icon-shape .label rect,#mermaid-svg-3xs34s0AAiIe6aAD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3xs34s0AAiIe6aAD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3xs34s0AAiIe6aAD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3xs34s0AAiIe6aAD :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

自建医院 HIS 接口故障每次调用挂满 60 秒才返回失败

号源刷新定时任务持续提交3 个核心线程全被占死

任务堆积塞满 1024 长度的队列

线程池无限膨胀并发线程从 3 涨到 17+

十几个线程并发写号源表行锁竞争激烈,锁等待堆积

数据库实例整体过载所有表的事务一起变慢

patient / portal / mpc 报Lock wait timeout exceeded

Tomcat 线程全卡在等数据库网关 502 / 504 刷屏

用户视角:系统崩了

二、第一层:罪魁祸首是自建医院 HIS 接口

最先看到的是这种日志,从 10 点就开始刷:

[Forest] Response: Status = 504, Time = 60002ms
POST http://proxy–hospitals–node–1:19004/tiantan/api/reglist
Response Content: <html>...504 Gateway Time–out…nginx…

翻译一下:我们的系统每隔几分钟就去调自建医院的"查询号源"接口(告诉第三方 HIS 系统:这个医生今天还有哪些号)。结果对方的服务出了问题,请求发过去石沉大海,整整 60 秒后,对面的 nginx 代理等不下去了,直接回了一个 504。

注意这个细节:60 秒不是我们自己设置的超时,是对面 nginx 的 proxy_read_timeout 默认值。也就是说,我们这边的代码心甘情愿地等了 60 秒才拿到失败结果。

正常情况下,这个接口几百毫秒就返回了。现在是每调一次挂 60 秒。

如果只是偶尔一次,忍了。但号源刷新是定时任务,会循环调用、医院一多、科室一多、医生一多——这就出大问题了。往下看。

三、第二层:线程池里的"幽灵线程"

先搞清楚刷新的执行链路

我们有个定时任务类 RefreshHospitalScheduleTask,它的活儿是长轮询:每隔一段时间,把所有医院的号源刷一遍。为了不阻塞,它把"刷新某家医院"这个任务丢给线程池:

// 原来的代码
private static final ExecutorService executorServiceTask = ThreadUtil.newExecutor(3);

写这行代码的人的意图很明显:3 个线程慢慢刷,一家一家来。

故障期间,我们去看线程日志,发现了一个诡异的现象:执行刷新任务的线程,名字是这样的:

pool–6–thread–1
pool–6–thread–2
pool–6–thread–3
pool–6–thread–7
pool–6–thread–13
pool–6–thread–17

等等,不是说好 3 个线程吗?thread-17 是哪来的?中间那些 4、5、6 呢?

反编译字节码,真相大白

我们怀疑 ThreadUtil.newExecutor(3) 的行为和想象中不一样,于是直接把项目里用的 hutool 5.8.20 的 jar 包反编译看了一眼,newExecutor 的源码是这样的:

public static ExecutorService newExecutor(int threadSize) {
ExecutorBuilder builder = ExecutorBuilder.create();
if (threadSize > 0) {
builder.setCorePoolSize(threadSize); // 只设置了核心线程数!
}
return builder.build();
}

它只设置了 corePoolSize,没设置 maxPoolSize!

而 hutool ExecutorBuilder 的默认值是:

  • corePoolSize = 你传的 3
  • maxPoolSize = Integer.MAX_VALUE(约 21 亿)
  • 队列 = LinkedBlockingQueue(1024)

用图对比一下"你以为的"和"实际的":

#mermaid-svg-FCq4qA0bw0SJKsFc{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FCq4qA0bw0SJKsFc .error-icon{fill:#552222;}#mermaid-svg-FCq4qA0bw0SJKsFc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FCq4qA0bw0SJKsFc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FCq4qA0bw0SJKsFc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FCq4qA0bw0SJKsFc .marker.cross{stroke:#333333;}#mermaid-svg-FCq4qA0bw0SJKsFc svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FCq4qA0bw0SJKsFc p{margin:0;}#mermaid-svg-FCq4qA0bw0SJKsFc .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc .cluster-label text{fill:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc .cluster-label span{color:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc .cluster-label span p{background-color:transparent;}#mermaid-svg-FCq4qA0bw0SJKsFc .label text,#mermaid-svg-FCq4qA0bw0SJKsFc span{fill:#333;color:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc .node rect,#mermaid-svg-FCq4qA0bw0SJKsFc .node circle,#mermaid-svg-FCq4qA0bw0SJKsFc .node ellipse,#mermaid-svg-FCq4qA0bw0SJKsFc .node polygon,#mermaid-svg-FCq4qA0bw0SJKsFc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FCq4qA0bw0SJKsFc .rough-node .label text,#mermaid-svg-FCq4qA0bw0SJKsFc .node .label text,#mermaid-svg-FCq4qA0bw0SJKsFc .image-shape .label,#mermaid-svg-FCq4qA0bw0SJKsFc .icon-shape .label{text-anchor:middle;}#mermaid-svg-FCq4qA0bw0SJKsFc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FCq4qA0bw0SJKsFc .rough-node .label,#mermaid-svg-FCq4qA0bw0SJKsFc .node .label,#mermaid-svg-FCq4qA0bw0SJKsFc .image-shape .label,#mermaid-svg-FCq4qA0bw0SJKsFc .icon-shape .label{text-align:center;}#mermaid-svg-FCq4qA0bw0SJKsFc .node.clickable{cursor:pointer;}#mermaid-svg-FCq4qA0bw0SJKsFc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FCq4qA0bw0SJKsFc .arrowheadPath{fill:#333333;}#mermaid-svg-FCq4qA0bw0SJKsFc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FCq4qA0bw0SJKsFc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FCq4qA0bw0SJKsFc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FCq4qA0bw0SJKsFc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FCq4qA0bw0SJKsFc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FCq4qA0bw0SJKsFc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FCq4qA0bw0SJKsFc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FCq4qA0bw0SJKsFc .cluster text{fill:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc .cluster span{color:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FCq4qA0bw0SJKsFc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FCq4qA0bw0SJKsFc rect.text{fill:none;stroke-width:0;}#mermaid-svg-FCq4qA0bw0SJKsFc .icon-shape,#mermaid-svg-FCq4qA0bw0SJKsFc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FCq4qA0bw0SJKsFc .icon-shape p,#mermaid-svg-FCq4qA0bw0SJKsFc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FCq4qA0bw0SJKsFc .icon-shape .label rect,#mermaid-svg-FCq4qA0bw0SJKsFc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FCq4qA0bw0SJKsFc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FCq4qA0bw0SJKsFc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FCq4qA0bw0SJKsFc :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

实际的 hutool:3 核心 + 21 亿上限

队列没满

队列满了

源源不断的任务

队列最多装 1024 个

3 个核心线程处理

继续新建线程上限约 21 亿

你以为的 newExecutor(3):固定 3 线程池

源源不断的任务

排队等待

3 个线程这就是天花板

用餐厅类比一下这个坑

你可以把线程池想象成一个餐厅:

  • 核心线程 = 正式员工,一直驻店干活(3 个)
  • 队列 = 门口排队的取号区,能站 1024 个人
  • 最大线程数 = 招临时工的限额

正常你理解的"3 线程池"应该是:3 个厨师炒菜,订单排队,最多就 3 个厨师。

但 hutool 这个写法等于:临时工限额是无限的。平时 3 个厨师+排队区完全够用,相安无事。可一旦订单暴涨,把 1024 个号的排队区都站满了,后面的订单进不来——怎么办?无限招临时工!来了 17 个,还能来 100 个、1000 个……

好,现在把事故当天的场景套进去:

  • 自建医院 HIS 挂了,每刷一个医生要等 60 秒
  • 一家医院可能有几十个医生、十几个科室,一个刷新任务要跑几个小时
  • 3 个正式员工全被这些"僵尸任务"占住,新任务全堆在队列里
  • 队列堆满 1024 个之后,线程池开始疯狂"招临时工"
  • 最后 17+ 个线程同时在跑号源刷新
  • 把故障期间线程池的膨胀过程用时序图画出来:

    自建医院 HIS-已故障

    hutool 线程池

    定时任务-生产者

    自建医院 HIS-已故障

    hutool 线程池

    定时任务-生产者

    #mermaid-svg-fAKoDQUbQxWRlSEq{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fAKoDQUbQxWRlSEq .error-icon{fill:#552222;}#mermaid-svg-fAKoDQUbQxWRlSEq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fAKoDQUbQxWRlSEq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fAKoDQUbQxWRlSEq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fAKoDQUbQxWRlSEq .marker.cross{stroke:#333333;}#mermaid-svg-fAKoDQUbQxWRlSEq svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fAKoDQUbQxWRlSEq p{margin:0;}#mermaid-svg-fAKoDQUbQxWRlSEq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fAKoDQUbQxWRlSEq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fAKoDQUbQxWRlSEq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-fAKoDQUbQxWRlSEq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-fAKoDQUbQxWRlSEq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-fAKoDQUbQxWRlSEq .sequenceNumber{fill:white;}#mermaid-svg-fAKoDQUbQxWRlSEq #sequencenumber{fill:#333;}#mermaid-svg-fAKoDQUbQxWRlSEq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-fAKoDQUbQxWRlSEq .messageText{fill:#333;stroke:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fAKoDQUbQxWRlSEq .labelText,#mermaid-svg-fAKoDQUbQxWRlSEq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .loopText,#mermaid-svg-fAKoDQUbQxWRlSEq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fAKoDQUbQxWRlSEq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-fAKoDQUbQxWRlSEq .noteText,#mermaid-svg-fAKoDQUbQxWRlSEq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-fAKoDQUbQxWRlSEq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fAKoDQUbQxWRlSEq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fAKoDQUbQxWRlSEq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fAKoDQUbQxWRlSEq .actorPopupMenu{position:absolute;}#mermaid-svg-fAKoDQUbQxWRlSEq .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-fAKoDQUbQxWRlSEq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fAKoDQUbQxWRlSEq .actor-man circle,#mermaid-svg-fAKoDQUbQxWRlSEq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-fAKoDQUbQxWRlSEq :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    loop

    [每隔几分钟一轮]

    每个任务占线程 60 秒

    3 个核心线程全部占死

    任务疯狂堆积 → 队列塞满 1024

    thread-7、thread-13、thread-17……

    17+ 个线程同时在跑刷新

    提交"刷新某医院"任务

    thread-1~3 并发调用

    60 秒后才返回 504

    队列满,开始无限建临时工线程

    到这里你可能觉得:多几个线程同时刷新,顶多 HIS 压力大点,能有多大事?

    这就是这次事故最核心的认知盲区——线程池隔离了,但数据库没隔离。

    四、第三层:数据库是怎么被打垮的

    刷新任务落地时要干什么

    每个刷新任务跑完 HIS 查询后,要把号源写进数据库,涉及这么几类 SQL:

    — 1. 插入新号源(有唯一键,冲突就更新)
    INSERT INTO ihm_doctor_schedule_detail ... ON DUPLICATE KEY UPDATE ...

    — 2. 删掉旧数据(这个科室这批医生的历史号源)
    DELETE FROM ihm_doctor_schedule_detail
    WHERE dept_id = ? AND doctor_id = ? AND trace_id <> ?

    — 3. 统计并更新医生的号源天数
    UPDATE ihm_doctor_extend ext, (按 trace_id 聚合子查询) sss
    SET ext.schedule_days = sss.total ...

    平时一个线程慢慢跑,这些 SQL 都没问题。但现在变成 17 个线程并发跑:

    • INSERT ON DUPLICATE KEY 在并发插同一个键的时候会互相排队
    • 大范围 DELETE 会持有大量行锁
    • 多个任务交叉执行,A 等 B 的锁,B 等 C 的锁……

    用图书馆类比 Lock wait timeout

    数据库的行锁就像图书馆的书:事务 A 借走了《Java 编程思想》(锁住了某一行),事务 B 也想借,只能等 A 还书。

    InnoDB 有个规矩:最多等 50 秒(innodb_lock_wait_timeout 默认值),50 秒还拿不到锁就直接报错放弃——这就是 Lock wait timeout exceeded。

    事故日志里全是这个:

    Lock wait timeout exceeded; try restarting transaction
    耗时: 50006ms ← 恰好 50 秒,对上了

    最关键的推理:为什么"不相干"的表也在报锁超时?

    排查时有个细节让我后背发凉。报 Lock wait timeout 的不只是号源表,还有:

    • INSERT INTO ihm_order(订单表)
    • INSERT INTO ihm_short_message(短信表)
    • INSERT INTO platform_user(用户表)
    • UPDATE ihm_consultation_record WHERE id = ?(按主键更新,正常是毫秒级)

    这些表和号源八竿子打不着,普通的单条 INSERT,怎么会锁等待 50 秒?

    不同的表之间根本不存在行锁冲突——InnoDB 的行锁只锁自己表里的行。唯一的解释是:

    数据库实例整体过载了。大量锁等待把连接占满、长事务堆积、CPU 和 IO 被打满,此时所有新来的事务都变慢,一个平时 5 毫秒的 INSERT 也要排队几十秒,然后超时。

    这就好比小区只有一个大门(数据库实例),某几户人家装修堵住了楼道(号源表的锁竞争),最后演变成整个小区的人都出不了门。

    数据库一瘫,连锁反应就来了:

    #mermaid-svg-x2dVbgVdIzOBBbrZ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-x2dVbgVdIzOBBbrZ .error-icon{fill:#552222;}#mermaid-svg-x2dVbgVdIzOBBbrZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-x2dVbgVdIzOBBbrZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .marker.cross{stroke:#333333;}#mermaid-svg-x2dVbgVdIzOBBbrZ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-x2dVbgVdIzOBBbrZ p{margin:0;}#mermaid-svg-x2dVbgVdIzOBBbrZ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .cluster-label text{fill:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .cluster-label span{color:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .cluster-label span p{background-color:transparent;}#mermaid-svg-x2dVbgVdIzOBBbrZ .label text,#mermaid-svg-x2dVbgVdIzOBBbrZ span{fill:#333;color:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .node rect,#mermaid-svg-x2dVbgVdIzOBBbrZ .node circle,#mermaid-svg-x2dVbgVdIzOBBbrZ .node ellipse,#mermaid-svg-x2dVbgVdIzOBBbrZ .node polygon,#mermaid-svg-x2dVbgVdIzOBBbrZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .rough-node .label text,#mermaid-svg-x2dVbgVdIzOBBbrZ .node .label text,#mermaid-svg-x2dVbgVdIzOBBbrZ .image-shape .label,#mermaid-svg-x2dVbgVdIzOBBbrZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-x2dVbgVdIzOBBbrZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .rough-node .label,#mermaid-svg-x2dVbgVdIzOBBbrZ .node .label,#mermaid-svg-x2dVbgVdIzOBBbrZ .image-shape .label,#mermaid-svg-x2dVbgVdIzOBBbrZ .icon-shape .label{text-align:center;}#mermaid-svg-x2dVbgVdIzOBBbrZ .node.clickable{cursor:pointer;}#mermaid-svg-x2dVbgVdIzOBBbrZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .arrowheadPath{fill:#333333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-x2dVbgVdIzOBBbrZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-x2dVbgVdIzOBBbrZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-x2dVbgVdIzOBBbrZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-x2dVbgVdIzOBBbrZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .cluster text{fill:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ .cluster span{color:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-x2dVbgVdIzOBBbrZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-x2dVbgVdIzOBBbrZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-x2dVbgVdIzOBBbrZ .icon-shape,#mermaid-svg-x2dVbgVdIzOBBbrZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-x2dVbgVdIzOBBbrZ .icon-shape p,#mermaid-svg-x2dVbgVdIzOBBbrZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-x2dVbgVdIzOBBbrZ .icon-shape .label rect,#mermaid-svg-x2dVbgVdIzOBBbrZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-x2dVbgVdIzOBBbrZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-x2dVbgVdIzOBBbrZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-x2dVbgVdIzOBBbrZ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    数据库实例整体过载锁等待堆积、长事务堆积

    各服务的 Tomcat 线程全部卡在等数据库

    接口 60 秒没响应

    连接都建立不起来

    portal 调 patient 的 Feign等不到响应

    网关返回 504

    网关返回 502

    Feign 报 Read timed out

    用户看到的就是:系统崩了

    这里顺便澄清三个容易混淆的报错,它们其实是同一件事在不同层面的投影:

    报错谁报的一句话解释
    Lock wait timeout MySQL 等一个行锁等了 50 秒还没等到
    Read timed out Feign 客户端 调下游接口,约定时间内没等到响应
    504 Gateway Time-out nginx 我等上游 60 秒,超时了,主动断开
    502 Bad Gateway nginx 我连上游都连不上(对方线程全满或重启中)

    五、修复方案

    找到了根因,修复就清晰了。核心思路一句话:让故障的爆炸半径变小。

    修复 1:拆池 + 有界队列 + CallerRunsPolicy

    原来的问题是两个池混用 + 无限膨胀。我们改成:

    // 自建(SETTLE_IN)和拎包(HANDBAG)各一个池,彻底隔离
    private static final ExecutorService settleInExecutor = buildRefreshPool("schedule-refresh-settlein");
    private static final ExecutorService handbagExecutor = buildRefreshPool("schedule-refresh-handbag");

    private static ExecutorService buildRefreshPool(String prefix) {
    AtomicInteger seq = new AtomicInteger();
    return new ThreadPoolExecutor(3, 3, 0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<>(100), // 有界队列!
    r -> new Thread(r, prefix + "-" + seq.incrementAndGet()),
    new ThreadPoolExecutor.CallerRunsPolicy()); // 满了由提交线程自己执行
    }

    三个设计点分别说:

    ① 为什么拆两个池? 还是餐厅的例子:自建医院和拎包医院各开一家分店,各配 3 个厨师。自建分店的供应商(HIS)塌了,只会堵死自建自己的后厨,拎包分店照常营业。这次事故里拎包医院的刷新就是被自建的僵尸任务排着队"饿死"的——任务排在几百个 60 秒的任务后面,理论上要等好几个小时才轮得到。

    ② 为什么队列要有界? 队列无界 = 内存隐患 + 任务无限堆积。给 100 的上限,堆满就触发拒绝策略,绝不重演"1024 堆满后无限膨胀"。

    ③ 为什么用 CallerRunsPolicy? 这个策略的机制很有意思:队列满了之后,谁提交任务,谁自己干活。

    还是奶茶店的例子:3 个店员做奶茶,队伍限长 100 人。队伍排满后,下一个顾客来了,老板不让他排队,而是亲自给他做一杯——老板做奶茶期间,就不会再放新顾客进来排队。

    这就是"背压"(backpressure):下游消费不动时,压力反向传导,上游自动降速。效果是:

    • 任务一个都不会丢(要么店员做,要么老板做)
    • 队列永远不会溢出(满了就有人亲自下场消化)
    • 生产速度被强制限制在消费速度以内

    而且拎包医院总共就几十家,池子能装 103 个任务(3 执行 + 100 排队),正常情况下背压根本不会触发,它只是个保险丝。

    对比一下其他策略为什么不行:DiscardOldestPolicy 会直接丢任务(而且丢的是排队最久的,可能正好是拎包医院的),任务里负责计数的 countDown() 也永远不会执行,后续的等待逻辑直接乱套;AbortPolicy 会抛异常,而我们的提交代码在 for 循环里没捕获,一个异常就把后面所有医院的提交全断了。

    修复 2:HIS 调用加超时,快速失败

    第二个修复更直接:凭什么等 60 秒?号源查询本来就该秒级返回。

    @Post(url = "{url}", contentType = "application/json", dataType = "json",
    connectTimeout = 3000, readTimeout = 10000)
    Result<List<HosScheduleBO>> regListByJson(@Var("url") String url, @Body HosScheduleParam param);

    连接超时 3 秒,读取超时 10 秒。正常医院响应都是几百毫秒,10 秒留了 5 倍余量,不会误伤;只有真病了的请求才会被掐断。

    一个实战细节:项目用的 Forest 是 1.5.32 版本,没有 @ConnectTimeout、@ReadTimeout 这两个独立注解(那是 1.6.x 才加的),但 @Post 注解自带这两个属性,直接写在里面就行。这也是为什么要养成看依赖版本源码的习惯。

    这两个修复叠加后的效果:

    • 原来一个故障医生的刷新卡 60 秒 → 现在卡 10 秒
    • 原来任务堆积会膨胀到 17+ 并发 → 现在最多 3 并发,队列最多 100
    • 自建出事 → 拎包完全不受影响

    修复后的架构一图流:

    #mermaid-svg-DmCZkNjRn8jO2QF1{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DmCZkNjRn8jO2QF1 .error-icon{fill:#552222;}#mermaid-svg-DmCZkNjRn8jO2QF1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DmCZkNjRn8jO2QF1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .marker.cross{stroke:#333333;}#mermaid-svg-DmCZkNjRn8jO2QF1 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DmCZkNjRn8jO2QF1 p{margin:0;}#mermaid-svg-DmCZkNjRn8jO2QF1 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .cluster-label text{fill:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .cluster-label span{color:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .cluster-label span p{background-color:transparent;}#mermaid-svg-DmCZkNjRn8jO2QF1 .label text,#mermaid-svg-DmCZkNjRn8jO2QF1 span{fill:#333;color:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .node rect,#mermaid-svg-DmCZkNjRn8jO2QF1 .node circle,#mermaid-svg-DmCZkNjRn8jO2QF1 .node ellipse,#mermaid-svg-DmCZkNjRn8jO2QF1 .node polygon,#mermaid-svg-DmCZkNjRn8jO2QF1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .rough-node .label text,#mermaid-svg-DmCZkNjRn8jO2QF1 .node .label text,#mermaid-svg-DmCZkNjRn8jO2QF1 .image-shape .label,#mermaid-svg-DmCZkNjRn8jO2QF1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-DmCZkNjRn8jO2QF1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .rough-node .label,#mermaid-svg-DmCZkNjRn8jO2QF1 .node .label,#mermaid-svg-DmCZkNjRn8jO2QF1 .image-shape .label,#mermaid-svg-DmCZkNjRn8jO2QF1 .icon-shape .label{text-align:center;}#mermaid-svg-DmCZkNjRn8jO2QF1 .node.clickable{cursor:pointer;}#mermaid-svg-DmCZkNjRn8jO2QF1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .arrowheadPath{fill:#333333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DmCZkNjRn8jO2QF1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DmCZkNjRn8jO2QF1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DmCZkNjRn8jO2QF1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DmCZkNjRn8jO2QF1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .cluster text{fill:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 .cluster span{color:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DmCZkNjRn8jO2QF1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DmCZkNjRn8jO2QF1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-DmCZkNjRn8jO2QF1 .icon-shape,#mermaid-svg-DmCZkNjRn8jO2QF1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DmCZkNjRn8jO2QF1 .icon-shape p,#mermaid-svg-DmCZkNjRn8jO2QF1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DmCZkNjRn8jO2QF1 .icon-shape .label rect,#mermaid-svg-DmCZkNjRn8jO2QF1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DmCZkNjRn8jO2QF1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DmCZkNjRn8jO2QF1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DmCZkNjRn8jO2QF1 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    拎包池 schedule-refresh-handbag

    自建池 schedule-refresh-settlein

    连接超时 3s / 读取超时 10s快速失败

    连接超时 3s / 读取超时 10s

    队列满 → CallerRunsPolicy提交线程亲自执行,背压降速任务一个不丢

    保险丝:正常永不触发

    自建医院刷新任务

    固定 3 线程+ 有界队列 100

    拎包医院刷新任务

    固定 3 线程+ 有界队列 100

    自建 HIS(故障中)

    拎包 HIS(正常)

    后续我们还规划了第三层防护:医院维度的熔断降级(连续失败 N 次直接跳过这家医院几分钟,用 Resilience4j 的编程式 API 按医院 ID 动态创建熔断器),配合 Forest 超时形成"单次快失败 + 整体早跳过"的双保险。这个改造量大一些,放在二期。

    六、该方案会不会影响拎包医院正常刷新?——不会,而且比原来更顺畅

    用数字算一笔账就清楚了:

    指标数值
    拎包池容量 3 个工作线程 + 100 个队列位 = 可容纳 103 个任务
    一轮提交量 拎包医院总数(一般十几~几十家,远小于 103)
    提交速率 每秒 1 家(for 循环里 sleep(1000)),一轮提交完就进入 latch.await(10分钟) 暂停
    单任务耗时 正常 HIS 秒级响应(现在超时上限 10s),一家医院几分钟内跑完

    结论:拎包医院数量 < 103 的前提下,队列永远不可能满,CallerRunsPolicy 根本没有触发机会——它只是个永不引爆的保险丝。正常刷新路径和原来完全一样:submit 进队列 → 3 个工作线程轮流执行。

    对比原来反而更好:原实现拎包和自建共用一个池,自建故障时拎包任务排在几百个 60 秒任务后面被“饿死”(刷新滞后几小时);现在拎包独享 3 线程,自建那边怎么乱都影响不到它。

    七、CallerRunsPolicy 背压机制详解

    1. 先看 ThreadPoolExecutor 提交任务的四步决策链

    executor.submit(task) 每次提交时按顺序检查:

    ① 线程数 < 3(core)? → 新建工作线程执行 ← 正常时走这里
    ② 核心 3 个都忙? → 尝试放进队列(容量100)
    ③ 队列也满(100个)? → 尝试新建线程?本池 max=3,拒绝
    ④ 全都放不下 → 触发 RejectedExecutionHandler
    ↓
    CallerRunsPolicy 在这里接管

    2. CallerRunsPolicy 的行为:“谁提交,谁干活”

    走到第④步时,它不丢任务、不抛异常,而是让调用 submit() 的那个线程——也就是 refreshHospitalScheduleByType 里的长轮询线程——当场把这家医院的号源刷新任务同步跑完:

    executor.submit(() -> {
    refreshHosScheduleTask(hospitalId); // ← 队列满时, 轮询线程亲自在这里执行
    latch.countDown();
    });
    // submit 这一行会"卡住", 直到任务跑完才返回, for 循环才继续提交下一家

    3. 为什么这叫“背压”(backpressure)

    背压 = 下游消费不动时,压力反向传导给上游,让生产自动降速。看触发后的连锁反应:

    拎包HIS突然全体变慢(比如全部卡10s超时)
    ↓
    3个工作线程都被长任务占住, 队列逐渐堆满100
    ↓
    第104个任务提交时触发CallerRuns → 轮询线程亲自执行
    ↓
    轮询线程干活期间, for循环暂停 → 生产速率从"每秒1个"降到 0
    ↓
    队列被3个工作线程慢慢消化, 出现空位
    ↓
    轮询线程恢复submit, 继续提交剩余医院

    核心效果:生产速度被强制限制在消费速度以内。队列永远 ≤ 100,任务永远不会无限制堆积。对比原实现:队列 1024 堆满后池开始无限膨胀,十几路并发把数据库打挂——背压正是那个灾难的解药。

    4. “任务不丢、不堆积、语义完整”逐词解释

    说法含义
    任务不丢 CallerRuns 不丢弃任何任务——每个提交的任务要么被工作线程执行,要么被提交线程亲自执行,100% 都会跑,且 latch.countDown() 必然执行(这是 DiscardOldest 做不到的)
    不堆积 队列有 100 硬上限,超出的部分由提交线程同步消化掉,不会出现“队列 1024 堆满后线程池膨胀到 17+”
    语义完整 latch.await(10分钟) 等的 count 减到 0 的路径完整——所有任务都真实执行过,不会白等满 10 分钟
    代价 触发时本轮提交变慢(轮询线程被占用)——但这正是想要的行为:宁可刷得慢,不可堆到爆。正常情况下(见第一问的算账)它根本不会触发

    八、总结:这次事故教会我们的三件事

    1. 别迷信工具类的"便捷方法"。 ThreadUtil.newExecutor(3) 看起来是"3 线程池",实际是"3 核心 + 21 亿上限 + 1024 队列"。线程池四个参数(core、max、队列、拒绝策略)是个整体设计,用 ThreadPoolExecutor 显式把参数写全,代码多三行,心里踏实一百倍。

    2. 线程池隔离 ≠ 资源隔离。 每个业务用独立的线程池,只能保证 Tomcat 工作线程不被占用——这是很多人理解的"隔离"。但线程池背后的数据库、缓存、下游接口是共享的,真正的爆炸半径要沿着共享资源去评估。这次事故里,线程池一个没满,系统照样崩了,崩在数据库上。

    3. "再等等看"的代码最危险。 面对故障的下游,等 60 秒和等 10 秒的结果都是失败,但前者会让失败以 6 倍的速度堆积。给所有外部调用设置合理的超时,是性价比最高的防御手段。

    最后留个思考题:如果自建医院 HIS 故障持续 30 分钟,修复后的系统会发生什么?

    答案:自建池 3 个线程各自慢速重试(每个医生 10 秒超时),队列慢慢堆到接近 100 后触发背压,轮询线程亲自下场干活,整体降速但绝不膨胀;拎包池毫无感知,正常刷新;数据库无压力。这就是我们想要的故障隔离。


    如果这篇文章帮你避了一个坑,点个赞再走吧。

    赞(0)
    未经允许不得转载:171主机测评 » 记一次线上事故:一个“不起眼“的线程池配置-无界队列,如何拖垮整个微服务系统
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址