Skip to content

JPA 与 MyBatis 集成

Spring Data JPA vs MyBatis 选型

对比项Spring Data JPAMyBatis
开发效率✅ 高(方法名推导、无 SQL)中等(需写 SQL)
SQL 控制❌ 弱(复杂查询需 @Query 或原生 SQL)✅ 强(完全控制 SQL)
灵活性低(ORM 思维,表结构映射对象)✅ 高(SQL 思维,适合复杂查询)
学习成本中等(JPA 规范 + Hibernate)低(会 SQL 就能上手)
适用场景CRUD 为主、领域模型清晰复杂查询多、报表统计、遗留数据库
国内使用外企、Spring 生态深度用户✅ 国内主流

选型建议: 国内大多数项目用 MyBatis(SQL 控制力强,团队熟悉)。如果是 CRUD 为主、领域模型清晰、追求开发效率的项目,可以用 JPA。两者也可以共存。

Spring Data JPA 核心用法

Repository 接口

java
// 只需定义接口,Spring Data 自动生成实现
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 方法名推导查询 — 不需要写 SQL
    List<User> findByUsername(String username);
    List<User> findByAgeGreaterThanAndStatus(int age, String status);
    Optional<User> findByEmail(String email);
    
    // @Query — 自定义 JPQL
    @Query("SELECT u FROM User u WHERE u.department.id = :deptId")
    List<User> findByDepartment(@Param("deptId") Long deptId);
    
    // 原生 SQL
    @Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
    List<User> findRecentUsers(@Param("date") LocalDate date);
}

JPA 核心注解

注解说明
@Entity标记实体类
@Table指定表名
@Id主键
@GeneratedValue主键生成策略(IDENTITY、SEQUENCE、AUTO)
@Column列映射
@OneToMany / @ManyToOne关联关系
@Transient不映射到数据库

MyBatis 核心用法

XML 映射(主流)

