做Java Web开发,不管你是刚入门还是已经干了几年,持久层框架基本是绕不开的一环。而MyBatis在国内Web项目里的占有率,大家心里都有数。今天这篇就是系列第六篇,专门聊Web环境下用MyBatis这件事——不是简单讲讲配置怎么写,而是把Web项目里集成MyBatis时那些真正让人头疼的点:动态SQL怎么组织、事务为什么不生效、缓存到底要不要开、SQL打印怎么排查,一次性理清楚。这篇文章适合正在做Java Web项目、想系统梳理MyBatis实战用法的同学,哪怕你之前只用过JdbcTemplate,看完也能在Web项目里把MyBatis用得明明白白。
1. 为什么Web项目里要用MyBatis做持久层
1.1 从手写JDBC到MyBatis,到底解决了什么痛点
我在早期做Web项目的时候,还是Servlet + JDBC那一套。写一个查询接口,要手动注册驱动、获取Connection、拼PreparedStatement、处理ResultSet、关资源。代码长不说,最怕的就是忘记关闭连接导致连接池耗尽,或者SQL里少了一个空格、多个逗号,排查半天发现是字符串拼接的问题。尤其是查询条件稍微复杂一点,if else拼SQL拼到怀疑人生。
MyBatis解决的恰恰就是这些痛点。它把JDBC的样板代码全部封装掉,你只需要关心两件事:SQL本身,以及参数和结果集的映射。你可以把SQL写在XML里,也可以写在注解里。相比Hibernate那种全自动ORM,MyBatis是半自动的——对象映射可以自动做,但SQL完全由你掌控。这在Web项目里特别重要,因为业务SQL往往千奇百怪,多表联查、子查询、动态条件,用Hibernate的Criteria或者JPQL写起来反而绕远路。
从架构角度说,Web项目天然是分层结构:Controller接收请求,Service处理业务,Mapper操作数据库。MyBatis正好落在最底层的数据访问层,它不关心你上层是Spring MVC还是Spring Boot,只负责把你定义的SQL执行掉,把结果变成Java对象返回。这种边界清晰的设计,让整个团队协作变得简单——前端不用管SQL,业务层不用管数据库连接,DBA也能直接看XML里写的SQL来做优化。
1.2 一个Web项目里MyBatis到底扮演什么角色
我们经常说MyBatis是"持久层框架",但这个"持久层"在Web项目里具体包含哪些东西,很多人其实没仔细想过。我拆开来说:
- SqlSessionFactory:整个MyBatis的核心,负责创建SqlSession。在Web项目里,一个应用通常只需要一个SqlSessionFactory(单例),它从数据源里获取连接,管理事务,加载Mapper配置。
- SqlSession:你可以把它理解成一次数据库会话。它封装了JDBC的Connection操作,真正执行SQL就靠它。Web项目里每一次请求都会创建、使用、关闭SqlSession,用完必须关,否则连接泄漏。
- Mapper接口与XML:这是MyBatis最具特色的设计。你定义一个Java接口,比如UserMapper,里面写接口方法。同时定义一个XML文件,里面写SQL。MyBatis启动时通过动态代理生成接口的实现类,把方法调用映射到XML里的SQL语句。接口和XML之间靠"命名空间+方法名"来绑定。
- 数据源与连接池:MyBatis本身不提供连接池,它需要你外接一个。Web项目里最常用的是HikariCP、Druid这些。数据源的作用是高效管理数据库连接,避免每个请求都新建连接。
- Spring集成层:现代Web项目基本都是Spring Boot + MyBatis的组合。Spring通过mybatis-spring-boot-starter自动配置SqlSessionFactory和Mapper扫描,帮我们把SqlSession绑定到当前事务上下文中,做到"一次请求一个事务、一个SqlSession"。
这套组合有个特别好的地方:职责单一。SqlSessionFactory负责会话工厂,Mapper负责SQL定义,Spring负责事务边界,连接池负责连接复用。出了问题,你能很清楚地知道去哪里排查。不像早期用JDBC的裸奔方式,哪个环节出问题都得从头到尾看一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Web环境下的MyBatis核心配置与整合要点
2.1 Spring Boot项目中引入MyBatis的最小配置
现在的Web项目十有八九是Spring Boot。引入MyBatis其实非常简单,在pom.xml里加上依赖就行:
xml复制<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
数据库驱动根据实际情况选:
xml复制<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
然后是application.yml里的核心配置。我常用的最小配置大概是这样的:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/web_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.webdemo.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这里面有几个关键点:
driver-class-name要注意版本。MySQL 8.x的驱动类名是com.mysql.cj.jdbc.Driver,老项目里写的com.mysql.jdbc.Driver在新驱动里会导致启动报错。MySQL 8.x还要在url里加serverTimezone参数,否则会有时区相关异常。url里的useUnicode=true&characterEncoding=utf8是多年来的经验,防止中文乱码。当然如果数据库和表本身是utf8mb4,建议把characterEncoding也改成utf8,配合characterSetResults一起用。map-underscore-to-camel-case这行强烈建议开。数据库字段普遍是create_time这种下划线风格,Java实体类用的却是createTime驼峰风格,开启这个配置后MyBatis会自动帮你做映射,省去一堆resultMap手写工作量。log-impl配置成StdOutImpl,开发环境可以直接把SQL打印到控制台,排查问题非常方便。生产环境建议去掉,或者换成SLF4J输出到日志文件。
注意:
mapper-locations的路径一定要和实际XML放置位置一致,否则MyBatis启动时扫描不到XML文件,Mapper接口的方法执行时会报"Invalid bound statement (not found)"。
2.2 Mapper接口与XML映射的对应关系
很多新手刚接触MyBatis时最大的困惑是:接口方法和XML是怎么关联起来的?我见过不少同事在这个环节踩坑。
先说接口。定义接口很简单:
java复制package com.example.webdemo.mapper;
public interface UserMapper {
User findById(Long id);
List<User> findByCondition(UserQuery query);
int insert(User user);
int updateById(User user);
int deleteById(Long id);
}
然后XML文件:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.webdemo.mapper.UserMapper">
<resultMap id="userMap" type="User">
<id property="id" column="id"/>
<result property="userName" column="user_name"/>
<result property="createTime" column="create_time"/>
</resultMap>
<select id="findById" parameterType="long" resultMap="userMap">
SELECT id, user_name, create_time
FROM sys_user
WHERE id = #{id}
</select>
<select id="findByCondition" parameterType="com.example.webdemo.query.UserQuery" resultMap="userMap">
SELECT id, user_name, create_time
FROM sys_user
<where>
<if test="userName != null and userName != ''">
AND user_name LIKE CONCAT('%', #{userName}, '%')
</if>
<if test="createTime != null">
AND create_time >= #{createTime}
</if>
</where>
</select>
<insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id">
INSERT INTO sys_user (user_name, create_time)
VALUES (#{userName}, #{createTime})
</insert>
</mapper>
几个容易混淆的点:
namespace必须写Mapper接口的全限定名,MyBatis靠它把XML和接口绑定起来。- XML里的
id必须和接口方法名完全一致,包括大小写。 parameterType可以写别名(比如User),前提是在type-aliases-package里配置了实体类包路径;也可以写全限定名(比如com.example.webdemo.query.UserQuery),不配置别名时全限定名最稳妥。resultMap用于复杂字段映射。如果开了map-underscore-to-camel-case,普通字段可以不写resultMap,直接用resultType="User"。
这里的核心思想是:MyBatis在应用启动时会扫描所有XML,构建一个MappedStatement的映射表,key就是"命名空间 + id"。当你调用userMapper.findById(1L)时,MyBatis通过动态代理拦截到这个调用,按key找到对应的SQL,把参数绑定进去执行,再把结果集映射成List
2.3 动态SQL是Web业务里出镜率最高的功能
Web项目的接口,几乎不可能都是固定SQL。列表页要支持按用户名搜索、按时间范围筛选、按状态过滤,这些条件组合起来就是动态SQL。MyBatis的<if>、<where>、<set>、<foreach>就是干这个的。
上面例子里的<where>标签很典型。它有两个作用:自动去掉SQL里第一个多余的AND/OR,以及当所有条件都不成立时不生成WHERE子句。我见过老代码用手工拼接的方式,业务一复杂就是几百行的if else,维护起来非常痛苦。
再比如批量插入,这也是Web后台管理里经常用到的场景。比如批量导入用户,一次插入几千条数据,如果循环调单条insert,性能会非常差。用<foreach>做个批量插入:
xml复制<insert id="batchInsert" parameterType="list">
INSERT INTO sys_user (user_name, create_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userName}, #{item.createTime})
</foreach>
</insert>
这里有几个细节经验:
collection在参数是List时写list,是数组时写array,是@Param指定名称时写对应的名字。separator=","会帮你在每一条记录之间自动加逗号,注意别在最后一条后面多加。- 批量插入一次别搞太多,建议一批500~1000条。我遇到过同事一次性插入5万条,直接把数据库的max_allowed_packet打爆,报
PacketTooBigException。
动态SQL还有一个容易忽略的场景:更新操作。用<set>加上<if>,可以做到"只更新有值的字段",避免把null写到数据库里:
xml复制<update id="updateById" parameterType="User">
UPDATE sys_user
<set>
<if test="userName != null">user_name = #{userName},</if>
<if test="createTime != null">create_time = #{createTime},</if>
</set>
WHERE id = #{id}
</update>
<set>标签会自动去掉最后那个多余的逗号,比手工写逗号拼接要安全得多。
3. Web请求链路里的事务与缓存设计
3.1 事务控制在Web项目里的正确姿势
Web项目的Service层通常一个方法对应一个业务操作,比如"创建订单"这个方法里可能要同时写订单表、扣库存、记流水,任何一个步骤失败,整个操作都要回滚。这就是事务的典型场景。
Spring Boot + MyBatis下,事务用@Transactional注解就行:
java复制@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto);
stockMapper.decreaseStock(dto.getProductId(), dto.getQuantity());
// 其他业务操作...
}
}
重点说几个Web项目里容易踩的坑:
- 事务不生效的第一号元凶:方法被
this调用。比如同一个类里methodA()调用了methodB(),methodB上有@Transactional,事务不会生效。因为Spring的事务是通过AOP代理实现的,this调用走的是this对象本身,绕过了代理。解决方案是拆到不同的Bean里,或者自己注入自己,或者用AopContext.currentProxy()。 - 异常被吞掉:事务回滚的条件是抛出了RuntimeException(默认配置下)。如果你在代码里try-catch把异常捕获了并且没有重新抛出,Spring就感知不到异常,事务照样提交。这一点我在代码review里见过太多次了。
rollbackFor建议显式声明:虽然Spring默认对RuntimeException和Error回滚,对受检异常(CheckedException)不回滚。但有些团队习惯在业务里抛自定义受检异常,这时候必须rollbackFor = Exception.class,否则数据就悄悄提交了。- 事务时间不宜过长:Web请求里的长事务会导致数据库连接长时间占用,并发一高连接池就满了。我曾经排查过一个线上问题:某接口里在事务内调外部RPC,超时10秒,导致连接池被占满,整个应用卡死。我的建议是:事务方法里只放数据库操作,外部调用、文件操作尽量放在事务外。
3.2 MyBatis缓存机制:一级缓存与二级缓存
好多人对MyBatis的缓存有误解,以为它是解决性能问题的银弹。实际上,在Web项目里用缓存要非常谨慎。
先聊一级缓存。它是SqlSession级别的缓存,默认开启,无法关闭。同一个SqlSession里执行相同的SQL,第二次会直接走缓存,不再查数据库。问题在于:Web项目里SqlSession的生命周期通常绑定在请求/事务级别,同一个请求里的多次相同查询确实能命中缓存。但一级缓存有个知名坑:如果你在同一个SqlSession中先查询数据,然后执行了更新操作,缓存会被清空,这个没事。更隐蔽的是,如果两个线程并发操作同一个SqlSession(实际上Spring管理的SqlSession一般不会出现这种情况),可能读到旧数据。
二级缓存是Mapper级别的缓存,跨SqlSession共享。配置方式是在XML里加<cache/>标签:
xml复制<mapper namespace="com.example.webdemo.mapper.UserMapper">
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>
...
</mapper>
参数含义:eviction是淘汰策略,LRU是最近最少使用;flushInterval是刷新间隔,单位毫秒;size是缓存对象个数;readOnly为true时返回缓存对象的同一个实例(性能好但有安全隐患),false时会序列化后返回副本。
我个人的经验是:二级缓存在单机小项目里可以玩玩,但Web项目一旦上了多实例部署,本地二级缓存就会出问题——节点A更新了数据,节点B的缓存还是旧的,数据不一致。所以现在的主流做法是:MyBatis的二级缓存基本不用,业务缓存直接上Redis,用Spring Cache或者手动RedisTemplate来管。这样既保证一致性,又方便做统一的缓存过期策略。
注意:如果你非要开二级缓存,一定要想清楚数据一致性要求。对于实时性要求高的业务(比如库存、余额),宁可不加缓存也不要冒着脏读的风险。
4. Web项目中常见的MyBatis问题排查实录
4.1 让人崩溃的经典报错与解决思路
我在做技术咨询和代码Review时,总结出几个Web项目里出现频率最高的MyBatis问题,每个都能写一篇文章。
第一个是**"Invalid bound statement (not found)"**。这个报错意思是Mapper接口方法找不到对应的SQL。排查思路就三步:
- 检查XML的
namespace是不是写成了接口的全限定名; - 检查XML里方法对应的
id是不是和接口方法名一致; - 检查
mapper-locations路径配置是否覆盖到了XML文件。
还有一个隐蔽原因:用Spring Boot的@MapperScan扫描接口时,扫描的包路径必须包含所有Mapper接口,否则接口没被注册,也会报这个错。
第二个是**"Write operations are not allowed in read-only mode"**。这个报错通常出现在你设置了只读事务,然后又在里面执行了写操作。比如:
java复制@Transactional(readOnly = true)
public void updateUser(User user) {
userMapper.updateById(user); // 这里报错
}
解决办法就是把readOnly去掉,或者把写操作拆到另一个非只读事务的方法里。这个报错的根本原因,是Spring把只读事务映射到了数据库连接的只读模式,MySQL会在执行写操作时直接拒绝。
第三个是**"TooManyResultsException"**。这个比较有意思:你调用了一个返回单个对象的方法,但SQL查出了多条记录,MyBatis会直接抛异常。比如User findById(Long id),如果数据库里id有重复(通常是因为没加唯一索引),就会触发这个异常。排查思路是先确认SQL结果集的条数,再检查参数是否传递正确。
第四个是**"数据库没写入数据,但也没有报错"**。这个情况我说过很多次,十有八九是事务压根没提交。可能的原因有:
- 方法没有加
@Transactional,但MyBatis的SqlSession和Spring的数据库连接没有绑定,默认的SqlSession不会自动提交; - 加了
@Transactional,但方法被同类调用,事务没生效; - 配置了
spring.datasource.hikari.auto-commit=false,但事务没有被正确管理。
排查这类问题有个通用技巧:把MyBatis的SQL日志打开,看看SQL到底执行没执行。如果SQL打印了但没提交,那就是commit的问题;如果SQL压根没有打印,那就是方法都没走到Mapper那一层。
4.2 让SQL现原形:日志打印与参数查看
排查MyBatis问题,第一步永远是让SQL"现原形"。我在开发环境常用两种方式:
第一种是MyBatis自带的日志输出:
yaml复制mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这样每次执行SQL都会在控制台打印,像这样:
code复制==> Preparing: SELECT id, user_name, create_time FROM sys_user WHERE id = ?
==> Parameters: 1(Long)
<== Columns: id, user_name, create_time
<== Row: 1, zhangsan, 2024-01-01 10:00:00
<== Total: 1
能清楚看到执行的SQL语句、参数值、返回的结果集。特别适合排查参数绑定类问题。
第二种是针对线上环境的。生产环境一般不开StdOutImpl,而是用logback + MyBatis的SLF4J日志,把Mapper包下的日志级别设为DEBUG:
yaml复制logging:
level:
com.example.webdemo.mapper: debug
这样SQL会输出到日志文件里,排查线上问题的时候直接查日志就行。
还有一个排查利器:拦截器。MyBatis提供了Interceptor接口,可以拦截SQL执行,把预编译的SQL和参数拼接成完整SQL打印出来。我常用的做法是写一个简单的拦截器,专门格式化打印SQL,方便在日志里直接拷贝出来到数据库客户端执行。这里贴一个简化版参考:
java复制@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SqlLogInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql().replaceAll("\\s+", " ");
log.info("执行SQL: {}", sql);
return invocation.proceed();
}
}
有同事会用p6spy或者log4jdbc这类第三方库,它们能自动拼接参数、打印真实SQL。如果团队里已经引入这类组件,也可以直接用,省得自己写拦截器。
4.3 常见问题速查表
我整理了一个速查表,这些是从我自己和身边同事踩坑经历里总结的高频问题,建议收藏:
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| Invalid bound statement (not found) | namespace/id不匹配、XML未扫描 | 检查XML的namespace和id;确认mapper-locations路径;确认@MapperScan包路径 |
| Write operations are not allowed in read-only mode | 只读事务里执行写操作 | 去掉readOnly=true;拆分事务方法 |
| SQL执行了但数据没写入 | 事务未提交 / 事务不生效 | 检查@Transactional注解、同类调用问题、检查日志确认commit是否执行 |
| 参数绑定时报BindingException | @Param未加/参数名不匹配 | 多参数方法建议显式加@Param注解 |
| 查询结果字段为null | 驼峰映射未开启 / 字段名不一致 | 开启map-underscore-to-camel-case;检查resultMap映射;检查数据库字段和实体属性 |
| 批量插入很慢 | 单条insert循环调用 | 用<foreach>批量插入;每批500~1000条 |
| 连接池耗尽超时 | 长事务 / 连接未释放 | 检查事务边界;检查SqlSession是否正确关闭;优化连接池参数 |
5. 在Web项目里把MyBatis用得更好的几个进阶思路
5.1 SQL层面的优化:分页、批量和N+1问题
Web项目做得久了,你会发现性能瓶颈很多时候不在框架,而在SQL本身。MyBatis只是帮你执行SQL,SQL写得烂,框架再优化也没用。
分页是Web项目里最常见的功能。MyBatis本身不带分页能力,需要你自己写LIMIT语句,或者引入PageHelper这类分页插件。我建议在Spring Boot项目里直接用PageHelper:
xml复制<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
用法非常简单:
java复制PageHelper.startPage(pageNum, pageSize);
List<User> list = userMapper.findByCondition(query);
PageInfo<User> pageInfo = new PageInfo<>(list);
PageHelper.startPage后面接的第一条SQL会被自动加上LIMIT,返回的PageInfo里包含了总条数、总页数、当前页数据等信息。
但PageHelper有个经典坑:startPage方法必须紧跟查询语句,中间不能穿插其他SQL操作。如果你在startPage和Mapper方法调用之间执行了别的查询,分页插件会应用在错误的SQL上,导致数据错乱或者分页失效。还有一个是:startPage会作用于线程上下文,如果查询方法里没有立刻执行SQL(比如先做了某些校验),分页参数可能被后续的其他查询"吃掉"。所以我的建议是:把PageHelper.startPage和Mapper调用紧挨着写,中间不要有任何别的数据库操作。
N+1查询问题也值得单独说。比如查询订单列表,再根据每个订单查下单用户,如果循环去查用户,那就是经典的N+1。MyBatis里可以通过<collection>做关联查询或者嵌套查询,但更推荐直接用一条JOIN SQL把数据查出来,再用resultMap映射成嵌套对象。我讲一个实际场景:一开始用循环查询,100个订单需要执行101条SQL,接口耗时800ms;改成JOIN一条SQL搞定后,耗时降到30ms。对于Web接口来说,这种提升是很直接的。
5.2 连接池与缓存层面的实践建议
Web应用的高并发场景下,数据库连接池的参数直接决定了系统的吞吐上限。Spring Boot默认用的是HikariCP,它的默认配置已经不错了,但还是要根据实际业务调整几个参数:
maximum-pool-size:最大连接数。不是越大越好,MySQL默认最大连接数是151,你设置200也没用。一般建议cores * 2 + effective_spindle_count这个公式,4核8G的机器设置为10~20比较合理。minimum-idle:最小空闲连接数。这个值设置太大会浪费资源,设置太小冷启动会变慢,一般保持和最大连接数一致或一半。connection-timeout:获取连接的超时时间,默认30秒太长,线上一般建议3~5秒。如果排队超过5秒还拿不到连接,不如直接快速失败,让上层重试或者降级。max-lifetime:连接的最大生命周期,必须小于数据库的wait_timeout。MySQL默认wait_timeout是8小时,HikariCP的max-lifetime默认30分钟,这个配置比较安全。
缓存层面,前面说了MyBatis二级缓存不建议在分布式场景用,更好的方案是用Redis做业务缓存。在很多项目里,MyBatis只是做数据持久化,Redis做一级缓存,通过AOP注解实现"先查Redis、再查数据库"的套路。
java复制@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
public User getUserById(Long id) {
String key = "user:" + id;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, User.class);
}
User user = userMapper.findById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
}
return user;
}
}
这种模式下,MyBatis的二级缓存基本可以忽略,因为数据一致性更好控制,缓存过期策略也更灵活。热数据放Redis,冷数据直接查库,两全其美。还有一个常见做法是把MyBatis的查询结果直接缓存到Redis,通过自定义注解+拦截器来做,但这套方案其实对缓存更新一致性的要求很高,我建议团队里没有专门的基础设施支持时,还是老老实实手动写缓存逻辑,至少出问题的时候你心里有数。
6. 关于MyBatis与Web框架集成的最后几点个人体会
说实话,MyBatis这个框架本身不算难,它只是一个SQL映射工具,核心概念就那几个:SqlSession、Mapper、XML、动态SQL。真正决定项目质量的,是你怎么在Web这个大环境里去用它的细节。
比如:简单查询用注解还是XML?我的习惯是单表简单操作(按主键查询、简单的增删改)用注解,复杂SQL、动态条件查询、报表统计这类必须用XML,把SQL集中管理,方便DBA审查和优化。比如:Mapper接口里到底要不要写业务逻辑?我的答案是绝对不要,Mapper只做数据访问,业务判断放Service层,这样单元测试也好写。
再比如:要不要用MyBatis Plus?这是一个经常被讨论的话题。如果你的项目里有大量单表CRUD,MyBatis Plus确实能省很多模板代码,但它的一些"自动填充""逻辑删除""乐观锁"特性,也会让不熟悉的人迷惑。我的建议是:新项目可以用,但前提是团队里有人能讲清楚这些功能背后的SQL原理,否则出了问题很难排查。我自己在核心业务项目里反而倾向于用原生MyBatis,因为它更可控,SQL都摆在明面上。
还有一个踩坑经验想分享给大家:升级MyBatis版本的时候,一定要关注mybatis-spring-boot-starter和spring-boot版本之间的兼容性。我遇到过一次升级Spring Boot 3.x后,MyBatis启动直接报Package org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration not found,原因是旧版starter不兼容Spring Boot 3,必须升级到2.3.0以上版本。这类问题排查起来不算难,但如果不了解版本对应关系,很容易浪费半天时间。
最后再说一个小技巧:如果你们团队的Web项目经常要排查SQL问题,可以统一在MyBatis配置里把日志调整为Slf4jImpl,并把Mapper包级别的日志设为DEBUG。这样既能输出SQL,又不会像StdOutImpl那样直接把SQL打到控制台造成日志混乱。我已经在团队里推广这个做法,排查效率提高不少。
Web项目里使用MyBatis,本质上是在"控制力"和"开发效率"之间找一个平衡点。你掌握了SQL控制权,就要承担SQL质量的责任;你享受了Mapper的便利,就要理解它背后的代理机制。把这些细节吃透了,你会发现MyBatis其实是一个既简单又可靠的工具,用好了,能让你的Web项目开发事半功倍。
