SpringBoot性能优化4:HikariCP连接池与SQL事务优化

我是架构师老李。今天不是生产复盘,而是把我在纵腾集团参与海外末端派送系统研发管理时遇到的一类问题,改写成教学项目“远航派送”。人物、项目名、运单号、租户、压测数据均为教学假设,不涉及生产源码,也不复用生产性能数据。
一、背景:远航派送在做什么
“远航派送”是面向海外末端派送场景的教学系统。业务上有三类角色:司机查询自己需要派送的运单,确认地址、联系人、重量和派送要求;运营查看运单轨迹,判断货物是否按计划移动;客服追踪异常运单,例如轨迹长时间不更新、地址异常或联系不上收件人。
系统边界有两个核心对象:运单和轨迹。运单是静态业务主数据,轨迹是随时间追加的事件流。司机查询主要访问运单主表;运营和客服查看轨迹时,会同时访问运单主表和轨迹明细。本课固定使用一个租户和一个运单:tenant-a 与 WB-DEMO-001,所有实验围绕这个固定输入展开,避免多租户数据干扰。
二、冲突:查询变慢,连接池全部借出
教学场景里,开发小周接到现象:司机端查询运单接口在高峰期变慢,监控显示数据库连接池里的连接全部处于借出状态,等待获取连接的线程持续增加。小周第一反应是:“池子太小了,开到500试试。”
但数据库侧已经繁忙。数据库连接数不是免费的,每个连接都占用数据库服务端资源。如果数据库本身已经繁忙,继续扩大客户端连接池,只会让数据库更忙。这里有一个关键判断:连接池全部借出,是结果,不是原因。真正要回答的是:为什么连接被占用那么久?是SQL慢,还是事务持有连接时间过长,还是取到连接后做了不该做的事情?
三、概念与机制:连接池到底限制了什么
连接池限制的是“应用同时占用数据库连接的数量”,不是SQL执行时间,也不是接口并发数。以HikariCP为例,maximumPoolSize 表示池中最大连接数,即应用最多能同时占用多少个数据库连接;connectionTimeout 表示线程从池中获取连接的最大等待时间,这个时间只限制“取连接等待”,不是SQL执行超时;idleTimeout、maxLifetime、minimumIdle 则管理空闲连接和连接生命周期。
常见误用是把 connectionTimeout 当成SQL超时。实际上,如果SQL执行需要10秒,而 connectionTimeout 设置为3秒,线程可能在等待连接阶段就超时,但已经拿到连接的线程仍会继续执行SQL。反过来,如果SQL本身慢,连接池再大也解决不了问题。
连接周转时间由三部分决定:SQL执行时间,受索引、返回行数、数据量影响;事务持有时间,即事务开启到提交或回滚之间的全部时间;连接归还速度,取决于应用是否及时释放连接。如果每个请求平均占用连接50毫秒,48个连接理论上可支撑接近960 req/s的数据库访问。但如果事务里混入远程HTTP调用,占用时间可能从50毫秒变成500毫秒甚至更长,同样48个连接只能支撑不到100 req/s。
四、链路定位:H2与MySQL 8要分开看
教学项目使用两个数据库环境。H2用于快速启动、接口联调和SQL流程验证,不代表MySQL的真实执行计划;MySQL 8作为独立profile,用于索引与连接池实验。H2结果不可代表MySQL,比如H2里一条SQL可能走全表扫描也很快,因为数据量小、内存充足;但同样写法在MySQL 8上可能扫描几十万行。
定位链路按这个顺序看:接口层先确认HTTP请求是否在事务内发起了远程调用;领域查询层看SQL是否命中正确索引,返回行数是否合理;连接池层看连接获取等待时间、连接占用时间、池大小;数据库层看MySQL的 Threads_connected、Threads_running、慢查询日志。
五、决策:先做单一变量实验,不盲目扩池
小周提出把池开到500,我没有直接否定,而是让他先做实验:在10万条种子运单上,比较池4、8、16三种配置。教学假设如下:目标应用资源2 vCPU / 2GiB;压测机单独部署,不与应用同机;种子数据10万条运单,20%热点数据;阶梯负载50 / 100 / 200 / 400 req/s;每轮预热60秒,采样180秒,重复3轮;实验目标为可持续负载下p95≤300ms,错误率<1%。这些是实验目标,不是已实现结果。
容量示例也要算清楚。假设服务3副本,每副本连接池16,则应用侧最大连接数为3×16=48。如果数据库预算为64连接,预留16个给运维、迁移或突发任务,那么应用侧最多只能使用48。此时不能再盲目扩副本或扩池。这个计算直接回应了小周的“开到500”:单副本500,3副本就是1500,数据库根本承受不了。
六、代码实践:最小可复现配置
教学基线使用Spring Boot 3.5.16、JDK 21、Maven 3.9+。Servlet/Tomcat 10.1依赖由BOM管理。这里不声称3.5是最新版,也不混用4.x参数。
H2快速体验配置:
spring:
datasource:
url: jdbc:h2:mem:farway;MODE=MySQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password: ""
jpa:
hibernate:
ddl-auto: create
show-sql: false
MySQL 8实验profile配置:
spring:
datasource:
url: jdbc:mysql://localhost:3306/farway?useSSL=false&serverTimezone=UTC
username: farway
password: farway
hikari:
maximum-pool-size: 8
minimum-idle: 2
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
查询运单的核心方法,只读取需要的列,避免 SELECT *:
public interface WaybillRepository extends JpaRepository<Waybill, Long> {
@Query("""
select w.waybillNo, w.tenantId, w.consigneeName, w.consigneePhone,
w.destinationAddress, w.weight, w.status
from Waybill w
where w.tenantId = :tenantId and w.waybillNo = :waybillNo
""")
Optional<WaybillProjection> findByTenantAndWaybill(
@Param("tenantId") String tenantId,
@Param("waybillNo") String waybillNo);
}
索引检查使用MySQL 8的 EXPLAIN:
EXPLAIN
SELECT waybill_no, tenant_id, consignee_name, consignee_phone,
destination_address, weight, status
FROM waybill
WHERE tenant_id = 'tenant-a' AND waybill_no = 'WB-DEMO-001';
关注 key 列是否命中联合索引,rows 列是否接近1。如果 rows 很大,说明索引没有生效或索引设计不合理。
事务边界上,HTTP调用必须放在事务外。错误示例:
@Transactional
public WaybillDetail getDetail(String tenantId, String waybillNo) {
WaybillProjection waybill = repository.findByTenantAndWaybill(tenantId, waybillNo);
CarrierInfo carrier = httpClient.getCarrierInfo(waybillNo); // 远程调用占着事务连接
return assemble(waybill, carrier);
}
正确做法是先查运单,再调用HTTP,最后组装:
public WaybillDetail getDetail(String tenantId, String waybillNo) {
WaybillProjection waybill = repository.findByTenantAndWaybill(tenantId, waybillNo);
CarrierInfo carrier = httpClient.getCarrierInfo(waybillNo);
return assemble(waybill, carrier);
}
静态查询与承运商补充信息分开计量。运单主表查询是数据库压力来源,承运商HTTP调用是外部依赖,两者不能混在一个事务里。
七、故障实验:池4/8/16与索引删除
实验按固定流程执行:输入 tenant-a 与 WB-DEMO-001;小周按本课方案修改单一变量,例如只改池大小,或只删索引;小陈校验功能、错误路径与负载有效性;老李复核延迟、吞吐及资源门禁;输出 results/04/实验报告.md;未通过则回滚本课配置,沿调用链反馈定位。
池大小实验记录三个指标:池等待时间,即线程从池中获取连接等待了多久;SQL耗时,即SQL在数据库侧执行了多久;扫描行数,通过 EXPLAIN 或慢日志确认。如果池等待时间高,但SQL耗时低,说明瓶颈在连接周转,不在SQL本身。如果SQL耗时高,扫描行数大,说明索引或SQL写法有问题。
删除索引后重跑并恢复,是验证索引有效性的标准动作。删除联合索引后,同样查询的扫描行数会明显上升,SQL耗时增加,连接占用时间变长,池等待随之上升。恢复索引后指标回落。模拟慢查询时,失败可定位为池等待或语句超时。具体做法是人为制造一个慢SQL,观察应用日志里是 Connection is not available 还是 Statement timeout。前者是池等待,后者是SQL执行超时,两者处理方式完全不同。
八、结果与复盘
教学实验的预期结果是:池4在低负载下可能够用,但高负载下等待明显;池8在目标负载下表现较稳;池16在数据库预算内可能略好,但收益递减。具体数字以实验报告为准,这里不虚构压测结果。
复盘时重点看三个问题:连接池全部借出时,先看连接被谁占用、占用多久;扩池前先算数据库总连接预算,不能只看单副本;远程调用、慢SQL、缺索引都会放大连接占用,要分开定位。
九、三问三答
问题一:连接池等待超时和SQL执行超时是一回事吗?
不是。连接池等待超时是线程在池外排队等连接,SQL执行超时是已经拿到连接后执行语句超时。前者调 connectionTimeout,后者调语句超时或数据库超时。
问题二:每个副本都把池开到很大,会有什么问题?
数据库总连接数会被快速耗尽。3副本×16连接=48,如果数据库预算64且预留16,应用侧最多48。每副本开到100,3副本就是300,数据库直接被打满。
问题三:下面哪种情况最可能导致连接池被借空?
A. SQL执行时间从10毫秒变成20毫秒
B. 事务内发起远程HTTP调用,平均耗时500毫秒
C. 连接池最大连接数从8改成16
答案是B。远程调用期间事务连接一直被占用,连接周转时间被外部依赖拉长,池子再大也会被拖垮。
十、常见误用与边界
常见误用有三个:将池等待超时误当SQL超时;各副本都开大池,忽视数据库总连接预算;远程调用期间占着事务连接。
本课所有实验基于教学假设,不声称任何生产系统达到或未达到某个指标。Spring Boot 3.5.16是教学基线,不声称是最新版。H2只用于快速体验,不代表MySQL行为。


浙公网安备 33010602011771号