MyBatis核心技巧实战:动态SQL、结果映射与缓存机制详解
用了这么多年MyBatis最深的感受是常用操作其实不难难的是把一个一个使用技巧背后的“为什么”想明白。这篇文章不打算讲架构层面的空道理就聚焦日常开发里大家真正会天天碰到的这些事——配置怎么写得稳、动态SQL怎么拼不踩坑、结果映射怎么处理、缓存到底能不能依赖、拦截器怎么用不翻车、报错怎么快速定位。无论你是刚从JDBC写烦了转过来的新手还是已经用了两三年但总觉得哪里没吃透的同学这篇文章都适合你对照着查漏补缺。1. MyBatis凭什么能一直留在持久层从JDBC的重复劳动到半自动ORM1.1 先看看没有MyBatis的日子最早做Java后端的人大概都经历过手写JDBC的阶段。每次查询要经历加载驱动、获取连接、创建PreparedStatement、手动绑定参数、执行查询、遍历ResultSet逐列取值、最后还要在finally里关掉连接和语句。一个最简单的列表查询代码量轻轻松松五六十行而且百分之八十是重复劳动。Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn dataSource.getConnection(); ps conn.prepareStatement(select id, name, age from user where age ?); ps.setInt(1, 18); rs ps.executeQuery(); ListUser users new ArrayList(); while (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setName(rs.getString(name)); user.setAge(rs.getInt(age)); users.add(user); } return users; } finally { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); }这段代码的问题不只是“长”更严重的是容易出错。列名拼错、参数顺序搞反、资源忘记关闭、ResultSet关闭之后才去取数据都是当时常见的线上事故来源。团队里每个人写出来的JDBC风格还不一样有的用if null判断有的直接扔异常代码评审天天为这种破事吵。1.2 半自动ORM的取舍把SQL控制权还给开发者MyBatis走了一条和全自动ORM完全不同的路。它不是一个“帮你把SQL生成出来”的框架而是一个“你写SQL我帮你处理参数和结果集”的框架。也就是说SQL的生成权、优化权始终在你手里MyBatis专注解决参数注入、结果集映射、连接管理这些“脏活累活”。这样的取舍在实际开发里非常实用。遇到复杂的多表关联查询、报表统计、需要手工调优索引的慢SQL你是完全可控的。而全自动ORM框架在面对这类场景时往往需要你写一堆奇怪的API去引导“生成器”最后生成的SQL还不一定符合预期。反过来说MyBatis也不是没有缺点——简单CRUD如果你真的每条都手写XML工作量也不小所以后来才有了通用Mapper、MyBatis-Plus这类基于它做的增强工具甚至MyBatis官方也提供了自动生成代码的机制。网上很多人争论“用哪个更好”其实结论很朴素复杂查询多的项目用MyBatis更顺手纯单表CRUD占比高的项目用增强型工具更省事。1.3 什么场景下别硬上MyBatis我也见过一些不太合适的用法。比如一个项目里只有三张表、所有的查询都是单表按主键取对象前端页面也就二十来个这种场景用原生JDBC或者Spring自带的JdbcTemplate反而更轻引入MyBatis还要建一堆Mapper接口和XML维护成本远大于收益。再比如团队成员普遍SQL基础薄弱大家都只会写简单的单表查询那不如直接用全自动ORM。工具没有绝对好坏只有合不合适。MyBatis适合的团队画像很清晰SQL功底不错、对数据库性能可控性有要求、愿意为每一条SQL负责。2. SqlSessionFactory与配置文件几个设置项直接影响项目生死2.1 一套能跑通的最小配置长什么样很多同学第一次接触MyBatis就被一堆概念绕晕SqlSessionFactory、SqlSession、Mapper、XML映射文件、Configuration。其实它的启动链条非常直白你提供一个全局配置文件MyBatis读取之后构建出一个Configuration对象然后基于它创建SqlSessionFactory。每次需要操作数据库时从工厂里拿一个SqlSessionSqlSession内部绑定着一条数据库连接负责执行SQL并管理事务。所有Mapper接口最终都会被注册到这个Configuration里调用Mapper方法时实际就是让SqlSession去执行对应的SQL语句。一套最小的配置文件大概是这样?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/ /settings typeAliases package namecom.example.demo.entity/ /typeAliases 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 package namecom.example.demo.mapper/ /mappers /configuration这里面比较容易被忽略的是typeAliases的包扫描。配置了它之后XML里的resultType就可以直接写类名User不用写全限定名com.example.demo.entity.User减少拼写错误。我在实际项目里见过有人没配这个每个resultType都写超长全限定名一旦类移动了包路径整片XML都要改非常痛苦。2.2 那些“开与不开”差别很大的设置项除此之外还有几个setting值得单独拎出来说因为它们决定了项目运行时的很多潜在行为。我把最常用的几个整理成一个清单配置项默认值作用与建议mapUnderscoreToCamelCasefalse数据库列名下划线转驼峰强烈建议开启否则user_name映射不到userNamelazyLoadingEnabledfalse是否开启延迟加载开启后关联对象在真正使用时才查询aggressiveLazyLoadingtrue老版本设置为false会更“懒”避免一次访问触发全部懒加载cacheEnabledtrue二级缓存总开关注意基础配置下二级缓存默认不生效需要显式配置cache/useGeneratedKeysfalse是否使用JDBC生成的主键配合insert语句的keyProperty使用defaultExecutorTypeSIMPLE日常用SIMPLE即可批量操作用BATCH时需要额外配置callSettersOnNullsfalse查询结果某字段为null时是否调用setter一般保持false除非有特殊需求这里我想特别强调一下mapUnderscoreToCamelCase。很多新手第一次写MyBatis查询发现查出来的对象某个属性是null但数据库列明明是有的大概率就是没开这个配置或者列名和属性名差异太大。比如数据库列叫order_no实体类属性叫orderNo你要么开启驼峰映射要么在resultMap里显式写result columnorder_no propertyorderNo/没有第三条路。2.3 与Spring整合时的配置要点现在大多数人不会单独启动MyBatis而是配合Spring使用。Spring整合时需要额外关注两个BeanSqlSessionFactoryBean和MapperScannerConfigurer。前者负责把MyBatis的配置和Spring的数据源串起来后者负责扫描Mapper接口并生成代理对象注入到Spring容器。有一个特别常见的错在MyBatis配置文件里配了mappers包扫描又在Spring的配置里配了MapperScannerConfigurer结果两套机制叠加。其实你用Spring管理之后全局配置文件里的dataSource和environments就没什么用了真正起作用的是Spring容器里的数据源。整合的推荐做法是MyBatis全局配置只保留settings和typeAliases数据源交给SpringMapper扫描交给MapperScannerConfigurer。这样职责清晰排查问题时也能少吃一惊。3. 日常CRUD与动态SQL把Mapper接口写到顺手的一套组合打法3.1 Mapper接口与XML绑定先搞清楚几件事Mapper接口的本质是一个代理机制你在接口里定义一个方法在XML里写一条ID和方法名一致的SQLMyBatis启动时会把它们绑定起来。运行时你调用接口方法代理对象会按照方法签名去匹配对应的SQL语句执行并返回结果。这里有三个容易出问题的点第一XML的namespace必须写Mapper接口的全限定名写错一个字母就是Invalid bound statement第二方法名必须和XML里statement的id一致第三方法的返回类型要和SQL预期的结果匹配查询多条数据却返回单个对象运行时会直接报错。下面的例子是一个标准的用户查询操作mapper namespacecom.example.demo.mapper.UserMapper select idselectByCondition resultTypeUser select id, username, email, status from user where if testusername ! null and username ! and username like concat(%, #{username}, %) /if if teststatus ! null and status #{status} /if /where order by id desc /select /mapper这个where标签很贴心它会自动处理掉第一个条件前面的and或or新手拼SQL经常在“第一个条件到底要不要加and”上纠结用where就不用操这个心了。3.2 #{}和${}的差异这是最值钱的知识点之一网上讲#{}和${}区别的文章不少但很多人看完还是记不住什么时候该用哪个。我换一种方式说#{}是预编译占位符MyBatis会把它翻译成?然后交给JDBC的PreparedStatement去绑定参数${}是字符串拼接直接把值拼进SQL里。前者是防SQL注入的后者极其危险但后者能处理一些小场景比如动态表名、排序字段。什么情况下你不得不使用${}举个例子分页排序时排序字段来自前端参数你要根据这个参数决定order by后面跟着哪一列。这里有一个非常实用的安全策略哪怕用${}也绝不能让用户输入直接落进去。更稳妥的做法是在Java代码里做一层白名单校验只允许通过你预设的字段名和方向。例如前端传sortFielduserName你就在Map里查一下查到才放行查不到就报错根本不应该把用户传的原始字符串拼到SQL里。还有一个关于#{}的细节#{name}里可以写#{name, jdbcTypeVARCHAR}等更复杂的形态日常开发中大多数场景直接写属性名就够了但如果在某些数据库的边界情况下遇到类型转换报错可以主动指定jdbcType这是一张有用的底牌。3.3 动态SQL的七个标签配合起来才是完整套路MyBatis提供了丰富的动态SQL标签最常用的就是if、choose、when、otherwise、where、set、foreach这几种。把它们组合起来能覆盖几乎所有“多条件组合查询”和“按需更新”的需求。先说choose它等同于Java里的switch。举个例子查询时可能按照姓名查也可能按照邮箱查还可能是全表查select idsearchUser resultTypeUser select * from user where choose when testusername ! null and username ! and username #{username} /when when testemail ! null and email ! and email #{email} /when otherwise and status 1 /otherwise /choose /where /selectset标签则专门用于update语句。更新用户信息时如果只传了用户名没传邮箱你不能把邮箱也更新成null。用set标签加if判断动态拼接需要更新的字段它会自动去掉最后一个多余的逗号update idupdateUser parameterTypeUser update user set if testusername ! nullusername #{username},/if if testemail ! nullemail #{email},/if if teststatus ! nullstatus #{status},/if /set where id #{id} /update最后是foreach它最常见的用途就是批量插入和按ID列表查询insert idbatchInsert insert into user(username, email, status) values foreach collectionlist itemitem separator, (#{item.username}, #{item.email}, #{item.status}) /foreach /insert有个坑值得提醒foreach的collection属性值在不同参数场景下可能不同。如果Mapper接口方法参数直接是ListUser需要在Param(list)上显式指定如果是UserQuery对象里有个ListLong idscollection要写成ids。很多人在这里栽过跟头报错信息说“Parameter xxx not found”就是因为collection名字和方法参数对不上。3.4 批量更新尽量避免逐条for循环批量插入的写法大家比较熟悉批量更新却常常被忽略。我刚工作那几年也干过在Java代码里for (User user : userList) { userMapper.update(user); }这种傻事。直到有一次上线后数据库连接池被打满了才发现问题一万条数据就要发起一万次网络往返事务时间被拉得极长。比较好的方案有两种。第一种是数据库允许的情况下用CASE WHEN拼SQL一次性更新所有记录update idbatchUpdate update user set foreach collectionlist itemitem openusername case id closeend when #{item.id} then #{item.username} /foreach /set where id in foreach collectionlist itemitem open( close) separator, #{item.id} /foreach /update第二种是引入批量Executor在Spring配置里把MyBatis的executor-type设置为BATCH这样同一条连接上多次update会进入批处理模式减少网络往返。注意BATCH模式下查询操作后可能会遇到没有立即刷盘的问题需要适当调用flushStatements()。我在项目里一般优先用第一种SQL逻辑清晰可控也不容易触发批处理模式的边界情况。4. 结果映射的本质resultType、resultMap与一对多嵌套查询实战4.1 resultType自动映射够用但别高估resultType的映射规则是把查询结果的列名转成对应属性名找不到就忽略默认情况下。这意味着只要你的实体类属性和数据库列名能对得上并且项目已经开启了驼峰映射绝大多数简单查询可以直接用resultType搞定。它适合单表查询、简单多表查询返回DTO的场景。但如果你遇到这几个问题中的任何一个就得考虑上resultMap了第一查询结果里的列名无法和属性名对应且不想改SQL别名第二需要处理一对多关系一个订单要带出订单明细列表第三需要通过typeHandler做特殊类型转换比如把数据库里的JSON字符串自动转成Java对象。4.2 resultMap的完整定义字段映射别偷懒resultMap的基本结构是id定义主键result定义普通字段的映射关系。很多人以为id和result没区别其实id在二级缓存命中时起到关键作用它决定了MyBatis如何判断两条记录是不是同一个对象。遇到联合主键的情况id可以配置多个。下面是一个典型的resultMap示例resultMap idOrderMap typeOrder id columnorder_id propertyid/ result columnorder_no propertyorderNo/ result columnuser_id propertyuserId/ result columntotal_amount propertytotalAmount/ association propertyuser javaTypeUser id columnuser_id propertyid/ result columnusername propertyusername/ /association collection propertyitems ofTypeOrderItem id columnitem_id propertyid/ result columnproduct_name propertyproductName/ result columnquantity propertyquantity/ result columnprice propertyprice/ /collection /resultMap对应的SQL就要注意列别名问题。多表联查时如果两个表都有id列你必须用别名把它们区分开否则MyBatis无法正确映射会把第二个id覆盖掉第一个。这个坑非常隐蔽因为系统不会报错只是你发现对象的id和userId串了。4.3 association和collection的一对多映射association用于处理“对象”属性多对一collection用于处理“列表”属性一对多。实现方式有嵌套结果和嵌套查询两种。嵌套结果的思路是一条SQL用JOIN把数据都查出来MyBatis按照resultMap的规则把同一主键的行合并成一个对象并把关联的列填到子对象或列表里。嵌套查询的思路则是先查主表然后对每一条主表记录再次发起关联查询。它的写法是在association或collection里配置select属性指向另一个查询语句的id并通过column传参。好处是主查询和子查询解耦、SQL简单坏处是如果不做延迟加载会引发著名的N1问题查了1条主记录就要额外执行N次子查询性能很容易崩。两种方式放在一起对比就清晰了对比项嵌套结果嵌套查询SQL数量一条大JOIN主查询关联查询多条性能风险数据量大时JOIN结果集膨胀容易产生N1问题代码可维护性需要精心设计列别名子查询独立复用性好延迟加载不支持支持推荐场景明细数据量可控、列表展示主对象多、关联对象使用不频繁我个人的习惯是列表页展示用嵌套结果一次查完避免循环查库详情页如果关联对象不一定用到就配嵌套查询加懒加载。4.4 懒加载的实际配置与心理预期真正把懒加载打开之后还需要注意一个行为假如你先获取了Order对象紧接着调用了order.getUser().getName()这不算懒加载命中——因为拿User对象时已经触发子查询了。懒加载的意思是把“查User”这件事推迟到真正访问User属性的那一刻而不是推迟到访问User内部字段的时刻。这些细节决定了你排查“为什么SQL多了”时的效率。还有一点aggressiveLazyLoading如果设置得不好会出现“我明明只取UserName结果User和OrderItem一起查出来了”的现象这时候去检查一下是不是这个配置把懒加载打回了原形。5. 一级缓存和二级缓存用之前先理解失效规则5.1 一级缓存默认开启但千万别依赖它MyBatis的一级缓存是SqlSession级别的。同一个SqlSession内执行两次完全相同的查询第二次会直接从内存里拿结果不再查数据库。这听起来很美好但它的生命周期异常短暂一旦执行了新增、修改、删除操作或者调用了clearCache()缓存立刻失效。更关键的是在Spring整合的项目里SqlSession通常绑定在事务上——一个事务一个SqlSession事务结束就销毁。这意味着如果你的业务没有在一个事务里重复查询一级缓存几乎就是“睡着的状态”。还有一个坑很隐蔽如果Service层开了事务DAO层连续查询两次相同条件第一次确实走缓存但如果你在两次查询之间调用了任何一个Mapper的update操作整个SqlSession的缓存都会清空第二次查询照样打库。所以我的结论是一级缓存不要去依赖它去做性能优化它更像是一个意外收益帮你避免同一个事务里的重复无脑查询。5.2 二级缓存跨SqlSession共享但一致性需要自己看清二级缓存是namespace也就是Mapper级别的。开启的方式是在Mapper的XML里加一行cache/或者用CacheNamespace注解。它的作用范围是整个SqlSessionFactory管理的所有SqlSession只要能找到这个namespace的statement就能命中同一份缓存。听起来更美好了但项目里真正敢开二级缓存的不多原因在于数据一致性。二级缓存放的是Mapper查询的结果对象这些对象可能被多个方法、多个事务共享。如果A查询通过UserMapper缓存了一份用户列表B通过UserMapper修改了某个用户的状态MyBatis确实会让UserMapper的缓存失效但如果是通过另一条SQL更新了同一张表或者项目里有其他系统在直接改数据库二级缓存就拦不住了你查到的永远是老数据。多实例部署时这个问题尤其严重两个应用节点各自维护一份二级缓存Node1的更新Node2永远不知道。所以我的建议是单机低并发、数据变更不频繁、而且所有读写都走同一个Mapper的场景可以尝试开二级缓存高并发、多实例、数据来源复杂的系统老老实实关掉需要缓存就交给Redis那类外部组件至少失效规则是你能主动控制的。5.3 本地缓存与外部缓存如何分工实际项目里我见过比较舒服的方案是让MyBatis管好它自己的那条SQL执行链路业务层面的热点数据、列表数据、统计结果统一交给Redis缓存。你在Service层控制缓存的写入、更新和过期逻辑清楚出问题也好排查。MyBatis的二级缓存留着给那些“极少变化、查询极其频繁、允许短暂不一致”的字典类数据用比如省市区列表、枚举映射表。这样既利用了框架自带能力又把风险控制在了可接受范围内。6. 拦截器插件机制公共字段自动填充与分页的通用手段6.1 拦截器到底拦截的是什么MyBatis的插件机制基于JDK动态代理可以拦截四大核心对象的方法Executor、StatementHandler、ParameterHandler、ResultSetHandler。你可以理解为在SQL执行链路上放了一个切面在方法调用前后插入自定义逻辑。常见的用途有自动填充创建时间、修改时间、执行统一分页、对敏感字段加解密、打印慢SQL日志。下面用一个典型的Intercepts定义来说明Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class AuditInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; // 在这里根据SQL类型填充公共字段 return invocation.proceed(); } }这个插件拦截了Executor的update方法也就是说不管是insert还是update最终都会走进这个方法。你会拿到MappedStatement和参数对象然后就可以对参数做手脚了。6.2 用拦截器自动填充createTime和updateTime很多团队要求每张表都有创建时间、修改时间两个字段。如果没有MyBatis-Plus这类增强工具传统做法是每个insert、update方法手动set一遍写起来烦且容易漏。用拦截器统一处理代码只在某一个类里写一次判断SQL类型是insert还是update再从参数里找到实体对象用反射把时间字段塞进去。简化版的实现思路如下public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; if (parameter null) { return invocation.proceed(); } SqlCommandType sqlCommandType ms.getSqlCommandType(); SetString fieldNames new HashSet(); // 处理单个实体参数或 Map 参数等情况 Object realParam getRealParameter(parameter); if (realParam ! null isEntity(realParam)) { Class? clazz realParam.getClass(); for (Field field : clazz.getDeclaredFields()) { fieldNames.add(field.getName()); } } if (SqlCommandType.INSERT sqlCommandType fieldNames.contains(createTime)) { if (getValue(realParam, createTime) null) { setValue(realParam, createTime, new Date()); } setValue(realParam, updateTime, new Date()); } else if (SqlCommandType.UPDATE sqlCommandType fieldNames.contains(updateTime)) { setValue(realParam, updateTime, new Date()); } return invocation.proceed(); }这里有几个地方容易出错我一个一个踩过第一插入时如果实体传的是Map参数你就拿不到字段名需要另行处理第二反射赋值时要小心父类字段实体如果继承了基类getDeclaredFields拿不到父类属性建议直接遍历父类第三批量插入时参数是一个List需要遍历处理别只处理了第一个。6.3 分页插件的核心思路自己也能实现分页插件有很多现成方案但理解它的原理会让你在配置出错时更快定位。它的本质就是拦截Executor的query方法在真正执行SQL之前先改写SQL加一句limit同时执行一条count语句统计总数最后把分页参数和总条数一起塞进返回对象。自己实现一个简化版并不难核心就两步取出原始SQL用正则或字符串截取在分号前拼上limit ?, ?参数里记录offset和limit。真正复杂的是怎么兼容各种数据库方言、怎么处理子查询、怎么在count语句里去掉不必要的order by。我在团队里遇到的大多数“分页数据不对”的报错都出在“没有判断是否已经分页就二次分页”或“count语句里带了group by导致结果不准”这两类问题上。如果你用的是第三方的分页插件先检查拦截器是否只注册了一次如果自己写控制好SQL改写的位置不要在已经被包装过的SQL上再包装一次。7. 高频踩坑记录那些让你怀疑人生的MyBatis报错7.1 “Invalid bound statement (not found)”的完整排查链路这个报错可以说是MyBatis新人接触最多的噩梦。它的意思翻译成人话是你调用的Mapper接口方法没有找到对应的SQL语句。排查顺序应该固定下来先看Mapper接口的全限定名和XML的namespace是否完全一致再看XML文件的路径是否在最终编译产物里target/classes下有没有这个xml然后看接口方法名和statement的id是否一致最后看Mapper扫描范围有没有覆盖到对应包。如果项目用的是Spring Boot还有一个隐藏原因xml如果放在src/main/java目录下面构建时不会自动复制到classes目录需要在pom.xml或build.gradle里加资源配置让构建过程把xml一起打包。有一次我帮同事排查折腾了半小时最后发现是热部署工具加载了旧版本的编译产物删掉target目录重新构建就好了。这类问题往往不是配置本身错而是环境缓存了旧文件所以每次排查前先清理构建产物能少走很多弯路。7.2 状态为0或Boolean为false时条件判断失效动态SQL的if判断用的是OGNL表达式它和Java的逻辑不完全一样。这里最大众的坑有两个一个是状态值0被“误杀”另一个是Boolean false被“漏掉”。先说状态0问题。很多人写判断时习惯写status ! null and status ! 这在String类型下很常见但当status是Integer类型、并且实际值为0时和0在OGNL表达式中的比较结果经常不符合直觉导致带status0的查询条件被丢弃。最稳妥的写法是数字类型判断只写status ! null字符串才需要额外判断! 。再说Boolean的问题。如果你写的是if testflag ! null而flag为false这个条件其实是成立的因为false也是非null值。但如果你写了if testflag那么flag为false时条件不成立很多人在这里被坑到怀疑人生。在拦截器编排、逻辑删除等场景里类似的Boolean判断到处都是一定要养成“写清楚比较条件”的习惯。7.3 模糊查询的三种写法和一种错误写法第一种推荐写法是用concat拼接if testkeyword ! null and keyword ! and username like concat(%, #{keyword}, %) /if第二种是用bind标签先定义变量bind namepattern value% keyword %/ if testkeyword ! null and keyword ! and username like #{pattern} /if第三种是把占位符写在SQL里and username like % || #{keyword} || %注意Oracle和MySQL的字符串拼接符号不同方言不一样||在MySQL里的默认行为可能不符合预期。最要不得的写法是把${keyword}直接拼进去例如and username like %${keyword}%这等于把SQL注入漏洞的钥匙递到了用户手里。模糊查询里如果还要匹配下划线、百分号这些通配符记得对关键字做转义否则用户输入一个%你的SQL语义就全变了。7.4 自增主键总是不回来插入一条记录后日志里显示数据库自增主键已经生成了但实体对象里的id还是null。这个问题的根因通常是忘了配置useGeneratedKeys和keyProperty。正确写法是insert idinsertUser useGeneratedKeystrue keyPropertyid insert into user(username) values (#{username}) /insertuseGeneratedKeys告诉MyBatis使用JDBC返回的自增键keyProperty告诉它把主键值写回参数对象的哪个属性。如果你用的是selectKey方式主要是Oracle等非自增数据库要关注order属性是BEFORE还是AFTER。自增键拿不到时优先检查这两个属性有没有写对而不是怀疑数据库配置。7.5 多环境部署时SQL日志是第一诊断工具最后分享一个队伍里几乎人人称道的习惯在本地开发环境打开MyBatis的SQL日志每一条实际执行的SQL语句都必须出现在日志里包括参数值和影响行数。这样哪怕你在XML里写错了条件导致查询结果和预期不符也能立刻从日志里看到“哦原来它执行的是这条SQL”。很多疑难问题最后查下来根本不是框架问题而是SQL的逻辑本身就和你想的不一样。打开SQL日志后这些问题平均排查时间能缩短一半以上。configuration appender nameSTDOUT classch.qos.logback.core.ConsoleAppender encoder pattern%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n/pattern /encoder /appender logger namecom.example.demo.mapper levelDEBUG additivityfalse appender-ref refSTDOUT/ /logger /configuration日志级别至少给到Mapper包路径的DEBUG如果项目日志框架是Log4j2或其它思路是一样的把持有Mapper接口文件的包设置为DEBUG级别。这样MyBatis在执行时会打印预处理SQL、参数列表和返回行数。遇到线上问题需要临时排查时也在灰度环境用同样的方式打开日志验证看SQL是否符合预期确认之后马上恢复级别不要在线上一直开着。用下来这些年MyBatis给我的感觉像一个“有原则的老伙计”它把重复的杂活接过去但把最关键的控制权稳稳交还给你。你尊重SQL它就不辜负你你图省事乱拼字符串它也会毫不客气地把问题暴露在线上。这里分享的每一个操作和技巧都是从项目里一次一次踩出来的经验尤其是动态SQL的if判断、resultMap的关联映射、缓存失效规则这几块每一次深入理解都能带来实打实的效率提升。如果这篇文章里有一两个点能帮你少加一次班、少挨一顿代码评审的批评那就值了。