当MyBatis-Plus的like遇上SQL通配符:下划线翻车记

在最近做知识库管理功能时,我遇到了一个关于模糊查询的诡异问题:搜索"QA知识库_cdd"竟然返回了所有知识库记录,甚至后来直接报SQL语法错误。本以为只是简单的like查询,结果却让我和DS同学折腾了好一阵。今天就把这次踩坑经历分享出来,希望能帮到遇到类似问题的你。


一、从知识库搜索说起:预期与现实的落差

事情是这样的:我们的系统需要实现知识库的名称模糊搜索功能。用户输入关键词,后端用MyBatis-Plus的LambdaQueryWrapper.like()方法拼接SQL,然后分页返回结果。

我天真的以为这不过是小菜一碟------前端传什么,后端like什么,完事。结果绳子专挑细处断 ,当用户搜索"QA知识库_cdd"时,返回的却是所有非空名称的知识库(包括"按时按时"、"撒啊"等完全不相关的记录)。更崩溃的是,当我尝试用apply方法转义下划线后,控制台直接抛出了SQL语法错误。

这到底是为什么呢?我们来一探究竟。


二、探案过程:从"通配符陷阱"到"反斜杠噩梦"

原始代码(问题版本)

java 复制代码
LambdaQueryWrapper<KnowledgeBase> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotBlank(query.getName())) {
    wrapper.like(KnowledgeBase::getName, query.getName());
}
wrapper.orderByDesc(KnowledgeBase::getCreateTime);
Page<KnowledgeBase> page = new Page<>(query.getPageNum(), query.getPageSize());
Page<KnowledgeBase> kbPage = this.page(page, wrapper);

现象一:搜索_返回了所有记录

当用户输入包含下划线_的关键词时,生成的SQL类似于:

sql 复制代码
SELECT * FROM knowledge_base WHERE name LIKE '%_%' AND tenant_id = 47

问题根源 :在SQL的LIKE模式中,%匹配任意个字符,而_匹配恰好一个任意字符 。因此'%_%'等价于"至少包含一个字符",也就是所有非空的name都会被命中。这正是为什么搜索"QA知识库_cdd"会返回全部数据------数据库把下划线当成了通配符,而不是普通字符。

现象二:转义反斜杠导致语法错误

我立刻想到了转义,用apply方法手工构建SQL:

java 复制代码
wrapper.apply("name LIKE CONCAT('%', REPLACE(REPLACE({0}, '_', '\\_'), '%', '\\%'), '%') ESCAPE '\\'", searchValue);

结果报错:

复制代码
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''\\') AND tenant_id = 47' at line 1

根本原因 :Java字符串中的\\经过MyBatis参数替换后,传递给MySQL时变成了单反斜杠,而MySQL解析ESCAPE '\\'时又需要双反斜杠才能表示一个反斜杠字符。多层转义导致最终SQL畸形。简单说,用反斜杠做转义字符在MyBatis+MySQL组合下非常容易翻车


三、解决方案:换一个"安全"的转义字符

找到了病根,解决方案就清晰了:选择一个不会与用户输入冲突的字符作为转义符 ,例如!#$。这样既不需要处理反斜杠的噩梦,又能精确控制转义。

方案一:直接使用!转义(简单实用)

java 复制代码
if (StringUtils.isNotBlank(query.getName())) {
    String searchValue = query.getName();
    wrapper.apply("name LIKE CONCAT('%', REPLACE(REPLACE({0}, '_', '!_'), '%', '!%'), '%') ESCAPE '!'", 
                  searchValue);
}

原理 :将用户输入的_替换为!_%替换为!%,然后声明ESCAPE '!',这样!_!%在匹配时就被当作普通字符处理。

方案二:封装工具类,动态选择转义符(更健壮)

如果担心用户输入中本身包含!,可以动态检测一个未出现的字符:

java 复制代码
public class LikeEscapeUtil {
    public static String escapeLike(String value, String escapeChar) {
        if (value == null) return null;
        return value.replace(escapeChar, escapeChar + escapeChar)  // 转义转义符自身
                    .replace("_", escapeChar + "_")
                    .replace("%", escapeChar + "%");
    }
    
    public static String getSafeEscapeChar(String value) {
        String[] candidates = {"!", "#", "$", "&", "@"};
        for (String c : candidates) {
            if (!value.contains(c)) return c;
        }
        return "|"; // 兜底
    }
}

使用时:

java 复制代码
String keyword = query.getName();
String escapeChar = LikeEscapeUtil.getSafeEscapeChar(keyword);
String escaped = LikeEscapeUtil.escapeLike(keyword, escapeChar);
wrapper.apply("name LIKE CONCAT('%', {0}, '%') ESCAPE '" + escapeChar + "'", escaped);

四、可复用的工具类:完整分页查询代码

我将最终稳定的版本封装成了一个通用方法,供大家参考:

java 复制代码
/**
 * 知识库名称安全模糊查询工具类
 * 解决用户输入包含SQL通配符(% 和 _)时导致查询结果异常的问题
 * 
 * 原理:将用户输入中的 _ 替换为 !_ ,% 替换为 !% ,然后使用 ESCAPE '!' 声明转义符,
 *       使得 !_ 和 !% 在LIKE匹配时被当作普通字符处理。
 * 
 * @author 时间静止不是简史
 * @date 2026-04-17
 */
public class LikeEscapeUtil {

    // 默认转义字符(需确保业务搜索词中极少出现该字符,若可能出现可动态检测)
    private static final String DEFAULT_ESCAPE = "!";

