☰
MySQL性能优化实战:从慢查询定位到架构设计
2026/10/13 7:54:48 网站建设 项目流程

1. 从一条慢查询说起:这个系列到底想讲什么

1.1 一个真实到有点尴尬的深夜

先说个发生在我自己身上的事。某天凌晨两点,线上告警群突然炸了,某业务接口的 TP99 从 200ms 一路飙到 3 秒。我睡眼惺忪地打开慢查询日志,发现罪魁祸首是一条看起来人畜无害的 SQL:

SELECT * FROM orders WHERE user_id = 1024 ORDER BY created_at DESC LIMIT 20;

这表当时有 3000 万行,user_id 上确实建了索引。可问题就出在SELECT *和ORDER BY created_at的组合上:MySQL 需要先根据 user_id 找到几千条记录,再对这几千条记录做一次临时表的排序,最后才取 20 条。如果运气不好,user_id 命中的人特别多,再叠加写入高峰,慢查询就直接把连接池拖垮了。

那晚之后我意识到一件事:数据库优化不是靠某一条“神级 SQL”或者一个“万能参数”就能解决的,它是一套从现象到根因、从设计到验证的完整方法。这也是我决定写这个系列的最初动机。

1.2 MySQL 优化到底在优化什么

很多人一听到“MySQL 优化”,第一反应是“调几个参数”或者“加个索引”。其实这只是最表层的东西。你把问题想深一层就会发现,MySQL 优化本质上是在做四件事:

  • 减少磁盘 I/O。数据要从磁盘读到内存,内存不够还要和磁盘频繁换入换出,I/O 一旦成为瓶颈,再快的 CPU 也白搭。
  • 减少排序和临时表。排序、去重、分组都会在内存或磁盘上生成临时结果集,这些操作极其昂贵。
  • 减少锁竞争和事务等待。并发上来之后,行锁、间隙锁、元数据锁会把请求排队排到怀疑人生。
  • 减少不必要的网络和日志开销。比如返回了根本用不到的字段、binlog 刷得太频繁、每条 SQL 都多传了不该传的数据。

换句话说,优化目标不是“让某条 SQL 变快”,而是“让整个数据库在既定硬件和业务负载下,吞吐更高、延迟更稳、可预期性更强”。这个系列的每一篇都会围绕这些目标展开,不会只丢给你几个参数就完事。

1.3 这个系列适合谁看、应该怎么用

如果你是刚入行的后端开发,经常被 DBA 或者导师说“这条 SQL 太差了”,那这个系列能帮你建立起排查问题的基础框架,知道从哪看起、怎么分析、怎么改。如果你已经写了两三年业务代码,开始接手一些性能优化工作,那你不仅能拿到具体手段,还能看到每个方案背后的取舍和适用边界。即使你是 DBA,想整理一套给研发团队看的优化规范,这个系列里的排查方法和避坑清单也可以直接拿来当参考。

我建议你不要只是收藏。最好的办法是,手边准备一套测试环境,导入一份真实业务脱敏数据,跟着文章里的步骤实际跑一遍。优化这件事,光看永远不会,踩过坑才算真的会。

2. 性能问题定位:先搞清楚瓶颈在哪再动手

2.1 用户感知和数据库指标怎么对上号

优化最忌讳的就是“还没确诊就开药”。很多同学一上来就问:“MySQL 的 CPU 跑到 90% 了,怎么办?”但你要知道,CPU 高只是表象,背后可能是全表扫描,可能是频繁排序,也可能是大量逻辑读导致的行锁竞争。所以在动手之前,先要把业务现象翻译成数据库指标。

  • 接口变慢:优先看慢查询日志、innodb_row_lock_current_waits、Threads_running。
  • 数据库卡死:重点看连接数、线程并发、锁等待、redo log 写入情况。
  • 偶尔抖动:需要结合监控看是否和备份、大事务、DDL、缓存失效时间点重合。
  • 主从延迟:关注 Seconds_Behind_Master、binlog 堆积量、从库的刷盘压力。

我做优化时有个习惯:先拉出最近一周的时间线,把接口延迟曲线、CPU、I/O、慢查询数量、连接数叠到一张图上。很多时候问题根本不是“某个值太高”,而是“某个时间段内各指标一起突变”。比如凌晨的定时任务和大促流量撞在一起,那单纯调索引也救不回来。

