
先交代一个背景上周团队处理一批客户上传的商品数据时发现一个诡异现象——用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百分号和下划线就会变成通配符。存储路径和 URLWindows 路径C:\Program Files\、URL 参数?rate50%、对象存储 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 ORMProduct.objects.filter(discount_rule__containsuser_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 里推荐的做法依然是显式 ESCAPESELECT * 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( posStrPos(discount_rule, search_text) ).filter(pos__gt0)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__containskeyword)这里有一个细节Django 在 MySQL 后端下__contains生成的 SQL 默认会把反斜杠作为转义符但如果你切换到了 SQLite 或者其他数据库行为可能完全不同。所以最稳的方案还是避开contains用数据库提供的“纯子串函数”。Java MyBatis 的场景也差不多。Mapper XML 里这种写法非常常见select idsearch resultTypeProduct 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 字段无法 LIKEOracle 不支持 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 约定、统一的排查清单这类“凌晨事故”发生的概率就会小很多。