高并发写入实测:线程池队列打满、CallerRuns 退化,把最重的 MySQL 改回同步,QPS 反而涨了 38%

131 阅读14分钟

「高并发实测」系列第 2 篇。

环境:4C/8T 单机笔记本,Docker 部署 MySQL / Redis / ES / Mongo / Kafka,Spring Boot 2.5 + JDK 8,线程池 core=12 / max=24 / queue=1000(CallerRunsPolicy)。k6 每档 250 并发持续 60s,每个版本先跑 3 轮 10s 预热,正式跑 3 轮取中位数,每轮结束做四库对账。结论有强前提——CPU 是瓶颈,资源富裕时别照搬。

〇、一分钟带走版

一条「HTTP → Disruptor → Kafka → 3 个消费者 → 写 4 个存储」的高并发写入链路,三种"同步/异步怎么分配"的写法,交错跑了 9 轮(每轮都是全新进程),结果:

版本写入方式中位 QPS线程池状态
AES 同步 + 其余 3 个异步7,574队列打满 1000,CallerRuns 持续触发
B4 个存储全异步7,387队列打满 1000,触发最猛(367-448 次/60s)
CMySQL 同步 + 其余 3 个异步10,171队列全程≈0,一次都没触发

把最重的 MySQL 改回同步执行,比"全异步"快了 38%——「异步 ≠ 更快」。 根因是线程池的 CallerRunsPolicy:高负载下它把异步任务踢回调用线程执行,你以为在异步,其实早就退化成同步了,还额外背上调度开销。这次我用埋点把它抓了个正着(第三节),顺便还讲一个我们差点发布错误结论的故事(第四节)——它比性能数字更值得看。

全文关键词:线程池拒绝策略、CallerRunsPolicy、Kafka 消费者、异步/同步选型、压测方法论、数据一致性对账。

一、场景:一条典型的"削峰 + 多写"链路

先说清楚:这是我自己搭的实验环境,不是生产系统。 我想验证一个业务系统里非常典型的形态——接口收到数据要同时落到 4 个存储(MySQL 存业务、Redis 做缓存、ES 做搜索、Mongo 做流水),还要扛住突发流量。既然"削峰 + 多写"到处都在用,我就把整条链路搭出来,把"同步/异步怎么分配"这一个变量单独拎出来压。

实现分三段:

HTTP → Controller → Disruptor RingBuffer(2^17) → Kafka Producer → Kafka(4 分区)
                                                                    ↓
                                              3 个消费者线程(批量拉取 + 手动 ACK)
                                                                    ↓
                                                    写入 4 个存储(批量)

问题出在最后一步:这 4 个存储的写入,是同步做还是异步做?

二、对照实验:一个变量,三轮中位数

先交代实验是怎么试出来的——我是穷举着测的:最先是 4 个存储全部同步写;然后一个一个换,让每个存储轮流当那个"同步"、其余异步;最后才收敛到现在这个组合(MySQL 同步 + 其余 3 个异步)。

为什么最终是 MySQL 当同步?因为四个存储里只有它是主数据源——Redis 是缓存、ES 是搜索索引、Mongo 是流水,这三个丢了都能从 MySQL 重建,只有 MySQL 不行。所以"最不能丢的那个先落、同步落",是从数据角色推出来的选择,不是猜的。

(其余组合的完整记录我没留全,下面只讲三个有完整数据的代表性版本。)

三个版本的代码差异只有一处——消费者拿到一批数据后,怎么安排这 4 次写入:

// 版本 A:ES 同步(bulkAsync().get() 等 HTTP 返回),其余异步
CasUtil.runSafe(() -> bulkProcessorESService.batchBulkWrite(list));
CompletableFuture.allOf(Redis, MySQL, Mongo → taskExecutor);

// 版本 B:4 个存储全部丢进线程池,全异步
CompletableFuture.allOf(Redis, ES, MySQL, Mongo → taskExecutor);