2.2 慢查询日志:最原始的破案线索

慢查询日志是排查性能问题最直接的入口。先确认它有没有打开,别笑,很多生产实例默认就是关的。

SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';

如果没开,可以通过参数临时打开:

SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;

long_query_time我建议从 1 秒起步,先看一个周期的日志量,再决定要不要收紧到 500ms。设置太大等于没设,太小容易把日志刷爆。另外提醒一句:log_queries_not_using_indexes在生产环境要慎开,因为某些全表扫描的查询其实是合理的(比如数据量极小的字典表),真开了会让日志量爆炸。

拿到慢查询日志之后,别直接抄 SQL 去 EXPLAIN。先做聚合分析,看看哪些 SQL 出现的次数最多、累计执行时间最长。同一个模子的 SQL 往往只是参数不同,要抽象出模板再分析。我用过不少工具,但实际上自己写个脚本按“去掉具体值的 SQL 指纹”聚合,往往最灵活。

2.3 用 EXPLAIN 读懂执行计划

定位到候选 SQL 之后,就该看执行计划了。这是每个优化者必须掌握的基本功。

EXPLAIN SELECT * FROM orders WHERE user_id = 1024 ORDER BY created_at DESC LIMIT 20;

重点看几个字段:

  • type:从 const、ref、range 到 index、ALL,访问效率越来越差。ALL就是全表扫描,能避免尽量避免。
  • key:实际用到的索引。明明建了索引但 key 是 NULL,那就是索引没生效。
  • rows:估算扫描的行数。这个数字和真实行数差距很大时,统计信息可能过期了,可能要ANALYZE TABLE。
  • Extra:出现Using temporary或者Using filesort时就要警惕。filesort 不代表磁盘文件排序,它也可能在内存排序,但排序这个动作本身是有成本的。

多说一句:EXPLAIN是估算值,不是真实执行值。对于一些复杂 SQL,我有时候会改用EXPLAIN ANALYZE(MySQL 8.0+),它能给出实际执行时间和各步骤耗时,定位更精准。如果生产环境不方便用,也可以在测试环境先导入数据再跑。

2.4 性能基线和日常巡检

没有基线,就没法判断“变慢了多少”。我见过很多团队,上线前不压测,上线后凭感觉说“系统变慢了”,可具体慢多少、慢在哪里,谁也说不清。

我的建议是,至少给核心接口和核心 SQL 建立三层基线:

  1. 接口层:TP50、TP95、TP99,按业务维度统计。
  2. 数据库层:QPS、TPS、慢查询数量、活跃连接数、临时表创建速率。
  3. 资源层:CPU、内存、磁盘 I/O、网络带宽。

巡检频率可以分两级:每天自动巡检一次,把关键指标和前 7 天对比;每周人工看一眼趋势,重点排查那些“缓慢爬坡”的指标,比如临时表数量逐日上涨、缓冲池命中率逐步下降。这类趋势问题最阴,不会立刻炸,但会在某一天突然要你还债。

3. 索引优化:大多数慢查询的症结都在这

3.1 索引为什么快,为什么也会失效

索引的本质是换一个数据结构去减少数据扫描量。InnoDB 的索引用的是 B+ 树,叶子节点有序存储,查询从原来的“扫全表”变成“按路径查找”,复杂度从 O(n) 降到 O(log n)。你可以把它理解成一本字典的目录:正常查字不会从第一页翻到最后一页,而是先翻目录、再定位页码。

但索引不是银弹。设计得不好,它带来的可能不是加速而是负担。因为每次 INSERT、UPDATE、DELETE 都要同步修改索引,索引越多,写放大越严重。所以“能不加索引就不加,需要加才加”才是正确心态。

索引失效更是高频问题。最典型的一种:对索引列做了函数运算或隐式类型转换。比如WHERE DATE(created_at) = '2024-01-01'这种写法,会让 created_at 上的索引失效,因为 MySQL 要对每一行都先算一遍 DATE 才能比较。正确的做法是改写范围条件:

WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00'

3.2 联合索引的设计原则与常见误区

联合索引是面试和实战里的常客,也是坑最多的地方。它的核心原则叫“最左前缀原则”,意思是查询条件必须从联合索引的最左列开始连续命中,索引才会被用到。

