ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL 8.0语法入门到精通:窗口函数、CTE与索引优化实战

MySQL 8.0语法入门到精通:窗口函数、CTE与索引优化实战

几年前接手一个项目时,我需要把一个跑了四五年的业务库从MySQL 5.7迁移到8.0。当时以为这部分工作很简单:导出、导入、改改连接串,最多处理一下字符集。结果真正做完之后我才意识到,MySQL 8.0的语法和5.7之间差距比我想象中大得多。如果你也想真正掌握MySQL 8.0语法,这篇文章就是从一个实际使用者的角度,把入门到精通的路线、语法细节、常见坑和优化手段串起来,希望能帮你少走一段我已经走完的弯路。

1. MySQL 8.0语法到底变了什么:先理清学习地图

1.1 为什么很多人写了多年SQL,还是要回头学语法

我见过不少同事,写SELECT、INSERT、UPDATE非常熟练,日常业务完全够用,但一提到MySQL 8.0的新语法,就只停留在"好像有个窗口函数"这种模糊认知。这不怪大家,因为绝大多数业务场景用不到那些犄角旮旯的语法。可一旦你遇到"取每个分组内的前N条""算累计值""要做递归查询"这类需求,老思路只能靠应用层循环或者多次SQL拼接,写出来又慢又丑。

另一个容易被忽略的问题是,MySQL 8.0不只是增加了新特性,它还改变了很多旧行为。比如默认字符集改成了utf8mb4,认证插件换成了caching_sha2_password,默认SQL mode里开启了ONLY_FULL_GROUP_BY。这意味着同一句GROUP BY查询,在5.7里能跑,到了8.0里直接报错。所以"掌握MySQL 8.0语法"这件事,实际上混合了三个层面:

  • 新的语法特性,比如窗口函数、CTE、CHECK约束、角色管理。
  • 旧语法在新版本里的行为变化,比如GROUP BY、字符集、默认值写法。
  • 优化器相关的语法工具,比如EXPLAIN ANALYZE、优化器提示、不可见索引、降序索引。

如果不把这几个层面拆开看,只靠零散搜索,你掌握到的东西会一直是碎片的。

1.2 MySQL 8.0语法体系的全景变化

我先把自己在项目迁移过程中真正感受到的语法变化列成一张表,这样你心里有个谱,知道接下来应该重点学什么。

语法方向5.7时代的常见做法8.0的新选择
复杂排名/分组统计用户变量模拟或应用层处理窗口函数:ROW_NUMBER、RANK、DENSE_RANK、LAG、LEAD
临时结果集复用派生表(子查询套子查询)公共表表达式(CTE),支持WITH RECURSIVE递归
表约束只能靠触发器或应用层校验CHECK约束真正生效,表达式默认值
索引方向只能普通升序支持降序索引、不可见索引、函数索引
查询分析EXPLAIN 估计算法EXPLAIN ANALYZE 能看真实执行开销
账号权限用户直接授权CREATE ROLE角色统一管理
DDL执行特性部分DDL非原子原子DDL,失败自动回滚

这张表看起来像是在列产品更新日志,但实际上每一个点都会直接改变你写SQL的方式。举个例子:5.7时代要在查询里做"按部门分组,取每个部门薪资排名第二的员工",要么用临时表,要么用变量写一大段绕来绕去的SQL。8.0里一个窗口函数就搞定了,这不仅省事,性能也会好很多。

所以我的学习建议是:不要先把所有语法都背一遍,而是先建立一张"语法地图"。知道MySQL 8.0有什么,遇到哪类需求应该用哪类语法,然后再深入细节。接下来的章节,我就按照这张地图从基础往进阶讲。

2. 写查询前必须吃透的DDL/DML基础语法

2.1 建表与字段类型:语法正确的表设计才能撑起后续查询

很多人觉得DDL很简单,不就是一个CREATE TABLE。但MySQL 8.0里,建表语法的信息量比大多数人想象的大。一个典型的8.0建表语句长这样:

CREATE TABLE user_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', user_name VARCHAR(64) NOT NULL COMMENT '用户名', email VARCHAR(128) DEFAULT NULL COMMENT '邮箱', register_date DATETIME NOT NULL DEFAULT (CURRENT_DATE) COMMENT '注册日期', profile JSON DEFAULT NULL COMMENT '扩展信息', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常,0禁用', PRIMARY KEY (id), UNIQUE KEY uk_user_name (user_name), KEY idx_register_date (register_date), CONSTRAINT chk_status CHECK (status IN (0, 1)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

注意两个8.0的特性:

第一,CHECK约束是真的生效了。在5.7里,你写上CHECK约束不会报错,但它只是摆设,不会真正阻止你插入非法数据。8.0的InnoDB会严格执行这个约束。我用上面这张表试过,往status里插入2,直接被拒绝。这个特性对数据质量要求高的业务很有用,但要注意别在CHECK里写子查询或者函数调用,MySQL要求约束必须是简单条件表达式。

第二,默认值可以用表达式了,而且必须用括号包起来。比如DEFAULT (CURRENT_DATE)、DEFAULT (UUID())、DEFAULT (RAND())都是合法的。老的常量默认值写法还保留,但如果你能用到表达式默认值,很多原本需要触发器或者应用层处理的逻辑可以下沉到数据库。

字段类型的选择是另一个需要认真对待的点。大量使用VARCHAR(255)是非常懒惰的做法,MySQL 8.0里VARCHAR类型的长度声明是指字符数,一个utf8mb4字符最多占4字节。对于固定长度的编号,优先考虑CHAR;对于枚举值明确的字段,可以考虑ENUM;对于只存0/1的布尔状态,TINYINT即可;如果有复杂结构数据,JSON类型能省掉大量拆表。

改表结构的语法也值得列一下,因为生产环境的表很少一次建完:

ALTER TABLE user_info ADD COLUMN nick_name VARCHAR(50) NULL AFTER user_name; ALTER TABLE user_info MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1; ALTER TABLE user_info DROP COLUMN nick_name; ALTER TABLE user_info RENAME COLUMN email TO contact_email; ALTER TABLE user_info ALTER COLUMN status SET DEFAULT 2;

这里有个小提醒:8.0的DDL是原子的,也就是说ALTER TABLE执行过程中如果出了错,不会留下半改不动的状态。这对于大表操作是很大的安心感,但不要因为原子就随意在业务高峰期改大表结构,锁等待和磁盘开销依然存在。

2.2 增删改查里容易被忽略的DML写法

DML是每天写最多的东西,但很多人的写法停留在最简单层面。8.0里你完全可以写得更灵活。

INSERT最基本的是VALUES写法,但有两个容易被忽略的变体:

-- 从其他表直接插入 INSERT INTO user_info (user_name, email, register_date) SELECT name, email, created_at FROM temp_users WHERE status = 1; -- 冲突时更新,而不是报错 INSERT INTO user_info (user_name, status) VALUES ('zhangsan', 1) ON DUPLICATE KEY UPDATE status = VALUES(status);

ON DUPLICATE KEY UPDATE配合唯一键非常好用,比如做导入数据同步时,同一个用户名第二次出现就更新状态而不是插入报错。但8.0.20版本之后,官方开始不推荐VALUES()函数取当前插入值,建议改用别名方式:

INSERT INTO user_info (user_name, status) VALUES ('zhangsan', 1) AS new ON DUPLICATE KEY UPDATE status = new.status;

DELETE的写法很多人只会DELETE FROM tab WHERE id = ...。但面对复杂业务时,多表关联删除和排序删除效率更高:

-- 多表删除,只删目标表的数据 DELETE u FROM user_info u JOIN temp_delete_list t ON u.id = t.user_id WHERE t.reason = 'expired'; -- 按条件删除限定数量,适合清理任务 DELETE FROM operation_log WHERE created_at < '2024-01-01' ORDER BY id LIMIT 1000;

UPDATE多表关联更新也一样:

UPDATE user_info u JOIN temp_score s ON u.id = s.user_id SET u.status = 1 WHERE s.score > 60;

这些语法在实际维护中特别有用,尤其是清历史数据、批量打标签、批量修复数据这类脏活。掌握了它们,你不必先把数据捞到内存再逐条更新,一条SQL就能完成。

3. 从简单查询到复杂关联:JOIN、子查询与聚合语法的正确打开方式

3.1 JOIN语法与ON条件的执行逻辑

JOIN是SQL查询里最容易出问题的部分。很多初学者背下了"左连接返回左表所有行"这句话,但真遇到数据不符预期时,还是搞不清楚问题出在哪。

MySQL 8.0支持几种JOIN类型:

类型语义常见用途
INNER JOIN只返回两边匹配的行取交集数据
LEFT JOIN返回左表全部行,右表无匹配则为NULL保留左表维度
RIGHT JOIN返回右表全部行,左表无匹配则为NULL与LEFT JOIN对称,一般可改写
CROSS JOIN笛卡尔积生成组合数据,慎用
STRAIGHT_JOIN强制按前面的表为驱动表优化特定查询顺序

用实际的例子来看ON条件的影响。假设有两张表,订单表orders和用户表users,你想查"所有用户以及他们的订单数量"。

-- 正确:按用户维度关联后聚合 SELECT u.id, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id; -- 错误:如果在ON里加用户筛选,没问题;但如果在WHERE里加订单条件,LEFT JOIN会被改成INNER JOIN SELECT u.id, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' GROUP BY u.id;

第二句的问题在于WHERE o.status = 'paid'会把右表NULL的行全部过滤掉,左表里没有订单的用户就消失了。如果你确实想保留所有用户,但只统计已支付订单,应该把订单状态条件放到ON里:

SELECT u.id, COUNT(o.id) AS paid_order_cnt FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid' GROUP BY u.id;

这个细节我见过无数人踩坑。本质原因是SQL的逻辑执行顺序:JOIN先做,WHERE后做,WHERE一旦过滤右表NULL,LEFT JOIN的意义就没了。

JOIN的索引影响也很大。关联字段必须尽量有索引,否则MySQL对每一条驱动表记录都要扫描一次被驱动表。我处理过一个慢查询,原因就是JOIN字段两边都没有索引,一张几十万行的表和一张几万行的表关联,跑出十几秒。把被驱动表的关联字段加上索引后,查询降到几十毫秒。

3.2 子查询、GROUP BY与HAVING的配合

子查询可以放在SELECT、FROM、WHERE三个位置。放在SELECT里的标量子查询通常是关联子查询,会逐行执行,性能往往不好。放在WHERE里的子查询遇到过很多次NOT IN的坑:当子查询结果集里包含NULL时,NOT IN整条判断会变成UNKNOWN,结果就是什么都不返回。更安全的写法是用NOT EXISTS:

-- 有风险:如果sub_user_ids里出现NULL,结果异常 SELECT id, user_name FROM users WHERE id NOT IN (SELECT user_id FROM temp_blacklist WHERE user_id IS NOT NULL); -- 推荐:空值安全 SELECT id, user_name FROM users u WHERE NOT EXISTS (SELECT 1 FROM temp_blacklist b WHERE b.user_id = u.id);

GROUP BY在8.0下最需要注意的就是ONLY_FULL_GROUP_BY。默认开启后,SELECT的列必须是GROUP BY列本身,或者是聚合函数包裹的列。5.7里很多人习惯直接SELECT多个非聚合列,8.0里会报错:

-- 错误例子:name没有分组,也没被聚合 SELECT dept_id, name, COUNT(*) FROM employee GROUP BY dept_id; -- 正确例子 SELECT dept_id, MAX(name) AS sample_name, COUNT(*) FROM employee GROUP BY dept_id;

聚合函数里,COUNT(*)统计所有行,COUNT(col)统计该列非NULL的行,两者结果可能不同。GROUP_CONCAT可以把分组内的多行合并成一个字符串,比如把某个部门所有员工名拼在一起,默认以逗号分隔:

SELECT dept_id, GROUP_CONCAT(emp_name ORDER BY emp_name SEPARATOR '、') FROM employee GROUP BY dept_id;

HAVING跟在GROUP BY后面做二次过滤,它与WHERE的分工很清楚:WHERE过滤原始行,HAVING过滤聚合后的分组。值得提醒的是,HAVING里用到的聚合条件无法用索引优化,所以能用WHERE过滤的尽量在WHERE做。

4. 8.0最值得学的语法特性:窗口函数与CTE的实战拆解

4.1 窗口函数语法结构:PARTITION BY / ORDER BY / 窗口框架

窗口函数是MySQL 8.0引入的最大亮点。它的语法结构是:函数 + OVER子句。

SELECT emp_id, dept_id, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS ranking FROM employee;

这个查询的作用是:按部门分区,在部门内按薪资从高到低编号,ranking就是每个员工在部门里的名次。传统写法要做到同样效果,得用自连接或变量,写出来又慢又绕。

常用窗口函数分三类:

  • 排名函数:ROW_NUMBER、RANK、DENSE_RANK。RANK和DENSE_RANK的差别在于并列名次:RANK在并列后会跳过名次,比如1、1、3;DENSE_RANK不会跳过,得到1、1、2。
  • 聚合窗口函数:SUM、AVG、COUNT、MAX、MIN后面也能加OVER,用来算累计值。典型场景是"统计每天的累计销售额"。
  • 偏移函数:LAG、LEAD,用来取同一分区内前一行或后一行的值。计算环比、同比时非常省事。

假设一张销售流水表:

SELECT sales_date, amount, SUM(amount) OVER (ORDER BY sales_date) AS cumulative_amount, LAG(amount, 1) OVER (ORDER BY sales_date) AS prev_day_amount, amount - LAG(amount, 1) OVER (ORDER BY sales_date) AS day_over_day_change FROM daily_sales;

窗口框架本身也很值得学。ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW是默认累加到当前行;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW则代表只算最近7天。做滑动平均、移动求和时,这个框架语法是核心。

我在实际项目里用窗口函数解决过一个需求:取每个商品分类下的销量前3名商品。以前要写嵌套子查询、自连接,现在一句:

SELECT category_id, product_id, sales_count FROM ( SELECT category_id, product_id, sales_count, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales_count DESC) AS rn FROM product_sales ) t WHERE t.rn <= 3;