// 版本 C:MySQL 同步(消费者线程直接 batchInsert),其余异步
CasUtil.runSafe(() -> batchInsertService.batchInsert(list));
CompletableFuture.allOf(Redis, ES, Mongo → taskExecutor);

注意版本 C 里那个"同步"是真同步——batchInsert() 是实打实的 DB 批量写入,不是往队列里 offer() 一下就返回:

@Transactional
public void batchInsert(List<User> userList) {
    userMapper.batchInsert(userList);   // 真正的 DB 写,不是入队
}

实验协议(这一条差点没做到,第四节细说):

  • 三个版本交错轮跑:每轮按 A→B→C 顺序各起一个全新进程(先杀干净再启动,并校验启动成功)
  • 每个进程先跑 3 轮 10s 预热(排除 JIT 冷启动),再正式跑 250 并发 × 60s
  • 共 3 轮 × 3 版本 = 9 次运行,每模式取中位数
  • 每次正式运行前后做四库对账(第五节)

9 次运行的原始数据:

轮次ABC
17,5897,4219,680
27,5747,37810,171
36,8807,38710,430
中位数7,5747,38710,171

three-versions.png

C 比 B 快 +37.7%,比 A 快 +34.3%;模式内波动只有 ±5%——这次信号是干净的。一个会阻塞消费者线程的真实同步 DB 写,赢了把 I/O 全部藏进线程池的版本。是谁在拖后腿?线程池自己。

三、凶手:CallerRunsPolicy 把"异步"偷偷变回同步

先看线程池配置:

taskExecutor:core=12,max=24,queue=1000,拒绝策略 = CallerRunsPolicy

CallerRunsPolicy 的行为是:队列满了、线程也满了,新任务不丢弃,而是由提交任务的那个线程自己执行。设计意图是"反压 + 不丢数据",听起来很稳妥。但放进这条链路里,画面变成:

Kafka 消费者线程 → 提交 4 个异步任务 → taskExecutor 队列满
                                    → CallerRuns 触发
                                    → 消费者线程被迫自己执行 ES/Redis/Mongo 写入 ❌

说好的异步呢? 消费者线程不但要等,还等了个更贵的:既付出线程池调度成本,又付出同步阻塞成本。而且全程没有一行异常日志。

光说没用,这次我给线程池埋了点:自定义一个带计数的拒绝策略(每次触发计数 +1 并打时间戳),外加每秒采样一次 active / poolSize / queue / completed / CallerRuns 累计 / 进程 CPU。9 轮跑下来,A/B/C 的线程池行为泾渭分明:

指标(60s 正式窗口)ABC
队列峰值1000(满仓)1000(满仓)0
活跃线程峰值24(钉死 max)24(钉死 max)6-7
CallerRuns 触发次数178-251367-4480

timeline.png

同一个线程池、同样的流量,A/B 被打爆,C 基本闲着。 B 的触发频率最高——4 个异步任务全挤一个池,I/O 等待把 24 个线程占满,队列顶到 1000,之后每秒都有 6-8 个任务被踢回消费者线程"亲自下场"。

一个我还没归因的反常:A 也只有 3 个异步任务(和 C 一样多),却和 B 一样把队列打满了。目前的猜测是——ES 同步把消费者线程拖慢后,批次到达的节奏从"均匀"变成"一阵一阵",瞬时并发反而更高。但这只是猜测,要证实得给 A 再补一组拆解实验。先如实标在这里,不硬编故事。

机制串起来就是这条恶性循环:

I/O 密集型任务长期占住线程(阻塞式 I/O 等待时线程不释放)
  → 新批次到达,可用线程不足,queue(1000) 填满
  → CallerRuns 触发,消费者线程亲自同步写
  → 消费者被阻塞,RingBuffer 释放变慢 → Producer 端反压 → QPS 下跌
  → 下一批任务继续堆积……(正反馈恶化)

callerruns.png