举个例子,表上有联合索引(user_id, status, created_at):

  • WHERE user_id = 1024 AND status = 1:能用索引。
  • WHERE status = 1 AND user_id = 1024:MySQL 优化器会做调整,也能用索引。
  • WHERE status = 1:不走索引,因为跳过了最左列 user_id。
  • WHERE user_id = 1024 AND created_at > '2024-01-01':能走索引,但只用到 user_id 这一列,created_at 上的索引条件可能无法继续用于范围检索之后的精确定位。

设计联合索引时,我的经验排序是:区分度高的列放前面,其次是把经常用于等值查询的列放前面,最后才是范围查询列。别盲目把每个查询条件都塞进同一个索引里,不然索引会越来越宽,存储和写入成本都会上升。更好的办法是统计业务真实查询,挑出高频的组合再去设计,而不是拍脑袋决定。

3.3 索引失效场景自查清单

我整理了这些年在实际代码 review 里最常见的索引失效场景,基本覆盖了 90% 的问题:

  • 对索引列使用函数或表达式,如WHERE id + 1 = 10。
  • 隐式类型转换,比如索引列是字符串,却传入数字比较。
  • 使用LIKE '%关键词'这种左模糊匹配。
  • OR 连接的多个条件里,有一个列没建索引。
  • 联合索引不满足最左前缀。
  • 优化器认为全表扫描比走索引更划算,常见于小表或统计信息严重不准。

前五条都好理解,最后一条最容易被忽略。优化器判断要不要走索引,是基于估算行数和成本模型。如果表的统计信息没更新,或者数据分布极度不均匀(比如某个值占了 90% 的行),它可能做出“反直觉”的决定。这时候要做的不是强制FORCE INDEX,而是先ANALYZE TABLE更新统计信息,再考虑改写 SQL。

3.4 覆盖索引与回表:少一次查询就多一分快

InnoDB 的二级索引只保存索引列和主键。如果查询需要的字段不在索引里,就得根据主键再到聚簇索引里取一次完整行记录,这个过程叫“回表”。回表本身不一定是坏事,但扫描行数多时,回表次数也会跟着多,I/O 消耗就上去了。

覆盖索引的思路,就是让查询需要的所有列都包含在某个索引中,这样二级索引本身就能满足查询,不用回表。比如你经常用user_id查status和created_at,那联合索引(user_id, status, created_at)就是一个覆盖索引。注意这里有个权衡:为了覆盖而把很多宽字段塞进索引,会让索引体积暴涨,反而不划算。一般只对高频且字段量少的查询做覆盖优化。

如果你看到执行计划里的Extra显示Using index,就说明这条查询已经被覆盖索引完全接住了,这是很理想的状态。

4. SQL 写法优化:换个写法快十倍不是玄学

4.1 SELECT * 的隐藏成本

很多开发者写SELECT *纯粹是图方便,但它有两个问题。第一,返回了业务根本不需要的列,白白增加网络传输时间和内存占用。第二,如果这些列不在索引中,就会迫使 MySQL 回表去取完整记录,覆盖面越大,代价越高。尤其当表里有一两个大字段(比如 JSON、TEXT)时,SELECT *很可能把一个本来很快的索引查询拖成重度 I/O 查询。

正确的姿势是先看业务到底需要哪些字段,只查用到的那几列。这不是让你“每列都要点名”,而是要有意识避免无脑全取。配合覆盖索引优化时,这一点尤其重要。

4.2 深分页问题:LIMIT 1000000, 20 为什么这么慢

分页接口最常见的写法是:

SELECT * FROM orders ORDER BY created_at DESC LIMIT 1000000, 20;

这个写法的执行逻辑是:扫描前 1000020 行,然后丢掉前 1000000 行,只把最后 20 行返回。翻得越深,扫描和丢弃的行越多,性能呈线性甚至更差。

解决办法有两种。第一种叫“游标分页”,前提是排序字段唯一且有序:

SELECT * FROM orders WHERE created_at < '2024-05-01 00:00:00' ORDER BY created_at DESC LIMIT 20;

前端拿到上一页最后一条记录的排序字段值,下一页带上这个值继续查。这种方案对无限下拉的 App 很合适。第二种是“延迟关联(延迟 join)”:

SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 1000000, 20 ) t ON o.id = t.id;