性能上窗口函数通常优于老写法,但要注意它需要在内存或磁盘里排序。如果PARTITION BY后分区的数据量很大,建议先确认排序字段上有合适索引。

4.2 递归CTE与其他CTE使用场景

CTE的全称是公用表表达式,语法是WITH开头。第一次接触的人容易把它当成一张临时表,但它比临时表更优雅,因为它是纯SQL层面的、查询结束后自动消失。

最简单的用法是让复杂查询分步清晰。以前写一长串嵌套子查询,可读性非常差,用CTE可以逐步拆分:

WITH dept_avg AS ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ) SELECT e.emp_name, e.salary, d.avg_salary FROM employee e JOIN dept_avg d ON e.dept_id = d.dept_id WHERE e.salary > d.avg_salary;

CTE最独特的杀手锏是递归查询。8.0支持WITH RECURSIVE,这在处理树形结构、层级关系数据时简直是降维打击。比如组织架构表,要查某个部门下面所有子部门,以前要么写存储过程循环,要么业务层递归,现在一条SQL:

WITH RECURSIVE dept_tree AS ( SELECT dept_id, parent_id, dept_name, 1 AS level FROM department WHERE dept_id = 1 UNION ALL SELECT d.dept_id, d.parent_id, d.dept_name, dt.level + 1 FROM department d JOIN dept_tree dt ON d.parent_id = dt.dept_id ) SELECT * FROM dept_tree;

