MyBatis 把 SQL 的编写权留给开发者,只接管参数映射、SQL 执行、结果映射这三件重复劳动。Spring Boot 用一个 starter 完成 SqlSessionFactory 构建、Mapper 接口注册、SqlSession 托管的全套装配。本文沿"自动配置 → Mapper 代理 → SqlSession 与事务"这条主线拆解整合原理,再落到实践规范。自动配置的通用机制见Spring Boot Startup

核心架构

组件与生命周期

组件职责生命周期
SqlSessionFactoryBuilder解析配置,构建工厂方法局部,用完即弃
SqlSessionFactory生产 SqlSession应用级,单例
SqlSession执行 SQL 的会话请求/事务级,非线程安全
Mapper 接口声明 SQL 操作应用级(实际是代理对象)

原生 MyBatis 要求手工管理 SqlSession 的创建与关闭;Spring 整合后这一层被 SqlSessionTemplate 接管,生命周期问题随之消失。

Spring Boot 整合

依赖与配置

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

starter 3.0.x 对应 Spring Boot 3.2–3.5、Java 17+。

1
2
3
4
5
6
7
8
9
10
11
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?serverTimezone=UTC
username: root
password: ${DB_PASSWORD}

mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.demo.entity
configuration:
map-underscore-to-camel-case: true

自动配置做了什么

MybatisAutoConfiguration 的两个前提条件:

1
2
3
@ConditionalOnClass({SqlSessionFactory.class,
SqlSessionFactoryBean.class})
@ConditionalOnSingleCandidate(DataSource.class)

满足后注册两个核心 Bean:

  • SqlSessionFactory:由 SqlSessionFactoryBean 构建。启动时解析 mapper-locations 指定的全部 XML,把每条 SQL 连同参数映射、结果映射编译成 MappedStatement,以 namespace.方法名 为 key 存入 Configuration
  • SqlSessionTemplate:SqlSession 的线程安全门面,所有 Mapper 代理最终调用的都是它
flowchart TB
  classDef boot fill:#e1f5fe,stroke:#0288d1,color:#01579b
  classDef scan fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c
  classDef bean fill:#fff3e0,stroke:#f57c00,color:#e65100
  classDef ready fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  subgraph S1["自动配置"]
    D["SqlSessionFactoryBean<br/>解析 mapper XML"]:::boot
    E["MappedStatement<br/>SQL 与映射的元数据"]:::boot
    F["SqlSessionTemplate<br/>线程安全会话门面"]:::bean
    D --> E
  end

  subgraph S2["Mapper 扫描"]
    A["@MapperScan / @Mapper"]:::scan
    B["ClassPathMapperScanner<br/>扫描指定包"]:::scan
    C["每个接口注册为<br/>MapperFactoryBean"]:::scan
    A --> B --> C
  end

  G["容器就绪:注入接口<br/>即注入代理对象"]:::ready
  E --> G
  C --> G

Mapper 接口的代理机制

整合的核心问题:接口没有实现类,为什么能被注入并执行 SQL?

扫描与注册

@MapperScan 触发 MapperScannerRegistrar 注册 MapperScannerConfigurerClassPathMapperScanner 扫描指定包,把每个接口注册为一个 MapperFactoryBean 的 BeanDefinition,并注入 sqlSessionFactory。

@Mapper 走同一个扫描器,只是入口换成自动配置里的 AutoConfiguredMapperScannerRegistrar,默认扫描主类所在包。二者机制等价,选一即可:包结构固定时 @MapperScan 一次圈定全部接口;多模块或类路径不确定时用 @Mapper 显式标记。

运行期调用链

MapperFactoryBean.getObject() 调用 getSqlSession().getMapper(interface),MyBatis 用 JDK 动态代理生成 MapperProxy。调用接口方法的完整链路:

flowchart TB
  classDef app fill:#e1f5fe,stroke:#0288d1,color:#01579b
  classDef proxy fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c
  classDef session fill:#fff3e0,stroke:#f57c00,color:#e65100
  classDef exec fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A["userService.findById(id)"]:::app
  B["MapperProxy.invoke()<br/>JDK 动态代理"]:::proxy
  C["MapperMethod<br/>定位 MappedStatement"]:::proxy
  D["SqlSessionTemplate"]:::session
  E["SqlSessionInterceptor<br/>绑定事务会话"]:::session
  F["Executor<br/>预编译参数,执行 SQL"]:::exec
  G["ResultSetHandler<br/>结果映射为对象"]:::exec

  A --> B --> C --> D --> E --> F --> G

MapperMethod 以 接口全限定名.方法名 定位 MappedStatement,把接口参数转成 SQL 参数,交给 SqlSession。注解 SQL 与 XML SQL 在 MappedStatement 这一层完全统一,差别只是元数据的来源。

SqlSession 与事务

SqlSessionTemplate 内部所有调用都经过 SqlSessionInterceptor,它决定真实会话的来源与归宿:

  • 无事务:每次 Mapper 调用新建 SqlSession,执行完立即关闭。一级缓存形同虚设,同一查询调两次就查两次库
  • @Transactional:从 TransactionSynchronizationManager 取当前事务绑定的 SqlSession,事务内复用,提交或回滚后才关闭。一级缓存生效

由此得到两个实践推论:

  1. 依赖一级缓存去重的逻辑必须包在同一事务内,否则不生效
  2. 事务内的缓存可能读到过期数据(其他连接已修改未提交前读取的数据不会刷新),敏感场景将缓存作用域降为语句级:
1
2
3
mybatis:
configuration:
local-cache-scope: statement

实战规范

实体与 Mapper

简单单表 CRUD 用注解足够;动态 SQL、多表关联、复杂结果映射交给 XML。

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.demo.entity;

import java.time.LocalDateTime;
import lombok.Data;

@Data
public class User {
private Long id;
private String username;
private String email;
private LocalDateTime createdAt;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.demo.mapper;

import java.util.List;
import org.apache.ibatis.annotations.*;
import com.example.demo.entity.User;

@Mapper
public interface UserMapper {

@Select("SELECT * FROM users WHERE id = #{id}")
User findById(Long id);

@Insert("""
INSERT INTO users (username, email, created_at)
VALUES (#{username}, #{email}, #{createdAt})
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);

List<User> findByCondition(
@Param("username") String username);
}

动态 SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<mapper namespace="com.example.demo.mapper.UserMapper">

<sql id="base_columns">
id, username, email, created_at
</sql>

<select id="findByCondition" resultType="User">
SELECT <include refid="base_columns"/>
FROM users
<where>
<if test="username != null and username != ''">
AND username LIKE CONCAT('%',
#{username}, '%')
</if>
</where>
ORDER BY created_at DESC
</select>
</mapper>

<sql> 片段集中维护列清单,避免 SELECT * 在表结构变更时连带破坏映射。

映射方式的选择

map-underscore-to-camel-case 开启后,单表查询用 resultType 直接映射实体,无需 resultMap。resultMap 留给三种场景:列名与属性名无法靠驼峰规则对齐、多表关联需要 association/collection 嵌套映射、需要 discriminator 多态映射。不要为"规范"而写 resultMap——多数 resultMap 只是驼峰规则的手工复写。

批量插入

1
int batchInsert(@Param("list") List<User> users);
1
2
3
4
5
6
<insert id="batchInsert">
INSERT INTO users (username, email) VALUES
<foreach collection="list" item="u" separator=",">
(#{u.username}, #{u.email})
</foreach>
</insert>

foreach 拼接的是单条多值 SQL,条数控制在几百级别,超过会顶到 max_allowed_packet 与 SQL 长度限制;更大批量分批提交,或改用 JDBC batch + rewriteBatchedStatements=true

分页

1
2
3
4
5
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
1
2
3
4
public PageInfo<User> page(int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
return new PageInfo<>(userMapper.findAll());
}

PageHelper 通过拦截器改写 SQL 追加 LIMIT。startPage 与目标查询必须紧邻:它把分页参数塞进 ThreadLocal,下一条 SQL 会被消费掉,中间插入任何查询都会被错误分页。

事务边界在 Service

1
2
3
4
5
6
7
8
9
10
11
12
@Service
@RequiredArgsConstructor
public class UserService {

private final UserMapper userMapper;

@Transactional(rollbackFor = Exception.class)
public void register(User user) {
userMapper.insert(user);
// 同一事务内的后续查询复用 SqlSession
}
}

rollbackFor = Exception.class 是必须项:默认只回滚 RuntimeException 与 Error,受检异常会静默提交。

HikariCP 连接池

Spring Boot 默认使用 HikariCP。数据库连接的建立要经过 TCP 握手、认证、会话初始化,代价高到必须复用;连接池维护一组常驻连接,用租借语义摊薄成本。

参数说明推荐值
maximum-pool-size池上限CPU 核数 × 2 + 有效磁盘数,通常 10–20
minimum-idle空闲下限与 maximum 相同,固定大小池
connection-timeout取连接等待超时30s,按业务容忍度调整
max-lifetime连接最大存活默认 30min,须小于数据库 wait_timeout

池不是越大越好:连接过多只会加剧数据库侧的锁竞争与上下文切换,瓶颈在磁盘与网络而非应用线程数。

监控接 Actuator 后直接看 hikaricp_connections_active 等指标;代码侧走 MXBean:

1
2
3
4
5
HikariPoolMXBean pool =
dataSource.getHikariPoolMXBean();
pool.getActiveConnections();
pool.getIdleConnections();
pool.getThreadsAwaitingConnection();

常见陷阱

#{}${}

#{} 生成预编译占位符 ?,参数走 PreparedStatement,类型安全;${} 是字符串直接拼接,存在 SQL 注入风险。${} 仅用于无法预编译的位置——动态表名、排序列名,且值必须来自白名单校验后的枚举,绝不接受用户输入。

MySQL 8 小时断连

MySQL 默认 wait_timeout=28800,空闲连接 8 小时后被服务器单方面关闭。HikariCP 默认 max-lifetime 为 30 分钟,会在服务器超时前主动淘汰连接,默认配置即已规避。只有手动调大 max-lifetime、或数据库侧把 wait_timeout 调小时,才需要保证 max-lifetime < wait_timeout。故障特征是隔夜后第一批请求抛 CommunicationsException

连接泄漏

连接借出未还,池逐渐耗尽,最终报 Connection is not available, request timed out。开启泄漏检测,HikariCP 会打印超时连接的持有栈:

1
2
3
4
spring:
datasource:
hikari:
leak-detection-threshold: 60000

Spring 整合下正常业务代码不会泄漏——SqlSessionInterceptor 总会归还连接;泄漏通常来自绕过框架手工开的 Statement、ResultSet 或数据源连接。

相关阅读