
刚开始接触 MySQL 那会儿我脑子里一直转着一个问题我明明只是在客户端敲了一行SELECT * FROM user WHERE id 1怎么回车之后数据就哗啦一下出来了这中间到底是谁把我的 SQL 变成了一堆看得懂的指令又是按照什么顺序去执行的后来被前辈带着排了几次现场故障才慢慢意识到MySQL 里的代码执行和我原本以为的代码执行根本不是一回事。你写的 SQL 只是表达的愿望真正决定愿望怎么落地的是 MySQL 内部一整套庖丁解牛式的流程——把请求切开、拆开、优化、然后一行一行去存储引擎里取数据。这篇文章我就用庖丁解牛的思路把 MySQL 的代码执行从外到内切一遍。我会覆盖最核心的执行链路连接、解析、优化、执行、存储过程这类数据库内置程序的执行方式、优化器和执行计划怎么决定你的 SQL 跑得是快是慢以及在高并发场景下事务和锁如何参与每一次执行。适合刚接触 MySQL 原理的开发者也适合写了好几年 SQL 但从没深究过执行细节的 DBA 或后端同学。看完你会对SQL 为什么会慢索引为什么失效存储过程到底干了什么这类问题有一个更踏实的判断力。1. 先别急着看代码MySQL 里的代码执行到底指什么很多刚入行的同学会把MySQL 代码执行理解为写一段代码跑在数据库里。这个理解方向没错但不完整。SQL 本身是一门声明式语言你告诉数据库我想要什么而不是我要怎么做。真正去决定怎么做的是 MySQL 的服务层——这正是代码执行的核心区域。1.1 你写的 SQL 只是指令不是代码我见过不少开发同学把 SQL 当成 Python 一样去理解以为它是一行一行顺序执行的。实际上SQL 是集合并基于集合的运算你写SELECT * FROM orders WHERE amount 100MySQL 并不会像for循环那样逐条去比对 amount 是否大于 100而是在优化器生成执行计划后通过索引、扫描、连接等方式一次性筛选出符合条件的行。这个差别非常关键你要的是结果MySQL 给你编排的是获取结果的路径。如果把数据库比作一家餐厅SQL 就是你下的菜单而后厨执行引擎决定先洗菜还是先烧水用什么锅、开多大火最后把你点的菜端上来。菜单上写的是清蒸鲈鱼但后厨实际执行的是去鳞、剖鱼、腌制、上锅蒸、淋油这一串动作。MySQL 的代码执行就是后厨那一整套动作而不是菜单本身。1.2 庖丁解牛的第一步把 MySQL 的运行骨架先摸清楚MySQL 在整体架构上分成了两层我把它称作前台和后台。前台是 MySQL Server 层包含连接管理、查询缓存8.0 之后移除了、解析器、优化器、执行器后台是存储引擎层InnoDB、MyISAM 这些真正负责数据的读写。这两层的分工很有意思Server 层不关心你的数据到底存成什么格式它只负责把 SQL 变成一个可执行的方案存储引擎层则负责按方案去磁盘或内存中取数。也就是说MySQL 的代码执行不是由一个核心引擎全部包揽而是 Server 层和存储引擎层通过统一的 API 接口协作完成的。InnoDB 有它的 B 树索引实现MyISAM 有它的堆表实现但 Server 层调用它们的方式是固定的。注意理解这个分层最大的价值在于——当你遇到同样的 SQL换个存储引擎就变慢/变快的问题时你不会觉得玄学而是会清楚地知道问题出在哪一层。2. 一条 SQL 的生命周期从连接到返回结果中间到底发生了什么我以前排故障的时候习惯把一条 SQL 的旅程画在纸上因为每个环节都可能成为性能瓶颈。这条旅程大体是连接器 → 查询缓存 → 解析器 → 预处理器 → 优化器 → 执行器 → 存储引擎然后原路返回。MySQL 8.0 把查询缓存移除了所以现在的路径更短但对概念理解更纯粹。2.1 连接器到执行器请求的入口和身份校验你使用任何客户端连接 MySQL第一步永远是连接器的工作。连接器负责建立连接、校验用户名密码、读取你的权限列表。这里有个容易忽略的细节MySQL 权限在连接建立的那一刻就确定下来之后你即使去改了权限表当前已有连接也不会立刻生效需要重连。我在生产环境遇到过排查半天才发现是新改的权限没生效走了弯路。建立连接之后你发来的 SQL 字符串就进入了 Server 层。如果 MySQL 开启了查询缓存且你的语句命中了缓存8.0 之前它会直接返回结果不做任何解析。缓存被移除的原因基本可以归结为缓存命中率太低而且并发更新下缓存失效太频繁维护成本远大于收益。2.2 解析器与预处理把 SQL 字符串变成一棵语法树解析这个过程听起来很抽象实际就是 MySQL 把SELECT * FROM user WHERE id 1这一串字符分解成一个个单词token再根据 MySQL 的语法规则组装成一棵结构化的语法树。这一步如果发现你的 SQL 语法有问题比如少了个逗号、关键字拼错就会在这里直接报错。语法树长什么样可以这么理解根节点是一个 SELECT 节点下面分出了目标列子节点、FROM 子节点、WHERE 子节点。MySQL 后面所有的优化和执行都是在这棵树上做文章。预处理阶段则负责检查语法树里的表名、列名是否存在权限是否足够并处理一些语义层面的问题。一个经典的例子是SELECT * FROM user WHERE no_such_col 1报错Unknown column就是预处理阶段干的事。2.3 优化器帮你决定怎么查最省钱语法树建立之后优化器就上场了。它的核心任务是把语法树转换成执行计划。你可能觉得SELECT * FROM user WHERE id 1没什么可优化的但优化器要考虑的是走主键索引直接定位还是全表扫描当表有多个索引时选哪个当查询涉及 JOIN 时先连接哪张表MySQL 的优化器是基于成本的优化CBO它会为每个可能的执行方案估算代价包括 IO 代价和 CPU 代价然后选择它认为代价最小的方案。这里的代价是一个量化数字跟你表里的行数、索引的区分度、是否排序等因素相关。后面我会用一整章展开这部分的细节因为绝大多数SQL 突然变慢的坑都在这里。2.4 执行器与存储引擎最后一步的握手优化器生成执行计划后执行器就开始按计划调用存储引擎的接口。拿最简单的全表扫描来说执行器会调用 InnoDB 的接口逐行取数据然后判断 WHERE 条件是否满足满足的行放入结果集最后返回给客户端。注意执行器并不是一次性抓出所有数据给客户端而是可以一行一行取、一批一批返回。这也解释了为什么大查询不一定会把内存撑爆MySQL 可以通过边查边发的方式控制内存和网络占用的节奏。同时执行器还会维护一个行计数和锁信息为后续的行锁、事务控制提供上下文。你可以把它看成餐厅里的传菜员后厨做一道菜传菜员端一道菜而不是等全桌菜都做完才一起端上来。3. 存储过程、触发器、函数MySQL 里真正的代码如果说 SQL 是声明式的菜单那么存储过程、函数、触发器、事件这类对象才是 MySQL 里真正有程序感的代码。它们有变量、有流程控制、有循环、有游标可以用一段段逻辑去完成一条 SQL 做不到的事。很多业务系统把复杂数据处理下推到数据库里执行用的就是这些能力。3.1 存储过程一段驻留在数据库里的程序存储过程是一组预编译的 SQL 语句和控制语句的集合整体存储在数据库服务端。客户端只需要调用存储过程的名字并传入参数就能执行整段逻辑。这从代码执行的角度看非常有趣你写的存储过程代码在创建时会被 MySQL 解析并保存待到调用时再执行其中的每一条语句。为什么要把逻辑放到数据库里我在实际项目里见过两种典型场景。一种是把多条 SQL 合并成一个事务保证要么全部成功要么全部失败另一种是批量数据处理比如定时把订单表里的历史数据归档到备份表如果在应用层做你得一条一条发 SQL网络往返开销很大在存储过程里做就省掉了这些开销。存储过程的语法我挑核心的列一下DELIMITER $$ CREATE PROCEDURE archive_orders(IN days INT) BEGIN DECLARE done INT DEFAULT 0; DECLARE oid BIGINT; DECLARE cur CURSOR FOR SELECT order_id FROM orders WHERE create_time DATE_SUB(NOW(), INTERVAL days DAY) LIMIT 1000; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; OPEN cur; REPEAT FETCH cur INTO oid; IF NOT done THEN INSERT INTO orders_archive (order_id, create_time) SELECT order_id, create_time FROM orders WHERE order_id oid; DELETE FROM orders WHERE order_id oid; END IF; UNTIL done END REPEAT; CLOSE cur; END$$ DELIMITER ;这段代码在数据库里就是一个完整的程序。你可以看到DECLARE声明变量、CURSOR逐行取数、CONTINUE HANDLER捕获结束标记、REPEAT...UNTIL循环控制。这就是代码执行——数据库并不是只能执行单条 SQL它也能执行存储在内部的一段逻辑。3.2 游标与流程控制存储过程里怎么写逻辑上面示例中的游标是存储过程里最常见的代码执行方式之一。游标本质上是一种逐行读取结果集的机制。在面向集合的 SQL 世界里游标是少数的逐行处理例外。注意游标有一个隐蔽的性能陷阱它需要临时结果集支持而且逐行遍历的效率远低于集合操作。我在经历过一次慢到无法忍受的存储过程后得出的经验是能用一条 UPDATE 或 INSERT...SELECT 搞定的批量操作尽量不要用游标。存储过程适合的是必须按行判断并做复杂分支处理的场景而不是单纯的批量搬运。流程控制方面MySQL 支持IF、CASE、LOOP、WHILE、REPEAT、LEAVE、ITERATE。这些控制语句和你在 Python 或 Java 里写的几乎一样只不过运行在数据库进程内部。这里有个关键点存储过程中的每一条 SQL 语句在执行时依然要走解析、优化、执行那一整套流程所以存储过程并不天然比应用层拼 SQL 快。它的优势主要在减少网络往返和统一事务边界。3.3 触发器和事件被动的代码执行触发器Trigger是另一类代码执行对象。它由 DML 语句自动触发不需要显式调用。定义一个在orders表插入后触发的审计触发器你可以记录插入日志、同步冗余字段等。CREATE TRIGGER trg_orders_after_insert AFTER INSERT ON orders FOR EACH ROW BEGIN INSERT INTO orders_audit(order_id, action, operate_time) VALUES (NEW.order_id, INSERT, NOW()); END;这里NEW是插入后的新行你可以访问它的字段。触发器的优点是把业务约束固化到数据库层缺点是排障困难一个 UPDATE 语句如果意外触发了多个触发器整个执行链路会变得非常隐蔽。我的建议是触发器只做轻量级操作绝不在触发器里做复杂查询或调用外部存储过程。事件Event Scheduler则是 MySQL 自带的定时任务。它按照时间表自动执行一段 SQL 或存储过程适合清理过期数据、定期统计汇总。比如每天凌晨两点执行归档CREATE EVENT evt_daily_archive ON SCHEDULE EVERY 1 DAY STARTS 2024-01-01 02:00:00 DO CALL archive_orders(30);这段代码执行逻辑非常直观时间一到MySQL 内部创建一个新的会话去执行CALL。注意MySQL 的事件调度器默认可能是关闭的需要显式开启event_scheduler ON才能生效。4. 执行计划才是核心优化器如何决定代码怎么跑我做了这么多年最常被问的问题就是为什么这条 SQL 突然变慢了。绝大多数情况下答案不在 CPU 或内存上而在执行计划变了。执行计划就是优化器给这一次代码执行制定的执行方案。理解它是 MySQL 性能调优的及格线。4.1 优化器是怎么算代价的MySQL 优化器为每个可能的执行计划估算一个代价然后选择最小的。代价由两部分组成IO 代价和 CPU 代价。简单说IO 代价就是读多少个数据页的成本CPU 代价是处理多少行记录的成本。有一个很粗糙但有用的理解方式全表扫描时优化器会估算表有多少行、多少数据页走索引时它会估算需要扫描多少索引记录、回表多少次。然后把这些估算值乘上对应的权重系数得出一个总分。优化器还会计算排序需要的临时表代价、大小 join 的顺序排列等。这个估算过程依赖于表的统计信息比如行数、索引基数Cardinality。如果你发现优化器选择了明显不合理的执行计划第一件事不是急着加 hint而是先执行ANALYZE TABLE 表名更新统计信息。很多时候统计信息太久没更新MySQL 对行数和索引区分度的估算已经严重失真才导致选错了路径。我把这叫用过期地图导航地图的问题不解决再怎么骂导航都没用。4.2 索引用没用上别靠猜看 type 和 key要快速判断执行计划最直接的手段是EXPLAIN。我要求团队里每个后端同学在写复杂 SQL 时至少要会看输出里的type、key、rows、Extra四列。type表示访问类型从好到差大致是type含义典型场景system表只有一行系统表const主键或唯一索引命中WHERE id 1eq_ref唯一索引关联join 时被驱动表走主键关联ref普通索引等值WHERE status 1range索引范围扫描WHERE id BETWEEN 100 AND 200index全索引扫描覆盖索引但需要遍历全部索引记录ALL全表扫描无索引或优化器认为全表更快key列显示的是优化器实际选中的索引rows是优化器估算的需要检查的行数Extra列经常出现Using index、Using where、Using temporary、Using filesort。其中Using filesort和Using temporary是我最不想看到的两项它们意味着 MySQL 需要额外的排序或临时表在大数据量下会显著拖慢查询。经验type ALL不一定是坏事如果表只有几百行走全表扫描可能比走索引还快。判断的标准不是有没有用索引而是扫描的行数和实际返回的行数差距大不大。4.3 常见的优化器坑和纠正手段优化器不是神它也会犯错。常见的有几类第一索引失效。你对索引列使用了函数比如WHERE DATE(create_time) 2024-01-01MySQL 无法直接使用create_time上的普通索引只能全表扫描。这个很好理解索引列被函数修饰后原来有序的 B 树不再有序没法快速定位。正确的写法是WHERE create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00。第二隐式类型转换。如果 ON 或 WHERE 条件是字符串列和数字列比较MySQL 会把字符串转成数字后再比较索引照样失效。我曾排查过一个按订单号查询超时的线上问题原因是订单号列是varchar但代码里传参的是数字MySQL 自动做了类型转换导致索引不可用。第三优化器选错索引。比如表里有 A、B 两个单列索引MySQL 可能根据统计信息选择了区分度较低的那个。此时你可以用FORCE INDEX或USE INDEX强制指定索引但这只是最后一公里的手段长期还是要靠建立正确的联合索引来根治。更复杂的场景join 时驱动表选错也可以用STRAIGHT_JOIN调整连接顺序。5. 并发执行现场事务、锁与隔离级别怎么影响代码执行数据库天生是多用户并发访问的这不是一种可选项而是默认状态。所以每次代码执行都不是在真空里发生的它必然和其他执行过程共享同样的数据、同样的索引、同样的锁资源。理解并发下的执行状态是区分会写 SQL和懂 MySQL的分水岭。5.1 隔离级别决定你看到什么InnoDB 默认使用REPEATABLE READ隔离级别这和我接触过的很多其他数据库比如 PostgreSQL 的默认READ COMMITTED不一样。隔离级别决定了在并发执行时一个事务里能看到其他事务的哪些修改。四个隔离级别从宽松到严格是READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。在 MySQL 的实现里REPEATABLE READ和READ COMMITTED都是通过 MVCC多版本并发控制实现的。MVCC 是一个很聪明的设计简单理解就是每行记录背后不光有当前值还有历史版本。执行器在读取数据时根据当前事务的生成快照找到符合可见性规则的那个版本。这样读操作不会被写操作阻塞写操作也不会被读操作阻塞。这也是为什么我常说快照读不是实时读——在可重复读级别下同一个事务里执行两次相同查询看到的是同一个快照。你可以在事务里通过SELECT ... LOCK IN SHARE MODE或SELECT ... FOR UPDATE主动发起当前读直接读取最新版本并加锁。例如BEGIN; SELECT total_amount FROM account WHERE user_id 1 FOR UPDATE; -- 然后基于最新值做扣减 UPDATE account SET total_amount total_amount - 100 WHERE user_id 1; COMMIT;FOR UPDATE走的是当前读它锁住了这一行防止其他并发事务同时修改。这是悲观锁的典型实现适合对一致性要求极高的场景。5.2 锁介入的时机和粒度锁是另一个影响代码执行的关键因素。InnoDB 的锁分为行锁、表锁、间隙锁Gap Lock、Next-Key Lock 等。一般在当前读或写操作时InnoDB 会按需给涉及到的索引记录加锁。一个常见误解是InnoDB 锁的是行。更准确地说InnoDB 锁的是索引记录。如果没有索引那么它可能会锁住所有记录因为 InnoDB 在无索引条件下只能通过全表扫描定位需要加锁的行锁的代价自然被放大。这也是为什么我会要求核心表尽量都有合理索引不只是为了查得快更是为了保护写并发时锁的粒度足够小。间隙锁是REPEATABLE READ级别下解决幻读的手段。当你在一个范围条件上加当前读锁时InnoDB 不仅锁定已存在的记录还会在索引记录的间隙之间加锁阻止其他事务在这个间隙中插入新行。这能解决幻读但也更容易引发死锁。所以在高并发系统里如果业务可以接受把隔离级别设为READ COMMITTED往往能减少很多不必要的间隙锁冲突。5.3 死锁两个事务互相等死锁是并发代码执行中最经典的故障。举个最简单的例子事务 AUPDATE t SET x2 WHERE id1; UPDATE t SET y3 WHERE id2; 事务 BUPDATE t SET y3 WHERE id2; UPDATE t SET x2 WHERE id1;如果两个事务各拿了一部分锁然后互相等对方释放就死锁了。InnoDB 内部有死锁检测机制发现后会回滚其中一个事务让另一个继续执行。因此你在日志里看到Deadlock found when trying to get lock; try restarting transaction时说明系统已经自动帮你做了一次仲裁。死锁的根治思路我在实战里总结三条保持事务内 SQL 的执行顺序一致先处理 id1 再处理 id2两个事务就很少会互相占用。事务尽量短小减少锁的持有时间。核心表更新尽量走同一索引入口避免行锁散落在不同的索引上。6. 实战复盘把一个慢 SQL 的执行全链路扒开看讲了这么多概念终究要落到排查问题这个真实场景里。我复盘一个之前处理过的订单查询慢的例子完整走一遍定位和优化的流程大家可以直接把方法论抄去用。6.1 第一步慢查询日志锁定嫌疑人线上业务反馈订单查询接口最近偶尔会卡顿。我第一时间打开慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;然后从慢日志里发现了一个反复出现的 SQL平均执行时间达到了 3.8 秒SELECT o.id, o.order_no, u.nickname FROM orders o LEFT JOIN users u ON o.user_id u.id WHERE o.status 0 AND o.create_time 2024-06-01 ORDER BY o.create_time DESC LIMIT 20;这条 SQL 逻辑不复杂但 3.8 秒的耗时绝对不正常。顺着日志里的执行时间和扫描行数我初步怀疑是orders表的数据量涨上来了而索引没有跟上。6.2 第二步EXPLAIN 读执行计划我用EXPLAIN看执行计划EXPLAIN SELECT o.id, o.order_no, u.nickname FROM orders o LEFT JOIN users u ON o.user_id u.id WHERE o.status 0 AND o.create_time 2024-06-01 ORDER BY o.create_time DESC LIMIT 20;结果中orders表的type是ALLrows估算到了 800 万Extra里还带着Using temporary; Using filesort。这说明 MySQL 扫了一遍全表把所有满足 part 条件的行找出来再放到临时表里排序最后取 20 条返回。问题的根源很清楚WHERE里用了两个条件status和create_time但表上没有一个同时覆盖这两个条件的索引导致优化器只能全表扫描。全表扫描意味着要读几万个数据页再排序不慢才怪。6.3 第三步针对性建索引并对比效果我给orders表加了一个联合索引把区分度高的create_time放在前面status放在后面ALTER TABLE orders ADD INDEX idx_status_create_time (status, create_time);选择把status放在第一位是因为业务上status0表示待处理订单这个值在总数据量中的占比相对较小如果反过来把create_time放第一那索引扫描的范围还是太大。建立索引后再跑一次EXPLAINtype变成了refrows从 800 万降到了 2 万耗时从 3.8 秒降到 0.04 秒。6.4 第四步用 profile 定位更细的耗时点如果 EXPLAIN 还不够细我会再开 profilingSET profiling 1; -- 执行刚才那条 SQL SHOW PROFILES; SHOW PROFILE FOR QUERY 1;输出里会列出executing、Sending data、statistics、preparing等阶段的耗时占比。有一次排查我发现Sending data占了大头这代表执行器向存储引擎取数据并处理结果集很慢随后立刻想到了是索引问题如果是statistics阶段耗时高那多半是统计信息不准或优化器计算计划太重需要ANALYZE TABLE。profile 和 EXPLAIN 配合用基本能把一次执行链路里的瓶颈定位到具体环节。7. 常见问题与排查技巧实录最后我把这些年积累的常见问题做成一个速查表。这些坑我在不同项目里反复踩过几乎每个都值得写一篇单独的文章但这里先用最直接的形式记录下来。现象原因排查手段解决方向SQL 执行很慢但加索引无效函数/隐式转换导致索引失效EXPLAIN 查看 typeALL改写 SQL去除函数保持类型一致分页很深时越来越慢深分页需要扫描大量行查看 offset 值rows 估算大延迟关联或基于游标分页CPU 很高但 SQL 简单大量并发全表扫描或排序慢日志 SHOW PROCESSLIST增加索引优化 join减少扫描行数偶尔出现死锁日志多事务锁顺序不一致或间隙锁冲突查看死锁日志SHOW ENGINE INNODB STATUS统一加锁顺序事务拆小必要时降隔离级别update 一条记录很慢锁等待被其他事务持有锁information_schema.innodb_trx查看阻塞事务优化事务时长减少锁范围临时表占用磁盘排序或分组未走索引EXPLAIN Extra 出现 Using temporary加联合索引替代 filesort统计信息不准导致选错索引长时间未 ANALYZE TABLESHOW INDEX FROM 表名查看 Cardinality定期 ANALYZE TABLE 或调整优化器开关我在实际操作中的一个体会是不要被慢字吓住SQL 慢一定会在执行计划、慢日志或锁等待中留下痕迹。顺着痕迹一层层拨开大多数问题都能归结到三类索引没建好、执行计划选错、并发锁冲突。最后一个实用小技巧开慢日志时把long_query_time设小一点比如 0.5 秒但只在内网测试环境这么做。生产环境建议 1 到 2 秒并且不要长期开启general_log否则日志增长会反过来拖垮数据库性能。排查时宁可多用几次EXPLAIN也不要靠猜猜来猜去最后还是要回到执行计划上去验证。