递归CTE也可以用来生成连续的日期序列,这在报表场景特别常用:

WITH RECURSIVE date_range AS ( SELECT DATE('2024-01-01') AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM date_range WHERE d < '2024-01-31' ) SELECT d FROM date_range;

这段SQL会生成2024年1月1日到1月31日的每一天。拿它去LEFT JOIN业绩表,就能把没有业绩的日期补零,这是做月度报表时几乎绕不开的需求。

CTE还有一个性能上的优势:在一条查询里如果多次引用同一个CTE,MySQL只会物化一次。而相同含义的子查询如果在多个位置重复出现,可能会被执行多次。所以遇到复用次数多的结果集,优先考虑CTE。

5. 程序化语法:存储过程、函数与控制流的完整写法

5.1 DELIMITER与块结构:为什么MySQL的存储过程这么容易写错

有经验的MySQL开发者都清楚,命令行里写存储过程,第一道坎就是DELIMITER。存储过程内部有多个语句,按分号分隔,如果不重新定义分隔符,MySQL客户端会在第一个分号处就把语句提交了,导致后面的语句解析失败。

DELIMITER $$ CREATE PROCEDURE get_user_count(IN p_status INT, OUT p_cnt INT) BEGIN SELECT COUNT(*) INTO p_cnt FROM user_info WHERE status = p_status; END$$ DELIMITER ;

写完过程后调用:

CALL get_user_count(1, @cnt); SELECT @cnt;

存储过程有三种参数类型:IN是传入参数,OUT是传出参数,INOUT既能传入又能传出。在存储过程体内,变量需要用DECLARE声明,比如DECLARE v_count INT DEFAULT 0;。注意DECLARE必须放在BEGIN块的最前面,不能出现在其他语句后面,这是MySQL语法上的硬性约束,和很多编程语言不一样。

