在最近做知识库管理功能时,我遇到了一个关于模糊查询的诡异问题:搜索"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注入