    /**
     * 转义用户输入中的SQL LIKE通配符(% 和 _),并返回可直接用于 LIKE ... ESCAPE 的值
     * 
     * 注意:本方法不拼接 % 前后缀,仅做内部转义。调用方需要自行 CONCAT('%', 转义后结果, '%')
     * 
     * @param raw 用户原始输入(如 "QA知识库_cdd")
     * @return 转义后的字符串(如 "QA知识库!_cdd"),若raw为null则返回null
     */
    public static String escapeLike(String raw) {
        return escapeLike(raw, DEFAULT_ESCAPE);
    }

    /**
     * 使用自定义转义字符转义SQL LIKE通配符
     * 
     * @param raw        用户原始输入
     * @param escapeChar 转义字符(如 "!"、"#"),必须确保该字符不会在raw中作为普通搜索内容出现
     * @return 转义后的字符串
     */
    public static String escapeLike(String raw, String escapeChar) {
        if (raw == null) {
            return null;
        }
        if (escapeChar == null || escapeChar.isEmpty()) {
            throw new IllegalArgumentException("转义字符不能为空");
        }
        // 重要:先转义转义字符自身,防止用户输入恰好包含该字符导致意外转义
        String escaped = raw.replace(escapeChar, escapeChar + escapeChar);
        // 转义 SQL 通配符
        escaped = escaped.replace("_", escapeChar + "_");
        escaped = escaped.replace("%", escapeChar + "%");
        return escaped;
    }

    /**
     * 动态选择一个不会出现在用户输入中的字符作为转义符(避免冲突)
     * 
     * @param raw 用户原始输入
     * @return 可用的转义字符(如 "!"、"#"、"$"、"&"、"@" 中的一个,或兜底 "|")
     */
    public static String getSafeEscapeChar(String raw) {
        if (raw == null || raw.isEmpty()) {
            return DEFAULT_ESCAPE;
        }
        String[] candidates = {"!", "#", "$", "&", "@"};
        for (String c : candidates) {
            if (!raw.contains(c)) {
                return c;
            }
        }
        // 极端情况:用户输入包含了所有候选字符,返回一个不太常用的字符(理论上极少发生)
        return "|";
    }

    /**
     * 完整构建安全模糊查询的 SQL 片段(含 CONCAT 和 ESCAPE)
     * 适用于 MyBatis-Plus 的 apply 方法
     * 
     * 使用示例:
     * <pre>
     * String keyword = "QA知识库_cdd";
     * String sqlSegment = LikeEscapeUtil.buildLikeSql(keyword);
     * wrapper.apply(sqlSegment, LikeEscapeUtil.escapeLike(keyword));
     * </pre>
     * 
     * @param raw 用户原始输入
     * @return SQL 片段,如 "name LIKE CONCAT('%', {0}, '%') ESCAPE '!'"
     */
    public static String buildLikeSql(String raw) {
        String escapeChar = getSafeEscapeChar(raw);
        return "name LIKE CONCAT('%', {0}, '%') ESCAPE '" + escapeChar + "'";
    }
}

使用时

java 复制代码
// 1. 构造查询条件
LambdaQueryWrapper<KnowledgeBase> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotBlank(query.getName())) {
    String keyword = query.getName();
    // 获取安全的转义字符(动态避免冲突)
    String escapeChar = LikeEscapeUtil.getSafeEscapeChar(keyword);
    // 转义用户输入中的 _ 和 %
    String escaped = LikeEscapeUtil.escapeLike(keyword, escapeChar);
    // 使用 apply 构建安全模糊查询(必须转义前后缀 % 已在 SQL 中固定添加)
    wrapper.apply("name LIKE CONCAT('%', {0}, '%') ESCAPE '" + escapeChar + "'", escaped);
}
wrapper.orderByDesc(KnowledgeBase::getCreateTime);

// 2. 执行分页查询
Page<KnowledgeBase> page = new Page<>(query.getPageNum(), query.getPageSize());
Page<KnowledgeBase> kbPage = this.page(page, wrapper);

关键注意点

  • 必须同时转义%_,二者都是SQL通配符
  • 如果转义字符本身可能出现在关键词中,需要先将其自身转义(如replace("!", "!!")
  • 尽量使用{0}占位符,避免字符串拼接导致SQL注入

相关推荐
两年半的个人练习生^_^2 小时前
每日一学:设计模式之建造者模式
java·开发语言·设计模式
我登哥MVP2 小时前
【SpringMVC笔记】 - 6 - RESTFul编程风格
java·spring boot·spring·servlet·tomcat·maven·restful
yhole2 小时前
spring security 超详细使用教程(接入springboot、前后端分离)
java·spring boot·spring
zjjsctcdl2 小时前
SpringBoot3.3.0集成Knife4j4.5.0实战
java
夕除2 小时前
javaweb--10
mybatis
彭于晏Yan2 小时前
Spring Boot 集成邮件服务实现发送邮件功能
java·spring boot·后端
浮尘笔记2 小时前
Java Snowy 框架生产环境安全部署全流程(服务器篇)
java·运维·服务器·开发语言·后端
宸津-代码粉碎机2 小时前
Spring Boot 4.0虚拟线程实战续更预告:高阶技巧、监控排查与分布式场景落地指南
java·大数据·spring boot·分布式·后端·python
Rsun045512 小时前
6、Java 适配器模式从入门到实战
java·开发语言·适配器模式