子查询里只查主键并做排序,扫描的字段宽度很窄,效率比全行扫描高很多。我实测过百万级深分页,这种写法能快一个数量级。

4.3 JOIN 和子查询:别迷信任何一种

网上有两种声音,一边说子查询性能差,一边说 JOIN 容易产生临时表。我的观点是:没有绝对优劣,关键看执行计划和数据量。小数据量子查询可能没问题,大表 JOIN 时必须注意驱动表的选择和连接字段的索引。

实际排查中我更在意的是:JOIN 的关联字段有没有索引。比如订单表和用户表做 JOIN,如果关联字段 user_id 在用户表上没有索引,那每匹配一条订单,都要去用户表做一次全表扫描,这是灾难。通常做法是,让驱动表尽量小,被驱动表的连接列尽量有索引。

另外,分页配合 JOIN 时,强烈建议先分页再 JOIN,而不是 JOIN 全部结果再分页。也就是先查出一个 page 的主键集合,再去关联其他表,这能显著减少大结果集的组合数量。

4.4 IN、EXISTS 和 OR 的取舍

先说 OR。它最容易被优化器折腾,如果 OR 连接的多个条件中任何一个涉及的列没有可用索引,MySQL 可能放弃索引转而全表扫描。这种 SQL 通常可以改成 UNION ALL,让每一段都能独立走索引。

再说 IN 和 EXISTS。IN 适合外表大、内表小的情况,EXISTS 适合外表小、内表大的情况。但 MySQL 优化器在 5.6 之后做了一些半连接优化,很多时候会把 IN 改写成 EXISTS 或其他形式。所以与其死记规则,不如直接看EXPLAIN,确认是不是用了合适的执行策略。另外要注意 IN 列表太长导致索引结构反复查找的开销,一般超过几百个值建议分拆或者改写 JOIN。

还有一个不易察觉的坑:IN (NULL)或者列表中包含 NULL 会导致结果语义变化,处理不当容易查出“不该出现”的数据,这个属于逻辑正确性问题,比性能更值得警惕。

5. 配置文件与参数调优:改参数前先理解这些

5.1 InnoDB 缓冲池:最重要的一块内存

InnoDB 的数据读写并不是直接和磁盘打交道,而是先经过缓冲池(Buffer Pool)。读数据时,如果数据页已经存在于缓冲池,就直接返回;不存在才去磁盘读取,并放入缓冲池。写数据也一样,先修改缓冲池中的页,再由后台线程慢慢刷回磁盘。

所以 Buffer Pool 越大,数据页在内存中的命中率越高,磁盘 I/O 就越少。一般建议把这个值设为物理内存的 60% 到 75%,具体要看服务器上是否还有其他服务在跑。设置之后别忘了观察命中和读写情况:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages%';

Innodb_buffer_pool_read_requests是总读取次数,Innodb_buffer_pool_reads是从磁盘读取的次数,两者相除就能估算命中率。如果命中率低于 99%,说明缓冲池可能偏小,或者数据访问模式存在大量冷读。

MySQL 8.0 里还可以把缓冲池设置成多个实例,用innodb_buffer_pool_instances控制,减少大并发下的内部锁竞争。改这个参数要重启实例,需要提前做好变更窗口和回滚方案。

5.2 关键参数怎么调、哪些不建议乱动

很多刚接触优化的同学喜欢在网上找“最佳配置”直接粘贴,这种做法我不赞同。数据库配置和业务模型强相关,没有放之四海而皆准的数字。但有几个参数是值得每个实践者去理解的:

  • innodb_flush_log_at_trx_commit。这个参数决定事务提交时 redo log 的刷盘策略。默认 1 是每次提交都刷盘,最安全但最慢;设成 2 表示提交时只写操作系统缓存,每秒才刷一次盘,性能提升明显但异常断电可能丢 1 秒内的日志。在线业务、账务相关的数据别乱调,宁可性能差一点。
  • sync_binlog。和innodb_flush_log_at_trx_commit配合着看,安全组合同样是 1,性能损耗最大;想要更高性能可以设置成 N 或 0,但主从切换、异常断电时可能出现数据不一致的风险。
  • innodb_io_capacity和innodb_io_capacity_max。这组参数控制后台刷页的速率,机械硬盘和 SSD 的设定逻辑完全不同,设太低会导致脏页堆积,设太高又会干扰正常业务。最好结合磁盘的实际 IOPS 能力做压测确定。
  • max_connections。这个值不是越大越好,连接数多了会带来大量线程上下文切换。更稳妥的做法是控制应用层的连接池规模,配合数据库端监控,超出合理水位就告警。

