MyBatis与JPA性能优化:N+1、批量与缓存实战

在 Java 后端项目里,MyBatis 与 JPA(Hibernate)是持久层两大主流框架。很多接口的”慢”,根因不在业务逻辑,而在数据库访问层:一个看似普通的列表查询,背后可能偷偷发了几百条 SQL。MyBatis 与 JPA 性能优化的核心,就是消灭 N+1 查询、做对批量操作、用好缓存。本文结合可运行示例,讲清最影响持久层吞吐的三件事。

一、为什么持久层最容易成为瓶颈

ORM 框架把 SQL 藏到了对象映射之后,带来开发效率的同时也掩盖了真实的数据访问成本。三类问题最常见:一是 N+1 查询,遍历集合时逐条补查关联;二是 批量操作姿势错,本可一条 SQL 干完的事拆成几百条;三是 缓存没用好,热点数据反复回源数据库。它们叠加起来,轻则接口延迟翻倍,重则把数据库连接池打满,引发雪崩。典型信号是接口 P99 延迟随列表长度线性上涨,而应用与数据库 CPU 都不高——瓶颈不在计算,而在 SQL 往返次数。我们在JVM 内存模型与 GC 调优里关注过堆与回收,持久层则是另一处”看不见的吞吐黑洞”。

二、N+1 查询:最隐蔽的性能杀手

N+1 指的是:1 条主查询拿到 N 条记录,随后为每条记录各发 1 条关联查询,总共 N+1 条 SQL。单看每条都很快,N 一大就崩。两个框架都会踩。

2.1 MyBatis 的 N+1:嵌套 collection 的坑

下面这种 resultMap 里用 select 嵌套关联,默认是懒加载,遍历时才补查,正是典型的 N+1。

<!-- 反例:collection 用 select 嵌套,遍历 100 个订单触发 100 条子查询 -->
<resultMap id="orderMap" type="Order">
  <id property="id" column="id"/>
  <result property="orderNo" column="order_no"/>
  <collection property="items" ofType="Item"
              select="selectItemsByOrderId" column="id"/>
</resultMap>
<select id="selectOrders" resultMap="orderMap">
  SELECT id, order_no FROM orders WHERE user_id = #{uid}
</select>
<!-- 1 条主查询 + 100 条子查询 = N+1 -->

<!-- 正解:一次 JOIN 把明细捞出来,避免逐条补查 -->
<select id="selectOrdersWithItems" resultMap="orderItemMap">
  SELECT o.id, o.order_no, i.id AS item_id, i.sku, i.qty
  FROM orders o
  LEFT JOIN order_item i ON i.order_id = o.id
  WHERE o.user_id = #{uid}
</select>

2.2 JPA 的 N+1:延迟加载与 fetch 策略

JPA 默认对关联用 LAZY 加载,访问 order.getUser() 时才会触发 SELECT,于是循环里又变成 N+1。用 @EntityGraph 或 JPQL 的 JOIN FETCH 一次性把关联捞出来即可根除。

// 反例:findByUser 返回 List<Order>,访问关联时逐条补 SELECT
List<Order> orders = orderRepo.findByUserId(uid);
orders.forEach(o -> log.info(o.getUser().getName())); // 每条额外一条 SQL

// 正解 1:@EntityGraph 一次性加载关联
@EntityGraph(attributePaths = {"user", "items"})
@Query("select o from Order o where o.user.id = :uid")
List<Order> findByUserIdWithUser(@Param("uid") Long uid);

// 正解 2:JPQL 的 JOIN FETCH
@Query("select o from Order o join fetch o.user where o.user.id = :uid")
List<Order> findByUserIdFetch(@Param("uid") Long uid);

三、批量操作:insert/update 的正确姿势

逐条写库是批量场景的大忌。两条路都能把几十上百条操作压成一条或少数几条 SQL,但细节各有讲究。尤其导入、对账、埋点落库这类场景,批量姿势直接决定耗时是从分钟级降到秒级,还是把连接池占满拖垮全站。

3.1 MyBatis 批量插入

