业务系统性能改造手册 · 执行提速
让线程池与连接池共用容量预算
从请求时间线和下游容量反推应用工作线程、任务队列与数据库连接预算,用有界队列、明确拒绝和阶梯升压暴露过载,而不是靠扩大线程池制造更多等待。
03-02 已经减少逐条访问,并测出了批次执行时间和连接占用。接下来很容易走进另一个误区:既然单批可以执行,那就多开一些线程并行跑。
线程数加上去后,吞吐可能短暂提高,也可能只是把等待从入口搬到任务队列,再搬到数据库连接池。工作线程拿不到连接时并没有做业务工作,却仍占着线程;队列继续接收任务时,调用方看到的不是明确过载,而是越来越长的尾延迟。
小编不会先问“线程池配多少”。先要回答的是:数据库和其他下游在当前负载下允许多少工作同时进入,单个请求在各段最多可以等待多久。线程、队列和连接都应从这份容量预算中取数。
一、先把一次请求中的等待拆开
只看接口 P99 无法判断应该改哪个池。为订单批量查询或状态更新补齐下面这条时间线:
1请求到达
2 → 等待工作线程
3 → 工作线程执行非数据库逻辑
4 → 等待数据库连接
5 → 持有连接执行 SQL / 事务
6 → 释放连接
7 → 执行剩余逻辑并返回同一个实验窗口内,至少对齐这些数据:
| 观察面 | 必须保留的原始值 | 它能回答的问题 |
|---|---|---|
| 接口 | 吞吐、P50/P95/P99、超时和错误数 | 用户看到的延迟是否已越过目标 |
| 工作线程池 | pool size、active、queue size、completed、rejected | 任务是在排队、执行,还是已被拒绝 |
| JVM 线程 | RUNNABLE、WAITING、TIMED_WAITING 的栈样本 | 活跃线程是在计算,还是等连接、锁或下游 |
| HikariCP | active、idle、pending、连接获取耗时与超时 | 数据库连接是否成为准入瓶颈 |
| 数据库 | 活跃会话、SQL 延迟、锁等待、CPU、I/O | 增加连接是否已让数据库本身变慢 |
不要把不同采集窗口的峰值拼成一张因果图。接口、线程池、连接池和数据库指标需要使用相同实验 ID、阶段和时间窗口,线程栈则在异常阶段重复采样。
几种常见形状可以帮助缩小范围:
- 任务队列持续增长,而连接池仍有空闲:工作线程或连接前的代码先成为瓶颈。
- 工作线程接近上限,连接池
pending和获取耗时同时上升:过多任务正在争抢连接,或者连接持有时间变长。 - 连接持续满载,数据库 SQL 延迟、锁等待或资源占用也恶化:数据库已经越过当前可持续并发,再放连接只会扩大竞争。
- 连接池不忙,但接口仍慢:需要回到线程栈和调用链检查外部服务、锁或 CPU,不能把所有等待都归因于数据库。
HikariCP 的 maximumPoolSize 包含空闲和使用中的连接;达到上限且没有空闲连接后,getConnection() 最多等待 connectionTimeout,随后抛出异常。HikariCP 配置说明
二、预算从下游向入口反推

