2026/9/16 3:29:09

MyBatis IN查询优化:自定义扩展干掉foreach模板,SQL更干净可审计

MyBatis IN查询优化:自定义扩展干掉foreach模板,SQL更干净可审计 写 MyBatis 写了快十年要说哪个地方让我最想吐槽“IN 查询”绝对能排进前三。不是 IN 这功能不行而是每次都要写的那一长串foreach模板真的又臭又长。我见过团队里新来的同事把collection属性名写错排查了半小时也见过老代码里一个 IN 查询拼接了七八个if标签日志里 SQL 长得像天书压根没法直接复制到数据库里执行。更折磨的是一旦线上慢查询你连那个 SQL 到底传了什么参数都看不出来。这篇文章我不打算讲 MyBatis 的基础用法而是直接聊一个能让你从重复劳动里解放出来的方向通过 MyBatis 自定义扩展干掉那套 foreach 模板让 IN 语句的 SQL 回归干净、可读、可审计的状态。顺手把 SQL 编写效率提上去。1. 模板写法的核心痛点解析1.1 日志与排障的“信息黑洞”先说一个最实际的问题foreach 模板在日志里到底长什么样假设你有这么一条 Mapperselect idlistByUserIds resultTypeUserInfo SELECT * FROM user_info WHERE user_id IN foreach collectionuserIdList itemuserId open( separator, close) #{userId} /foreach /select如果你把 MyBatis 的日志级别调到 DEBUG打印出来的是这样的 Preparing: SELECT * FROM user_info WHERE user_id IN (?, ?, ?, ?, ?) Parameters: 101(Long), 102(Long), 103(Long), 104(Long), 105(Long)IN 里面有几万个参数问号你的?会被拉得极长。更麻烦的是这套日志你在本地开发时还能勉强读一旦上了生产SQL 经过各种 Proxy 和采集系统切割你想把“这条 SQL”和“这批参数”对应起来非常费劲。有时候你在日志平台里点开一条慢 SQL前面是IN (?, ?, ?, ?, ?...)后面 Parameters 被截断了你根本不知道线上到底传了哪些 ID 进去。这还不是最要命的。IN (?, ?, ?, ?, ?...)这种写法数据库执行计划没法“记住”你的 SQL。MySQL 对IN (?,?,?)会做参数化但如果参数数量一多语句的文本结构每次都在变。换一批 5 个 ID 和换一批 6 个 IDPreparedStatement 的文本就不一样了等于变相加大了数据库解析 SQL 的开销。你明明只想查一条数据结果因为 ID 列表数量不固定SQL 每次重新硬解析这在千万级数据量的表上尤其明显。从代码维护角度讲这个模板还有个隐性成本所有新来的开发都得先学一遍foreach的collection、item、open、separator、close这几个属性是什么意思。时间久了大家复制粘贴倒是溜了但遇到collection传成对象属性、传成 Map 的 key、传成数组变量名各种坑就出来了。你问为什么会错因为 foreach 的属性值是纯字符串写错了 MyBatis 只有在运行到那条语句的那一刻才会告诉你报错编译期根本发现不了。1.2 参数结构受限与代码洁癖问题foreach 模板还有个让人头疼的地方接口方法参数必须得“服务”于模板。比如你有个需求要根据一批用户 ID 查询同时还要过滤一个状态字段。很多人的接口就变成了ListUserInfo listByUserIds(Param(userIdList) ListLong userIdList, Param(status) Integer status);Mapper 里的 XML 也有了一堆ifSELECT * FROM user_info WHERE status #{status} AND user_id IN foreach collectionuserIdList itemuserId open( separator, close) #{userId} /foreach如果你后续又加了“按用户名模糊查询”“按创建时间范围查询”这几个条件这个 XML 的 where 部分瞬间膨胀。动态 SQL 的if越多排列组合的复杂度越高你测试的路径也得成倍增加。更现实的是很多业务接口的入参就是ListLong你根本没法决定它底层是不是被谁复用。当这个 List 是空的时候IN ()直接语法报错你还得先在外面判断CollectionUtils.isEmpty然后想办法绕过这条 SQL。这套重复劳动几乎所有写过 MyBatis 的人都干过。代码洁癖角度再看一眼一条非常简单的查询因为 IN 的存在被迫在 XML 里写十几行标签。本来两三行就能表达清楚的语义变得又长又绕。我在不少项目里见过一个 XML 500 多行光 IN 查询就占了大几十行看着就头大。这类代码多了以后你去看一个查询到底做了什么需要上下反复滚动效率极低。2. 自定义扩展的方案选型2.1 两条技术路线拦截器与 SQL 函数变通既然 foreach 这么不舒服市面上一般有两类解决思路。第一条路是用数据库函数曲线救国比如 MySQL 的FIND_IN_SET、PostgreSQL 的 ANY(ARRAY[...])把原本要展开的参数序列变成一个整体传进去SELECT * FROM user_info WHERE FIND_IN_SET(user_id, #{userIdStr})这种写法确实能少写标签但它有个致命的坑FIND_IN_SET用不上索引数据量一大查询直接退化成全表扫描。哪怕你在user_id上建了二级索引优化器也不会用。所以这条路线在小型工具类项目里玩玩可以真正放到核心业务链路我是不敢用的。第二条路就是做 MyBatis 的自定义扩展。MyBatis 留了口子允许我们在 SQL 解析阶段、参数绑定阶段做手脚。核心思路是在 XML 里不写foreach而是写一个类似IN (:userIdList)的占位表达式然后通过拦截器在 SQL 执行前把它展开成真正的IN (?,?,?)再把参数值按顺序绑定进去。这样一个扩展写出来团队里所有 Mapper 都能复用SQL 干净了参数也清楚了底层能力还完全复用 MyBatis 的原生占位符和 PreparedStatement 预编译索引照样走权限照样控。可能有人担心自定义扩展会不会引入复杂度说实话第一次写这个拦截器的时候确实有点绕但一旦跑通收益是非常直观的。我后面会把核心代码拆开讲你就明白它其实没那么神秘。2.2 拦截器方案的工作机制MyBatis 执行一条 SQL 的大致流程是这样的先由SqlSession拿出 Mapper 对应的MappedStatement然后解析动态 SQL 生成BoundSql再通过Executor调用StatementHandler去拿数据库连接、准备 PreparedStatement、绑定参数、执行查询。我们在StatementHandler.prepare前后插入一层拦截逻辑就有机会改写BoundSql里的 SQL 文本和参数映射关系。我的实现方案是在Interceptor里拦截StatementHandler#prepare(Connection, Integer)方法。当 SQL 文本里检测到自定义的:xxx形式占位符就触发参数展开逻辑从BoundSql拿到原始 SQL 和原始参数。扫描 SQL 文本找到所有IN (:参数名)片段。从参数对象里取出对应集合或数组计算元素个数 n。把 SQL 里的IN (:参数名)替换成IN (?,?,?,...)n 个问号用逗号分隔。调整BoundSql的ParameterMappings把原来指向:参数名的一个映射替换成 n 个按索引排列的参数映射。在BoundSql的参数对象里按新映射的顺序塞入 n 个实际值。这样做完之后你自己打印出来的 SQL 日志就是干干净净的SELECT * FROM user_info WHERE user_id IN (?, ?, ?, ?, ?)Parameters列表也按顺序排列一眼就能看懂。更关键的是这套逻辑对上层 Mapper 完全透明你接口里依然传一个ListLong但 XML 里不用再写那套标签了。2.3 为什么不直接用 FIND_IN_SET 或拆表有人可能会问既然只是不想展开那我把 List 拼成一个字符串用FIND_IN_SET不行吗我在 2.1 已经提了索引问题这里再展开讲透一点。FIND_IN_SET(user_id, 101,102,103)这类函数在 MySQL 的优化器看来对user_id是没法应用索引扫描的因为它要先从逗号分隔的字符串里解析出每个值再逐个比较。哪怕你的数据只有几千行全表扫描也能勉强撑住可一旦到几百万行、上千万行这条 SQL 就是慢查询排行榜的常客。另外如果字符串里的 ID 数量特别多比如一次性传几千个FIND_IN_SET要处理的字符串长度会非常夸张不仅 MySQL 有max_allowed_packet限制函数本身的 CPU 消耗也很大。反过来用我们自定义扩展生成的IN (?,?,?)是最传统、最标准的数据库写法优化器认识它索引能用上执行计划也能稳定缓存。两相对比方案的差距是代际性的。拆表方案更不建议。有的团队为了让 IN 查询走索引把大表按 ID 取模拆成多张表人为把数据分散。但这解决了索引问题却带来了跨表查询的麻烦你必须知道一个 ID 到底落在哪张表才能去对应的表里查。业务一复杂这拆表逻辑就成了新的复杂度来源。不到万不得已别碰这条思路。3. 自定义扩展核心实现解析器与拦截器3.1 解析 IN 表达式的核心类先实现一个表达式解析器。这个解析器的目标是处理一段 SQL 文本遇到形如IN (:xxx)的片段就把 xxx 作为参数名记录下来同时预留好接下来的替换动作。为了节省篇幅我这里不再贴完整的正则扫描代码重点说思路。你可以用Pattern匹配IN\\s*\\(\\s*:\\w\\s*\\)把匹配到的片段整体拿到。匹配到一个片段后解析出参数名。然后根据参数名在传入的parameterObject中取值。取值时要注意参数可能是Map、JavaBean也可能是 MyBatis 的ParamMap不要写死成某一种类型。我通常的做法是先判断是不是Map是就直接从里面取不是就用反射按属性名取如果都取不到就抛一个可读性好的异常方便定位。拿到参数值之后把它当成一个集合处理。这里要兼容List、Set、数组、Iterable甚至是一个逗号分隔的字符串。为什么兼容字符串因为有些团队的历史接口是ListLong改造成自定义语法后未必能把所有调用方都改成传 List兼容一下字符串能平滑过渡。正常情况下解析器返回一个展开结果对象里面包含两个关键信息展开后的 SQL 文本、以及“参数名 - 展开后的参数值列表”。我这里把参数值的展开放在解析阶段一并完成每匹配到一个:userIdList就取出集合保存成ListObject形式。3.2 拦截器实现解析器搞定后拦截器就顺理成章了。核心逻辑如下Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class InParameterExpandInterceptor implements Interceptor { private static final Pattern IN_PATTERN Pattern.compile(IN\\s*\\(\\s*:\\w\\s*\\), Pattern.CASE_INSENSITIVE); Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String originalSql boundSql.getSql(); // 没有自定义 IN 占位符直接放行避免多余开销 if (!IN_PATTERN.matcher(originalSql).find()) { return invocation.proceed(); } // 解析并展开 SQL同时得到每个参数对应的值列表 ExpandResult expandResult SqlInExpander.expand(originalSql, boundSql.getParameterObject()); // 构建新的 ParameterMapping 列表 ListParameterMapping newMappings buildNewMappings(expandResult); // 生成新参数对象按顺序把展开后的值放进去 Object newParameterObject buildNewParameterObject(expandResult); // 通过反射替换 BoundSql 内部的 SQL 和 mappings ReflectUtil.setFieldValue(boundSql, sql, expandResult.getExpandedSql()); ReflectUtil.setFieldValue(boundSql, parameterMappings, newMappings); ReflectUtil.setFieldValue(boundSql, parameterObject, newParameterObject); return invocation.proceed(); } }核心点有三个。第一反射替换BoundSql内部字段这段代码对 JDK 版本和 MyBatis 版本有一定要求建议用ReflectUtil做统一封装。第二新的ParameterMapping列表每一个展开后的参数都要生成一个独立的ParameterMapping并且property不能用原来的参数名而是用__in_param_0、__in_param_1这类唯一标识。第三新的参数对象我一般用一个MapString, Object存放所有展开后的值key 就是__in_param_0之类的名字。这里有个容易忽略的细节MyBatis 的BoundSql里还有一个additionalParameters和metaParameters如果你在 SQL 里用了foreach它们也可能承载部分参数。但我们的自定义语法里没有 foreach一般影响不大。万一你的项目在同一个 SQL 里混用了老式 foreach 和新式自定义 IN那就要小心处理新建的 ParameterMapping 和 additionalParameters 必须协调否则会报“Parameter xxx not found”的错。我的建议是混用只保留在过渡期新代码统一用自定义扩展。3.3 参数类型适配参数类型适配是个容易踩坑的地方。展开后原来的单个ParameterMapping变成了 n 个那么ParameterHandler在绑定参数时需要从参数对象里按property取出值。如果你把所有展开值放进一个 Map那TypeHandler的类型判断必须正确。比如ListLong展开后__in_param_0的值是Long__in_param_1是LongMyBatis 会走到LongTypeHandler绑定到 PreparedStatement 时设成setLong。这没问题。但如果你传的是ListInteger而 SQL 里比较的字段是bigintMyBatis 不会自动帮你转型数据库那边做隐式转换可能索引失效。所以我在实际项目中会在展开时检查元素类型和ParameterMapping的javaType是否匹配不匹配就记录 warn 日志提示开发自查。再有就是空集合的情况。如果你传了一个空的List我们总不能生成IN ()这在 SQL 语法上就是错的。我的处理策略是捕获到空集合就把对应的 SQL 片段替换成一个恒为假的表达式1 0这样既能保证查询不会报错又符合“空集合查不到数据”的业务预期。如果你需要“空集合查全表”那属于另一套语义建议在业务层显式处理别在拦截器里一刀切。数组类型也要考虑。Java 的基本类型数组比如long[]、Long[]反射取长度和取值的方式跟 List 不一样。我在解析器里统一转成ListObject来处理这样后续逻辑只认集合省心很多。遇到二维数组或者多维数组直接抛异常因为那不属于 IN 参数的正确用法。字符串参数是最特殊的。比如你传了101,102,103这到底算一个值还是三个值我提供的方案是如果参数类型是String并且包含逗号默认按分隔符拆分后再展开。如果你确实想查一个以逗号开头的字符串本身可以在表达式里显式加函数或写法提示避免歧义。不过在我实际使用的项目里这种需求几乎不存在大家传 List 很规矩兼容字符串纯粹是为了接老接口。4. 实操落地改造一个真实 Mapper4.1 改造前 VS 改造后放一个真实场景。某个后台系统里有“批量给用户发通知”的功能需要根据一批用户 ID 查用户信息。改造前的 Mapper 接口ListUserInfo listByUserIds(Param(userIdList) ListLong userIdList);XMLselect idlistByUserIds resultTypecom.example.UserInfo SELECT * FROM user_info WHERE user_id IN foreach collectionuserIdList itemuid open( separator, close) #{uid} /foreach /select改造后接口保持不变XML 变成select idlistByUserIds resultTypecom.example.UserInfo SELECT * FROM user_info WHERE user_id IN (:userIdList) /select是不是一眼清爽很多IN (:userIdList)这个写法完全契合直觉你传一个名为userIdList的参数它就按 List 展开。不需要记collection、item、open、separator、close这一堆标签属性也不用担心写错属性名导致运行时才炸。这里要强调一下因为我在拦截器里兼容了空集合和逗号分隔字符串所以即使接口被别的地方复用传入一个null或空 ListSQL 也能正常执行不会在IN ()上出错。业务方少写了一堆判空分支。4.2 动态 SQL 与静态 SQL 的取舍可能有朋友会想既然自定义扩展这么好是不是所有IN场景都可以用我的答案是不是。动态 SQL 里如果还有其他条件比如创建时间范围、状态过滤这些该用if的还是要用if。我们的自定义扩展只解决“IN 展开”这一件事不要试图把所有动态条件都塞进这个机制里。举个例子一个用户查询页面筛选项很多其中用户 ID 的批量筛选是触发条件之一。这个时候XML 依然需要where标签和if来控制条件拼接。我改造后的写法是select idsearchUser resultTypeUserInfo SELECT * FROM user_info where if teststatus ! null AND status #{status} /if if testuserIds ! null and userIds.size() 0 AND user_id IN (:userIds) /if /where /select注意IN (:userIds)这里并没有用if去判断userIds.size() 0吗我写了if是为了在 SQL 层面避免走一个恒假的1 0。虽然拦截器里也做了空集合保护但if的语义更直接其他开发一看就知道空集合时不会执行这条条件。两者结合使用可读性最好。这种取舍背后是清晰的边界动态条件用 MyBatis 原生if/where参数展开用自定义扩展。大家各司其职代码既灵活又干净。4.3 日志输出与可观测性提升改造完之后最直观的感受是排障效率大幅提升。以前 DEBUG 日志打印出来的 SQL 是一长串问号现在打印出来的依然是IN (?, ?, ?, ?, ?)但问题在于你至少能在代码里一眼看到:userIdList对应的是这个参数。为了进一步可观测我在拦截器里加了一个可选日志开关展开完成后把“参数名 - 实际值列表”打印成一行摘要例如[in-expander] userIdList expanded, size5, values101,102,103,104,105这个日志级别设为 DEBUG平时不输出排查问题时打开即可。相比去日志平台翻被截断的 Parameters这种方式对开发和运维都友好太多。另外一个提升点SQL 审计。公司有 DBA 或者安全团队要定期抽检 SQL以前 foreach 生成的动态 SQL 在审计系统里非常难看因为参数位充满了随机长度。现在展开后的 SQL 是标准的、可复制的预编译语句审计人员拿去数据库执行也毫无压力。5. 常见问题与排查技巧实录5.1 常见问题速查表我把实际踩过的坑整理成一张表方便遇到问题时快速对照排查。现象原因解决方案报错Parameter userIdList not found参数对象是 POJO但属性名和:userIdList不匹配检查 XML 里的参数名是否和 Java 属性一一对应SQL 变成IN (1 0)导致查不到数据传入集合为空触发空集合保护这是预期行为确认业务是不是真的允许空集合日志里有IN (?, ?, ?)但查询超时集合元素太多展开后的问号数量巨大控制单次批量查询的 ID 数量比如上限 1000查询结果用了索引但执行计划不稳定元素数量变化导致 SQL 文本变化把数量固定为 2 的幂次比如 8、16、32不足补 0String类型的参数被误拆元素本身就是带逗号的字符串在表达式后加:keep提示或换用 List 类型PageHelper 分页不生效分页拦截器和 IN 扩展拦截器执行顺序不一致调整Intercepts的order让分页拦截器在 prepare 之前执行其中“执行计划不稳定”这一点我想多说两句。数据库对 SQL 做参数化之后如果?数量固定执行计划可以缓存复用。如果你的业务查询ID 数量有时候 3 个有时候 50 个那我建议你不要把 SQL 文本完全依赖集合大小而是人为固定一个上限。比如在拦截器里如果发现集合超过预设阈值就切成多批查询每批固定 1000 个参数。虽然多了一次查询但避免了超长 SQL 和解析开销整体性能反而更好。5.2 避坑清单最后列几个我趟过的坑写在这里当提醒。第一个坑拦截器里直接改 SQL 时千万别忘了同步更新BoundSql里的parameterMappings。我最早只改了sql字段结果执行时报Parameter index out of range排查半天才发现映射列表没换。MyBatis 绑定参数是按parameterMappings一个个来的这个列表必须和 SQL 里的?数量一致。第二个坑多个拦截器叠加时顺序非常关键。我有一次和自定义的分页拦截器一起用结果分页拦截器对展开后的 SQL 做了 count 查询但参数列表还没来得及展开导致 count 的?数量和参数数量错位。后来我把 IN 扩展拦截器放在最外层保证 prepare 阶段第一步就把 SQL 和参数整理好后续所有依赖 BoundSql 的逻辑拿到的都是终态。第三个坑正则替换要小心大小写和空白。有人写in (:ids)小写in前面还有多个空格如果你的正则没做好忽略大小写和空白就会出现“替换了又没替换”的诡异情况。我的做法是编译正则时直接带上CASE_INSENSITIVE并且\\s*处理空格这样无论怎么写都能命中。第四个坑TypeHandler的选择。前面提过如果集合里是Integer数据库字段是bigintMyBatis 默认用IntegerTypeHandler绑定参数数据库做隐式转换时可能让索引失效。我在做参数展开时特意把元素的 javaType 和数据库字段的类型映射关系打印出来方便一眼发现问题。第五个坑SQL 注入风险。有人一听到“自定义扩展”就担心会不会破坏预编译机制。其实完全不用担心我们展开后仍然生成?占位符参数值通过PreparedStatement绑定不会拼进 SQL 文本。安全性上和原生#{userId}完全一致这也是我坚持用占位符而不是直接把值拼进 SQL 的原因。如果你在扩展时图省事直接字符串拼接那才是给 SQL 注入留后门千万不要那么干。我在实际项目中用这套自定义扩展跑了两年多最深的体会是一个团队的技术债务很多时候不是靠多写防御性代码解决的而是靠把重复劳动和易错模式消灭掉。foreach 模板本身不复杂但它把“参数展开”这件事暴露给每个写 Mapper 的人于是每个人都可能踩坑。自定义扩展把复杂度收敛到一处交给框架层去处理普通的 CRUD 代码就回归了它本来的简洁。如果你也在维护一个 MyBatis 项目我特别建议先把这套扩展在一个非核心的查询上试点跑通再逐步推广到所有批量查询。你会发现SQL 写起来舒服了排查慢查询也顺手了团队里因为collection写错而浪费时间的讨论也会大幅减少。这是我这两年最满意的一次技术投资。