线上系统报警那会儿,慢查询日志里刷出来的 SQL 长这样:WHERE order_no LIKE '%3109' ORDER BY created_at DESC LIMIT 20。表只有 310 万行,字段上也明明建了索引,可这条查询稳定跑在 2 秒以上。光看这条 SQL,大部分人的第一反应是“LIKE 就这德行,不以固定前缀开头,索引铁定失效”。这个判断没错,但要解决它,不是拆 SQL、改业务就能了事,得从存储侧想办法。最后我用的是标题里说的“反向存储大法”:多维护一列反转数据,把 %3109 这种后缀匹配改写成前缀匹配,查询直接从全表扫描变成索引范围扫描,同样条件下耗时降了两个数量级。这篇文章就把这个方案的原理、落地写法和使用边界完整拆一遍。
1. 线上这条慢 SQL,到底慢在哪个环节
1.1 业务场景和慢 SQL 长什么样
业务上有个搜索入口,用户只记得订单号的后几位。比如客户打电话来:“帮我查一下尾号 3109 的订单。”后端代码里很自然就会写成:
sql复制SELECT *
FROM trade_order
WHERE order_no LIKE '%3109'
ORDER BY created_at DESC
LIMIT 20;
这张 trade_order 表有 310 万行,order_no 上有一个普通二级索引。表结构大概是这样:
sql复制CREATE TABLE trade_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_order_no (order_no)
) ENGINE=InnoDB;
在 30 万行的时候这条 SQL 可能没什么体感,但数据一过 300 万,赶上晚高峰,慢查询立刻变成告警。我拉出执行计划:
text复制id | select_type | table | type | possible_keys | key | ref | rows | filtered | Extra
1 | SIMPLE | trade_order| ALL | NULL | NULL | NULL| 3100000 | 10.00 | Using where; Using filesort
注意 type=ALL,这就是全表扫描。310 万行并不是全部都要回表后返回,但因为要按 created_at DESC 取最新的 20 条,MySQL 没法提前终止,必须把所有符合 LIKE '%3109' 的行都找出来做排序。这个成本在业务高峰期会被无限放大。
1.2 明明有索引,为什么 LIKE 带前导通配符就不走了
很多人说“LIKE 以 % 开头索引失效”,这句话对,但知其然不够,还得知道索引为什么没用。InnoDB 的索引结构是 B+Tree,叶子节点按索引列的值排好序。字符串的排序规则是一路从左到右比较字符的:先比第一个字符,再比第二个,以此类推。索引能快速定位,是因为查询条件描述了一个“确定的前缀区间”,比如 LIKE '3109%' 等价于在索引树上找一个区间:
text复制order_no >= '3109' AND order_no < '3110'
数据库只需要在 B+Tree 上从根节点一路往下,命中 3109 这个起点,然后按链表顺序扫到第一个超过 3110 的节点停住即可。
但 LIKE '%3109' 没有确定起点。索引树是按照完整字符串排序的,所有以任意前缀开头、只要结尾是 3109 的行都可能落在索引的任何位置。优化器很清楚:就算强制走 idx_order_no,也只能从头到尾把索引叶子节点都过一遍,然后每条还要回表拿 created_at、user_id 等字段,成本不会比全表扫描低,所以干脆 type=ALL。
用查字典打个比方:正向索引等于按姓排序的电话簿,你想查“姓张的人”,翻到 Z 开头那一段就行。可现在你要查的是“名字最后一个字是‘伟’的人”,按姓排序的电话簿完全帮不上忙,只能从第一页翻到最后一页。
1.3 慢的本质:磁盘随机访问与无效的全量遍历
慢查询最怕的还不是 CPU,而是随机 I/O。310 万行全表扫,读取的数据页一大堆,其中绝大多数行根本不符合条件;符合条件后还要回主键索引,而主键索引里的行数据分散在不同页,每一次回表都可能是一次磁盘随机读。
有人可能说:“我 LIMIT 20,是不是扫到 20 条就能停了?”不一定。这里带 ORDER BY created_at DESC,数据库不知道当前扫到的这 20 条是不是最终结果里最新的 20 条,所以必须把所有匹配行都收集齐,排完序才能返回。于是查询延迟被放大到秒级,且随着表增长线性恶化。
所以问题的关键不是“SQL 写法本身有什么语法问题”,而是:数据在物理存储上的顺序,和查询需要的匹配顺序不一致。只要我们能改变数据在索引里的排列方式,让 B+Tree 按“尾号”来排序,就有救。反向存储正是这么做的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 反向存储为什么能反转结局:一个例子说清匹配逻辑
2.1 关键在把后缀条件翻到前面
“反向存储”在概念上极其简单:给表增加一列 order_no_rev,写入时把 order_no 的每个字符反转后存进去。以 order_no = 'A00188239013109' 为例,反转后得到:
text复制order_no_rev = REVERSE('A00188239013109') = '90131093288100A'
如果用户想查尾号 3109,原始写法是:
sql复制WHERE order_no LIKE '%3109'
换成反转列之后,查询变成:
sql复制WHERE order_no_rev LIKE CONCAT(REVERSE('3109'), '%')
REVERSE('3109') = '9013',所以实际条件是:
sql复制WHERE order_no_rev LIKE '9013%'
看到没有?原来在正向字符串上搜的是后缀,反转之后,在反向列上搜的变成了前缀。也就是说,我们把一个“最左侧不确定”的模糊查询,转换成了一个“最左侧确定”的前缀查询。B+Tree 最擅长处理的就是这种前缀查询。
2.2 改成前缀之后,InnoDB 是怎么走索引的
给 order_no_rev 建完索引后,WHERE order_no_rev LIKE '9013%' 会被优化器翻译成类似区间的扫描条件:
text复制order_no_rev >= '9013' AND order_no_rev < '9014'
这里不是数值比较,但在 ASCII/一般排序规则下,任何以 9013 开头的字符串都满足这个范围。优化器会从 B+Tree 根节点二分定位到 9013 附近,然后顺着叶子节点链表顺序扫描,扫到 9014 边界为止。要扫描的叶子节点数量,只和“反向列里以 9013 为前缀的记录数”有关,和全表 310 万行没有直接关系。
如果需求命中率比较低,比如整个表只有几百条尾号是 3109 的订单,那索引范围内可能只需要看几个数据页,回表次数也压缩到几百次以内。原来那种全表扫描的“大量无效遍历 + 大量随机回表”全部消失。
2.3 为什么性能差距能到一个数量级以上
很多人以为“加了索引就一定能快 100 倍”,其实不是,这里本质上是扫描代价的变化。
原始查询的全表扫描,需要读取主键索引的所有叶子页,310 万行数据光扫描就是几 GB 甚至更多。而反转列方案下,二级索引 idx_order_no_rev 本身是按反转字符串排序的,那部分匹配行在叶子节点上相对集中,读取的数据页量级从“全表所有页”降到“几十个页”。I/O 数量差了不止两个数量级,再叠加回表次数大幅减少,耗时从秒级降到毫秒级就很自然了。
标题里的“100 倍”不是固定的理论值,它取决于三个变量:
- 表的总数据量越大,索引命中的访存越局部,收益越大。
- 匹配行的比例越低,全表扫描的浪费越严重,收益越大。
- 如果尾号本身区分度很差,比如表中一半数据都以同一个尾巴结尾,那么反转列走索引也是扫一大段,收益会大幅缩水。
这一点在后面“边界”部分我会再展开。
3. 实战落地:冗余列、生成列、函数索引三种实现怎么选
方案听起来不难,但生产落地有一堆讲究。你不能只改查询,还得保证反转列在写入时永远正确。我实际用过的方案有三种,适用版本和风险各不相同。
3.1 应用层维护反列:改起来最快,隐患也最大
最朴素的做法是在表里增加一个普通字段 order_no_rev,然后业务代码在 insert 时自行赋值 REVERSE(order_no)。更新时同样要带着赋值。查询时用 order_no_rev LIKE ...。
这种方案的问题很明显:应用的写入链路可能不止一处。订单服务写、管理后台写、定时任务导入,只要有一个地方漏写,反转列就是脏数据。查询结果就会少数据或者查不到。线上查错订单这种事,一次就够呛。
所以除非是临时验证思路、或者这个表只有唯一写入入口,否则我不建议应用层手动维护反列。它不是技术难度问题,是很容易埋雷。