<insert id="batchInsert">
  INSERT INTO order_item (order_id, sku, qty) VALUES
  <foreach collection="list" item="it" separator=",">
    (#{it.orderId}, #{it.sku}, #{it.qty})
  </foreach>
</insert>
<!-- 注意:单条 SQL 拼接值过多会超 max_allowed_packet,
     建议每 500 条切一批,配合 JDBC 的 rewriteBatchedStatements=true -->

3.2 JPA 的 saveAll 与 JDBC 批处理

JPA 的 saveAll 默认对每条实体各执行一条 INSERT,除非 JDBC 开启批处理。光改 JPA 不够,JDBC URL 必须加上 rewriteBatchedStatements=true,否则批处理不生效。

# application.yml 关键配置
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 500        # 每批 500 条
        order_inserts: true
        order_updates: true
  # JDBC URL 必须带 rewriteBatchedStatements,批处理才真正合并
  datasource:
    url: jdbc:mysql://db:3306/app?rewriteBatchedStatements=true&useServerPrepStmts=true

// 更稳的写法:用 EntityManager 分桶 flush,避免持久化上下文暴涨撑爆堆
for (int i = 0; i < list.size(); i++) {
    em.persist(list.get(i));
    if (i % 500 == 0) { em.flush(); em.clear(); } // 参考 GC 调优思路,别让上下文无限增长
}

四、缓存:一级/二级与 Spring Cache 协同

缓存是回源数据库的”减速带”。MyBatis、JPA 各自有一级/二级缓存,Spring Cache 则站在业务层。三者作用域和一致性不同,用错地方反而会读脏数据。

方案作用域一致性适用场景
MyBatis 一级缓存同一 SqlSession强(会话级)同一事务内重复查询
MyBatis 二级缓存Mapper namespace弱(易脏读)只读/低频变更字典表
JPA 二级缓存EntityManagerFactory中(需区域缓存)实体引用复用
Spring Cache方法返回值取决于后端业务层聚合结果

实践建议:MyBatis 二级缓存只放变更极少的字典类数据,且要配缓存命中率监控;JPA 二级缓存需配合 Ehcache/Redis 区域缓存;而最常用、最可控的是在 Service 层用 Spring Cache 包裹聚合查询,后端接 Caffeine 或 Redis。注意缓存与数据库的一致性,写操作务必同步 evict

@Cacheable(cacheNames = "user", key = "#id", unless = "#result == null")
public UserDTO getUser(Long id) {
    return userRepo.findById(id).map(this::toDTO).orElse(null);
}

@CacheEvict(cacheNames = "user", key = "#user.id")
public UserDTO updateUser(User user) {
    // 写后清缓存,避免脏读
    return toDTO(userRepo.save(user));
}

五、连接与事务:别让数据库被拖垮

持久层再快,也架不住事务粒度过粗、连接长期占用。两点最值得强调:一是 大事务拆分,一个横跨远程调用的方法挂着事务,连接会被占住几十秒,连接池很快耗尽——事务传播与边界的取舍可参考Spring 事务传播机制深度解析;二是 只读事务打标,查询方法加 @Transactional(readOnly = true),JPA 会跳过脏检查、MyBatis 也能让连接池识别只读连接复用。连接池本身(如 HikariCP)的 maximumPoolSizeconnectionTimeout 也要按数据库承载能力压测设定,避免无限制堆积。经验值上,maximumPoolSize 可按”核心数 × 2 + 磁盘数”粗估,connectionTimeout 设 3 秒而非无限等待,让请求快速失败而非无限挂起,给熔断留空间。

六、实战避坑清单

  • 先暴露真实 SQL 条数:用 p6spy 或 Spring Boot Actuator 的 data-source 端点,确认一次请求到底发了几条 SQL,N+1 无所遁形。
  • 分页必须带 count/limit:列表查询永远加分页,防止一次拉全表把内存和连接同时拖垮。
  • 大事务拆分:把只读查询移出写事务,远程调用不要包在事务里(结合事务传播机制设计边界)。
  • 批量 size 控制在 500–1000:太小没收益,太大超 max_allowed_packet 或撑爆持久化上下文。
  • 二级缓存只放低变更数据:订单、库存这类高频变更实体千万别进二级缓存,否则脏读比慢查询更致命。
  • 监控慢查询与连接池:把慢 SQL、连接等待时间接进监控体系,配合JVM 与 GC 调优一起看,才能定位是 SQL 慢还是连接不够。

MyBatis 与 JPA 性能优化的主线其实很清晰:先用工具把隐藏的 N+1 揪出来,再用 JOIN FETCH / 批量 INSERT 把 SQL 条数压下去,最后用分层缓存挡住重复回源。批量操作和缓存都要守好”分桶”与”一致性”两条底线,否则省下的查询时间会换成更难排查的脏数据。把这几件事做成代码评审的默认检查项,持久层相关的性能工单通常会少一大半。值得注意的是,优化不是一次性的:随着数据量增长,曾经安全的查询也可能退化成新的瓶颈,所以需要把它纳入常态化监控。相关工程化与持续集成可参考GitHub Actions 实战,把慢 SQL 检测接进流水线,从源头卡住性能回归。

上一篇 轻量日志收集实战:Loki 与 ELK 选型落地
下一篇 Redis缓存三大问题:穿透击穿雪崩防护实战