栈:Kafka 3.5.1(KRaft,3 broker,8C32G)、Spring Kafka 3.1.5、Java 17(G1)、MySQL 8.0.36(NVMe SSD)、HikariCP 5.1 场景:日志落库管道
event-ingest,32 分区,日均 6000 万条,目标表t_event_log约 1.1 亿行、6 个二级索引、单表 11GB
一、现象与背景:加 12 个线程,吞吐掉了 38%
上周的故障复盘。管道在业务高峰(每天 17:00–19:00)lag 稳定爬升:30 分钟积压 80 万条。当时的消费端结构很朴素:
- 消费组 4 实例,每实例分配 8 个分区;
- 每实例内部一个 4 线程池 并发处理
poll()回来的消息(max.poll.records=2000,4 线程各分到 500 条),逐条addBatch→executeBatch→commitSync; - HikariCP
maximumPoolSize=4(每实例)。
值班同学的第一反应是「消费太慢,加线程」,把线程池从 4 调到 16,结果 10 分钟内的实测数据:
| 指标 | 4 线程 | 16 线程 | |---|---|---| | 单实例吞吐 | 2.1k msg/s | 1.3k msg/s(-38%) | | 单条处理 P99 | 180ms | 640ms(×3.6) | | CPU(8 核) | 41% | 37% | | HikariCP active/max | 3–4 / 4 | 恒 4 / 4,等待队列 8–14 |
CPU 没打满、broker 侧 UnderReplicatedPartitions=0、MySQL Threads_running 只有 5 条——每一个看起来都像「不忙」,但吞吐就是掉了。这就是我要讲的问题:在「多消费线程共享 JDBC 连接池做批量写」的链路上,加线程不是加并行度,是把竞争放大。
二、根因:三个锁点把 16 个线程串行成了 4 个
锁点 1:HikariCP 连接池是硬天花板
16 个线程、4 条连接,多出来的 12 个线程全部睡在 awaitConnection 上。HikariCP 的默认 connectionTimeout=30s,所以不报错——「加线程」在这条链路上的物理上限就是 4 并发,第 5 个线程开始,吞吐增益恒为 0,只贡献上下文切换和排队。
锁点 2:rewriteBatchedStatements 把 batch 拼成 multi-row INSERT,串行化了
这是最容易被忽略的一层。开了 rewriteBatchedStatements=true 之后(不开的话参见 @林墨白 在《JDBC 批量插入从 1200 行/s 提到 9 万行/s》里的量化拆解),Connector/J 会把整批 addBatch 拼成一条 INSERT INTO ... VALUES (...),(...),(...) 大语句发出去。问题是:
- 这条大语句持有一条连接直到整个语句执行完;
- 4 条连接、16 个线程轮流抢,每次抢到的都是一次「500 行 multi-row INSERT」的完整执行窗口(我们实测单条 500 行 INSERT 平均 90–120ms,P99 260ms);
- 于是 16 线程的「并发」退化成「4 并发 + 12 排队」,而排队线程每次被唤醒还要重走一次取连接、拼语句、等待的完整路径。
线程数 > 连接数时,JDBC batch 链路的有效并发恒等于连接数,这是第一性结论。
锁点 3:InnoDB 侧的页争用被并发放大
更隐蔽的一层。1.1 亿行、6 个二级索引,buffer pool 16GB(表 11GB + 索引约 9GB,装不下全部)。4 并发时写入是相对顺序的追加,页分裂率约 40/s;16 并发抢同几条连接的写入到达时间更密集、更乱序,实测:
SHOW ENGINE INNODB STATUS里History list length从 120 涨到 1400+(undo 堆积,purge 追不上);- 二级索引页分裂 40/s → 320/s,每次分裂都是一次同步磁盘写;
- redo log 写速率从 28MB/s 涨到 91MB/s,逼近
innodb_log_file_size=4GB的 checkpoint 触发线,出现间歇性 flush 风暴。
也就是说:数据库侧的写路径在低并发下是「准顺序」的,高并发下变成「随机」的——同样的数据量,16 线程的 I/O 形态比 4 线程差一个量级。CPU 没满、连接在等、磁盘在抖,三者同时成立,只有「锁竞争 + I/O 形态劣化」能解释。
三、具体做法:把「消费线程」和「写库线程」解耦,线程数按连接数算
改造后的结构(单实例内):
Kafka poll 线程(1 个)
→ 内存有界队列(ArrayBlockingQueue,容量 8192)
→ 写库线程池(大小 = HikariCP maximumPoolSize = 4,每连接 1 线程)
→ JDBC batch(每批 500 行)→ commitSync
关键配置:
# 消费端
max.poll.records=2000 # poll 一次拿多,摊薄 poll 开销
fetch.min.bytes=524288 # 512KB 凑批
fetch.max.wait.ms=200
enable.auto.commit=false
max.poll.interval.ms=600000 # 队列积压时 poll 线程只入队不处理,预算放宽
# 写库侧
maximumPoolSize=4 # 与写库线程数严格相等
jdbcUrl=...&rewriteBatchedStatements=true&useServerPrepStmts=false
写库线程池的拒绝策略不是默认的 AbortPolicy,而是入队失败即 consumer.pause(),队列水位降到 50% 以下再 resume()——把背压做在消费端而不是让队列无限涨(pause/resume 的坑位 @江景行-345 在《Kafka 消费端背压的四层降级》里讲得很细,这里只给结论:必须成对,且要兜底超时,否则实例「假活」)。
改造后 10 分钟实测:
| 指标 | 4 线程(旧) | 16 线程(旧) | 解耦 + 4 写线程(新) | |---|---|---|---| | 单实例吞吐 | 2.1k msg/s | 1.3k msg/s | 2.6k msg/s(+24%) | | 单条处理 P99 | 180ms | 640ms | 140ms | | History list length | 120 | 1400+ | 80 | | 页分裂 | 40/s | 320/s | 35/s |
吞吐反而比最初的 4 线程版本高了 24%,P99 更低——因为 poll 线程与写库线程解耦后,poll() 不再和 executeBatch 抢同一条线程的时间片,batch 的凑批更满(平均批大小从 380 行涨到 492 行)。
四、踩过的坑与反直觉之处
- 「加线程」在 JDBC batch 链路上的有效并发上限 = 连接池大小。线程数超过连接数,多出来的线程只做排队,且每个排队线程被唤醒后都要重走一次「取连接→拼 multi-row INSERT→等待」的完整路径,总耗时反而更长。正确姿势是先定连接数,再定线程数 = 连接数,而不是反过来。
rewriteBatchedStatements=true只支持 INSERT(包括 multi-table INSERT),对 UPDATE / DELETE / SELECT 不生效。我们最早在「批量更新状态」的链路上也开了这个参数,以为同样享受 multi-row 优化,实际上驱动根本没改写,还是逐条UPDATE ... WHERE id=?发出去——这个坑文档里有一行字,但没人会去读那一行。- P99 比均值更值得盯,但要盯「单条 P99 × 批大小」。@江景行-345 在文里给过一个很好的公式:消费端容量按 P99 × 批大小算,不是均值。我们的 4 线程版本,均值 95ms 看着健康,P99 260ms × 500 行 = 单批最坏 130s,离
max.poll.interval.ms=300s的预算只差 2.3 倍余量——一次 GC 或者一次页分裂风暴就击穿。 - InnoDB 的写路径对「并发到达形态」敏感,不只是对「数据量」敏感。同样的 6000 万条/天,4 并发是准顺序追加(页分裂 40/s),16 并发变成乱序插入(页分裂 320/s)。这意味着「加机器」「加线程」这类扩容手段,在写多读少的流水表上不一定单调有效,要先看 I/O 形态再决定。
History list length是判断「并发写是否过载」最便宜的一个指标。它不依赖任何额外埋点,SHOW ENGINE INNODB STATUS一眼就能看到,而且它在页分裂、redo 刷盘、buffer pool 装不下这三类问题里都会先于其他指标动。我们后来把它加进了核心监控面板,阈值设在 500(超过即告警)。
五、结论与可复用清单
- Kafka 消费端「加线程」不是免费的。在共享 JDBC 连接池做批量写的链路上,有效并发 = min(线程数, 连接数),超过的部分纯排队。先定连接数,再定线程数。
- 消费线程与写库线程解耦(poll 线程入队、写库线程消费队列),配合
pause/resume做背压,是比「加线程」更稳的扩吞吐手段。 rewriteBatchedStatements=true只优化 INSERT,UPDATE/DELETE 批量场景要换INSERT ... ON DUPLICATE KEY UPDATE或拆成两条语句。- 写多读少的流水表,扩容前先看 I/O 形态:页分裂率、History list length、redo 写速率,三个指标同时恶化说明「并发到达形态」劣化,加线程只会更糟。
max.poll.interval.ms的预算要按「P99 × 批大小 × 安全系数 2」算,不是按均值。我们的 4 线程版本预算余量只有 2.3 倍,一次 GC 就击穿——这是 @江景行-345 那篇文里最值钱的公式之一。History list length > 500作为一级告警阈值,比 CPU、连接池等待队列都更早暴露写路径过载。