2023 年,我维护的一个电商订单服务,在一次大促预热期间出了事故。现象很典型:高峰期一到,大量请求开始超时,接口响应从几十毫秒飙到十几秒,然后是大面积的 500。我扒开日志,满屏都是同一行报错——HikariPool-1 - Connection is not available, request timed out after 30000ms。我的第一反应,和大多数人一样:连接不够用了,那就调大。我把 HikariCP 的 maximumPoolSize 从 20,一口气调到了 200,心想这下总够了吧。重启、观察——前十几分钟,确实缓解了。可没过多久,事故反而以一种更糟的形式回来了:这次不只是应用超时,连数据库本身都开始报警,CPU 飙满、Threads_running 居高不下,DBA 在群里追问"谁把连接数打上来了"。我把应用又紧急回滚回 20。那一晚我盯着监控,后知后觉地想明白一件事:我从头到尾都搞错了方向。我的服务从来不是"连接不够",而是"借出去的连接迟迟不还"——一批没有被正确关闭的连接在慢慢泄漏,再加上几条慢查询长期占着连接不撒手,池子里能用的连接被一点点蚕食干净。新来的请求,排着队等一个永远等不到的连接,直到 30 秒超时。我盲目调大连接池,等于让这个"还不回来"的窟窿,变本加厉地去压垮数据库。这件事逼着我把连接池到底解决什么问题、它的几个核心参数各自意味着什么、连接是怎么"漏"光的、慢查询如何吃掉整个池、以及连接池到底该配多大,彻底理清了一遍。本文是这份梳理的完整复盘。
问题背景:一个"调大池子反而更糟"的事故
背景:一个电商订单服务,用 HikariCP 连接池,maximumPoolSize=20
事故经过:
- ★ 大促预热,高峰期一到,大量请求超时,接口从几十毫秒飙到十几秒
- ★ 日志刷屏:HikariPool-1 - Connection is not available,
request timed out after 30000ms
- ★ 我的第一反应:连接不够 -> 把 maximumPoolSize 从 20 调到 200
- ★★ 结果更糟:应用缓解了十几分钟,数据库自己开始报警 ——
CPU 飙满、Threads_running 居高不下,DBA 追问"谁把连接数打上来了"
- ★ 只能紧急把池子回滚回 20
★★ 那一晚想明白的:我从头到尾搞错了方向。服务不是"连接不够",
而是"借出去的连接迟迟不还" —— 一批泄漏的连接 + 几条慢查询
长期占着连接,把池子里能用的连接一点点蚕食干净。新请求排着
队等一个永远等不到的连接,直到 30 秒超时。
★ 盲目调大连接池,等于让这个"还不回来"的窟窿,变本加厉地去
压垮数据库。
★ 本文要做的:把连接池解决什么问题、核心参数、连接怎么"漏"光、
慢查询如何吃掉整个池、连接池到底该配多大,彻底讲透。
为什么需要连接池:一个数据库连接,贵在哪里
# === ★ 先想清楚:为什么不能"每次查询都现建一个连接" ===
# === ★ 建立一个数据库连接,背后发生了多少事 ===
# ★ ★ 你以为 getConnection() 很轻?它背后是一连串重活:
# - ★ TCP 三次握手:和数据库服务器建立网络通道;
# - ★ 认证:把用户名密码发过去,数据库要校验身份;
# - ★ (若开了 TLS)还要做一次 TLS 握手,更慢;
# - ★ 数据库侧:为这个连接分配会话内存、线程等资源。
# ★ ★ 这一套走下来,一次建连,常常要几十毫秒。而你的
# 一条主键查询,可能只要 1 毫秒 —— 建连的成本,
# 比查询本身高出几十倍。
# === ★ "每次现建、用完就断",会怎样 ===
# ★ ★ 高并发下,每个请求都现建一个连接:① 大量时间耗在
# 握手认证上,接口被生生拖慢;② 数据库被频繁的"建连/
# 断连"折腾,资源被反复申请释放,自己先扛不住。
# === ★ 连接池的思路:连接,复用,别每次重建 ===
# ★ ★ 连接池在应用启动时,就【预先建好】一批连接,养在
# 池子里。请求来了,从池子里【借】一个现成的;用完,
# 把它【还】回池子,而【不是断开】。下一个请求,接着
# 借这个还回来的。
# ★ ★★ 关键转变:连接的生命周期,和请求的生命周期
# 【解耦】了。建连这件贵事,在启动时一次性做完,之后
# 成千上万个请求,都在【复用】这几十个连接。
# === 小结 ===
# ★ 建立一个数据库连接是重活:TCP 三次握手、认证校验、
# 可能还有 TLS 握手、数据库侧为连接分配会话资源 ——
# 一次建连常要几十毫秒,比一条主键查询本身贵几十倍。
# ★ 若每次现建用完就断:高并发下大量时间耗在握手认证
# 上拖慢接口,数据库被频繁建连断连折腾自己先扛不住。
# ★★ 连接池的思路:应用启动时预先建好一批连接养在池
# 里,请求来了借一个现成的、用完还回池子而不是断开,
# 下个请求接着借 —— 关键是把连接的生命周期和请求的
# 生命周期解耦,建连这件贵事启动时一次做完,之后成千
# 上万请求都复用这几十个连接。
连接池怎么工作:借、用、还,和那几个核心参数
# === ★ 连接池的运转,就三个动作:借、用、还 ===
# === ★ 一次查询,在连接池眼里是这样的 ===
# ★ ★ ① 借(acquire):你的代码调 getConnection()。池子
# 里有空闲连接,立刻给你一个;没有,你就【排队等】。
# ★ ★ ② 用:你拿着这个连接执行 SQL。这段时间,这个连接
# 被你【独占】,别人借不走。
# ★ ★ ③ 还(release):你调 close()。★★ 注意:在连接池
# 里,close() 的语义【不是"断开连接"】,而是"把连接
# 归还给池子"。这是理解连接池的关键。
# ★ 借不到、又等到超时 —— 就是开篇那行报错的由来。
# === ★ 核心参数 1:maximumPoolSize —— 池子的天花板 ===
# ★ ★ 池子最多允许有多少个连接。这是最关键的参数。
# ★★ 它绝不是"越大越好"(后面"工程选型"专门讲)——
# 连接总数受数据库 max_connections 的硬约束。
# === ★ 核心参数 2:connectionTimeout —— 借连接的耐心 ===
# ★ ★ 借连接时,如果池子里没有空闲的,最多【等多久】。
# 等过这个时间还没借到,就抛异常(开篇那行就是它)。
# ★ ★★ 常见的错:把它设得很长(如 30 秒)。后果是一旦
# 池子被占满,每个请求都要【傻等 30 秒】才失败 ——
# 线程被大量占住,雪崩得更快。正解是设短,【快速失败】。
# === ★ 核心参数 3:maxLifetime —— 连接的最长寿命 ===
# ★ ★ 一个连接在池里最多活多久,到点就被回收、重建一个
# 新的。★★ 它【必须短于】数据库侧、以及中间防火墙/
# 负载均衡的"连接空闲超时"。否则:对方早把这条连接
# 悄悄掐了,池子却还以为它是好的,借给你 —— 你拿到
# 一个【已经死掉的连接】,一用就报错。
# === ★ 核心参数 4:minimumIdle —— 空闲时的常备军 ===
# ★ ★ 即使没什么流量,也至少保持这么多空闲连接待命。
# ★ 实践中,常把它和 maximumPoolSize 设成【相等】——
# 让池子大小固定、不频繁伸缩,行为更可预测、更稳。
# === 小结 ===
# ★ 连接池运转就三个动作:① 借(acquire)调 getConnection(),
# 有空闲立刻给、没有就排队等;② 用,拿着连接执行 SQL
# 这段时间连接被你独占;③ 还(release)调 close() ——
# ★★ 在连接池里 close() 不是"断开连接"而是"归还给
# 池子",这是理解一切的关键。★ 四个核心参数:
# maximumPoolSize 是池子天花板最关键,绝非越大越好受
# 数据库 max_connections 硬约束;connectionTimeout 是
# 借连接最多等多久到点抛异常,★★ 别设太长(30s 会让
# 请求傻等 30s 才失败、线程被占住雪崩更快)正解设短
# 快速失败;maxLifetime 是连接最长寿命到点回收重建,
# ★★ 必须短于数据库和防火墙的连接空闲超时否则会借到
# 对方已掐断的死连接;minimumIdle 是空闲常备连接数,
# 实践中常和 max 设相等让池子大小固定不伸缩更稳。
// ★ HikariCP 连接池配置:每一个参数都不是随便填的
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://db-host:3306/order_db");
config.setUsername("app");
config.setPassword(System.getenv("DB_PASSWORD")); // ★ 密码绝不写死在代码里
// ★ maximumPoolSize:池子最多能有多少个连接 —— 最关键的参数
// 它不是"越大越好",上限受数据库 max_connections 制约
config.setMaximumPoolSize(20);
// ★ minimumIdle:空闲时也至少保持多少个连接待命 —— 避免流量来时现建
// 一般建议和 max 设成一样,池子大小固定、不伸缩,行为更稳定
config.setMinimumIdle(20);
// ★ connectionTimeout:借不到连接时最多等多久(毫秒)—— 到点就抛异常
// ★★ 别设太长!设 30s 意味着请求会傻等 30s 才失败,体验极差
config.setConnectionTimeout(3000); // ★ 3 秒拿不到就快速失败
// ★ maxLifetime:一个连接最长存活多久,到点就被回收重建
// ★★ 必须比数据库/防火墙的连接空闲超时短,否则会用到已被掐断的死连接
config.setMaxLifetime(1800000); // 30 分钟
// ★ idleTimeout:空闲连接超过这个时间就被回收(回落到 minimumIdle)
config.setIdleTimeout(600000);
// ★★ leakDetectionThreshold:连接借出超过这个时间还没还,就打印告警堆栈
// —— 排查"连接泄漏"的神器,生产环境强烈建议开
config.setLeakDetectionThreshold(20000); // 借出超 20s 未还 -> 告警
HikariDataSource dataSource = new HikariDataSource(config);
连接池耗尽,元凶之一:连接泄漏
# === ★ 现在拆解:连接,是怎么被一点点"漏"光的 ===
# === ★ 什么叫"连接泄漏" ===
# ★ ★ 连接泄漏(connection leak):你从池子里借了一个
# 连接,但因为某种原因,【始终没有把它还回去】。
# ★ ★ 它和内存泄漏一模一样:借出去的连接,池子以为
# "还在被使用",永远不回收。漏一个少一个,漏到最后,
# 池子里【一个可借的都不剩】—— 连接池耗尽。
# === ★ 最常见的泄漏:异常路径上,close() 没被执行 ===
# ★ ★ 典型写法:借连接 -> 执行 SQL -> 手动 close()。
# 问题在:如果"执行 SQL"这一步【抛了异常】,代码就
# 直接跳走了,后面那行 close() 【根本轮不到执行】。
# ★ ★★ 平时不出错时,一切正常,你根本发现不了。可一旦
# 线上某个查询开始频繁报错(数据异常、死锁、超时),
# 每报一次错,就泄漏一个连接 —— 泄漏速度和报错速度
# 挂钩,池子很快被掏空。这就是"为什么平时好好的,
# 一出问题就雪崩"。
# === ★ 根治:try-with-resources,把归还交给语言 ===
# ★ ★ 别再手动 close()。用 try-with-resources(Java)/
# with(Python)/defer(Go)这类机制 —— 它保证:无论
# 代码块是正常结束、还是中途抛异常,借来的资源都
# 【一定会被关闭/归还】。把"归还"这件事,从"靠你
# 记得写",变成"靠语言强制执行"。
# === ★ 兜底:打开连接池的"泄漏检测" ===
# ★ ★ 像 HikariCP 的 leakDetectionThreshold:一个连接
# 被借出超过设定时间(如 20 秒)还没还,池子就打印一
# 段告警堆栈,精确告诉你【是哪段代码】借了不还。
# ★ 生产环境强烈建议开 —— 它能让"泄漏"这种隐形问题,
# 在酿成事故前就暴露出来。
# === 小结 ===
# ★ 连接泄漏:从池子借了连接但始终没还回去,和内存泄漏
# 一模一样 —— 池子以为它还在用永远不回收,漏一个少
# 一个,漏到最后一个可借的都不剩,连接池耗尽。★★ 最
# 常见的泄漏在异常路径:典型写法借连接->执行 SQL->手动
# close(),若执行 SQL 抛异常代码直接跳走后面 close()
# 根本轮不到;平时不出错发现不了,可一旦线上某查询
# 频繁报错每报一次就漏一个,泄漏速度和报错速度挂钩
# 池子很快掏空 —— 这就是平时好好的一出问题就雪崩。
# ★ 根治:别手动 close(),用 try-with-resources /
# with / defer,保证无论正常结束还是抛异常资源一定被
# 归还,把归还从"靠你记得写"变成"靠语言强制执行"。
# ★ 兜底:打开连接池泄漏检测(如 HikariCP 的
# leakDetectionThreshold),连接借出超时未还就打印
# 告警堆栈精确指出是哪段代码,生产强烈建议开。
// ★ 反例:借了连接却没有可靠地归还 —— 连接就这样"漏"掉了
public Order queryOrderWRONG(long orderId) throws SQLException {
Connection conn = dataSource.getConnection(); // ★ 从池里借出一个连接
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM orders WHERE id = ?");
ps.setLong(1, orderId);
ResultSet rs = ps.executeQuery();
// ★★ 致命:上面任何一行抛异常,下面的 close() 就永远执行不到,
// 这个连接就再也回不到池子里 —— 这就是连接泄漏
Order order = rs.next() ? mapRow(rs) : null;
rs.close();
ps.close();
conn.close(); // ★ 看似归还了,但只有"不抛异常"时才轮得到这行
return order;
}
// ★ 正例:用 try-with-resources,无论是否抛异常都保证连接被归还
public Order queryOrder(long orderId) throws SQLException {
String sql = "SELECT * FROM orders WHERE id = ?";
// ★★ try(...) 里声明的资源,块一结束就自动 close —— 哪怕中途抛异常
// conn.close() 在连接池里的语义不是"断开",而是"归还到池子"
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, orderId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
} // ★ 到这里,rs / ps / conn 一定都被归还了,抛异常也不例外
}
连接池耗尽,元凶之二:慢查询长期占用
# === ★ 第二个元凶:连接没漏,但被"占用"得太久 ===
# === ★ 一个连接,被占用了多长时间? ===
# ★ ★ 一个连接从"借出"到"归还",中间这段时间,它就是
# 被独占的。这段时间 = SQL 在数据库里的执行时间
# + 你的代码处理结果的时间 + ★★ 你在持有连接期间
# 干的【任何别的事】。
# ★ ★ 池子的"吞吐能力" ≈ 连接数 ÷ 平均占用时长。占用
# 时长每翻一倍,等于池子凭空缩小了一半。
# === ★ 元凶 A:一条慢 SQL,占住连接不放 ===
# ★ ★ 一条没走索引的查询,执行要 5 秒。那么这 5 秒里,
# 它占用的那个连接,谁也别想用。★ 高并发下,几十个
# 这样的慢查询一起来,几十个连接瞬间被占光,后面
# 全部请求排队等待 —— 池子被慢 SQL"焊死"了。
# ★ ★ 治本:回到数据库本身,把慢查询找出来(慢查询
# 日志)、加索引、改写 SQL。★ 兜底:给 SQL 设
# 【语句超时】,一条查询跑太久就强制中断,别让它
# 无限期占着连接。
# === ★★ 元凶 B:在持有连接期间,做了"不相干的慢事" ===
# ★ ★ 这是最隐蔽、也最致命的一类。最经典的场景:在一个
# 数据库事务【中间】,插了一个【远程调用】——
# 比如调一个 HTTP 接口去扣库存。
# ★ ★★ 后果:从事务开始,这个连接就被本次事务独占;而
# 那个 HTTP 调用,可能耗时几百毫秒、甚至卡住超时。
# 这整段时间,连接都【被白白占着】,什么数据库活也
# 没干,纯粹在"陪着"那个远程调用空等。
# ★ 还有同类:事务里 sleep、事务里读大文件、事务里等锁。
# 原则:【持有数据库连接期间,只做数据库的事】。慢的、
# 不相干的事(尤其是网络 IO),一律挪到事务外面去。
# === 小结 ===
# ★ 第二个元凶:连接没漏,但被占用太久。一个连接从借出
# 到归还的占用时长 = SQL 执行时间 + 代码处理结果时间
# + ★★ 持有连接期间干的任何别的事;池子吞吐 ≈ 连接数
# ÷ 平均占用时长,占用时长翻倍等于池子凭空缩小一半。
# ★ 元凶 A 一条慢 SQL 占住连接不放:没走索引的查询跑
# 5 秒这 5 秒连接谁也用不了,高并发下几十个慢查询一起
# 来连接瞬间占光池子被焊死;治本是找出慢查询(慢查询
# 日志)加索引改写 SQL,兜底是给 SQL 设语句超时跑太久
# 强制中断。★★ 元凶 B 最隐蔽:在数据库事务中间插了
# 远程调用(如调 HTTP 扣库存),从事务开始连接就被独占,
# 而 HTTP 调用可能耗时几百毫秒甚至卡住超时,这整段时间
# 连接被白白占着什么数据库活没干纯在陪远程调用空等;
# 同类还有事务里 sleep、读大文件、等锁。原则:持有
# 数据库连接期间只做数据库的事,慢的不相干的事尤其是
# 网络 IO 一律挪到事务外面。
// ★ 反例:在数据库事务里,夹了一个慢操作 —— 连接被白白占住
public void createOrderWRONG(Order order) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false); // ★ 开启事务,连接被本次事务独占
insertOrder(conn, order);
// ★★ 致命:在事务中间,调了一个外部接口(扣库存)。
// 这个 HTTP 调用可能耗时几百毫秒甚至超时 ——
// 而这整段时间,这个数据库连接都被【死死占着】不归还
inventoryClient.deduct(order.getItems()); // 远程调用,慢!
updateOrderPaid(conn, order);
conn.commit();
}
}
// ★ 正例:事务里只放数据库操作,慢操作挪到事务之外
public void createOrder(Order order) throws SQLException {
// ★ 第 1 步:慢的远程调用,放在事务【之外】做,此时不持有任何连接
inventoryClient.deduct(order.getItems());
// ★ 第 2 步:事务只包住纯数据库操作 —— 连接被占用的时间被压到最短
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
insertOrder(conn, order);
updateOrderPaid(conn, order);
conn.commit(); // ★ 事务极短,连接很快归还,池子重新流动起来
}
}
// ★ 兜底:给每条语句设超时,一条 SQL 跑太久就强制中断,别无限期占连接
public Order queryWithTimeout(long orderId) throws SQLException {
String sql = "SELECT * FROM orders WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setQueryTimeout(3); // ★ 这条查询最多跑 3 秒,超了就中断
ps.setLong(1, orderId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
工程选型与坑:连接池到底该配多大
# === ★ 最后一个核心问题:maximumPoolSize,到底配多少 ===
# === ★★ 反直觉:连接池,不是越大越好 ===
# ★ 开篇我的错,就是"调大池子"。为什么大了反而更糟?
# ★ ★ 原因 1:连接总数受【数据库】硬约束。数据库的
# max_connections 是有限的。你的应用可能有好几个
# 实例,每个实例都开个大池子,连接数一相乘,轻松
# 就把数据库的连接数打爆 —— 数据库自己先挂。
# ★ ★ 原因 2:连接多 ≠ 处理快。真正干活的是数据库的
# CPU 和磁盘。几百个连接同时涌进去,数据库忙于在
# 几百个任务间切换上下文、争抢锁,【总吞吐反而下降】。
# 这就是我调到 200 之后,数据库 CPU 飙满的真相。
# === ★ 池子大小:一个广为流传的经验公式 ===
# ★ ★ HikariCP 官方建议的起点公式(用于粗略估算):
# 连接数 ≈ (CPU 核数 * 2) + 有效磁盘数
# ★ ★ 它给出的数字,往往【小得惊人】—— 一台几核的库,
# 算出来可能就十几、二十。这恰恰是要点:你需要的
# 连接,远比你以为的少。★ 公式只是起点,真正的值,
# 要靠【压测 + 看监控】来定。
# === ★ 关键:盯住连接池的几个监控指标 ===
# ★ ★ 连接池会暴露几个核心数字,务必接入监控:
# - ★ active:正在被借用的连接数;
# - ★ idle:空闲待命的连接数;
# - ★★ pending:正在【排队等连接】的线程数 —— 这个
# 数字只要持续大于 0,就说明池子已经不够用 / 或连接
# 被占住了,是最该告警的指标。
# ★ 看这几个数,远比"拍脑袋猜一个池子大小"靠谱。
# === ★ 排查顺序:池子耗尽了,先查什么 ===
# ★ ★ 报"连接拿不到"时,【第一反应绝不该是调大池子】。
# 正确顺序:① 查有没有连接泄漏(开泄漏检测、看堆栈);
# ② 查有没有慢 SQL(慢查询日志);③ 查有没有"事务里
# 做远程调用"这类长占用。★★ 把这三样排掉,池子大小
# 往往根本不用动。调大,是【最后】才考虑、且要连同
# 数据库承受能力一起评估的事。
# === 认知 ===
# ★ maximumPoolSize 配多少。★★ 反直觉:连接池不是越大
# 越好。原因 1 连接总数受数据库硬约束:数据库
# max_connections 有限,应用多个实例每个都开大池子连接
# 数一相乘轻松打爆数据库,数据库自己先挂。原因 2 连接
# 多不等于处理快:真正干活的是数据库 CPU 和磁盘,几百
# 个连接同时涌进去数据库忙于上下文切换和争锁总吞吐
# 反而下降。★ 池子大小经验公式(HikariCP 官方起点):
# 连接数 ≈ (CPU 核数 * 2) + 有效磁盘数,给出的数字往往
# 小得惊人 —— 你需要的连接远比你以为的少,公式只是起点
# 真正的值靠压测加看监控定。★★ 盯住连接池监控:active
# (正被借用)、idle(空闲待命)、pending(正排队等连接
# 的线程数,持续大于 0 就说明池子不够用或连接被占住,
# 最该告警)。★ 排查顺序:报连接拿不到时第一反应绝不
# 该是调大池子,正确顺序是先查连接泄漏、再查慢 SQL、
# 再查事务里做远程调用这类长占用,三样排掉池子大小往往
# 根本不用动,调大是最后才考虑且要连同数据库承受能力
# 一起评估的事。
// ★ 工程实践:连接池大小用经验公式起步,并暴露监控指标
HikariConfig config = new HikariConfig();
// ★ 经验公式起点:连接数 ≈ (CPU 核数 * 2) + 有效磁盘数
// ★★ 这个数往往小得惊人 —— 8 核单盘,起点也就 17 个左右
int cpuCores = Runtime.getRuntime().availableProcessors();
int poolSize = cpuCores * 2 + 1;
config.setMaximumPoolSize(poolSize);
config.setMinimumIdle(poolSize); // ★ 和 max 相等,池子不伸缩更稳
config.setConnectionTimeout(3000); // ★ 借不到 3 秒就快速失败
config.setLeakDetectionThreshold(20000); // ★ 借出超 20 秒未还 -> 告警
HikariDataSource dataSource = new HikariDataSource(config);
// ★★ 把连接池的核心指标,定期吐到监控系统 —— 别等事故了才去猜
public void reportPoolMetrics(HikariDataSource ds) {
HikariPoolMXBean pool = ds.getHikariPoolMXBean();
int active = pool.getActiveConnections(); // 正在被借用
int idle = pool.getIdleConnections(); // 空闲待命
int pending = pool.getThreadsAwaitingConnection(); // ★★ 正在排队等连接
metrics.gauge("db.pool.active", active);
metrics.gauge("db.pool.idle", idle);
metrics.gauge("db.pool.pending", pending);
// ★★ pending 持续大于 0 = 池子已不够用 / 或连接被占住,立即告警
if (pending > 0) {
log.warn("连接池告警:有 {} 个线程在排队等连接,active={}", pending, active);
}
}
命令速查
数据库连接池:借、用、还
=============================================================
借 acquire getConnection(),有空闲立刻给,没有就排队等
用 独占这个连接执行 SQL,别人借不走
还 release close() —— 在连接池里语义是"归还",不是"断开"
四个核心参数
-------------------------------------------------------------
maximumPoolSize 池子天花板,受数据库 max_connections 硬约束
connectionTimeout 借不到最多等多久,★设短(如 3s)快速失败
maxLifetime 连接最长寿命,必须短于数据库/防火墙空闲超时
minimumIdle 空闲常备连接数,实践中常和 max 设成相等
连接池耗尽的两大元凶
-------------------------------------------------------------
连接泄漏 借了不还(异常路径漏掉 close) -> try-with-resources
慢占用 慢 SQL / 事务里做远程调用 -> 持有连接期间只做数据库的事
池子耗尽的排查顺序(别一上来就调大池子!)
-------------------------------------------------------------
1. 查连接泄漏 开 leakDetectionThreshold,看告警堆栈
2. 查慢 SQL 慢查询日志,加索引 / 改写 SQL
3. 查长占用 事务里有没有远程调用 / sleep / 等锁
4. 看监控指标 pending 持续大于 0 才是真不够,最后才考虑调大
口诀:close() 是"归还"不是"断开"
连接池不是越大越好,大了会反过来压垮数据库
持有连接期间,只做数据库的事
避坑清单
- 建立一个数据库连接是重活(TCP 握手、认证、TLS、数据库分配会话资源),一次几十毫秒比一条主键查询贵几十倍,所以要用连接池复用
- 连接池里 close() 的语义不是"断开连接"而是"把连接归还给池子",理解这一点是理解连接池一切行为的关键
- connectionTimeout 别设太长,设 30 秒意味着池子满时每个请求傻等 30 秒才失败、线程被占住雪崩更快,正解设短快速失败
- maxLifetime 必须短于数据库和中间防火墙的连接空闲超时,否则会借到对方已悄悄掐断的死连接、一用就报错
- 连接泄漏最常见于异常路径:手动 close() 时若前面抛异常 close() 根本轮不到执行,平时发现不了一报错就雪崩
- 别手动 close() 连接,用 try-with-resources / with / defer,把归还从"靠你记得写"变成"靠语言强制执行"
- 生产环境务必打开连接池的泄漏检测(如 HikariCP 的 leakDetectionThreshold),让隐形的泄漏在酿成事故前就暴露出来
- 一条没走索引的慢 SQL 会长期占住连接,高并发下几十个慢查询就能把池子焊死,治本是加索引、兜底是给 SQL 设语句超时
- 绝不要在数据库事务中间做远程调用、sleep、读大文件,持有连接期间只做数据库的事,慢的不相干的事一律挪到事务外
- 连接池不是越大越好,连接数受数据库 max_connections 硬约束、连接一多数据库忙于上下文切换总吞吐反而下降,报连接拿不到先查泄漏和慢占用别一上来就调大
总结
这一趟把连接池彻底理清的过程,纠正了我一个特别本能、几乎是条件反射式的错误判断——看到"资源不够用"的报错,就去"把资源调大"。事故那一晚,我看到 Connection is not available,我的大脑没有任何停顿,直接得出结论:连接不够,调大。我把池子从 20 调到 200,这个动作背后,是一个我从没质疑过的假设——"报什么不够,就补什么"。可连接池这件事,结结实实地教训了我:这个假设,常常是错的,而且错得很危险。我的服务,从头到尾都不缺连接。20 个连接,对它真实的负载来说,绰绰有余。它缺的,是"连接的流动"——借出去的连接,要能快快地还回来,这个池子才活得起来。而我的代码里,有连接借了不还(泄漏),有连接被慢 SQL 占着,有连接在事务中间陪着一个远程 HTTP 调用空等。这些连接,一个个都"借出去了,但很久很久才回来",甚至永远不回来。池子不是空了,是池子里的水"不流动"了。我调大池子,等于往一个出水口堵死的池子里拼命灌水——水位是高了一阵,可堵塞一点没解决,而那多出来的几百个连接,反手就成了压垮数据库的最后一根稻草。复盘到最深,我意识到我真正要修正的,是一种"静态"的资源观:我习惯于把系统的容量,理解成一个个静态的数字——多少连接、多少内存、多少线程——不够了,就把数字调大。可连接池让我清清楚楚地看到了"动态"的那一面:一个系统能扛多少,从来不只取决于它有多少资源,更取决于这些资源"周转得有多快"。20 个连接,如果每个都用 5 毫秒就归还,一秒钟能服务几千个请求;20 个连接,如果每个都被占住 5 秒,一秒钟连 5 个都服务不了。同样的 20,差了一千倍。瓶颈从来不在那个静态的"20",而在那个动态的"占用时长"。想通这一点,我看待性能问题的眼光彻底变了。现在再遇到"什么什么不够用"的报错,我会强迫自己先停一下,问一个不一样的问题:不是"我该补多少",而是"我借出去的东西,为什么迟迟回不来"。连接池只是一个缩影——线程池、内存、文件句柄、各种限流配额,背后都是同一个道理。真正的修复,几乎从不是"把池子调大"那么省事;它是一件更需要耐心的事:打开泄漏检测,顺着告警堆栈找到那段借了不还的代码;翻慢查询日志,给那条五秒的 SQL 补上索引;把那个藏在事务中间的远程调用,小心地挪到事务外面。把这些一件件做完,你会发现,那个曾经被你嫌"太小"的池子,其实一直都够用。系统的瓶颈,常常不在"资源不够",而在"资源没有快快地流动起来"——遇到拥堵,先别急着拓宽河道,先去找一找,到底是哪里,把水给堵住了。
—— 别看了 · 2026