java
// Mapper 接口
@Mapper
public interface UserMapper {
    User selectById(Long id);
    List<User> selectByCondition(UserQuery query);
    int insert(User user);
    int update(User user);
}
xml
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
    
    <resultMap id="userMap" type="User">
        <id property="id" column="id"/>
        <result property="username" column="user_name"/>
        <result property="createTime" column="created_at"/>
    </resultMap>
    
    <select id="selectById" resultMap="userMap">
        SELECT * FROM users WHERE id = #{id}
    </select>
    
    <!-- 动态 SQL -->
    <select id="selectByCondition" resultMap="userMap">
        SELECT * FROM users
        <where>
            <if test="username != null">
                AND user_name LIKE CONCAT('%', #{username}, '%')
            </if>
            <if test="status != null">
                AND status = #{status}
            </if>
            <if test="minAge != null">
                AND age >= #{minAge}
            </if>
        </where>
        ORDER BY created_at DESC
    </select>
</mapper>

#{} vs ${}

语法处理方式SQL 注入使用场景
#{}预编译(PreparedStatement 参数)✅ 安全绝大多数场景
${}字符串拼接❌ 有风险动态表名、列名、ORDER BY
xml
<!-- ✅ 安全 — 预编译 -->
SELECT * FROM users WHERE id = #{id}
<!-- 实际执行: SELECT * FROM users WHERE id = ? -->

<!-- ❌ 危险 — 字符串拼接,可能 SQL 注入 -->
SELECT * FROM users WHERE id = ${id}
<!-- 实际执行: SELECT * FROM users WHERE id = 1 OR 1=1 -->

<!-- $() 的合理用途 — 动态表名 -->
SELECT * FROM ${tableName} WHERE id = #{id}

MyBatis-Plus 简述

MyBatis-Plus 是 MyBatis 的增强工具,在 MyBatis 基础上只做增强不做改变:

  • 内置通用 CRUD(BaseMapper)— 单表操作不用写 SQL
  • 条件构造器(QueryWrapper / LambdaQueryWrapper)— 链式编程
  • 分页插件 — 自动分页
  • 代码生成器 — 自动生成 Entity、Mapper、Service
java
// 继承 BaseMapper 即获得基础 CRUD
public interface UserMapper extends BaseMapper<User> { }

// 条件查询
List<User> users = userMapper.selectList(
    new LambdaQueryWrapper<User>()
        .eq(User::getStatus, "active")
        .ge(User::getAge, 18)
        .orderByDesc(User::getCreateTime)
);

数据源与连接池

HikariCP(Spring Boot 默认)

Spring Boot 2.x+ 默认使用 HikariCP,号称最快的连接池。

yaml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: secret
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20          # 最大连接数(默认 10)
      minimum-idle: 5                # 最小空闲连接
      idle-timeout: 600000           # 空闲超时(10 分钟)
      max-lifetime: 1800000          # 连接最大生命周期(30 分钟)
      connection-timeout: 30000      # 获取连接超时(30 秒)

连接池大小经验公式: connections = ((core_count * 2) + effective_spindle_count)。大多数场景 10-20 足够,过大反而因为上下文切换降低性能。

连接池排队论数学(高级面试必备)

💡 「Hikari maxPoolSize 怎么定?」标准答案

不是拍脑袋——用 Little 定律算。面试金句:「连接池本质是 M/M/c 队列,给定 QPS 和单次 SQL 平均耗时,根据 Little 定律就能算出最优池大小」。

Little 定律:连接池的"圣经"

L = λ × W
  • L = 平均在系统中的请求数(= 同时占用的连接数)
  • λ = 请求到达率(QPS / 秒)
  • W = 平均请求服务时间(SQL 平均执行时间,秒)

直接推导连接池大小

最小连接数 = QPS × 平均 SQL 时间

实战推算示例

场景:业务 1000 QPS,平均 SQL 耗时 20 ms。

最小连接数 = 1000 × 0.02 = 20

但要留余量——P99 延迟、突发流量、GC pause 都会推高瞬时占用:

实际配置 = Little 定律值 × 安全系数(1.5 ~ 2.0)
        = 20 × 1.5 = 30

Hikari 官方反直觉建议:小池子更快

HikariCP wiki 的著名实验:

PostgreSQL 9.2 + 96 核服务器
连接池大小   TPS
   50      ~100K
   100     ~85K   ← 反而下降
   200     ~55K
   500     ~25K   ← 严重下降

为什么连接越多反而越慢?

瓶颈解释
CPU 上下文切换数据库 worker 线程数 ≫ CPU 核数 → 调度抖动
磁盘 IO 队列堆积100 连接同时写 redo log,IO 排队反而拖垮
锁竞争InnoDB 缓冲池 / row lock 等内部锁随连接数二次方放大
网络包碎片数千个 socket 频繁切换,TCP 缓冲区利用率下降

Oracle 推荐公式(黄金参考)

connections = ((core_count × 2) + effective_spindle_count)
  • core_count:DB 服务器 CPU 核数
  • effective_spindle_count:物理磁盘数(SSD 算 1,RAID 算实际盘数;云上一般算 1)

举例:8 核服务器 + SSD → 8 × 2 + 1 = 17,配 20 即可。

⚠️ 怎么验证你的配置

  1. JMH / wrk 压测:从 5 个连接开始,每次翻倍,找拐点
  2. wait_count / idle_connections 监控:Hikari metricRegistry 暴露这两个指标
  3. 看 DB 端 SHOW PROCESSLIST:连接长期空闲就是浪费
  4. 看应用 connection-timeout 告警:频繁 timeout = 池太小

队列论延伸:P99 延迟预测

当请求到达率 λ 接近系统处理能力 μ × c(μ=单连接吞吐,c=连接数),等待时间会爆炸式增长

利用率 ρ = λ / (μ × c)

ρ → 0.8 时:P99 等待时间 ≈ 平均服务时间 × 4
ρ → 0.9 时:P99 等待时间 ≈ 平均服务时间 × 10
ρ → 0.95 时:P99 等待时间 ≈ 平均服务时间 × 20   ← 雪崩前夜
ρ → 1.0 时:P99 → ∞(队列无限堆积)

结论生产推荐 ρ ≤ 0.7——即"连接利用率不超过 70%"。低于这个值才能保证 P99 稳定。

连接池关键参数与坑

参数含义生产推荐
maximum-pool-size最大连接数10-20(不要无脑设大)设 200 会让 MySQL 反而变慢
minimum-idle最小空闲等于 max(HikariCP 官方建议频繁创建销毁连接慢
connection-timeout等连接超时3-10 秒默认 30 秒太长,会拖垮上游
max-lifetime连接最大存活时间比 MySQL wait_timeout 短(如 28 分钟)超过 MySQL 主动断开会报"Connection is closed"
validation-query检测语句MySQL 8+ 不需要老版本用 SELECT 1
leak-detection-threshold泄漏检测阈值5-10 秒(开发期开,生产慎用)不开就发现不了连接泄漏
keepalive-time心跳保活30-60 秒防止防火墙断连

⚠️ 连接池配置 Top 3 坑

maximum-pool-size 过大:MySQL 上下文切换瓶颈,500 连接吞吐反而下降;② max-lifetime > MySQL wait_timeout:连接被 MySQL 断开但池子不知道,下次借连接报 Connection is closed;③ 没设 leak-detection-threshold:连接泄漏不报警,最后连接耗尽全挂。

N+1 查询问题:ORM 最经典的性能杀手

N+1 是 ORM 面试 Top 1 性能题——能讲清楚是什么、怎么发现、怎么解决,立刻显出对 ORM 的真正理解。

什么是 N+1

java
// JPA 查询用户列表,每个 User 有多个 Order
List<User> users = userRepo.findAll();   // 1 次 SQL: SELECT * FROM user
for (User u : users) {
    System.out.println(u.getOrders());   // N 次 SQL: SELECT * FROM order WHERE user_id=?
}
// 总共 1 + N 次 SQL → N+1 问题

1 次主查询 + N 次关联查询 = 数据量越大 SQL 越多,直接打爆数据库。

N+1 的根因

ORM触发原因
JPA / Hibernate默认 @OneToMany / @ManyToOne 是 LAZY,访问时才发 SQL
MyBatisresultMap 配置了 association/collection 且开启了 lazyLoading

4 种解决方案

方案适用 ORM原理代价
JOIN FETCHJPASELECT u FROM User u JOIN FETCH u.orders 一次 JOIN 取回笛卡尔积,结果集可能膨胀
@EntityGraphJPA注解声明要立即加载的关联同上
IN 批量查询通用第二次查询用 WHERE user_id IN (id1, id2, ...)2 次 SQL 替代 N+1 次
MyBatis nested select + fetchType=eagerMyBatis显式控制配置复杂

JPA 解法示例

java
// ❌ 触发 N+1
@Query("SELECT u FROM User u WHERE u.status = 1")
List<User> findActiveUsers();

// ✅ 方案 1: JOIN FETCH(一次 JOIN)
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders WHERE u.status = 1")
List<User> findActiveUsers();

// ✅ 方案 2: @EntityGraph
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u WHERE u.status = 1")
List<User> findActiveUsers();

// ✅ 方案 3: Hibernate @BatchSize(每次取 50 个 user 的 orders)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<Order> orders;
// → 1 + ceil(N/50) 次 SQL

MyBatis 解法

xml
<!-- 方案 A: 立即加载 + nested select -->
<resultMap id="userMap" type="User">
  <id property="id" column="id"/>
  <collection property="orders" select="findOrdersByUser"
              column="id" fetchType="eager"/>
</resultMap>

<!-- 方案 B: 一次 JOIN(推荐)-->
<resultMap id="userMap" type="User">
  <id property="id" column="u_id"/>
  <collection property="orders" ofType="Order">
    <id property="id" column="o_id"/>
    <result property="amount" column="o_amount"/>
  </collection>
</resultMap>
<select id="findActiveUsers" resultMap="userMap">
  SELECT u.id u_id, o.id o_id, o.amount o_amount
  FROM user u LEFT JOIN order o ON u.id = o.user_id
  WHERE u.status = 1
</select>

怎么发现 N+1

properties
# JPA: 打印所有 SQL
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# MyBatis: 启用日志
logging.level.com.example.mapper=DEBUG

# 生产环境: P6Spy 拦截 + 计数告警
# 单接口 SQL 数 > 10 触发告警

💡 面试黄金回答

"N+1 是 ORM 最经典坑——1 次主查询 + N 次关联查询。JPA 用 JOIN FETCH 或 @EntityGraph 一次性 JOIN;如果担心笛卡尔积膨胀,用 @BatchSize 折中。MyBatis 用 nested resultMap 或一次 JOIN。生产用 P6Spy 拦截 SQL 数量做告警,超阈值立即报警。"

Hibernate 一级缓存 / 二级缓存

JPA/Hibernate 缓存机制 是面试的"加分细节":

缓存范围默认开启适用
一级缓存(Session)单次事务/Session 内同一事务多次查同一个对象不重复
二级缓存(SessionFactory)整个应用读多写少的字典表 / 配置
查询缓存SQL → 结果几乎不推荐用(命中率低)

⚠️ 一级缓存的"脏读"陷阱

Hibernate 一级缓存默认开启,但只对同一对象 ID 生效。同一事务内:

java
User u1 = repo.findById(1L);   // 走 DB
jdbcTemplate.update("UPDATE user SET name='X' WHERE id=1");  // 绕开 ORM
User u2 = repo.findById(1L);   // 还从一级缓存返回旧数据 → 看不到 X

解决:要么全走 ORM,要么用 entityManager.clear() 清缓存。

MyBatis 执行原理深度

MyBatis 内部源码是 Java 后端面试 Top 5 高频追问点——能讲清"Mapper 接口怎么变成 SQL 执行的"立刻显出对 MyBatis 的真正理解。

Mapper 接口的实现:JDK 动态代理

@Mapper UserMapper          ← 只是接口,没有实现类!

那 userMapper.findById(1) 怎么执行的?

Spring 启动时:
  ① MapperScannerConfigurer 扫描 @Mapper 接口
  ② 为每个 Mapper 用 JDK 动态代理生成代理对象
  ③ 把代理对象注册为 Spring Bean

运行时调用 userMapper.findById(1):
  → MapperProxy.invoke(method, args)

  → 根据 method 找到对应的 MappedStatement (含 SQL)

  → SqlSession.selectOne(statementId, args)

  → Executor.query() (走缓存或 DB)

  → StatementHandler 准备 PreparedStatement

  → ParameterHandler 绑定参数

  → JDBC 执行 SQL

  → ResultSetHandler 把 ResultSet 映射回 Java 对象

四大核心组件

组件职责
Executor(执行器)顶层入口,控制缓存、事务、批处理
StatementHandler负责 JDBC PreparedStatement 的创建和参数绑定
ParameterHandler把 Java 参数填到 ? 占位符
ResultSetHandler把 JDBC ResultSet 转换为 Java 对象(按 resultMap)

MyBatis 一级缓存 vs 二级缓存

缓存范围默认生命周期推荐生产用法
一级缓存(Session/SqlSession)单个 SqlSession默认开启SqlSession 关闭即销毁保留默认(同一事务内重复查询有用)
二级缓存(Namespace/Mapper)整个应用,按 Mapper namespace 划分关闭应用启动到关闭生产建议关闭,用 Redis 替代

一级缓存的关键陷阱(生产 Top 1 踩坑)

java
// 同一事务内
User u1 = userMapper.findById(1);   // 走 DB → 缓存
// ... 中间没有写操作
User u2 = userMapper.findById(1);   // 命中缓存 ✓

// 但只要发生任何 INSERT/UPDATE/DELETE → 一级缓存全部清空
userMapper.updateXXX();              // 清缓存
User u3 = userMapper.findById(1);   // 又走 DB

⚠️ 为什么二级缓存生产慎用

分布式部署下不同步——N 个节点 N 份独立缓存;② 侵入业务——所有 update 必须经过同一 namespace,跨表更新无法感知;③ 替代方案明显更好——直接用 Redis 做分布式缓存,可控且一致性更好。

生产实践mybatis.configuration.cache-enabled=false + 业务层显式用 @Cacheable + Redis。

MyBatis Plugin(拦截器)机制

面试 Top 3 追问:能用 Plugin 实现什么?答出分页 / 数据脱敏 / 多租户 / SQL 监控 / 加密 立刻加分。

4 个可拦截点

java
@Intercepts({
    @Signature(type = Executor.class,         method = "update", args = {...}),  // 增删改
    @Signature(type = Executor.class,         method = "query",  args = {...}),  // 查询
    @Signature(type = StatementHandler.class, method = "prepare", args = {...}), // SQL 准备
    @Signature(type = ParameterHandler.class, method = "setParameters", args = {...}),
    @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {...})
})
拦截对象典型用法
ExecutorPageHelper 分页、二级缓存增强
StatementHandler改写 SQL(多租户、敏感字段脱敏、SQL 监控)
ParameterHandler参数加密(如手机号、身份证)
ResultSetHandler结果集解密、字段脱敏

实战:分页插件原理(PageHelper)

java
@Intercepts(@Signature(type = Executor.class, method = "query", args = {...}))
public class PaginationInterceptor implements Interceptor {
    public Object intercept(Invocation invocation) throws Throwable {
        // 1. 从 ThreadLocal 取出 PageHelper.startPage(1, 10) 设置的分页参数
        Page page = PageContext.getLocalPage();
        if (page == null) return invocation.proceed();    // 没设分页 → 原样执行

        // 2. 拦截原 SQL,加上 LIMIT
        BoundSql boundSql = ...;
        String pageSql = boundSql.getSql() + " LIMIT " + page.getOffset() + "," + page.getSize();

        // 3. 再查一次 SELECT COUNT(*) 算总数
        long total = countQuery(...);
        page.setTotal(total);

        // 4. 用 pageSql 真正执行
        return executeWithSql(pageSql);
    }
}

💡 多租户实战

用 StatementHandler Plugin 自动给所有 SQL 加上 WHERE tenant_id = ? → 业务代码完全感知不到多租户。这是 SaaS 公司必备技巧。

MyBatis 与 MyBatis-Plus

维度MyBatisMyBatis-Plus
CRUD手写 SQLBaseMapper 内置 CRUD
分页需 PageHelper 插件内置 IPage
条件构造拼字符串 / 动态 SQLLambdaQueryWrapper 类型安全
代码生成内置生成器(Entity/Mapper/Service)
租户/字段加密/逻辑删除自己写内置
生产推荐老项目新项目首选
java
// MyBatis-Plus LambdaQueryWrapper(编译期检查字段名)
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
    .eq(User::getStatus, 1)
    .gt(User::getAge, 18)
    .orderByDesc(User::getCreatedAt);
List<User> users = userMapper.selectList(wrapper);

⚠️ MyBatis-Plus 也有坑

① 复杂 JOIN 仍要手写 XML;② 默认乐观锁、逻辑删除注解要全局配置;③ Wrapper 滥用会让 SQL 难追踪——生产建议给关键模块开启 SQL 日志。

JdbcClient:Spring 6.1+ 的现代 SQL 客户端

💡 2026 新选择

Spring 6.1 / Boot 3.2 引入 JdbcClient,一个 fluent builder 风格的 JDBC API,官方推荐替代 JdbcTemplateNamedParameterJdbcTemplate。语法更现代,与 record / Optional 友好。

java
@Repository
class UserRepo {
    private final JdbcClient client;

    UserRepo(JdbcClient client) { this.client = client; }

    Optional<User> findById(long id) {
        return client.sql("SELECT * FROM users WHERE id = :id")
            .param("id", id)
            .query(User.class)            // 自动映射到 record/POJO
            .optional();
    }

    List<User> findActive() {
        return client.sql("SELECT * FROM users WHERE status = ?")
            .param(1, "ACTIVE")
            .query(User.class).list();
    }

    long insert(User u) {
        return client.sql("INSERT INTO users(name, email) VALUES(:n, :e)")
            .param("n", u.name()).param("e", u.email())
            .update();
    }
}
维度JdbcTemplateNamedParameterJdbcTemplateJdbcClient
参数风格? 位置参数命名参数两种都支持
链式 API❌ 大量重载方法✅ Fluent Builder
record / POJO需手写 RowMapper同左query(MyRecord.class) 自动映射
Optional需 catch EmptyResultDataAccessException同左.optional() 一行
流式.stream() 返回 Stream<T>

选型建议:① 新项目 / 重构 → JdbcClient;② 老代码与 JdbcTemplate 共存即可,无需强行替换;③ 复杂多表查询仍可走 MyBatis(JdbcClient 不提供 ORM 能力)。

Hibernate 6.x / Spring Data JPA 现代最佳实践

主题Hibernate 5 时代Hibernate 6 / JPA 3.x(Boot 3)
包名javax.persistence.*jakarta.persistence.*
方言必须指定 MySQL8Dialect自动检测(无需配置)
批量插入hibernate.jdbc.batch_size + 手动 flush同左,但 @SQLInsert 注解 可写原生批量
N+1 解决@EntityGraph / JPQL JOIN FETCH@EntityGraph 仍是首选;新增 @FetchProfile
DTO 投影@Query + 构造器表达式JPA 3.1 _TypedQueryReference,类型更安全
records 支持可直接做投影目标interfacerecord

⚠️ Spring Data JPA 三个 2026 高频坑

  1. @Transactional + findById 后改对象 → 自动 dirty check 触发 UPDATE,忘记 @Transactional 不会保存
  2. findAll(Pageable) 全表 COUNT(*) → 大表慢;改 Slice<T> 或显式 @Query 不带 count
  3. LazyInitializationException 仍是 Top 1 → 严禁在 Controller 层访问懒加载关联;用 DTO 投影 / @EntityGraph / Open Session In View(新项目不推荐 OSIV,已默认关闭)

R2DBC 与响应式 ORM 现状(2026)

💡 现状速判

虚拟线程让 R2DBC 的"必要性"大幅下降。除非你已经是 WebFlux 栈或追求流式数据,新项目建议先用 MVC + JDBC/JdbcClient + Loom

方案状态适合
Spring Data R2DBC✅ 稳定WebFlux + 高并发流式查询
Hibernate Reactive✅ 稳定Quarkus / WebFlux 项目;JPA API 响应式版
jOOQ + R2DBC强类型 SQL + 响应式
MyBatis 响应式❌ 无官方支持

面试常问 & 怎么答

Q1: JPA 和 MyBatis 怎么选?

看项目需求:CRUD 为主、领域模型清晰选 JPA(开发效率高);复杂查询多、需要精确控制 SQL 选 MyBatis(灵活性强)。国内大多数项目用 MyBatis,外企和 Spring 深度用户倾向 JPA。两者也可以共存。

Q2: MyBatis 的 #{} 和 ${} 的区别?

#{} 是预编译参数(PreparedStatement),安全防 SQL 注入,绝大多数场景使用;${} 是字符串拼接,有 SQL 注入风险,只在动态表名、列名、ORDER BY 等不能用预编译的场景使用。

Q3: 连接池怎么配置?

Spring Boot 默认用 HikariCP。核心参数是 maximum-pool-size(最大连接数,默认 10),经验公式是 CPU 核心数 × 2 + 磁盘数。不要设太大,过多连接反而因上下文切换降低性能。

看到什么就先想到这类

  • 出现 JPA vs MyBatis 选型。
  • 出现 #{} vs ${}、SQL 注入。
  • 出现 ResultMap、动态 SQL。
  • 出现数据源、连接池、HikariCP。