不太建议随手改的参数包括innodb_buffer_pool_size之外的各类“高并发魔法参数”,比如innodb_thread_concurrency、innodb_spin_wait_delay。很多人只看到别的团队改完效果不错,却不知道别人的硬件规格、业务类型和压测样本是什么。你把一堆参数堆上去,可能只换来微小的提升,却引入更多不确定性。

5.3 连接数、事务隔离级别与锁等待

连接数问题往往是“压垮数据库的最后一根稻草”。应用层连接池配置过大、请求突增、慢 SQL 占用连接不释放,三者叠加会直接把线程池打满。用一个简单公式估算:连接池大小建议控制在 CPU 核心数乘以 2 再加一点余量的水平。如果核心的 SQL 都是快查询,连接池没必要设到 100;如果有一些不可避免的重查询,连接池再大,慢查询也会把它占满,还不如把慢查询优化掉。

事务隔离级别方面,大多数业务用默认的 REPEATABLE READ(可重复读)问题不大,但它会产生更多的间隙锁。如果你对一致性要求没那么高,很多报表类、统计类场景可以改成 READ COMMITTED,显著降低某些锁等待和死锁概率。这个改动是全局性的,需要和业务一起评估。

锁等待排查常用这几条语句:

SHOW ENGINE INNODB STATUS; SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM sys.innodb_lock_waits;

看到锁等待事件后,先定位持锁事务跑了多久、涉及哪些 SQL。我见过很多死锁都是“两条 SQL 对同一批数据按不同顺序加锁”导致的,比如事务 A 先更新订单再更新用户,事务 B 先更新用户再更新订单。这种问题靠调参数解决不了,必须改业务代码,把加锁顺序统一起来。

5.4 参数修改后的验证方法

改参数最怕的就是“改完没验证,上线就出事”。我的标准流程是:先在测试环境按生产流量模型压测,对比修改前后的 TP99、CPU、I/O 和锁等待。确认没问题后,再在生产环境做灰度变更,一次只改一个参数,改完观察至少一个业务高峰周期。

要注意,有些参数是动态的,用SET GLOBAL马上生效但重启后失效;有些参数写在配置文件里必须重启。动态生效的参数也要记得同步到配置文件,避免下次重启被还原成旧值。这个细节我踩过不止一次。

6. 架构层面的优化:单实例到头了怎么办

6.1 读写分离:读多写少业务的起步方案

当单库的读压力已经让 CPU 和 I/O 持续走高,读写分离通常是第一反应。核心思路很简单:主库负责写,从库负责读,通过 binlog 把数据变化同步到从库。

但读写分离的难点不在“搭”,而在“一致性”。主从复制是异步的,刚写入主库的数据,在从库上可能还查不到。如果业务对实时性要求高,比如“下单之后立刻看到订单详情”,这种查询如果落到从库,就会读到旧数据。常见的处理方式有:

  • 关键读流量强制走主库,比如用户自己的订单列表。
  • 接受短时间延迟,在页面做状态提示。
  • 用半同步复制或组复制,把不一致窗口压小,但代价是写入性能会受影响。

我之前接手过一个系统,读写分离做好之后,接口性能确实提升明显,但偶然出现“用户反馈刚改的密码登录不了”的问题,就是因为修改密码的写走主库,登录校验读走了从库,复制延迟了那么几百毫秒。最后是把登录校验改成优先读主库才解决。这类坑,设计阶段就要想清楚。

6.2 分库分表:能不分就不分,分了就别回头

分库分表是优化手段里成本最高的一个,应该排在其他手段都试过之后再考虑。因为它会带来分布式 ID、跨库查询、事务一致性、扩容复杂性等一系列新问题。这里我给出几个判断条件:

  • 单表数据量过大,索引维护成本和查询性能明显下降。
  • 写入吞吐超过单库能力,CPU、I/O 持续在高位。
  • 预估未来数据增长会继续超过硬件扩容的性价比边界。

