今天是我系统补 MySQL 的第三天,主题仍然是 SQL,但和前两天已经不一样了。第一天建库建表、导数据,第二天的 SQL-1 把增删改查和 where 过滤条件过了一遍,到了 Day3-MySQL-SQL-2 这个部分,我才真正意识到:会写 SQL 和能写好 SQL 之间,隔着一整条实践鸿沟。以前我写 SQL 只要能跑出结果就收工,排序、去重、连表都是复制旧代码改改字段名。直到上周一个线上统计报表来回改了三次,才发现自己连 ORDER BY 的 NULL 排序规则、DISTINCT 的组合逻辑、InnoDB 锁的基本行为都理解得很浅。这篇文章就是第三天的完整记录,适合刚学完基础增删改查、想往查询优化和事务方向走的人,也适合那些写过不少 SQL 但说不出"为什么这样写"的朋友。
1. 排序去重看起来简单,为什么一到业务里就出错
1.1 ORDER BY 的隐藏规则:不是你想的"按顺序输出"
排序是每个 SQL 新手都会用的功能,但业务里的坑往往藏在最基础的地方。先看最容易被忽略的 NULL 排序:在 MySQL 中,ORDER BY col ASC时 NULL 值默认排在最前面,ORDER BY col DESC时 NULL 排在最后面。很多从 Oracle 转过来的人在这里很容易翻车,因为 Oracle 的默认行为和 MySQL 正好相反。我知道这个规则是在一次数据流水核对时踩的坑:某个报表要按金额降序排,却没有过滤 NULL,结果一长串空值全堆在顶部,乍一看以为是数据重复了。
多列排序的生效逻辑也需要特别注意。ORDER BY price DESC, id ASC的含义是:先按 price 降序,只有当 price 完全相同时,才按 id 升序。换句话说,如果你只写ORDER BY price DESC,在 price 相同的那些行里,顺序完全由 MySQL 自己决定。别觉得这无所谓,分页查询时如果顺序不稳定,第二页和第一页之间就可能出现重复数据或者漏掉数据。我们在取"每个分类下价格最高的前十条"时,几乎总是会加上一个唯一字段作为最后的兜底排序,比如ORDER BY category_id, price DESC, id ASC。
还有一个关于中文排序的问题。默认字符集和排序规则下,ORDER BY name并不是按拼音排,而是按字符集对应的规则排。utf8mb4_general_ci下许多汉字排序结果看起来毫无规律。如果业务明确要求按拼音排序,可以这样写:ORDER BY CONVERT(name USING gbk)。这个技巧在导出 Excel 名单时特别管用,否则你拿到的名单顺序会和用户预期完全不一样。
1.2 DISTINCT 去重到底去的是什么
我见过不少人用SELECT DISTINCT name, age时以为是"只对 name 去重,随意取一个 age"。这是对 DISTINCT 最深的误解。DISTINCT 作用于后面所有列的组合,也就是说name和age两列拼接后的值相同才算重复,只去掉完全重复的行。如果你真的只想对某一列去重并取出其他字段,DISTINCT 解决不了,需要用 GROUP BY 或窗口函数。
DISTINCT 和 GROUP BY 的核心区别在于:GROUP BY 通常配合聚合函数使用,会把多行折叠成一行;DISTINCT 只是过滤重复行,不会聚合。举个例子,统计订单表中出现过多少个用户 ID,可以用COUNT(DISTINCT user_id),这里 DISTINCT 是在 COUNT 内部起作用,而不是写在 SELECT 后面。注意这个形式的 COUNT 不会统计 NULL 值,如果 user_id 本身允许为空,结果可能比实际用户数少。
数据清洗时经常需要"针对某一列去重,保留其中一条记录"。最稳妥的做法不是简单地 DELETE,而是利用业务唯一键找出重复组。MySQL 8.0 之后可以直接用 ROW_NUMBER 窗口函数,比如把order_no重复的记录按id正序编号,保留编号为 1 的那条,其余删除。这个思路本身比具体 SQL 更重要,后面讲窗口函数时我会再回到这个场景。
1.3 分页排序时最容易翻车的组合
分页是业务系统里逃不开的操作,LIMIT 1000, 20这种写法在小数据量时没什么感觉,一旦表里有了几十万行,翻到后面就会明显变慢。原因是 MySQL 不是"直接从第 1001 行开始读",它会把前 1000 行都扫描出来丢弃后再取后面 20 行,越往后扫描越多。
我处理深分页的常用方案有两个。第一个是延迟关联,先只查主键,再回原表关联出完整行:
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 100000, 20 ) t ON o.id = t.id;子查询里只读主键列,可以走覆盖索引,扫描代价比取所有列小很多。第二个方案是游标分页:WHERE id > ? ORDER BY id LIMIT 20,每次把上一页的最大 id 作为条件传过来。这种方案的前提是排序字段必须具备唯一性,如果你用create_time排序,而同一秒内有大量记录,还是会出现漏数据。所以实际项目中我一般会用id或者(create_time, id)复合排序来保证严格唯一。
排序和分页组合时的另一个坑是:ORDER BY 后面的字段如果没建索引,MySQL 会在内存或磁盘里做 filesort,数据量大时性能惨不忍睹。这个我会在慢 SQL 优化那节展开,但你现在可以记住一个结论:分页越深,排序字段有没有索引的差异越明显。
2. 窗口函数救了我半天时间,这三类场景直接套用
2.1 窗口函数和 GROUP BY 的本质差异
窗口函数是 MySQL 8.0 引入的一类函数,解决了一个 GROUP BY 很难优雅处理的需求:既想看到聚合结果,又不想丢失明细行。比如统计"每个用户当前的订单总金额,同时又要保留每一张订单自己的编号",用 GROUP BY 只能得到每个用户一行汇总,而窗口函数可以做到每行旁边都挂着一个用户汇总值。
SELECT order_id, user_id, amount, SUM(amount) OVER(PARTITION BY user_id) AS user_total_amount FROM orders;执行结果里每一行都有 user_id 对应的累计金额,但你依然能看到每张订单的原始信息。这类写法在做列表页附带汇总字段时非常有用,不需要再单独跑一条聚合 SQL 再用 HashMap 去匹配。
需要留意的是窗口函数的执行位置。它在 WHERE 和 GROUP BY 之后才会计算,所以你不能直接在 WHERE 里写SUM(amount) OVER(...) > 100来过滤。正确的做法是包一层子查询,在外层再过滤:
SELECT * FROM ( SELECT order_id, user_id, amount, SUM(amount) OVER(PARTITION BY user_id) AS user_total_amount FROM orders ) t WHERE t.user_total_amount > 100;关于执行顺序,我建议你只记结果:窗口函数不能在 WHERE 阶段生效。这个限制在实际开发中几乎每周都会遇到,知道原因之后就不会再纠结了。
2.2 排名统计:ROW_NUMBER、RANK、DENSE_RANK 怎么选
窗口函数里最容易混淆的是三个排名函数。我用一张成绩表来演示就特别清楚:
| 函数 | 分数序列 | 排名结果 | 适用场景 |
|---|---|---|---|
| ROW_NUMBER() | 90, 90, 80 | 1, 2, 3 | 取前 N 名,每条记录有唯一顺序 |
| RANK() | 90, 90, 80 | 1, 1, 3 | 体育比赛式排名,允许并列但会跳号 |
| DENSE_RANK() | 90, 90, 80 | 1, 1, 2 | 并列名次不跳号,适合发奖等级 |
实际业务里最常见的用法是"每个分组取最新一条"。比如每个用户取最近一笔订单,可以这样写:
SELECT * FROM ( SELECT order_id, user_id, create_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM orders ) t WHERE t.rn = 1;这个需求在没有窗口函数的 MySQL 5.7 里要写用户变量或者自连接,代码又长又容易出错。如果你还在 5.7 上,我的建议是尽快规划升级,哪怕只是到 8.0,SQL 的书写体验都会上一个台阶。当然,如果暂时不能升级,也可以用临时表配合变量实现,但可读性和维护成本都高很多。
2.3 移动平均与累计值的业务用法
移动平均在数据看板和财务对账里特别常用。比如计算每个日期最近三天的平均订单金额:
SELECT order_date, amount, AVG(amount) OVER( ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg FROM daily_sales;这里ROWS BETWEEN 2 PRECEDING AND CURRENT ROW表示从当前行往前数两行,一共三行取平均。如果不写这个窗口范围,默认是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,在有重复排序列的情况下,计算范围可能会把并列的所有行都包含进去,结果就和你想的不一样了。所以涉及移动窗口计算时,我习惯明确写出 ROWS 而不是靠记忆猜默认行为。
累计值同理,比如看每天累计销售额:
SELECT order_date, amount, SUM(amount) OVER(ORDER BY order_date) AS cumulative_amount FROM daily_sales;还可以用PARTITION BY category让累计值按分类隔离。把这类窗口函数和 GROUP BY 的汇总结果结合,可以做出很多以前要写好几条 SQL 才能完成的报表逻辑。我第三天的核心收获之一就在这:很多"看起来复杂"的统计需求,其实就是一个窗口函数的事。
3. 事务、锁和存储过程,我把它们串成了一条线
3.1 ACID 和隔离级别,事务的四个档位
事务是 MySQL 里"保证数据不被写坏"的机制。银行转账是最经典的类比:A 扣 100,B 加 100,两步必须同时成功或同时失败,否则账就对不上。ACID 是四个特性的缩写:原子性、一致性、隔离性、持久性。原子性解决"要么全做要么全不做",一致性保证约束和业务规则不被破坏,隔离性让多个事务互不干扰,持久性确保提交后数据不丢。
真正影响日常开发的是隔离级别。MySQL 默认隔离级别是 REPEATABLE READ,也就是可重复读。四个级别从低到高分别是:读未提交、读已提交、可重复读、串行化。每个级别解决的脏读、不可重复读、幻读问题可以用一张表看明白:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ(默认) | 不会 | 不会 | 可能(InnoDB 用锁解决) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
你可以用SELECT @@transaction_isolation;查看当前隔离级别,8.0 之前叫@@tx_isolation。临时改某个会话可以用SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;。我遇到过一个需要实时读取刚提交数据、对可重复读不敏感的业务,就单独给那个会话设置为读已提交,效果比全局改级别安全得多。
3.2 锁的分类:表锁、行锁、间隙锁
InnoDB 引擎的锁机制是整个事务正确性的基石。按粒度分,有表锁、行锁、间隙锁、临键锁、意向锁。行锁听起来比表锁更精细,但它依赖索引。如果 UPDATE 或 DELETE 的 WHERE 条件没有索引,InnoDB 会退化为锁住全表记录,实际效果近似表锁,并发度瞬间下降。这也是为什么我在建表时会特别检查 update 条件字段是否有索引。
间隙锁很多人听完就忘,但它直接影响 REPEATABLE READ 下幻读的解决。简单说,当你执行SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE时,InnoDB 不仅锁住已有的 10 到 20 之间的行,还会锁住这个范围里不存在的间隙,防止其他事务插入新的 id 为 15 的记录。这个机制保证了在同一个事务里跑两次同样的范围查询,查出来的结果集完全一致。
死锁是最让人头疼的问题。最常见的原因是两个事务按不同顺序更新同一批资源。比如事务 A 先更新 id=1 再更新 id=2,事务 B 先更新 id=2 再更新 id=1,两边互相等对方持有的锁,谁也前进不了。排查死锁时先执行SHOW ENGINE INNODB STATUS;,看 LATEST DETECTED DEADLOCK 部分,里面会明确写出两个事务分别持有什么锁、在等什么锁。日常规避死锁的核心经验就两条:保持事务时间短,所有事务按相同顺序访问资源。
3.3 存储过程:什么时候该用,什么时候千万别用
存储过程就是把一组 SQL 封装成一个可调用的程序。MySQL 的存储过程语法和函数不太一样,需要先修改分隔符,否则分号会被当成存储过程结束:
DELIMITER // CREATE PROCEDURE sp_get_user_orders(IN uid INT) BEGIN SELECT * FROM orders WHERE user_id = uid; END // DELIMITER ; CALL sp_get_user_orders(1);存储过程的优点是减少应用和数据库之间的网络交互,复杂逻辑可以整体放在数据库里执行。适合的场景是批量初始化数据、一次性的数据迁移、定时统计任务。但我不建议把核心业务逻辑写进存储过程,原因有三个:第一,它没法像应用代码一样做单元测试和版本管理;第二,数据库服务器的 CPU 资源通常比应用服务器更宝贵,所有计算都压到数据库上,扩容成本很高;第三,存储过程的调试体验很差,报错信息往往不够直观。我的经验是,存储过程适合做"一次性脚本"或"内部工具",而不适合做"对外服务"。
4. 慢 SQL 优化不是玄学,执行计划告诉你怎么下手
4.1 慢查询日志定位问题
很多系统不是一开始就慢,而是数据量涨到一定程度后,某几条 SQL 突然变成性能瓶颈。要找到它们,先打开慢查询日志。在 MySQL 命令行里可以动态开启:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;long_query_time单位是秒,默认 10 秒太宽松,开发环境我会设成 1 秒。日志路径可以通过SELECT @@slow_query_log_file;查看。需要注意,这种动态设置重启后会失效,如果要在生产环境长期开启,需要写进 my.cnf 配置文件的[mysqld]段。
日志文件积累一段时间后,可以用自带的mysqldumpslow工具做聚合分析。比如按平均执行时间排序,查看最耗时的前几条:
mysqldumpslow -s at /var/lib/mysql/slow.log聚合结果会把变量变成抽象形式,比如WHERE id = N,这样你看到的就是同一类 SQL 的汇总,而不是成千上万条单独的日志。拿到慢 SQL 之后不要急着改索引,先去搞懂执行计划。
4.2 EXPLAIN 执行计划到底看哪几列
EXPLAIN 是优化 SQL 最核心的工具,用法就是在任何 SELECT 语句前加一个 EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE user_id = 100;返回结果里最值得关注的几列,我整理成了一张平时检查用的表:
| 列名 | 关注点 |
|---|---|
| type | 连接类型,从好到差大致是 const、eq_ref、ref、range、index、ALL |
| key | 实际使用的索引,NULL 表示没走索引 |
| rows | 预估扫描行数,越小越好 |
| Extra | 可能出现的 Using filesort、Using temporary、Using index 等 |
看到type = ALL说明是全表扫描,通常是 first optimization target。看到Extra = Using filesort说明排序没有用到索引,MySQL 需要额外排序,当数据量大时很伤性能。看到Extra = Using temporary说明产生了临时表,常见于 GROUP BY 或 UNION 操作,同样需要警惕。需要注意的是,rows是优化器的预估值,不是精确值,但不能因为不精确就不看它,数量级的差异仍然很有参考意义。
我在排查一条慢 SQL 时,会先看 key 是否为空,再看 type 是否为 ALL,接着看 Extra 有没有 filesort 和 temporary。这三步能解决大多数索引缺失和排序问题。
4.3 索引失效的五个现场与修复
建了索引但查询还是慢,往往是索引没被用上。我遇到最多的失效原因有五个:
- 前置通配符:
WHERE name LIKE '%abc%'无法走 name 索引,改成'abc%'可以。 - 对列使用函数:
WHERE DATE(create_time) = '2025-01-01'会让 create_time 索引失效,改成范围查询create_time >= '2025-01-01' AND create_time < '2025-01-02'。 - 隐式类型转换:varchar 字段 phone 用
WHERE phone = 123查询,MySQL 会把字段转成数字比较,索引失效,写成'123'即可。 - OR 连接非索引字段:
WHERE id = 1 OR status = 2,如果 status 没有索引,整个查询可能退化为全表扫描,考虑拆成 UNION。 - 联合索引不满足最左匹配:索引
(category_id, price),查询条件是price > 100,category_id 不在条件里,用不到这个索引。
修复原则其实一句话:让查询条件里的列保持"原样",别让它被函数、运算、隐式转换包裹。比如日期范围查询改写后,不仅走了索引,还更容易利用范围扫描,效果立竿见影。
联合索引还有一个容易被忽视的规则:范围查询之后的列会失效。比如索引(a, b, c),查询WHERE a = 1 AND b > 100 AND c = 5,其中 c 的条件无法继续走索引。这种场景要么调整索引列顺序,要么把范围列放到最后。设计索引时,把等值条件放前面,范围条件放后面,是基本要求。
5. SQL 注入与日常开发防护,安全课一节不能少
5.1 注入是怎么发生的
SQL 注入听起来很像安全专家的专属话题,但它实际离普通开发者很近。核心原因是:应用在拼接 SQL 时,把用户输入的文本直接当成 SQL 语句的一部分,改变了原语句的语义。最典型的场景是登录查询、搜索关键字、列表排序字段。如果用户输入了一段特殊的文本,而你没有做任何处理,数据库看到的就不再是"查询某一条记录",而可能是"返回所有记录"甚至"删掉某张表"。
我见过不少项目在早期为了赶进度,习惯用字符串拼接的方式组装 SQL。比如String sql = "SELECT * FROM users WHERE username = '" + username + "'"。只要 username 里出现单引号或注释符,整个查询逻辑就可能被改写。这问题不是某个语言的专利,Java、Python、PHP、Go 都有类似情况,区别只是开发者在拼字符串的时候有没有意识到风险。
对于 SQL 注入,我的立场是:不要抱着"研究绕过姿势"的心态去拿生产库试。理解原理是为了写出防御代码,把所有输入都当成不可信数据,而不是为了挑战数据库的底线。在自建测试环境里验证原理可以,但任何绕过技巧都不应该出现在生产环境讨论清单里。
5.2 参数化查询为什么是底线
防御 SQL 注入最重要也最有效的手段是参数化查询。它的原理很简单:SQL 语句先以模板形式发给数据库完成预编译,用户输入作为参数单独传过去,数据库把参数当普通"值"处理,而不是 SQL 代码。这样无论用户输入里带什么特殊字符,都只是数据,不会改变语句语义。
以 Java JDBC 为例:
String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery(); }Python 里的cursor.execute("SELECT * FROM users WHERE username = ?", (username,))也是同一个道理。几乎所有主流语言和 ORM 都支持参数化查询,关键是在编码规范里明确"禁止直接拼接 SQL 字符串"。
有一个容易忽略的点:表名、字段名这类结构标识符不能用占位符参数化。比如ORDER BY ?是无法预编译的。这时候需要用白名单映射,把允许排序的字段列出来,用户只能传枚举值,绝不能直接把用户输入拼进 ORDER BY 语句。
5.3 输入校验和最小权限
参数化查询能防住大多数注入,但它不是银弹。剩下的防线是输入校验和数据库账号权限。输入校验不是简单的trim()和长度判断,而是根据字段类型做严格校验:数字就校验范围,日期就校验格式,枚举就校验取值。这样即使某处漏写了参数化查询,恶意输入也很难构造成功。
数据库账号权限是我见过很多公司忽略的点。应用连接数据库时用的账号,只应该拥有它自己业务表的最小权限,比如 SELECT、INSERT、UPDATE、DELETE,绝不应该给 DROP、ALTER 甚至 FILE 权限。一个内部后台系统只需要读写 orders 表,那就别拿拥有整个库管理权限的 root 账号去连接。这样即使应用被攻破,攻击者也拿不到文件读取或删库的权限。
另外,数据库密码、连接串配置要纳入代码管理之外的保密渠道,不要把强密码硬编码在代码里。定期检查错误日志和慢查询日志,也是一种便宜而有效的安全审计方式。
6. 今天踩过的坑和整理出来的查漏清单
6.1 排序相同时的稳定性问题
这个坑在排行榜类需求里特别常见。比如要取销量最高的前 5 条,很多人的写法是ORDER BY sales DESC LIMIT 5。当第 5 名和第 6 名销量一样时,MySQL 并不保证每次返回同一条记录。第一次查可能取到商品 A,第二次查可能取到商品 B,后端程序哪怕逻辑完全一样,接口返回也在变。
解决方法是给排序加一个全局唯一的兜底字段。通常我会写成ORDER BY sales DESC, id ASC。这样即使销量相同,也有 id 决定最终顺序,结果稳定。这个习惯对分页同样重要,前面提到的游标分页也是同一逻辑。
6.2 去重后统计翻车
统计和去重组合时,NULL 坑最隐蔽。COUNT(col)不会统计 NULL 值,COUNT(DISTINCT col)也不会统计 NULL。如果某个字段允许为空,而你要统计"出现过多少种取值",记得先明确要不要包含空值。比如统计用户填写的手机号种类,空手机号不参与去重统计通常没问题,但统计"多少个用户填写了邮箱"时,如果用COUNT(email),没填的用户就会被漏掉,而你可能希望他们也算在总数里。
数据清洗里的去重则建议不要用"先查出重复组,再逐条删"的笨办法。用窗口函数标记重复行之后再删,是最稳的:
DELETE FROM orders WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER(PARTITION BY order_no ORDER BY id) AS rn FROM orders ) t WHERE t.rn > 1 );先在内层子查询给重复的记录按 id 从小到大编号,保留 rn=1,删除其他。MySQL 不允许直接对同一表做子查询更新,所以要套两层,这个细节很容易踩。
6.3 连接查询时 NULL 过滤的陷阱
LEFT JOIN 有一个经典误用:左表是用户,右表是订单,想查所有用户以及他们的订单信息,很自然写成LEFT JOIN orders o ON u.id = o.user_id。如果这时候想在结果里只保留有过订单的用户,有人会在 WHERE 里加o.amount > 100。这个条件一加,LEFT JOIN 实际上变成了 INNER JOIN,没有订单的用户会被过滤掉,因为右表字段为 NULL,不满足条件。
正确做法是把右表条件放到 ON 子句里:
SELECT u.id, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.amount > 100;这样保留的是所有用户,左表用户的订单信息如果不符合条件,就显示 NULL。统计用户订单数量时也一样,COUNT(o.id)在 LEFT JOIN 下只统计有匹配订单的记录,而COUNT(*)会把无订单的用户也算一条,两种结果含义完全不同。
6.4 顺手记下两个小问题:SSL 连接错误和默认值
有一天我在连接测试环境 MySQL 8 时报了 SSL 连接错误,第一反应以为是 SQL 写错了,调试了半天才发现和 SQL 一点关系都没有。MySQL 客户端和服务端默认协商 SSL,当服务端证书不受客户端信任时,就会报 SSL 相关错误。开发环境临时处理可以在 JDBC 连接串里加useSSL=false&allowPublicKeyRetrieval=true,线上环境则要正确配置 CA 证书或使用 SSL 模式。这个排错经验价值在于:看到"SSL connection error"先别怀疑 SQL,把问题定位到连接层,能省掉大量时间。
另一个小问题:给已有表字段设置默认值为 0,很多人以为执行完ALTER TABLE就万事大吉。实际上修改默认值只影响之后新插入的行,已经存在的 NULL 值不会自动变成 0。如果你需要历史数据也统一为 0,必须再执行一次显式的UPDATE table SET col = 0 WHERE col IS NULL;。这类"DDL 不回头修改存量数据"的问题,在数据初始化脚本里非常常见。
这一天学下来,我最明显的感受是:SQL 性能和安全问题从来不是某一个单独技巧能解决的,而是要把排序规则、索引设计、事务隔离、权限最小化这些基础概念串起来。以后再遇到一条慢 SQL,我会先问自己:数据库看到这条语句时,是会扫全表,还是走索引?排序会不会产生临时文件?事务隔离级别会不会放大锁的竞争?当你开始用数据库的执行视角去审视 SQL,很多以前靠运气和试错解决的问题,都会变成可控的、可以被解释的结论。