实际项目里,存储过程的使用应当克制。MySQL的存储过程调试手段有限,而且优化器对存储过程内部的语句做优化时会有更多限制。我见过一些项目把所有业务逻辑塞进存储过程,后来维护成本超高。建议只在两类场景用:一是高频固定动作需要减少网络往返,二是必须在数据库内部完成的数据加工任务,比如批量归档。

5.2 循环、判断与异常处理语法

存储过程内的流程控制语法分成几个家族:

IF判断:

IF p_status = 1 THEN SET v_msg = 'enabled'; ELSEIF p_status = 0 THEN SET v_msg = 'disabled'; ELSE SET v_msg = 'unknown'; END IF;

WHILE和REPEAT循环:

WHILE v_i < 10 DO SET v_i = v_i + 1; END WHILE; REPEAT SET v_i = v_i + 1; UNTIL v_i >= 10 END REPEAT;

LOOP是无限循环,必须配合LEAVE跳出;ITERATE则类似其他语言里的continue。一个打印1到10的循环:

DECLARE v_i INT DEFAULT 1; DECLARE v_result VARCHAR(100) DEFAULT ''; loop_label: LOOP IF v_i > 10 THEN LEAVE loop_label; END IF; SET v_result = CONCAT(v_result, v_i, ','); SET v_i = v_i + 1; END LOOP;

异常处理一般用DECLARE HANDLER:

DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET v_error = 1; END;

游标的使用是存储过程里比较进阶的部分。游标可以把SELECT结果逐行取出来处理,但有个关键点:游标循环必须在取完所有行之后设置退出条件。一般写法是先声明DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;,然后循环里用IF判断退出。流程是:声明游标、打开游标、循环FETCH、关闭游标。少一步都不行,顺序错了也会直接语法报错。

以我个人的经验,能用一条SQL解决的问题,不要想着用游标。游标本质是逐行处理,性能天然比集合操作差。它的合理场景是:对每一行数据都需要执行复杂的判断或调用另一个过程,无法用纯SQL表达。

6. 让语法真正跑快的优化语法:索引、EXPLAIN与优化器提示

6.1 创建索引的语法细节与选择原则

MySQL 8.0里创建索引有几种写法,本质上都一样:

CREATE INDEX idx_emp_dept ON employee (dept_id); ALTER TABLE employee ADD INDEX idx_emp_dept (dept_id); ALTER TABLE employee ADD UNIQUE INDEX uk_emp_phone (phone);

8.0新增的几个索引特性非常实用。第一个是降序索引。在5.7里,你用ORDER BY salary DESC只能靠反向扫描升序索引,效率会打折扣。8.0可以直接创建降序索引:

CREATE INDEX idx_salary_desc ON employee (salary DESC);

第二个是不可见索引。刚创建的索引如果拿不准是不是好用,或者确认某个索引已经失效但不敢立刻删,可以设置成INVISIBLE,索引会保留但优化器不会使用。等确认没问题再删,心里踏实很多:

CREATE INDEX idx_temp ON employee (status) INVISIBLE; -- 或者 ALTER TABLE employee ALTER INDEX idx_temp VISIBLE;

第三个是函数索引,也就是基于表达式的索引。比如你经常按邮箱的域名查,或者按出生年月查,可以直接在表达式上建索引。注意表达式必须用额外括号包起来:

CREATE INDEX idx_birth_month ON employee ((DATE_FORMAT(birth_date, '%Y-%m')));

建索引的选择规律并不神秘。首先要关注WHERE子句里的等值条件字段,然后是JOIN的关联字段,再然后是ORDER BY和GROUP BY涉及的字段。复合索引要遵循最左前缀原则:索引(dept_id, status, register_date)可以匹配dept_id,也可以匹配dept_id + status,但直接查status用不上这个复合索引。

索引不是建得越多越好。每个索引在写入数据时都要维护,索引过多会拖慢INSERT、UPDATE和DELETE。我的经验是:单表索引数量控制在5个以内,实际效果往往比堆 10 个索引好。建索引前先用EXPLAIN验证有没有命中,不要凭感觉堆。

6.2 EXPLAIN输出解读与优化器提示

会写SQL只是第一步,能否让SQL跑得高效,关键在看执行计划。MySQL 8.0的EXPLAIN输出比5.7又进了一步。

