1. 先给"程序操作优化"划个边界,别什么都往里面装
数据库性能优化这个系列写到第三篇,前两篇我分别聊了架构层面的水平拆分、垂直拆分,以及数据库实例本身的参数调优、索引设计。有读者留言问:前两篇讲的东西我都照着做了,慢查询也抓了,索引也加了,但业务高峰期该卡还是卡,怎么办?
我的回答是:你大概率没看程序是怎么操作数据库的。
所谓程序操作优化,指的是不改表结构、不改索引、不调数据库参数,单纯从应用代码访问数据库的方式上找问题、做优化。换句话说,数据库已经把能做的都做到位了,接下来要看你的代码有没有"好好用"它。这个环节经常被忽略,因为它的优化点散落在业务代码里,不像加个索引那么"立竿见影",但它往往是性能问题真正的藏身之处。
程序操作优化涵盖的面很广,从连接池怎么配、SQL怎么写、事务怎么开、批量操作怎么做,到并发场景下怎么避免锁冲突,都属于这个范畴。这篇文章不会讲怎么改数据库配置,也不会讲怎么调服务器参数,我只讲一件事:程序侧怎么做,才能让数据库跑得更顺。适合那些已经做完基础优化、但性能仍不达标的团队参考,也适合准备做系统性能整改的开发同学当一份查漏补缺的清单。
2. 连接池配置:一个参数设错,数据库直接"假死"
2.1 连接数不是越大越好,连接池也不是装饰品
先说一个我实际遇到过的案例。某业务系统在做压测的时候,数据库CPU使用率不到30%,但接口平均响应时间从50ms一路涨到800ms,最终大量请求超时。查了半天,发现应用侧的数据库连接池最大连接数被某个同事从50改成了500。原因是他觉得"连接多一点,并发能力就强一点"。
这就是最典型的连接池误用。连接不是免费的,每一条数据库连接都要占用数据库端的进程/线程资源、内存资源和锁资源。MySQL里连接数上限默认是151,你一个连接池要500个连接,光排队建立连接就能把数据库拖垮。而且当连接数过大时,数据库的上下文切换消耗会急剧上升,响应时间不降反升。
连接池存在的意义不是"无限提供连接",而是复用连接,减少建连开销。建连是个重操作,TCP握手、鉴权、会话初始化,动辄几十毫秒。如果每次请求都新建连接,响应慢不说,数据库还得不停地fork线程来处理新连接。
2.2 连接池参数的推荐配置与调整逻辑
连接池的核心参数无非这几个:最小连接数、最大连接数、连接空闲超时、获取连接超时。我整理了一份常见的初始配置参考:
| 参数 | 推荐初始值 | 说明 |
|---|---|---|
| 最小连接数 | 5~10 | 保证低峰期也有可用连接,避免突发请求全部走建连流程 |
| 最大连接数 | 数据库上限的50%~70% | 给其他工具、管理端留余量,别把数据库撑满 |
| 连接空闲超时 | 30~60秒 | 太短会导致频繁建连,太长会占用空闲资源 |
| 获取连接超时 | 3~5秒 | 超过这个时间直接快速失败,别让请求无限等下去 |
启动时初始化多少连接、高峰期允许扩展到多少连接,这个配比要结合QPS和数据库的负载能力来定。我的经验是:最大连接数要小于数据库实例允许的最大连接数,并且需要预留20%~30%的余量。至于单条SQL的执行时长,建议通过连接池的"连接最大存活时间"参数定期回收连接,防止数据库端主动断开后应用还在使用僵死连接。
2.3 最容易踩的坑:获取连接后不释放
程序操作层面的连接泄露是个老生常谈的问题,但直到今天还在发生。有的团队用了连接池,以为就万事大吉了,结果在异常分支里忘了归还连接,或者在使用try-with-resources之前的老代码里,finally块漏写了close。连接池能复用的是"归还"的连接,你不归还,池里的连接越用越少,最后全部请求阻塞在"获取连接"这一步。
排查连接泄露有一个简单实用的方法:在连接池监控里查看活跃连接数。如果压测结束后活跃连接数迟迟降不下来,多半就是泄露了。另外,建议使用连接池自带的泄露检测能力,比如HikariCP的leakDetectionThreshold参数,设置为5000ms就能在连接被占用超过5秒时输出告警日志,定位到具体代码栈。
3. 批量操作的正确姿势:从"一条一条来"到"攒一批一起上"
3.1 为什么逐条INSERT会那么慢
先看一段常见代码:
for (User user : userList) { jdbcTemplate.update("INSERT INTO t_user (name, age) VALUES (?, ?)", user.getName(), user.getAge()); }如果这段代码处理的是1000条数据,那么向数据库发起了1000次INSERT。每一次INSERT都要经历一次网络往返、一次SQL解析、一次事务日志写入。这里有个被很多人忽略的点:即使你在同一个事务里执行这一千条INSERT,每一条仍然是独立的SQL执行,数据库对每一条都要做完整的处理流程。
实测下来,逐条插入1000条数据,在本地MySQL上大约需要1.2~1.8秒;如果用批量方式,把1000条组装成一条多值INSERT,耗时能降到100~200毫秒。差距接近10倍。
3.2 多值INSERT、分批提交与事务边界
批量插入最常用的写法是多值INSERT:
INSERT INTO t_user (name, age) VALUES ('张三', 18), ('李四', 19), ('王五', 20), ...一条SQL带几百上千个value是MySQL支持的,每个value会生成一行数据。但凡事有度,我见过有人把5万条数据一次性拼成一条SQL,直接把数据库的max_allowed_packet打爆。根据实际压测,每批500~1000条是比较稳的量级,超过2000条后,SQL解析和网络传输的耗时就开始明显上升。
再说事务边界。批量操作应该在每个批次结束后提交事务,而不是等所有批次完成后再统一提交。假设你有10万条数据要插入,分100批,每批1000条。如果每一批单独提交,单个事务只涉及1000条数据,事务日志小、锁持有时间短。如果10万条放一个事务里,虽然中途失败可以全部回滚,但事务日志巨大,锁范围长期占用,对数据库的冲击不小。
3.3 批量UPDATE和批量DELETE的特殊风险
批量UPDATE比批量INSERT更需要注意。逐条UPDATE的问题和INSERT类似,但不建议直接把多条UPDATE拼成一条SQL,因为UPDATE没法像INSERT那样做多值优化。正确的做法是使用CASE WHEN改写:
UPDATE t_user SET age = CASE id WHEN 1 THEN 18 WHEN 2 THEN 20 WHEN 3 THEN 22 END WHERE id IN (1, 2, 3)这种方式一条SQL完成多个行的更新,只需要一次SQL解析、一次表扫描,效率远高于逐条UPDATE。不过要注意控制IN列表里的id数量,我建议不超过1000个,否则SQL太长,解析时间和网络传输时间都会增加。
批量DELETE有个风险点:一次删除大量数据会持有大量行锁,影响并发的读写;同时会生成大量binlog,主从复制可能因此产生延迟。安全做法是分批删除,每批500~1000条,加LIMIT条件,并适当sleep一段时间。
DELETE FROM t_log WHERE create_time < '2024-01-01' LIMIT 1000;每执行一次,检查受影响行数,如果等于1000说明可能还有存量,继续执行下一批。如果小于1000,说明删完了,循环结束。
4. 事务边界设计:短事务是银弹,长事务是事故现场
4.1 一个"攒一批再提交"的事务引发的连锁反应
有个经典翻车案例可以说明事务边界的重要性。某订单系统的定时任务每5分钟执行一次,每次读取500条待处理订单,然后逐条处理并更新状态。为了"保证原子性",开发人员把整个任务包在一个大事务里,任务跑完才提交。
任务正常跑完需要20秒,也就是说事务持有这些订单行锁的时间是20秒。问题在于,这些订单状态更新锁住的不仅仅是本批次的500条数据——数据库的行锁在MVCC机制下会影响相同主键的并发更新。前端用户查看订单列表不受影响,但用户A在手机端修改地址,恰好改的是这批订单里的一条,就会一直等待直到事务提交。高峰期多个定时任务叠加,锁等待越积越多,最后数据库的活跃连接数暴涨,系统整体响应变慢。
这个案例暴露了两个问题:事务太大、执行时间太长。事务的基本原则是:能短则短,干了什么就立刻提交,没事别占着连接不放手。
4.2 事务中的查询、远程调用和"顺手操作"
很多人意识不到,当事务开启之后,事务内的所有SELECT不加锁读也是要读快照的,长事务会让快照版本堆积,导致undo log膨胀,查询也可能变慢。更常见的是,有人在事务内做了本不该做的事。
我见过最离谱的情况是:在事务里调用了外部HTTP接口,等待第三方返回结果。一个HTTP请求少说需要几百毫秒,这期间数据库连接被占用、事务迟迟不提交、相关行的锁一直没释放。一旦第三方接口超时,整个事务回滚,数据库连接被长时间占用。这种"顺手操作"最坑人。
事务边界设计的几条硬性要求:
- 事务内只做数据库操作,不做远程调用、不做文件读写、不发送消息
- 事务内不要包含大批量查询,查询时间越长,事务持锁越久
- 事务要有明确的结束点,该提交就提交,不要等GC帮你close
- 一个业务操作拆多个事务,比如"扣库存一个事务、更新订单状态一个事务、记账一个事务",而不是从头到尾一个大事务
4.3 自动提交与隐式事务的坑
MySQL默认autocommit=1,每一条SQL都是独立事务自动提交。很多人觉得反正自动提交,就不用显式开事务了。但程序操作优化恰恰建议把autocommit关掉,显式控制事务。
原因很简单:当多条SQL需要保证一致性时,自动提交会导致一部分成功、一部分失败,数据不一致。当单条SQL不涉及事务时也许没问题,但在批量操作场景下,配合自动提交的逐条INSERT会每条都刷新一次事务日志,性能极差。
更隐蔽的是ORM框架的坑。Hibernate的open-in-view视图模式会在整个请求周期内保持数据库事务和会话打开,包括渲染模板的时间。这么做只是为了让视图层可以懒加载数据,代价是事务持有时间长、连接占用时间长、锁范围不可控。我建议项目里关掉open-in-view,在Service层手动控制事务边界。
5. SQL写法的程序层改造:同一个查询,换种写法性能差十倍
5.1 从"能查到"到"查得快":ORM生成SQL的隐性问题
程序操作优化绕不开SQL改写。用ORM框架(MyBatis、Hibernate)的人,经常只看Mapper里的方法名,不看生成的SQL是什么。但ORM生成的SQL往往不是最优的。
一个典型问题是N+1查询。比如查询用户列表时,要先查所有用户,然后循环遍历用户,再去查每个用户的订单信息。10个用户就要执行11次SQL。数据量上来、并发一高,这种写法会直接拖垮数据库。解决方案是改成JOIN一次性查出数据,或者在ORM里使用批量查询(IN)代替循环单查。
// 错误示范:循环查询 List<User> users = userMapper.findAll(); for (User user : users) { List<Order> orders = orderMapper.findByUserId(user.getId()); ... } // 正确做法:一次查询或IN批量查询 List<Order> orders = orderMapper.findByUserIds(userIds);IN查询里要注意IN列表的长度。IN后面跟的ID数量越大,SQL解析越慢,而且MySQL对IN列表的优化在某些版本有限制。建议拆分成每批500~1000个ID。
5.2 深翻页与OFFSET陷阱
分页查询是程序操作优化里绕不开的坑。LIMIT 100000, 20这种写法,在数据量达到几十万行时会让数据库扫描前面10万行,然后丢弃,只把最后20行返回给应用。这种深翻页操作随着页码增大越来越慢。
解决深翻页的思路有三种。一是基于游标的分页,用上一页最后一条记录的ID作为下一页的查询条件:
SELECT * FROM t_order WHERE id > #{lastOrderId} ORDER BY id LIMIT 20;这种方式走主键索引,翻到第10000页也不会变慢。二是延迟关联,先查出ID,再关联原表获取完整数据:
SELECT o.* FROM t_order o INNER JOIN (SELECT id FROM t_order ORDER BY id LIMIT 100000, 20) t ON o.id = t.id;三是限制最大翻页深度,产品层面直接禁止用户翻到5000页以后,搜索引擎也只展示前几十页。这三种方法我都用过,实操中最推荐的还是基于游标的分页,没有多余语法,索引利用率最高。
5.3 循环中操作数据库:性能杀手No.1
有一个非常常见的反模式,我在代码评审里见了无数次:在for循环里调用数据库操作。不管是SELECT还是UPDATE,只要循环里有数据库访问,就要警觉。哪怕单次查询只要1毫秒,循环1000次就是1秒,而且数据库连接被单线程独占,其他请求全部排队。
怎么根治?思路只有一个:把循环内的数据访问提到循环外。先收集所有需要的数据ID,一次SQL查出,在内存里做关联;或者用批量UPDATE代替循环子查询。
循环内的UPDATE尤其坑人。比如订单状态流转,每个订单根据自身状态计算下一个状态,开发人员很容易写出"循环更新"的代码。但状态流转本身是可以抽象成批量的:先查出所有符合条件的订单,在内存中算出它们各自的新状态,再拼成CASE WHEN批量UPDATE。循环单更新和批量更新,性能差距在数据量达到万级以上时是数量级的差异。
6. 并发控制与锁冲突:程序侧能做的比想象中多
6.1 锁竞争的本质:为什么并发越高,性能下降越夸张
数据库并发操作的性能瓶颈,很多时候不是CPU、不是磁盘I/O,而是锁冲突。多个事务同时操作同一行数据时,后到的事务必须等待前面的事务释放锁。等待的人越多,系统吞吐量就越低。
程序操作优化要解决的问题就是减少锁冲突、缩短锁持有时间。上一节讲的事务边界是缩短锁持有时间的关键手段,这一节讲的是改变操作顺序和粒度。
先看一个死锁案例的日志:
Transaction A持有行1的锁,等待行2的锁 Transaction B持有行2的锁,等待行1的锁两个事务互相等待,谁也无法继续执行,数据库的死锁检测机制会杀死其中一个事务让它回滚。这种死锁在程序操作层面最常见的成因是两个事务以不同的顺序操作多张表或行。
6.2 统一操作顺序:最简单有效的死锁规避手段
规避死锁有一个百试不爽的土办法:规定一个全局统一的业务对象操作顺序。比如涉及用户账户和订单表更新的业务,所有代码都先更新用户账户、再更新订单表,事务A和事务B就不会出现互相持有对方资源的情况。
如果一次性操作多行,还要保证行之间的顺序一致性。比如批量转账,要么都按账户ID从小到大排序,要么都按固定的业务主键排序。只要不同事务对同一组行的访问顺序一致,死锁的概率会降到极低。
6.3 悲观锁与乐观锁的选择
程序操作层面控制并发还有两个常用手段:悲观锁(SELECT...FOR UPDATE)和乐观锁(版本号/CAS)。我在实际项目中的选型经验是:
- 读多写少的场景,优先乐观锁。版本号字段加一次UPDATE,冲突时重试即可,不会阻塞读请求。
- 写多读少、且冲突概率高的场景,悲观锁更可靠。FOR UPDATE会锁住行,后续读会被阻塞,但保证了严格串行化。
- 库存扣减、金额变动这类强一致场景,推荐行级锁或乐观锁重试,而不是表锁。
- 锁粒度能小则小,能锁行不锁表,能锁记录不锁间隙。InnoDB的间隙锁在某些隔离级别下会自动启用,可能会锁住一个范围内的所有间隙,这比行锁更可怕。如果业务允许,可以考虑将隔离级别降为READ COMMITTED来避免间隙锁带来的锁冲突。
6.4 避免一个常见的"锁表"操作
很多人不知道,表结构变更(DDL)在MySQL 5.6及之前的版本会锁全表,即使是最新的MySQL 8.0,在线DDL在某些阶段也会持有元数据锁。如果程序里有动态建表、动态加字段的逻辑,且与业务高峰期重叠,很容易引发大面积锁等待。
我见过一个坑:为了记录业务日志,程序每天都动态创建一个新表,建完再往里插数据。DDL执行期间,所有涉及同库的查询全部被阻塞,因为元数据锁是全局性的。后来改成了预创建表结构、按日期分表,问题消失。这个案例的教训是:程序里不要频繁做DDL操作,表结构变动必须走审核流程并安排在低峰期执行。
7. 优化效果的验证方法:从"感觉变快了"到"数据证明变快了"
7.1 先量化,再动手:压测和慢查询日志
程序操作优化做了一堆改动之后,怎么证明真的有效?靠感觉是不行的,必须有数据支撑。推荐的做法是:优化前后各做一轮压测,对比同一并发级别下的吞吐量(TPS/QPS)、响应时间(P99/P95)、数据库活跃连接数和锁等待次数。
光靠压测还不够。慢查询日志是发现程序操作问题最直接的来源。开启慢查询日志后,把执行时间超过100毫秒的SQL全部捞出来分析,你会发现大量耗时SQL的根源不是索引,而是程序层面的设计问题:循环查询、深翻页、大事务、锁等待。
7.2 程序侧监控指标
做程序操作优化,需要在应用侧埋几个关键监控指标:
- 数据库连接池活跃连接数:观察波动情况。如果频繁打满,要么最大连接数不够,要么存在连接泄露。
- 事务平均耗时、最长耗时:超过1秒的事务需要重点关注,查查它到底做了什么操作。
- 锁等待次数:通过
SHOW STATUS LIKE 'Innodb_row_lock%'查询当前行锁的等待次数和等待时长。 - SQL执行次数分布:哪条SQL被调用得最多、最频繁,它就是性能优化的优先对象。
7.3 一套我自己常用的优化前后对比模板
实际操作时,我会做一个简单的对照表,每次优化前后各填一份:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| TPS(每秒事务数) | 320 | 1050 | 提升228% |
| P99响应时间 | 800ms | 180ms | 下降77% |
| 数据库活跃连接数峰值 | 180 | 45 | 释放大量连接 |
| 锁等待次数/小时 | 2300 | 45 | 下降98% |
| 慢查询数/小时 | 85 | 3 | 下降96% |
有了这张表,才能确认每次改动到底是真有效还是心理安慰。没有数据支撑的优化,很容易陷入"改来改去不知道哪里对了哪里错了"的泥潭。
就我个人的经验来说,程序操作优化最迷人的一点是:它不需要增加任何硬件资源,不需要改任何数据库结构,完全靠代码层面的调整就能拿到成倍的性能收益。连接池配好、批量操作做起来、事务边界划清楚、SQL改写做到位、并发访问理明白,这一套流程走下来,大多数系统的性能问题都能解决七八成。剩下的一部分,才需要去考虑读写分离、分库分表、引入缓存中间件这些更重的方案。希望这篇能帮你少走一些弯路。