先抛个结论:数据库性能出问题,大约只有三成是数据库本身扛不住,剩下七成得从程序那边找原因。同样的SQL,有人写出来能打到每秒几千次,有人写出来不到一百次就把连接池拖垮,差别就在“操作方式”上。
这是数据库性能优化系列的第三篇,前两篇聊了SQL改写和配置层面的调优手段,这一篇专门针对程序侧的优化。适合谁看?后端开发、接口负责人、维护老系统的同学,以及那些“SQL看着没问题但服务就是慢”的疑难杂症排查者。核心解决这些问题:连接数被打满、每秒钟大量重复请求、事务越开越长、结果集越查越大、代码里循环访问数据库。这些不是调数据库能解决的,必须改代码。
1. 程序操作优化到底在优化什么
先说思路。程序操作数据库的本质,就是“客户端发出一堆指令,数据库执行并返回结果”。这个链路里,真正消耗时间的其实不只是SQL执行本身,还包括:网络往返、驱动处理、连接获取、并发排队、锁等待、GC停顿,甚至代码里一条无关紧要的日志打印。
所以程序操作优化的目标,不是把某一条SQL改得飞快,而是把“程序与数据库之间的交互模式”调整到最优,把数据库的资源和时间花在真正需要的地方。
1.1 最常见的程序访问数据库坏味道
列出我这些年排查线上问题时,在代码里反复看到的几类典型问题,占了数据库性能故障的大头:
- 获取连接后不放回,或者未使用连接池而每次新建连接。
- 在for循环里逐条执行SQL,一条业务数据就把数据库往返次数拉到几千次。
- 查出来的数据量动辄几万行,代码层不做分页、不限制条数。
- 事务内混入大量非数据库操作,比如调外部接口、等待锁、文件读写。
- SQL逻辑本身没问题,但同一个查询在短时间内被重复执行几百遍,且没有缓存。
这些问题有一个共同特点:单看代码,每一行好像都没问题,但叠加到数据库上就是灾难。程序侧的优化,就是去解决这些交互层面的“放大效应”。
1.2 用资源预算思维看待程序与数据库的交互
举个例子,一个接口拉取数据后,每行调用一个字段翻译服务,最坏情况导致每条主数据额外产生几十次子查询,这在资源消耗上的放大是乘数级的。数据库资源有限,连接数、IO、CPU、内存就那么多,程序每次无谓的交互,都是在抢占其他请求的资源,而不是“多花一点时间”这么简单。
你可以把数据库连接想象成一座大桥的车道。车流量正常时每条车道都好走;一旦某几辆车走得很慢又占着车道不放,后面所有车都得排队。程序操作优化的本质就是:减少占道车辆、缩短占用时间、让车流更有序。后面聊的内容都会围绕降低连接占用、减少访问次数、缩短事务与锁持有时间、降低无效压力这四条展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连接管理:从连接池参数到连接泄漏排查
连接是数据库操作的第一步,也是最容易被忽视的环节。很多线上故障表现为“连接池耗尽”“Too many connections”,但根因往往不是数据库连接数配置太小,而是程序侧获取连接后没有释放、连接池参数严重不合理、或者单个请求持有多余连接。
2.1 程序不应绕过连接池
我曾经接手过一个老项目,测试环境一切正常,一到生产就间歇性报连接失败。翻代码后发现,有个定时任务为了“提升性能”自己写了个连接管理器,每次任务新建物理连接,结束后又没真正关闭,最终把数据库连接数打满。
数据库物理连接的创建成本远比想象中高。一次TCP三次握手走完,数据库要完成线程创建、认证、权限检查、环境变量初始化等步骤。在MySQL 8.0和Oracle里,创建连接的开销通常能达到几十毫秒到上百毫秒。高并发下大量新建短连接,不仅响应变慢,还会让数据库CPU和上下文切换急剧上升。
所以第一原则:程序必须走连接池,如HikariCP、Druid、Tomcat JDBC Pool,并且确保从池中获取的连接最终都在finally或try-with-resources里释放。
java复制// 正确用法:try-with-resources 保证连接关闭
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, userId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// 处理逻辑
}
}
} // 连接自动归还连接池
有些框架封装的DAO基类里容易漏掉这个细节,排查时可以先确认各数据访问层的连接是否都遵循了这个模式。
2.2 连接池参数不能拍脑袋
连接池大小并非越大越好。很多刚入行的同学以为把maximumPoolSize调大就是性能优化,实际上如果设置过大,数据库要维护大量空闲连接,反而占用内存和线程资源。连接池应尽量设置小而足够的数量。
HikariCP官方给过一个经验结论:连接数 = TPS × 单连接处理耗时(秒)。比如目标TPS是100,每条SQL平均耗时20ms,那么连接数约等于100×0.02=2,再留一定余量设置成5-10就够了。很多系统的真实瓶颈根本不是连接不够,而是单条SQL太慢,或者业务在循环里发SQL,导致连接被长时间占用。
推荐的HikariCP配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
connection-test-query: SELECT 1
这里有两个关键参数容易踩坑。max-lifetime必须小于数据库自身的wait_timeout,否则连接会被数据库侧强制断开,程序还在用半死连接导致偶发异常。MySQL默认wait_timeout是8小时,max-lifetime设置成30分钟是比较保守且合理的。connection-timeout是获取连接的超时时间,设置太短会出现误报,设置太长则请求会长时间挂起,一般建议3-5秒。
2.3 连接泄漏的三种排查手段
连接池被打满时,首先要判断是“请求量真的很大”还是“连接有借无还”。三种排查手段可以组合使用:
- 开启连接池的泄漏检测。HikariCP可以通过设置
leak-detection-threshold: 60000来检测连接持有超过60秒未归还的情况,超时会打印告警日志。 - Druid连接池提供监控页面,可以实时查看活跃连接数、执行SQL次数和最慢SQL。
- 在数据库侧执行
SHOW PROCESSLIST;(MySQL)或查询v$session(Oracle),如果一个IP下冒出大量Sleep状态的连接,基本就是程序没有释放。
我排查过的一个真实案例:某服务每天上午10点准时假死,数据库活跃连接数直接顶到上限。开启连接池泄漏检测后发现,代码里有个事务方法内部捕获了异常却忘记回滚,事务拦截器没能正常释放连接。修复后故障消失。这种问题靠调大连接池只会延迟爆发。
3. 减少往返:批量操作与查询策略调整
“客户端与数据库间的往返次数”是程序操作优化中收益最大、最容易忽视的维度。一次网络往返内网可能只有0.1-0.5ms,看起来很少,但乘以几万次,再叠加上SQL执行、结果集传输、连接排队,几百毫秒就没了。
3.1 批量插入比循环插入快在哪里
最经典的坏味道代码是循环插入。业务里拿着一份Excel文件,几千行数据,for循环里每次执行一条INSERT。看起来理所应当,每条也就一毫秒左右,但一万条就是十几秒,而且应用服务器与数据库之间要往返一万次。
优化第一步是把循环插入改成批量插入。JDBC层面使用addBatch和executeBatch,一次往返提交多条数据:
java复制// 批量插入示例
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO t_order (order_no, user_id, amount) VALUES (?,?,?)")) {
for (Order order : orderList) {
ps.setString(1, order.getOrderNo());
ps.setLong(2, order.getUserId());
ps.setBigDecimal(3, order.getAmount());
ps.addBatch();
// 每500条执行一次,避免单次batch过大
if (orderList.size() % 500 == 0) {
ps.executeBatch();
}
}
ps.executeBatch();
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
}
}
这里有个隐藏较深的坑:MySQL JDBC驱动默认并不真正批量执行,而是把每条语句单独发送。需要在连接参数里显式添加rewriteBatchedStatements=true,驱动才会把多条INSERT语句重写成一条多VALUES的语句,性能提升可以达到10倍以上。很多团队加了Batch代码却没加这个参数,最后实测效果不明显,然后误以为批量没用。
3.2 ORM框架里批量操作的正确姿势
如果使用MyBatis,不能直接在Mapper接口方法上标注@Insert循环调用,那依然是一次一条。正确做法是在XML里写foreach批量插入,或者使用SqlSessionTemplate的ExecutorType.BATCH。示例:
xml复制<insert id="batchInsert" parameterType="list">
INSERT INTO t_order (order_no, user_id, amount)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.orderNo}, #{item.userId}, #{item.amount})
</foreach>
</insert>
需要注意批量插入的单批大小。MySQL一次能接收的max_allowed_packet有限,企业环境通常默认64MB,但并非批越大越好。我实测下来,每批500到1000条通常是最佳区间,批太大容易导致undo膨胀、binlog过大以及锁范围过大,批太小又体现不出性能优势。
3.3 查询侧减少往返:fetchSize与N+1问题
查询侧最常见的性能杀手是N+1问题。一个列表接口查出100条主记录,前端每个主记录都要查询所属详情,于是产生1条主查询加100条子查询。数据库往返次数直接从1变成101,这在性能测试里几乎必炸。
MyBatis的解决方式有两种:一种是使用collection或association进行嵌套查询并启用fetchType="lazy",另一种是进行连表查询,一次把数据带出来。实际开发中我更推荐连表查询或分步批量查询,即在主查询后收集ID列表,再用IN查出所有关联数据,最后在内存中做映射,避免数据库层面出现笛卡尔积膨胀。
另外,分页查询必须使用LIMIT或ROWNUM等手段进行数据库端分页,不要查全表数据后在Java内存里截取。如果是导出场景,一次性查询几十万行并循环处理,应在JDBC连接参数上设置fetch size分批次取数。MySQL需要在连接属性里加useCursorFetch=true&defaultFetchSize=500,否则驱动会在一次调用里把所有结果拉到客户端,内存直接爆掉。
4. ORM层与查询代码的隐藏性能损耗
很多人认为ORM框架只是帮我们生成SQL,性能差异不大,但实际使用中,ORM层的编码习惯直接影响数据库执行计划。这块内容放在“程序操作优化”里再合适不过。
4.1 select *是最直接的性能隐患
select *的危害有两个。第一是网络传输开销变大,SELECT返回了不需要的TEXT字段或大字段,一次查询可能把几十KB甚至几MB数据拉到应用服务器,这是数据库端SQL优化解决不了的问题,必须改查询列。第二是会影响覆盖索引的使用,如果表的索引包含查询所需的全部列,InnoDB直接读索引即可,回表都不需要,而select *迫使引擎必须回表查完整行。
我曾经把一个高频统计接口里的select *改成只查需要的5列,SQL执行时间从120ms降到20ms,变化就这么大。同时要注意,实体类里不需要的字段应设成不参与查询的映射,避免ORM自动映射时报错或产生多余的ResultMap配置。
4.2 ORM缓存的正确使用方式
MyBatis默认开启一级缓存(SqlSession级别),但在Spring集成后,每次Mapper调用如果是独立SqlSession,一级缓存基本形同虚设。二级缓存是跨SqlSession的,但很多项目直接开启却从没调优过,导致缓存一致性问题和序列化开销。
缓存应用的原则:只对变更频率低、访问频率高的数据使用二级缓存,比如字典表、配置表、商品基础信息表。注意,任何涉及增删改的操作都会使该表的缓存整体失效,频繁更新的表开启缓存反而会拖慢系统。Redis这类外部缓存的使用也是同理,热点数据可以放本地缓存或Redis,但不能把“每次请求都查询数据库”当成默认操作。
4.3 状态判断与数据库再查询的设计
很多程序在代码里会先查一次数据库判断状态,再执行更新,然后再查询验证一次。改成对数据一致性影响可控的情况下,直接通过UPDATE条件判定受影响行数来达到目的,能减少一次甚至两次查询。举例如下:
java复制// 原逻辑:先查订单状态,再更新,再查结果
Order order = orderMapper.selectById(id);
if (order.getStatus() == 1) {
orderMapper.updateStatus(id, 0);
}
简化后的写法:
java复制// 优化逻辑:用UPDATE的条件更新替代select+if+update
int affected = orderMapper.updateStatusIfMatched(id, 1, 0);
if (affected == 0) {
// 状态不匹配或记录不存在
} else {
// 更新成功
}
对应SQL:
sql复制UPDATE t_order SET status = 0, update_time = NOW()
WHERE id = #{id} AND status = 1
影响行数为1则更新成功,否则说明状态不是可流转状态。这样既保证原子性,又减少了一次数据库往返,还顺带避免了并发状态下判断过期的问题。
5. 事务边界与锁竞争的实操优化
事务是保证数据一致性的基础,但在程序操作层面,不必要的大事务是数据库锁竞争的温床。优化事务边界能同时改善数据库死锁、连接耗尽和主从延迟等一系列症状。
5.1 大事务到底拖垮了什么
一个事务对一行数据加了锁,如果事务迟迟不提交,锁就迟迟不释放。其他事务要更新同一行数据,只能等待,等待中占据着连接,于是连接池也被拖垮。
大事务的来源通常是事务包围了耗时的外部操作。比如事务内调用第三方接口、写文件、发送消息等。实际优化时,把外部调用移出事务是收益很明显的改动,只把必要的数据库读写放进事务,一个接口的耗时能从几百毫秒降到几十毫秒。
5.2 保证一致性同时缩短锁持有时间的经验
有一次一个库存扣减接口出现并发超卖,开发直接给整个方法加了synchronized,结果接口吞吐量极低。后来改成数据库乐观锁方式,在扣减SQL里加上库存数量条件,代码如下:
java复制int affected = stockMapper.deductStock(productId, quantity, expectedVersion);
对应SQL:
sql复制UPDATE t_stock SET stock = stock - #{quantity}, version = version + 1
WHERE product_id = #{productId}
AND stock >= #{quantity}
更新行数大于0说明扣减成功,否则说明库存不足或版本已过期。这样的锁粒度远小于方法级悲观锁,接口吞吐量提升了近一个量级。程序操作优化有一个重要原则:能用版本号解决的并发问题,不要用长事务加锁去解决。
事务尽量只包含必要操作,要控制事务内的循环批量处理不要一次处理几万条数据,数据量过大要分批提交。
5.3 处理数据库死锁的正确姿势
程序侧遇到死锁时,第一反应不应该是靠数据库超时参数解决,而要减少死锁发生的概率。常见的死锁场景是不同事务按不同顺序更新多条记录,解决方法是程序全局约定锁顺序,令各事务以相同顺序锁定记录。例如先锁accountId小的一条,再锁另一条,能减少死锁出现概率。但死锁不可能完全杜绝,高并发系统必须具备死锁重试机制。
在实际代码中,当抛出死锁异常时(MySQL的Deadlock found错误码为40001,Oracle为ORA-00060),让当前请求进行有限次数的重试。通常死锁发生非常快,重试一次的代价远小于整体抛出异常让前端报错的代价。示例:
java复制int retryCount = 0;
int maxRetry = 3;
while (true) {
try {
doTransfer(fromAccountId, toAccountId, amount);
break;
} catch (DeadlockException e) {
if (++retryCount >= maxRetry) {
throw e;
}
Thread.sleep(ThreadLocalRandom.current().nextLong(10, 50));
}
}
但事务方法注意不要因为加了重试而把整个事务范围扩大,重试逻辑要放在事务边界外,否则第一次事务失败回滚后,新事务的执行正常不会处理连接问题。
6. 缓存与读写分类:把压力从数据库上挪走
程序操作优化里有一块经常被忽略的实际价值——做读写分离和缓存,让数据库只处理无法避免的请求。排序而言,如果能用缓存解决80%的重复读请求,数据库压力会显著下降。这里需要区分热点缓存与数据一致性之间的取舍,多级缓存与旁路缓存是两种常见的方案。
6.1 多级缓存与热点数据挡在数据库前面
对于读多写少的业务,在应用层加一个本地缓存或集中式缓存(Redis),可以非常有效地降低数据库压力。常见设计是:请求先查本地缓存,未命中再查Redis,再未命中才查数据库,并回填两级缓存。
真实系统里需要注意两个典型问题:缓存穿透与缓存击穿。穿透指查询一个必然不存在的数据,每次都打到数据库;击穿指某个热点key过期的一瞬间大量请求同时打到数据库。针对穿透,可以用布隆过滤器或缓存空值短时间;针对击穿,可以在缓存加载时使用互斥锁,保证同一个key只有一个线程去查数据库。
6.2 缓存更新的失败与一致性权衡
缓存更新的经典困难是数据库更新成功但缓存更新失败,导致旧数据被读走。常规方案是Cache Aside模式:更新数据库后删除缓存,等下次读取时重建缓存;而不是先更新缓存,因为缓存更新的失败概率更高,且并发写会造成更复杂的脏数据问题。如果公司允许最终一致性场景,最简单的方式是让缓存设置较短过期时间,比如5分钟,数据库更新后删除一次缓存即可。
在程序操作优化里,还需要检查是否存在“缓存没生效却查询数据库”的问题。比如有些团队缓存了DTO对象,但方法参数中携带当前时间戳或随机串,导致每次缓存key都不同,缓存形同虚设。这种代码问题比缺少缓存更隐蔽,排查时要留意日志里SQL执行频率和缓存命中率指标,而不是只看有没有引入Redis。
6.3 写操作合并与异步化场景
对于写多读少的场景,比如用户行为日志、操作流水,这类数据允许延迟落库,可以引入异步批量写入机制,把多条写入合并成一次批量写库。实际开发中可以用内存队列加定时批量任务,或者用消息队列做缓冲,注意必须有可靠的重试、告警和落盘补偿机制,避免数据丢失风险。
使用异步写时,有一个容易忽略的点:事务与消息发送的顺序问题。如果先写数据库再发消息,事务提交前消息已发出,一旦事务回滚就会出现消息多发;处理不好就是脏数据/幽灵事件。常用的做法是先写本地消息表,事务提交后再投递或扫描投递,程序操作层面的“顺序一致性”处理得当,能规避大量数据不一致问题。
7. 程序性能问题的定位思路与排查清单
如果你想直接拿到一套可复用的排查套路,这部分的清单可以解决大多数程序操作引发的数据库性能问题。我自己排查耗时比较大,常按下面顺序检查,效率很高。
7.1 指标收集:先把问题变成数字
排查任何程序性能问题,先拿数据,不要靠猜。需要建立的指标包括:应用层连接池的活跃连接数、等待获取连接数、SQL执行次数、单条SQL耗时分布;数据库层的慢查询数量、锁等待次数、活跃会话数、临时表数量。用Arthas或性能监控平台挂到应用层可以拿到每个接口的调用链耗时,用SHOW PROCESSLIST或performance_schema可以拿到数据库此刻在执行的SQL状态。
指标分析可以快速定位三类特征问题:连接池等待高说明连接创建/占用有问题;SQL执行时间正常但接口耗时长说明应用层或者网络有问题;单条SQL执行时间长则需要反过来调整SQL和索引。
7.2 慢查询日志的二次分析与程序定位
慢查询日志里的SQL如果只是单条慢,可以摘出来做索引和SQL优化。如果一个表的慢查询每次都是不同ID条件的简单主键查询,那问题大概率不在数据库而在应用层,例如一次批处理任务中重复调用同一个查询方法几百次。
开启数据库慢查询日志是最终要做的第一件事。MySQL可以在配置文件中开启:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
随后用mysqldumpslow -s at命令把慢查询按平均耗时排序,快速得到问题大头列表。有了慢SQL后再结合代码调用链,找出是哪段业务逻辑发出的慢SQL,再按前面章节介绍的方法做改造。
7.3 常见程序操作隐患速查表
排查用的时间长了,下面这些隐患是一抓一个准的高频问题,整理成速查表方便对照:
| 表现 | 可能原因 | 优化方向 |
|---|---|---|
| 连接池打满 | 连接泄漏或连接数配置过小 | 泄漏检测、调整max-lifetime/池大小 |
| 接口耗时稳定但吞吐低 | 事务内有外部调用 | 外部调用移出事务 |
| CPU不高但请求排队 | 单条SQL快但循环查询 | 改批量查询或连表 |
| 偶发超时报死锁 | 多事务锁顺序不一致 | 统一锁顺序、死锁重试 |
| 数据库IO高但SQL不慢 | 结果集返回过大 | 分页/只查必要列/调fetchSize |
| 查询命中率极低 | 缓存key设计错误 | 检查缓存参数,去除动态值 |
每条对应的问题,往往不是靠“把数据库配置调大”能解决的,需要从程序代码维度做修改。比如快速定位到某个方法后,最简单有效的做法不是绕过去做更多缓存,而是先“消灭无效数据库访问”:判断这次查询的结果是否被使用,判断是否可以在上层做合并,判断是否可以调整事务和锁的边界,这通常比任何数据库调优都能更快看到效果。
7.4 压测数据是唯一的验收标准
最后特别强调:任何优化措施,最终要用压测数据来验收。优化前用压测工具记录接口的TPS、响应时间P99、数据库活跃连接数等基线数据;优化后跑相同场景,看数字变化。不能凭“感觉快了”就收工。
有一次我帮一个团队优化导出功能,从循环查询改成批量读取后,用单条查询日志看好像是缩小了,但实际压测时导出100万行的总时间没有显著变化,继续排查后发现瓶颈在数据写入Excel的代码上,一次一行写入且频繁刷盘,把耗时吃掉了大半。后来改成分批写入和临时文件合并,效果才真正出来。这个案例说明程序操作优化必须看整体链路,不能只盯着数据库交互环节。
最后分享一个排查小技巧
个人经验中,排查程序操作导致的数据库问题时,最快的切入点是先看数据库端的活跃会话,把连接池配置和当前SQL关联起来观察。比如MySQL的information_schema.processlist会告诉你此刻每个连接正在执行的SQL、执行了多久、处于什么状态,这一条信息能直观反映出程序是在查询慢、锁等待还是空闲持连、事务未提交等问题。相比在应用服务器上猜测,直接看数据库侧“每个连接在干什么”往往是最直观的起点。
操作时我用过一条比较顺手的SQL,查询当前所有连接的状态和最后执行的语句:
sql复制SELECT id, user, host, db, command, time, state,
LEFT(info, 100) AS current_sql
FROM information_schema.processlist
WHERE command != 'Sleep'
ORDER BY time DESC;
如果大量连接处于Sleep状态但程序有异常,排查顺序就改为连接是否释放;如果大量连接显示执行某条慢SQL,就直接回到SQL优化方向。这套方法论适用于MySQL和大部分关系型数据库,推荐先从这一步开始排查,再进入其它环节。程序操作优化本质上是一个习惯问题,连接管理、批量、事务边界、缓存、反馈数据,这些技能练成熟后,写出来的程序至少不会把数据库当成一次性消耗品去用。
