☰
MySQL后缀LIKE慢查询100倍提速:反向存储大法实战详解
2026/10/7 3:53:31 网站建设 项目流程

有段时间我被一个线上慢查询折腾到头皮发麻:订单表里要按单号尾号查记录,SQL 写的是WHERE order_no LIKE '%8866'。数据量冲到 500 万以后,这个查询稳定跑 8 秒多,每次上线大促,DBA 群里必然有人甩出这个 SQL 的执行计划截图。我当时试过加普通索引、调 optimizer 参数、改排序规则,全部无效——因为%在左边,索引根本用不上。后来把字符串反转以后单独存了一列,查询改写成本前缀匹配,同一台机器上耗时从 8 秒掉到 0.08 秒,差不多就是标题里说的 100 倍。这个“反向存储大法”特别适合处理后缀模糊匹配,我后来在新项目里也沿用成了默认方案,今天把完整的思路、落地方案和坑一次性说清楚。

1. 为什么“后缀 LIKE”会让 MySQL 索引彻底失灵

1.1 索引的“舒适区”是前缀匹配

MySQL 的 InnoDB 默认索引结构是 B+ 树,二级索引里的数据按索引键值有序排列。B+ 树能做到高效查找的关键在于“有序”:只要给出一个确定的起点,沿着叶子节点顺序扫描就能覆盖整个区间。比如普通索引(col)上执行WHERE col LIKE 'abc%',优化器能够定位到第一个abc开头的键,然后向右扫描,执行计划通常是type=range,扫描的行数只跟匹配结果集大小相关。这就像按拼音查字典:你知道要找的字读“p”,直接翻到 p 那一节就可以了,不需要从头翻到尾。

但WHERE col LIKE '%abc'是另一回事。条件里第一个有效字符是通配符%,优化器无法确定索引扫描的起点,它在 B+ 树上根本不知道应该先跳到哪个位置。既然没法缩小范围,优化器就倾向于直接放弃二级索引,退化成全表扫描——不是它笨,而是从数据分布上看,任何二级索引都无法对这种条件做裁剪,扫二级索引再回表的成本甚至可能比全表扫更高。

1.2 前导通配符让优化器只能“全表硬扫”

当条件写成LIKE '%abc',MySQL 的执行计划大概率是type=ALL,也就是全表扫描。全表扫描意味着每一行都要读出来,然后对目标字段做字符串匹配,不管表里面是 10 万行还是 1000 万行,必须一行不落。这个成本会随着表数据增长线性上升,而且很难通过加索引解决。500 万行数据、单行字段 30 字节左右,光扫描的二级缓冲页就是几十万个,再叠加行锁、IO 抖动,慢查询自然就来了。

更隐蔽的一个问题是,如果表上有多个索引,优化器在LIKE '%abc'条件下经常“随便选”一个统计信息看起来不错的普通索引做type=index扫描,本质上还是遍历整个索引。这种查询虽然名字叫“Using index”,但扫描行数依然接近全表,耗时并不会改善。我见过不少同学看到type=index以为索引生效了,结果一比对 rows 才发现和全表一样多,这就是前导通配符带来的错觉。

1.3 为什么“后缀匹配”在业务里这么高频

做过电商、支付、物流类系统的同学都有体会:用户不知道完整单号,但能记得尾号;客服查工单时也经常输入“单号后几位”;运营导报表时更是经常按末尾固定后缀筛选。这种“尾号查询”本质上是后缀匹配,和索引默认支持的前缀匹配天然冲突。手机号尾号、身份证尾号、订单号后几位,这些字段可能本身有唯一索引或主键索引,但因为查询方向不对,索引全部作废。

所以问题的核心不是“LIKE 太慢”,而是“后缀匹配无法落到 B+ 树的扫描起点上”。只要能让后缀变成一个前缀,问题就迎刃而解,这就引出了反向存储方案。

2. 反向存储大法的核心思路:把后缀问题翻个面

2.1 反转列怎么让“后缀”变成“前缀”

“反向存储大法”的做法非常直白:额外增加一列,用来存原字段反转后的字符串,然后给这一列建普通 B+ 树索引。例如原字段order_no = 'A1238866',反转后order_no_rev = '6688321A'。原本要查order_no LIKE '%8866',现在改写为order_no_rev LIKE '6688%'——因为'8866'反转后是'6688',原来在尾部的片段翻到了头部,查询条件自然变成了前缀匹配,优化器就能用上新列上的索引。

