先交代一个背景:上周团队处理一批客户上传的商品数据时,发现一个诡异现象——用LIKE '%50%'去查折扣率刚好是 50% 的商品,结果把 5% 的、150% 的、甚至以“50”结尾的 SKU 全部捞了上来。更离谱的是,当数据里出现反斜杠时,同样的 SQL 在测试环境跑得好好的,一上生产就查不到东西。排查到半夜才发现,问题全出在“字段里的转义符”和LIKE通配符打架。这篇文章就把这类“字段中含有转义符涉及模糊匹配查询”的问题彻底讲透。
这类问题的核心,是数据库里的数据本身带有反斜杠、百分号、下划线这些特殊字符,而模糊匹配查询(LIKE)又恰好把这些字符当成了通配符。两边一冲突,查询结果就完全不可控。本文适合所有写过 SQL 的后端开发、数据分析师和 DBA,尤其是踩过“明明加了%却查不到”“没加条件却查出来一堆”这种坑的人。
1. 问题现象:一次返工到凌晨的模糊查询事故
1.1 事故现场复盘:查询结果为什么会“超纲”
先还原当时的具体场景。商品表product里有一个discount_rule字段,存储的是优惠规则的原始文本,比如:
满300减50 折扣率:50% 折扣率:5% 原价:150元我用了一条最简单的模糊查询去统计所有“折扣率是 50%”的商品:
SELECT id, discount_rule FROM product WHERE discount_rule LIKE '%50%';结果是 347 行。验数的时候发现里面有大量误伤:折扣率 5% 的商品被打包进来了,因为“5%”里的%是通配符,5%可以匹配“5”后面跟任意字符;折扣率 150% 的商品也进来了,因为“150”里包含了子串“50”。更隐蔽的问题是,如果字段里出现路径字符串(比如C:\temp\50%off),%off会被当成%匹配任意字符再加off,整个查询逻辑就乱成了筛子。
这就是“字段中含有转义符”和“模糊匹配”叠加时的典型症状:结果集要么“超纲”,要么“漏查”。而且很多情况下,同样的 SQL 在开发库跑得好好的,到了生产库因为sql_mode或者数据库版本差异,行为还会变。
1.2 转义符在查询链路里的三个身份
要理解这个问题,得先搞清楚一个字符在查询过程中会经历哪些层级的解释。我把它拆成三层:
- 数据层的原始字面量:数据库里真实存储的字符,比如一个反斜杠
\、一个百分号%、一个下划线_,在数据里就是普通的字符,没有特殊含义。 - SQL 解析层的字符串转义:你写在 SQL 里的字符串常量,比如
'50\%',解析器先处理反斜杠,把它当成转义标记。在 MySQL 默认模式下,'50\%'会被解析成一个“反斜杠 + 百分号”的字面量还是“百分号”,取决于上下文。 - LIKE 运算符的通配符语义:进入
LIKE匹配阶段后,%和_才会被赋予通配符含义。这时,前面的转义符才派上用场,用来告诉引擎“我后面的%和_是普通字符,不是通配符”。
大多数人踩坑,是因为只考虑了其中某一层。比如写LIKE '%50%%'想去匹配“折扣率:50%”,结果后面那个%又被当成通配符了;又比如写LIKE '%50\%',在 MySQL 里看着对了,代码一迁移到 PostgreSQL 又废了,因为不同数据库对字符串转义的处理逻辑不同。
1.3 通配符“泛滥”的业务场景比想象中多
别以为只有极端脏数据才会有这种问题。我梳理了下,至少这几类常见业务场景会踩雷:
- 用户输入的搜索关键词:用户搜索
50%、C++、100_200之类的关键词,直接把用户输入拼进LIKE,百分号和下划线就会变成通配符。 - 存储路径和 URL:Windows 路径
C:\Program Files\、URL 参数?rate=50%、对象存储 key,这些字符串天然带着反斜杠和百分号。 - 折扣、百分比、占比类数值的格式化文本:
优惠50%、完成率80%,百分号在业务文本里是合法字符,在LIKE里却是通配符。 - 文件名和序列号:
2024_report_001.xlsx、SKU_50_BLACK,下划线在_是单字符通配符,匹配别的字符会造成大量误报。
只要做的是搜索、筛选、数据清洗,就离不开通配符与转义符的正确处理。它不是偶发问题,而是高频雷区。
2. 根因拆解:LIKE 的通配符机制和转义规则的碰撞
2.1 %、_、\ 三兄弟在 LIKE 里的真实语义
先讲基础,把LIKE的通配符机制彻底讲清楚。在几乎所有关系型数据库里,LIKE支持两类通配符:
%(百分号):匹配任意数量的字符,包括零个字符。_(下划线):匹配恰好一个字符。
这两条规则意味着:LIKE '%50%'的含义是“包含子串 50”的任何字符串,其中50前后可以跟任意内容;而LIKE '50_'则是精确匹配“以 50 开头、后面跟且只跟一个字符”的字符串,比如502、50A都命中,5010就不命中。
第三个角色是转义符。SQL 标准提供了一个ESCAPE子句,用来指定一个转义字符,让它后面的通配符被还原为普通字面量。比如:
SELECT * FROM product WHERE discount_rule LIKE '%50!%%' ESCAPE '!';这里的!是转义符,!%表示字面量的百分号。这条 SQL 就能匹配“折扣率:50%”,因为第一个%(在50前)是通配符,!%被当成普通字符,最后一个%是通配符。
这里有个关键点:ESCAPE从句指定的转义符只作用于LIKE匹配阶段,不影响 SQL 字符串本身的解析。而很多人更熟悉的反斜杠转义,是另一个层面的东西。
2.2 反斜杠转义的“两层皮”:别把两件事混为一谈
反斜杠在 SQL 里有“两层皮”,这是我见过最多人犯迷糊的地方。
第一层,字符串转义。在 MySQL 的默认行为里,\是字符串字面量的转义符。也就是说:
SELECT '50\%';实际上返回的是50\%还是50%?答案是50%,因为\%被字符串解析器当成一个普通的%字符输出了。这意味着你在 SQL 里写LIKE '%50\%%',走到 LIKE 运算时,已经变成LIKE '%50%%'——后面两个%全成了通配符,根本表达不了“查找含 50% 文本”的意图。
第二层,LIKE 通配符转义。MySQL 在默认模式下,LIKE的默认转义符也是反斜杠。所以:
SELECT * FROM product WHERE discount_rule LIKE '%50\%%';能生效的前提,是 SQL 字符串里真的包含“反斜杠 + 百分号”这两个字符,让 LIKE 引擎把\%识别成“普通百分号”。但前面第一层的解析往往已经提前把\%合并成了%,于是两层逻辑互相干扰,最终行为变得极其脆弱。
这就是为什么我强烈不建议在模糊匹配里依赖反斜杠转义:不同数据库对字符串层的处理不一致,MySQL 的NO_BACKSLASH_ESCAPES模式一改,原先能跑的 SQL 立刻失效;PostgreSQL 里standard_conforming_strings的设置也会改变\的含义;Oracle 则干脆用ESCAPE子句来显式指定转义符,默认根本没有反斜杠转义。把逻辑押在一个“各数据库行为不统一”的字符上,迟早要出事。
2.3 为什么参数化查询救不了这个场
很多同学会说:我不是都用了预编译和参数化查询吗?为什么还是出了这种问题?
这个必须澄清:参数化查询解决的是 SQL 注入,以及 SQL 字符串拼接带来的语法错误。参数化之后,用户输入的50%确实会作为参数高效传递进去,不会破坏 SQL 语法结构。但数据库收到参数之后,执行到LIKE算子时,通配符语义依然生效——50%中的%不会因为是参数就变成普通字符。
试一个经典案例。用户搜索输入50%,你写了 Django ORM:
Product.objects.filter(discount_rule__contains=user_input)你期待的是“包含字面量50%”,但实际 SQL 生成的是LIKE '%50%%',最终匹配了所有含 50 的字符串。参数化帮我挡住了 SQL 注入,却没有挡住通配符的语义问题。
所以,转义符处理是独立的一层工作,必须显式做:要么在传入参数前把%、_、\进行转义预处理,要么在 SQL 里用ESCAPE子句指定转义字符。两者结合,才能得到一个真正严谨的模糊查询。
3. 实操解法:各语言携带 ESCAPE 的规范化实现
3.1 MySQL 场景:显式 ESCAPE 才是跨模式的正解
MySQL 默认将反斜杠作为 LIKE 的转义符,但这个行为不靠谱,因为它是可配置的。最稳妥的手法,是使用一个业务侧的占位符作为 ESCAPE 字符,然后对所有需要按字面量匹配的特殊字符做统一处理。
先看标准语法:
SELECT * FROM product WHERE discount_rule LIKE '%50!%%' ESCAPE '!';这里!就是自定义的 ESCAPE 字符,!%代表一个字面量百分号,最后的%是通配符。注意 ESCAPE 子句只能指定单个字符,不能是字符串。选!还是\或者别的字符,原则是“尽量选业务数据里极少出现的字符”,减少把正常业务字符误伤成转义符的概率。
写成生产环境可复用的完整场景,应该是这样。用户输入的搜索关键词,先做转义函数处理:
def escape_like(keyword: str) -> str: return ( keyword .replace('!', '!!') # 先转义转义符本身 .replace('%', '!%') # 再转义百分号 .replace('_', '!_') # 最后转义下划线 )然后在 SQL 里:
SELECT * FROM product WHERE discount_rule LIKE CONCAT('%', #{escaped_keyword}, '%') ESCAPE '!';这个函数里有严格的“先转义转义符本身”的顺序,原因是如果先转义%,再转义!,那么!%里的!会被二次处理,破坏原有转义结果。顺序错了,你处理完的关键词可能完全失效。
3.2 PostgreSQL 与 SQL Server:默认转义符并不相同
PostgreSQL 的默认行为跟 MySQL 差别很大。PostgreSQL 的 LIKE 默认没有转义符,所以LIKE '%50\%%'在这里不会按\%来理解,它会原封不动地把\%当成一个反斜杠跟一个百分号去匹配。这意味着,如果你要把 MySQL 的代码迁移到 PostgreSQL,之前的“默认转义”逻辑会全部失效。
PostgreSQL 里推荐的做法依然是显式 ESCAPE:
SELECT * FROM product WHERE discount_rule LIKE '%50!%%' ESCAPE '!';同样要配escape_like函数做前置处理。不过 PostgreSQL 提供了更现代的替代方案,如果你不是非用 LIKE 不可,可以试试正则表达式和POSITION函数。比如:
SELECT * FROM product WHERE discount_rule LIKE '%' || escape_like('50%') || '%' ESCAPE '!';或者用strpos做纯子串匹配,它不带通配符语义:
SELECT * FROM product WHERE strpos(discount_rule, '50%') > 0;这样一点转义符都不用处理,其实是更省心的路子。
SQL Server 又有一套玩法。它支持ESCAPE子句,但特别的是,可以用方括号[]来包裹特殊字符,表示“匹配这个字面量字符本身”。比如:
SELECT * FROM product WHERE discount_rule LIKE '%50[%%]' ESCAPE '!';这里[%]表示匹配字面量的百分号。当然,方括号这种语法是 SQL Server 的方言,不通用。如果目标是跨数据库兼容,优先选择标准的ESCAPE子句。
3.3 Django ORM、Java MyBatis 与 Node.js 中的落地实践
套用到具体框架,操作方法有两类:
第一类:尽量用“包含查询”代替 LIKE 通配查询,避免手动转义。
Django ORM 里:
from django.db.models.functions import StrPos # 最推荐的做法:只要是判断子串,就直接用 strpos rows = Product.objects.annotate( pos=StrPos('discount_rule', search_text) ).filter(pos__gt=0)StrPos是 PostgreSQL 专有函数,底层就是strpos,完全绕开 LIKE。如果必须在 MySQL 里做等价操作,可以用LOCATE函数作为注解来过滤,但 Django 对 MySQL 的LOCATE封装并不统一,我更建议用原生态 SQL 处理。
第二类:预处理参数,然后用 ORM 的 contains 方法。
Django ORM 的__contains对应 SQL 的LIKE '%keyword%',不会自动帮你转义特殊字符,所以必须在传入之前处理:
def escape_like(keyword: str) -> str: for char in ['\\', '%', '_']: keyword = keyword.replace(char, f'\\{char}') return keyword keyword = escape_like(user_input) rows = Product.objects.filter(discount_rule__contains=keyword)这里有一个细节:Django 在 MySQL 后端下,__contains生成的 SQL 默认会把反斜杠作为转义符;但如果你切换到了 SQLite 或者其他数据库,行为可能完全不同。所以最稳的方案,还是避开contains,用数据库提供的“纯子串函数”。
Java MyBatis 的场景也差不多。Mapper XML 里这种写法非常常见:
<select id="search" resultType="Product"> SELECT * FROM product WHERE discount_rule LIKE CONCAT('%', #{keyword}, '%') </select>但#{keyword}传入的任何%和_都依然是通配符。正确做法是在 Java 服务层预先调用转义工具方法:
public static String escapeLike(String keyword) { return keyword .replace("!", "!!") .replace("%", "!%") .replace("_", "!_"); }XML 里写成:
SELECT * FROM product WHERE discount_rule LIKE CONCAT('%', #{escapedKeyword}, '%') ESCAPE '!'Node.js 配合 mysql2 或者 Knex 时同理,取到用户输入后先做字符串替换,再用参数化查询绑入,SQL 里同样加ESCAPE '!'。
3.4 Oracle 的 CLOB 字段特殊处理
如果字段类型是 Oracle 的CLOB,直接LIKE是没法用的。Oracle 的LIKE不能作用在 CLOB 上,会直接报ORA-00932: inconsistent datatypes。这时候要先用DBMS_LOB.INSTR或DBMS_LOB.SUBSTR做预处理。
转义符的坑在 CLOB 场景里同样存在,而且更隐蔽。因为 CLOB 不支持LIKE,所以很多同学会写:
WHERE DBMS_LOB.INSTR(discount_rule, '50%') > 0INSTR是纯字面量查找,没有通配符语义,这样反而天然规避了 percent 通配符的问题。但如果商品文本里既有50%又有下降档位5%末尾加了个字符,实际业务仍需要模糊语义时,就得先把 CLOB 切片出来再匹配,例如:
WHERE DBMS_LOB.SUBSTR(discount_rule, 4000, 1) LIKE '%50!%%' ESCAPE '!'这里SUBSTR会把 CLOB 转为 VARCHAR2 再处理,注意 CLOB 截断上限是 4000 字节,超长会被截断,匹配结果可能会有遗漏,所以要结合DBMS_LOB.INSTR做两段式判断。
4. 索引选择、数据清洗与查询正确性的通盘校验
4.1 别再天真地对 LIKE 前缀查询建索引
谈完转义语法,还得说一个常常被忽略的问题:模糊查询的性能。LIKE '%keyword%'这种双百分号的写法,本身就是索引杀手。就算你处理对了转义符,查询也对不了全表扫描的命运。
如果业务真的需要高频搜索文本字段,通常要考虑三类方案:
- 方案一:利用覆盖索引 + 前缀匹配。只有
LIKE 'keyword%'(右模糊)才能用上普通索引,左模糊和双模糊都走不了。这要求业务能接受“只按前缀匹配”。 - 方案二:用全文索引/全文检索。MySQL 的
FULLTEXT、PostgreSQL 的tsvector、Elasticsearch,都是更适合大文本搜索的载体。通配符和转义符的问题在这些系统里语义完全不同。 - 方案三:数据清洗 + 精确匹配。把需要搜索的字段拆出来,单独存一个“标准化字段”,让查询走精确匹配或者前缀匹配,绕开
%通配符。
如果你的查询模式注定是双模糊,那性能这块基本无解,得从架构上引入检索组件。
4.2 脏数据里的反斜杠和转义符怎么清洗
有时候问题出在数据写入端。比如 CSV 导入时,50%被导成了50\%;Windows 路径被存成C:\\Users\\name,字段里有两个反斜杠;跨系统同步时,JSON 转义符\"被原样存进了字段。这些数据如果不清理,转义符问题会反复出现。
清洗的思路分两步:
先做“字段体检”,找出哪些行包含可疑特殊字符:
-- 找出含有反斜杠、百分号、下划线的记录 SELECT id, discount_rule FROM product WHERE discount_rule LIKE '%\\%%' ESCAPE '\\' OR discount_rule LIKE '%\\_%' ESCAPE '\\' OR discount_rule LIKE '%\\\\%' ESCAPE '\\';这里每一行的 ESCAPE 用法很讲究,比如第一个条件想找“含百分号的记录”,用%\\%%这个模式的意思是:首尾两个%是通配符,中间\\%是转义后的字面量百分号。
体检之后再做批量 UPDATE。清洗时要注意“一次性修复根因”,别只修表象。比如如果是导入程序写坏了,哪怕你手动 UPDATE 清一遍,下次导入还会再脏。我通常的做法是写个幂等清洗脚本,在数据写入的 pipeline 里统一执行:把\\%还原为%、把\\_还原为_、把双反斜杠还原为单反斜杠。
不过,清洗脚本本身要小心:如果业务里真的存在“反斜杠 + 百分号”这种合法连续字符,一刀切地替换会破坏业务数据。所以清洗前一定要先取样确认,最好把清洗规则放在测试环境跑一遍,核对变更后的数据字段语义是否仍然正确。
4.3 排查模糊查询问题的速查表
把实战中常见的症状、原因和修复方法整理成一张表,直接对照使用:
| 症状 | 可能原因 | 排查与修复 |
|---|---|---|
查%50%返回了5%相关数据 | 用户输入中的%被当成通配符 | 先用escape_like处理输入,再LIKE ... ESCAPE '!' |
数据里是50%但怎么都查不到 | 字符串层的\%被提前解析成了%,或 LIKE 层转义失败 | 不要依赖反斜杠默认转义,改用显式ESCAPE子句 |
| 查询包含下划线,结果异常 | _被当成单字符通配符 | 转义_或换用INSTR/strpos纯字面量匹配 |
| 迁移数据库后查询失效 | 不同数据库对默认转义符行为不一致 | 全面改用ESCAPE '!'显式语法 |
数据字段含\导致匹配错乱 | 反斜杠在字符串层或 LIKE 层被特殊处理 | 清洗字段,或者统一用自定义转义符 |
| 查询慢,全表扫描 | 双百分号 LIKE 无法走索引 | 改用全文检索或前缀匹配方案 |
| CLOB 字段无法 LIKE | Oracle 不支持 CLOB 直接用 LIKE | 用DBMS_LOB.INSTR或SUBSTR预处理 |
这张表基本覆盖了我这些年见过的模糊查询转义类问题,适合直接贴到团队文档里当 FAQ。
4.4 一个典型修复案例的完整还原
最后用文章开头那个“折扣率 50%”的案例,完整展示修复过程。
原始查询:
SELECT id, discount_rule FROM product WHERE discount_rule LIKE '%50%';修复后的查询:
user_input = "50%" escaped = escape_like(user_input) # 结果为 "50!%"SELECT id, discount_rule FROM product WHERE discount_rule LIKE CONCAT('%', '50!%', '%') ESCAPE '!';执行流程拆解:
- 先查
%通配符会把50!%前后的任意内容兜住; - 中间
50是普通匹配; !%被 ESCAPE 子句识别为普通百分号字面量;- 结果集只包含“折扣率:50%”“优惠50%封顶”这类真正含字面量
50%的记录; - 而
5%、150%这些不再误入。
为了交叉验证,我习惯再跑一条对照组:
SELECT id, discount_rule FROM product WHERE discount_rule LIKE '%50!%%' ESCAPE '!';和修复后结果对比,如果两条 SQL 的结果集不一致,说明还有转义逻辑遗漏的行,需要逐行核对字段里的特殊字符分布。这步虽然有点笨,但确实能兜住边界情况。
5. 个人经验与最后的避坑建议
处理了这么多轮转义符和模糊查询的问题,我的核心体会是:别跟数据库的默认行为较劲,直接跟它把规则定死。不管是 MySQL、PostgreSQL、Oracle 还是 SQL Server,一律用显式的ESCAPE '!',在前置参数处理里把%、_、!按固定顺序转义。这套规则写成一个公共函数,所有项目复用。
再给大家三个实操层面的建议:
第一个建议:能不用 LIKE 就别用 LIKE。如果业务目标是判断“字段里包含某个字符串”,优先用INSTR、LOCATE、strpos这类纯子串函数。它们没有通配符语义,根本不涉及转义,代码更简单,语义更清晰。我自己后来很多搜索逻辑都改成了这种模式。
第二个建议:写一个统一的转义工具函数,配上完整单元测试。这个函数要针对%、_、!(或者其他自定义转义符)做处理,并测试边界值:空字符串、全是特殊字符的串、只有单个%、%_连续出现等情况。只要这个函数测试覆盖到位,上层所有查询都安全。
第三个建议:每次排查模糊查询问题,先查数据,再查 SQL。先用工具把字段中的不可见字符原样查出来,搞清楚数据里到底存的是什么,再决定转义策略。很多时候不是 SQL 写错了,而是数据本身就是脏的,带着肉眼看不出来的隐藏字符,查了半天查不出头绪。
转义符的问题,说到底是“字符的语义在不同上下文之间切换”的问题。只要数据可能含有通配符或转义符,模糊查询就不存在“写一次永久通用”的银弹。让全团队形成统一的转义函数、统一的 ESCAPE 约定、统一的排查清单,这类“凌晨事故”发生的概率就会小很多。