十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JDBC性能优化核心实践:连接池、批处理与慢SQL排查

JDBC性能优化核心实践:连接池、批处理与慢SQL排查 不绕弯子直接说结论绝大多数项目里的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握手、认证鉴权构造并发送SQL5%~15%字符串拼接、网络传输字节数数据库执行SQL30%~60%SQL本身、索引、锁、IO结果集传输10%~25%返回字段过多、网络带宽、逐行取数应用侧解析ResultSet10%~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还是HikariCPmaximumPoolSize最大连接数按上述公式粗略估算不要盲目加大。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配合useServerPrepStmtstrue可以把SQL模板在服务端预编译后面只传参数大大减少网络传输和解析开销。JDBC连接串里这几个参数是我在项目里的标配jdbc:mysql://127.0.0.1:3306/db?useUnicodetruecharacterEncodingutf8useServerPrepStmtstruecachePrepStmtstrueprepStmtCacheSize500prepStmtCacheSqlLimit2048rewriteBatchedStatementstrue逐个说一下含义useServerPrepStmtstrue启用服务端预编译让MySQL真正复用执行计划避免每次执行都重新解析SQL。cachePrepStmtstrue客户端缓存预编译语句配合prepStmtCacheSize和prepStmtCacheSqlLimit防止相同的SQL模板反复提交预编译请求。rewriteBatchedStatementstrue这个参数是批量写入的胜负手。开启后JDBC驱动会把多条INSERT语句改写成多值INSERT语句一次网络往返就能提交大量数据。不开启的话即使你用了addBatch()驱动也会一条一条地发送性能几乎没有提升。useSSLfalse内网环境关闭SSL可以减少握手和加解密开销但这个要看公司安全规范不能为了性能牺牲安全。3.2 结果集处理能取多少取多少别什么都查有一次排查慢查询发现一个分页接口返回的时候把整个表的所有字段都查出来了包括一个几KB的JSONB字段但前端根本不用。这个字段极大地增加了网络传输和数据库IO。优化后的SQL只查需要的字段接口耗时从1.2秒降到了300毫秒。查询优化里最基础却最有效的一招就是SELECT尽量列字段名不要用SELECT *。每一次查询返回的字节数都实实在在地压在网络上列越多响应越大GC压力越大。另一个容易被忽视的点是setFetchSize()。MySQL驱动默认会一次性把结果集全部拉到客户端内存里如果查询返回10万行内存会瞬间暴涨。通过设置statement.setFetchSize(Integer.MIN_VALUE)可以启用流式读取让驱动边读边取内存占用大幅下降。但这个方式只适合需要遍历大结果集并且不打算复用连接做其他操作的场景因为流式读取期间连接是被占用的。如果只是分页展示更推荐LIMIT 覆盖索引的方案而不是把全量数据拉到内存再截取。3.3 避免无止境的N1查询N1问题在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, 20MySQL要扫描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 批量入库的正确姿势上面提到了rewriteBatchedStatementstrue这里单独拿出来讲因为批量写入的优化空间实在太大了。我之前优化过一个数据同步任务原先逐条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_connections151按实际并发调高但别超过2000过高会导致系统负载飙升innodb_buffer_pool_size128M物理内存的60%~70%InnoDB的缓存池越大命中率越高max_allowed_packet4M/16M调大到64M~128M大批量写入或大字段会有帮助transaction-isolationREPEATABLE-READ读多写少可权衡READ-COMMITTED降低间隙锁带来的一些锁竞争slow_query_logOFF打开并设置long_query_time1慢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?transactionIsolationREAD-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是refkey也正确但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%再考虑分库分表、读写分离、引入中间件这些更大的动作。
返回列表