ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MyBatis DML映射核心细节:主键回填、动态SQL与批量插入实战

MyBatis DML映射核心细节:主键回填、动态SQL与批量插入实战 写Mapper XML写了七八年要说被问得最多的一个点不是“#{}和${}有什么区别”这种面试题而是很朴素的问题“DML映射到底怎么写才对”很多人Service层跑得飞起一进到XML配置文件就开始凭感觉写标签结果线上出了“字段没更新”“主键没回填”“批量插入慢成狗”这种问题回头调半天才发现是映射文件里的细节没搞对。DML映射——也就是insert、update、delete这三种操作的SQL映射——是MyBatis的日常主力也是面试里最容易被深挖的考点。这篇东西不打算讲成八股文我会按实际项目的写法把DML映射的设计思路、核心细节、实操步骤、常见坑一次性捋清楚适合刚开始接触MyBatis的新人也适合用了几年但没深究过底层机制的老手拿来做一次系统复盘。1. 整体设计思路Mapper接口与XML是怎么接上的1.1 为什么Mapper接口不需要写实现类很多人第一次接触MyBatis都有一个疑问我定义了一个UserMapper接口里面只声明了方法没有写任何实现类为什么程序跑起来能直接userMapper.insert(user)答案就是JDK动态代理。MyBatis在启动阶段扫描Mapper接口为每个接口生成一个代理对象当你的业务代码调用insert方法时实际调用的是MapperProxy的invoke方法它会拿着方法名去Configuration里找对应的MappedStatement——这个MappedStatement就是从XML里解析出来的那条DML语句的完整包装。一个方法名对应一个id就这么接上了。整个过程对业务代码完全透明所以你会发现一个很有意思的事实DML映射的逻辑中枢其实不在Java代码里而在XML文件中。接口里的方法声明只负责两件事——告诉MyBatis你要调用哪个id的MappedStatement以及把参数原样传进去。因此写好DML映射的第一步不是打开IDE写Java而是先想清楚SQL本身长什么样、参数要传哪些、返回什么结果。1.2 四个DML标签的职责边界MyBatis的XML映射文件里有四个基础标签insert、update、delete、select。前三个就是DML映射的核心专门负责写操作。每个标签在解析时都会生成一个MappedStatement并注册到Configuration中。标签的职责边界非常清晰insert负责新增数据最核心的痛点是主键回填和批量插入。update负责更新数据最核心的痛点是动态set和防止误更新。delete负责删除数据看起来最简单但配合动态SQL时同样有坑。select虽然属于DML查询但它的映射机制和缓存联动会在DML编写时产生重要影响。这里有一个容易混淆的点DML标签的执行与事务提交是两回事。MyBatis默认情况下sqlSession.insert(...)只是把SQL发给数据库执行并不会自动commit。如果脱离Spring管理你必须手动调用sqlSession.commit()在Spring Boot整合环境中事务由Spring统一托管Transactional标注的方法才会在结束时提交。很多新手在原生MyBatis环境下发现数据没写进去就是因为没commit这属于对DML映射执行链路的理解问题。2. 核心细节解析回填、动态SQL与TypeHandler2.1 insert主键回填的两种方案及底层原理主键回填是DML映射里最典型的实用场景没有之一。假设你的业务表用的自增主键插入一条新记录后需要立刻拿到这个主键值用于关联子表的写入。MyBatis提供了两种方式第一种也是推荐优先使用的insert idinsertUser parameterTypecom.example.model.User useGeneratedKeystrue keyPropertyid INSERT INTO user (name, email) VALUES (#{name}, #{email}) /insert关键点在于useGeneratedKeystrue和keyPropertyid。前者告诉MyBatis需要使用JDBC的getGeneratedKeys()方法获取数据库自动生成的主键后者告诉MyBatis把拿到的值塞回到传入实体对象的哪个属性上。所以插入完成后你的user.getId()已经有值了不需要再查一次数据库。第二种方案是用selectKey适合Oracle这种不支持自增主键、需要用序列生成主键的数据库insert idinsertUserOracle selectKey keyPropertyid resultTypelong orderBEFORE SELECT user_seq.NEXTVAL FROM dual /selectKey INSERT INTO user (id, name, email) VALUES (#{id}, #{name}, #{email}) /insertselectKey的执行时机由order属性控制BEFORE表示先执行查询主键的SQL再执行insertAFTER表示先insert再执行查询主键的SQL。MySQL的自增主键配合useGeneratedKeys更自然selectKey主要出现在老项目对接Oracle或需要业务主键预生成的场景。从源码角度看useGeneratedKeys的底层依赖JDBC的Statement.getGeneratedKeys()所以有个隐含前提——数据库驱动必须支持该方法。MySQL的Connector/J是支持的实测下来没有任何问题。2.2 动态SQL在DML中的正确使用姿势动态SQL是DML映射最灵活、也最容易出错的部分。核心场景是更新操作希望只更新传入的非空字段批量操作希望一次性插入多条数据删除操作希望根据可选条件删除指定范围。先说set它在update语句里几乎是标配update idupdateUser UPDATE user set if testname ! null and name ! name #{name}, /if if testemail ! null and email ! email #{email}, /if /set WHERE id #{id} /updateset会自动处理掉最后一个条件后面的逗号。这里一个常见的错误是在SQL里手写了逗号又用了set标签结果拼接出来的SQL末尾留了个逗号导致语法错误。记住set的语义是动态生成set子句它会去掉尾逗号你不需要自己去控制逗号的位置。再说trim它是set和where的底层实现但也常被单独使用来做更灵活的前后缀控制。例如trim prefixSET suffixOverrides, if testname ! nullname #{name},/if /trimprefix指定拼接后加什么前缀suffixOverrides指定去除什么后缀。当你需要同时控制多个规则时直接用trim更好理解。最后是foreach批量插入的利器insert idbatchInsert INSERT INTO user (name, email) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.email}) /foreach /insertseparator,自动在每项之间补逗号这个写法比自己在循环里拼字符串安全得多。foreach不仅用在insert里delete和update同样适用比如按id列表批量删除。2.3 TypeHandler类型转换的最后一道关卡TypeHandler是一道容易被忽略的关卡。它的作用有两个方向一是把Java参数值转换成JDBC类型写入数据库二是把数据库返回的JDBC类型还原成Java对象属性。DML映射中参数绑定时执行的是第一个方向。什么时候你会感知到TypeHandler的存在典型场景是Java 8的LocalDateTime配合某些旧版MyBatis使用直接报TypeException那是因为MyBatis默认没有注册针对LocalDateTime的类型处理器MySQL驱动和MyBatis的版本对不上就会踩坑。解决方案通常是升级MyBatis3.4.0以上默认支持JSR-310时间类型或者自定义TypeHandler。自定义TypeHandler的写法也不复杂继承BaseTypeHandlerT实现四个方法MappedTypes(LocalDateTime.class) MappedJdbcTypes(JdbcType.TIMESTAMP) public class LocalDateTimeTypeHandler extends BaseTypeHandlerLocalDateTime { // setNonNullParameter: 写库时调用 // getNullableResult: 读库时调用三种重载分别对应 getXxx 的列名、索引、游标 }配置方式分两种全局注册在mybatis-config.xml里加typeHandlers节点这是最省事的办法局部指定在resultMap或insert标签上通过typeHandler属性显式指定适合只针对某个字段做特殊处理。我个人的建议是优先全局注册局部指定容易漏而且容易导致同一字段在不同Mapper中行为不一致。3. 实操过程完整DML映射的编写与调试3.1 一次完整的新增、更新、删除映射实战直接上一套完整的Mapper XML对应一个最基础的用户表操作。先建好实体类User包含id、name、email、createTime四个字段然后看映射文件如何写mapper namespacecom.example.mapper.UserMapper insert idinsertUser parameterTypecom.example.model.User useGeneratedKeystrue keyPropertyid INSERT INTO user (name, email, create_time) VALUES (#{name}, #{email}, #{createTime}) /insert update idupdateUser UPDATE user set if testname ! null and name ! name #{name},/if if testemail ! null and email ! email #{email},/if /set WHERE id #{id} /update delete iddeleteUserById DELETE FROM user WHERE id #{id} /delete /mapper这段代码看起来很基础但里面有几个关键点值得展开。参数绑定上#{name}和#{createTime}走的是ParameterMappingMyBatis会根据实体类的属性名用反射拿到值再经过TypeHandler转换成JDBC类型。这里如果不小心把属性名拼错了MyBatis不会在启动时报错而是运行到这条SQL时抛出ReflectionException告诉你找不到对应属性。关于${}和#{}我的原则写得很死DML映射里一律用#{}不要用${}。#{}会生成预编译占位符由JDBC的PreparedStatement处理参数天然防SQL注入${}是直接字符串拼接虽然可以灵活拼接列名、表名但同时也意味着注入风险。如果你确实要动态指定表名或排序列也要确保传入值经过严格白名单校验不然后果很严重。3.2 批量DML操作的两种实现与性能权衡批量插入在业务系统里太常见了。有一种写法是在Java代码里循环调用单条insert比如在Service层for循环里执行userMapper.insert(user)这种写法最大的问题是性能每一次insert都单独走一次JDBC连接和SQL执行1000条数据就要建立和销毁1000次执行上下文耗时指数级上升。推荐的做法有两种。第一种就是前面提到的foreach拼接VALUES一条SQL搞定1000条数据。这种方式对MySQL很友好因为MySQL原生支持多VALUES插入。但要注意SQL长度限制max_allowed_packet太小而一次插入的数据量太大时会报PacketTooBigException。一般控制在500条左右一批是安全值。第二种使用MyBatis的ExecutorType.BATCH执行器。在原生MyBatis环境中手动创建SqlSession时指定SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH);配合在循环里调用session.insert(com.example.mapper.UserMapper.insertUser, user)MyBatis会累积SQL直到session flush时才真正批量发送到数据库。这种方式相比foreach更适合更新类操作、数据量极大的场景但代码复杂度高一些而且对事务边界的管理要求也更精细。从实测数据看使用foreach批量插入1000条数据比循环单条插入通常能快几十倍使用ExecutorType.BATCH又能再快一些但边际收益未必抵得上复杂度的增加。我的建议是优先使用foreach只有在它解决不了的问题上再考虑BATCH模式。3.3 如何打印MyBatis执行的SQL与参数很多排查DML问题的人卡在第一步不知道MyBatis到底执行了什么SQL、参数到底是什么。MyBatis默认是不打印SQL的需要做配置。在Spring Boot的application.yml里加一行mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样执行DML时会输出类似这样的日志 Preparing: INSERT INTO user (name, email, create_time) VALUES (?, ?, ?) Parameters: zhangsan(String), zhangsanexample.com(String), 2025-01-01T12:00:00(LocalDateTime) Updates: 1但如果配置了log-impl还是没输出通常是因为日志框架的级别拦截了Mapper包下的日志把对应包的日志级别调整到DEBUG即可logging: level: com.example.mapper: debug这个配置能帮你直接看到Preparing里的SQL语句和Parameters里的实际参数值排查参数绑定问题几乎必不可少。更进一步你可以通过MyBatis拦截器拦截StatementHandler在执行前把PreparedStatement上的参数逐个取出来拼成一条可以直接粘到数据库客户端里执行的完整SQL。这样排查起来更加直观尤其是批量操作时能看到每一条真实SQL长什么样。4. 常见问题与排查技巧实录4.1 动态SQL条件不生效的经典案例动态SQL条件不生效是DML映射中最常见的线上问题。最典型的写法是if testname ! null and name ! name #{name}, /if这个判断本身没问题问题通常出在传入值的类型上。如果你在接口方法里用了多个参数但没有加Param注解User selectByName(String name, String email);然后在XML里写if testname ! null运行时会直接报There is no getter for property named name in class java.lang.String。这是因为多参数情况下MyBatis会把参数包装成一个ParamMap但没有Param注解时默认键名是param1、param2你写name它根本找不到。解决办法很简单要么给方法参数加Param(name)注解要么在XML里用param1、param2引用不推荐可读性差。另一个容易踩的坑是关于if判断里写! 时Java层如果传的是null判断顺序一定是先判null再判空字符串否则会报空指针。我见过有同事把判断写成testname ! and name ! null看着对但实际OGNL表达式求值是左到右的先执行了name ! 此时name为null就出问题了。4.2 DML操作与MyBatis缓存的联动机制DML映射不只是执行SQL它还会触发缓存清理这是很多人理解不到位的地方。MyBatis一级缓存是SqlSession级别的默认开启。你在同一个SqlSession里先查了一条数据再执行一次同条件的查询第二次不会真正走数据库而是直接返回缓存。但如果你在两次查询之间执行了一次DML操作insert、update、deleteMyBatis会清空当前SqlSession的一级缓存防止读到脏数据。这个机制是默认的不需要你管。二级缓存需要手动开启它是namespace级别的也就是每个Mapper XML一个缓存区域。这里有个经典坑如果在同一个查询里关联了多个表而只有其中一个表的Mapper开启了二级缓存那么其他表的数据变更不会清空这个缓存的namespace就可能导致脏数据。所以我做项目时的习惯是涉及多表关联查询的Mapper默认不开启二级缓存开启二级缓存的前提是这个Mapper的DML操作能完整覆盖该namespace涉及的所有数据变更。4.3 面试高频考点从DML映射到源码级理解把热词里的那些面试题串起来你会发现它们其实是同一根主线。比如为什么Mapper接口没有实现类答JDK动态代理MapperProxy拦截方法调用按方法名从Configuration里拿MappedStatement。#{}和${}有什么区别答#{}是预编译占位符走PreparedStatement防注入${}是字符串拼接有注入风险。DML映射中应全部使用#{}。insert如何回填主键答useGeneratedKeyskeyProperty底层是JDBC的getGeneratedKeys()或者用selectKey配合orderBEFORE/AFTER。TypeHandler的作用是什么答Java类型与JDBC类型的双向转换参数写库时执行setNonNullParameter结果集映射时执行getNullableResult。MyBatis初始化流程是什么答XMLConfigBuilder解析全局配置XMLMapperBuilder解析Mapper XML每个DML标签经LanguageDriver处理生成SqlSource最终封装成MappedStatement注册到Configuration。一级缓存和二级缓存的区别与失效时机答一级缓存是SqlSession级别任何DML操作都会清空当前缓存二级缓存是namespace级别DML操作默认flushCachetrue会清空对应namespace的缓存但跨表查询存在脏读风险。能把这些点串成一条线讲清楚比死记硬背几个知识点要有说服力得多。5. 从DML映射看MyBatis的初始化与Spring Boot整合5.1 XMLConfigBuilder如何构建MappedStatement热词里反复提到XMLConfigBuilder它其实是MyBatis初始化流程的起点。简单梳理一遍MyBatis启动时会调用XMLConfigBuilder解析mybatis-config.xml全局配置文件读完mappers节点后拿到所有Mapper XML的路径转交给XMLMapperBuilder继续解析。XMLMapperBuilder逐个解析insert、update、delete、select标签每个标签的SQL内容经过XMLStatementBuilder处理后交给LanguageDriver创建对应的SqlSource再把SQL、参数映射、结果映射等属性组装进MappedStatement。这意味着一个关键事实DML映射的SQL在启动时就会被解析成内部结构而不是每次执行时临时拼串。因此如果XML里存在语法级别的错误比如标签少了闭合、引号不对MyBatis启动阶段就会报错根本等不到运行期。MappedStatement里组装了哪些内容包括sqlSourceSQL来源、parameterMappings参数映射列表、resultMaps结果映射、flushCache执行前是否清缓存、useGeneratedKeys是否回填主键、keyProperty主键属性名、statementTypePREPARED/CALLABLE/STATEMENT等。理解了这个结构你再看XML里每个属性就有了落点——每一个标签属性都对应MappedStatement中的一个字段。5.2 Spring Boot MyBatis整合的配置要点Spring Boot整合MyBatis已经是非常成熟的方案了但整合时的配置仍然有一些容易踩的坑尤其是与DML映射直接相关的。第一Mapper接口的扫描。使用MapperScan(com.example.mapper)放在启动类或配置类上可以批量注册Mapper如果不喜欢注解可以在每个Mapper接口上加Mapper注解。两者可以共存但注意别重复扫描导致Bean冲突。第二mapper-locations的路径配置。在Spring Boot的application.yml里mybatis: mapper-locations: classpath:mapper/*.xml这个路径写错是最常见的启动期报错来源。报错信息一般是Invalid bound statement (not found)它真实的含义是Mapper接口方法在Configuration里找不到对应的MappedStatement。也就是说要么XML没有加载进来路径错了或没有匹配的文件要么namespace和接口全限定名不一致要么方法名和标签id对不上。排查顺序千万别乱先看日志确认XML有没有被加载再看namespace最后看id。第三事务配置。Spring Boot的DataSourceTransactionManager需要正确注册MyBatis的SqlSessionFactory在整合时会感知到这个事务管理器从而让DML操作纳入Spring的事务管理。如果你发现执行insert后数据没有提交大概率是事务没有正确开启——在类或方法上漏了Transactional或者事务管理器配置有问题。6. 结尾一点个人体会写DML映射这几年我最深的体会是别把insert、update这些标签当成简单的SQL模板它们背后连接的是参数绑定机制、类型转换机制、缓存机制和事务机制。很多人只记住了标签怎么写却忽略了参数从Java到JDBC的完整链路、以及DML操作对缓存和事务的影响出了问题只能靠瞎猜。最后分享一个小技巧给项目配置一个MyBatis拦截器拦截Executor的update方法打印出每次DML操作影响的行数、耗时和真实SQL。这个拦截器写起来十几分钟但排查线上问题时的价值远超预期。尤其是别人写的Mapper XML你不知道里面藏着什么逻辑时这个日志能帮你快速定位是哪一条SQL出了问题。DML映射的坑不算多但每一个都需要你对执行链路有清晰的理解而不是等到线上炸了才反过来追查。
返回列表