写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与TypeHandler
2.1 insert主键回填的两种方案及底层原理
主键回填是DML映射里最典型的实用场景,没有之一。假设你的业务表用的自增主键,插入一条新记录后需要立刻拿到这个主键值,用于关联子表的写入。MyBatis提供了两种方式:
第一种,也是推荐优先使用的:
<insert id="insertUser" parameterType="com.example.model.User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (name, email) VALUES (#{name}, #{email}) </insert>关键点在于useGeneratedKeys="true"和keyProperty="id"。前者告诉MyBatis需要使用JDBC的getGeneratedKeys()方法获取数据库自动生成的主键,后者告诉MyBatis把拿到的值塞回到传入实体对象的哪个属性上。所以插入完成后,你的user.getId()已经有值了,不需要再查一次数据库。
第二种方案是用selectKey,适合Oracle这种不支持自增主键、需要用序列生成主键的数据库:
<insert id="insertUserOracle"> <selectKey keyProperty="id" resultType="long" order="BEFORE"> SELECT user_seq.NEXTVAL FROM dual </selectKey> INSERT INTO user (id, name, email) VALUES (#{id}, #{name}, #{email}) </insert>selectKey的执行时机由order属性控制:BEFORE表示先执行查询主键的SQL,再执行insert;AFTER表示先insert,再执行查询主键的SQL。MySQL的自增主键配合useGeneratedKeys更自然,selectKey主要出现在老项目对接Oracle或需要业务主键预生成的场景。
从源码角度看,useGeneratedKeys的底层依赖JDBC的Statement.getGeneratedKeys(),所以有个隐含前提——数据库驱动必须支持该方法。MySQL的Connector/J是支持的,实测下来没有任何问题。
2.2 动态SQL在DML中的正确使用姿势
动态SQL是DML映射最灵活、也最容易出错的部分。核心场景是:更新操作希望只更新传入的非空字段;批量操作希望一次性插入多条数据;删除操作希望根据可选条件删除指定范围。
先说<set>,它在update语句里几乎是标配:
<update id="updateUser"> UPDATE user <set> <if test="name != null and name != ''"> name = #{name}, </if> <if test="email != null and email != ''"> email = #{email}, </if> </set> WHERE id = #{id} </update><set>会自动处理掉最后一个条件后面的逗号。这里一个常见的错误是:在SQL里手写了逗号,又用了<set>标签,结果拼接出来的SQL末尾留了个逗号,导致语法错误。记住,<set>的语义是动态生成set子句,它会去掉尾逗号,你不需要自己去控制逗号的位置。
再说<trim>,它是<set>和<where>的底层实现,但也常被单独使用来做更灵活的前后缀控制。例如:
<trim prefix="SET" suffixOverrides=","> <if test="name != null">name = #{name},</if> </trim>prefix指定拼接后加什么前缀,suffixOverrides指定去除什么后缀。当你需要同时控制多个规则时,直接用<trim>更好理解。
最后是<foreach>,批量插入的利器:
<insert id="batchInsert"> INSERT INTO user (name, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.email}) </foreach> </insert>separator=","自动在每项之间补逗号,这个写法比自己在循环里拼字符串安全得多。<foreach>不仅用在insert里,delete和update同样适用,比如按id列表批量删除。
2.3 TypeHandler:类型转换的最后一道关卡
TypeHandler是一道容易被忽略的关卡。它的作用有两个方向:一是把Java参数值转换成JDBC类型,写入数据库;二是把数据库返回的JDBC类型还原成Java对象属性。DML映射中,参数绑定时执行的是第一个方向。
什么时候你会感知到TypeHandler的存在?典型场景是Java 8的LocalDateTime配合某些旧版MyBatis使用,直接报TypeException,那是因为MyBatis默认没有注册针对LocalDateTime的类型处理器,MySQL驱动和MyBatis的版本对不上就会踩坑。解决方案通常是升级MyBatis(3.4.0以上默认支持JSR-310时间类型),或者自定义TypeHandler。
自定义TypeHandler的写法也不复杂,继承BaseTypeHandler<T>,实现四个方法:
@MappedTypes(LocalDateTime.class) @MappedJdbcTypes(JdbcType.TIMESTAMP) public class LocalDateTimeTypeHandler extends BaseTypeHandler<LocalDateTime> { // setNonNullParameter: 写库时调用 // getNullableResult: 读库时调用(三种重载分别对应 getXxx 的列名、索引、游标) }配置方式分两种:全局注册,在mybatis-config.xml里加<typeHandlers>节点,这是最省事的办法;局部指定,在<resultMap>或<insert>标签上通过typeHandler属性显式指定,适合只针对某个字段做特殊处理。我个人的建议是,优先全局注册,局部指定容易漏,而且容易导致同一字段在不同Mapper中行为不一致。
3. 实操过程:完整DML映射的编写与调试
3.1 一次完整的新增、更新、删除映射实战
直接上一套完整的Mapper XML,对应一个最基础的用户表操作。先建好实体类User,包含id、name、email、createTime四个字段,然后看映射文件如何写:
<mapper namespace="com.example.mapper.UserMapper"> <insert id="insertUser" parameterType="com.example.model.User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user (name, email, create_time) VALUES (#{name}, #{email}, #{createTime}) </insert> <update id="updateUser"> UPDATE user <set> <if test="name != null and name != ''">name = #{name},</if> <if test="email != null and email != ''">email = #{email},</if> </set> WHERE id = #{id} </update> <delete id="deleteUserById"> DELETE FROM user WHERE id = #{id} </delete> </mapper>这段代码看起来很基础,但里面有几个关键点值得展开。
参数绑定上,#{name}和#{createTime}走的是ParameterMapping,MyBatis会根据实体类的属性名,用反射拿到值,再经过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), zhangsan@example.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 test="name != null and name != ''"> name = #{name}, </if>这个判断本身没问题,问题通常出在传入值的类型上。如果你在接口方法里用了多个参数但没有加@Param注解:
User selectByName(String name, String email);然后在XML里写:
<if test="name != 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再判空字符串,否则会报空指针。我见过有同事把判断写成test="name != '' and name != null",看着对,但实际OGNL表达式求值是左到右的,先执行了name != '',此时name为null,就出问题了。
4.2 DML操作与MyBatis缓存的联动机制
DML映射不只是执行SQL,它还会触发缓存清理,这是很多人理解不到位的地方。
MyBatis一级缓存是SqlSession级别的,默认开启。你在同一个SqlSession里先查了一条数据,再执行一次同条件的查询,第二次不会真正走数据库,而是直接返回缓存。但如果你在两次查询之间执行了一次DML操作(insert、update、delete),MyBatis会清空当前SqlSession的一级缓存,防止读到脏数据。这个机制是默认的,不需要你管。
二级缓存需要手动开启,它是namespace级别的,也就是每个Mapper XML一个缓存区域。这里有个经典坑:如果在同一个查询里关联了多个表,而只有其中一个表的Mapper开启了二级缓存,那么其他表的数据变更不会清空这个缓存的namespace,就可能导致脏数据。所以我做项目时的习惯是,涉及多表关联查询的Mapper,默认不开启二级缓存;开启二级缓存的前提是,这个Mapper的DML操作能完整覆盖该namespace涉及的所有数据变更。
4.3 面试高频考点:从DML映射到源码级理解
把热词里的那些面试题串起来,你会发现它们其实是同一根主线。比如:
- 为什么Mapper接口没有实现类?答:JDK动态代理,MapperProxy拦截方法调用,按方法名从Configuration里拿MappedStatement。
- #{}和${}有什么区别?答:
#{}是预编译占位符,走PreparedStatement,防注入;${}是字符串拼接,有注入风险。DML映射中应全部使用#{}。 - insert如何回填主键?答:
useGeneratedKeys+keyProperty,底层是JDBC的getGeneratedKeys();或者用selectKey配合order="BEFORE/AFTER"。 - TypeHandler的作用是什么?答:Java类型与JDBC类型的双向转换,参数写库时执行
setNonNullParameter,结果集映射时执行getNullableResult。 - MyBatis初始化流程是什么?答:
XMLConfigBuilder解析全局配置,XMLMapperBuilder解析Mapper XML,每个DML标签经LanguageDriver处理生成SqlSource,最终封装成MappedStatement注册到Configuration。 - 一级缓存和二级缓存的区别与失效时机?答:一级缓存是SqlSession级别,任何DML操作都会清空当前缓存;二级缓存是namespace级别,DML操作默认
flushCache="true"会清空对应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里组装了哪些内容?包括:sqlSource(SQL来源)、parameterMappings(参数映射列表)、resultMaps(结果映射)、flushCache(执行前是否清缓存)、useGeneratedKeys(是否回填主键)、keyProperty(主键属性名)、statementType(PREPARED/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映射的坑不算多,但每一个都需要你对执行链路有清晰的理解,而不是等到线上炸了才反过来追查。