最基础的用法是:

EXPLAIN SELECT * FROM user_info WHERE user_name = 'zhangsan';

执行计划里最需要关注几个字段:

  • type:访问类型,从好到差大致是const、eq_ref、ref、range、index、ALL。出现ALL就是全表扫描,要重点关注。
  • key:实际使用的索引名。如果为NULL,说明没有用上索引。
  • rows:预估扫描行数,值越少越好。
  • Extra:常见有Using index(覆盖索引)、Using filesort(需要排序)、Using temporary(用到临时表)。

MySQL 8.0.18开始提供的EXPLAIN ANALYZE则完全不同,它会真正执行这条SQL,然后显示每一步的实际耗时和行数:

EXPLAIN ANALYZE SELECT * FROM user_info WHERE user_name = 'zhangsan';

输出里会带有一组类似这样的信息:

-> Index lookup on user_info using uk_user_name (user_name='zhangsan') (cost=0.35 rows=1) (actual time=0.024..0.026 rows=1 loops=1)

actual time和actual rows是真实执行的结果,比EXPLAIN的估算值可靠得多。排查慢查询时,我一般先用EXPLAIN ANALYZE看整体,再逐层定位是哪个步骤耗时最高。

MySQL 8.0还支持优化器提示,可以在SQL里直接指定优化策略,而不是修改全局配置。比如:

SELECT /*+ JOIN_ORDER(users, orders) */ * FROM users JOIN orders ON orders.user_id = users.id;

常用的提示有JOIN_ORDER指定关联顺序、INDEX指定强制走某个索引、MAX_EXECUTION_TIME限制最大执行时间。这些提示在特殊场景下很管用,但不要一遇到慢查询就加提示。先检查索引命中和统计信息,比调试提示更靠谱。优化器提示是最后的定向手段,不是默认手段。

7. 事务与并发语法:从BEGIN到隔离级别的实用细节

7.1 事务控制语句与保存点

MySQL 8.0的事务控制语句不算多,但细节很多。

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;

出错时回滚:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 发现余额不足,回滚 ROLLBACK;

保存点(SAVEPOINT)是事务内更精细的控制手段。你可以在一个长事务里设置多个保存点,出错时只回滚到某个保存点,而不是全部回滚:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; SAVEPOINT sp_after_deduct; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 发现id=2账号异常,只回滚这条更新 ROLLBACK TO SAVEPOINT sp_after_deduct; COMMIT;

这里有一个坑必须警惕:很多语句会触发隐式提交。比如DDL语句、START TRANSACTION本身之前的操作、GRANT等,一旦执行,当前事务就自动提交了。所以写了START TRANSACTION之后,事务内部尽量不要混入DDL操作,否则事务的原子性会被悄悄破坏。

事务的隔离级别由参数transaction_isolation控制。MySQL 8.0默认是REPEATABLE READ,因为它要保证主从复制的一致性和崩溃恢复安全。设置隔离级别的语法是:

SET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; -- 或者只对当前会话生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

不同隔离级别对应的现象差异很大。READ UNCOMMITTED能读到其他事务未提交的数据,基本没人会在生产环境用;READ COMMITTED解决了脏读,但在同一个事务里两次相同查询可能结果不同;REPEATABLE READ解决了幻读问题,是InnoDB的默认选择;SERIALIZABLE通过加锁把并发度压到最低,一般只在高一致性要求的极少数场景下使用。

7.2 锁相关的SQL语法

8.0里,行锁的获取通常通过SELECT语句的锁定子句实现。FOR SHARE是共享锁,多个事务可以同时加共享锁,但谁都不能改;FOR UPDATE是排他锁,事务一旦获取,其他事务连读共享锁都加不上,写操作更是要等。

-- 悲观锁的经典写法 START TRANSACTION; SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 拿到锁后做余额判断和更新 UPDATE account SET balance = balance - 100 WHERE id = 1; COMMIT;

FOR UPDATE在扣库存、领券这类并发非常高的场景下很常用,但它的代价是并发度下降。我用过一个优化思路:先UPDATE并判定影响行数,利用条件更新天然加锁,不必先SELECT再UPDATE:

UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100; -- 如果影响行数为0,说明余额不够

这个写法把检查和更新合并成一步,逻辑更简洁,也减少了持锁时间。注意UPDATE语句本身就会对匹配行加X锁,直到事务结束。