这个思路在生活中也有对应:查字典时按正常字序只能查首字,但如果有人把整本字典按倒序重新编一版,那么“查以某个字结尾的词”就会变成“查新字典里以某个字开头的词”。反向存储本质上就是给字段做了一本“倒序字典”,让原本不可索引的查询路径重新变得有序。

2.2 只适用“后缀匹配”,别贪心

在使用这个方案前,一定先确认业务查询是不是真正的“后缀匹配”:也就是需要匹配的片段必须固定出现在字符串末尾。LIKE '%abc'和LIKE 'xyz%abc'都属于后缀匹配变种,前者直接反转参数;后者反转后条件变成REVERSE('abc')%再叠加原前缀约束,仍然可以处理。

但LIKE '%abc%'这类“任意位置包含”是不能通过反向存储解决的。因为不管你反转多少次,只要通配符位于片段两端,就没有任何一侧能提供确定的前缀起点。反转后它依然是%cba%,优化器照样只能全表扫描。我见过有人把方案写成“所有 LIKE 变快”,结果线上出现LIKE '%123%'的查询还是慢得离谱,这就是没理解适用边界。遇到这种情况,别硬套反向列,老老实实用全文索引或外部检索系统。

2.3 提升 100 倍的本质:扫描量级被压缩了

很多人问:不就是加了个列吗,为什么能快 100 倍?答案不在“列”本身,而在索引扫描的起点和范围变了。改成order_no_rev LIKE '6688%'后,执行计划会变成type=range,走的索引扫描范围是“所有以 6688 开头的反转字符串”,这个范围只对应原表中尾部为 8866 的那些行。假设 1000 万订单表里匹配 1 万行,索引扫描量从 1000 万直接降到 1 万附近,差距就是三个数量级。

当然,“100 倍”不是稳定保证。如果匹配片段本身选择性很差,比如查所有尾号是“0”的单号,那反转后匹配范围依然很大,索引虽然能用,但扫描量还是高,回表成本照样吃不住。所以这个方案真正解决的问题是“让优化器有路可走”,最终提速倍数跟查询片段的选择性成正比。实际项目里,百万到千万级数据量、后缀匹配片段有足够区分度的情况下,从几秒降到几十毫秒是很常见的。

3. 实操落地:建表、同步、查询改写一个都不能少

3.1 表结构与反向列设计

第一次落地时我以为只要加一列加索引就行,结果低估了数据一致性带来的工作量。先给出最基础的表结构设计:

CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, order_no_rev VARCHAR(32) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_no (order_no), KEY idx_order_no_rev (order_no_rev) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

反向列order_no_rev的字符集、排序规则必须与原列保持完全一致。长度建议定义为原列最大长度,不要为了省空间缩短,否则一旦遇到超长字符串,写入时直接报Data too long错误。反向列不需要唯一约束,因为它只是检索冗余,不是业务数据;即使某个字符串反转后和另一个相同,也不能说明原值相同,唯一索引容易造成误判。

3.2 存量数据回填与增量同步

老表加列后,需要把历史数据一次性回填:

UPDATE t_order SET order_no_rev = REVERSE(order_no) WHERE order_no_rev = '';

但千万别一条 UPDATE 跑全表。500 万行的大表直接更新会持有大量行锁,长时间阻塞业务写请求,还可能撑爆 undo log。正确做法是按主键 ID 分批循环,比如每次取 1 万条:

-- 伪代码思路 SELECT MIN(id), MAX(id) FROM t_order WHERE order_no_rev = ''; -- 循环按 id 区间分批更新,每次 COMMIT

回填之后,要立刻验证数据完整性:对比COUNT(*)中order_no_rev != REVERSE(order_no)的行数,确保零差异。这个校验脚本在高危操作里建议保留下来,以后每次做迁移都能复用。

增量同步我首推 MySQL 的生成列,业务代码完全不用改,数据库自己在写入时维护反转值。如果项目用的是 MySQL 5.7 以上,可以直接这么加:

ALTER TABLE t_order ADD COLUMN order_no_rev VARCHAR(32) GENERATED ALWAYS AS (REVERSE(order_no)) STORED, ADD KEY idx_order_no_rev (order_no_rev);

