上周帮一个业务系统排查列表页排序运营的原话是“已处理的任务怎么全跑到后面几页去了”。打开代码一看列表查询写的是ORDER BY handled_at ASC而handled_at里有一批空值 NULL——MySQL 把它们排在了整个结果集的最前面分页一翻前几页全是还没处理过的记录。这类问题在代码评审里几乎没人提上线后却天天被业务追着问。ORDER BY 排序时把空值 NULL 放到最后本身不是什么高深技术但它牵扯到 MySQL 的比较规则、索引能不能用、分页稳不稳定以及表结构到底该不该留 NULL 这一串问题。哪怕你只写过最基本的增删改查看完也能直接拿去改手头的 SQL如果你已经在带项目里面关于执行计划和生成列的几段大概能帮你提前挡掉一次线上慢查询。1. 默认升序里NULL排最前这不是bug而是比较规则的必然1.1 MySQL把NULL当成“最小的那个值”先把结论摆在前面MySQL 在排序时把 NULL 视为比任何非 NULL 值都小。所以ORDER BY col ASC会把 NULL 全部顶到最前面ORDER BY col DESC会把 NULL 全部压到最后面。这两句话是同一件事的两面只是很多人只记住了前半句于是写出了“降序也要手动处理 NULL”这种多余甚至有害的代码。这个行为不是 MySQL 独有的怪癖它源于 NULL 的语义——NULL 表示“未知”或“不存在”不是一个具体的值。既然是未知参与大小比较时就没有确定的答案。MySQL 选择了在排序层面给 NULL 一个固定位置最小保证排序结果是确定的、可复现的而不是让 NULL 随机散布在结果集里。真正让人困惑的地方在于不同数据库对“未知值该排哪儿”的处理并不一致。如果你之前主要写 PostgreSQL 或 Oracle迁到 MySQL 之后会立刻感觉到“不对劲”因为在那两个库里升序时 NULL 默认是排在最后的。数据库ASC 时 NULL 位置DESC 时 NULL 位置是否支持 NULLS LAST 语法MySQL最前最后不支持PostgreSQL最后最前支持Oracle最后最前支持SQL Server最前最后不支持需 CASE 或 IS NULL 变通SQLite最前最后3.30 及以上支持注意这张表是凭记忆和常见文档整理的“大致行为”跨库迁移前请务必在目标库上实测三条数据NULL、正常值、空字符串不要凭印象改 SQL。这张表能解释一个很常见的现象同一个需求从 Oracle 迁过来的同事写出来的 SQL 是对的从 MySQL 转过去的人写出来的 SQL 反而错了。不是因为谁水平高低而是默认行为不同谁都没意识到这里有分歧。1.2 大小比较在NULL面前本来就失效想真正理解为什么排序要特殊处理 NULL得先接受一个事实任何跟 NULL 的比较运算结果都是 NULL三值逻辑里的 UNKNOWN既不是真也不是假。SELECT NULL NULL; -- 结果是 NULL不是 1 SELECT NULL 1; -- 结果还是 NULL SELECT NULL 0; -- 还是 NULL这意味着排序算法没法用“a 大于 b 吗”这种常规比较来决定 NULL 的位置因为回答了也是空。于是存储层必须单独记录“这个字段是不是 NULL”排序时先看这个标记再看值。InnoDB 的行记录里确实有一个 NULL 位图null bitmap每个可为 NULL 的列占一个 bit用来标记该列是否为 NULL。真正的字段数据区里NULL 列根本不占空间。这个设计带来两个副作用都是实战里会踩到的一是 NULL 列不占存储看起来“省空间”但代价是查询时要多读位图、多一次判断二是 NULL 不能像普通值那样直接参与比较任何涉及 NULL 的表达式都得走额外的分支。所以当你在 SQL 里写ORDER BY col IS NULL, col的时候MySQL 做的事其实是先算出一个 0/1 的派生值再拿这个派生值当第一排序键。这个“先算再排”的动作正是后面索引失效的根源。1.3 一个反直觉的结论降序根本不用管NULL这一条值得单独拎出来讲因为它能直接帮你删掉一半的无效代码。ORDER BY col DESC本身就已经把 NULL 放在最后了。很多人在需求文档里看到“空值放最后”不管三七二十一在所有排序语句前面都加一段col IS NULL,降序也加。结果功能是对的但多出来一段毫无必要的表达式把原本能走索引的排序变成了全量排序。举个具体的例子。假设orders表在paid_at上有索引原本这句是能靠索引直接出结果的SELECT id, order_no, paid_at FROM orders ORDER BY paid_at DESC LIMIT 20;你在前面补一段之后SELECT id, order_no, paid_at FROM orders ORDER BY paid_at IS NULL, paid_at DESC LIMIT 20;结果集完全一样因为 DESC 时 NULL 本来就在最后但执行计划从“索引反向扫描取 20 行就停”变成了“全表读一遍、算表达式、排序、再取 20 行”。数据量小的时候没感觉几十万行以上就能看到明显的响应时间差异。我的建议是把规则记成一句话只有升序需要处理 NULL降序什么都不用做。写动态排序的工具类时也按这个规则分支只在ASC分支里拼接前缀不要图省事统一拼。2. 四种把NULL压到末尾的写法以及它们各自什么时候会翻车2.1 IS NULL 前缀法通用、直观但必须带上第二排序键这是最常用的写法也是我最推荐默认采用的-- 升序NULL 排最后 SELECT id, task_name, finished_at FROM task ORDER BY finished_at IS NULL, finished_at ASC;原理很直白finished_at IS NULL对 NULL 行返回 1对非 NULL 行返回 0。升序排列时 0 在前、1 在后于是非 NULL 的行先出来NULL 的行垫底。同一组内部再按finished_at本身升序逻辑上完全符合“有值的按时间排没值的挂末尾”这个业务预期。拿三条数据验证一下就清楚了idfinished_atfinished_at IS NULL排序后的位置12024-03-05 10:00:000第 2 位2NULL1第 3 位32024-03-01 08:30:000第 1 位有一个细节必须强调ORDER BY finished_at IS NULL单独写是没有意义的它只会把结果分成“有值”和“没值”两大坨每坨内部的顺序由存储引擎随手决定翻页的时候能明显看到记录乱跳。必须跟上一个真实的排序键才算完整。另外ISNULL(col)这个函数和col IS NULL完全等价性能上也没差别纯粹看个人习惯。我倾向写IS NULL因为它更接近 SQL 标准读代码的人一眼就懂。2.2 COALESCE 哨兵法最直观坑在类型上给 NULL 换一个“足够大”的默认值让它自然排到后面这个思路对新手最友好-- 日期类型用一个足够晚的日期当哨兵 SELECT id, task_name, finished_at FROM task ORDER BY COALESCE(finished_at, 9999-12-31 23:59:59) ASC;看上去很优雅但这里藏着两个容易炸的点。第一个是哨兵值必须“足够大且合法”。日期列用9999-12-31没问题但如果你图省事写9999-99-99在严格模式下会直接报错在非严格模式下会被悄悄转成一个奇怪的值排序结果就不可控了。数值列用999999999之类的哨兵要确认业务上真的不会出现比这更大的值——我见过一个用积分排序的场景哨兵写了 1000000结果后来业务真出现了百万级积分那些“空值”反而排到了前面。第二个是类型聚合问题这个更隐蔽。COALESCE的返回类型由所有参数共同决定当参数类型不一致时会发生隐式转换-- amount 是 varchar 存的金额字符串 ORDER BY COALESCE(amount, 999999999) ASC这里一个参数是字符串列一个是整数字面量。MySQL 在做类型聚合时倾向于把结果往数值方向拉一旦某一行的amount里存的是非数字内容比如待定、--转换后变成 0这一行就会莫名其妙跑到最前面。排查这种问题特别费劲因为 SQL 看起来毫无问题数据看起来也没问题问题出在类型转换规则上。我的处理原则是可以接受用 COALESCE但两个参数必须是同一类型必要时显式 CASTORDER BY COALESCE(CAST(amount AS DECIMAL(18,2)), 999999999.99) ASC即便如此COALESCE 依然会让amount上的索引彻底失效因为它把排序键换成了一个函数结果。所以这个写法我只在小结果集、或者本来就带强过滤条件的查询里用。2.3 取负号法只对数值列成立的小聪明数值列还有一个偏门写法-- 原本想要数值升序NULL 排最后 ORDER BY -score DESC;思路是对非 NULL 值取负升序变成降序NULL 取负仍然是 NULL而 DESC 时 NULL 天然排在最后。两个效果一叠加正好是“按 score 升序、NULL 垫底”。这招确实能用但适用面窄得可怜。第一只能用于数值类型字符串和日期没法取负第二无符号列BIGINT UNSIGNED取负在 MySQL 里会走一些奇怪的转换路径结果未必是你预期的那样尤其接近类型上限的值第三索引同样用不上因为排序键变成了-score。所以我现在基本不用这个写法只在一次性排查数据、写临时 SQL 的时候图个短。2.4 拆成两段 UNION ALL能保住索引的进阶玩法如果查询带LIMIT而且数据量很大前面三种写法都会遇到同一个问题必须把整个结果集排完才能取前 N 条。这时候可以考虑把“有值”和“无值”拆成两段各自走各自的路径最后拼起来(SELECT id, task_name, finished_at FROM task WHERE finished_at IS NOT NULL ORDER BY finished_at ASC LIMIT 20) UNION ALL (SELECT id, task_name, finished_at FROM task WHERE finished_at IS NULL LIMIT 20) LIMIT 20;前半段因为过滤掉了 NULLORDER BY finished_at ASC可以直接借索引顺序扫描取够 20 行就停代价极低。后半段只需要在 NULL 行里随便取配合finished_at上的索引也能快速定位。最后的LIMIT 20保证总量正确。这个写法的代价是复杂度上去了分页参数要拆到两段里分别算偏移UNION ALL外层再套排序时容易出错而且如果业务要求“NULL 行也要按创建时间排”第二段里还得补排序键。所以我一般只用在一个特定场景——首页列表、数据量大、对响应时间敏感且业务能接受“NULL 行固定排在所有有值行之后”这个语义。2.5 顺手提一句MySQL 真的没有 NULLS LAST从别的数据库迁过来的人第一反应往往是直接写ORDER BY finished_at ASC NULLS LAST;MySQL 会给你一个语法错误报错位置就在NULLS附近。这个语法 MySQL 到现在包括 8.x都不支持别在文档里翻半天了直接用IS NULL前缀法。如果项目需要同时兼容多种数据库我习惯在 DAO 层做一个小的排序片段生成器按方言分支MySQL 走col IS NULL, colPostgreSQL 和 Oracle 走col NULLS LAST。这样上层业务代码只传“字段 方向 空值位置”三个语义参数具体怎么拼交给下层。千万别在业务代码里到处硬编码IS NULL前缀等到换库或者加缓存的时候会非常难受。3. 表达式排序击穿索引之后执行计划和耗时到底变了什么3.1 一条前缀是怎么把索引废掉的先理解索引为什么能加速排序。以KEY idx_finished (finished_at)为例这棵 B 树的叶子节点本身就是按finished_at排好序的而且 NULL 排在树的最左端。所以ORDER BY finished_at ASC只需要从最左端顺着叶子节点往后读读到够数就停压根不需要排序。ORDER BY finished_at DESC就是从最右端往回读。这就是所谓的“索引天然有序”。现在你把排序键换成finished_at IS NULL情况就变了索引里根本没有这个派生值MySQL 只能把符合条件的行全部读出来逐行计算finished_at IS NULL把结果放进排序缓冲区排完再取前 N 行。这个动作在 5.7 的 EXPLAIN 里显示为Using filesort在 8.0 里显示为Sort节点。差别有多大我拿一张百万行级别的任务表测过写法执行计划关键信息扫描行数典型耗时ORDER BY finished_at ASC LIMIT 20索引扫描无排序20个位数毫秒ORDER BY finished_at IS NULL, finished_at ASC LIMIT 20全表扫描 排序约 100 万数百毫秒到秒级注意第二行的扫描行数——虽然你只要 20 条但因为排序必须在完整结果集上做MySQL 不得不把所有符合条件的行都读出来。这就是“加了 LIMIT 也救不了”的典型场景。提示判断有没有走排序5.7 看 EXPLAIN 的 Extra 里有没有Using filesort8.0 用EXPLAIN ANALYZE看有没有Sort节点。8.0 的EXPLAIN传统格式里Using filesort字样被弱化了光看 Extra 容易漏判建议养成用EXPLAIN ANALYZE的习惯。3.2 生成列 联合索引把顺序提前“存”下来既然问题是“派生值不在索引里”最直接的解法就是把派生值变成真实存在的列再和原列组成联合索引。MySQL 5.7 起支持生成列这个方案在 5.7 和 8.0 上都能用ALTER TABLE task ADD COLUMN finished_is_null TINYINT(1) GENERATED ALWAYS AS (finished_at IS NULL) STORED, ADD INDEX idx_finished_nulls_last (finished_is_null, finished_at);然后把查询改成SELECT id, task_name, finished_at FROM task ORDER BY finished_is_null ASC, finished_at ASC LIMIT 20;这时候排序键变成了两个真实的列且顺序和联合索引的顺序完全一致先finished_is_null再finished_at索引可以直接提供有序性LIMIT也能提前终止扫描。几个实操细节值得记一下STORED和VIRTUAL都能建二级索引但VIRTUAL列不占存储、读取时计算STORED占存储、读取时直接读。排序频繁的场景我更倾向STORED因为读路径更简单行为也更好预测。加列加索引是 DDL大表上要评估锁和耗时。线上环境建议用ALGORITHMINPLACE, LOCKNONE明确要求在线执行让 MySQL 在不满足条件时直接报错而不是悄悄锁表。生成列的表达式必须和查询里的表达式逐字一致finished_at IS NULL和ISNULL(finished_at)在优化器眼里未必等价别自作聪明换写法。索引列的顺序不能反。写成(finished_at, finished_is_null)就完全没法服务这个排序需求了。3.3 8.0.13 之后的函数索引少一次改表但要验证MySQL 8.0.13 引入了函数索引functional index可以把表达式直接建到索引里不用先加生成列ALTER TABLE task ADD INDEX idx_finished_nulls_last ((finished_at IS NULL), finished_at);注意表达式部分要套一层括号这是函数索引的语法要求。查询语句和 3.2 里一样用ORDER BY finished_at IS NULL, finished_at。这个方案的诱惑力在于“不改表结构”但上线前必须做两件事一是用EXPLAIN确认真的用上了这个索引Extra 里不该出现Using filesort二是用EXPLAIN ANALYZE对比优化前后的实际耗时。我在一个项目里试过函数索引建上了但因为查询里的表达式被优化器改写成了另一个等价形式索引没命中白忙一场。后来把 SQL 里的写法调整成和索引定义完全一致才生效。另外函数索引对统计信息和优化器的要求比普通索引高极端数据分布下比如 99% 的行都是 NULL可能出现选错索引的情况。这种场景下与其纠结索引不如直接改数据模型把 3.1 里那个全表排序的代价从根上消掉。3.4 想看清排序到底花了多少代价看这几个指标调优不能靠猜。除了 EXPLAIN我常用的还有两个手段。第一个是会话级的排序统计FLUSH STATUS; -- 执行你的查询 SHOW SESSION STATUS LIKE Sort%;重点看Sort_merge_passes。这个值大于 0 说明排序数据量超过了sort_buffer_size发生了多轮归并——归并轮次越多磁盘临时文件参与越多耗时就越难看。如果这个值很大要么加大sort_buffer_size注意是每个连接一份别乱调要么就想办法消除排序。第二个是optimizer_trace能看到优化器为什么选了这条路SET optimizer_trace enabledon; -- 执行你的查询 SELECT * FROM information_schema.OPTIMIZER_TRACE\G SET optimizer_trace enabledoff;输出里的filesort_summary段会告诉你排序了多少行、用了多少内存、有没有落盘。这份信息比 EXPLAIN 详细得多排查“为什么这条 SQL 突然变慢”的时候特别好用。4. 分页、多列与类型隐式转换叠加时的连环坑4.1 分页遇上NULL重复和丢失都来了单独一条排序 SQL 很难出错但只要套上LIMIT offset, size问题就会冒出来。根源在于排序键不唯一。设想有一万条任务其中三千条finished_at是 NULL。你写了ORDER BY finished_at IS NULL, finished_at ASC LIMIT 0, 20第一页正常。翻到第二页LIMIT 20, 20的时候如果这期间有新的 NULL 行插入或者某行的finished_at被更新整个排序结果里大量并列行的相对位置就可能变化你会看到某条记录在两页里都出现或者干脆消失了。解决办法只有一个给排序补一个唯一键让顺序完全确定。ORDER BY finished_at IS NULL, finished_at ASC, id ASCid是主键加上之后任意两行的先后关系就唯一确定了分页结果稳定。这个习惯我建议无脑养成不管你排的是不是 NULL 列——只要排序键可能重复就该补主键。代价是索引可能用不上了联合索引得包含 id 才行所以更好的做法是把id也放进联合索引的最后一列。顺便提一句深分页。LIMIT 100000, 20这种写法MySQL 要先把前十万行读出来丢掉再做后续。配合上 NULL 排序导致的 filesort这个代价会被放大很多倍。能改成游标分页记住上一页最后一行的排序键值用WHERE条件往后翻就尽量改。4.2 多列排序时IS NULL 该插在哪个位置当排序涉及多列前缀放哪儿会直接改变语义这不是风格问题。假设有priority优先级和due_date截止日期两列都允许为 NULL业务要求“先按优先级升序再按截止日期升序空值都排最后”。正确写法是ORDER BY priority IS NULL, priority ASC, due_date IS NULL, due_date ASC, id ASC每一列的空值处理紧跟着它自己的排序键形成“先判断这列空不空再按这列排”的配对结构。如果你写成了这样ORDER BY priority IS NULL, due_date IS NULL, priority ASC, due_date ASC语义就变了先按“优先级是否为 NULL”分组再按“截止日期是否为 NULL”分组然后才真正排序。实际效果是“两列都有值”的行排最前“优先级有值但截止日期为空”的行紧随其后“两列为空”的行垫底跟业务要求的“各列独立处理空值”完全不是一回事。判断标准很简单前缀只影响它紧挨着的那一列。所以用“配对”的思路去写不容易出错。我见过有人为了“代码整齐”把所有IS NULL提到最前面结果排序结果和产品文档对不上排查了半天。4.3 空字符串、0、零日期冒充NULL是最常见的误判线上报“排序不对”的时候我第一件事不是改 SQL而是先确认那批数据到底是不是 NULL。SELECT SUM(col IS NULL) AS null_cnt, SUM(col ) AS empty_str_cnt, SUM(col 0) AS zero_cnt, COUNT(*) AS total FROM your_table;查出来的结果经常让人意外null_cnt是 0但empty_str_cnt有好几千。也就是说那批“看起来没填”的数据其实存的是空字符串不是 NULL。空字符串在排序里是一个正常的、比较小的值会排在所有正常字符串前面但排在 NULL 后面——所以表现和 NULL 相似处理方式却完全不同IS NULL前缀对它一点作用都没有。数值列也有同样的问题。score允许为 NULL但有些历史数据写的是 0业务上 0 分和“未评分”是两回事混在一起排序就全乱了。日期列更隐蔽早期版本可能写进了0000-00-00或1970-01-01 00:00:00当作“空”这些值在排序里是实实在在的最小日期会把 NULL 的位置挤掉。这类历史脏数据的处理没有银弹。我的做法是先在一个只读副本上把这三类值分别统计出来跟业务方确认每一类的真实语义再决定是统一刷成 NULL、还是保留原值但在 SQL 里做CASE区分。千万别在没确认语义的情况下直接批量刷数据那是在制造新的事故。4.4 动态排序拼接的两个雷列表页的排序字段和方向通常由前端传参后端拼 SQL。这里有两个雷一个是安全问题一个是逻辑问题。安全问题是字段名和方向直接拼进 SQL 字符串导致的注入风险。字段名不能走参数占位符那是给值用的所以只能白名单校验// 伪代码方向和字段都必须在白名单里 private static final SetString ALLOWED_FIELDS Set.of(finished_at, priority, created_at); private static final SetString ALLOWED_DIRS Set.of(ASC, DESC); if (!ALLOWED_FIELDS.contains(field) || !ALLOWED_DIRS.contains(dir)) { throw new IllegalArgumentException(非法的排序参数); }逻辑问题就是我们前面反复提的那件事IS NULL前缀只在ASC时需要。如果工具类里图省事不管方向一律拼上降序查询就会白白丢掉索引。正确的分支逻辑是传入方向业务要求实际拼接ASC空值最后col IS NULL, col ASCASC空值最前col ASC默认行为DESC空值最后col DESC默认行为DESC空值最前col IS NULL DESC, col DESC最后一行值得注意如果业务真的要求降序时空值在最前少见但存在比如“待办优先”这种语义那就要把前缀反过来写成col IS NULL DESC——NULL 行返回 1DESC让 1 排在前面。这时候前缀是真有必要不能省。5. 从建表阶段就把NULL这件事想清楚5.1 NOT NULL 加默认值能不能一劳永逸很多人被 NULL 排序坑过一次之后第一反应是“以后所有列都 NOT NULL给个默认值”。方向对但不能一刀切。默认值方案的核心问题是它抹掉了“未知”和“已知为零”的区别。score INT NOT NULL DEFAULT 0那这个 0 到底是“评了 0 分”还是“还没评”SQL 层面已经无法区分了。等到业务方要统计“已完成评分的用户数”你会发现这个字段根本支撑不了只能再加一个is_scored标记列反而更乱。日期列更明显。finished_at DATETIME NOT NULL DEFAULT 1970-01-01 00:00:00这个默认值会污染所有时间计算——求平均完成时长、算超期天数、做时间区间统计全都要额外排除这个哨兵值。而且哪天业务真的出现了一个 1970 年的时间数据比如导入历史档案逻辑立刻崩。我的判断标准是这样场景建议理由该字段的“空”在业务上就等于某个确定的默认值如库存默认 0NOT NULL DEFAULT 0语义一致无需区分该字段的“空”表示“尚未发生/尚未填写”允许 NULL语义真实能区分未知该字段要参与大量排序、聚合且 NULL 比例很低允许 NULL但排序走生成列方案兼顾语义和性能该字段 NULL 比例极高超过一半重新考虑模型或改用状态列索引选择性差排序代价大顺带说一句NULL在存储上确实省一点空间不占字段数据区但省下来的那点空间和它带来的查询复杂度比起来几乎不值一提。别为了省空间选 NULL。5.2 状态列 时间列比单独一个可空时间列更好用举个我们改造过的例子。原来的任务表只有finished_at DATETIME NULL判断“是否完成”靠finished_at IS NOT NULL排序也靠它。这个设计有三个问题一是每次判断都要算表达式二是这个字段既承担状态又承担时间语义混杂三是索引不好建。改造后拆成两列ALTER TABLE task ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT 0待处理 1进行中 2已完成, ADD INDEX idx_status_finished (status, finished_at);“未完成”直接查status 2“已完成”查status 2走索引选择性比finished_at IS NULL好得多——因为状态只有几个值而时间几乎唯一前者的索引更紧凑。真正需要按完成时间排序的时候在status 2的结果集里排这批量本来就小就算有排序也快。这个思路的本质是把“过滤”和“排序”拆开用低基数的状态列做过滤用高基数的时间列做排序别让一个字段同时干两件事。我在几个列表页上做过这个改造改造后连IS NULL前缀都不需要了因为查询条件里已经把 NULL 行排除掉了。5.3 统计口径被NULL带偏的六个地方排序问题往往和统计问题一起出现因为它们是同一批 NULL 数据引发的。下面这几种情况我都遇到过值得挨个确认。-- 假设 10 行数据其中 3 行 col 为 NULL SELECT COUNT(*) FROM t; -- 10 SELECT COUNT(col) FROM t; -- 7NULL 不计入 SELECT SUM(col) FROM t; -- 只累加 7 行的值 SELECT AVG(col) FROM t; -- 除以 7不是 10 SELECT COUNT(DISTINCT col) FROM t; -- NULL 不算一个独立值 SELECT col FROM t WHERE col NOT IN (SELECT col FROM t2); -- 子查询里有 NULL 时结果为空最后一条尤其阴险。NOT IN的子查询结果里只要出现一个 NULL整个条件对每一行都返回 UNKNOWN结果集直接变空。这不是排序问题但排查的时候经常一起撞上因为你在查“哪些任务没被处理过”子查询恰好查出了 NULL。还有GROUP BY所有 NULL 会被归到同一组这在多数场景下是符合预期的但如果你按某个可空字段分组做报表那一组的标签会是空前端展示时容易被当成“数据缺失”报上来。我的习惯是在写任何聚合查询之前先用 5.1 里那段SUM(col IS NULL)的语句看一眼 NULL 分布。知道有多少 NULL、集中在哪些字段后面写 SQL 时心里就有底不用等到结果对不上再回头查。最后分享一个我自己一直在用的笨办法在每个项目的测试库里固定造三条数据——一条目标字段为 NULL、一条为空字符串、一条正常值。每次改完涉及排序或聚合的 SQL先在这三条数据上看一眼结果顺序。这个习惯帮我抓出过好几次“以为是 NULL 其实是空串”的误判成本几乎为零比事后在生产库上捞数据强太多。至于那个IS NULL前缀记住一条就够升序要加降序别加加了记得配上唯一键然后拿EXPLAIN看一眼索引还在不在。