表级锁的语法是LOCK TABLES和UNLOCK TABLES。生产环境几乎没必要手动锁表,因为InnoDB的行锁已经做了很细的并发控制。手动锁表一旦忘了解锁,或者在锁表期间执行了其他表的操作,很容易造成整套业务卡死。

死锁是并发场景下绕不开的话题。死锁发生时,MySQL会回滚其中一方事务,并返回类似Deadlock found when trying to get lock的错误。规避死锁的通用手法是:多个事务按相同顺序访问资源,尽量降低单事务中的加锁行数,避免交互输入等长时间持锁。

8. 我踩过的MySQL语法坑:常见错误与规避技巧

8.1 最容易迷惑的语法点

第一个坑是NULL比较。NULL既不是等于也不是不等于,任何普通比较结果都是未知的。WHERE name = NULL永远匹配不到任何行,正确写法是WHERE name IS NULL。NOT IN遇到NULL子查询结果集时查不出数据,前文已经提过,碰到这种情况直接改NOT EXISTS。

第二个坑是ONLY_FULL_GROUP_BY。5.7时代很多老项目迁移到8.0时会突然报错,比如字段没有出现在GROUP BY里,也没用聚合函数包裹。解决办法是修改SQL,而不是关掉SQL mode,除非你能完全确认业务语义。

第三个坑是utf8mb4排序规则。8.0默认排序规则是utf8mb4_0900_ai_ci,5.7常见的是utf8mb4_general_ci。如果从5.7迁移后做表关联,两表字段的排序规则不一致可能导致索引失效或JOIN速度变慢。解决方法要么在建表时刻意指定兼容的排序规则,要么在JOIN条件里保持两侧字符集排序规则一致。

第四个坑是ALTER TABLE加列的语法位置。AFTER col_name只能用在ADD COLUMN场景,MODIFY列属性时不可以用AFTER,需要把AFTER放到MODIFY的后面。这个细节官方文档写得很清楚,但我在早期经常写错。

第五个坑是DELETE和UPDATE的别名问题。MySQL的DELETE多表删除必须让目标表紧跟在DELETE关键字后面,不然会报语法错误。同理,UPDATE多表关联时,表别名如果在UPDATE前定义,后面的SET和WHERE里都要用别名。

8.2 上线前的SQL自查清单

写多了之后,我基本固定了一套自查流程,每条SQL上线前检查这几个点:

  • WHERE条件里的字段有没有合适的索引,用EXPLAIN验证type至少是ref或range。
  • JOIN两侧字段字符集和排序规则是否一致,避免隐式转换。
  • 是否需要处理NULL,尤其使用NOT IN、WHERE != 时。
  • GROUP BY语句是否违反ONLY_FULL_GROUP_BY,SELECT列是否全部合法。
  • 大批量DML之前是否先备份,哪些操作会触发表级锁。
  • 事务范围内是否混入了隐式提交语句。

这套清单并不是什么高深理论,只是踩过坑之后的自我保护。有一次我在生产环境执行一条DELETE,想清理近一年前的日志,结果没加LIMIT也没仔细看EXPLAIN,语句跑了很久并且锁住了大量行,最终只能KILL掉重新处理。如果上线前按清单检查,至少会意识到这个表该按批次删除,而不是一把梭。

8.3 日常维护中真正值得养成的三个习惯

第一,多写窗口函数,少用变量。用户变量做排序编号在5.7里很流行,但8.0窗口函数方案清晰而且性能更好。老写法留着,新的代码优先考虑标准语法。变量在并发和某些初始化场景下行为怪,与其踩坑,不如直接换成窗口函数或CTE。

第二,把EXPLAIN ANALYZE当成慢查询排查的默认工具。它给的是真实执行数据,比估算值可靠得多。我第一次用它在一条复杂查询里定位到一个大表全扫描后,瞬间就明白了为什么这条SQL在数据量翻倍后会从1秒变成30秒。

第三,事务粒度要小。写代码的时候顺手把无关查询放在事务外面,事务里只保留必要的写操作。经验是,一个事务的执行时间越短,死锁和锁等待的概率越低。做批量更新时,按主键范围分片事务提交,既控制锁的持有时间,又容易重试。

这些习惯不复杂,但确实是我维护多个数据库服务之后慢慢沉淀下来的。语法可以快速学会,但把这些细节融进日常写法,才是从入门走向精通过程中更重要的部分。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进