1. 一个“优雅”的梦想,始于一次惨痛的SQL注入
只要是天天写业务代码的Java后端,几乎没人能绕开MyBatis里的模糊查询。需求往往一模一样:用户输入几个关键字,我们在数据库里做 LIKE '%关键字%' 的匹配,把符合条件的数据拉出来。标题写的是“mybatis中like优雅使用”,我理解这里的“优雅”,不是指代码写得花里胡哨,而是指安全、可维护、不给自己埋雷。但这三个词,听起来容易,做起来经常是另一回事。
我见过太多团队在模糊查询上翻车。最典型的就是图省事,在Mapper XML里直接写:
xml复制<select id="listByKeyword" resultType="com.example.User">
SELECT * FROM user
WHERE name LIKE '%${keyword}%'
</select>
第一眼看过去,这SQL好像没什么问题,跑起来也通。但稍微有点经验的开发,看到 ${keyword} 这几个字符,血压就应该上来了。${} 是字符串替换,MyBatis会把传入的值原封不动地拼进SQL语句里。也就是说,用户如果在搜索框输入 ' OR '1'='1,最终执行的就是:
sql复制SELECT * FROM user WHERE name LIKE '%' OR '1'='1%'
这条SQL在大多数数据库里都能把整个表的数据带出来。如果后面再接上 UNION SELECT 之类的语句,数据泄露就是分分钟的事。这个坑不是危言耸听,OWASP的SQL注入攻击案例里,模糊查询的注入比例相当高,因为很多人觉得“不过是个搜索框而已”。
所以当我看到有人问“MyBatis里Like怎么优雅使用”时,我第一反应是:先把 ${} 从代码里删掉,再来谈优雅。这不是技术选型问题,是安全底线问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种安全的Like写法:从CONCAT到bind标签
2.1 写法一:XML里用CONCAT函数拼通配符
使用 #{} 是MyBatis推荐的方式,它会被解析成预编译的占位符 ?,配合JDBC的 PreparedStatement,用户输入的任何内容都只是“数据”,不会变成“SQL逻辑”。这是安全的根本保证。
但 #{} 有个限制:你不能直接在占位符里写 %,也就是 LIKE '%#{keyword}%' 这种写法在MySQL里是语法错误。于是最常见的解决思路,就是用数据库自带的字符串拼接函数:
xml复制<select id="listByKeyword" resultType="com.example.User">
SELECT * FROM user
WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>
这个方法在MySQL里很好用,CONCAT 把通配符和参数拼成一个完整的匹配串,#{} 负责安全绑定参数。代码简短,语义清晰,大多数人误以为这就是“优雅”的终点。
2.2 写法二:Java层拼好通配符再传入
另一个思路是,把 % 的拼接从数据库层拿到Java层。在Service或Controller里,把用户输入处理成带通配符的字符串,Mapper接口里只接收一个普通参数:
java复制public List<User> searchUsers(String keyword) {
// 这里可以顺手去掉首尾空格,也可以对特殊字符做预处理
String pattern = "%" + keyword.trim() + "%";
return userMapper.selectByKeyword(pattern);
}
对应的Mapper XML就非常干净:
xml复制<select id="selectByKeyword" resultType="com.example.User">
SELECT * FROM user
WHERE name LIKE #{pattern}
</select>
这种做法把SQL面维护得尽量简单,逻辑上也只是把拼接动作换了位置。缺点也明显:% 的逻辑散落在Java代码里,如果项目里有几十个模糊查询,每个调用点都要自己拼一遍,容易漏。有人会把拼接动作封装成一个工具方法,比如 LikeUtils.wrap(keyword),算是折中方案。
2.3 写法三:bind标签的解放与约束
要说真正意义上的“MyBatis风格写法”,当属 <bind> 标签。它允许我们在XML里声明一个局部变量,先拼接好再绑定给SQL:
xml复制<select id="listByKeyword" resultType="com.example.User">
<bind name="pattern" value="'%' + keyword + '%'"/>
SELECT * FROM user
WHERE name LIKE #{pattern}
</select>
<bind> 的好处是,% 的拼接逻辑留在XML里,和SQL在一起,维护时不用跳转到Java代码。value 属性里写的是OGNL表达式,可以调用字符串处理方法,比如做空值兜底:
xml复制<bind name="pattern" value="'%' + (keyword == null ? '' : keyword) + '%'"/>
这样即使 keyword 传了 null,也不会导致整个SQL异常。不过 <bind> 也不是银弹,OGNL表达式写复杂了同样难以调试,在代码评审时很容易被质疑可读性。我的建议是:只在“拼接逻辑简单且复用一个参数”时用bind,一旦超过两个判断分支,就回归Java层。
2.4 三种写法怎么选
做个简单对比,你一看就明白:
| 方案 | 安全性 | 可读性 | 复杂逻辑处理 | 推荐场景 |
|---|---|---|---|---|
| CONCAT拼接 | 高 | 较好 | 一般 | MySQL单库项目 |
| Java层拼接 | 高 | 最好 | 强 | 项目规范统一,封装工具类后复用 |
| bind标签 | 高 | 看OGNL复杂程度 | 中 | 需要做空值兜底、简单拼接 |
如果团队里有人问“到底用哪种”,我的答案是:没有强迫症的话,选Java层拼接+工具类。理由很简单——SQL保持最简,逻辑可单测,团队里新人不容易踩坑。
3. 多数据库方言兼容:同一份XML如何通吃MySQL、Oracle、PostgreSQL
很多人没意识到,CONCAT 的写法其实是一种“数据库方言绑定”。如果你的项目将来要从MySQL迁移到PostgreSQL,CONCAT('%', #{keyword}, '%') 在PostgreSQL同样能跑。但要是迁到Oracle,情况就不一样了——Oracle虽然也有 CONCAT 函数,但它只支持两个参数,你写 CONCAT('%', #{keyword}, '%') 会直接报错。
Oracle、PostgreSQL、SQL Server里更通用的写法是使用 || 运算符:
sql复制-- Oracle
WHERE name LIKE '%' || #{keyword} || '%'
-- PostgreSQL
WHERE name LIKE '%' || #{keyword} || '%'
-- SQL Server
WHERE name LIKE '%' + #{keyword} + '%'
所以,如果你的代码里到处都是 CONCAT('%', #{keyword}, '%'),一旦面临数据库切换,这些SQL全部要改一遍。这就不“优雅”了。
更稳的做法是结合MyBatis的 databaseId 机制。在MyBatis全局配置里启用多数据库支持:
xml复制<databaseIdProvider type="DB_VENDOR">
<property name="MySQL" value="mysql"/>
<property name="Oracle" value="oracle"/>
<property name="PostgreSQL" value="postgresql"/>
<property name="SQL Server" value="sqlserver"/>
</databaseIdProvider>
然后在Mapper XML里,同一个查询可以写多个带 databaseId 的版本,MyBatis会自动根据当前连接的数据库选择执行:
xml复制<select id="listByKeyword" resultType="com.example.User" databaseId="mysql">
SELECT * FROM user
WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>
<select id="listByKeyword" resultType="com.example.User" databaseId="oracle">
SELECT * FROM user
WHERE name LIKE '%' || #{keyword} || '%'
</select>
如果你的项目本身就是多数据库中间件,或者有云上RDS切换的规划,建议提前把这个机制铺好。用 databaseId 不是过度设计,而是把“方言差异”显式管理起来。没有这个需求的项目,也别硬上,否则会有一堆重复SQL等着你维护。
4. 动态SQL细节:空值、转义与特殊字符一个都不能少
4.1 空值、空串与空白符号的判断
模糊查询里的参数为空,是一个非常经典的问题。用户不输入关键字、只敲了一堆空格,或者前端传过来的是 null,这三种情况在业务上通常都代表“不过滤”,但如果不加处理,SQL执行出来的结果会让人困惑:
null传入LIKE CONCAT('%', null, '%'),在大多数数据库里结果是NULL,整个条件不成立,查不出任何数据。- 空串
''传入,查询结果等价于LIKE '%%',不报错,但会把整张表的数据捞出来。 - 一串空格
' ',行为取决于数据库的排序规则。
所以动态SQL一定要做空值兜底。一个比较稳妥的写法是:
xml复制<select id="listByKeyword" resultType="com.example.User">
SELECT * FROM user
<where>
<if test="keyword != null and keyword.trim() != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
</where>
</select>
这里的 keyword.trim() != '' 能过滤掉纯空格的情况。有人会问,工具类里不是已经trim过了吗?但你要知道,接口是给多人调用的,保不齐有人绕过Service直接调Mapper。在XML侧再兜一层 trim() 判断,成本极低,收益极高。
4.2 通配符转义:用户搜索“%”怎么办
数据库里 % 和 _ 都是通配符。用户如果在搜索框里输入一个 %,TA的真实意图可能是“找出包含百分号这个字符的记录”,但SQL会把它当成通配符,把几乎所有数据都匹配出来。
处理方案是使用 ESCAPE 子句。以MySQL为例:
xml复制<bind name="pattern" value="'%' + keyword.replace('!', '!!').replace('%', '!%').replace('_', '!_') + '%'"/>
<select id="listByKeyword" resultType="com.example.User">
SELECT * FROM user
WHERE name LIKE #{pattern} ESCAPE '!'
</select>
注意一个细节:replace 的顺序不能乱,先把 ! 自身替换成 !!,再把 % 替换成 !%,最后处理 _。否则用户输入里现有的 ! 会被第二次替换误伤。
看起来麻烦,但这个场景真实存在。尤其是搜索框会被输入法、日志、复制粘贴等场景带入特殊字符,不做转义,你的结果集就会“莫名其妙地全量返回”。这个小坑,踩过一次就知道了。
4.3 if test里的indexOf等动态判断细节
网络热搜词里有个“mybatis if test indexof”,这其实是动态SQL里一个稍冷门但好用的技巧。<if> 的 test 走的是OGNL表达式,可以调用String的 indexOf 方法做条件分支:
xml复制<if test="keyword.indexOf('id:') == 0">
AND id = #{keyword.substring(3)}
</if>
比如搜索关键字以 id: 开头时走精确匹配,否则走模糊匹配,这样一个接口就能同时满足模糊搜索和精确搜索的需求。但要注意,keyword 为 null 时调用 indexOf 会抛异常,所以必须先判空:
xml复制<if test="keyword != null and keyword.indexOf('id:') == 0">
AND id = #{keyword.substring(3)}
</if>
这类写法适合小而巧的字段筛选,别扩大到复杂的业务规则里。动态SQL一旦膨胀到几十行,排查问题时的精神损耗远大于它带来的“便利”。
5. 成也索引败也索引:Like查询的性能陷阱与排查优化
5.1 前置通配符为什么能让索引失效
安全之外,“优雅”的第二个大问题是性能。模糊查询最常见的写法是 LIKE '%关键字%',前后都带通配符。数据库在执行这个条件时,无法从B+树的根节点开始快速定位,只能做全表扫描——预处理数据量大时,性能直接崩。
如果业务确实只需要前缀匹配,比如搜手机号、订单号、编码,强烈建议写成 LIKE '关键字%',这样在MySQL里是可以走索引的。以手机号搜索为例:
xml复制<select id="listByMobile" resultType="com.example.User">
SELECT * FROM user
WHERE mobile LIKE CONCAT(#{mobile}, '%')
</select>
而 %关键字% 的场景,如果数据量在百万级以上,常规调优手段开始失效。此时可以考虑引入全文检索中间件(Elasticsearch、OpenSearch)或数据库自带的全文索引(MySQL全文索引、PostgreSQL的pg_trgm)。这个决策要结合团队运维能力,不能因为“搜索需求简单”就直接上ES,运维成本也是成本。
5.2 打印真实SQL:从参数占位符到可执行SQL
写模糊查询时,经常遇到“SQL在数据库客户端里能查到数据,但代码里查不到”的情况。问题往往出在参数传递上——你以为传了 %张三%,实际传的是 %张 三% 或者根本没传进去。
排查这类问题的最好方式是打印可执行SQL。在MyBatis配置里开启日志:
yaml复制mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这样控制台会打出Preparing和Parameters两行。但注意,它打印的是预编译SQL和参数列表,不是完整可执行SQL。如果你需要一条可以直接复制到数据库工具里跑的真实SQL,建议用第三方插件,比如MyBatis Log Plugin(IDEA插件)或MyBatis Log Easy Plus,它们会把 ? 替换成实际参数值并处理字符串引号,定位问题效率高很多。
这些工具属于“排查辅助”,没必要加成依赖。但真遇到场景时,它们能救命。
5.3 从LIKE扩展到加密字段的查询设计
热搜词里有一条“spring boot + mybatis实现数据库字段级加密了怎么做查询”,这个问题和Like放到一起,很容易让人头大。因为字段加密后存储的是密文,而 LIKE '%密文片段%' 在大多数加密方案下不可用——密文不具备可预测的局部模式。
实际项目中,字段级加密加模糊查询,可行的方案大致有三条:
- 前缀固定检索:如果业务只需要前缀匹配(比如手机号后四位固定),可以对加密串做特殊编码,或者额外建一个明文索引列,单独存储一个可模糊匹配的摘要值。
- 在应用层解密后过滤:数据量小时,把密文查出来,应用层解密,再做内存过滤。数据量一大,这种方式就不可行了。
- 引入搜索中间件:把密文存数据库,同时把明文(或脱敏后的文本)同步到ES,由ES承担模糊检索,数据库只负责按ID回表。这是最常见的做法。
这个主题本身可以单独写一篇长文。在这里提它,是想提醒一件事:写Like查询时,别只盯着SQL本身,要往前想一步——这条数据未来会不会加密?数据量会不会暴涨?用户搜索的场景会怎么变? 提前留好扩展位,比之后改结构省心得多。
我在实际项目里踩过一次坑:早期为了快速上线,全站搜索都用 LIKE CONCAT('%', #{keyword}, '%'),数据量涨到几百万后,搜索接口的P99延迟从80ms一路飙到3秒,最后不得不连夜上ES。所以现在写每一个模糊查询前,我都会多问一句:这列数据将来会有多大?搜索频率多高?这算是经验主义,但确实帮你避开很多“上线一时爽,维护火葬场”的雷。