分片键的选择是重中之重。绝大多数场景用“用户 ID”做分片键,因为业务查询天然以用户维度切入。比较难受的是按时间分表之后,跨月或跨年的聚合查询会变成“扫多张表再合并”,这类需求如果很重,就需要评估是不是应该上分析型存储或数据仓库。

还有一点:分库分表之后,尽量把所有查询都带上分片键,否则就会走广播查询,把全部库表都访问一遍。无法带分片键的查询就要考虑引入映射表或者搜索引擎。这块不是一朝一夕能做完的,最好在数据量还没爆掉的时候提前规划。

6.3 缓存层:挡在 MySQL 前面的第一道防线

缓存和 MySQL 不是二选一,而是配合关系。最理想的状态是 MySQL 只承担“最终一致性”的落库职责,大量重复读流量被缓存挡住。常见的缓存策略有 Cache Aside、Read Through、Write Through 几种,但实践中用得最多的是 Cache Aside。

以用户信息查询为例:先查缓存,命中就直接返回;没命中就查 MySQL,然后把结果写回缓存并返回。更新时先更新数据库,再删除缓存,而不是更新缓存。为什么是“删缓存”而不是“更新缓存”?因为更新缓存在并发场景下容易出现新老数据交错,并且如果缓存里的值需要经过复杂计算得到,那每次更新都算一遍太浪费。删除缓存虽然第一次读会 miss,但能避免很多一致性隐患。

用缓存最怕的是“缓存雪崩”和“缓存击穿”。要么给过期时间加随机抖动,避免大量 key 同时过期;要么用互斥锁或热点 key 永不失效策略保护那些频繁访问的单一数据。好的缓存设计不是让命中率变成 100%,而是在极致流量下保证绝大多数请求不被打到 MySQL。

6.4 数据归档与冷热分离

很多表的性能问题不是 MySQL 不行,而是“热数据被冷数据拖累”。比如订单表积累了三五年历史数据,大部分旧订单不会再被访问,但它们依然占据空间、参与统计信息计算、拖慢索引维护。

最有效的办法就是归档。把超过一定时间的数据从主表搬到归档表,或者导出到其他存储。归档方案要提前想好两个问题:一是归档任务的执行不能影响在线业务,建议分批进行,每批控制在几千条以内;二是归档后的数据如果未来要查询,必须有清晰的查询路径,归档表在不在线库、能不能通过老接口查到,这些都要跟业务约定清楚。

我的经验是,冷热分离之后,主表体积变小,索引高度变低,查询性能往往能有一个档次的提升。但要注意,归档不是把数据丢掉,它是一套从在线库到离线存储的数据生命周期管理流程,需要有监控、补偿和恢复机制。

7. 常见问题与排查技巧实录

7.1 索引明明建了,SQL 还是很慢

遇到这种问题,第一步先别怀疑 MySQL,先检查执行计划。常见情况:

  • SQL 里对索引列做了隐式转换。比如表里 user_id 是 varchar,传入的是数字,MySQL 会尝试把列转成数字再比较,索引就失效了。
  • 查询条件写的顺序和联合索引不一致,但你以为一致。
  • 统计信息过期,优化器估算偏差大。用ANALYZE TABLE更新后重新 EXPLAIN。
  • 数据分布极度倾斜。某个值命中了几百万行,优化器算下来还是全表扫描更“划算”,但不代表业务可以接受。这时要么改查询语义,要么考虑按业务再分层索引。

我在某张表上遇到过一种情况:加了索引还慢,后来发现是 WHERE 条件里有个OR,另一列没索引,优化器干脆放弃全部索引。改成 UNION ALL 之后直接从 8 秒降到 300ms。这类问题光看表结构是看不出来的,必须看到完整 SQL。

7.2 测试环境快,生产环境慢

“测试环境明明几百毫秒,生产就要几秒”这种反馈我听得太多了。根因往往不在 SQL,而在环境和数据差异:

  • 生产数据量远大于测试环境,索引基数、统计信息分布完全不同。
  • 生产环境的 Buffer Pool 被大量业务数据占满,测试环境的缓存冷却度低。
  • 生产环境的并发写和锁等待,测试环境根本模拟不出来。
  • 生产环境的硬件可能在高峰期被其他业务抢占,尤其是虚拟化环境。

