HIKARICP · SPRING TRANSACTION · MYBATIS · MYSQL · POSTGRESQL

一排拨通的电话,谁打完谁挂

程序和数据库说话,要先打通一部“电话”(连接)。打通很慢,所以提前拨好一排,谁要就借一部,说完还回来:这就是连接池。事务呢,就是借着电话、一直不挂的那段时间。性能问题,大多出在“电话不够用”或者“占着电话不挂”。

每次现拨没有连接池

一次请求
新建连接 ≈ 20 ms
SQL

一共约 23 ms,大部分花在拨号上

从池子里借HikariCP

一次请求
SQL

一共约 3 ms:借连接只要几微秒,看不见

两条按同一比例画:橙色是建连接,棕色是执行 SQL(≈ 2 ms),紫色是提交(≈ 1 ms)。数字是量级示意,实际看网络、TLS 和硬件。

连接池HikariCP

提前拨好的一排电话。Spring Boot 默认用它,默认 10 部。

借不到就排队,最多等 30 秒
连接Connection

一条通往数据库的线路。同一时刻只能给一个线程用。

数据库那头对应一个线程或进程
事务@Transactional

从开始到提交,连接一直被占着,改过的行也一直锁着。

越长越贵
数据库MySQL / PostgreSQL

同时能认真干活的,差不多就是它的 CPU 核数那么多。

连接多了反而互相拖累

为什么新建一个连接这么贵

打通一条连接,两边要来回好几趟,数据库那头还得专门安排一个线程或进程来伺候它。

1TCP 握手来回一趟网络
2TLS 握手开了加密就再来一两趟
3认证交换密码摘要,再来回几趟
4数据库开工MySQL 分一个线程,PostgreSQL fork 一个进程
5设置会话字符集、时区、自动提交……

MySQL:一个连接一个线程

连接 1 → 线程连接 2 → 线程连接 3 → 线程…

线程比进程轻,但每个也要占内存和调度。默认 max_connections = 151。

PostgreSQL:一个连接一个进程

连接 1 → 进程连接 2 → 进程连接 3 → 进程…

每个后端进程都要占几 MB 内存,建得更慢。默认 max_connections = 100,连接多的场景常在前面再加一层 PgBouncer。

池子开多大:不是越大越好

先算清楚“要多少”,再想清楚“数据库扛多少”。两个数里取小的。

要多少:利特尔法则

同时占用的连接数 = 每秒请求数 × 每次占用多久
1000 次/秒 × 5 ms平均只占 5 个连接,池子 10 个绰绰有余 1000 次/秒 × 205 ms要占 205 个,因为事务里多了一个 200 ms 的外部调用

反过来算上限:10 个连接、每次占 205 ms,每秒最多只能服务 10 ÷ 0.205 ≈ 49 次。

扛多少:数据库的核数

连接数 ≈ CPU 核数 × 2 + 有效磁盘数

这是 HikariCP 文档引用的、来自 PostgreSQL 社区的经验公式。8 核 + SSD 的库,起步十几到二十个就够了。

这是整个数据库的总数。10 台应用实例各开 20 个,数据库那头就是 200 个。

CPU 就那么几个核

8 核同一时刻最多真正跑 8 个查询,多出来的连接只是在排队,还要来回切换。

锁和缓冲区更挤

同时改的人越多,抢同一把锁、同一块内存页的冲突越多,每个人都变慢。

内存被吃掉

PostgreSQL 每个连接一个进程,排序、哈希还各自要 work_mem,几百个连接能把内存吃光。

连接池模拟器

请求按随机节奏到来,每个先借连接,用完归还。借不到就排队,排满 1 秒(为了演示,把默认的 30 秒缩短了)还没借到就算失败。Tomcat 一共 200 个线程,等连接的、在用连接的、在调外部接口的,都占着线程。

预设:
模拟时间 0.0 秒

连接池(实心 = 正在用)
Tomcat 的 200 个线程都在干什么
拿着连接在等连接在调外部接口(不占连接)
等连接的线程数,最近 2 秒

模拟器里,数据库永远按时执行完 SQL。真实世界里连接一多,数据库自己也会变慢(上一节说的),所以把池子调到 100 在这里看着很美,现实中不一定。

事务怎么吃掉连接

Spring 在进入 @Transactional 方法时就借走连接,直到提交才还(MyBatis 第 02 篇)。方法里干的所有事,哪怕跟数据库无关,都算在占用时间里。

整个方法包在事务里

方法
调支付接口 200 ms
占着连接
206 ms:从头占到尾

每次占 206 ms,期间改过的行也一直锁着

只把写库那两步包进事务

方法
调支付接口 200 ms(事务外)
占着连接

每次只占 6 ms,同一个池子能多扛三十多倍

事务里别做慢事

调 HTTP 接口、发消息、上传文件、复杂计算,都挪到事务外面。事务里只留读写数据库的几步。

单条查询不用包事务

不加 @Transactional,MyBatis 每条 SQL 借一下就还。加了事务,还要多一次开始和提交的来回。

readOnly 不是白给的

readOnly = true 让数据库少做点事,比如 InnoDB 不用分配事务 ID;但驱动可能多发一条设置语句。多条查询要看到一致的数据时再用。

数据库那一头:提交、版本和锁

一个事务在数据库里的成本,主要是三样:提交时等日志落盘、改过的数据留着旧版本、改过的行一直锁着。

插 1000 行,每行自己一个事务

1000 次提交 = 1000 次等日志写到磁盘。MySQL 默认 innodb_flush_log_at_trx_commit=1,PostgreSQL 默认 synchronous_commit=on,都是每次提交都要落盘。

1000 行放进一个事务,批量发