而版本 C 把最重的 MySQL 拿回消费者线程同步做(本地 Docker 批量 insert 只要约 3ms),池里只剩 3 个轻量任务——队列再也没有满过,CallerRuns 一次都没触发。减少的不是工作量,是让线程池退出了那个"满负荷 + 频繁退化"的恶性区间。

一句话总结这个坑:CallerRunsPolicy 在低负载时是安全网,在高负载 I/O 场景下是伪装成异步的同步器。

四、差点被骗:这组数字重跑了三遍才对

上面那句"实验协议"看起来平平无奇,但它是踩了两次坑之后才立起来的。这段值得所有写压测的人看。

第一坑:杀不死的进程。 压测脚本里用 kill $(cat app.pid) 停应用,在 Windows + Git Bash 下经常是无效操作(MSYS 的 PID 不是 Windows 的 PID)。结果:上一个版本的进程一直霸占 8081,新起的进程端口冲突静默失败,k6 的流量从头到尾打在同一个旧应用上。我第一轮"交错矩阵"跑完,A/B/C 三个模式的 QPS 中位数几乎相同——当时差点下结论"三种写法没差异,原结论是错的"。

说实话,看到那三个几乎相同的中位数时,我的第一反应不是兴奋,是不信。

如果三种写法真的没差异,那我前面几轮的结论就全是噪声——这个可能性当然存在,但不能因为"看起来也说得通"就接受它。所以我做了两件事:给线程池加埋点,把内部状态打出来;再多跑几轮交叉验证。

正是这个"不信"救了我。

揭穿它的是一行日志。 埋点计数器显示 CallerRuns 累计值在 9 轮之间严格单调递增(218→424→694→…→1959)。如果每轮真是独立的新进程,计数器每轮都必须从 0 开始——9 个独立计数器恰好严格递增的概率约四十万分之一。唯一解释:9 轮跑的是同一个进程。 改用按映像名强杀 + 每轮校验启动标记后重跑,才有了第二节那张干净的表。

第二坑:冷启动和脏数据。 修好进程问题后还栽过两次:一次是 k6 打在一个已经崩掉的应用上,60 秒"跑出" 13,845 QPS、P95 归零——错误响应被计成了请求;一次是清空数据库的时机早于消费积压排干,TRUNCATE 之后残留的异步写入又把数据填了回来,对账永远差一截。

三个教训,值一张卡片:

① 每次压测前验证被测进程是新的(计数器归零、PID 比对、启动日志标记,任选其一)

② QPS 异常高 + 延迟异常低 = 你打在了空气上

③ 清空数据要在写入端完全排干之后,清完还要再确认一次

这也是我为什么把"协议"写进正文:压测文章里最贵的不是结论,是"这个结论在什么操作下才成立"。

五、闭环对账:快慢之外,先看丢没丢

压测只能回答"多快",回答不了"丢没丢"。所以 9 次运行每次都做同一套对账:

清空四库(等消费积压排干 + 二次确认归零)
  → 正式压测 60s,记录 k6 注入请求数 N
  → 等四库落库排干
  → 对账:MySQL 16 分表 COUNT 之和 == Redis key 数 == ES _count == Mongo countDocuments == N

结果:9 次运行,四库总量全部精确等于注入数,一次不差。 最高的 C 第三轮是 626,538 条 / 60 秒,四个存储各自都是 626,538。

轮次版本注入四库对账
1-3A413K-455K✅✅✅
1-3B443K-445K✅✅✅
1-3C581K-627K✅✅✅

两个结论:

  1. 三种模式都不丢数据——CallerRunsPolicy 确实兜住了任务(它把任务退回调用线程执行而不是丢弃),"异步 vs 同步"的选型是性能问题,不是安全问题;
  2. 真正的安全风险在别处,而且三种模式都有:代码里 ack.acknowledge() 都在异步任务提交之后立即执行(A/B/C 都一样),不等写入真正完成。稳态下没问题——这次 9 次对账一条没丢;但如果恰好在"任务已提交、尚未执行完"时进程被杀(发布、OOM、K8s 驱逐),队列里那些任务就是丢的数据,而 Kafka 已经认为消费成功了。C 的暴露窗口最小(只剩 3 个轻量任务、队列从不积压),A/B 的暴露窗口是那个 1000 容量的队列。要么等写入完成再 ACK,要么明确接受这个窗口——这次没踩到,不代表它不存在。

