MyBatis动态SQL实战:OGNL陷阱、缓存与批量操作全解析
上个月组里一个小伙子排查一个线上问题查了整整一下午。场景不复杂后台系统里有一个状态筛选前端传status0时列表应该过滤出所有未启用的用户结果过滤条件完全没生效。他把 Mapper 里的 SQL 翻来覆去看了好几遍SQL 单独拎到数据库客户端里跑也没有问题最后才发现是 XML 里一个不起眼的if判断把0给吞了。这种问题在 MyBatis 项目里太典型了。MyBatis 作为 Java 生态里使用率最高的持久层框架之一表面上看不过是接口加 XML但真正跑起来之后动态 SQL 的解析规则、缓存的作用边界、批量执行的底层行为、日志的配置方式每个环节都有不少隐藏细节。这篇我就用一个完整的 MyBatis 示例串起这些内容从项目搭建讲到实际开发里最容易踩的坑尽量把框架讲透。1. 最小示例跑通之后先搞清楚这几层对应关系1.1 三要素配置、Mapper接口、XML映射文件不管你是从零学 MyBatis还是在 Spring Boot 项目里用现成的 starter底层都离不开这三样东西全局配置文件、Mapper 接口、XML 映射文件。我见过不少人上来就撸代码结果连 Mapper 接口和 XML 是怎么对应上的都说不清后面排查问题全靠猜。先准备一个最简单的库表CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) DEFAULT NULL, age int(11) DEFAULT NULL, status varchar(10) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应的实体类public class User { private Long id; private String name; private Integer age; private String status; // getter/setter 省略 }然后是最小可用的mybatis-config.xml。单独使用 MyBatis不接 Spring时这个文件是入口?xml version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN https://mybatis.org/dtd/mybatis-3-config.dtd configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ /settings environments defaultdev environment iddev transactionManager typeJDBC/ dataSource typePOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/demo?useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ /dataSource /environment /environments mappers mapper resourcemapper/UserMapper.xml/ /mappers /configurationMapper 接口public interface UserMapper { User findById(Long id); ListUser search(MapString, Object params); int insert(User user); int batchInsert(ListUser users); }XML 映射文件UserMapper.xmlmapper namespacecom.example.mapper.UserMapper select idfindById resultTypecom.example.entity.User SELECT id, name, age, status FROM user WHERE id #{id} /select insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO user(name, age, status) VALUES(#{name}, #{age}, #{status}) /insert /mapper这里最容易被忽略的是对应关系的三条规定namespace必须等于 Mapper 接口的全限定名select等标签的id必须等于接口方法名接口方法的参数类型、返回值类型要和 XML 里parameterType、resultType严格匹配。这三条只要有一条对不上启动时不一定报错调用时才报Invalid bound statement (not found)排查起来特别容易绕圈子。1.2 为什么是半自动框架JDBC 到 MyBatis 的设计思路有人会问都什么年代了项目里还有必要单独拎一个 MyBatis 示例出来讲吗我的看法是越基础的框架越值得搞明白底层。MyBatis 的设计思路说穿了就是把 JDBC 的样板代码收编把 SQL 控制权留给开发者。用原生 JDBC 写一个查询要经历加载驱动、获取连接、创建 Statement、填充参数、执行查询、遍历 ResultSet、释放资源这一整套流程。MyBatis 帮你做掉了其中大部分但你依然要亲手写 SQL。这就是半自动和 Hibernate 这类全自动ORM 的核心差别。全自动框架想在单表 CRUD 上省事但遇到复杂查询、多表关联、数据库分页方言反而要花大量时间绕开它的规则。MyBatis 选择了相反的方向把 SQL 的控制权交给你框架只负责参数映射和结果映射。所以你在评估一个项目要不要引入 MyBatis 时不要只看 CRUD 快不快要看团队的 SQL 能力。团队对 SQL 有掌控力MyBatis 能让复杂查询如虎添翼团队全是 ORM 思维一开始就倾向于全自动那 MyBatis 的灵活反而可能变成负担。1.3 示例运行时的常见卡点跑通这个最小示例时有几个高频问题我直接列出来org.apache.ibatis.binding.BindingException多半是 namespace 写错或者接口全限定名和 XML 的 namespace 不一致Invalid bound statementXML 文件没有打到 classpath 里。Maven 项目要注意src/main/resources下的 mapper 目录是否被正确打包如果 XML 放在src/main/java下需要在 pom.xml 里配置 resource 规则Unknown JDBC driverMySQL 8.x 驱动类名是com.mysql.cj.jdbc.Driver老版本才是com.mysql.jdbc.Driver网上很多教程还在用旧类名连接串里的符号在 XML 里必须写成amp;否则解析报错。这些坑都不深但拦住了不少人。建议你把上面这个最小示例亲手跑一遍再往后看动态 SQL 和缓存就不会觉得是在听天书了。2. 单个数字字符比较的翻车现场OGNL 在 XML 里的隐性规则2.1 事故复盘status0 为什么被过滤条件丢弃回到开头那个线上问题。当时 Mapper 里的动态 SQL 是这样写的select idsearch resultTypecom.example.entity.User SELECT id, name, age, status FROM user WHERE 1 1 if teststatus ! null and status ! AND status #{status} /if if teststatus 0 AND name LIKE CONCAT(%, #{name}, %) /if /select前端传了status0和namezhang结果status 0这个条件怎么都不进。单独拿 SQL 去数据库客户端跑数据没问题去掉if直接写死AND status 0也没问题。于是整段 SQL 被反复检查浪费了大量时间。其实问题出在引号上。MyBatis 的if标签是交给 OGNL 表达式引擎解析的在 OGNL 的世界里单引号包一个字符是char类型不是String类型。0是一个字符常量Character[0]而status从 HTTP 请求进来也好从Map里取出来也好都是String类型。String.equals(Character)的结果永远是false所以这个条件永远不走而且不会报错。这就是单个数字字符比较最坑的地方它不像空指针那样大声报错而是安静地吞掉你的条件。2.2 OGNL 中单引号与双引号根本不是一回事我用一个小工具类做了个对照实验把各种写法在 MyBatis 3.5.16 里的真实表现记录了下来test 表达式status 实际值结果原因status 00Stringfalse0被 OGNL 解析为 charString 与 char 不相等status 00Stringtrue0是 String两者 equalsstatus 0.toString()0Stringtrue显式转成 String 再比较status ! null and status ! 0Stringtrue在 OGNL 里被当成空字符串能正常判断status ! null and status ! Stringfalse空字符串被正确排除status ! null0Stringtrue只判 null 永远进注意看表格里第二种写法status 0。在 XML 属性值本身用双引号包裹时内层再写双引号会冲突所以很多人习惯性地把内层也改成单引号于是坑就踩上了。正确做法是让 XML 属性值用单引号包裹内部表达式用双引号if teststatus 0如果你已经被注释或代码风格绑架必须让 test 属性用双引号那内部可以用0.toString()或者干脆写成0.equals(status)。我个人最推荐的是if teststatus ! null and status 0这样可读性最好也不容易再被 OGNL 的类型规则坑到。2.3 再深挖一层为什么有的写法在旧版本里表现不一样聊到这儿可能有老读者会问网上好多文章说status ! 也会把0吞掉怎么回事这个说法确实存在但多半是旧版本 OGNL 对单引号空字符串的解析有差异或者是把和 混为一谈。 是单个空格字符是空字符串两者完全不同。我在新版本里实测status ! 对0的判断是正常的。但为了避免版本差异和团队理解成本我建议你在判断字符串是否为空时不要过度依赖这种魔法写法。如果需求只关心有没有传值优先写status ! null如果确实需要排除空字符串再在 Java 层把空串统一转成 null让 XML 里的判断保持简单。把复杂逻辑从模板语言里挪到 Java 里是减少这类问题的最有效手段。这个坑还有个变体就是拿数字0和空字符串比较。如果status是Integer类型status ! 在 OGNL 里的行为同样会受到类型转换影响稳妥做法依然是只判 null 或者在 Java 层预处理。3. 缓存不能想当然一级二级缓存在示例里的真实表现3.1 一级缓存同一个 SqlSession 里的隐身命中MyBatis 的一级缓存默认就是开启的作用域是SqlSession。也就是说在同一个SqlSession里执行完全相同的两条查询语句第二次不会真的打数据库。比如这段代码try (SqlSession session MyBatisConfig.build().openSession(true)) { UserMapper mapper session.getMapper(UserMapper.class); User u1 mapper.findById(1L); User u2 mapper.findById(1L); System.out.println(u1 u2); }输出结果是true。原因就是第二次findById(1L)命中了一级缓存返回的是同一个对象引用。但这里有一个容易误解的点一级缓存是SqlSession级别的不是 Mapper 级别的也不是全局的。很多人以为既然 MyBatis 有缓存那我在任何地方查同一个 id 都是缓存这是错的。一旦SqlSession关闭或清空一级缓存就没了。而到了 Spring Boot 整合的场景SqlSessionTemplate会为每次数据库操作管理SqlSession的生命周期没有事务时每次 Mapper 方法调用基本都是新建一个会话、用完关闭一级缓存自然就不跨方法共享。所以你别指望靠一级缓存提升 Spring Boot 项目里的查询性能先弄清楚它的作用域再谈优化。3.2 二级缓存开启很简单踩坑更容易二级缓存的作用域是 namespace也就是一个 Mapper XML 对应一个缓存区域。开启方式很简单在UserMapper.xml里加一行cache evictionLRU flushInterval60000 size512 readOnlytrue/但开启之前必须先明确两个硬性条件查询结果对应的实体类必须实现Serializable接口因为二级缓存可能涉及序列化存储Mapper 里的所有增删改操作都会触发该 namespace 的缓存清空但其他 namespace 的更新不会通知这里。怎么观察二级缓存有没有生效把日志级别调到 DEBUG你会看到类似输出Cache Hit Ratio [com.example.mapper.UserMapper]: 0.5这个数字代表缓存命中率。第一次查询时缓存未命中执行 SQL第二次相同查询命中缓存不再执行 SQL。我建议你写个独立示例手动开两个SqlSession分别查同一个 id对比日志里 SQL 的输出次数。这样远比死记二级缓存是跨 SqlSession 的要牢靠。3.3 一次多表查询导致的缓存脏读二级缓存最大的坑是多表关联时的脏读。举一个很典型的案例UserMapper里有一条 SQL 关联查询order表查询结果被缓存到了com.example.mapper.UserMapper这个 namespace。此时另一个请求在OrderMapper里删掉或修改了一条订单OrderMapper的清空操作只会清空OrderMapper自己的 namespace 缓存不会去管UserMapper的缓存。结果就是UserMapper的缓存里还留着旧订单数据用户在页面上看到的数据是错的。所以我的经验是二级缓存只有在单一 Mapper 完全负责自己涉及的所有表时才比较安全。一旦出现跨表查询要么给关联查询所在的 Mapper 设置useCachefalse要么干脆不用二级缓存。现实中很多项目默认关闭二级缓存不是因为它不好而是因为脏读一旦发生定位成本远比省下的那点数据库压力高。4. 批量写操作三选一foreach、BATCH 执行器还是插件4.1 批量插入最常见的做法foreach 拼接很多人的第一个批量插入示例就是foreach拼 SQLinsert idbatchInsert INSERT INTO user(name, age, status) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.status}) /foreach /insert这种写法本质上是生成一条多 VALUES 的大 SQL然后一次性发给数据库。优点非常明显网络往返只有一次SQL 清晰直观数据量不大的时候速度非常快。但它有两个限制你要心里有数数据库对大 SQL 有长度限制。MySQL 的max_allowed_packet默认一般是 64MB看起来够大但如果你批量插入的字段里有超大文本、图片 base64 之类很容易超限某些数据库或中间件对单条 SQL 的 VALUES 数量有限制比如 Oracle 旧版本允许的INSERT ALL数量有限分页中间件也可能因为 SQL 过大影响性能。另外foreach拼接出来的 SQL 没法利用预编译语句的 SQL 缓存。网上有人拿 10 万条数据测过foreach是能跑但性能和内存都不是最优所以它适合几百条以内的场景。4.2 ExecutorType.BATCH 的原理与使用限制批量操作的第二个方案是使用 MyBatis 的ExecutorType.BATCHtry (SqlSession session MyBatisConfig.build().openSession(ExecutorType.BATCH, false)) { UserMapper mapper session.getMapper(UserMapper.class); for (User user : userList) { mapper.insert(user); } session.commit(); }关键区别在于ExecutorType.BATCH不会每调用一次insert就立刻执行 SQL而是把多条相同结构的 SQL 累积在 JDBC 的批量缓冲区里最后在commit()时一次性提交。但这里有个很容易被忽略的配置MySQL JDBC 驱动默认不一定真的走批量执行。你需要在连接串上加上参数jdbc:mysql://localhost:3306/demo?rewriteBatchedStatementstrue不加这个参数驱动可能把批量语句拆成单条执行性能收益大打折扣。加了之后BATCH才能真正形成多行 INSERT 或批量预编译执行。使用BATCH执行器还有几个注意事项混用查询时要小心。在BATCH模式下执行查询会先触发一次隐式的flushStatements()把之前的批量操作刷出去否则可能拿到过时数据批量插入后立即获取自增主键行为可能和普通模式不一样返回的id可能没有被正确回填需要根据场景验证批量操作一定要放在事务里并且注意commit()的时机不要每循环一次就 commit那等于让批处理白干。4.3 实际开发中怎么选型搜索热词里有一条是实际开发时这种情况多吗。我可以明确说批量写操作在真实项目中非常常见数据导入、报表初始化、库存批量调整、消息落库都会遇到。我的选型习惯是场景推荐方案一次性导入几百到几千条foreach拼接简单直观够快几万条以上且 SQL 结构固定ExecutorType.BATCHrewriteBatchedStatements单表 CRUD 为主项目已用 MyBatis-PlusIService.saveBatch省事但要看它对批次的封装方式大量数据且 SQL 复杂考虑分批 多线程或者走中间件导入不要硬怼一条超大 SQL还有个实操心得无论用哪种方案都建议先设一个小批次做上限比如每批 1000 条循环插入。这样可以避免单次事务过大导致锁竞争和回滚日志膨胀。把批的概念从一次全部改成分批固定大小是很多性能问题的最简单解法。5. 让 SQL 日志开口说话三个层级的排查手段5.1 最省事的办法STDOUT_LOGGING 与 logImplMyBatis 日志打印很多人第一反应是加 log4j 或者 logback 配置其实 MyBatis 自己还带了一个极其简单的STDOUT_LOGGING。在mybatis-config.xml里settings setting namelogImpl valueSTDOUT_LOGGING/ /settings运行示例时控制台会直接输出类似这样的内容 Preparing: SELECT id, name, age, status FROM user WHERE id ? Parameters: 1(Long) Columns: id, name, age, status Row: 1, zhang, 20, 0 Total: 1这种方式的优点是零依赖、配置简单缺点是输出格式比较原始而且会打印到标准输出。在本地调试时用一用没问题但生产环境千万别开日志量会变得非常大还可能把参数值打到日志文件里带来安全隐患。5.2 用 MyBatis Log Plugin 还原完整 SQL 的注意事项很多人喜欢在 IDEA 里装 MyBatis Log Plugin 这类插件它能把上面那段Preparing和Parameters还原成一条可以直接复制的完整 SQLSELECT id, name, age, status FROM user WHERE id 1这在排查复杂 SQL、人工验证逻辑时确实很方便。但你需要清楚它的原理它只是拦截了 MyBatis 的日志输出把?占位符替换成参数值并不是 MyBatis 真的执行了这样一条 SQL。所以遇到参数类型转换、绑定失败之类的问题时它还原出的完整 SQL不一定能准确反映数据库真实接收到的内容。使用这类插件的安全建议是不要因为它显示了完整 SQL就忽略预编译本身的价值生产环境仍然要依赖#{}参数绑定来防注入插件也可能因为版本不兼容导致控制台输出错乱误以为 SQL 有问题其实只是插件的解析问题在团队项目里建议把排查结论留在代码注释里而不是口口相传这个插件能看到完整 SQL方便新人理解。5.3 从日志反推参数绑定问题的排查思路日志排查最有价值的场景是定位参数绑定相关的诡异问题。比如你在 XML 里写了select idsearch resultTypecom.example.entity.User SELECT id, name, age, status FROM user WHERE name #{name} AND status #{status} /select调用时只传了一个参数对象或者参数名写错了MyBatis 的报错信息有时不够直观。这时把日志打开先看Preparing那行打印出来的?数量是否和 SQL 里的#{xxx}数量一致。如果?数量对但值不对再看Parameters行的参数顺序是不是和#{}顺序对应。还有一个我自己踩过的坑多个参数没有加Param注解时MyBatis 会当成param1、param2来处理。日志里参数显示正常但 SQL 结果就是不对就是因为 XML 里写的是#{name}实际参数名却是param1。这时候日志能帮你迅速发现到底是参数名不匹配还是 SQL 本身逻辑有问题而不是盯着那一大段 SQL 干瞪眼。6. 进入 Spring Boot 生态注解、XML、MyBatis-Plus 怎么选6.1 Spring 是如何把 Mapper 接口变成 Bean 的单独使用 MyBatis 时Mapper 接口要靠手动session.getMapper(UserMapper.class)来实例化。而在 Spring Boot 项目里你可以直接在 Service 里Autowired一个 Mapper 接口这就让很多人产生了疑问接口没有实现类Spring 是怎么注入进来的答案藏在MapperFactoryBean和MapperScannerConfigurer里。Spring Boot 启动时会扫描MapperScan指定包下的所有接口为每个接口生成一个动态代理对象这个代理负责把方法调用转发到 SqlSession 去执行 XML 或注解里定义的操作。所以你可以这样理解Spring 容器里的 Mapper Bean 不是接口的实现类而是 MyBatis 为接口生成的一个代理。这也是为什么我们强调 namespace 全限定名必须和接口全限定名一致因为代理要找 XML 全靠这个命名空间去定位。如果启动时报MapperScan扫描不到 Mapper排查顺序一般是MapperScan的包路径是否覆盖了 Mapper 接口所在包Mapper 接口上是否加了Mapper注解用了MapperScan就不用每个接口都加是否在启动类上同时出现多个冲突的扫描配置。6.2 注解与 XML 的边界Spring Boot 项目里经常看到有人用注解写 SQLSelect(SELECT id, name, age, status FROM user WHERE id #{id}) User findById(Long id);也有很多人坚持用 XML。我的经验是两条路都行但要看边界。注解适合简单 CRUD、固定 SQL优点是零配置文件、代码量少。但一旦出现动态 SQLif判断、foreach循环、choose分支注解里写起来就非常痛苦。你当然可以用script标签包一层那可就太丑了维护性很差。XML 适合复杂查询、动态 SQL、多表关联支持团队沉淀 SQL 审查流程。缺点是文件数量多需要配置 mapper 路径。个人建议的划分标准是单表简单 CRUD、现场排障时方便快速看逻辑的可以用注解涉及动态条件、批量操作、复杂关联查询的一律放 XML。6.3 MyBatis-Plus 能解决什么不能解决什么热词里出现了springboot mybatis-plus我顺便聊聊它。MyBatis-Plus 不是一个替换 MyBatis 的框架它是 MyBatis 的增强工具包。它的核心价值在单表 CRUD内置了BaseMapper和IService提供selectById、saveBatch、updateById、分页插件、逻辑删除、乐观锁等开箱即用的能力。如果你项目里 80% 以上的操作是单表 CRUDMyBatis-Plus 能极大减少模板代码。条件和结果映射也做了很多自动化处理比如驼峰命名自动转下划线让mapUnderscoreToCamelCase的功力进一步发挥。但 MyBatis-Plus 解决不了复杂查询。多表 JOIN、特殊数据库方言、复杂子查询最终你还是得回到 XML 或者Select注解里写原生 SQL。它的条件构造器QueryWrapper适合简单场景一旦嵌套复杂可读性会直线下降。团队里如果有人滥用Wrapper拼复杂条件我一般是建议他在代码里加注释甚至直接规定超过三层嵌套必须改 XML。从面试角度来说MyBatis 与 Spring 的集成原理、MyBatis-Plus 的自动填充和乐观锁实现、Mapper 代理机制都是高频考点。但比背考点更重要的是你要能说清楚什么场景该用哪个工具。这比会写十条 SQL 更能体现功底。最后再分享一个我在实际项目里的体会把 MyBatis 用好的关键不是背下所有标签和配置而是理解它的设计边界。什么时候把活儿交给框架什么时候自己控制 SQL这个判断力是踩过坑之后才有的。如果你刚接触 MyBatis建议先把这个最小示例亲手跑一遍然后故意制造几次缓存、日志、参数绑定的坑亲眼看看现象。只有亲眼见过那些不报错但结果不对的时刻你才算真正开始懂它。