STORED会真实占用磁盘空间,但胜在查询时可以直接走索引;如果空间紧张,也可以用VIRTUAL,虚拟列不占用额外物理存储,MySQL 也支持在虚拟列上创建索引,但要注意区分版本行为和优化器是否真的选择该索引。我自己的习惯是项目里用真实列 + 应用层同步,另一个团队用触发器,两种方式各有代价,后面会详细讲。

3.3 查询改写和执行计划验证

业务查询改写是最容易写错的一步。假设外部传入参数tail = '8866',正确写法是:

-- 错误:还是在原列上用尾部通配 SELECT * FROM t_order WHERE order_no LIKE '%8866'; -- 错误:对带 % 的参数整个反转 SELECT * FROM t_order WHERE order_no_rev LIKE REVERSE('8866%'); -- 正确:参数先反转,再拼前缀通配符 SELECT * FROM t_order WHERE order_no_rev LIKE CONCAT(REVERSE('8866'), '%'); -- 更推荐:应用层直接算好参数传入 SELECT * FROM t_order WHERE order_no_rev LIKE '6688%';

很多同学会把%也一起反转,写出来变成WHERE order_no_rev LIKE '%6688',那就又回到老路上了。如果不想在 SQL 里写REVERSE函数,最简单的是在应用层把tail反转好,拼成reverse_tail + '%'再传进查询,这样 SQL 里完全看不到函数,执行计划也更稳定。

改完之后用 EXPLAIN 验证:

EXPLAIN SELECT order_no FROM t_order WHERE order_no_rev LIKE '6688%';

理想的 type 是range,key 显示idx_order_no_rev,rows 远小于全表。如果还是ALL,检查是不是建了索引但没被识别,或者查询条件里对反向列做了函数包装。

3.4 触发器方案:能用,但要注意维护成本

如果因为历史原因不能使用生成列,触发器是另一个常见选择。需要同时创建插入和更新两个触发器:

DELIMITER // CREATE TRIGGER trg_order_no_insert BEFORE INSERT ON t_order FOR EACH ROW BEGIN SET NEW.order_no_rev = REVERSE(NEW.order_no); END// CREATE TRIGGER trg_order_no_update BEFORE UPDATE ON t_order FOR EACH ROW BEGIN SET NEW.order_no_rev = REVERSE(NEW.order_no); END// DELIMITER ;

触发器的好处是隐藏了同步逻辑,业务代码无感知;坏处是一旦有人绕过 SQL 直连更新数据、或者使用批量导入工具跳过触发器,反向列就会失真,而且很难及时发现。我建议在应用层做双写时,把反向列的赋值封装成公共方法,所有新增/更新操作都必须走同一个数据层入口,这样至少能保证逻辑可控。

4. 常见问题与避坑实录:我踩过的那些坑

4.1 大小写与排序规则不一致导致匹配对不上

这个坑我踩得最惨。有一次表是utf8mb4_0900_ai_ci(大小写不敏感),但我在建反向列时顺手指定成了utf8mb4_bin,结果原列里order_no = 'a8866'和order_no = 'A8866'在业务逻辑里是同一个,反转列却因为排序规则不同在索引里被放到了不同区域。业务侧查询时只传小写后缀,大写开头的记录就查不出来。

排查方法很简单:看两条 SQL 的结果是否一致:

SELECT COUNT(*) FROM t_order WHERE order_no LIKE '%8866'; SELECT COUNT(*) FROM t_order WHERE order_no_rev LIKE '6688%';

如果两个 count 差异明显,优先确认排序规则是否统一。反向列不是业务列,容易在建表时被忽略,建议直接复制原列的定义,不要手写。

4.2 反向列长度和截断问题

反向列长度一定要大于等于原列。有人觉得order_no是 16 位,order_no_rev定义 16 位就够,实际上原列如果允许 NULL,反转后长度不变,但如果原列是可变字符,存在定长替代字符时长度可能被扩展。我遇到过把反向列定义成 32,但原列是 64 位字符的场景,回填时直接报错,触发器里也经常出现诡异异常。加完列后先跑一遍:

SELECT MAX(CHAR_LENGTH(order_no_rev)) FROM t_order;

如果这个值等于反向列定义长度,就说明长度已经满了,建议扩列。别小看这个步骤,生产上扩列要锁表,成本很高。

4.3 到底该用LIKE 'xxx%'还是范围查询

对固定后缀查询,我更推荐范围写法,而不是LIKE。举个例子:

-- 等价的 LIKE 写法 SELECT * FROM t_order WHERE order_no_rev LIKE '6688%'; -- 范围写法 SELECT * FROM t_order WHERE order_no_rev >= '6688' AND order_no_rev < '6689';

