「高并发实测」系列第 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 | 线程池状态 |
|---|---|---|---|
| A | ES 同步 + 其余 3 个异步 | 7,574 | 队列打满 1000,CallerRuns 持续触发 |
| B | 4 个存储全异步 | 7,387 | 队列打满 1000,触发最猛(367-448 次/60s) |
| C | MySQL 同步 + 其余 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 次运行的原始数据:
| 轮次 | A | B | C |
|---|---|---|---|
| 1 | 7,589 | 7,421 | 9,680 |
| 2 | 7,574 | 7,378 | 10,171 |
| 3 | 6,880 | 7,387 | 10,430 |
| 中位数 | 7,574 | 7,387 | 10,171 |
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 正式窗口) | A | B | C |
|---|---|---|---|
| 队列峰值 | 1000(满仓) | 1000(满仓) | 0 |
| 活跃线程峰值 | 24(钉死 max) | 24(钉死 max) | 6-7 |
| CallerRuns 触发次数 | 178-251 | 367-448 | 0 |
同一个线程池、同样的流量,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 下跌
→ 下一批任务继续堆积……(正反馈恶化)
而版本 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-3 | A | 413K-455K | ✅✅✅ |
| 1-3 | B | 443K-445K | ✅✅✅ |
| 1-3 | C | 581K-627K | ✅✅✅ |
两个结论:
- 三种模式都不丢数据——
CallerRunsPolicy确实兜住了任务(它把任务退回调用线程执行而不是丢弃),"异步 vs 同步"的选型是性能问题,不是安全问题; - 真正的安全风险在别处,而且三种模式都有:代码里
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%,是三件事:
- "异步"是一个需要被验证的假设,不是默认正确的选择。 我是穷举着测出来的:全同步 → 每个存储轮流当同步 → 全异步,最后收敛到"MySQL 同步 + 其余异步"。而那个"看起来更高级"的全异步版本,比最终方案慢了 27%。这次推翻的不是别人的八股,是"把 I/O 藏进线程池就更先进"这个直觉。
- 反常数据比符合预期的数据更值钱。 差点因为"轮跑结果一致"就推翻原结论——揭穿假象的恰恰是埋点里那行不该单调递增的计数器。
- 优化的方向不一定是"加"。 在这台 CPU 紧张的机器上,减少并发任务数比调大线程池更有效。
局限照例先说:
- 环境:单机 4C/8T、Docker 部署全部中间件、250 并发档、正式 3 轮取中位数(轮次仍偏少,模式内 ±5% 的波动是真实水平);未记录内存分配器(jemalloc / libc)与 lazyfree 配置。
- 适用前提:结论建立在 CPU 是瓶颈 之上。如果你的机器核多、下游 I/O 延迟高,异步的收益大概率仍然大于调度成本,别照搬。
- 施压模型:本实验用闭环压测(固定 250 VU),存在协同遗漏(coordinated omission) 效应:系统变慢时施压速率会同步下降,因此绝对延迟值偏乐观。三种方案用同一施压模型对比,相对结论不受影响。
- 覆盖范围:我把"每个存储轮流当同步"以及"全同步"都跑过,趋势一致,但早期那几轮的完整记录没留全,所以本文只报有完整数据的三个代表性版本,其余组合不列具体数字。
如果你也在用共享线程池 + CallerRunsPolicy,去看一眼你的队列水位和拒绝计数。很可能你线上跑的也不是异步。
八股可以被背,结论只能被跑出来。这是「高并发实测」系列第二篇,上一篇:《别再死背 44 字节了:Redis String 三种编码压测》。