只落盘 1 次。再配上批量改写(MySQL 的 rewriteBatchedStatements=true,PostgreSQL 驱动的 reWriteBatchedInserts=true),网络来回也少了。

上面两条按提交次数画:左边是 1000 条细线,右边只有最右边一条。不过事务也别大到上百万行,见下面的长事务。

长事务:旧版本清不掉

一个没提交的事务
10:00 开始,到 10:30 还开着
这期间某一行
v1v2v3v4v5v6v7v8 最新

别人每改一次,就多一个版本。那个老事务可能还要看旧版本,所以这些版本一个都不能清。

MySQL(InnoDB)PostgreSQL
旧版本放在undo 日志表里,旧行变成死元组
长事务的后果undo 清不掉,history list 越来越长,查询越来越慢VACUUM 清不掉,表和索引不断膨胀
默认隔离级别可重复读读已提交
提交时落盘redo 日志(开了 binlog 还有 binlog)WAL
还有锁update、delete 改到的行,一直锁到提交。事务越长,别人等这把锁的时间越长,等的那个请求又占着自己的连接,连锁反应就是整个池子被耗光。MySQL 默认的可重复读下还有间隙锁,锁住的范围更大。

MyBatis 和驱动这一层

同样一条 SQL,驱动参数不同,网络来回次数和内存占用可能差很多。

要做的事MySQL Connector/JPostgreSQL JDBC
批量插入变成一条rewriteBatchedStatements=truereWriteBatchedInserts=true
预编译语句默认在客户端拼好再发;HikariCP 建议开 useServerPrepStmts=true 和 cachePrepStmts=true同一条语句执行 5 次后,自动在服务端预编译(prepareThreshold=5)
大结果集别一次读进内存默认整个结果集拉进来;要流式读,开 useCursorFetch=true 再设 fetchSize要在事务里(自动提交关掉)并设了 fetchSize,才按批取
MyBatis 这边批量用 ExecutorType.BATCH(MyBatis 第 05 篇 的 saveBatch);一对多别写成 N+1;大查询在 select 上设 fetchSize
一份起步配置先用这个,再按监控调
spring:
  datasource:
    # MySQL
    url: jdbc:mysql://db:3306/shop?rewriteBatchedStatements=true&cachePrepStmts=true&useServerPrepStmts=true
    # PostgreSQL:jdbc:postgresql://db:5432/shop?reWriteBatchedInserts=true
    hikari:
      maximum-pool-size: 20        # 默认 10;所有实例加起来别超过数据库的上限
      minimum-idle: 20             # 默认和上面一样:固定大小,不用临时建连接
      connection-timeout: 3000     # 默认 30000 ms;借不到快点失败,比卡 30 秒好
      max-lifetime: 1800000        # 默认 30 分钟;要比数据库和中间网络设备的超时短
      leak-detection-threshold: 60000   # 借出去 60 秒没还就打日志,排查泄漏

JDK 8 和 JDK 17

连接池和事务的原理两边完全一样。变的是 HikariCP 的版本,以及虚拟线程带来的一个误会。

Spring Boot 2

JDK 8+
  • 默认连接池就是 HikariCP,Boot 2.7 用的是 4.0.3,也是支持 JDK 8 的最后一个版本
  • 配置写在 spring.datasource.hikari.*

Spring Boot 3

JDK 17+
  • HikariCP 5.x 起要求 JDK 11+,Boot 3 用的是 5.x 及以后的版本,配置项不变
  • JDK 21 的虚拟线程能让 Tomcat 同时挂起成千上万个请求,但数据库连接还是那几个。开了虚拟线程,池子照样是瓶颈:等连接的从几十个线程变成几千个虚拟线程
没变的:连接贵,所以要池池子不是越大越好事务从头占到尾提交要落盘长事务拖累整个库

常踩的坑

看到慢或者报错,先问两件事:连接够不够用?每个连接被占了多久?

Connection is not available, request timed out after 30000ms

池子被借光了,30 秒没等到。查慢 SQL、长事务、连接泄漏,最后才考虑加大池子。

Connection leak detection triggered for … on thread …

有人借了连接没还,多半是自己 dataSource.getConnection() 后没放进 try-with-resources。日志后面跟着借走时的调用栈。

池子从 20 加到 200,反而更慢了

数据库的核数没变,多出来的连接只是在抢 CPU 和锁。先把每次占用的时间降下来。

平时好好的,一到高峰全部超时

事务里调了外部接口,对方一慢,连接全被占着。把外部调用挪出事务。

Too many connections / FATAL: sorry, too many clients already

实例数 × 每个实例的池子大小,超过了数据库的上限(MySQL 默认 151,PostgreSQL 默认 100)。扩容时别忘了这笔账。

Possibly consider using a shorter maxLifetime value.

连接在池子里放太久,被数据库(MySQL 默认 wait_timeout 8 小时)或中间的防火墙悄悄断了。max-lifetime 要比它们都短。

导入 10 万行要好几分钟

每行一个事务、每行一次网络来回。放进一个事务分批提交,打开批量改写参数。

导出报表时 OutOfMemoryError

驱动默认把整个结果集读进内存。用流式读取(fetchSize),或者按主键分批查。

库越来越慢,查不出是哪条 SQL

看看有没有开了很久没提交的事务:MySQL 查 information_schema.innodb_trx,PostgreSQL 查 pg_stat_activity 里的 idle in transaction。

接了 PgBouncer 后报 prepared statement "S_1" does not exist

事务池模式下,同一个会话的语句可能落到不同的后端连接上。升级到支持预编译语句的 PgBouncer(1.21+)并配好,或在 JDBC 里设 prepareThreshold=0。