MyBatis在Web开发中的最佳实践:从JDBC到Spring Boot整合

Web开发做到第6个阶段,大部分人都逃不开一个问题:数据库操作到底怎么组织。早期我在项目里用JDBC写DAO,每个方法都要重复“获取连接、创建PreparedStatement、遍历ResultSet、关闭资源”这一套,代码是真的无聊到爆。后来换到MyBatis,最直观的感受就是SQL终于回到了它该在的位置——XML文件里,跟Java代码分离;结果集映射也由框架自动完成,我只需要关心业务本身。这篇文章就是《MyBatis——6. Web中使用MyBatis》这一系列的重点内容总结,从环境搭建、Mapper映射细节、Spring事务整合到常见报错排查,一条线讲完,适合刚接触MyBatis的Web开发新手,也适合用了一段时间但总在配置和报错上卡壳的同学。

很多人觉得MyBatis就是“会用Mapper接口就行了”,但在真实Web项目里,踩坑的地方往往不在CRUD本身,而在配置、事务、动态SQL、缓存、日志这几个不起眼的角落。我这些年接手过不少半路项目,问题千奇百怪:有insert不报错但数据没写进去的,有批量插入慢到怀疑人生的,有逻辑删除后怎么也查不出数据的,还有生产环境SQL占位符看不到实际参数导致排查效率极低的。这篇文章我会把这些场景一个个拆开讲清楚,并且尽量交代清楚“为什么这样做”,让大家看完不是只会照抄,而是能举一反三。

1. 为什么在Web项目里要把MyBatis单独拎出来讲

1.1 从JDBC到MyBatis的演进逻辑

先看一个最原始的JDBC查询。你要查用户表,流程大概是:Class.forName注册驱动、DriverManager.getConnection拿连接、conn.prepareStatement拼SQL、setString设置参数、executeQuery执行、然后while(rs.next())逐个字段取值,最后还得在finally里关闭ResultSet、Statement、Connection。如果一张表有20个字段,每写一个查询方法就要重复一次“取字段、set字段”的过程,一个User表写满增删改查加上几个自定义查询,代码量轻松上千行,而且80%是重复劳动。

MyBatis解决的核心痛点就是这一块:连接管理交给池子,参数绑定靠#{}占位符自动处理,结果集映射通过resultType或resultMap自动装配。你只需要写SQL本身,Java侧只需要定义接口方法。这种设计对Web项目的意义非常大,因为Web系统通常表结构多、查询条件复杂、联合查询频繁,Hibernate那样完全由框架生成SQL的方案在复杂报表、分页统计、多表关联时反而束手束脚,SQL不可见、不好调优。MyBatis把SQL的控制权彻底还给开发者,同时把机械的样板代码消灭掉,所以在国内企业级Web开发里,它的普及率一直很高。

1.2 MyBatis在Web分层架构中的定位

典型的Web工程是三层职责分离:Controller负责接收请求和参数校验,Service负责业务编排和事务边界,Mapper/DAO负责与数据库交互。MyBatis就处在最底层的数据持久化位置。它不关心你的Controller是Spring MVC还是Spring Boot,也不关心前端是JSP还是Vue,它只管把Mapper接口方法翻译成SQL语句,再把数据库返回的ResultSet变成Java对象。

这个位置看似简单,实际影响却很大。因为Service层的事务要控制到Mapper层,如果你在Service方法上加了@Transactional,那么整个方法内所有Mapper调用都在同一个事务里;如果你在Service里用this调用内部方法,事务注解可能失效;如果你的Mapper没有交给Spring管理,SqlSession生命周期就会失控,最终导致连接不释放、事务边界混乱。后面第4节我会详细展开这些Web集成要点,这里先建立一个整体认知:MyBatis单用不难,难的是在Spring容器里跟事务、连接池、插件体系正确协作。

需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。

2. 环境搭建与初始化配置的实操细节

2.1 依赖引入与版本选择建议

如果你是Spring Boot项目,依赖其实很简单,核心就两个:

xml复制<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.3.2</version>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

版本这里多说一句,mybatis-spring-boot-starter 2.x系列对应Spring Boot 2.x,3.x系列对应Spring Boot 3.x。很多人在Eclipse里遇到“SpringBoot集成MyBatis一直报downloading…”的问题,多半是Maven没有正确下载依赖,而不是代码写错。遇到这种情况,先检查settings.xml里的镜像仓库,阿里云镜像换成这个基本能解决:

xml复制<mirror>
    <id>aliyunmaven</id>
    <mirrorOf>central</mirrorOf>
    <url>https://maven.aliyun.com/repository/public</url>
