数据库性能优化这事,写到第三篇,前面聊的基本都是“让数据库自己跑得更快”的事:服务器磁盘换固态、内存加大、参数调优。今天这篇换个角度,聊点更提气的:很多时候一条SQL慢、一个接口卡、一个后台任务跑不动,真不是数据库配置不行,而是咱们在程序里“用数据库”的方式不对。
“程序操作优化”听起来有点虚,说白了就是管好三件事:你发给数据库的SQL怎么写、你的事务怎么开、你的程序跟数据库之间怎么交互。我见过太多项目,数据库配置拉满、硬件堆到顶,结果代码里满屏SELECT *、循环里面查库、事务开着去调外部接口,照样把生产库拖到报警。恰恰相反,很多看起来“慢得要死”的系统,把程序层的操作理顺之后,响应时间直接掉一个量级,根本不用加机器。
这篇东西我按自己排查问题的思路来写:先讲整体判断方向,再拆SQL访问路径、连接与事务、批量交互、并发锁策略,最后给一套实际排查记录和速查表。适合正在做后端开发、维护线上系统、或者刚接手一个“说不清哪里慢”的项目的人。有基础的可以直接跳到第2章看执行计划那部分,新手建议从头顺一遍。
1. 程序操作优化的整体思路:先搞清楚慢在哪一层
1.1 从一次线上故障说起
之前帮一个团队看订单系统的慢接口,接口本身只是查一张订单表和一张明细表,数据量也不过百万级。一开始他们都在折腾数据库参数,什么innodb_buffer_pool_size调大、redo log刷盘策略改掉,效果都不明显。我接手第一件事就是开慢查询日志、抓执行计划,结果发现一个特别典型的场景:订单列表页每查一次,代码就对明细表做一次COUNT(*)统计,而且这个统计还触发了一次文件排序。
问题根本不在数据库配置,而在程序把“一次能做完的事拆成了几十次”。这就是程序操作优化跟其他层面优化最不一样的地方:数据库层的优化是在“已有的请求”上做文章,程序层的优化是直接从源头减少请求、改掉请求的方式。
我习惯把慢的问题先分三层看:第一层是访问路径,也就是SQL有没有走对索引、有没有全表扫描;第二层是交互方式,也就是程序跟数据库之间怎么握手、事务怎么控制、连接怎么复用;第三层是并发策略,也就是多个请求同时来的时候,锁和事务怎么排队。三层之间是递进关系,先看SQL本身,再看程序怎么调的,最后才看并发碰撞的问题。
1.2 优化方向就四个字:量、次、锁、排
很多人一听性能优化就想到索引,其实索引只是“量”的一部分。我给自己总结了一个四字框架,排查问题的时候对照着看:
- 量:每一次操作实际扫描和处理的数据量。该不该全表扫、读出来的字段有没有用、排序的临时表有多大。
- 次:程序跟数据库之间的交互次数。同一个逻辑能不能一条SQL完成?循环里能不能避免单条查库?批量插入能不能减少网络往返?
- 锁:操作持锁的范围和时间。事务里有没有无关操作?锁粒度能不能缩小?会不会互相等锁?
- 排:排序和临时表。
ORDER BY、GROUP BY、DISTINCT这些操作有没有走索引,还是靠文件排序硬扛。
这四个字几乎能覆盖90%的程序操作问题。比如第1.1节说的那个订单系统,就是典型“次”出了问题——一次页面请求,代码里实际查了几十次数据库;然后“排”也跟着出事——COUNT(*)加排序导致临时表。把第一个问题解决掉,响应时间直接从三秒掉到两百毫秒。
所以我的建议是,任何性能排查都先别急着上工具,先拿这四个字对着代码过一遍,往往能省下一大半力气。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQL访问路径优化:你的SQL真的走对路了吗
2.1 隐式转换和函数包裹:索引不是失效,是压根用不上
SQL访问路径,说白了就是执行计划里那张表到底怎么被访问的。很多开发刚学会建索引,一看到查询慢就加索引,加上去还是慢,就开始怀疑索引失效。这里我想说一个最常见的误区:不是索引“失效”,而是你写的SQL让优化器根本没法用索引。
最典型的就是隐式转换。比如字段类型是VARCHAR,你写WHERE phone = 13800000000,数字常量会被转成字符串去比,MySQL还能处理,但索引列上套了隐式函数或类型转换,走不了索引。更隐蔽的是字符集不一致,两张表关联字段一个是utf8mb4一个是utf8,关联查询也走不了索引,这个当年坑了我一整天才查出来。
第二种是函数包裹列。WHERE DATE(create_time) = '2024-01-01'这种写法,等于把索引列变成了函数的结果,优化器没法直接利用create_time上的索引。正确的做法是写成范围条件:WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。这个是老生常谈,但我在代码评审里仍然经常看到。
判断SQL走没走对路,最直接的方式是看执行计划。MySQL里就是EXPLAIN,主要看type列,从system到const、ref、range、index再到ALL,性能一个比一个差。如果看到ALL就是全表扫描,index是扫全索引,这两种都要警惕。配合rows列看预估扫描行数,基本就能判断这条SQL是不是“病根”。
提示:MySQL 8.0以上还可以用
EXPLAIN ANALYZE看实际执行行数和耗时,比单纯看execution plan更接近真相。Oracle和达梦也有类似的DBMS_XPLAN、EXPLAIN工具,思路一样,看访问路径和行数估算。
2.2 深分页为什么慢,怎么改
分页是程序操作里最容易出问题的场景。很多系统刚开始数据量小,LIMIT 100, 20觉得没什么,等表到了几百万行,翻到500页之后,接口就开始秒级响应了。
原因很好理解:LIMIT offset, size的工作方式是把前offset + size条数据全部查出来,然后丢掉前offset条。你翻到第500页,数据库实际上扫描了几万行甚至几十万行,只是给你返回了最后那20条。这跟“量”直接相关。
我常用的优化方案有三种:
- 延迟关联:先只查主键,再关联回原表取完整字段。比如:
sql复制SELECT a.* FROM orders a
INNER JOIN (SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 20) b
ON a.id = b.id;
先走覆盖索引快速拿到20个主键,再回表查数据,前面10万行扫的是索引,压力小很多。
-
游标式分页(键集分页):利用排序字段上的索引,记住上一页最后一条的位置,下一页用
WHERE create_time < ?去取。这种方案最适合“下拉加载更多”的场景,翻页多时性能几乎是恒定的。 -
限制可翻页深度:有些产品需求实际翻不到几百页,那直接在查询层加一个
LIMIT 10000之类的上限,防止用户或者爬虫把数据库拖垮。
方案各有适用场景,延迟关联适合“跳页”需求,游标式适合“瀑布流”,限制深度适合兜底。实际项目中我经常三种组合用:列表接口用键集分页,后台管理用延迟关联,所有接口统一加最大翻页深度。
2.3 大数据量删除更新:一次删十万还是一批一批来
大表上的DELETE和UPDATE,如果一次性操作几百万行,后果比很多人想象中严重。行锁会持有较长时间,期间其他事务全部排队;binlog和undo log会爆炸式增长;主从复制还可能因为大事务延迟好几十分钟。
我踩过一次特别惨的坑:一个定时任务想清理半年前的日志,一条DELETE FROM operation_log WHERE create_time < ?直接干了一天没结束,最后把主库binlog撑爆,整个集群都受影响。从那以后我所有大数据量清理都写成“分批循环”:
sql复制-- 每次只删5000行,删除完成后短暂停顿,给其他事务喘息机会
DELETE FROM operation_log
WHERE create_time < '2024-01-01 00:00:00'
LIMIT 5000;
程序里循环执行,直到影响行数为0。这样每一批持锁时间都很短,binlog和undo也控制得住,主从延迟基本能压在可接受范围内。更新和归档同理,凡是涉及大范围更新,先按主键范围切片,再分批执行。
注意:MySQL的
DELETE ... LIMIT是支持直接写的,但UPDATE ... LIMIT在部分版本和存储引擎下行为有差异,更稳妥的方式是先SELECT主键,再用主键范围做UPDATE。SQL Server和Oracle没有这种LIMIT写法,可以用TOP或ROWNUM配合子查询,或者直接用WHERE条件切段。
3. 连接管理与事务控制:小参数背后的大学问
3.1 连接池参数别照抄,先算清楚你的并发模型
程序操作优化的第二个大块是连接管理。数据库连接本质上是昂贵资源,创建连接要经过TCP握手、认证、分配内存,一次完整的建连开销能和执行十几条简单SQL相比。所以现在几乎所有项目都会用连接池,但连接池参数怎么设,我见过太多“从别人项目里抄过来”的配置。
先说结论:连接池不是越大越好。你开200个连接,数据库同时能真正跑起来的并行事务是有上限的,剩下全是排队等锁、等CPU。以MySQL为例,一般建议连接池上限在CPU核心数 * 2 + 1附近,比如8核的机器设17左右。这个公式不是绝对的,但思路是对的——数据库的计算能力决定了并发上限,连接开再多也只是让请求在连接池里排队,而不是“同时执行”。
HikariCP是我用的比较多的连接池,几个关键参数我会这么给:
| 参数 | 推荐值 | 说明 |
|---|---|---|
maximumPoolSize |
核心数*2+1 | 最大连接数,按库并发能力算 |
minimumIdle |
与maximum同值或略小 | 减少连接抖动,避免频繁创建销毁 |
connectionTimeout |
3000~10000ms | 获取连接超时,太短容易误杀,太长影响响应 |
maxLifetime |
数据库wait_timeout的70%~80% | 一定要短于数据库侧连接超时时间 |
validationTimeout |
1000~5000ms | 连接有效性检测超时 |
这里最容易被忽略的是maxLifetime和数据库端的wait_timeout的配合。如果数据库默认8小时断开空闲连接,但连接池的maxLifetime设了12小时,池里的连接就会变成“半死不活”的状态——表面连着,实际数据库那边已经关了。每次拿到这种连接执行第一条SQL就会报连接异常,然后程序重试,这种偶发性能抖动排查起来特别费时间。
还有一点,Druid连接池里的testWhileIdle和testOnBorrow要理解清楚再开。后者代表每次从池里拿连接都做一次探活,安全是安全,但等于每次查询都额外多一次网络往返,高并发下开销不小。现在HikariCP默认不用这些机制,靠maxLifetime合理设置就能规避“死连接”问题,我更推荐这种方式。
3.2 事务粒度:不该包进去的别包进去
事务是保证数据一致性的基础,也是性能问题的重灾区。最典型的错误写法是:在一个事务里做大量无关查询、调用外部HTTP接口、或者循环执行几十次SQL。事务里的每条SQL都会持有锁,事务不提交,锁就不释放。你把一个耗时300毫秒的外部接口调用放在事务里,那个事务就要持锁300毫秒,其他写操作全部排队。
我曾经接手过一个库存系统,接口响应经常超过5秒,一查代码,事务里先查询商品信息、再调营销系统接口、再扣库存、再写日志,整个串下来快2秒。扣库存这个动作本身只需要几十毫秒,却要等外部接口返回才能提交事务。这个改起来也不难:把外部调用移到事务外面,事务里只保留“查库存、扣库存、记录变更”三个必要操作,接口耗时直接降到100毫秒以内。
判断事务粒度是否合理,我一般问三个问题:
- 这个操作真的要原子性吗?所有这些SQL必须同生共死吗?
- 事务里有网络调用、文件读写、用户等待这类耗时操作吗?
- 事务持锁期间,其他事务会发生什么?
如果第三个问题答案是“会排队变慢”,那这个事务粒度就有问题。另外还有一个细节:只读查询不要手动开启事务。很多ORM框架默认每条查询都包一层事务,这种开销在低并发下感受不到,但到了高并发,每一条查询都要多一轮事务提交,累加起来非常可观。MyBatis里select默认不走事务,但Spring的@Transactional标注到查询方法上就会强制开事务,这个习惯要改掉。
3.3 死锁与锁等待:两个事务互相堵车的真相
锁等待和死锁是程序操作优化绕不开的话题。很多人一看到Deadlock found when trying to get lock就慌,其实死锁本身不一定是bug,它是数据库并发控制下的正常现象,关键是死锁之后程序怎么处理、系统里死锁的频率高不高。
我拿最经典的场景举例:两个事务同时更新两张表,但顺序相反。事务A先更新订单表再更新用户表,事务B先更新用户表再更新订单表。A持有订单表锁等用户表,B持有用户表锁等订单表,两边都不放手,数据库检测到循环等待后会强制回滚其中一个。
预防死锁最有效的办法是让所有事务都按固定顺序访问资源。比如规定所有代码必须先更新订单再更新用户,这个规定需要在团队层面统一,光靠DBA兜底是不够的。另外缩小事务范围也能降低死锁概率——事务持锁时间越短,跟别人“撞车”的窗口就越小。
排查死锁,MySQL里最直接的是SHOW ENGINE INNODB STATUS,里面能看到最近一次死锁涉及的事务和SQL。Oracle可以查V$LOCK和DBA_BLOCKERS。达梦数据库也有类似动态视图。我看到死锁信息后,第一步永远是先看两个事务分别持有哪把锁、在等哪把锁,然后顺着代码找到对应的两个事务,确认它们的资源访问顺序是否一致。
注意:程序里一定要对数据库死锁异常做重试。死锁事务是被数据库主动回滚的,不会自动重试。一个健壮的数据访问层应当捕获死锁异常,延迟几毫秒后重试整个事务。这是应对死锁的“最后一道防线”,哪怕预防做得再好也建议保留。
4. 数据交互方式优化:从循环单条到批量处理
4.1 N+1查询:最隐蔽的性能杀手
N+1查询这个词,后端开发应该都听过,但实际代码里还是经常出现。典型的场景是ORM框架的懒加载:你先查了一个订单列表,得到100个订单,然后遍历这100个订单去查各自的明细,每次查另一个表一条记录。最终执行了1条主查询+100条例明细查询,这就是N+1。数据库本身没做错什么,是程序把本来一条JOIN就能搞定的事拆成了101次。
我在一次代码评审里看到过一个统计报表的实现:外层循环几千个用户,内层每个用户查一次订单表和一次地址表,一次报表跑下来要执行上万条SQL。后来改成用IN批量查询两次,循环里只做内存匹配,执行时间从十几分钟降到十几秒。
解决N+1有两条路,要看具体场景:
- 小数据量、表结构简单:直接改
JOIN,一条SQL查出所有关联数据。但要小心JOIN之后字段重复、结果集膨胀的问题。 - 大数据量、结果集复杂:保留两次查询,主数据一次查出,关联数据用
WHERE id IN (...)批量查出,在内存里做映射。这种方案对数据库压力更小,也更容易做缓存。
还有一个容易被忽略的变种:不是遍历查库,而是在循环里调用一个封装好了的DAO方法,那个方法内部又查了一次库。代码层面看起来是“调一个方法”,实际上还是N+1。所以排查N+1时,不能只看循环里有没有SQL,要看整个调用链上有没有“循环→方法→SQL”的路径。
4.2 批量插入的三个关键参数,改一个天上一个地下
批量插入是批量操作里最典型的高频场景。同样插1万条数据,一条一条插和批量插入的性能差距可以用“数量级”来形容。但很多人在代码里写了addBatch(),却发现性能没有提升,甚至更慢,原因大概率是忽略了JDBC的rewriteBatchedStatements参数。
MySQL的JDBC驱动默认不会把addBatch()的多条SQL合并成一条真正的批量插入,而是仍然一条条执行。只有加上rewriteBatchedStatements=true,驱动才会把多条INSERT合并成INSERT INTO ... VALUES (...), (...), (...)这种形式。这个参数我见过太多项目没加,批量插入性能差距能有10倍以上。
除了这个参数,还有两个容易被忽略的点:
maxAllowedPacket:一次批量插入的SQL报文不能超过该值,默认可能只有4MB。如果每批塞了1万条数据,单条SQL报文超出限制,会被数据库直接拒绝。所以批量插入不是越大越好,通常我建议每批500~1000条,既能减少网络往返,又不容易触顶报文上限。- 事务边界:如果批量插入的每一步都自动提交,性能会非常难堪。一个完整的批量插入过程,应该手动开启事务,所有批次插入完成后统一提交。
java复制// 伪代码示例:每批1000条,分批次插入
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
String sql = "INSERT INTO orders (order_no, user_id, amount) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 0; i < orders.size(); i++) {
ps.setString(1, orders.get(i).getOrderNo());
ps.setLong(2, orders.get(i).getUserId());
ps.setBigDecimal(3, orders.get(i).getAmount());
ps.addBatch();
if (i % 1000 == 999) {
ps.executeBatch();
}
}
ps.executeBatch();
conn.commit();
}
}
批量更新的姿势也类似,但SQL写法上更讲究。一条UPDATE更新一行、循环N次,改成CASE WHEN合并成一条SQL批量更新,同样是数量级提升。不过这种SQL会变得很长,要小心SQL长度上限和锁的范围——一条更新100行的SQL,锁的范围可能比分成10批更新更大,这个要权衡。
4.3 ORM框架层面的几个坑:MyBatis和JPA的常见姿势
项目用得越多,ORM框架的隐性问题越常见。MyBatis里最典型的是foreach批量插入拼接SQL过长,OPEN和CLOSE循环没控制好,插入1万条数据拼出一个几十KB的SQL,数据库解析都费劲。我建议MyBatis批量插入同样分批,比如foreach里每500条执行一次insert,不要指望一条SQL搞定所有。
另一个MyBatis的隐患是嵌套查询的N+1。<collection>标签里如果用了select属性,框架会对每一条主记录都执行一次子查询,这就是典型的N+1。正确做法是使用<collection>的resultMap只做映射,关联数据用JOIN查出来一次性封装。
Spring Data JPA也有对应的坑。saveAll()方法并不保证批量插入,它默认还是逐条save,每条insert之后还会触发一级缓存的flush机制,性能比JDBC原生批处理差很多。如果对批量插入性能有硬性要求,还是建议直接用JdbcTemplate或原生JDBC,别绕一层saveAll。
ORM还有个共性问题:查询时把实体类整个映射出来,即使只需要两个字段。比如列表页只需要订单号和金额,结果查出来整个订单实体,包含几十个字段的JSON序列化,这部分开销看似不大,但高并发下会被放大。写查询时尽量用DTO或者SELECT明确的字段列表,别图省事SELECT *。
5. 并发与锁策略优化:热点数据怎么扛
5.1 乐观锁和悲观锁,选错方向就是给系统挖坑
并发控制是程序操作优化里最容易走偏的领域。悲观锁和乐观锁,听起来是教科书概念,实际选型错了影响很大。我的判断标准很简单:如果冲突概率高、业务对“读到的数据必须是最新”有强要求,用悲观锁;如果冲突概率低、业务可以接受偶尔重试,用乐观锁。
悲观锁就是SELECT ... FOR UPDATE,它会在读取时直接锁住行,其他事务改不了也读不了(取决于隔离级别)。这种方案优点是强一致,缺点是并发能力差。库存扣减这种并发写入量极高的场景,如果每条扣减请求都先FOR UPDATE锁住库存行,很快这行数据就变成整个系统的“瓶颈点”,所有请求都排队等锁。
乐观锁则是在数据表里加一个version字段,更新时带上版本号:
sql复制UPDATE inventory
SET stock = stock - 5, version = version + 1
WHERE id = #{id} AND version = #{version};
如果更新影响行数为0,说明版本对不上,有其他人改过,程序里重试或者报错。这种方式并发能力比悲观锁高,但代价是应用层要做重试逻辑。读多写少的场景一般很适合乐观锁,比如用户资料更新、配置项修改。
实际项目里我经常组合用:展示层用乐观锁,写操作的入口处再加一层条件约束。比如扣库存,直接UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?,这其实是条件更新,比先查再更新的做法少一次往返,也天然防止超卖。
5.2 热点行更新的三种处理方案
热点行问题比普通并发更棘手。典型场景就是爆款商品库存:一个SKU就一行记录,双十一的时候几万个请求同时打过来,如果全走行锁,数据库的吞吐直接腰斩。这种时候再调参都没用,要在程序层面换思路。
我常用的三种方案:
- 库存拆分:把一个商品拆成多个库存行,比如
sku_001_01、sku_001_02,扣减时随机选一行。这类方案适合库存量大的场景,但实现复杂度高,要处理库存汇总、超额售卖、库存归还等多线程一致性问题。 - 操作串行化:把对同一商品的所有操作放到队列里串行执行。可以用Redis的分布式锁在应用层控制,也可以用数据库的
GET_LOCK一把锁把同一商品的写操作串起来,从而避免大量行锁争抢。 - 异步化:把扣减请求写进消息队列,后端异步消费。用户下单后立刻返回“下单成功,正在处理”,库存扣减在后台排队完成。这种方式能极大缓解热点行压力,但对业务一致性要求高,退款、超卖、库存恢复都要重新设计。
方案没有银弹。库存拆分的“账目”复杂,异步化的“体验”有取舍。我的原则是:并发量没到一定程度,别乱上方案。先压测,确认热点行确实成为瓶颈,再考虑改造。
5.3 读多写少场景:缓存是数据库的救星
程序操作优化里,最简单粗暴但最有效的就是加缓存。热点数据查询走了几十次数据库,不如一次查询放入Redis,后面几十次直接在缓存里取。但缓存不是一加了事,我见过很多缓存引发的问题比解决的还多。
缓存更新的经典套路是“先更新数据库,再删缓存”。有些人会问为什么不是先更新缓存?因为并发下先更新缓存会导致数据库和缓存长时间不一致,最终一致性差。先删缓存也可能在删除之前有其他线程读到旧值重新回填了,所以还要配合延迟双删(删缓存、等几百毫秒、再删一次)。
还有一个比缓存一致性更隐蔽的问题:缓存穿透。恶意或巧合的请求查一个不存在的ID,缓存里没有,每次都打到数据库,数据库压力比不加缓存还大。解决方式是在缓存里也存“空值”,或者用布隆过滤器做前置判断。对于高并发系统,这一步不能省。
缓存还会带来“缓存雪崩”和“缓存击穿”的问题,但这根本不是数据库层面的优化,属于系统架构设计。我只想说一点:用了缓存以后,数据库的读压力明显下降,但写路径上的一致性逻辑要好好测试,尤其是“先写数据库再删缓存”和“写失败怎么补偿”这些边界条件。
6. 典型问题排查实录与速查表
6.1 一个真实案例:从慢查询到执行的完整排查过程
去年帮一个项目排查数据导入慢的问题,场景是运营后台批量导入商品数据,一次导入两万条,之前都是人工在Excel里操作,改用系统后经常超时。我第一次看日志,发现一条批量插入SQL执行了30多秒,第一反应是检查数据库有没有瓶颈。结果数据库CPU只有20%,磁盘IO也不高,明显是程序操作层面的问题。
然后我检查了代码,发现是用MyBatis的foreach拼了一个两万行的INSERT ALL,同时没有分批,也没有开启批量提交。这条SQL在数据库端要解析、要执行、要写redo,一次插入两万行还会持有大量行锁,其他业务查询全部被阻塞。
排查下来我把思路理成一条线:
- 看代码:确认数据访问方式是循环单条、批量
foreach、还是分页处理。 - 看SQL日志:统计出每个阶段执行了多少条SQL、总耗时。
- 看事务边界:手动提交还是一直自动提交。
最后改成“每500条一批 + 手动事务 + 分批提交”,两万条数据的导入从30秒降到2秒以内。整个改造只改了代码逻辑,数据库没动任何配置。
6.2 程序操作优化问题速查表
我把日常排查中常见的程序操作问题整理成了一张速查表,遇到问题可以直接对照定位:
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 接口响应慢,数据库CPU不高 | 程序与数据库交互次数过多,N+1查询、循环查库 | 用批量查询合并SQL,检查ORM懒加载链 |
| 翻页越翻越慢 | LIMIT深分页,每次扫描大量行 |
延迟关联、键集分页、限制翻页深度 |
| 批量操作很慢 | 没有启用JDBC批量重写,事务边界不合理 | 加rewriteBatchedStatements=true,分批+手动事务 |
| 偶发连接失效 | 连接池maxLifetime大于数据库wait_timeout |
调整连接池参数,让连接池侧先断开 |
| 更新或删除大表超时 | 一次性操作大量行,持锁时间过长 | 按主键分片分批执行,控制每批行数 |
| 死锁频繁 | 多事务资源访问顺序不一致,事务范围过大 | 统一访问顺序,缩小事务,死锁异常重试 |
| 单条SQL执行计划变了导致变慢 | 统计信息过旧,SQL写法有隐式转换 | 更新统计信息,检查索引列类型和字符集 |
这张表不是说能覆盖所有情况,但在大多数中小型系统里,定位问题的方向基本都在里面。真遇到查不出来的,再考虑抓执行计划、看information_schema里的锁等待和事务状态,一层层往下钻。
我自己的习惯是,每次写数据访问代码之前先问三句话:能少查一条吗?能一次查完吗?能不锁吗?把这三句话变成肌肉记忆,很多坑根本不会踩。程序操作优化练到最后,拼的不是工具多熟,而是写每一行数据库访问代码的时候,心里有没有装着“这条路数据库会怎么走”这张地图。