排查路径是:先在生产库上用EXPLAIN对比执行计划,看扫描行数是不是差了很多;再看数据库当时的活跃会话和锁等待;最后压测环境尽量用生产脱敏数据的抽样集,而不是造几条假数据。数据规模差一个数量级,很多结论都会被推翻。

7.3 连接数被打满,应用疯狂报超时

连接池打满通常不是突然发生,而是“慢 SQL 拖着连接不放”导致的恶性循环。一条慢 SQL 跑了 10 秒,就意味着这个连接被占用了 10 秒;当慢 SQL 并发到几十条,连接池就瞬间被消耗完,后续请求全部排队超时。

处理步骤:

  1. 先杀掉明显异常的慢查询,释放连接,恢复服务。
  2. 抓取慢查询日志,定位哪些 SQL 在高峰期集中出现。
  3. 检查应用层连接池配置,maxTotal 和 maxWait 是否合理。
  4. 优化 SQL 和加索引,让单条查询耗时降下来。
  5. 配置熔断和降级策略:当慢查询数量超过阈值时,拒绝部分非核心请求。

比较实操的建议是:给应用层连接池设置一个比数据库 max_connections 小很多的峰值,预留一些连接给 DBA 和管理工具使用。否则应用层把连接用完后,DBA 想连上去杀进程都连不进去。

7.4 主从延迟越来越大

主从延迟的成因很多,常见的有这么几类:

  • 从库硬件比主库差,或者承担了大量读流量,刷盘和 redo 应用能力不足。
  • 主库大事务,比如一次 UPDATE 影响几十万行,这个事务的 binlog 传到从库后要执行很久。
  • 从库上存在慢查询和锁等待,导致 SQL 线程被阻塞。
  • 从库复制是单线程应用,在高并发写入场景下容易跟不上。

排查时先用SHOW SLAVE STATUS看 Seconds_Behind_Master,同时对比主库的 binlog 写入量和从库的执行进度。如果是大事务导致,只能拆分事务,分批提交。如果是从库读压力太大,建议在从库前面加一层缓存,或者增加更多只读从库做负载均衡。这里还要注意:Seconds_Behind_Master 在某些情况下可能是 0,但不代表数据完全一致,比如从库的 SQL 线程正在执行一个大事务时,这个值无法反映真实差距。最好再对比一下主从的 binlog 点位或者 GTID 集合。

7.5 死锁:不是调参数能解决的

死锁的本质是两个或多个事务互相持有对方需要的锁,形成循环等待。InnoDB 检测到死锁会回滚其中一个事务,让另一个继续执行。应用层看到的现象就是某个事务突然报错。

定位死锁最直接的办法是:

SHOW ENGINE INNODB STATUS;

看LATEST DETECTED DEADLOCK部分,里面会记录两个事务执行的最近 SQL 和持有的锁信息。绝大多数死锁的解决方案是调整业务代码里的加锁顺序,比如约定所有事务都先操作订单表再操作用户表。实在改不动代码的情况下,可以缩短事务执行时间,减少锁持有范围,降低死锁概率,但根治还是要靠顺序一致。

我在实际项目里还总结过一个经验:审查死锁的时候,别只看报错的那条 SQL,要把它所在的事务里所有 SQL 都翻出来看。死锁往往不是单条 SQL 的问题,而是整个事务的锁集合和别的锁集合交叉。

8. 最后分享几点我的真实体会

优化 MySQL 这件事,我一直觉得最难的其实是“克制”。知道加索引很容易,知道调大内存参数很容易,但知道什么时候不该加索引、什么时候不该改全局参数,才是真正考验经验的地方。一个成熟的优化方案,应该是业务收益、维护成本、风险边界三者平衡的结果,而不是把某个指标调到最好看。

我个人的习惯是,每次优化完都会在文档里记三个东西:优化前现象、优化做了什么、优化之后指标变化。还会备注一个“如果下次再出现类似问题,先看什么”。三个月之后回看这些记录,你会发现自己对问题的理解深度完全不一样。

如果你想快速提升,建议选一套测试环境,把本系列提到的方法全部走一遍:构造慢查询、定位执行计划、设计索引、改写 SQL、调整参数、模拟主从延迟。中间必然会踩坑,但那些坑才是真正属于你的经验。下一篇我会详细拆解一个从慢查询到索引设计的完整案例,包括具体的数据规模、SQL 变化和执行计划对比,到时候见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询