Spring Boot 集成 PostgreSQL 的连接池调优:HikariCP、prepared statement 与服务器资源边界
1. 先看一个实际场景:接口没变,下午三点开始变慢
假设你负责一个 Spring Boot 订单服务,工作日白天 QPS 大约 200。某天下午三点开始,下单接口的 P99 从 80ms 涨到 900ms,日志里偶尔出现 Connection is not available, request timed out after 30000ms,但 PostgreSQL 的 CPU 只有 40%,慢查询日志几乎为空。你第一反应可能是"数据库不行了",于是把 HikariCP 的 maximum-pool-size 从 20 调到 100,重启之后更慢了。
这类问题的根子通常不在"连接不够",而在三个相互纠缠的边界:客户端连接池里的连接数量、服务端为每条连接准备的语句缓存和内存、以及数据库实例本身能同时容纳多少个活跃后端进程。把其中任意一个维度单独拉大,都会挤压另外两个。
本文的目标不是让你背参数,而是先建立一张能复述的框架图,再通过示例把 HikariCP、prepared statement 与 PostgreSQL 服务器资源边界串起来。读完你应该能回答:连接池该配多大、prepareThreshold 该不该开、wait_timeout 到底是谁的参数。
2. 一句话模型与全局框架
可以先把它理解成一条流水线:Java 业务线程不会直接碰数据库,它先从连接池借一条已经建立的 TCP 连接,通过这条连接发送 SQL,PostgreSQL 为这条连接分配一个后端进程来执行,执行结果再沿原路返回,最后连接被归还。
把整体拆成四部分:
- 请求侧:Spring Boot 的 Tomcat 工作线程,同时可能有几百个,但绝大多数时间在等 IO。
- 连接池侧:HikariCP 维护一个固定上限的连接集合,用"借出---归还"替代"每次新建连接"。
- 协议与语句侧:JDBC 驱动决定 SQL 是走简单查询协议,还是先准备语句再执行。
- 数据库侧:PostgreSQL 为每条连接 fork 一个后端进程,进程要占内存,语句缓存要占内存,两者都受实例总内存约束。
一次下单请求的流转顺序如下:
text
Tomcat 工作线程
|
v
HikariCP.getConnection() --- 池中有空闲连接? ---> 有:直接借出
| |
| 没有:等待 connectionTimeout
v
JDBC 驱动
|
|-- 第一次执行某条 SQL:发送 Parse/Bind/Execute(扩展查询协议)
|-- 之后:优先命中客户端或服务端已准备的语句
v
PostgreSQL 后端进程(每条连接一个)
|
|-- 访问 shared_buffers / 本地 work_mem / 磁盘
v
返回结果集 -> 归还连接到池
记住一个最小模型:连接池的上限决定"同时有多少条 SQL 能进入数据库",它不是并发能力的放大器,而是并发进入数据库的闸门。闸门开得过大,数据库会自己用锁、IO 和内存把请求排队,表现反而是变慢。
3. 当前端和后端都连不上:连接的真实成本
先理解为什么需要池。建立一条 PostgreSQL 连接的完整过程包括:TCP 三次握手、启动包交换、认证(可能是 SCRAM)、后端进程 fork、初始化会话状态。在高并发下,这些动作的耗时可以达到几毫秒到几十毫秒,而且 fork 后端进程会消耗内存和 CPU。
如果没有连接池,每个 HTTP 请求都新建连接,那么 200 QPS 就意味着每秒 200 次认证和 fork。连接池做的事情是把这 200 次新建摊薄成几十条长期存活的连接,让业务线程复用它。
但"复用"不是免费的:每条长期连接在 PostgreSQL 里就是一个常驻后端进程,这个进程即使空闲,也有基础内存开销;如果它缓存了 prepared statement,还要额外占用内存。所以调连接池本质是在"减少建连开销"和"控制服务端常驻资源"之间找平衡点。
这里最容易误解的是把连接池大小等同于性能。连接池的作用是限制并发进入数据库的请求数量,让数据库在可控压力下工作;如果一个查询本身要扫描百万行,那么给它更多连接只会让多个慢查询同时抢 IO,整体吞吐不会提高。
4. HikariCP 在 Spring Boot 里到底做了什么
4.1 池的基本状态与借还流程
HikariCP 的池内部维护几种状态:空闲连接、正在被借出的连接、正在创建或关闭的连接。业务线程调用 getConnection() 时,如果存在空闲连接就直接标记为已借出并返回;如果没有空闲但总数未达上限,就异步创建一个;如果已达上限,就进入等待队列,直到有连接被归还或超过 connectionTimeout。
归还时不是直接关闭,而是先把连接恢复到可复用状态:回滚未提交事务、重置会话变量、清理警告,然后放回空闲列表。这也是为什么连接泄漏非常危险:借出的连接不还,池会一直等它。
4.2 关键参数逐一说明
maximum-pool-size:池允许存在的最大连接数,含正在使用的和空闲的。minimum-idle:池要维持的最少空闲连接,通常不建议设得和最大值一样大,否则高峰期后也长期占着数据库连接。connection-timeout:等待空闲连接的最长时间,超时抛SQLTransientConnectionException。idle-timeout:空闲连接被回收前的最长空闲时间。max-lifetime:连接的最长存活时间,应比数据库或中间层的连接上限略小,避免拿到已被服务端关闭的连接。leak-detection-threshold:借出超过该时长的连接会打印堆栈,用于定位泄漏。
下面是一个可直接放进 Spring Boot 的 YAML 配置,假设 PostgreSQL 实例允许的最大连接数是 200,应用有 4 个实例:
yaml
spring:
datasource:
url: jdbc:postgresql://db.internal:5432/orderdb
username: order_app
password: ${DB_PASSWORD}
hikari:
pool-name: order-hikari
maximum-pool-size: 30
minimum-idle: 5
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1740000
leak-detection-threshold: 20000
data-source-properties:
preparedStatementCacheQueries: 256
preparedStatementCacheSizeMiB: 16
prepareThreshold: 5
tcpKeepAlive: true
关键点:maximum-pool-size 是 30 而不是 100,因为 4 个实例乘以 30 是 120,给运维连接和后台任务留出余量;max-lifetime 是 29 分钟,略小于常见负载均衡器 30 分钟的空闲超时;preparedStatementCacheQueries 是客户端缓存条数,prepareThreshold 决定服务端从第几次执行开始准备语句。
4.3 池大小不是越大越好:一个数字推演
假设 4 核数据库、每个查询平均占用 CPU 2ms 且为纯 CPU 计算,理论上每秒可处理 2000 个查询。如果连接池设成 100,100 个查询同时进入,每个都在等 CPU,单个查询延迟被拉长,整体吞吐不变甚至下降;如果设成 8 到 16,它们轮流使用 CPU,单个请求延迟更短,吞吐接近上限。
这就是"连接池大小应该接近数据库能真正并行处理的数量,而不是应用线程数"的含义。经验起点是每核 2 到 4 条活跃连接,再根据 IO 占比调整:IO 越多,可以适当放宽,但仍然要受服务端内存和 max_connections 约束。
5. prepared statement:从客户端缓存到服务端语句
5.1 两种执行路径
JDBC 执行 SQL 有两条路。第一条是简单查询协议:执行时才把 SQL 文本发给服务端,服务端解析、规划、执行,每次都要重新解析。第二条是扩展查询协议:先 Parse 一次,服务端生成预备语句并返回一个语句名,之后只发送 Bind 和 Execute。
pgjdbc 驱动在满足条件时会自动使用第二条路。prepareThreshold 表示同一条 SQL 被执行到第几次时,驱动开始把它转为服务端预备语句,默认值是 5。preparedStatementCacheQueries 限制客户端缓存多少条语句,超过后按访问情况淘汰。
5.2 业务场景:高频下单和低频报表
下单 SQL 是高频、参数化的,适合准备;报表 SQL 可能包含不同的 IN 列表长度或不同的排序条件,SQL 文本变化频繁,准备后很快被淘汰,收益很低。如果报表也强制走服务端准备,pg_prepared_statements 会积累大量语句,内存上涨。
可以用下面的查询观察当前连接上的预备语句:
sql
SELECT name,
statement,
prepare_time,
generic_plans,
custom_plans
FROM pg_prepared_statements
ORDER BY prepare_time DESC;
如果看到几百条只执行过一两次的语句,说明准备策略过于激进;如果 generic_plans 和 custom_plans 的分布异常,也要结合参数分布看执行计划是否被泛化计划拖慢。
5.3 与 PgBouncer 的冲突
当应用和数据库之间加了连接池中间件(如 PgBouncer 的 transaction 模式)时,服务端预备语句必须绑定在具体后端连接上。事务结束后连接会被复用给别的客户端,原来准备的语句就失效了。这时要么使用会话模式,要么让驱动在服务端关闭预备语句并在每次执行时重新 Parse。
这里的关键取舍是:客户端准备(驱动本地缓存 SQL 与参数元数据)几乎总是有益且无副作用的;服务端准备是否开启,取决于连接是否会被跨会话复用、语句是否重复执行、以及服务端内存余量。
6. 服务器资源边界:连接数、内存与 wait_timeout
6.1 谁在限制连接数
PostgreSQL 的 max_connections 是实例允许的最大连接数,所有数据库、所有客户端共享。它必须大于所有应用实例的连接池上限之和,再加运维和复制连接。
sql
SHOW max_connections;
SHOW superuser_reserved_connections;
SELECT count(*) AS total,
state,
wait_event_type
FROM pg_stat_activity
GROUP BY state, wait_event_type
ORDER BY total DESC;
每条连接对应一个后端进程,进程有基础内存开销。真正影响性能的是其中有多少处于活跃状态、在跑什么查询,而不是连接总数本身。空闲连接也占内存,但一般不抢 CPU。
6.2 wait_timeout 到底是谁的参数
这是全文最容易混淆的点:wait_timeout 不是 PostgreSQL 的参数。它主要出现在 MySQL 的连接配置中,表示服务端关闭空闲连接前的等待秒数,也出现在 MySQL JDBC 连接串里。PostgreSQL 对应语义的参数是:
idle_in_transaction_session_timeout:一个事务空闲超过该时长就被终止,防止长事务一直占着旧快照。idle_session_timeout:一个会话整体空闲超过该时长就被断开,常用来清理"连上但不说话"的连接。statement_timeout:单条语句执行超过该时长就被取消。
如果在 PostgreSQL 环境里配置 wait_timeout 而没有对应语义,你会得到两种结果之一:驱动不认识它,或者中间件把它当成自己的参数。正确做法是把"空闲连接多久回收"交给 HikariCP 的 idle-timeout 和 max-lifetime,再在数据库侧用 idle_session_timeout 兜底。
| 场景 | MySQL 语义 | PostgreSQL 对应做法 |
|---|---|---|
| 关闭长期空闲的连接 | wait_timeout / interactive_timeout |
连接池 idle-timeout,数据库 idle_session_timeout |
| 防止长期空闲事务 | wait_timeout 间接影响 |
idle_in_transaction_session_timeout |
| 限制单条 SQL 时长 | max_execution_time |
statement_timeout |
| 限制连接总数 | max_connections |
max_connections |
6.3 内存边界怎么算
一条连接的常驻内存大致包括:后端进程基础内存、连接的会话状态、work_mem 在排序和哈希时临时分配、以及预备语句缓存。work_mem 不是每条连接立刻占用,而是每个排序或哈希节点在需要时分配。一个复杂查询可能有多个这样的节点同时分配,所以"连接数 乘 work_mem"会严重低估峰值内存。
sql
SHOW shared_buffers;
SHOW work_mem;
SHOW maintenance_work_mem;
SHOW max_connections;
工程上更稳的做法是:让 shared_buffers 占实例内存的 25% 到 40%,work_mem 保持较小值(如 4MB 到 16MB),用 log_temp_files 观察是否频繁落盘,再针对特定报表会话单独调大。
7. 示例一:最小可运行的连接池等待现象
目标:在本地复现"连接池不够用"的表现,理解 connectionTimeout 与池大小的关系。
前置环境:本地有 PostgreSQL,创建数据库 pool_lab,Spring Boot 项目引入 spring-boot-starter-jdbc 和 PostgreSQL 驱动。
java
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.concurrent.CountDownLatch;
public class PoolWaitDemo {
public static void main(String[] args) throws Exception {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/pool_lab");
config.setUsername("postgres");
config.setPassword("postgres");
config.setMaximumPoolSize(2);
config.setConnectionTimeout(1000);
config.setPoolName("demo-pool");
HikariDataSource ds = new HikariDataSource(config);
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
final int id = i;
new Thread(() -> {
try (Connection conn = ds.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT pg_sleep(3), " + id)) {
while (rs.next()) {
System.out.println("thread " + id + " ok, sleep=" + rs.getString(1));
}
} catch (Exception e) {
System.out.println("thread " + id + " failed: " + e.getClass().getSimpleName());
} finally {
latch.countDown();
}
}).start();
}
latch.await();
ds.close();
}
}
关键步骤:池只有 2 条连接,三个线程同时借,第三个线程等 1 秒后失败。预期输出中两个线程打印 ok,第三个打印 failed: SQLTransientConnectionException。
这条示例说明的是池容量与等待超时的直接关系。容易改错的地方:把 pg_sleep 换成真实慢查询后,失败可能不再发生,因为真实查询可能更快结束;不要用这个示例得出"池越大越好"的结论。
8. 示例二:Spring Boot 中验证 prepared statement 行为
目标:观察 prepareThreshold 对服务端预备语句的影响,并验证客户端缓存条数。
前置环境:本地 PostgreSQL,Spring Boot 3 项目,配置同第 4 节的 YAML,但把 prepareThreshold 改为 2。
java
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
public class StatementWarmupService {
private final JdbcTemplate jdbcTemplate;
public StatementWarmupService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void warmUp() {
// 同一条参数化 SQL 连续执行 5 次,前两次走简单查询,第三次起服务端准备
for (int i = 0; i < 5; i++) {
Long count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM orders WHERE status = ?",
Long.class, "PAID");
System.out.println("round " + i + " count=" + count);
}
}
}
验证方式:在另一个 psql 会话里执行 SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;,你会看到一条与上述 SQL 对应的预备语句。如果查询涉及不同参数值时执行计划差异很大,可以观察 generic_plans 是否为 0,说明服务端一直用自定义计划。
边界:服务端预备语句会占用连接内存,且连接归还并不会自动清空;在 PgBouncer transaction 模式下,这条语句可能在连接被复用后失效,出现 prepared statement "S_1" does not exist 类错误。
9. 示例三:一条诊断脚本摸清连接与语句现状
目标:不写 Java 代码,只用 SQL 和配置检查,判断当前实例是否被连接数或语句缓存拖累。
前置环境:可以直接连接目标 PostgreSQL 实例的只读账号,能访问 pg_stat_activity 和 pg_prepared_statements。
sql
-- 1. 当前连接按应用和状态分布
SELECT application_name,
state,
count(*) AS conn_count,
max(extract(epoch FROM now() - state_change)) AS max_idle_seconds
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY application_name, state
ORDER BY conn_count DESC;
-- 2. 长事务与长空闲事务
SELECT pid,
application_name,
state,
now() - xact_start AS xact_age,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_age DESC;
-- 3. 预备语句数量与计划分布
SELECT count(*) AS stmt_count,
sum(generic_plans) AS generic,
sum(custom_plans) AS custom
FROM pg_prepared_statements;
判读方法:第 1 条如果某应用连接数长期接近其池上限且大量为 idle,说明池偏大或泄漏;第 2 条如果出现长事务,优先查业务是否忘了提交,它会让 autovacuum 无法回收旧版本;第 3 条如果 stmt_count 很大而 custom 几乎为 0,说明大量语句从未被复用,服务端准备收益低。
边界:pg_prepared_statements 只显示当前会话,所以需要连到具体后端进程才能看到全部;上面的查询在多会话下会各自返回自己会话的语句。
10. 设计取舍:把三个旋钮对上
调优时不要分别看 HikariCP、驱动参数和数据库参数,而要按下面的顺序决策。
| 决策点 | 选择 A | 选择 B | 判断条件 |
|---|---|---|---|
| 池大小 | 每核 2 到 4 条 | 更大池 | 查询以 IO 为主且实例 IO 有余量时可选 B |
| minIdle | 设为池的 20% 到 30% | 等于 maxPoolSize | 高峰期波动大且建连慢时可选 B,代价是常驻连接多 |
| 服务端准备 | 开启,阈值 3 到 5 | 关闭 | 有 PgBouncer transaction 模式或 SQL 文本多变时选 B |
| 空闲回收 | 池 idleTimeout 加数据库 idle_session_timeout | 只靠数据库 | 池版本较老或需要更细粒度控制时选 A |
| 单语句超时 | statement_timeout 5s 到 30s | 不设 | 面向用户接口建议设,离线任务单独放宽 |
还有两条隐式约束:第一,所有应用实例的池上限之和必须小于 max_connections 减去复制和管理连接;第二,max_lifetime 要小于任何中间层的空闲超时,否则会随机出现"连接已关闭"的错误。
11. 常见误区
常见误区一:把 wait_timeout 当成 PostgreSQL 的参数写进连接串。它不会控制 PostgreSQL 空闲连接,真正生效的是 idle_session_timeout 和连接池自己的 idle-timeout。
常见误区二:连接池越大吞吐越高。数据库真正并行处理的能力受 CPU 核数、磁盘 IO 和锁竞争限制,超过这个限制后更多连接只会拉长队列和延迟。
常见误区三:work_mem 乘以连接数就是峰值内存。排序与哈希节点是按查询动态分配的,一个连接上可能有多个节点同时分配,所以峰值可能高得多;反过来,没有排序的查询也不会占用这部分内存。
常见误区四:prepareThreshold=0 就是关闭所有准备。不同驱动版本对 0 的语义不同,有的表示始终准备,有的表示关闭;升级驱动时要确认行为并写测试覆盖。
12. 生产实践建议
容量规划从服务端倒推:先确定 max_connections,扣除 superuser_reserved_connections、复制槽、监控和运维连接,再把剩余额度在应用之间分配,最后折算到每个实例的 maximum-pool-size。不要反过来先设池再改数据库。
监控上至少采集四组指标:HikariCP 的活跃连接数、空闲连接数、等待获取连接的线程数和超时次数;PostgreSQL 的 pg_stat_activity 按状态分组连接数;服务端预备语句数量与内存;以及 pg_stat_statements 中的平均执行时间与调用次数。
变更顺序建议是:先减压(缩短长事务、优化慢 SQL),再调池(减小而非盲目增大),最后调语句准备与超时。每一步只改一个变量,观察至少一个完整业务高峰。
13. 排障清单
| 现象 | 优先检查 | 常见结论 |
|---|---|---|
| 获取连接超时 | 池活跃数、等待线程数、长事务 | 连接泄漏或长事务占满连接 |
| 数据库 CPU 高但吞吐低 | 活跃连接数、执行计划、锁等待 | 并发过高导致上下文切换与锁竞争 |
| 连接被服务端断开 | idle_session_timeout、max_lifetime |
回收时间配置不匹配 |
| 出现 prepared statement 不存在 | 是否使用 PgBouncer transaction 模式 | 服务端准备与连接复用冲突 |
| 内存持续上涨 | 预备语句数量、work_mem、排序落盘 |
语句缓存或临时文件过多 |
text
排障顺序(从外到内)
1. 应用侧:HikariCP 指标 + 线程栈
|
2. 协议侧:是否报 prepared statement 相关错误
|
3. 数据库侧:pg_stat_activity + pg_stat_statements
|
4. 资源侧:CPU、IO、内存、临时文件
14. 面试与复盘问题
第一,为什么连接池大小通常小于应用线程数?可以从"数据库真正并行处理能力"和"等待队列"两个角度回答。第二,prepareThreshold 控制的是什么,它在客户端还是服务端生效?第三,wait_timeout 在 PostgreSQL 里对应哪些参数,idle_in_transaction_session_timeout 解决的是什么问题?第四,如果应用部署 10 个实例、每个池 50 条连接,而数据库 max_connections 是 200,会发生什么,怎么改?
这些问题背后是同一个框架:应用线程、连接池、驱动协议、数据库后端进程四层各自有限制,调优是让四层的限制互相匹配,而不是把某一层拉到最大。
15. 总结
回到开头的场景:接口变慢、日志报获取连接超时、数据库 CPU 却不高。按本文框架,先查 HikariCP 的活跃连接与等待线程,再看 PostgreSQL 里是否有长事务或大量空闲连接,然后确认预备语句缓存和语句超时配置,最后回到内存与 CPU 的实际余量。大多数情况下答案是"池偏大加长事务",而不是"池偏小"。
把三个边界收进一张图:
text
[应用线程数] --受限于--> [HikariCP 池上限]
|
受限于 PostgreSQL max_connections
|
v
[后端进程数 x 单连接内存]
|
受限于实例总内存与 IO
v
[实际吞吐与延迟]
能复述这张图,就能在遇到连接相关问题时先定位边界,再决定是调池、调语句准备还是调数据库参数,而不是盲目把 maximum-pool-size 调大。
16. 参考资料
- PostgreSQL 官方文档:Runtime Configuration,含
max_connections、idle_in_transaction_session_timeout、idle_session_timeout、statement_timeout、work_mem。 - PostgreSQL 官方文档:Frontend/Backend Protocol,扩展查询协议与 prepared statement 描述。
- PostgreSQL 官方文档:Monitoring Database Activity,
pg_stat_activity与pg_prepared_statements视图。 - pgjdbc 项目文档:PreparedStatement 与
prepareThreshold、preparedStatementCacheQueries等连接参数说明。 - HikariCP 项目文档:Configuration Knobs,
maximumPoolSize、minimumIdle、connectionTimeout、maxLifetime等参数。 - Spring Boot 官方文档:Configure a DataSource,HikariCP 相关配置属性。
- 《PostgreSQL 修炼之道》等 PostgreSQL 实践类书籍,关于连接与内存管理的章节。