最近在排查项目系统中的一个慢 SQL 时,通过引入覆盖索引,成功将一个耗时 10 秒以上的明细查询优化到了毫秒级。但在发布后却引出了一个意想不到的 Bug:原本按入库顺序(ID)展示的明细列表,顺序莫名其妙全乱了。
今天复盘一下这个非常经典,且极容易在做数据库性能优化时踩到的「连环坑」。
案发现场:让人头疼的慢查询
我们的核心业务场景是“入库单主表(主) + 入库单明细表(子)”的结构。 明细表 WE_DETAIL 数据量在千万级别。
引发慢查询的典型 SQL 逻辑如下(简化版):
SELECT id, item_sys_id, sku_sys_id, quantity
FROM we_detail
WHERE weid = 10086
AND enable_status = 1
AND item_sys_id IN (几十个长列表...)
AND sku_sys_id IN (几十个长列表...);
实际业务中会 Join 主表做状态校验,完整 SQL 如下:
SELECT d.id, d.item_sys_id, d.sku_sys_id, d.quantity
FROM we w JOIN we_detail d ON w.id = d.weid
WHERE w.status = 1
AND d.enable_status = 1
AND d.item_sys_id IN (几十个长列表...)
AND d.sku_sys_id IN (几十个长列表...);
主表 we 通常数据量小,瓶颈在明细表。原有的索引结构: 明细表上只有一个针对单据关联的单字段二级索引:weid_idx (weid)。
慢的原因: 由于有很多enable_status为0的数据,商品也很多,MySQL 通过 weid 找到当前单据的所有明细后,被迫拿着主键 ID 进行大量的回表查询,去磁盘拉取 enable_status、item_sys_id 等字段并在内存中做比对。遇到明细行数较多的“大单据”时,大量的随机 I/O 直接导致查询耗时飙升到 10s 以上。
看起来很完美的优化:覆盖索引 + 索引下推 (ICP)
为了消灭回表,决定对索引进行升级,祭出联合索引大杀器。
我们将原本单薄的 weid_idx 升级为了包含查询与过滤条件的四字段联合索引:
-- 删除旧索引
ALTER TABLE we_detail DROP INDEX weid_idx;
-- 创建新的联合索引
ALTER TABLE we_detail ADD INDEX idx_weid_en_item_sku
(weid, enable_status, item_sys_id, sku_sys_id);
优化的底层逻辑:
最左前缀极速定位:依然能通过 weid 快速锁定单据。
联合索引覆盖了所有过滤字段,weid、enable_status 能按最左前缀在索引里直接匹配;而 sku_sys_id IN (…) 这类无法参与索引范围查找的条件,由 ICP(索引下推)推到存储引擎,在扫描索引时顺带过滤,减少回表。
即使最后需要少量回表去捞取 quantity 字段,也是在极小的数据集上进行。
效果: 立竿见影,执行计划的 Extra 出现了美妙的 Using index condition,查询耗时从 10s+ 骤降到 10ms 级别。
意料之外的 Bug:数据顺序乱了
正当准备庆祝时,客户反馈入库单明细列表的顺序乱了!
经过排查,发现那些没有使用大长串 IN 列表,仅仅是根据 weid 简单查询单据明细的老业务代码,查出来的数据不再按创建时间的先后(ID)排序,而是看起来像按商品(ITEM)聚拢了。
老代码示例:
-- 历史遗留代码,没有写 ORDER BY
SELECT * FROM we_detail WHERE weid = 10086;
刨根问底:B+ 树的物理排序机制
为什么原先是按 ID 排序,加了联合索引后就变了?这要回到 InnoDB 二级索引的物理存储结构上来。
在 InnoDB 中,二级索引的叶子节点永远会附带主键 ID,且 B+ 树的数据存储是严格按照建索引时声明的字段顺序来排序的。
优化前的状态(隐式排序的温床) 当只有一个 weid_idx (weid) 时,该索引内部的实际存储结构是 (weid, id)。 所以,当老代码执行 WHERE weid = 10086 时,MySQL 顺着 B+ 树找到 weid = 10086 的节点区间。在这个区间内,由于 weid 都相同,数据在物理上自然就按照第二个字段(即主键 id)从小到大有序排列了。
老代码没有写 ORDER BY 却能得到按 ID 排序的结果,纯粹是“瞎猫碰上死耗子”,享受了单字段索引物理排列的副作用。
优化后的状态(原形毕露) 升级为联合索引后,内部结构变成了: (weid, enable_status, item_sys_id, sku_sys_id, id)
此时再去执行 WHERE weid = 10086,找到这批数据后,它们在物理上的排序规则变成了:
先按 enable_status 排序
重点:再按 item_sys_id 排序
……
最后才轮到主键 id
这就是为什么客户看到的列表顺序,从“按插入顺序”变成了“按商品聚拢”。底层 B+ 树的存储顺序变了,返回的结果集顺序自然也跟着变了。
避坑指南与解决方案
这个案例暴露了业务代码中一个普遍且致命的坏习惯:业务逻辑严重依赖了数据库的“隐式排序”。
在关系型数据库的铁律中有一条基本常识:如果 SQL 语句中没有显式指定 ORDER BY,那么数据库不保证返回结果的任何顺序。 结果集是一个无序集合(Set)。
解决方案: 既然业务逻辑要求这批明细必须按插入顺序展示,就必须在 SQL 层面显式声明。将依赖顺序的老 SQL,统统加上排序声明:
SELECT * FROM we_detail
WHERE weid = 10086
ORDER BY id ASC; -- 强制显式排序
总结
空间换时间是值得的:将单字段索引升级为覆盖查询条件的联合索引,是解决 Join 关联下复杂 IN 列表查询的最佳实践。
警惕隐式排序:永远不要依赖数据库默认的返回顺序。只要业务对顺序有要求,必须加 ORDER BY。
敬畏底层:懂一点 B+ 树的物理存储原理,不仅能让你写出跑得快的 SQL,还能在出现这种“灵异事件”时,迅速定位并给出合理的解释。