六、那到底什么时候该异步?

复盘完,判断标准收敛成一张表:

情况建议依据
单次操作耗时 < 5ms,且失败必须立刻感知同步调度开销比 I/O 本身还贵;本次 MySQL 本地批量 insert 约 3ms
单次操作耗时 > 10ms,且失败可以后补异步ES 同步一次 10–30ms,阻塞代价太大
线程池共享、多个 I/O 任务竞争同一池减少任务数或拆池本次 B 的 4 任务/批把 queue(1000) 打满
拒绝策略是 CallerRunsPolicy必须监控队列水位和拒绝计数它让"异步"静默退化成同步,零日志
CPU 是明确瓶颈(核少、线程多)优先做减法4C/8T 上线程总数 20+,调度成本极高
CPU 富裕、I/O 等待时间长异步前提不成立时,本文结论不适用

七、如果继续优化,我会做这五件事

方向预期收益依据
给线程池加监控(active / queue / 拒绝计数)让退化可见本次最大教训是"静默退化",一个计数指标五分钟定位
拆分线程池(每存储独立池)故障隔离,避免互相争抢共享池一刀切,一个慢存储拖垮全部
四存储分离部署解除 CPU 争抢当前单机是绝对瓶颈
关键后端用 Semaphore 限并发把"拒绝"变成可控的"排队/降级"比 CallerRuns 的隐式反压更可观测
增加 Kafka 消费者数(3→6)提速消费需要更多 CPU 支撑

八、写在最后

这篇最有价值的不是 38%,是三件事:

  1. "异步"是一个需要被验证的假设,不是默认正确的选择。 我是穷举着测出来的:全同步 → 每个存储轮流当同步 → 全异步,最后收敛到"MySQL 同步 + 其余异步"。而那个"看起来更高级"的全异步版本,比最终方案慢了 27%。这次推翻的不是别人的八股,是"把 I/O 藏进线程池就更先进"这个直觉。
  2. 反常数据比符合预期的数据更值钱。 差点因为"轮跑结果一致"就推翻原结论——揭穿假象的恰恰是埋点里那行不该单调递增的计数器。
  3. 优化的方向不一定是"加"。 在这台 CPU 紧张的机器上,减少并发任务数比调大线程池更有效。

局限照例先说:

  • 环境:单机 4C/8T、Docker 部署全部中间件、250 并发档、正式 3 轮取中位数(轮次仍偏少,模式内 ±5% 的波动是真实水平);未记录内存分配器(jemalloc / libc)与 lazyfree 配置。
  • 适用前提:结论建立在 CPU 是瓶颈 之上。如果你的机器核多、下游 I/O 延迟高,异步的收益大概率仍然大于调度成本,别照搬。
  • 施压模型:本实验用闭环压测(固定 250 VU),存在协同遗漏(coordinated omission) 效应:系统变慢时施压速率会同步下降,因此绝对延迟值偏乐观。三种方案用同一施压模型对比,相对结论不受影响。
  • 覆盖范围:我把"每个存储轮流当同步"以及"全同步"都跑过,趋势一致,但早期那几轮的完整记录没留全,所以本文只报有完整数据的三个代表性版本,其余组合不列具体数字。

如果你也在用共享线程池 + CallerRunsPolicy,去看一眼你的队列水位和拒绝计数。很可能你线上跑的也不是异步。


八股可以被背,结论只能被跑出来。这是「高并发实测」系列第二篇,上一篇:《别再死背 44 字节了:Redis String 三种编码压测》。