</mirror>

还有一个小坑:如果项目是IDEA 2024版本新建的,Spring Initializr默认http地址可能会因为服务超时卡住,建议在创建项目时把Server URL改成阿里云的start服务地址。这个问题跟MyBatis本身无关,但它会阻止你进入后续开发。

2.2 核心配置文件的逐项解读

单独使用MyBatis(不整合Spring)需要mybatis-config.xml,整合Spring Boot后大部分配置可以放到application.yml,但我还是建议你理解config文件里每个标签的作用,因为很多报错都跟这些配置相关。

yaml复制mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    cache-enabled: false

map-underscore-to-camel-case这个配置,说它是“省事神器”一点也不过分。数据库字段通常叫create_time,Java属性叫createTime,没有这个配置,你查出来的createTime永远是null,而且不报错,非常隐蔽。很多新手排查半天找不到原因,实际上就是这个开关没打开。

log-impl这里我建议开发环境用StdOutImpl,简单粗暴,直接在控制台打印SQL和执行参数。生产环境则换成logback或者log4j2,并且把Mapper包日志级别设置到DEBUG,方便排查线上问题。不过也要注意:打印日志会引入磁盘IO开销,高并发系统里不要把DEBUG级别开太久。

cache-enabled建议先设为false。MyBatis的二级缓存默认是打开的(其实默认值是true,但很多脚手架的config里会主动关掉),如果对缓存原理不熟,一不留神就会遇到“改完数据库查出来还是旧数据”这种诡异问题。先关掉,把业务功能跑通了,再考虑缓存策略。

2.3 第一个Mapper的完整落地过程

我以最经典的UserMapper为例,走一遍从接口到XML再到测试的完整链路。

首先是Java接口,注意不要写实现类:

java复制package com.example.mapper;

import com.example.entity.User;
import org.apache.ibatis.annotations.Param;

public interface UserMapper {
    User selectById(@Param("id") Long id);
    int insertUser(User user);
}

然后是对应的XML文件,放在resources/mapper目录下:

xml复制<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">

    <select id="selectById" resultType="com.example.entity.User">
        SELECT id, user_name, create_time
        FROM user
        WHERE id = #{id}
    </select>

    <insert id="insertUser" parameterType="com.example.entity.User"
            useGeneratedKeys="true" keyProperty="id">
        INSERT INTO user(user_name, create_time)
        VALUES(#{userName}, #{createTime})
    </insert>
</mapper>

这里的namespace必须写接口的全限定名,id必须与接口方法名一致,否则启动时可能不报错,一调用就抛“Invalid bound statement (not found)”。这是新手最常见的错误之一,原因就是namespace写错或XML文件没有被扫描到。

最后在启动类或配置类上加@MapperScan:

java复制@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

到这一步,你的第一个Mapper就已经能在Web项目里被注入并使用了。

3. Mapper接口与XML映射的核心细节

3.1 Mapper绑定机制与常见误区

MyBatis之所以让你只写接口不写实现类,是因为框架在启动时会扫描Mapper接口,解析同名XML文件,通过JDK动态代理生成实现类。这个代理对象在方法调用时做的事情很简单:根据方法的全限定名+方法名找到对应的MappedStatement,然后执行SQL并处理结果。

由此能推导出几个结论。第一,Mapper接口不能重载,因为方法名是查找SQL语句的key,重载会导致冲突。第二,XML文件必须被mapper-locations配置覆盖到,Spring Boot里默认扫描classpath:mapper/*.xml,如果你的XML放到别的目录,就得显式配置。第三,Mapper接口的每个方法参数,如果有多个,必须用@Param指定名称,否则XML里只能用arg0、param1这种方式,可读性很差。

再说一个很多人忽略的点:如果你在XML里同时定义了某个id的SQL,又在接口方法上写了@Select注解,XML中的定义会生效吗?实际上两个都会注册,方法调用时会按一定顺序查找,这种行为在不同版本里有细微差别,从设计上就不建议混用。团队开发里应该约定一个规范:简单SQL用注解,复杂SQL用XML,但一个方法只能选一种,不要双写。

3.2 参数传递、动态SQL与if test的实用写法

动态SQL是MyBatis的王牌功能,四个标签能覆盖90%的业务场景。我用一个多条件列表查询来演示:

xml复制<select id="listByCondition" resultType="com.example.entity.User">
    SELECT id, user_name, create_time
    FROM user
    <where>
        <if test="userName != null and userName != ''">
            AND user_name LIKE CONCAT('%', #{userName}, '%')
        </if>
        <if test="createTimeStart != null">
            AND create_time &gt;= #{createTimeStart}
        </if>
    </where>
    ORDER BY create_time DESC
</select>

注意两点:标签会自动去掉第一个多余的AND/OR,这是很多人手写WHERE拼接时最容易出的问题;XML里大于小于号要转义,>=写成>=,<=写成<=,否则XML解析直接报错。

关于if test里的判断,有个细节值得单独讲。偶尔会遇到需要判断字符串包含的场景,比如筛选某个状态下包含“审批”字样的记录,可以用indexOf方法,但要写成:

xml复制<if test="keyword != null and status.indexOf('审批') != -1">
    AND status = #{status}
</if>

这里有个大坑:OGNL表达式里字符串比较用单引号,双引号会被当成对象属性名,导致启动直接报错。我见过不止一个同事在这里卡一下午。

除了if,foreach也是批量操作的核心。批量插入时,如果你用:

xml复制<insert id="batchInsert">
    INSERT INTO user(user_name, create_time)
    VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.userName}, #{item.createTime})
    </foreach>
</insert>

语法本身没问题,但要注意MySQL对SQL语句长度有限制(默认max_allowed_packet是4MB,很多云数据库调到了16MB)。如果一次性插入几万条数据,生成出来的SQL字符串可能超过限制,建议按500~1000条一批去切分,既不会爆包,事务提交也更快。

3.3 #{}与${}的区别和应用边界

这个知识点面试必问,开发也必踩。简单说,#{}是预编译占位符,MyBatis会把它替换成?,再用PreparedStatement.setObject绑定参数,可以防SQL注入;${}是字符串拼接,直接把变量值拼接到SQL里,存在注入风险。

那么${}是不是完全不能用?也不是。像ORDER BY column、动态表名、动态数据库名,这些地方无法用占位符,只能用${}。看网上的安全规范,一般会要求:${}只允许出现在非用户直接输入的位置,或者做严格的白名单校验。比如前端传一个排序列名,后端用一个Map白名单把它映射成固定字符串再拼进去,不要直接拿原始值拼接。

我画个简单对比方便记忆:

对比项 #{} ${}
底层处理 PreparedStatement占位符 纯字符串替换
SQL注入风险
能用在 字段值、条件值 ORDER BY、表名、列名
预编译优化 支持 不支持
传参示例 AND id = # ORDER BY $

从实际项目经验看,安全规范应该当作红线来执行:面向用户的查询条件一律用#{},任何拼接用户输入进${}的做法都要在代码评审阶段打回。

3.4 resultType、resultMap与多表查询

resultType适合数据库字段名和Java属性名完全一致(或开启了驼峰映射)的单表查询;一旦涉及多表联查,比如用户表关联部门表,要返回一个包含部门名称的VO对象,推荐用resultMap显式映射,可读性和可维护性都更好。

xml复制<resultMap id="UserDeptMap" type="com.example.vo.UserDeptVO">
    <id property="id" column="id" />
    <result property="userName" column="user_name" />
    <result property="deptName" column="dept_name" />
</resultMap>

<select id="selectUserWithDept" resultMap="UserDeptMap">
    SELECT u.id, u.user_name, d.dept_name
    FROM user u
    LEFT JOIN dept d ON u.dept_id = d.id
    WHERE u.id = #{id}
</select>

这里有个性能隐患:一对多查询如果直接在SQL里用LEFT JOIN,可能会导致一张主表记录对应多张子表记录,返回行数膨胀。更常见的做法是分两条SQL查询,主记录查一次,子列表查一次,然后在Service层组装;或者用collection子查询,但要注意它可能导致N+1查询,数据量大时需要谨慎选择。

4. Web集成、事务与批量操作实战

4.1 Spring整合MyBatis的关键组件

Spring Boot里mybatis-spring-boot-starter已经帮你做了大部分配置,无非就是SqlSessionFactoryBean和MapperScannerConfigurer这两个核心动作。SqlSessionFactoryBean负责构建SqlSessionFactory,它需要数据源、全局配置、Mapper XML位置这些依赖;MapperScannerConfigurer负责扫描指定包下的Mapper接口,把它们注册为Spring Bean。

理解这一点对排查问题很有帮助。比如报错“No qualifying bean of type 'UserMapper'”,多半是@MapperScan路径没扫对,或者Mapper接口上没有加@Mapper注解。又比如报告“Invalid bound statement”,说明Mapper Bean创建成功了,但找不到对应的XML SQL,这是mapper-locations路径问题。

连接池选型方面,Web项目里我基本默认用HikariCP,Spring Boot 2.x之后的默认选择就是它,性能好、配置简单。有几个参数值得调一下:

yaml复制spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000

maximum-pool-size不宜设置过大,数据库连接是稀缺资源,经验值是“核心CPU核数×2+1”附近,理论上连接数太多反而因为上下文切换导致吞吐下降。如果生产环境监控发现连接池打满,优先排查SQL慢查询和连接泄漏,而不是盲目加大连接数。

4.2 事务管理与read-only mode报错

Spring事务通常是靠@Transactional注解实现的,放在Service类或方法上。MyBatis对这个注解的配合主要体现在SqlSessionTemplate的事务同步机制上:Spring开启事务后,MyBatis会把SqlSession绑定到当前线程,所有Mapper方法复用同一个会话,这样多个SQL就处于同一条数据库连接里,能够共享一个事务。

接下来重点说一个高频报错:write operations are not allowed in read-only mode (FlushMode.MANUAL)。出现这个错误通常是因为你在只读事务里执行了写操作。比如:

java复制@Transactional(readOnly = true)
public void updateUser(User user) {
    userMapper.updateById(user);
}

Spring在readOnly = true时会把FlushMode设置成MANUAL,MyBatis认为当前事务是只读的,不允许flush,于是抛出这个异常。解决办法是检查事务边界:只查询的方法保留readOnly = true,涉及写操作的方法把readOnly删掉。还有一种情况是同一个方法里既查询又更新,但方法上误标了readOnly = true。

另一个容易踩的坑是事务方法之间通过this调用导致代理失效。Spring事务基于AOP代理实现,如果类内部方法自调用,比如a()里调this.b(),b()上的@Transactional不会生效。正确的方式是把要开事务的方法放到另一个Bean里,或者自己注入代理对象。这个坑在代码评审时几乎每次都能看到。

4.3 分页查询的正确姿势

Web列表页几乎离不开分页。原生写法是手动写LIMIT #{offset}, #{pageSize},然后单独写一个COUNT查询,代码多,而且容易因为条件不一致导致总数和列表不匹配。

我的方案是用PageHelper,这也是国内Web项目最常见的分页插件。用法很简单,在查询前启动分页即可:

java复制PageHelper.startPage(pageNum, pageSize);
List<UserVO> list = userMapper.selectPageList(condition);
PageInfo<UserVO> pageInfo = new PageInfo<>(list);

PageHelper的原理是基于MyBatis拦截器,在执行SQL前拦截,自动生成COUNT查询和带LIMIT的查询,并把分页结果塞进Page对象。它确实方便,但有几个注意事项:

  1. 分页参数紧跟着的下一条SQL语句才会被拦截,如果中间插入其他查询,就会分页到错误的语句上。
  2. PageHelper.startPage只对下一次查询生效,查询后没有PageInfo包装,Page对象里的total需要强转或者用PageInfo获取。
  3. 多表JOIN查询时,COUNT语句会被拦截器自动处理,但如果你自己写了包含ORDER BY的复杂SQL,COUNT可能不够优化,遇到慢查询要手动改写。

4.4 批量插入和性能优化

批量操作是Web后台里常见的需求,比如数据导入、批量发布。处理方案一般有两条路。

第一条是前面讲过的foreach拼SQL,优点是SQL少、交互次数少,适合几百到几千条的数据量。第二条是MyBatis的ExecutorType.BATCH模式,适合几万条以上的大批次:

java复制SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
try {
    UserMapper mapper = sqlSession.getMapper(UserMapper.class);
    for (User user : userList) {
        mapper.insertUser(user);
    }
    sqlSession.commit();
} finally {
    sqlSession.close();
}

BATCH模式会把多条SQL累积到一批提交,减少网络往返,但它的坑在于无法获得每条记录的自动生成的主键,因为执行并没有真正执行,而是攒批。所以如果后续逻辑需要自增主键,批量模式就不合适。

还有一个很多人在MyBatis-Plus里踩的坑:调用batchInsert方法后数据没写进去但不报错。这个问题常见原因有三个。第一,事务没有提交,虽然单条方法默认自动提交,但在Spring事务包裹下要等事务方法结束;第二,实体类里主键字段为null且主键策略设置错误,导致插入SQL不包含主键字段,数据库有默认值但引擎未回填;第三,某些字段因为数据库字段名映射不上(下划线转驼峰配置没开),插入时被当成null,而字段又是非空约束,按理说会报错,但如果数据库有默认值,就可能出现“不报错也不写入”的假象。排查方法很简单:先看SQL日志,一条条过,看插入语句里到底有没有值。

5. 日志输出与SQL打印的实用技巧

5.1 日志打印的两种基本方式

开发阶段调试SQL,最常用的就是配置log-impl为StdOutImpl,MyBatis会把执行的SQL、参数、查询结果条数打印到控制台。它的优势是零依赖零成本,适合本地开发;缺点是没有日志级别控制,打印内容比较混乱,生产环境肯定不能开。

Spring Boot项目更规范的做法是配置日志级别:

yaml复制logging:
  level:
    com.example.mapper: debug

MyBatis的Mapper接口所在的包日志级别调到DEBUG,就会打印完整SQL和绑定参数,而且输出格式跟应用其他日志统一,可以接入日志文件、日志采集系统。如果你用logback,还可以给mapper包单独设置Appender,把SQL日志单独落到一个文件,排查问题时非常方便。

5.2 插件辅助打印可执行SQL

占位符SQL一个不方便之处是,日志中只显示参数和预编译语句,不能直接拷贝到Navicat执行。比如日志输出:

code复制==>  Preparing: SELECT * FROM user WHERE id = ?
==> Parameters: 1001(Long)

你没法一眼看出实际执行的完整SQL长什么样。后来我用了IDEA的MyBatis Log Plugin,它能把这类日志自动还原成可以直接执行的SQL语句,省去手动替换占位符的过程。这类插件很多,除了MyBatis Log Plugin,还有MyBatis Log EasyPlus。两者功能类似,都是监听控制台输出,解析后合并成实际SQL。

这里要提醒一点:这类插件只适合开发环境,千万不要在生产服务器上装。生产环境打印可执行SQL本来就有风险,因为参数里可能带电话号码、身份证号等敏感信息,日志很容易变成数据泄露的出口。如果确实需要在生产排查SQL问题,建议先做参数脱敏,打印前把敏感字段值替换成星号。

5.3 慢SQL监控思路

日志打印是基础手段,Web项目还想再进一步,就要关注慢查询。MySQL层面可以开启慢查询日志,但慢查询阈值往往需要在DBA那协调;应用层更直接的办法是给Mapper方法做耗时埋点。用MyBatis的Interceptor插件可以统一拦截,记录每个SQL的执行时间,超过阈值就输出WARN日志。

java复制@Intercepts(@Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
))
public class SlowSqlInterceptor implements Interceptor {
    private static final long SLOW_SQL_MILLIS = 500L;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return invocation.proceed();
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > SLOW_SQL_MILLIS) {
                // 输出SQL信息和耗时
            }
        }
    }
}

插件拦截器虽然写起来简单,但使用时要注册到MyBatis配置里,并且要理解它会对所有Executor调用生效,包括查询和更新,所以拦截逻辑要尽量轻量,避免本身成为性能瓶颈。

6. MyBatis缓存机制与实战边界

6.1 一级缓存、二级缓存的运行逻辑

一级缓存是SqlSession级别的,默认开启。同一个SqlSession里执行两次相同的查询,第二次会直接从缓存返回,不再查库。在Spring整合环境下,每个事务可能复用一个SqlSession,因此同一个Service方法里多次查询同一行数据,实际可能只查了一次库。

看起来是好事,但有一个坑:如果你在一个SqlSession里先查询用户,然后通过另一个Mapper更新了这个用户,仍然用原SqlSession去查,可能拿到脏数据。因为一级缓存只按statementId+参数+分页条件等维度判断,不感知数据变化。解决办法是手动调用sqlSession.clearCache(),或者把查询和更新放在不同的事务边界里。

二级缓存是namespace级别的,跨SqlSession共享。它的作用范围是同一个Mapper的XML文件里定义的SQL,一般配合redis或ehcache实现分布式缓存。但二级缓存最危险的地方在于:MyBatis并不清楚你的数据表之间是否有外键关联,一个Mapper的缓存无法感知另一个Mapper对同一张表的修改。比如UserMapper缓存了用户信息,UserDetailMapper更新了同一张用户表的某个字段,UserMapper的缓存依然返回旧值。

所以我的建议是:Web项目在没搞清楚所有写入路径之前,别轻易开二级缓存。大部分业务系统用Redis做应用层缓存(比如缓存热点数据),MyBatis负责数据库访问,两者职责清晰得多,远好过在持久层搞一套不可控的缓存。

6.2 从缓存报错看问题排查思路

有时候你会看到类似“Cache 'xxx' does not exist”的报错,或者flushCache相关配置不生效。这些问题往往出在配置冲突上:全局cache-enabled和XML里的标签混用,或者二级缓存实现类配置错误。

我处理这类问题有一个习惯:先通过日志确认SQL是否真正执行了。在Mapper XML的