数据库性能问题折腾到最后,你往往会发现,最气人的不是服务器配置不够,也不是SQL写得烂,而是程序代码里那些不起眼的操作习惯,把数据库的潜力一点一点浪费掉了。我之前写过两篇关于数据库性能优化的文章,一篇讲服务器参数调优,一篇讲索引与SQL改写,今天这篇是第三篇,专门聊程序操作层面的优化。
为什么我把“程序操作优化”单独拎出来写一篇?因为我见过太多团队把精力花在MySQL参数调优、硬件扩容上,结果数据库CPU还是动不动飙到90%往上。等我把慢查询日志和Druid监控拉出来一看,好家伙,原来罪魁祸首都在业务代码里——一个N+1查询、一次事务里的大批量循环更新、一个没走缓存的多表关联查询,每一处都在悄无声息地消耗数据库资源。
这篇文章我不会只摆理论,而是会从实际项目里踩过的坑出发,告诉你程序操作优化到底在优化什么、怎么定位问题、每一步怎么做,顺便把我平时排查性能问题的一套方法分享出来。不管是刚接触数据库优化的新手,还是已经被线上故障折磨过的老手,这篇文章都值得你花十分钟认真读完。
1. 先搞清楚程序操作优化到底在优化什么
很多同学一听“程序操作优化”,第一反应是“这说的是SQL优化吧”。严格来说不完全是。程序操作优化的范围比SQL优化要大得多,它的核心是:在不改变业务功能的前提下,优化你的应用程序访问数据库的方式,让每一次数据库交互都尽可能高效、紧凑、不浪费资源。
我习惯把程序操作优化拆成四个层面,排查的时候也是按这个顺序来。
1.1 四个层面的操作优化定位
第一层是连接管理。这一层的问题是程序怎么跟数据库建立连接、复用连接、释放连接。连接池参数设得不对,每次请求都新建连接,数据库光握手就要花掉大量时间,这个属于资源管理层面。
第二层是事务控制。事务用得好不好,直接决定数据库锁竞争是否激烈,隔离级别是否合适、事务粒度是否过大、事务内是否做了无关操作,这些都在这一层。
第三层是SQL交互方式。这个最容易被忽略——同样的查询,是一次性批量查询所有数据,还是循环一万次一条条查?是在应用内存里做数据拼装,还是依靠数据库的JOIN和子查询?这些操作方式的不同,对数据库的压力差距可能有几十倍。
第四层是数据读写的批量化与缓存化。程序在读写数据时的粒度是否合理,是否能利用批量提交和预编译来减少网络往返,是否能把高频读数据用缓存扛住而不压到数据库。
如果你拿到一个性能问题,不去分清楚它属于哪一层,上来就一顿乱改,很容易出现“症状转移”——把CPU问题改成了IO问题,或者把数据库的压力转移到了应用服务器上,问题却还在。
1.2 程序操作优化的核心指标
在做优化之前,先要有一个量化的抓手,不然你不知道优化到底有没有效果。我通常重点盯四个指标。
第一个是数据库QPS和TPS的构成比。TPS高不可怕,可怕的是TPS的构成里90%都是毫无意义的重复查询,那说明业务代码的缓存策略没有生效。
第二个是平均响应时间和慢SQL比例。程序操作优化做得好的系统,慢SQL占比应该在1%以内。如果这个比例超过了5%,先别急着加索引,多半是代码层面有人在循环里查数据库。
第三个是数据库连接池的活跃连接数和等待次数。活跃连接数长时间居高不下,等待次数持续增长,说明连接池参数设置不合理,或者有连接泄漏问题。
第四个是锁等待和死锁的次数。数据库监控里的锁等待次数突然飙升,九成概率是某个事务逻辑没写好,锁的粒度和时长失控了。
这四个指标,如果你平时完全没关注,那我建议从今天开始建一个监控面板,每天看一眼。不夸张地说,很多程序操作层面的性能隐患,都是通过这几个指标提前暴露的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQL交互方式的“魔鬼细节”与改写方案
说完了整体框架,我们来聊聊最常见的程序操作优化场景:SQL交互方式的改写。
为什么先把SQL交互方式拎出来?因为这是程序操作优化里性价比最高的一个环节。很多时候你不需要动任何数据库配置,只需要把代码里那种“一条条查”“一次性查太多没用字段”的写法改掉,数据库压力立刻就能降下来一大截。
2.1 一次经典的全表字段查询优化实录
让我用一个真实的例子说明。
之前做过一个电商后台管理系统的性能优化项目,运营同事反馈说,导出三个月订单数据时,接口总是超时。我打开后台代码一看,查询语句大概长这样:
sql复制SELECT * FROM order_info WHERE create_time BETWEEN ? AND ?
看起来很简单,对不对?但问题恰恰出在SELECT *上。order_info表有四十多个字段,包括几个TEXT类型的字段——比如买家留言、卖家备注、后台操作日志的JSON串。这些字段很多都有几百上千字节的数据,但运营同事在导出时根本用不到。
实际执行时的问题是:MySQL的存储引擎要把这些大字段从磁盘读出来,传到SQL层进行过滤,再通过网络传输给应用服务器。三个月的订单大约有三十多万条,每条就算多传输5KB无用数据,总传输量就多了一百多GB。更要命的是,大字段所在的页太大,导致InnoDB缓冲池里能缓存的有效索引页和数据页变少,冷数据在磁盘上的读取量也增加了。
改法很简单,不要查询任何不需要的字段,只保留真正要用的列:
sql复制SELECT order_id, order_no, user_id, product_name, total_amount, order_status, create_time
FROM order_info
WHERE create_time BETWEEN ? AND ?
后来我把整个后台所有的查询语句都过了一遍,凡是SELECT *的地方全部改成显式列出需要的字段。优化完之后,相同的数据量下,接口响应时间从之前的60多秒降到了8秒以内。
这里我想插一句:很多开发同事觉得写SELECT *省事,表结构一变更也少维护。但问题在于,表结构不是一成不变的,一旦有人往表里加了几个大字段,你的查询就会默默背上一堆永远用不到的IO开销。这种开销是隐性的,平时不出问题你根本感觉不到,等数据量一上来,它会成为最先爆炸的雷。
2.2 隐式类型转换和函数操作对索引的影响
还有个高频问题,是程序代码里传参的类型跟表字段的类型不匹配,导致索引直接失效。
举个例子,假设user表的mobile字段是varchar类型,索引建得好好的。但如果程序代码在拼SQL时把mobile当成了数字类型传进去,比如:
sql复制SELECT * FROM user WHERE mobile = 13800138000
MySQL会自动把字段做隐式类型转换再比较,相当于在索引列上用了CAST函数。在MySQL里,一旦对索引列使用函数,索引就会失效,查询就从索引查找退化成全表扫描。
我在排查一个用户量千万级的系统时,发现某个按手机号查询用户信息的接口,每次响应都在3秒以上,原因就是这里。Java代码里拼接SQL时没有给参数加引号,MyBatis又是以字符串替换的方式直接拼进去的,导致这个隐式转换问题一直没有暴露。后来加上正确类型之后,查询直接从1200毫秒降到了30毫秒。
还有一类是对索引列做函数操作,比如:
sql复制SELECT * FROM user WHERE DATE(create_time) = '2024-01-01'
这种写法不管create_time上有没有索引,都会失效,因为MySQL得先对所有行算一次DATE函数,才能判断是否符合条件。正确写法应该是范围查询:
sql复制SELECT * FROM user
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00'
程序操作优化的精髓就在于此:从源头避免让数据库做不必要的工作,而不是等数据库扛不住了再来想补救方案。
2.3 IN列表过大和深分页的另类解法
除了隐式类型转换,还有一个典型问题是大IN查询和深分页。
我在处理一个订单中心的慢查询时发现,有个接口会根据用户选中的一批订单ID去查订单明细,看起来逻辑很正常:
sql复制SELECT * FROM order_detail WHERE order_id IN (...)
问题在于,代码是通过一个for循环往IN列表里追加参数的,一次最多会传入几万个ID。MySQL对IN列表的长度是有优化上限的,列表过长时优化器会选择临时表和全量扫描,而且SQL语句的解析成本和网络传输成本都会暴涨。
改法是把IN列表拆成多个批次执行,比如每500个ID查一次,然后把结果合并。或者先把ID放到临时表里,再用JOIN的方式关联查询。具体用哪种要看业务场景——拆批操作更简单直接,临时表JOIN在列表特别长、且还需要关联其他表过滤时更高效。
深分页问题也很典型。比如前端要查看第10000页的数据,limit的写法是:
sql复制SELECT * FROM order_info ORDER BY create_time DESC LIMIT 500000, 20
这种写法不是查20条那么简单,而是先扫描并排序前500020条,再丢弃前500000条。数据量一大,这个操作相当耗时。程序操作层面的优化是改写成基于上次分页位置的方式:
sql复制SELECT * FROM order_info
WHERE create_time < '上次最后一条记录的create_time'
ORDER BY create_time DESC
LIMIT 20
也就是把“页码翻页”改成“键值翻页”,把深分页里的随机扫描变成基于索引的有序查找,速度能提升几个数量级。
3. 事务操作层面的优化:让锁的持有时间降到最低
很多程序操作层面的性能问题,都不是SQL本身的问题,而是事务层面的设计问题。
InnoDB的事务使用了锁机制来保证隔离性和一致性。一个事务里持有的锁越多、持有时间越长,其他事务的等待就越严重。结果就是,明明数据库资源很空闲,大量请求却因为锁等待而堆积成山。
3.1 一个让线上系统卡死的长事务案例
有次处理一个核心交易系统的偶发卡顿问题时,我观察到每次卡顿发生时,数据库监控里都有大量锁等待记录,而源头锁定在一张大表上的一批写操作。后来排查发现,有人在一个事务里写了非常多的操作日志更新代码,不仅有30多次insert和update,还有一个调用外部接口发送短信的逻辑,整个事务从开始到提交持续了近10秒。
这里面的问题有两个。第一,调用外部接口的网络耗时是非常不可控的,事务内一旦有外部RPC调用,就等于把锁的持有时间绑定在了外部系统的响应速度上。第二,事务里做了太多无关的写日志操作,这些操作本来完全可以放到事务外或者异步处理。
事务设计的核心逻辑很简单:锁啊,是数据库提供的保障,能为并发安全兜底,但它也是性能杀手。因此你的代码里,一行update、delete、insert都要尽快提交,绝不能把无关操作包到事务里来抻时间。每次我评审代码,看到有人把远程调用、发消息、发短信写进数据库事务里,都会直接打回。
3.2 隔离级别与锁粒度的实际选择
事务隔离级别的选择也直接影响锁竞争。
默认情况下,MySQL的InnoDB使用可重复读隔离级别(RR),它在当前读场景下会加Next-Key Lock,既能锁住符合条件的记录,也能防止幻读。但代价是锁的范围比读已提交(RC)要大得多。
在互联网高并发场景里,大部分业务对幻读其实并不敏感。如果把隔离级别调整为读已提交(RC),InnoDB可以只加Record Lock,不加Gap Lock,锁竞争的概率能明显下降。很多系统的默认参数把隔离级别设成RR,但在实际业务场景下RC性能更好,这也是阿里等公司内部大量使用RC的原因。
当然了,调整隔离级别不能盲目操作。如果你的业务涉及报表统计、金融对账等强一致性场景,需要在一个事务里对同一范围的数据执行多次查询并保证结果一致,那必须保留RR甚至串行化。每次调整前先跟业务方确认,隔离级别越低,并发能力越强,但业务方必须接受对应的数据一致性边界。
3.3 明确事务边界,避免事务误伤查询性能
还有一个经常被忽略的程序操作问题:在事务里做了大量查询操作。
有些开发同事喜欢把所有的业务逻辑都塞进一个方法里,然后在类上加一个@Transactional注解,于是这个事务就包含了查询、校验、扣减库存、生成订单等多个步骤。问题是,事务开启后,所有查询走的都是当前读(锁定读),如果该事务先写了几条记录,其他事务要读这些记录所在的区间时就会被阻塞。
正确的思路是把事务边界尽可能缩小到写操作本身。比如下单操作,库存校验可以先在事务外做一次快照读,真正扣减库存和生成订单时才开启事务,用乐观锁或者行锁来保证最终一致性。事务外查询和事务内查询分离开,既能保证并发事务的正确性,也可以显著减少锁等待的窗口期。
我自己平时要求团队写代码时遵循几条铁律:
- 事务内不允许有远程调用(HTTP、RPC、MQ投递)
- 事务内不允许一次性更新超过1000行数据,如果超过则拆批或改异步
- 事务与业务逻辑边界要评审,能放到事务外的读操作一律放出去
- 开启事务到提交之间的耗时要监控,超过500毫秒的事务要告警
4. 连接池与批量化操作:把“与数据库对话”的效率拉满
程序操作优化的第三大块,是连接管理和数据读写方式。这一块的优化目标非常直白:尽量减少程序与数据库之间的交互次数,尽量提高单次交互的效率。
4.1 连接池不是越大越好的理论依据
数据库连接池的配置逻辑,在业界的观点已经非常明确:不是越大越好,而是要有合理上限。
为什么?因为数据库服务端的每个连接都会占用一个线程来处理,每个线程又有自己的内存栈和缓存结构。连接数过多会导致上下文切换开销巨大,甚至让数据库在并发高的时候反而性能下降。
最典型的例子是Tomcat默认的200个线程,如果你把Druid连接池的maxActive也设成200,再配合数据库自身连接数上限,极易打满MySQL的max_connections,导致其他应用无法再建立连接。
我一直推荐的计算思路很简单,先测量核心接口的平均响应时间和系统目标QPS。比如你希望某个核心接口支持2000的QPS,而该接口平均耗时10毫秒,那么从理论上需要的并发连接数大约是2000乘以0.01,等于20。再乘上一个安全系数1.5到2,连接池的maxActive设置为40就已经足够了。
实际操作中还有一种常见误区是完全不设置参数,让连接池使用默认值。以Druid为例,如果你不设置maxActive,很多版本的默认值只有8,高并发场景下必然会出现连接获取等待。我在做性能压测的时候,见过太多项目就是因为连接池参数从未调过,压到几百并发时应用就开始大量报连接获取超时。
下面是一组相对稳妥的、适合绝大多数互联网应用的连接池初始参数,如果你暂时不知道该怎么调,可以参考这个基线:
| 参数 | 建议值 | 配置意图 |
|---|---|---|
| initialSize | 5 | 启动预热连接,避免首个请求被初始化拖慢 |
| minIdle | 5 | 保证低峰期有稳定的最小连接数 |
| maxActive | 40 | 按单接口RT与目标QPS估算得到的上限 |
| maxWait | 3000 | 获取连接超时3秒,避免无限阻塞拖垮Web线程 |
| timeBetweenEvictionRunsMillis | 60000 | 每60秒检测一次空闲连接 |
| minEvictableIdleTimeMillis | 300000 | 空闲超过5分钟则回收 |
| testWhileIdle | true | 空闲连接做轻量探测,避免断掉的连接被取出 |
| testOnBorrow | false | 取出时不再做重量级探测,提升获取速度 |
4.2 批量化读写:从一次一条到一次一批
除了连接池,程序与数据库交互的次数也是决定性能的关键环节。
我曾经排查过一个批量初始化数据的接口,业务代码大概是这样的逻辑:前端上传了一张Excel表,大约有五千行数据,程序在循环里对每一行执行了一次insert操作。五千次插入就要经过五千次网络往返、五千次SQL解析、五千次事务提交,整个过程跑了大约三分钟。
改法很明显:把单条提交改为批量提交。用JDBC的addBatch和executeBatch,或者MyBatis的批量执行器BatchExecutor,一次性提交五百条,事务提交次数也从五千次降到了十次。
这里有一个容易踩的坑:JDBC的rewriteBatchedStatements参数。MySQL驱动里默认很多版本没有开启这个参数,导致你在代码里用了executeBatch,但MySQL并不知道你要批量插入,实际执行的还是逐条插入,只是驱动帮你把多条语句连续发送而已,并没有真正减少SQL解析的开销。所以如果你用的是JDBC直连MySQL,请务必在连接串里加上rewriteBatchedStatements=true。加上之后,JDBC驱动会把原本的多条INSERT语句合并为一条多VALUES的语句,性能提升通常在三到五倍以上。
4.3 分页查询与超大结果集的内存陷阱
程序操作优化的另一个原则,是能用流式处理时不要一次性把所有数据加载到内存。
我遇到过一个报表导出功能,数据量是五十万条,代码写成了把所有查询结果全部加载到List里,然后异步生成Excel。结果就是应用服务器的JVM频繁Full GC,最后直接把内存打爆。
正确做法是用MyBatis的流式查询或者JDBC的fetchSize来控制。MySQL默认的fetchSize是Integer.MIN_VALUE特例,表示一次性取出所有结果到客户端内存。要真正流式地、逐条处理结果集,需要在JDBC查询时设置fetchSize为一个合理的值,比如1000,表示每次从数据库拉取1000条,处理完再拉下一批。
在MyBatis里,有一种更简单的流式处理写法,用Cursor来返回查询结果,这样结果集不会一次性全部加载进内存,而是在你迭代Cursor的过程中按需读取。对于导出百万级数据这种场景,这个改造能从根本上避免OOM问题。
4.4 ORM层的隐性开销与N+1查询
现在Java项目大部分都用MyBatis或者Spring Data JPA。框架带来了便利,但也容易让程序操作层面的问题更难被发现。
最典型的当属JPA和Hibernate的N+1查询问题。举个例子,你在查订单列表时,先查了两百条订单,然后代码又遍历每条订单去查对应的用户信息。表面上是两条业务逻辑,实际上产生了201条SQL语句,其中200条查询都是单点查询。数据库要为这200条小查询做200次网络交互和SQL解析,成本直接上升了一个量级。
排查这个问题有一个非常有效的手段:在开发环境打开数据库SQL日志,观察一个简单列表页面实际打印了多少条SQL。如果列表有几十条数据,SQL数量却成百甚至上千,那基本可以断定有N+1问题。MyBatis的解决办法是用联表查询,或者用IN查询一次性加载关联数据再在内存中映射;JPA的解法是显式使用JOIN FETCH,或者用@EntityGraph来声明关联抓取策略。
还有一个容易被忽略的ORM隐性开销:自动提交。MyBatis的SqlSession默认在每次执行后自动提交,如果你在一个批量操作场景里反复调用单条insert方法,实际上是每一单条INSERT都提交了一次事务。这个问题在生产环境里相当于隐性地把单条插入变成了同步刷盘模式,对性能的影响非常明显。
5. 缓存与并发控制:用程序操作替数据库“减负”
程序操作优化的另一个核心思路,是尽量让数据库少干活,让它把精力集中在真正需要它处理的事情上。缓存和并发控制策略就是这里面的关键。
5.1 缓存穿透、击穿、雪崩的操作层对策
如果你负责的系统里有一个热点数据的查询接口,频繁读数据库肯定不是长久之计。业界通用的做法是在应用层加一层缓存,这个缓存可以是本地缓存Caffeine,也可以是Redis等分布式缓存。
但缓存不是加了就万事大吉。我见过不少团队把缓存加上之后,接口还是被数据库慢查询拖住了,原因无非是缓存穿透、缓存击穿和缓存雪崩这三个典型问题。
缓存穿透指的是查询一个根本不存在的数据。比如查一个不存在的商品ID,每次请求都会穿透到数据库,大量请求甚至能把数据库打到连接池耗尽。解决办法有两个层面:其一,程序操作层对不存在的查询结果也做一个短暂的空值缓存,比如缓存5秒;其二,用布隆过滤器在缓存层之前挡住明显不存在的ID。这两种方案可以结合使用。
缓存击穿是指某个热点key在过期的一瞬间,大量请求同时发现缓存没命中,于是全部涌向数据库。这个问题的操作层解法是加互斥锁,只允许一个请求去查询数据库并回填缓存,其余请求在锁上等待,缓存回填完成后再走缓存查询。
缓存雪崩则是大量key在同一时间段内集中过期,导致数据库压力瞬间飙升。解法可以把缓存的过期时间设置成基础时间加一个随机偏移量,避免集中失效。这一点在批量缓存初始化时尤其重要,很多团队喜欢把一批数据设置成完全相同的过期时间,非常容易踩坑。
5.2 缓存一致性:程序操作层的主动更新策略
缓存写策略里,我最常用的还是Cache Aside Pattern,也就是先更新数据库,再删除缓存,而不是直接更新缓存。
为什么更新完数据库之后要删除缓存,而不是直接更新缓存?因为并发环境下,直接更新缓存的时序很难保证。举个例子,两个线程同时对同一份数据做了更新,线程A先修改了数据库,线程B后修改了数据库,但B更新缓存的操作可能比A执行得更快,最终缓存里保存的是旧数据,数据库里却是新数据,两边就对不上了。
相比之下,先更新数据库、再删除缓存的做法虽然也有极短窗口期的数据不一致,但相比“直接更新缓存”的方式,覆盖出错的可能性低得多。当然,这种方案还有一个小尾巴:如果删除缓存失败怎么办?通常的做法是引入一个可靠的消息队列,把删除缓存的操作异步投递出去并重试,保持程序操作层的容错性。
5.3 通过程序代码控制并发更新的冲突范围
最后说一下并发控制。这是程序操作优化里非常容易被人忽略但影响极大的一块。
如果你负责的业务里有库存扣减、余额变更这类高并发写操作,那么写代码之前就要想清楚冲突控制的方式。最挫的策略是直接用select查出来,在Java代码里做判断,再update回去。这个过程是典型的读改写循环,根本没有任何并发保护,必然会出现超卖问题。
比较靠谱的做法是使用乐观锁,在表中加一个version字段,更新时带上版本号条件:
sql复制UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = ? AND version = ?
如果更新影响的行数为0,说明版本冲突,程序可以重新读取数据并重试,或者直接返回操作失败让用户重新尝试。
如果冲突率非常高,比如秒杀场景下的库存扣减,这种统一更新仍然会出现大量行锁等待。此时可以采用预扣库存或者把库存拆分到多个独立的库存桶的方式,将原先的单点写拆成并行写,降低行锁竞争概率。但注意这一招会带来一定的实现复杂度,只有在单行更新确实成为瓶颈时才值得引入。
6. 踩坑实录与性能排查工具箱
程序操作优化做到最后,你会发现一件很有意思的事——很多性能问题并不是某个单一原因造成的,而是多个代码坏味道叠加出来的。所以除了改代码,你更需要一套靠谱的排查方法和工具,帮你准确找到病根。
6.1 我常用的性能排查路径
我处理过的数据库性能问题没有一百也有八十,总结出了一套自己的排查路径。
第一步,先看数据库全局状态。我会先跑一条SQL看当前库的整体指标,比如Threads_running、Threads_connected、Slow_queries、Innodb_row_lock_current_waits这些关键计数器的值。如果Threads_running长期超过几十,说明有大量并发操作正在同时执行。如果Innodb_row_lock_current_waits不为零,优先怀疑锁等待和长事务。
第二步,打开慢查询日志,把慢SQL按平均耗时或总耗时排序,先把Top 10的SQL捞出来分析。慢SQL里如果有明显不合理的大查询、全表扫描,就不需要再看后面的了。
第三步,如果SQL本身没大问题,我就把应用的连接池监控打开,观察活跃连接和等待连接数。如果应用在正常业务量下活跃连接一直顶到池上限,说明连接获取有问题。
第四步,最后看应用代码。把核心接口的SQL打印打开,或者用Arthas这类工具在线查看方法耗时。Arthas有一招非常好用,可以直接用trace命令跟踪一个方法的调用链路,能看到方法里每一步的真实耗时,帮助你定位究竟在哪一行代码上时间被消耗掉了。
很多程序员遇到数据库性能问题,第一反应是拿索引说事或者扩容服务器,但我的经验是:程序操作层的问题占到了线上数据库性能问题的六成以上。代码里的每一个不必要的查询、每一次过大的事务、每一条随手写出的SELECT *,都在透支数据库的性能。
6.2 常用数据库工具的一个补充
排查的过程中,如果你们公司还没有特别顺手的数据库运维工具,我这里顺带提一个方向。DBX数据库工具这类可视化客户端,日常用于连接管理、执行SQL、查看执行计划、对比表数据都比命令行高效不少,尤其是团队里有人不太适应写命令行的情况下。但工具再好也只是辅助,真正把性能问题解决掉的,还是你对业务访问模型的分析和代码操作层的调整。
6.3 常见问题速查表
我把之前踩过的一些高频问题整理成了一个速查表,你可以直接保存下来当作排查手册。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 单个接口响应慢,数据库CPU高 | SQL没走索引,或SELECT * 查了大量无用的字段 | 用explain查看执行计划,只查必要字段 |
| 接口在高峰期偶发超时 | 连接池maxActive太小,或存在连接泄漏 | 调大maxWait并监控活跃连接数,检查代码里连接是否都正常关闭 |
| 事务一多,数据库锁等待飙升 | 事务粒度过大,事务里包含远程调用或不相关写操作 | 缩小事务边界,把无关操作移出事务 |
| 批量插入速度极慢 | 未开启rewriteBatchedStatements,或循环单条提交 | 连接串加rewriteBatchedStatements=true,使用executeBatch批量提交 |
| 页面列表数据少但SQL极多 | ORM的N+1查询问题 | 联表查询替换循环单查,或使用IN批量查询 |
| 查询次数极多但都是重复数据 | 缓存策略失效或未生效 | 检查缓存穿透、击穿、雪崩,加空缓存/互斥锁/随机过期时间 |
| 大量数据导出导致内存溢出 | 一次性把结果集加载到内存 | 改成流式查询Cursor,或设置fetchSize分批读取 |
| 高并发扣减库存超卖 | 读改写无并发控制 | 改用乐观锁,或把热点行拆分 |
排查性能问题最怕的就是没有章法地瞎试。你把这个表从上往下对一遍,大部分线上问题都能找到大致方向。
7. 我的个人操作体会与收尾建议
这一篇“程序操作优化”聊了不少内容,从SQL交互方式的细节、事务控制策略、连接池与批量化操作,到缓存与并发控制,最后是一套排查工具箱。最后一节简单谈谈我这些年做数据库优化的个人体会。
程序操作优化跟服务器参数优化、索引优化最大的不同是:它没有一套固定的万能公式。每个业务系统有自己的读写模型,别人适用的方案到了你这里可能完全行不通。因此在动手优化之前,请务必把业务代码和实际调用链路看清楚,再做有针对性的调整。
以我自己的经验来说,程序操作层的优化往往能带来最显著、也最可持续的收益。因为它是从源头减少对数据库的压力,而不是等压力出现了再去扩充容量。如果你团队里正在为数据库性能发愁,可以先从代码评审入手,重点查三样东西:循环SQL、大事务、缺乏缓存设计的重复查询。把这三个问题解决掉,你可能会发现根本不需要花大价钱扩容。
最后再分享一个实用的小技巧:上线任何涉及数据库操作的程序改动之前,都先做一轮数据库层的对比压测。压测脚本不需要很复杂,只要模拟出真实业务的读写比例和并发量就行。改之前跑一轮基线数据,改之后跑一轮对比数据,用数据说话,而不是凭直觉判断优化是否有效。这样可以避免很多“自我感觉优化了,其实数据库压力一点没少”的尴尬情况。
