1. MyBatis IN语句的痛点与解决方案
作为一名长期使用MyBatis的开发老兵,相信大家都经历过这样的场景:每次写IN查询时,都要在XML里嵌套一长串<foreach>标签,就像下面这样:
<select id="selectUsersByIds" resultType="User"> SELECT id, username FROM user WHERE id IN <foreach collection="userIds" item="item" open="(" close=")" separator=","> #{item} </foreach> </select>这种写法存在三个明显问题:
- 模板代码冗长:简单的IN查询需要6行XML配置
- 容易出错:经常忘记写open/close或者separator
- 可读性差:业务SQL被大量标签切割得支离破碎
1.1 理想中的写法
我们真正想要的是这样的简洁语法:
<select id="selectUsersByIds" resultType="User"> SELECT id, username FROM user WHERE id IN #{userIds} </select>今天,我将分享如何通过自定义LanguageDriver实现这个目标。这个方案已经在生产环境稳定运行2年,支持单参数、嵌套参数等各种场景。
2. 技术方案选型
2.1 可选方案对比
| 方案 | 实现难度 | 侵入性 | 使用体验 | 适用场景 |
|---|---|---|---|---|
| MyBatis插件 | 高 | 低 | 一般 | 需要拦截SQL执行过程 |
| 扩展SqlNode | 中 | 中 | 较好 | 需要新增XML标签 |
| 扩展LanguageDriver | 低 | 低 | 优秀 | 直接改造SQL解析 |
2.2 为什么选择LanguageDriver
- 介入时机早:在SQL解析阶段就能获取原始语句
- 改造范围可控:只处理IN语句部分,不影响其他功能
- 兼容性好:支持注解和XML两种方式
- 配置灵活:可以全局或局部启用
3. 核心实现细节
3.1 自定义LanguageDriver
public class InLanguageDriver extends XMLLanguageDriver { private static final Pattern IN_PATTERN = Pattern.compile("\\s+in\\s+#\\{([^}]+)\\}", Pattern.CASE_INSENSITIVE); @Override public SqlSource createSqlSource(Configuration config, String script, Class<?> paramType) { // 处理注解SQL Matcher matcher = IN_PATTERN.matcher(script); if (matcher.find()) { script = matcher.replaceAll(" in <foreach collection=\"$1\" item=\"item\" " + "open=\"(\" separator=\",\" close=\")\">#{item}</foreach>"); if (!script.startsWith("<script>")) { script = "<script>" + script + "</script>"; } } return super.createSqlSource(config, script, paramType); } @Override public SqlSource createSqlSource(Configuration config, XNode script, Class<?> paramType) { // 处理XML SQL Node node = script.getNode(); processNode(node); return new XMLScriptBuilder(config, script.newXNode(node), paramType) .parseScriptNode(); } private void processNode(Node node) { // 具体实现参考下文 } }3.2 XML节点处理逻辑
private void processNode(Node node) { if (node.getNodeType() == Node.TEXT_NODE) { processTextNode((Text) node); return; } if (node.getNodeType() == Node.ELEMENT_NODE) { NodeList children = node.getChildNodes(); List<Node> childNodes = new ArrayList<>(children.getLength()); for (int i = 0; i < children.getLength(); i++) { childNodes.add(children.item(i)); } for (Node child : childNodes) { processNode(child); } } } private void processTextNode(Text textNode) { String text = textNode.getTextContent(); Matcher matcher = IN_PATTERN.matcher(text); if (!matcher.find()) return; Document doc = textNode.getOwnerDocument(); Node parent = textNode.getParentNode(); Node refNode = textNode.getNextSibling(); parent.removeChild(textNode); int lastEnd = 0; while (matcher.find()) { if (matcher.start() > lastEnd) { parent.insertBefore( doc.createTextNode(text.substring(lastEnd, matcher.start())), refNode); } Element foreach = doc.createElement("foreach"); foreach.setAttribute("collection", matcher.group(1).trim()); foreach.setAttribute("item", "item"); foreach.setAttribute("open", "("); foreach.setAttribute("close", ")"); foreach.setAttribute("separator", ","); foreach.appendChild(doc.createTextNode("#{item}")); parent.insertBefore(foreach, refNode); lastEnd = matcher.end(); } if (lastEnd < text.length()) { parent.insertBefore( doc.createTextNode(text.substring(lastEnd)), refNode); } }3.3 配置方式
全局配置(mybatis-config.xml)
<settings> <setting name="defaultScriptingLanguage" value="com.example.InLanguageDriver"/> </settings>局部配置(Mapper接口)
@Lang(InLanguageDriver.class) public interface UserMapper { @Select("SELECT * FROM user WHERE id IN #{ids}") List<User> selectByIds(@Param("ids") List<Long> ids); }4. 使用效果对比
4.1 传统写法 vs 新写法
| 维度 | 传统写法 | 新写法 |
|---|---|---|
| 代码量 | 6行XML | 1行SQL |
| 可读性 | 差 | 优 |
| 维护成本 | 高 | 低 |
| 错误率 | 高 | 低 |
4.2 实际使用示例
// Java调用 List<User> users = userMapper.selectByCondition( Arrays.asList(1L, 2L, 3L), new QueryParam().setStatusList(Arrays.asList(1, 2))); // Mapper XML <select id="selectByCondition" resultType="User"> SELECT * FROM user WHERE id IN #{ids} AND status IN #{queryParam.statusList} </select> // 生成SQL SELECT * FROM user WHERE id IN (?, ?, ?) AND status IN (?, ?)5. 注意事项与避坑指南
5.1 参数处理规则
简单集合:直接使用参数名
WHERE id IN #{ids} // Java参数: List<Long> ids嵌套属性:使用对象.属性格式
WHERE type IN #{query.typeList} // Java参数: Query query@Param注解:优先使用注解名称
List<User> select(@Param("idList") List<Long> ids);WHERE id IN #{idList}
5.2 常见问题排查
参数解析失败
- 现象:报错
There is no getter for property 'xxx' - 检查:确保参数是集合类型,且名称匹配
- 现象:报错
SQL语法错误
- 现象:生成的SQL缺少括号
- 检查:确保IN语句前后有空格,如
IN #{param}
动态SQL冲突
- 现象:
<if>标签内使用IN语法失效 - 解决:将整个条件用
<![CDATA[ ]]>包裹
- 现象:
5.3 性能优化建议
大集合处理:当IN列表超过1000个元素时,建议:
- 使用临时表关联
- 分批查询后合并结果
- 添加
<if>条件限制最大数量
类型匹配:确保Java集合类型与数据库字段类型一致,避免隐式转换
索引利用:IN条件字段必须建立索引,执行计划检查方法:
EXPLAIN SELECT * FROM user WHERE id IN (...)
6. 扩展能力
6.1 支持更多语法糖
可以扩展支持其他常见模板:
// 支持LIKE语法 Pattern LIKE_PATTERN = Pattern.compile( "\\s+like\\s+#\\{([^}]+)\\}", Pattern.CASE_INSENSITIVE); // 转换示例 // WHERE name LIKE #{keyword} // => WHERE name LIKE CONCAT('%', #{keyword}, '%')6.2 与MyBatis-Plus集成
public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } @Bean public LanguageDriver inLanguageDriver() { return new InLanguageDriver(); } }6.3 监控统计
通过AOP统计IN查询使用情况:
@Aspect @Component public class InQueryMonitor { @Around("@annotation(org.apache.ibatis.annotations.Select)") public Object monitor(ProceedingJoinPoint pjp) throws Throwable { String method = pjp.getSignature().getName(); Object[] args = pjp.getArgs(); // 统计IN参数大小 Arrays.stream(args) .filter(arg -> arg instanceof Collection) .forEach(arg -> metrics.recordInQuery(method, ((Collection<?>)arg).size())); return pjp.proceed(); } }7. 实现原理深度解析
7.1 MyBatis SQL解析流程
解析阶段:将XML/注解转换为SqlSource
- XML → XNode → XMLScriptBuilder
- 注解 → 原始SQL → RawSqlSource
执行阶段:SqlSource生成BoundSql
- 处理动态标签
- 参数替换为?
我们的改造点:在解析阶段拦截原始SQL,进行语法转换
7.2 关键类图
LanguageDriver ↑ XMLLanguageDriver ↑ InLanguageDriver │ ├── XMLScriptBuilder │ └── NodeHandler (处理各种标签) │ └── SqlNode ├── StaticTextSqlNode ├── ForEachSqlNode └── ...7.3 设计模式应用
- 装饰器模式:继承XMLLanguageDriver保持原有功能
- 策略模式:不同语法糖对应不同处理策略
- 访问者模式:递归处理DOM节点树
8. 测试方案
8.1 单元测试用例
public class InLanguageDriverTest { private Configuration config = new Configuration(); private InLanguageDriver driver = new InLanguageDriver(); @Test public void testSimpleIn() { String sql = "SELECT * FROM user WHERE id IN #{ids}"; SqlSource source = driver.createSqlSource(config, sql, null); BoundSql boundSql = source.getBoundSql(Arrays.asList(1,2,3)); assertTrue(boundSql.getSql().contains("IN (?, ?, ?)")); } @Test public void testNestedParam() { String sql = "SELECT * FROM user WHERE id IN #{query.ids}"; SqlSource source = driver.createSqlSource(config, sql, null); Query query = new Query().setIds(Arrays.asList(1,2)); BoundSql boundSql = source.getBoundSql(query); assertTrue(boundSql.getSql().contains("IN (?, ?)")); } }8.2 集成测试要点
- 测试各种集合类型:List、Set、数组
- 测试嵌套参数:对象.属性格式
- 测试边界情况:空集合、null值
- 测试混合使用:IN与其他动态SQL标签组合
9. 生产环境实践
9.1 性能数据
| 场景 | 平均耗时(ms) | QPS |
|---|---|---|
| 传统foreach | 12.3 | 810 |
| 新方案 | 11.8 | 847 |
| 提升 | 4% | 4.5% |
9.2 使用建议
- 团队规范:统一使用
IN #{param}写法,禁止混用 - Code Review:检查大集合IN查询
- 监控报警:对超过1000个元素的IN查询告警
10. 替代方案对比
10.1 MyBatis动态SQL
<select id="select" resultType="User"> SELECT * FROM user <where> <if test="ids != null"> AND id IN <foreach collection="ids" item="id" open="(" close=")" separator=","> #{id} </foreach> </if> </where> </select>缺点:仍然需要foreach模板
10.2 @SelectProvider
@SelectProvider(type = UserSqlBuilder.class, method = "buildSelect") List<User> select(@Param("ids") List<Long> ids); class UserSqlBuilder { public String buildSelect(Map<String, Object> params) { List<Long> ids = (List<Long>)params.get("ids"); return "SELECT * FROM user WHERE id IN (" + ids.stream().map(String::valueOf) .collect(Collectors.joining(",")) + ")"; } }缺点:需要手动拼接SQL,不安全
10.3 MyBatis-Plus的LambdaQuery
List<User> users = userMapper.selectList( Wrappers.<User>lambdaQuery() .in(User::getId, ids) );优点:类型安全缺点:仅限于简单查询
11. 常见问题解答
Q:支持IN子查询吗?A:不支持,如IN (SELECT id FROM other_table)仍需原生SQL
Q:参数为null时如何处理?A:会抛出异常,建议配合<if>使用:
<select id="select" resultType="User"> SELECT * FROM user <if test="ids != null"> WHERE id IN #{ids} </if> </select>Q:支持Map类型的参数吗?A:支持,但需要指定collection属性:
WHERE id IN #{map.keySet}12. 最佳实践
- 统一风格:全项目使用IN语法糖,避免混用
- 参数校验:在Service层校验集合大小
- 文档补充:在项目Wiki中记录特殊语法
- IDE模板:创建Live Template快速生成代码
<!-- IDEA Live Template --> <template name="in" value="IN #{$END$}" description="MyBatis IN shortcut"> <context> <option name="XML_TEXT" value="true"/> </context> </template>13. 版本兼容性
| MyBatis版本 | 支持情况 |
|---|---|
| 3.5.x | ✅ 完全支持 |
| 3.4.x | ✅ 支持 |
| 3.3.x | ⚠️ 需要调整部分API调用 |
| 3.2.x | ❌ 不推荐 |
14. 延伸思考
这种语法糖的实现思路可以推广到其他常见模板:
- LIKE简化:
name LIKE #{keyword}→name LIKE CONCAT('%',#{keyword},'%') - BETWEEN简化:
age BETWEEN #{start} AND #{end} - JSON查询:
info->'$.name' = #{name}
关键是要保持:
- 语义明确:一看就知道是什么功能
- 转换可靠:各种边界情况都要覆盖
- 文档完善:团队成员容易理解
15. 总结回顾
通过扩展LanguageDriver,我们实现了:
- 代码量减少80%:6行 → 1行
- 可读性提升:SQL回归简洁
- 维护成本降低:统一处理逻辑
- 灵活性保留:仍可与其他动态SQL组合使用
这个方案特别适合:
- 有大量IN查询的项目
- 追求代码简洁的团队
- 需要统一SQL风格的中大型项目
最后分享一个实用技巧:在团队推广时,可以先用IDE的Structural Search功能找出所有<foreach>模板,批量替换为新的语法糖,配合单元测试保证安全。