
1. 从一次模糊查询的“翻车”经历说起最近在做一个后台管理系统的需求涉及到用户列表的筛选。产品经理提了个很常见的需求在搜索框里输入用户名或手机号能进行模糊匹配。这活儿听起来简单不就是个like查询嘛。我心想项目里用的是 Mybatis-Plus这框架的LambdaQueryWrapper用起来多优雅链式调用编译期检查告别魔法值。于是我顺手就写下了类似这样的代码LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(User::getName, keyword) .or() .like(User::getPhone, keyword); ListUser list userMapper.selectList(wrapper);信心满满地跑了一下结果列表空空如也。我检查了数据库明明有叫“张三”的用户为什么搜“张”就搜不到了第一反应是参数传错了打印了一下 SQL 日志看到生成的语句是SELECT * FROM user WHERE (name LIKE ? OR phone LIKE ?)参数呢日志显示两个参数都是keyword变量的值。问题来了like方法默认的模糊查询语法是%keyword%吗我带着疑问去翻了一下源码这才发现LambdaQueryWrapper的like方法其默认行为是在参数值两侧自动添加百分号%的。也就是说wrapper.like(User::getName, “张”)生成的 SQL 条件是name LIKE ‘%张%’。那我传进去的keyword是什么我调试发现前端传过来的搜索词是“张 ”后面跟了一个空格。由于like方法自动加上了%最终的查询条件变成了name LIKE ‘%张 %’。这就意味着它要匹配的是名字中间包含“张 ”张空格的记录而数据库里存的是“张三”自然就匹配不上了。这个“翻车”经历让我意识到即使是最基础的like查询里面也有不少细节和“坑”。LambdaQueryWrapper提供的like及相关方法看似简单但如果不清楚其默认行为、参数处理和不同方法的区别很容易写出不符合预期的代码导致查询结果错误或性能问题。今天我就结合自己的实践和源码把LambdaQueryWrapper的模糊查询方法彻底梳理一遍记录下正确的使用姿势和那些容易踩到的“坑”。2. LambdaQueryWrapper.like 方法族全解析不只是加个%那么简单Mybatis-Plus 的LambdaQueryWrapper提供了多个用于模糊查询的方法它们都基于like关键字但在处理通配符%和_的行为上有所不同。理解这些差异是正确使用它们的前提。2.1 核心三剑客like, likeLeft, likeRight这是最常用的三个方法它们的区别完全体现在对通配符%的自动添加策略上。like(R column, Object val)这是最“智能”也最常用的方法。它的作用是生成column LIKE ‘%value%’的查询条件。关键点在于这个方法会自动在传入的val两侧加上%。你不需要也不应该在传入的参数值里自己包含%。// 查询 name 包含“技术”的用户 wrapper.like(User::getName, “技术”); // 生成SQL: name LIKE ‘%技术%’踩坑提示1参数值中的空格正如开篇提到的如果传入的参数值本身首尾有空格比如“ 技术 ”那么生成的 SQL 会是name LIKE ‘% 技术 %’。这很可能导致查询不到数据因为数据库里存储的值可能是“技术宅”而没有首尾空格。最佳实践是在调用like方法前对搜索关键词进行.trim()操作。String keyword param.getKeyword().trim(); // 先去除首尾空格 wrapper.like(StringUtils.isNotBlank(keyword), User::getName, keyword);likeLeft(R column, Object val)这个方法用于“左模糊”查询即匹配以指定值结尾的字段。它会自动在val的左侧添加%生成column LIKE ‘%value’。// 查询以“com”结尾的邮箱 wrapper.likeLeft(User::getEmail, “com”); // 生成SQL: email LIKE ‘%com’likeRight(R column, Object val)这个方法用于“右模糊”查询即匹配以指定值开头的字段。它会自动在val的右侧添加%生成column LIKE ‘value%’。// 查询手机号以“138”开头的用户 wrapper.likeRight(User::getPhone, “138”); // 生成SQL: phone LIKE ‘138%’性能小贴士likeRight 与索引在数据库优化中LIKE ‘value%’这种形式是**有可能利用到字段上的普通索引B-Tree索引**的。而LIKE ‘%value%’或LIKE ‘%value’则会导致索引失效进行全表扫描。因此在业务允许的情况下优先使用likeRight进行前缀匹配能有效提升查询性能尤其是在大表查询时。2.2 需要手动转义的 like 方法上面三个方法虽然方便但有一个共同的限制它们都会对参数值中的%和_进行自动转义。这是因为%和_在 SQL LIKE 语句中是通配符如果用户输入的搜索词中包含了这些字符例如搜索“100%完成度”直接拼接进 SQL 会导致语义错误。Mybatis-Plus 通过SqlUtils.escapeLike方法默认处理了这个问题。但有时候我们需要进行更复杂的模糊匹配比如明确要查询包含“10%”的记录或者我们自己已经构造好了包含通配符的完整模式字符串。这时就需要下面这两个方法like(boolean condition, R column, Object val, SqlLike sqlLike)这个重载方法多了一个SqlLike枚举参数用于指定通配符添加的位置。SqlLike枚举有四个值DEFAULT: 同like()方法两侧加%。LEFT: 同likeLeft()左侧加%。RIGHT: 同likeRight()右侧加%。CUSTOM:这是关键使用CUSTOM模式时框架不会自动在val两侧添加%也不会对val中的%和_进行转义。它直接将val作为完整的模式字符串拼接到 SQL 中。// 场景查询进度为“100%”的任务假设数据库存的就是“100%”这个字符串 // 错误做法wrapper.like(Task::getProgress, “100%”); // 框架会把 % 转义查不到 // 正确做法 wrapper.like(true, Task::getProgress, “100%”, SqlLike.CUSTOM); // 生成SQL: progress LIKE ‘100%’ // 注意这里的 ‘100%’ 中的 % 是SQL通配符会匹配“100”、“100%”、“100abc”等。 // 如果真要精确匹配“100%”这个字符串应该用 equals 或 LIKE ‘100\%’需要转义。like(boolean condition, R column, Object val)这个就是最基础的like方法其内部默认使用SqlLike.DEFAULT模式。它和上面提到的方法一样会对特殊字符进行转义。核心原理转义是如何发生的跟踪like方法的源码最终会调用AbstractWrapper的doIt方法。在构建 SQL 片段时如果SqlLike模式不是CUSTOM则会通过SqlUtils.escapeLike处理val。// 简化后的逻辑 if (sqlLike ! SqlLike.CUSTOM) { val SqlUtils.escapeLike((String) val); // 转义 % 和 _ } // 然后根据 sqlLike 值在转义后的 val 前后拼接 % String likeValue sqlLike.putLike(val); // 最后生成 “column LIKE likeValue” 的SQL片段SqlUtils.escapeLike方法的作用是将字符串中的\、%、_前面加上一个反斜杠\进行转义。所以如果你传入“100%”经过转义会变成“100\%”再拼接上%后最终的查询条件是LIKE ‘%100\%%’。这会导致数据库将%当作普通字符查找从而可能匹配到“100%”。2.3 不推荐但需了解的 notLike 方法族与like对应还有notLike、notLikeLeft、notLikeRight方法用于构建NOT LIKE条件。其通配符处理逻辑与对应的like方法完全一致。// 查询名字中不包含“管理员”的用户 wrapper.notLike(User::getName, “管理员”); // 生成SQL: name NOT LIKE ‘%管理员%’需要注意的是NOT LIKE在数据量大的情况下性能可能很差且结果集可能非常大排除一小部分留下绝大部分。在实际业务中应谨慎使用优先考虑用其他正向条件来缩小范围。3. 模糊查询的实战进阶组合、判空与性能优化掌握了基本方法我们来看看在实际业务场景中如何更安全、更高效地使用它们。3.1 条件构造与空值处理直接链式调用虽然流畅但如果没有对搜索词判空当关键词为空字符串“”时会生成name LIKE ‘%%’这样的条件。这个条件在逻辑上等同于true但它可能导致数据库无法使用索引且增加了SQL解析的负担。Mybatis-Plus 提供了条件参数来优雅地处理这个问题。使用boolean condition参数所有like方法的重载版本第一个参数基本都是boolean condition。当condition为false时该条件不会被拼接到最终的 SQL 中。String keyword request.getKeyword(); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(keyword), User::getName, keyword) .eq(User::getStatus, 1); // 如果 keyword 为 null 或空字符串则 like 条件被忽略SQL中只有 status1 的条件。这是最推荐的做法它从源头避免了无效条件的生成代码意图清晰。3.2 多字段模糊查询与 or() 的陷阱开篇的例子提到了多字段or查询。这里有一个常见的“坑”// 意图查询名字或手机号包含 keyword 的用户 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(User::getName, keyword) .or() .like(User::getPhone, keyword);这段代码生成的 SQL 是WHERE (name LIKE ?) OR (phone LIKE ?)符合预期。但是如果查询条件更复杂一些wrapper.eq(User::getType, “VIP”) .like(User::getName, keyword) .or() .like(User::getPhone, keyword);你期望的可能是(type‘VIP’ AND name LIKE ‘%key%’) OR (phone LIKE ‘%key%’)但实际生成的 SQL 是type‘VIP’ AND name LIKE ‘%key%’ OR phone LIKE ‘%key%’。由于AND优先级高于OR这个逻辑等价于(type‘VIP’ AND name LIKE ‘%key%’) OR (phone LIKE ‘%key%’)这仍然符合你最初的预期吗很可能不符合。你或许想要的是type‘VIP’ AND (name LIKE ‘%key%’ OR phone LIKE ‘%key%’)。这就需要使用and或or的嵌套 lambda 表达式wrapper.eq(User::getType, “VIP”) .and(wq - wq.like(User::getName, keyword) .or() .like(User::getPhone, keyword)); // 生成SQL: WHERE type ‘VIP’ AND (name LIKE ‘%key%’ OR phone LIKE ‘%key%’)and方法接受一个ConsumerLambdaQueryWrapper在这个 Consumer 里构造的条件会被括号包裹起来作为一个整体与前一个条件进行AND运算。or方法同理。3.3 模糊查询与索引失效的深水区如前所述LIKE ‘%value%’会导致索引失效。但这并非绝对这里有一些更深入的考量覆盖索引Covering Index即使WHERE条件用不上索引但如果查询的字段全部包含在某个索引中称为覆盖索引数据库可能会选择扫描该索引而不是全表因为索引通常比表数据小得多。例如在(name, phone)联合索引上执行SELECT name, phone FROM user WHERE name LIKE ‘%张%’虽然LIKE用不上索引的最左前缀但数据库仍可能扫描整个(name, phone)索引来获取数据这比全表扫描快。索引条件下推Index Condition Pushdown, ICP这是 MySQL 5.6 以后引入的优化。对于LIKE ‘%张%’这种条件存储引擎在遍历索引时会顺便利用索引中的name字段进行初步筛选将不满足LIKE条件的记录提前过滤掉减少回表次数。但这依然需要遍历大部分索引条目。全文索引Full-Text Index对于真正的全文模糊搜索需求如文章内容搜索LIKE是极其低效的应该使用数据库专用的全文索引MySQL 的FULLTEXT或者引入 Elasticsearch、Solr 等搜索引擎。最左前缀原则的灵活运用有时候我们可以通过业务设计来规避左模糊。例如对于手机号查询如果用户通常只输入后几位我们可以通过反转字符串并建立索引来实现高效查询。即新增一个phone_reverse字段存储手机号的反转字符串并为其建立索引。查询时将用户输入的关键词也反转然后使用likeRightWHERE phone_reverse LIKE ‘321%’这就能利用索引快速找到后几位是“123”的手机号。3.4 应对超长模糊查询参数化与预编译的安全保障使用LambdaQueryWrapper的like方法最终生成的 SQL 是参数化查询PreparedStatement即LIKE ?参数值是通过setString等方法传入的。这从根本上防止了 SQL 注入攻击无论用户输入什么都会被当作一个整体的字符串值来处理。但是极端长的模糊查询关键词比如几MB的字符串可能会带来问题数据库性能LIKE ‘%非常长的字符串%’操作数据库需要对每一行数据的对应字段进行完整的字符串模式匹配计算开销巨大。网络与内存长字符串作为参数传输和存储消耗更多资源。防护措施前端限制在输入框设置maxlength例如限制为 50 或 100 个字符。后端校验在 Controller 或 Service 层对参数长度进行校验。业务设计思考是否需要支持如此长的模糊匹配是否可以用更精确的查询如等于、范围查询或分词搜索来替代4. 特殊场景下的模糊查询方案除了直接使用like在一些特殊场景下我们可能需要其他方案。4.1 忽略大小写的模糊查询默认情况下LIKE的行为取决于数据库的排序规则Collation。如果字段的排序规则是大小写敏感的如utf8_bin那么LIKE ‘a%’将不会匹配到“Apple”。有几种解决方案使用数据库函数在查询时使用LOWER()或UPPER()函数。wrapper.apply(“LOWER({0}) LIKE LOWER({1})”, User::getName, “%” keyword “%”); // 注意这种方式会导致索引失效。修改字段排序规则将表字段的排序规则改为不区分大小写的如utf8_general_ci或utf8mb4_unicode_ci。这是最推荐的做法一劳永逸且不影响索引使用。但需在数据库设计初期规划。应用层处理如果数据量不大可以查询出来后在应用层进行大小写不敏感的过滤。但这仅适用于结果集很小的场景。4.2 使用自定义SQL片段Select注解或XML对于极其复杂的模糊查询逻辑或者需要用到数据库特定函数如 PostgreSQL 的ILIKE或~正则匹配可以退回到编写自定义 SQL。在 Mapper 接口中使用注解Select(“SELECT * FROM user WHERE name LIKE CONCAT(‘%’, #{keyword}, ‘%’) AND status #{status}”) ListUser selectByKeyword(Param(“keyword”) String keyword, Param(“status”) Integer status);在 XML 映射文件中编写select id“selectByComplexLike” resultType“User” SELECT * FROM user where if test“keyword ! null and keyword ! ‘’” AND (name LIKE CONCAT(‘%’, #{keyword}, ‘%’) OR email LIKE CONCAT(‘%’, #{keyword}, ‘%’)) /if AND deleted 0 /where /select注意在 XML 中手动拼接LIKE语句时强烈建议使用CONCAT函数来拼接通配符而不是在 Java 代码中拼接好“%” keyword “%”再传入。后者在某些情况下如关键字包含特殊字符时可能引发意料之外的问题而CONCAT函数是数据库层面的安全拼接。同时Mybatis 的参数替换#{keyword}仍然能有效防止 SQL 注入。4.3 与分页查询 Page 的结合模糊查询常常和分页一起使用。Mybatis-Plus 的Page对象与LambdaQueryWrapper结合非常方便。// 假设 pageNum2, pageSize10 PageUser page new Page(2, 10); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(keyword), User::getName, keyword) .orderByDesc(User::getCreateTime); IPageUser userPage userMapper.selectPage(page, wrapper); ListUser records userPage.getRecords(); // 当前页数据 long total userPage.getTotal(); // 总记录数这里有一个性能隐患当模糊查询匹配的记录数非常多比如几十万时SELECT COUNT(*)语句会非常慢。Mybatis-Plus 的selectPage方法会先执行一次COUNT(*)查询获取总数。如果业务上不需要精确的总数例如移动端下拉加载更多只需要知道“还有没有下一页”可以使用Page的setSearchCount方法来关闭 count 查询。PageUser page new Page(2, 10); page.setSearchCount(false); // 不查询总记录数 IPageUser userPage userMapper.selectPage(page, wrapper); // 此时 userPage.getTotal() 为 0但分页数据正常。 // 判断是否有下一页如果返回的 records.size() pageSize则可能还有下一页。5. 源码层面的理解与自定义扩展最后我们深入到源码层面看看like方法是如何工作的这有助于我们定位一些诡异的问题和进行高级定制。5.1 AbstractWrapper 的 doIt 方法所有的条件构造方法最终都会汇聚到AbstractWrapper的doIt私有方法中。对于like关键逻辑如下简化版protected String doIt(boolean condition, String column, Object val, SqlLike sqlLike, String mapping) { if (!condition) { return null; } String valStr String.valueOf(val); // 如果不是CUSTOM模式则对值进行转义 if (sqlLike ! SqlLike.CUSTOM) { valStr SqlUtils.escapeLike(valStr); } // 根据sqlLike模式在值前后添加% valStr sqlLike.putLike(valStr); // 将处理后的值放入参数池并返回占位符 return formatSql(“{0} {1} {2}”, column, mapping, “?”); }formatSql方法会将column这里是数据库字段名、mapping这里是“LIKE”和占位符“?”拼接起来。处理后的valStr会被放入一个paramNameValuePairs的 Map 中后续由 Mybatis 注入到 PreparedStatement 里。5.2 自定义一个“两边不加%”的 likeExact 方法假设我们有这样一个需求我们需要一个方法它像SqlLike.CUSTOM一样不自动加%但又需要框架帮我们转义%和_。框架没有直接提供这样的方法我们可以通过继承LambdaQueryWrapper来扩展。public class MyLambdaQueryWrapperT extends LambdaQueryWrapperT { /** * 自定义like方法不自动添加%但自动转义通配符。 * param column 字段 * param val 值可以包含通配符%_它们会被转义。 * return */ public MyLambdaQueryWrapperT likeExact(SFunctionT, ? column, Object val) { return likeExact(true, column, val); } public MyLambdaQueryWrapperT likeExact(boolean condition, SFunctionT, ? column, Object val) { if (condition) { String valStr String.valueOf(val); // 关键转义通配符但不额外添加% valStr SqlUtils.escapeLike(valStr); super.like(condition, column, valStr, SqlLike.CUSTOM); } return this; } } // 使用示例 MyLambdaQueryWrapperUser wrapper new MyLambdaQueryWrapper(); wrapper.likeExact(User::getCode, “A_B%”); // 生成的SQL: code LIKE ‘A\_B\%’ // 这会精确查找包含“A_B%”这个字符串的记录其中的下划线和百分号都被转义为普通字符。通过这种方式我们可以根据具体的业务需求灵活地扩展LambdaQueryWrapper的功能。回顾整个模糊查询的使用从最基础的like方法到复杂的组合查询、性能优化和源码扩展其核心在于理解框架的默认行为自动加%和转义并根据业务场景选择合适的方法或进行改造。在享受LambdaQueryWrapper带来的编码便利和类型安全的同时时刻对生成的 SQL 保持清醒的认识是写出高效、稳健数据访问层代码的关键。