两者的执行计划都能用上索引,但范围条件对优化器更友好,还能配合覆盖索引进一步减少回表。使用范围写法时要注意边界处理:'6688'到'6689'能覆盖所有以'6688'开头的反转字符串,不会漏掉,但如果你手动在应用层拼接字符串时把末尾字符写错,很容易产生漏数据。建议封装成同一段工具函数,避免每处代码各自拼接。

4.4 存在多个查询条件时怎么建复合索引

反向列不是银弹,一旦查询条件里还有其他过滤字段,索引设计就复杂了。例如WHERE status = 1 AND order_no LIKE '%8866',如果只给order_no_rev建单列索引,那么匹配到大量反转前缀后还要逐行回表检查status,回表过滤成本照样高。

这时候要回到复合索引的选择性问题:如果status只有 0/1 两个值,选择性极差,把status放在复合索引首位通常没有意义,就应该让order_no_rev走前缀匹配,再回表过滤status;如果status有几十种状态,选择性很好,那(status, order_no_rev)能直接把两个条件都折进索引扫描。这也是热搜词里“mysql where 条件 a and b 应该怎么建索引”的同一个道理:先统计选择性,再排列列顺序,不能只看 SQL 条件顺序。

4.5 空间与一致性成本要提前量化

反向列本质是冗余存储,代价必须提前算清楚。假如原字段平均 30 字节,加上字符集损耗,1000 万行就是 300MB 以上的额外空间,这还没算索引的空间。如果业务表本身已经很大,一个反向列加一个索引可能让单表膨胀 20%。另外,数据同步链路一旦断开,查询结果就会悄悄出错,而且这种错往往是间歇性的,特别难排查。

所以我的建议是:不是所有后缀查询都值得加反向列。只有满足“高频”“尾部片段有区分度”“存量数据结构可控”三个条件才值得做;低频分析型查询直接走全表扫描加上限流,或者丢到数仓里跑,不要在业务库上硬扛。

5. 反向存储解决不了的那类问题,怎么补位

5.1 “任意位置包含”查询别硬套反向列

LIKE '%abc%'是一个很常见的查询,但反向存储对它是完全无效的,因为反转后问题还是“两端带通配符”。遇到这类需求,先回到业务本身问一句:真的需要任意位置匹配吗?很多产品经理口中的“模糊搜索”,实际场景只需要前缀或后缀。比如手机号查人,本质是尾号匹配,属于后缀查询;商品名搜索,本质是分词匹配,属于全文检索。把需求做减法,比在数据库层面硬刚%abc%高效得多。

5.2 全文索引、外部检索和哈希方案怎么选

如果业务确实需要任意位置匹配,可选方案包括:

  • MySQL 自带FULLTEXT索引,适合大文本的自然语言分词,但对中文分词支持一般,精确到后缀这种模式也不擅长。
  • 外部检索引擎(比如 Elasticsearch)适合复杂模糊搜索,但引入组件成本高,维护链路长。
  • 如果查询目标是“某个完整字符串经过反转后等于某值”,那直接对反转列建唯一索引用=查即可,不要写LIKE。

我见过最优的折中方案是:业务库只承载前缀/后缀匹配,任意位置包含查询统一走专门的搜索组件。这样既保住了 OLTP 性能和一致性,又把复杂检索的弹性压力隔离到独立集群。

5.3 从业务需求反推查询模式才是优化前提

反向存储大法说到底只是一把专用工具。真正有价值的不是“给所有慢 LIKE 加反向列”,而是先搞清楚查询的模式:是前缀?后缀?包含?还是 token 匹配?然后再决定用普通索引、复合索引、反向列还是外部检索。我在分享这个方案时总强调一句:用 EXPLAIN 看执行计划,先确认瓶颈是不是全表扫描,再决定改什么。否则很像“手里拿着锤子,看什么都是钉子”。一个LIKE '%关键词%'的查询就算加了反向列也白搭,反而白白增加写放大和存储成本。

这个方案在实战中救过我很多次,也踩过不少坑。如果你正被后缀模糊匹配的慢查询折磨,可以先翻出最慢的几条 SQL 确认条件形态,再把反向列加进去、查询改成前缀匹配。动手之前,把排序规则、长度、同步机制、复合索引这几件事一并想好,基本就能稳定吃到索引效率的甜头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询