先说一个我最近遇到的场景。给订单查询接口做DAO层高并发压测,线程数直接开到80,HikariCP用的是默认的10个连接,跑了不到30秒,控制台开始疯狂输出Connection is not available, request timed out after 30000ms,用例失败率33%。数据库CPU当时才17%,慢查询日志干干净净——问题根本不在SQL,而是高并发下Java DAO层测试里最容易被低估的连接数管控。后来我把方案改成"连接数管控+错峰访问+并行限流"三板斧组合,同样的压测场景连跑三轮,失败率降到0,DAO方法耗时波动从±400ms压到±30ms左右。这篇文章就把这三部分的配置思路、代码实现和调参逻辑完整拆开,主要面向写业务DAO的Java开发、维护接口自动化测试的测试开发,以及自己搭压测脚本的工程师。
1. 高并发DAO测试的"崩溃现场":连接池先于业务代码倒下
1.1 一次订单查询压测事故的完整还原
先还原一下我当时是怎么写的压测代码。SERVICE层用MyBatis + HikariCP,数据库是MySQL 8.0,要模拟大促峰值下OrderMapper.selectByOrderId的稳定性。
java复制ExecutorService pool = Executors.newFixedThreadPool(80);
CountDownLatch start = new CountDownLatch(1);
List<Future<Long>> futures = new ArrayList<>();
for (int i = 0; i < 80; i++) {
futures.add(pool.submit(() -> {
start.await();
long begin = System.nanoTime();
for (int j = 0; j < 500; j++) {
OrderDo order = orderMapper.selectByOrderId(randomOrderId());
if (order == null) {
throw new IllegalStateException("order not found");
}
}
return System.nanoTime() - begin;
}));
}
start.countDown();
用CountDownLatch让80个线程同一瞬间起跑,是为了制造真实并发尖峰。这个思路本身没错,但我忽略了连接池上限和线程数之间的关系。跑了20秒左右,日志里开始刷异常:
code复制java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available,
request timed out after 30000ms
当时我第一反应是SQL慢、索引没命中、数据量太大,于是先去查慢查询和数据库监控。结果发现MySQL这边一切正常,单条SQL平均执行耗时只有11ms,thread running也没超过5。
问题出在连接分配环节,而不是SQL执行环节。
1.2 连接池分配链路里的瓶颈拆解
一个DAO查询从线程发起,到真正拿到数据库连接,经历的链路是:
- 线程进入HikariCP连接池的获取队列;
- 连接池判断是否有空闲连接可以分配,没有则尝试新建;
- 连接交给线程,线程执行SQL,最后把连接归还。
高并发场景里最容易被忽略的事实是:线程池里的线程数可以远大于连接池的连接数。线程很廉价,数据库连接却是昂贵资源。
打个比方,数据库连接就像公司的会议室,线程就是去开会的人。会议室只有10间,但80个人同时要开会,其余70个人只能在大厅排队。等久了,有人会失去耐心直接走人(连接获取超时),有人会不停追问前台什么时候有空房(重试),整个大厅乱成一锅粥。
当连接获取超时异常大量出现时,线程不会停着,它要么抛异常、要么触发重试,而异常处理和重试都会占CPU、输出日志、可能开启事务回滚。于是观察到的现象是:应用服务器CPU升高、接口响应时间飙升、错误率像锯齿一样震荡。数据库端却一切太平。
这一节必须放在最前面讲,是因为后面的连接数管控、错峰访问、并行限流,全都是针对这条连接分配链路做文章,而不是去优化SQL。方向如果搞反了,后面调什么参数都白费。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连接数管控:把HikariCP池化参数调到"既够用又留余量"
2.1 五个关键参数的含义与压测推荐值
重构测试方案时,我做的第一件事就是把连接池从默认配置改为明确可控的值。HikariCP核心参数逐个拆开看。
| 参数 | 默认值 | 高并发测试推荐值 | 说明 |
|---|---|---|---|
| maximum-pool-size | 10 | 与限流线程数匹配,一般取线程数的0.3~0.5倍 | 池中允许存在的最大连接数 |
| minimum-idle | 10 | 与maximum-pool-size相同或略小 | 保持的空闲连接数下限 |
| connection-timeout | 30000 | 3000~5000 | 获取连接的最大等待时间,单位毫秒 |
| idle-timeout | 600000 | 600000 | 空闲连接回收前的最大空闲时长 |
| max-lifetime | 1800000 | 1800000 | 连接在池中的最大存活时长 |
Java代码里的完整配置:
java复制HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/order_db");
config.setUsername("test");
config.setPassword("test");
config.setMaximumPoolSize(32);
config.setMinimumIdle(32);
config.setConnectionTimeout(4000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
config.setLeakDetectionThreshold(60000);
config.setConnectionTestQuery("SELECT 1");
HikariDataSource dataSource = new HikariDataSource(config);
如果是Spring Boot工程,直接在配置里覆盖:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 32
minimum-idle: 32
connection-timeout: 4000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
我把minimum-idle和maximum-pool-size设为相同,是因为测试环境不差那几次创建连接的开销。启动时直接把连接全部建好,避免运行时频繁扩缩容。这一点在长跑压测里很关键——连接池在运行中扩容,要经历TCP握手、MySQL认证、权限校验,这个动作本身就会在数据库端制造一个延迟尖峰。
2.2 为什么连接池不是越大越好
有人一看并发高,直接把maximum-pool-size干到200,以为万事大吉。这恰恰是高并发压测里最常见的误区。
原因有三层。
第一,数据库连接数翻倍,数据库端压力并不是线性增长的。MySQL每个连接都要分配线程和内存,连接数过大的时候,数据库自身的线程切换和锁竞争会把整体吞吐拖下来,反而出现"连接越多越慢"的怪象。
第二,应用端每个连接都要维护socket、缓冲区、事务上下文,连接对象本身不是免费的。200个连接意味着同时存在200个会话,很多云数据库实例的连接数配额就卡在200上下,测试直接撞配额上限。
第三,没有边界就没有可解释性。测试的目的不是把环境压爆,而是验证在某个明确边界下系统仍然稳定。连接池的上限就是数据库侧的第一道防洪堤,这个数字必须能够用业务目标来解释,而不是随手拍。
业内流传比较广的一个估算起点是connections = ((core_count * 2) + effective_spindle_count),其中core_count是CPU核心数,effective_spindle_count按SSD通常取1。这个公式来自数据库性能优化的经典经验,适合拿来估算初始值,但不能照搬。更靠谱的方式是用目标并发和目标响应时间反推。
假设压测目标是DAO方法P99不超过200ms,平均耗时50ms,测试线程数40。每秒理论处理能力就是40 × 1000 / 50 = 800次。连接池给到32,平均每8次查询复用一次连接,完全够用,还能留出足够余量应对瞬时波动。
2.3 连接泄漏检测:必须提前打开的开关
再补一个实操里最能救命的配置——泄漏检测。
DAO层压测,只要某个异常分支或mock开关忘记归还连接,跑几十分钟后连接池就会悄悄耗尽。表面现象还是Connection is not available,但根因和并发超时完全不同,排查起来非常痛苦。
HikariCP的leak-detection-threshold就是为这种情况设计的。设置为60000,表示一条连接如果超过60秒没有归还到池中,HikariCP会输出一条包含完整方法调用栈的警告日志。顺着调用栈能直接定位到是哪个Mapper、哪段代码没有关闭连接。
另外,所有DAO调用尽量走try-with-resources,或者在finally里确保归还。在使用Spring的@Transactional时尤其要注意,事务开启期间连接不会释放,如果事务范围包住了大量慢查询,连接占用时间会远超单条SQL耗时,这时候问题不在池子大小,而在事务边界设计。
3. 错峰访问:把"齐步走"的请求尖峰打散到时间轴上
3.1 同步起跑的下场
刚才那个CountDownLatch齐发版本,最大的问题不是并发数,而是"齐步走"。80个线程在同一毫秒发出请求,数据库收到的不是平滑流量,而是一个陡峭尖峰。第一波请求瞬间占满连接池,后续所有请求开始排队,排队线程再次加剧下一波尖峰,形成恶性循环。
更隐蔽的是,尖峰还会引发TCP连接集中建立、SQL首次编译集中触发、MySQL线程调度抖动,这些东西叠加在一起,会让启动阶段的耗时和后续稳定阶段完全不是一个量级。很多人压测结果不稳定,有一部分原因就在这里:前几秒数据特别难看,后面又过于顺畅,最后平均下来数值失真。
错峰访问解决的就是这个"齐步走"问题。它不减少请求总数,只调整请求的时间分布,让流量更贴近真实系统中"用户不会在同一毫秒全部点击"的基本假设。
3.2 固定间隔错峰:让DAO请求匀速到达
最简单可靠的错峰方案是用ScheduledExecutorService,让每个任务延迟固定时间后再执行。
java复制ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
for (int i = 0; i < 2000; i++) {
final int idx = i;
long delayMillis = idx * 20L; // 每隔20ms放行一个请求
scheduler.schedule(() -> {
long begin = System.nanoTime();
OrderDo order = orderMapper.selectByOrderId(orderIdList.get(idx));
long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);
metrics.record(cost);
}, delayMillis, TimeUnit.MILLISECONDS);
}
每20ms放一个请求,2000个请求历时约40秒。数据库端看到的请求到达曲线是匀速的,不会再出现"前2秒打爆连接池,后面38秒无事可做"的状态。
固定间隔错峰适合做容量评估和基准测试,因为它排除了随机性,数据可复现。跑完一轮调整参数再跑一轮,结果之间的差值主要来自系统本身波动,而不是流量分布差异。
3.3 随机抖动错峰:贴近真实业务波动
固定间隔有一个小问题:真实系统访问不会精确到毫秒级均匀分布,而是会呈现聚类和毛刺。如果希望压测更像生产流量,可以在固定间隔基础上叠加随机抖动。
java复制ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
for (int i = 0; i < 2000; i++) {
final int idx = i;
long baseDelay = idx * 20L;
long jitter = ThreadLocalRandom.current().nextLong(0, 40);
long delayMillis = baseDelay + jitter; // 20ms基础间隔 + 0~40ms随机抖动
scheduler.schedule(() -> {
long begin = System.nanoTime();
OrderDo order = orderMapper.selectByOrderId(orderIdList.get(idx));
long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);
metrics.record(cost);
}, delayMillis, TimeUnit.MILLISECONDS);
}
随机抖动不会让结果失真,反而会让测试更接近真实。因为真实流量的核心特征是"总体有规律、局部有波动",恒定间隔反而是一种人造流量。
有一点必须提醒:错峰不是万能的。如果你要验证的场景就是"所有用户同一时刻点按钮"的极限峰值,那就必须用CountDownLatch齐发模式来测。错峰方案适用于验证常规高并发下的稳定性,极端尖峰场景需要单独设计用例,两者不能互相替代。
4. 并行限流:用信号量和令牌桶给并发踩刹车
4.1 连接数管控和并行限流的分工
很多人把连接数管控和并行限流当成同一件事,其实两者管的是不同层面。
连接数管控管的是数据库侧"最多允许多少个连接存在";并行限流管的是应用侧"同一时刻最多允许多少个查询任务在执行"。连接池是数据库的保险丝,限流是应用自己的油门。
如果只有连接池限制,线程还是会在获取连接那一步全部挤到一起,等待队列照样爆炸,只是崩溃点从数据库挪到了连接池。如果只有限流而没有合理配置连接池,连接池上限依然可能成为隐性瓶颈,限流效果被池子大小卡住。
所以实际方案里两者必须组合使用:连接池给出物理上限,信号量管住应用侧在途请求数,令牌桶管住请求速率。
4.2 信号量限流的落地代码
Java里做并行限流最直接的工具是Semaphore,语义就是"最多N个许可证同时被持有"。
java复制Semaphore limiter = new Semaphore(24);
ExecutorService executor = Executors.newFixedThreadPool(50);
CountDownLatch done = new CountDownLatch(tasks.size());
for (OrderQueryTask task : tasks) {
executor.submit(() -> {
boolean acquired = false;
try {
acquired = limiter.tryAcquire(2, TimeUnit.SECONDS);
if (!acquired) {
metrics.recordTimeout();
return;
}
long begin = System.nanoTime();
task.execute();
long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);
metrics.record(cost);
} catch (Exception e) {
metrics.recordError(e);
} finally {
if (acquired) {
limiter.release();
}
done.countDown();
}
});
}
done.await(5, TimeUnit.MINUTES);
这段代码里留意三个细节,都是我实际踩过的坑。
第一,用tryAcquire(2, TimeUnit.SECONDS)而不是直接acquire()。直接acquire()会无限阻塞线程,一旦数据库响应变慢,线程会在限流器上越积越多,整个测试进程变成僵尸状态。带超时的tryAcquire保证最坏情况下2秒内快速失败,把错误集中在指标统计里,而不是无限期挂起。
第二,limiter.release()必须只对成功获得许可证的线程执行。我初版代码把release写在finally最外层,导致每次失败反而多放出一个许可证,限流器越跑越松,最后完全失去限流意义。这个bug排查了很久才定位到,算是信号量用得不够细的典型教训。
第三,用CountDownLatch等待所有任务结束再统计。没有这个屏障,主线程可能提前打印汇总结果,漏掉还在执行的掉队任务,统计一出错很容易误导后续判断。
4.3 令牌桶限流的适用边界:Guava RateLimiter做平滑
信号量管的是"最多同时几个在途",令牌桶管的是"每秒最多放行几个"。如果测试目标更看重速率维度,可以用Guava的RateLimiter。
java复制RateLimiter rateLimiter = RateLimiter.create(120.0); // 每秒最多放行120个查询
for (OrderQueryTask task : tasks) {
rateLimiter.acquire(); // 拿不到令牌就阻塞等待
executor.submit(task::execute);
}
RateLimiter最大的好处是平滑突发流量。即便某一瞬间有120个任务在排队,令牌也会按每秒120个的速率匀速发放,不会在同一毫秒全部涌入数据库。
但它有一个天然局限:不限制并发数。同一秒放行120个查询,如果每个查询耗时50ms,同时刻最多可能只有6个在途;但如果某个DAO慢查询耗时500ms,同一时刻就会有60个在途查询,照样可能把连接池打满。
所以在实践中我的组合方式是:RateLimiter定速率,Semaphore定在途上限。一个管频率,一个管并发,各司其职。
4.4 限流参数怎么定
限流参数不能拍脑袋,要从连接池和耗时指标反推。
在途并发数的上限,理论上就是maximum-pool-size。连接池给了32,Semaphore许可证数量可以先给32;然后根据目标P99来微调,如果P99超出目标,就把在途数降一档再测,取拐点之前的那个值。
速率上限可以这样估算:
code复制单条DAO平均耗时约50ms时,理论最大吞吐 = 连接池大小 × (1000 / 平均耗时)
= 32 × 1000 / 50 = 640 次/秒
实际限流速率设在480次/秒左右,留出25%余量。这样既能让系统跑出接近上限的负载,又不会一上来就把连接池打到队列深度很高。
5. 稳定性量化与压测结束后的判断清单
5.1 四个必须盯住的指标
跑完一轮高并发DAO测试,统计什么才算有效?我自己的清单是这四个:
| 指标 | 健康参考线 | 需要警惕的线 |
|---|---|---|
| 连接获取耗时P99 | 1ms以内 | 超过10ms说明池子偏小或等待严重 |
| DAO方法耗时P99 | 与平均值比值在3~5倍以内 | 超过10倍说明排队严重 |
| 连接获取超时次数 | 0 | 任何一次非0都要排查 |
| 数据库端查询到达曲线 | 平缓无突刺 | 前几秒出现陡峭尖峰说明错峰没生效 |
连接获取耗时是我最看重的一个。HikariCP的getConnection耗时如果长期超过10ms,说明大量线程在排队等连接,这种情况即使数据库负载不高、SQL执行很快,整体响应时间也会被连接分配拖垮。
5.2 三轮对比法:用数据说话
调优过程不能只跑一轮就下结论。我习惯固定一个场景,改一组参数跑一轮,把结果放进对比表里:
| 测试方案 | 并发模型 | 失败率 | DAO P99耗时 | 连接获取耗时P99 |
|---|---|---|---|---|
| 默认连接池 + CountDownLatch齐发 | 80线程齐发 | 33% | 超时 | 超过30000ms |
| 连接池32 + 固定间隔错峰 | 80线程匀速到达 | 0% | 48ms | 2ms |
| 连接池32 + 信号量24 + 令牌桶480/秒 | 目标速率480 | 0% | 51ms | 1ms |
上面是我在那个订单查询场景下实际记录的一组数据,不同项目配置会有差异,但对比逻辑是通用的。第一轮问题最严重,第二轮光是连接数管控加错峰就已经解决大半问题,第三轮加限流后连接获取耗时进一步稳定。三轮对比下来,每个方案的效果都能量化。
5.3 值得记住的排查顺序
如果DAO层高并发测试跑挂了,按这个顺序排查效率最高,能少走弯路:
- 先看连接池是否泄漏或耗尽——查
getConnection耗时、活跃连接数、数据库端Threads_connected。 - 再看并发模型是否合理——查测试脚本里是不是所有请求齐步走、限流是否生效。
- 最后才看SQL和索引——慢查询日志、执行计划、事务边界。
这个顺序背后是有逻辑的。连接池是DAO请求的第一道关卡,并发模型是第二道,SQL是第三道。从链路入口往出口排查,永远比从出口往入口排查快。我见过太多人一上来就调SQL索引,调了半天发现是连接池配置不对,白白浪费一晚上。
最后再分享一个算是我个人的经验值。HikariCP池大小32、连接超时4秒、信号量在途数24到32、令牌桶速率480每秒、再加上固定间隔错峰和启动预热,这套组合在订单中心这类中高频读多写少的DAO场景里非常稳。但要注意,每个项目的表数据量、事务长度、数据库规格都不一样,最优参数会有差异。调参本身不难,难的是理解调参背后的判断链路:连接池保的是数据库侧的安全,错峰保的是流量分布的合理性,限流保的是应用侧的可控性。这三件事理顺了,高并发下的DAO层测试才谈得上"稳"。
