不绕弯子,直接说结论:绝大多数项目里的JDBC性能问题,根本轮不到上什么中间件、换什么数据库,光是连接管理、批处理、预编译这几件事做扎实,性能翻一倍是很正常的。甚至很多慢到要被DBA约谈的接口,问题就出在一条getConnection()放在循环里这种最基础的写法上。
这篇东西我不会给你贴一大段官方文档式的代码,而是按照我自己排查和处理线上问题的习惯,从连接、语句、读写策略、参数调优到慢SQL分析,一层层拆开讲。里面所有的优化点,都是我在真实项目里验证过、压过测、上过线的,不是理论推导。你照着做完不敢说一定到百万QPS,但把当前系统的JDBC读写性能提升一个量级,把数据库CPU和连接池占用打下来一大截,是完全可以复现的。
1. 先说一个反直觉的事实:性能瓶颈经常不在SQL本身
很多人一提到JDBC优化,第一反应就是把SQL改来改去,或者去调MySQL的某个参数。但我在排查线上问题的时候,第一步做的恰恰是把SQL完全撇开,先看调用链路的耗时分布。因为大量所谓的慢SQL,根本不是SQL执行得慢,而是连接拿得太慢、发送到数据库的字节太多、结果集在网络上传输太久。
举一个真实的例子。之前帮一个电商后台系统做优化,有个订单导出接口,每次请求要查询近万条订单数据,线上耗时稳定在8秒以上。开发同学一开始怀疑是SQL写得有问题,EXPLAIN看了好几遍,索引也加了,就是没效果。后来我让他把执行链路拆开,打点看各阶段耗时,结果发现真正执行SQL的时间只有800毫秒,剩下的7秒多全部耗在了每次循环里创建PreparedStatement、反复网络往返、以及ResultSet遍历时逐行通过getString取字段这三件事上。
这就是JDBC性能优化的第一个原则:先分阶段,再谈优化。JDBC的一次查询,大致可以拆成下面几个环节:
| 阶段 | 耗时占比(典型场景) | 主要开销来源 |
|---|---|---|
| 获取数据库连接 | 10%~30% | 物理建连、TCP握手、认证鉴权 |
| 构造并发送SQL | 5%~15% | 字符串拼接、网络传输字节数 |
| 数据库执行SQL | 30%~60% | SQL本身、索引、锁、IO |
| 结果集传输 | 10%~25% | 返回字段过多、网络带宽、逐行取数 |
| 应用侧解析ResultSet | 10%~30% | 反射、类型转换、逐列get** |
如果你只盯着"数据库执行"这一块,最多只能优化那一半的耗时。但连接、传输、解析这几个阶段,往往才是性价比最高的优化点,改动也最容易被忽略。接下来我按连接、查询、写入、调优、排查这个顺序,把这些点一个一个说透。
2. 连接管理是第一道坎:拿不到连接的连接池,等于没有连接池
2.1 千万不要在循环里getConnection
这是JDBC性能问题里最常见、也最刺眼的一个反模式:
for (Order order : orderList) { Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement("UPDATE orders SET status = ? WHERE id = ?"); ps.setInt(1, order.getStatus()); ps.setLong(2, order.getId()); ps.executeUpdate(); ps.close(); conn.close(); }这段代码在功能上没错,但性能上是灾难级的。DriverManager.getConnection()每次都会经历完整的TCP建连、MySQL认证、权限校验,一次连接的耗时通常在20~100毫秒之间。如果订单列表有1000条,光建连就是20~100秒,这还不算SQL执行时间。
正确做法必然是使用连接池,但在用连接池之前,先把"连接的作用域"想清楚:连接尽可能复用,但不要让一个连接在多个线程间无保护地共享。早期的JDBC规范里没有规定Connection必须线程安全,实际实现也几乎都不线程安全。正确的方式是用连接池管理Connection的生命周期,把连接拿到手之后在单线程内完成一批操作,用完归还。
2.2 连接池参数不是越大越好
很多人以为连接池大小设得越大,并发越高,性能越好。这个认知在Java服务里是错的,而且错得很彻底。数据库连接池的大小设置,有一个经典的公式在业界流传了很久:
连接数 = ((核心线程数 * 2) + 有效磁盘数)这个公式来自HikariCP官方的推荐思路。虽然它不是绝对的,但背后逻辑是对的:对于纯SSD环境下的数据库,CPU核数为8的机器,连接池设个20左右就够了。设成200的话,不仅不会提升吞吐,反而会因为上下文切换、数据库端线程争用,把延迟拉高,甚至把数据库连接数打满导致其他服务连带故障。
我见过一个真实事故:一个服务用Druid连接池,maxActive配了500,压测时数据库连接数瞬间飙到上千,把数据库的连接数上限直接打爆,所有写入全部阻塞。后来把maxActive降到50,配合合理的等待超时,压测结果反而更稳定,P99延迟下降了40%。
核心参数上,我建议你至少关注这几个,无论用Druid还是HikariCP:
maximumPoolSize:最大连接数,按上述公式粗略估算,不要盲目加大。minimumIdle/minimum-idle:最小空闲连接数,不用配得很大,防止空闲连接过多占用数据库资源。connectionTimeout:获取连接的超时时间,建议3000毫秒以内,避免请求无限阻塞在连接池上。maxLifetime:连接最大存活时间,建议小于数据库的wait_timeout。validationTimeout/testWhileIdle:连接有效性检测,避免拿到失效连接后执行SQL才报错,白白浪费一次往返。
2.3 连接池选型的个人经验
Druid功能全、监控丰富、有SQL防火墙,适合需要可视化监控和SQL拦截的场景。HikariCP轻量、性能极致,Spring Boot 2.x+默认就是它,适合追求简洁和吞吐的场景。如果项目没有特殊要求,直接HikariCP就够了。Druid的监控虽然好用,但要小心它自带的拦截器和统计功能在高并发下本身会带来额外的性能损耗,必要的时候要关闭不必要的Filter。
3. 查询优化的核心思路:让网络和数据库少干活
3.1 PreparedStatement不只是防注入,更是性能利器
PreparedStatement相比Statement的核心优势有两点:一是预编译,数据库可以复用执行计划;二是在MySQL中,PreparedStatement配合useServerPrepStmts=true,可以把SQL模板在服务端预编译,后面只传参数,大大减少网络传输和解析开销。
JDBC连接串里这几个参数,是我在项目里的标配:
jdbc:mysql://127.0.0.1:3306/db?useUnicode=true&characterEncoding=utf8&useServerPrepStmts=true&cachePrepStmts=true&prepStmtCacheSize=500&prepStmtCacheSqlLimit=2048&rewriteBatchedStatements=true逐个说一下含义:
useServerPrepStmts=true:启用服务端预编译,让MySQL真正复用执行计划,避免每次执行都重新解析SQL。cachePrepStmts=true:客户端缓存预编译语句,配合prepStmtCacheSize和prepStmtCacheSqlLimit,防止相同的SQL模板反复提交预编译请求。rewriteBatchedStatements=true:这个参数是批量写入的胜负手。开启后,JDBC驱动会把多条INSERT语句改写成多值INSERT语句,一次网络往返就能提交大量数据。不开启的话,即使你用了addBatch(),驱动也会一条一条地发送,性能几乎没有提升。useSSL=false:内网环境关闭SSL可以减少握手和加解密开销,但这个要看公司安全规范,不能为了性能牺牲安全。
3.2 结果集处理:能取多少取多少,别什么都查
有一次排查慢查询,发现一个分页接口返回的时候把整个表的所有字段都查出来了,包括一个几KB的JSONB字段,但前端根本不用。这个字段极大地增加了网络传输和数据库IO。优化后的SQL只查需要的字段,接口耗时从1.2秒降到了300毫秒。
查询优化里最基础却最有效的一招就是:SELECT尽量列字段名,不要用SELECT *。每一次查询返回的字节数都实实在在地压在网络上,列越多,响应越大,GC压力越大。另一个容易被忽视的点是setFetchSize()。MySQL驱动默认会一次性把结果集全部拉到客户端内存里,如果查询返回10万行,内存会瞬间暴涨。通过设置statement.setFetchSize(Integer.MIN_VALUE)可以启用流式读取,让驱动边读边取,内存占用大幅下降。但这个方式只适合需要遍历大结果集并且不打算复用连接做其他操作的场景,因为流式读取期间连接是被占用的。如果只是分页展示,更推荐LIMIT+ 覆盖索引的方案,而不是把全量数据拉到内存再截取。
3.3 避免无止境的N+1查询
N+1问题在ORM框架里特别常见,但在原生JDBC场景下也会出现,比如:先查订单列表,再在循环里根据订单ID查每个订单的明细。每一次循环都是一次数据库往返,100个订单就是101次查询。
优化思路无非两种:一是改为一次JOIN查询,一次性把订单和明细都查出来,在应用层做组装;二是用WHERE id IN (...)批量查询,把100次查询压成1次。这里有个细节要提醒你:IN子句的列表长度要控制好。MySQL对IN列表的长度没有硬性限制,但列表太长会导致索引效率下降,一般建议单批不超过500~1000个ID。超过就拆成多批执行,多批之间可以考虑用多线程并发查询,但要注意控制并发度,不要让数据库被打爆。
3.4 分页查询深度翻页的坑
分页查询用LIMIT offset, size是很常见的写法,但这条语句在深分页场景下会越来越慢。原因是MySQL需要扫描并丢弃前offset行,才能返回目标数据。比如LIMIT 100000, 20,MySQL要扫描100020行,大量IO白白浪费。
常用的优化方案有两种:
- 延迟关联(子查询先取主键):
SELECT o.* FROM orders o INNER JOIN (SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 20) tmp ON o.id = tmp.id ORDER BY o.create_time DESC;- 游标分页(基于上一页最后一条记录的条件查询):
SELECT * FROM orders WHERE create_time < ? ORDER BY create_time DESC LIMIT 20;第二种方式在数据量大的场景下性能最好,但需要设计好排序字段的稳定性,避免重复或漏数据。具体的排序键可以根据业务选唯一键或复合键。
4. 批量写入优化:一次连接能做完的事,绝不反复跑
4.1 批量入库的正确姿势
上面提到了rewriteBatchedStatements=true,这里单独拿出来讲,因为批量写入的优化空间实在太大了。
我之前优化过一个数据同步任务,原先逐条INSERT 5万条数据,耗时将近10分钟。开启了批处理和rewriteBatchedStatements之后,同样数据量耗时降到了10秒以内,整整提升了60倍。这个差距完全取决于驱动是否开启了多值重写。
标准的批量写入代码是这样的:
Connection conn = dataSource.getConnection(); String sql = "INSERT INTO user(id, name, age) VALUES (?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(sql); for (User user : userList) { ps.setLong(1, user.getId()); ps.setString(2, user.getName()); ps.setInt(3, user.getAge()); ps.addBatch(); if (batchCount % 1000 == 0) { ps.executeBatch(); // 每1000条提交一次 ps.clearBatch(); } } ps.executeBatch(); // 剩余批次 ps.close(); conn.close();这里有几个关键点:
- 批次大小:建议每500~2000条提交一次,太大容易导致内存占用高、事务执行时间过长锁表。具体值要压测后定,我一般以1000为基准上下调整。
- 事务边界:批量写入一定要手动控制事务,把
setAutoCommit(false)加上,否则每一条都是一个独立事务,频繁提交会带来很重的磁盘fsync开销。全部执行完再commit()。 - 批量更新也可以用:MySQL的
rewriteBatchedStatements对UPDATE、DELETE也有优化效果,但不如INSERT明显。
4.2 大批量数据同步的终极方案:分批 + 多线程 + 重试
当数据量到达百万级甚至亿级,单线程批量写入已经不够用了。此时需要在批处理的基础上再做两层增强:分批并行、失败重试。
我通常把这种任务设计成如下形态:
- 分片:按主键ID范围或时间字段分段,把数据拆成多个分片。
- 并行:用线程池并行处理多个分片,线程数建议与目标表所在数据库的CPU核数匹配,一般4~8个并发就足够。
- 重试:每个分片内部按批次执行,如果某个批次失败,捕获异常后重试1~2次,重试仍失败则记录到日志表或消息队列,不阻塞整体任务。
还有一点容易被忽视:如果目标表有二级索引,大批量写入时索引维护会成为瓶颈。极端情况下可以先DROP索引,导入完成后重新创建。但实际操作中要权衡离线窗口和业务可用性,如果可以在低峰期做,收益非常明显;如果在线上做,风险也很高,需要谨慎评估。
4.3 别用JDBC处理大字段,除非你懂流式
有人会在JDBC里处理图片、文件、大文本这类BLOB/CLOB字段。说实话,JDBC本身处理大型二进制字段的能力是够用的,很多人用不好是因为一次性把整块数据Load到内存。比如用getBlob()再接getBytes(),一个几十MB的文件就会把内存撑上去,严重时引发OOM。
正确做法是使用流式读写:
Blob blob = rs.getBlob("file_content"); InputStream in = blob.getBinaryStream(); // 按块读取,写入文件或对象存储写入时同理,用setBinaryStream(),setCharacterStream()替代setBytes()和setString(),让驱动自己分块传输。这个点不算性能优化里最常见的一环,但一旦遇到,就是典型的"会者不难,难者不会"的场景。
5. 参数级调优:MySQL和JDBC一起配合,效果才明显
5.1 MySQL服务端的几个关键参数
JDBC优化不光是客户端的事,服务端配合不好,客户端怎么优化都白搭。下面这几个参数是我在线上环境最常调整的:
| 参数 | 默认值(典型) | 调优建议 | 说明 |
|---|---|---|---|
max_connections | 151 | 按实际并发调高,但别超过2000 | 过高会导致系统负载飙升 |
innodb_buffer_pool_size | 128M | 物理内存的60%~70% | InnoDB的缓存池,越大命中率越高 |
max_allowed_packet | 4M/16M | 调大到64M~128M | 大批量写入或大字段会有帮助 |
transaction-isolation | REPEATABLE-READ | 读多写少可权衡READ-COMMITTED | 降低间隙锁带来的一些锁竞争 |
slow_query_log | OFF | 打开并设置long_query_time=1 | 慢SQL监控是优化前提 |
其中max_allowed_packet特别值得注意。如果你批量插入的数据超过了这个值,即使JDBC那边配置一切都对,MySQL也会直接报Packet Too Large错误,导致整个批次失败。这个参数在批量导入和BLOB场景下几乎是必调的。
5.2 事务隔离级别对读性能的影响
MySQL默认的隔离级别是REPEATABLE-READ(可重复读),这个级别为了处理幻读引入了间隙锁,在并发写入场景下锁竞争会更激烈。如果你的应用场景以读为主,写入冲突很少,把隔离级别改为READ-COMMITTED(读已提交)通常能降低锁等待时间。
JDBC连接串里可以这样设置:
jdbc:mysql://127.0.0.1:3306/db?transactionIsolation=READ-COMMITTED但这个改动要业务确认语义上可以接受。如果你的业务的确依赖可重复读,不要为了性能强行改隔离级别,否则可能出现数据一致性问题。
5.3 GC和内存模型也是JDBC性能的一部分
JDBC客户端性能与JVM的GC息息相关。短连接频繁创建,会导致大量的byte[]对象在堆上分配和释放,触发频繁的Minor GC甚至Full GC。常见的优化手段包括:使用直接内存(DirectMemory)通过网络传输数据、合理设置年轻代大小、避免在循环中创建大对象。
但说实话,连接池+PreparedStatement缓存+合理的批次大小这几件事做对之后,GC压力已经大幅下降了。如果是IO密集型的批处理任务,可以考虑把堆内存调大一些,并参考G1收集器的参数做微调。不要一上来就迷信各种"神参数",垃圾回收调优是在业务代码层面的优化已经做无可做的情况下,才去触碰的领域。
6. 慢SQL排查:EXPLAIN到底该看哪些信息
6.1 先把慢查询日志打开
做任何SQL优化之前,第一步一定是拿到真实的慢SQL样本。MySQL的慢查询日志是最直接的工具:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';设置long_query_time = 1的意思是超过1秒的SQL都记下来。你可能会问为什么不设成0.1秒,把日志打全?那样日志量会非常大,干扰判断,而且很多本身没问题的SQL也会被捞进来。建议从1秒开始,先找到最严重的,再逐步缩小时间阈值。
拿到慢SQL后,用EXPLAIN分析执行计划。很多人执行完EXPLAIN只看一眼type是ALL就断定"全表扫描,加索引",这其实过于粗暴。我建议按下面的优先级逐项看:
| 字段 | 重点观察 | 风险信号 |
|---|---|---|
type | 访问类型 | ALL(全表扫描)、index(全索引扫描)需要重点关注 |
key | 实际使用的索引 | NULL说明没用到索引 |
rows | 预估扫描行数 | 和实际返回行数差距过大时,说明统计信息不准或没走对索引 |
Extra | 额外信息 | Using filesort(文件排序)、Using temporary(临时表)是性能杀手 |
filtered | 过滤比例 | 比例低说明扫描了大量行但只返回很少,索引选择可能有问题 |
6.2 一个真实案例:WHERE条件顺序和索引的坑
有一次排查一个分页查询慢SQL,表面上看条件用了索引字段,EXPLAIN的type是ref,key也正确,但rows扫描了70万行。后来仔细看发现,SQL中条件顺序是WHERE status = 1 AND category_id IN (...),而索引建立的顺序是(category_id, create_time),导致category_id的过滤条件没法有效利用索引前缀,MySQL只能扫描大范围。
这种问题的解法通常有两种:要么调整SQL中条件的顺序,使其与索引最左前缀匹配,要么直接调整索引的定义顺序。在MySQL 8.0之前,查询优化器对IN列表的处理也不够聪明,IN列表过多时容易走偏,所以除了看EXPLAIN,还要用FORCE INDEX做对比测试,但生产环境不要轻易用FORCE INDEX,因为数据分布变化后强制索引反而可能变慢。
6.3 区分慢SQL和慢事务
最后提醒一个容易被忽略的点:慢SQL日志只能看到单条语句的执行时间,但线上很多"慢"其实是事务层面慢。一个事务里可能包含多条快SQL,但由于事务迟迟不提交,锁一直被持有,后续请求全部排队。这种情况去优化单条SQL是徒劳的,必须通过查看information_schema.innodb_trx来找长时间未提交的事务。
我的排查习惯是:先看慢查询日志确认SQL本身是否慢,再看有没有未提交事务导致的锁等待,最后才动手调SQL或加索引。顺序反了,问题会越查越乱。
7. 一些容易被忽略的工程细节和踩坑记录
7.1 驱动版本别太老,也别太激进
MySQL JDBC驱动(Connector/J)每个版本的性能差异和参数支持都不同。rewriteBatchedStatements这个参数在8.0.x版本上表现比5.1.x好很多。如果项目还在用5.1.x且升级成本可接受,建议升级到8.0.x或更高。但注意,升级驱动要注意com.mysql.jdbc.Driver类路径和com.mysql.cj.jdbc.Driver的差异,同时检查连接串参数是否兼容。
7.2 连接泄漏是隐蔽的性能杀手
连接池配置得再好,如果代码里忘了close(),连接池就会被慢慢耗尽,最终所有请求都在getConnection()上等待甚至超时。排查这种问题,最有效的方法有两个:
- 在Druid或HikariCP中开启
leak-detection-threshold,超过阈值自动打印连接堆栈。 - 在代码上统一使用try-with-resources,从根本上杜绝连接泄漏。
HikariCP的配置示例:
spring: datasource: hikari: leak-detection-threshold: 60000设置成60秒,意味着连接被占用超过60秒就会在日志中输出创建该连接时的堆栈信息。这样一旦发生泄漏,你可以准确知道是哪个调用方拿走了连接没还。
7.3 环境差异坑:本地快,线上慢
本地开发数据库和线上数据库的数据量、部署拓扑、参数配置完全不同,很多在本地毫秒级的SQL,上到线上几十万行数据就会慢成灾难。所以我一直强调,JDBC性能优化要以线上或压测环境的真实数据为基准,不要用本地一两条数据来验证性能问题。EXPLAIN时也要记得用线上数据量级别来测试,rows的估值才有参考意义。
7.4 监控先行,优化在后
最后说一个方法论层面的东西:没有监控就没有优化。我通常在项目里接入以下监控数据,否则任何优化建议都是盲人摸象:
- 数据库连接池的活跃连接数、等待获取连接耗时。
- SQL执行耗时分布,特别是P99分位值。
- 慢SQL数量趋势。
- 数据库端的连接数、CPU、磁盘IO。
有了这些基础数据之后,再按本文的步骤逐步优化,就能保证每一步的改动都有量化结果验证。
根据我个人的经验,JDBC性能优化最难的往往不是某个技巧本身,而是"定位真正的瓶颈在哪一环"。如果你能建立起"连接 -> 发送 -> 执行 -> 传输 -> 解析"这个完整的耗时链路观念,再配合EXPLAIN和连接池监控,几乎80%的JDBC性能问题都能在不动架构的前提下解决。剩下的那20%,再考虑分库分表、读写分离、引入中间件这些更大的动作。