凌晨 2 点 47 分,线上又拉了一条慢查询告警:SELECT * FROM user_orders WHERE order_no LIKE '%245190'。这张 620 万行的订单表,执行计划里的 type 是 ALL,rows 直接拉满,跑了 8.9 秒才回结果。后端同学应该都有这种体验——表上明明建了好几个索引,但只要 LIKE 的通配符放在开头,这些索引就全部变成了废纸。今天要聊的“反向存储大法”,就是专门啃这类后缀匹配慢查询的:把数据倒过来存,让原本没法走索引的后缀查询变成可以走索引的前缀查询。我在 620 万行数据上实测,这条 SQL 从 8.9 秒降到 0.07 秒,量级正好是 100 倍。
这篇文章适合被 LIKE 慢查询搞到头大的后端开发、接手线上库之后反复被慢查询告警轰炸的 DBA、以及需要跟业务方解释“为什么加了索引还是慢”的人。看完你会有三个收获:B+Tree 索引为什么只认前缀;反向列怎么安全地落到 MySQL 里;以及最重要的——这个方案哪些场景救得了,哪些场景千万别硬套。
1. 同一个 LIKE,为什么有的走索引、有的全表扫?
1.1 B+Tree 索引的“字典式”前缀匹配逻辑
先把这个基础讲透,后面所有方案都是从这里推出来的。InnoDB 的 B+Tree 索引,叶子节点按索引列的值排好序,并且叶子之间用双向链表串起来。查询时从根节点做树搜索定位到第一个符合条件的叶子,然后沿着链表顺序往后扫。整个过程能高效,靠的不是字符串匹配算法有多快,而是“有序性”。
拿查字典打比方。你想找“bai”开头的字,知道 b、a、i 三个字母的位置,就能直接翻到 b-a-i 那一页,然后顺着往后翻,翻到 b-a-j 之前停。这就是 LIKE 'bai%' 能走索引的原因——它等价于一个范围区间 ['bai', 'baj'),B+Tree 天生支持这种区间扫描。
但如果你要找“以 ai 结尾”的字,字典就帮不上忙了。因为你不知道从哪一页开始翻,只能从第一页翻到最后一页,每个字都看一眼结尾是不是 ai。这就是 LIKE '%ai' 的现状——索引有序性完全用不上,只能全表扫。
1.2 三种 LIKE 写法在索引面前的真实待遇
把最常见的三种 LIKE 形态放在一张表里看,区别非常直观:
| LIKE 形态 | 能否用 B+Tree 索引 | 常见 type | 根本原因 | 典型场景 |
|---|---|---|---|---|
LIKE 'abc%' |
能 | range / index | 可确定扫描区间,本质是范围查询 | 前缀搜索、字典查询 |
LIKE '%abc' |
不能 | ALL | 扫描起点未知,必须遍历所有行 | 尾号查询、后缀搜索 |
LIKE '%abc%' |
不能 | ALL | 起点和终点都不知道 | 包含搜索、内容匹配 |
注意,LIKE 'abc%' 也不是绝对走索引。如果优化器估算返回的行数占比太高,比如超过表的 20% 到 30%,它可能觉得回表成本大于全表扫,干脆放弃索引。还有排序规则、字符集转换、条件函数包裹等因素也可能让索引失效。但至少从原理上,前缀匹配是有机会走索引的,后缀和包含匹配是完全没有机会。
1.3 最左匹配和索引下推:两个经常被误解的概念
很多人把“最左匹配”和“LIKE 前缀匹配”搞混,其实它们底层是同一个机制,只是应用层次不同。联合索引 (a, b, c) 能走索引的条件,是查询条件从最左列开始连续匹配,比如 a = ? AND b = ?、a = ? AND b > ?、甚至 a = ? AND c = ?(a 定位后 c 可以做索引条件下推过滤)。这个规则是 B+Tree 有序性决定的:只有从左往右的连续前缀,才能确定一个连续的扫描区间。
另外,MySQL 5.6 引入了索引下推(Index Condition Pushdown,ICP),很多人误以为有了 ICP,LIKE '%abc' 就能走索引了,这是我在无数文章评论区见过的经典误解。ICP 解决的是“在索引扫描过程里提前过滤”,前提是优化器已经能定位到一个索引区间。比如 WHERE a = 1 AND b LIKE '%abc',ICP 可以在 a=1 这个索引区间内提前过滤 b,减少回表次数;但裸写 WHERE b LIKE '%abc' 连区间都没有,ICP 也无从下手。
你可以在 EXPLAIN 的 Extra 列看到 Using index condition,这说明 ICP 参与工作了。但请记住:ICP 是锦上添花,不是雪中送炭,它救不了没有扫描起点的 LIKE。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 反向存储大法:把“后缀匹配”翻译成“前缀匹配”
2.1 核心思路,一句话就能讲完
正着查查不了,那就倒过来查。
举个例子。用户表 users.phone = '13812345678',业务要查“尾号 5678 的用户”,直觉写法是:
sql复制SELECT * FROM users WHERE phone LIKE '%5678';
这个写法没有任何索引可用。现在我们把 phone 反转一下,存到一个新列 phone_rev 里,值变成 '87654321831'('13812345678' 的倒序)。查询尾号 5678 时,先把查询串 5678 反转为 '8765',然后查:
sql复制SELECT * FROM users WHERE phone_rev LIKE '8765%';
这就是一个标准的前缀匹配,B+Tree 直接范围扫描,速度完全不一样。
邮箱场景更典型。查所有 @example.com 结尾的邮箱,正着写是 email LIKE '%@example.com',反着存一列 email_rev,查询变成:
sql复制SELECT * FROM users WHERE email_rev LIKE CONCAT(REVERSE('@example.com'), '%');
REVERSE('@example.com') 的结果是 'moc.elpmaxe@',所以条件就成了 email_rev LIKE 'moc.elpmaxe@%'。你只要记住这个规律:给查询串做反转,给边界补上通配符,放在反向列上做前缀匹配。
2.2 能解决的问题和不能解决的问题:这条边界必须划清
我必须先泼一盆冷水,不然你们拿这个方案去救 LIKE '%abc%',发现救不了,回来骂我。
反向存储大法是拿来救后缀匹配的,不是救包含匹配的。LIKE '%abc%' 这种中间匹配,把字符串整个反转之后,查询模式会变成 '%cba%'——通配符还是在首尾,还是没法定位扫描起点。
但现实业务里有个很有意思的现象:很多被写成 %abc% 的查询,本质上是后缀查询,是开发为了省事多写了一个 %。比如“查手机尾号 5678”,严谨的写法是 LIKE '%5678',但有人就写 LIKE '%5678%',结果一样慢。这类场景,反向存储完全可以救。
怎么判断你的业务到底是后缀还是包含?直接看查询逻辑:如果业务说的是“某某结尾”“尾号”“后缀”“末尾是”,那是后缀匹配,反向列直接上;如果业务说的是“包含”“含有”“任意位置出现”,那是包含匹配,请跳到第 5 节看全文索引方案,别在反向列上死磕。
2.3 反向列改变了什么,没有改变什么
反向列改变的只是“索引可用的前缀区间”,它没有改变数据本身的查询语义,也没有让 SQL 变聪明。反向列专门服务 LIKE 查询,它不能很自然地用于 ORDER BY、范围比较、聚合分组。
举个例子:phone_rev 是按手机号倒序排列的,你要“按手机号从小到大排序”,那还是得走原来的 phone 列上的索引,或者加 ORDER BY phone 让优化器自己选。反向列上建了索引,不代表所有查询都能沾光,它只对“反向列的前缀匹配”这一种查询模式有效。
另外,反向列是有维护成本的。要么应用层每次写入都多写一个字段,要么用数据库生成列自动维护。这一点我下一节专门讲,因为落地姿势没选对,后面全是坑。
3. 落地实操:三种把“反向列”接进索引的方式
3.1 方式A:应用层冗余列,最直观但最容易漏维护
这是最朴素的方案:建表时加一列 phone_rev,业务代码在写入时同步写一份反转值。
Java 示例差不多是这样:
java复制User user = new User();
user.setPhone("13812345678");
user.setPhoneRev(new StringBuilder(user.getPhone()).reverse().toString());
userMapper.insert(user);
如果项目只有这一个写入入口,这个方案能撑一阵子。但只要系统一复杂,批量导入脚本、定时任务、MQ 消费、数据订正工单、运营后台手工改数……只要有一个人漏掉了 phone_rev,线上就开始静默丢数据——数据明明在库里,按反向列查却查不到。
我早年就在批量导入脚本上翻过车,几十万条数据灌进去之后,尾部查询全查不到,排查了半天发现新数据 phone_rev 全是 NULL。从那以后,凡是和“冗余字段”沾边的逻辑,我都倾向让数据库来保证一致性,而不是依赖人的自觉性。
3.2 方式B:MySQL 生成列自动反转,5.7 以上推荐
MySQL 5.7 开始支持生成列(Generated Column),可以让数据库在写入时自动算出反转值,应用层完全无感。
sql复制ALTER TABLE users
ADD COLUMN phone_rev VARCHAR(20)
GENERATED ALWAYS AS (REVERSE(phone)) STORED;
ALTER TABLE users
ADD INDEX idx_phone_rev (phone_rev);
第一条语句给 users 表加了一个 phone_rev 列,它的值由 REVERSE(phone) 自动生成;第二条语句在这个生成列上建索引。注意我用的关键词是 STORED,意思是这一列真的物理存储。与之相对的 VIRTUAL 则不占用表空间,每次读取时实时计算。
那应该选哪个?我的判断是:phone_rev 是查询热点,几乎每次访问都要用它过滤,STORED 省掉每次读数据时的实时计算开销,代价是多占一点磁盘。反过来,如果你只是想在某些低频场景用一下,VIRTUAL 更省空间。在 InnoDB 里,VIRTUAL 生成列上是可以建二级索引的,索引节点会物化该列的值,但读数据时仍可能触发计算,所以高频查询我还是推荐 STORED。
加完以后,查询就固定写:
sql复制SELECT * FROM users
WHERE phone_rev LIKE CONCAT(REVERSE('5678'), '%');
3.3 方式C:MySQL 8.0 函数索引,最省事的现代方案
如果你用的 MySQL 是 8.0.13 及以上,还有更干净的做法:函数索引。它不需要你显式加冗余列,直接在原有列的反转表达式上建索引:
sql复制ALTER TABLE users
ADD INDEX idx_phone_rev ((REVERSE(phone)));
查询直接这样写:
sql复制SELECT * FROM users
WHERE REVERSE(phone) LIKE '8765%';
MySQL 会识别出查询里的 REVERSE(phone) 表达式和索引定义一致,从而走这条索引。函数索引的底层实现其实就是一个隐藏的生成列,但我在使用中最大的感受是:它大大减少了业务认知