2.1 先确定每个实例可用的连接份额
连接预算不能只看单个 Spring Boot 实例。先从数据库经实测可承受的业务连接总量中,扣掉管理、迁移、定时任务和其他服务的保留份额,再按部署实例分配:
1本服务全部实例可用连接
2 = 数据库已验证的安全业务连接上限
3 - 运维与故障处理保留
4 - 其他应用和后台任务预算
5
6单实例连接上限
7 ≤ 本服务全部实例可用连接 / 同时运行的实例数这里的“安全上限”必须来自固定查询组合下的阶梯实验,而不是数据库允许建立的最大会话数。滚动发布时新旧实例可能同时存在,分母要使用发布期间的最高实例数;否则一次扩容就可能让总连接数突破数据库预算。
2.2 用服务时间估算在途量,不把公式当答案
稳定窗口内,可以用 L ≈ λ × W 估算平均在途量。对数据库段来说,λ 是完成数据库工作的速率,W 是从取得连接到归还连接的平均持有时间,两者口径必须对应:
1平均连接占用量 ≈ 每秒完成的数据库操作数 × 平均连接持有秒数例如一个请求可能先调用外部服务,随后才短暂持有连接。整个请求耗时不能代替连接持有时间,工作线程数也因此不必机械地小于或等于连接数。
这个估算只适用于到达率和完成率接近、队列没有持续增长的稳定窗口。升压过程中队列正在积累、存在明显超时丢弃,或者平均值掩盖了长事务时,公式不能给出安全池大小。它只提供候选区间,最终上限还要由 P95/P99 持有时间、数据库拐点和恢复能力约束。
2.3 把等待时间放进端到端延迟预算
连接获取超时不是越长越稳。它属于接口总延迟的一部分:
1工作队列等待
2+ 连接前执行
3+ 连接获取等待
4+ SQL / 事务
5+ 连接后执行
6≤ 接口目标延迟如果外层请求只允许 ${REQUEST_TIMEOUT_MS},连接获取却允许等待 ${DB_ACQUIRE_TIMEOUT_MS} 且已经吃掉绝大部分预算,即使最终拿到连接,响应也可能来不及返回。数据库获取超时还不等于 SQL 执行超时、事务超时或网络超时,这几项要按目标技术栈分别验证。
把待实测参数集中在一张表里,避免线程配置和数据源配置由两拨人各自猜数:
1capacityBudget:
2 evidence:
3 experimentId: ${EXPERIMENT_ID}
4 applicationInstancesAtPeak: ${COUNT}
5 databaseSafeBusinessConnections: ${COUNT}
6 reservedConnections: ${COUNT_AND_OWNER}
7 databaseOperationRatePerInstance: ${OPS_PER_SECOND}
8 connectionHoldAverage: ${DURATION}
9 connectionHoldP95: ${DURATION}
10 endpointLatencyTarget: ${DURATION_AND_PERCENTILE}
11 perInstance:
12 databaseMaximumPoolSize: ${COUNT}
13 workerCoreSize: ${COUNT}
14 workerMaximumSize: ${COUNT}
15 workerQueueCapacity: ${COUNT}
16 connectionAcquireTimeout: ${DURATION}
17 requestTimeout: ${DURATION}
18 constraints:
19 taskShape: ${CPU_EXTERNAL_IO_DATABASE_PHASES}
20 maximumQueuedWorkAge: ${DURATION}
21 databaseGlobalConnectionCheck: ${FORMULA_AND_RESULT}
22 selectionReason: ${MEASURED_EVIDENCE}
23 rollbackTrigger: ${CONDITION}三、线程池必须有上限和可观察的拒绝
下面的 Java 21 示例为一类会访问数据库的后台工作建立独立执行器。配置值从上面的预算表注入,代码本身不假定任何“最佳线程数”。
1import java.time.Duration;
2import java.util.Objects;
3import java.util.concurrent.ArrayBlockingQueue;
4import java.util.concurrent.RejectedExecutionException;
5import java.util.concurrent.ThreadFactory;
6import java.util.concurrent.ThreadPoolExecutor;
7import java.util.concurrent.TimeUnit;
8import java.util.concurrent.atomic.AtomicInteger;
9import java.util.function.LongConsumer;
10
11record ExecutorBudget(
12 int coreThreads,
13 int maximumThreads,
14 int queueCapacity,
15 Duration keepAlive) {
16 ExecutorBudget {
17 if (coreThreads <= 0
18 || maximumThreads < coreThreads
19 || queueCapacity <= 0
20 || keepAlive == null
21 || keepAlive.isNegative()) {
22 throw new IllegalArgumentException("invalid executor budget");
23 }
24 }
25}
26
27final class TimedTask implements Runnable {
28 private final Runnable delegate;
29 private final long submittedAtNanos = System.nanoTime();
30 private final LongConsumer queueWaitRecorder;
31
32 TimedTask(Runnable delegate, LongConsumer queueWaitRecorder) {
33 this.delegate = Objects.requireNonNull(delegate);
34 this.queueWaitRecorder = Objects.requireNonNull(queueWaitRecorder);
35 }
36
37 @Override
38 public void run() {
39 queueWaitRecorder.accept(System.nanoTime() - submittedAtNanos);
40 delegate.run();
41 }
42}
43
44final class CapacityBoundExecutor implements AutoCloseable {
45 private final ThreadPoolExecutor executor;
46 private final LongConsumer queueWaitRecorder;
47
48 CapacityBoundExecutor(ExecutorBudget budget, LongConsumer queueWaitRecorder) {
49 this.queueWaitRecorder = Objects.requireNonNull(queueWaitRecorder);
50 AtomicInteger sequence = new AtomicInteger();
51 ThreadFactory threadFactory = task -> {
52 Thread thread = new Thread(task);
53 thread.setName("order-db-worker-" + sequence.incrementAndGet());
54 thread.setDaemon(false);
55 return thread;
56 };
57 this.executor = new ThreadPoolExecutor(
58 budget.coreThreads(),
59 budget.maximumThreads(),
60 budget.keepAlive().toMillis(),
61 TimeUnit.MILLISECONDS,
62 new ArrayBlockingQueue<>(budget.queueCapacity()),
63 threadFactory,
64 new ThreadPoolExecutor.AbortPolicy());
65 }
66
67 void execute(Runnable task) throws RejectedExecutionException {
68 executor.execute(new TimedTask(task, queueWaitRecorder));
69 }
70
71 int activeCount() {
72 return executor.getActiveCount();
73 }
74
75 int queuedCount() {
76 return executor.getQueue().size();
77 }
78
79 @Override
80 public void close() {
81 executor.shutdown();
82 }
83}ArrayBlockingQueue 让待执行任务数有明确上限,AbortPolicy 在池和队列都满时抛出 RejectedExecutionException。调用边界必须计数、记录业务操作和实验阶段,并把它转换成契约内的明确失败;不能捕获后只打一行日志,更不能伪装成任务已经提交成功。
CallerRunsPolicy 会让提交任务的线程亲自执行工作。它确实能减慢提交速度,但如果调用者是 HTTP 工作线程、事件循环或仍持有锁的线程,就会改变延迟和线程职责,甚至把阻塞扩散到入口。只有验证调用线程允许执行该任务、上下文传播和超时语义都正确时才考虑使用。DiscardPolicy 和 DiscardOldestPolicy 会丢弃工作,不适合依赖任务完成结果的订单操作。
Java 的 ThreadPoolExecutor 在达到核心线程数后会优先入队;只有队列放不下时才继续创建线程,直到最大线程数。因此“大队列 + 更大的 maximumPoolSize”不一定会使用到那些额外线程,反而可能先堆出长等待。Java SE 21 ThreadPoolExecutor
队列容量也不是按“能存多少对象”来定,而应按允许排队的工作量来定。近似稳定时,可以从允许的队列等待和可持续完成速率生成候选值;压测仍要观察任务在队列中的实际年龄。排队已超过调用方剩余超时的任务,即使最后执行也没有价值,需要在业务协议中明确取消或过期处理,不能只靠扩大队列保住接收数。
四、连接池参数要服从同一张表
若目标工程确认使用 Spring Boot 与 HikariCP,可将预算映射成类似配置:
1spring:
2 datasource:
3 hikari:
4 pool-name: order-database
5 maximum-pool-size: ${DB_MAXIMUM_POOL_SIZE}
6 connection-timeout: ${DB_CONNECTION_TIMEOUT_MS}具体 Spring Boot 版本尚未确定,属性名、单位、环境变量绑定以及指标导出必须在案例工程落地时用该版本官方文档和启动测试确认。HikariCP 的 connectionTimeout 单位是毫秒,当前文档给出的最低可接受值是 250 ms;不要把 ${DB_CONNECTION_TIMEOUT_MS} 原样当成一个带单位的 Duration 字符串。HikariCP 配置说明
示例故意没有写 minimum-idle。HikariCP 当前文档中,未设置 minimumIdle 时默认与 maximumPoolSize 相同,表现为固定大小池;是否需要弹性空闲连接,应结合启动冲击、数据库连接建立成本和目标版本重新测试,而不是顺手复制一组最小/最大值。
线程上限和连接上限之间不存在通用等号:
- 专门执行短数据库任务、任务几乎一开始就取连接的执行器,工作线程上限通常应靠近被允许进入数据库段的并发候选值,再用压测校正。
- 任务在持有连接前后还有外部 I/O 时,可以有更多工作线程,但要单独限制进入数据库段的并发,且不能在等待外部服务时继续占着连接。
- 一个请求可能嵌套取连接、跨线程传播事务或并行执行多条数据库分支,此时“一个线程一个连接”的模型已经失效,必须先修正事务和连接使用边界。
不要为了消除 HikariCP pending 就立刻扩大连接池。少量、短暂等待可能只是突发;持续等待才需要结合连接持有时间和数据库证据判断。若 SQL 变慢或连接泄漏,扩大池只会让更多慢工作同时进入数据库。
五、用阶梯升压找吞吐拐点和恢复能力
一次把并发打到峰值,只能看到系统坏掉,不能看出从哪一档开始失去可持续性。固定数据、查询组合、实例数和预热条件,逐级提高到达率;每档保持足够长的稳定窗口,最后降回基准档观察队列能否清空、延迟能否恢复。
1overloadExperiment:
2 experimentId: ${EXPERIMENT_ID}
3 fixedConditions:
4 applicationVersion: ${COMMIT_OR_ARTIFACT}
5 javaAndSpringBoot: ${VERSIONS}
6 hikariVersion: ${VERSION}
7 applicationInstances: ${COUNT}
8 databaseSnapshot: ${REFERENCE}
9 requestMix: ${REFERENCE}
10 executorAndPoolConfig: ${REFERENCE}
11 stages:
12 - offeredRate: ${RATE}
13 duration: ${DURATION}
14 completedThroughput: ${RATE}
15 endpointP95: ${DURATION}
16 endpointP99: ${DURATION}
17 executorActive: ${AVG_MAX}
18 queueDepth: ${AVG_MAX}
19 queueWaitP95: ${DURATION}
20 rejected: ${COUNT}
21 hikariActiveIdlePending: ${AVG_MAX_VALUES}
22 connectionAcquireP95: ${DURATION}
23 connectionTimeouts: ${COUNT}
24 databaseSqlLatencyAndLocks: ${REFERENCE}
25 databaseCpuAndIo: ${REFERENCE}
26 recovery:
27 returnToBaselineAt: ${TIMESTAMP}
28 queueDrainedIn: ${DURATION_OR_DID_NOT_DRAIN}
29 latencyRecoveredIn: ${DURATION_OR_DID_NOT_RECOVER}
30 decision:
31 sustainableStage: ${STAGE_AND_REASON}
32 firstUnstableStage: ${STAGE_AND_EVIDENCE}
33 selectedParameters: ${REFERENCE}
34 rollbackTrigger: ${CONDITION}可持续档位不是“错误率仍为零的最高档”。当输入继续增加,完成吞吐几乎不再增长,而队列年龄、连接等待或 P99 持续上升,系统已经进入排队区。此时有界队列触发的明确拒绝,比无限接收后统一超时更容易定位,也让调用方知道任务没有进入执行。
每轮只改一组主要参数。先保持 SQL、批次、请求组合不变,比较线程与连接候选;若同时修改批次大小、SQL 和连接数,就无法说明收益来自哪里。连接池参数变化还要核对数据库总连接、SQL 延迟和锁等待,不能只看应用吞吐。
六、上线和回退检查
上线前把预算表、配置和压测报告绑定到同一版本,并完成以下检查:
- 所有实例在滚动发布重叠期的连接总量仍不超过数据库预算。
- 任务队列有界,拒绝会计数并返回明确结果,没有静默丢弃。
- 队列等待、连接获取等待和连接持有时间能分别观测。
- 外层请求、连接获取、SQL 与事务超时的先后关系已经验证。
- 压测包含升压后的降载阶段,证明队列和尾延迟能够恢复。
- 数据库变慢、连接泄漏和任务执行异常都做过注入或等价演练。
回退时恢复上一组线程池与连接池配置,并按实例分批执行,持续观察总连接和队列。动态把核心线程、最大线程与队列容量改回去并不总是原子的;若当前实现不支持安全热变更,就通过受控重启回退。已经进入队列或正在事务中的工作还要按原业务契约收尾,不能把关闭执行器当成数据回滚。
这次改造成立的证据应该是:在相同工作负载下,可持续吞吐达到目标,P95/P99 仍在预算内;越过容量后,队列和连接等待不会无限增长,而是出现可观测的拒绝;降载后系统可以恢复。下一篇再讨论哪些工作可以真正移出同步主链路。把慢任务扔进另一个线程池,并没有完成异步化。