HIKARICP · SPRING TRANSACTION · MYBATIS · MYSQL · POSTGRESQL
一排拨通的电话,谁打完谁挂
程序和数据库说话,要先打通一部“电话”(连接)。打通很慢,所以提前拨好一排,谁要就借一部,说完还回来:这就是连接池。事务呢,就是借着电话、一直不挂的那段时间。性能问题,大多出在“电话不够用”或者“占着电话不挂”。
每次现拨没有连接池
一共约 23 ms,大部分花在拨号上
从池子里借HikariCP
一共约 3 ms:借连接只要几微秒,看不见
两条按同一比例画:橙色是建连接,棕色是执行 SQL(≈ 2 ms),紫色是提交(≈ 1 ms)。数字是量级示意,实际看网络、TLS 和硬件。
HikariCP提前拨好的一排电话。Spring Boot 默认用它,默认 10 部。
借不到就排队,最多等 30 秒Connection一条通往数据库的线路。同一时刻只能给一个线程用。
数据库那头对应一个线程或进程@Transactional从开始到提交,连接一直被占着,改过的行也一直锁着。
越长越贵MySQL / PostgreSQL同时能认真干活的,差不多就是它的 CPU 核数那么多。
连接多了反而互相拖累为什么新建一个连接这么贵
打通一条连接,两边要来回好几趟,数据库那头还得专门安排一个线程或进程来伺候它。
MySQL:一个连接一个线程
线程比进程轻,但每个也要占内存和调度。默认 max_connections = 151。
PostgreSQL:一个连接一个进程
每个后端进程都要占几 MB 内存,建得更慢。默认 max_connections = 100,连接多的场景常在前面再加一层 PgBouncer。
池子开多大:不是越大越好
先算清楚“要多少”,再想清楚“数据库扛多少”。两个数里取小的。
要多少:利特尔法则
反过来算上限:10 个连接、每次占 205 ms,每秒最多只能服务 10 ÷ 0.205 ≈ 49 次。
扛多少:数据库的核数
这是 HikariCP 文档引用的、来自 PostgreSQL 社区的经验公式。8 核 + SSD 的库,起步十几到二十个就够了。
这是整个数据库的总数。10 台应用实例各开 20 个,数据库那头就是 200 个。
8 核同一时刻最多真正跑 8 个查询,多出来的连接只是在排队,还要来回切换。
同时改的人越多,抢同一把锁、同一块内存页的冲突越多,每个人都变慢。
PostgreSQL 每个连接一个进程,排序、哈希还各自要 work_mem,几百个连接能把内存吃光。
连接池模拟器
请求按随机节奏到来,每个先借连接,用完归还。借不到就排队,排满 1 秒(为了演示,把默认的 30 秒缩短了)还没借到就算失败。Tomcat 一共 200 个线程,等连接的、在用连接的、在调外部接口的,都占着线程。
模拟器里,数据库永远按时执行完 SQL。真实世界里连接一多,数据库自己也会变慢(上一节说的),所以把池子调到 100 在这里看着很美,现实中不一定。
事务怎么吃掉连接
Spring 在进入 @Transactional 方法时就借走连接,直到提交才还(MyBatis 第 02 篇)。方法里干的所有事,哪怕跟数据库无关,都算在占用时间里。
整个方法包在事务里
每次占 206 ms,期间改过的行也一直锁着
只把写库那两步包进事务
每次只占 6 ms,同一个池子能多扛三十多倍
调 HTTP 接口、发消息、上传文件、复杂计算,都挪到事务外面。事务里只留读写数据库的几步。
不加 @Transactional,MyBatis 每条 SQL 借一下就还。加了事务,还要多一次开始和提交的来回。
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 条细线,右边只有最右边一条。不过事务也别大到上百万行,见下面的长事务。
长事务:旧版本清不掉
别人每改一次,就多一个版本。那个老事务可能还要看旧版本,所以这些版本一个都不能清。
| MySQL(InnoDB) | PostgreSQL | |
|---|---|---|
| 旧版本放在 | undo 日志 | 表里,旧行变成死元组 |
| 长事务的后果 | undo 清不掉,history list 越来越长,查询越来越慢 | VACUUM 清不掉,表和索引不断膨胀 |
| 默认隔离级别 | 可重复读 | 读已提交 |
| 提交时落盘 | redo 日志(开了 binlog 还有 binlog) | WAL |
MyBatis 和驱动这一层
同样一条 SQL,驱动参数不同,网络来回次数和内存占用可能差很多。
| 要做的事 | MySQL Connector/J | PostgreSQL JDBC |
|---|---|---|
| 批量插入变成一条 | rewriteBatchedStatements=true | reWriteBatchedInserts=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 同时挂起成千上万个请求,但数据库连接还是那几个。开了虚拟线程,池子照样是瓶颈:等连接的从几十个线程变成几千个虚拟线程
常踩的坑
看到慢或者报错,先问两件事:连接够不够用?每个连接被占了多久?
池子被借光了,30 秒没等到。查慢 SQL、长事务、连接泄漏,最后才考虑加大池子。
有人借了连接没还,多半是自己 dataSource.getConnection() 后没放进 try-with-resources。日志后面跟着借走时的调用栈。
数据库的核数没变,多出来的连接只是在抢 CPU 和锁。先把每次占用的时间降下来。
事务里调了外部接口,对方一慢,连接全被占着。把外部调用挪出事务。
实例数 × 每个实例的池子大小,超过了数据库的上限(MySQL 默认 151,PostgreSQL 默认 100)。扩容时别忘了这笔账。
连接在池子里放太久,被数据库(MySQL 默认 wait_timeout 8 小时)或中间的防火墙悄悄断了。max-lifetime 要比它们都短。
每行一个事务、每行一次网络来回。放进一个事务分批提交,打开批量改写参数。
驱动默认把整个结果集读进内存。用流式读取(fetchSize),或者按主键分批查。
看看有没有开了很久没提交的事务:MySQL 查 information_schema.innodb_trx,PostgreSQL 查 pg_stat_activity 里的 idle in transaction。
事务池模式下,同一个会话的语句可能落到不同的后端连接上。升级到支持预编译语句的 PgBouncer(1.21+)并配好,或在 JDBC 里设 prepareThreshold=0。