2629 字
约 8 分钟
17
S11-X18 · 持久层边界:MyBatis 与 ORM 的取舍

选 MyBatis 还是 JPA,真正的分歧只有一句话:SQL 由谁掌握。MyBatis 把 SQL 交回给开发者,框架只负责映射与参数绑定;JPA 让框架生成 SQL,开发者操作对象。这个选择决定了后面所有问题——分页怎么写、批量怎么做、N+1 从哪来、事务边界划在哪。这篇不讲"哪个更好",只讲各自边界在哪、代价是什么。


1. 定位差异:SQL 映射 vs 对象关系映射

维度 MyBatis JPA / Hibernate
核心抽象 SQL 语句 + 结果映射 实体对象 + 持久化上下文
SQL 由谁写 开发者(XML/注解/Provider) 框架生成,可逃逸到原生 SQL
学习曲线 低(会 SQL 就能用) 高(脏检查、懒加载、抓取策略)
复杂查询 直接写,可控 Criteria/JPQL 表达力受限,常退回原生 SQL
数据库可移植性 弱(SQL 与方言绑定) 强(换方言即可)
性能可预测性 高(SQL 可见) 低(要看生成的 SQL)
典型风险 SQL 散落、拼接注入 N+1、隐式更新、懒加载异常

一句话:MyBatis 的复杂度在"写 SQL",JPA 的复杂度在"猜 SQL"。SQL 功底强、报表多、要控执行计划选前者;领域模型复杂、CRUD 为主、要跨库移植选后者。混合用法常见,但要划清事务与缓存边界。


2. MyBatis 的三个关键机制

2.1 #{} 与 ${}:一个是占位符,一个是字符串拼接

<!-- 安全:预编译占位符,参数不参与 SQL 解析 -->
<select id="findByName" resultType="Order">
  SELECT * FROM t_order WHERE user_name = #{name}
</select>

<!-- 危险:直接拼接,值会被当作 SQL 片段解析 -->
<select id="findByColumn" resultType="Order">
  SELECT * FROM t_order ORDER BY ${sortColumn}
</select>
写法 编译结果 可否防注入 适用
#{name} ? + PreparedStatement.setXxx 是 所有值参数
${name} 直接插入 SQL 文本 否 表名、列名、ORDER BY 方向(必须白名单)

需要动态排序时,正确做法是服务端枚举映射,而不是把前端传来的字符串塞进 ${}:

private static final Map<String, String> SORT = Map.of(
    "amount", "amount", "createdAt", "created_at");

String column = SORT.getOrDefault(req.getSort(), "id");   // 白名单

2.2 一级缓存与二级缓存:作用域与失效条件

缓存 作用域 默认 失效条件
一级缓存 单个 SqlSession 开启 同一 session 内执行写操作、flushCache=true、session 关闭
二级缓存 单个 namespace(跨 session) 关闭,需 <cache/> 该 namespace 有写操作、配置 flushCache、事务提交后
<cache eviction="LRU" flushInterval="600000" size="1024" readOnly="false"/>

二级缓存最大的坑不是性能,而是多实例部署下的一致性:A 实例清了本机缓存,B 实例还是旧值。它只适合读多写少、能容忍分钟级不一致的字典表。

2.3 ResultMap 嵌套与 N+1

<resultMap id="orderWithItems" type="Order">
  <id column="id" property="id"/>
  <collection property="items" ofType="OrderItem"
              select="selectItemsByOrderId" column="id"/>
</resultMap>

<collection select="..."> 是嵌套查询:查 100 个订单会多发 100 条子查询,这是 MyBatis 版本的 N+1。改成嵌套结果(子集合直接写列映射 + 主查询 JOIN)只发一条 SQL,代价是结果集膨胀、需在内存去重。

映射方式 SQL 条数 内存 适用
嵌套查询(select) 1 + N 小 子集合很小、有分页
嵌套结果(JOIN) 1 大(笛卡尔积) 子集合小、无分页
两段查询 + 应用层组装 2 中 列表页最常用

3. MyBatis-Plus 的便利与代价

// 可读性:链式条件,编译期就能查字段名
LambdaQueryWrapper<Order> w = Wrappers.lambdaQuery();
w.eq(Order::getUserId, uid)
 .ge(Order::getCreatedAt, from)
 .orderByDesc(Order::getId)
 .last("LIMIT 20");
便利点 代价
LambdaQueryWrapper 字段名编译期校验 复杂 SQL(子查询、窗口函数)表达不出来,最终混写 XML
分页插件自动拼 LIMIT 与 COUNT COUNT 的 SQL 由插件生成,条件复杂时可能很慢
@TableLogic 逻辑删除 唯一索引必须把删除标记纳入,否则"删了再建"冲突
BaseMapper 免写 CRUD 生成的 SQL 不可见,慢查询排查多一层

逻辑删除对唯一索引的影响最容易被忽略:

-- 逻辑删除:删除变成 UPDATE ... SET deleted = 1
ALTER TABLE t_user ADD COLUMN deleted TINYINT NOT NULL DEFAULT 0;
-- 错误:唯一索引不含 deleted → 同邮箱只能存在一条(含已删的)
ALTER TABLE t_user ADD UNIQUE KEY uk_email (email);
-- 正确:把删除标记纳入,但同一邮箱仍只能有一条"已删除"记录
ALTER TABLE t_user ADD UNIQUE KEY uk_email (email, deleted);
-- 更稳妥:deleted 存删除时间戳/随机值,而不是 0/1

(email, deleted) 只能容忍"一条已删 + 一条未删";要允许多次删除重建,deleted 得存删除时刻或随机串,让每条已删记录的键都不同。


4. JPA 的三条坑

4.1 LazyInitializationException

@Transactional(readOnly = true)
public OrderDto get(Long id) {
    Order o = repo.findById(id).orElseThrow();
    return new OrderDto(o.getId(), o.getItems().size());  // 事务外访问 → 异常
}

代理对象要在 session 打开时初始化。三种解法:事务内转 DTO(推荐)、@EntityGraph 或 join fetch 一次抓出、显式 Hibernate.initialize()。把实体直接当 API 响应对象是最差的一种——序列化时触发懒加载,异常点在 Controller,栈里看不出根因。

4.2 N+1 与 @EntityGraph

@EntityGraph(attributePaths = {"items"})
List<Order> findByUserIdOrderByIdDesc(Long userId);
方案 SQL 条数 注意
默认懒加载 + 循环访问 1 + N 最典型的 N+1
@EntityGraph / join fetch 1 集合 JOIN 后分页会失效(内存分页)
@BatchSize(size = 100) 1 + N/100 折中,仍非一次查询
两段查询(先 id 再 IN) 2 分页安全,推荐

集合抓取与分页天然冲突:JOIN 出多行后 LIMIT 作用在行上而不是实体上,Hibernate 只能全部取回内存再分页,日志里会出现 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory。

4.3 脏检查的隐式更新

JPA 在事务提交时对比托管实体的快照,发现变化就发 UPDATE。默认 dynamic-update=false,即使只改了一个字段,生成的 SQL 也更新所有列:

-- 只改了 status,实际发出的是:
UPDATE t_order SET user_id=?, amount=?, status=?, remark=?, updated_at=? WHERE id=?

后果:乐观锁版本号被无意义推高、审计字段被反复覆盖、binlog 放大。收窄方式:@DynamicUpdate,或改用 @Modifying 的定向 UPDATE。


5. 批量、分页与事务边界

批量方式 原理 注意
foreach 拼多值 INSERT 一条 SQL 插 N 行 受 max_allowed_packet 限制,N 要分批
ExecutorType.BATCH JDBC addBatch 攒批 必须配 rewriteBatchedStatements=true 才真正合并
JPA saveAll + hibernate.jdbc.batch_size 攒批 flush 需要 order_inserts=true,且实体不能有自增主键冲突问题
# MySQL Connector/J:不开这个,BATCH 只是逐条发送
spring:
  datasource:
    url: jdbc:mysql://db:3306/app?rewriteBatchedStatements=true

分页在两类框架下的差别不大:MyBatis-Plus 的分页插件与 JPA 的 Pageable 都是先 COUNT 再 LIMIT,JOIN fetch 的分页还会退化为内存分页;深分页的优化(覆盖索引、游标)与框架无关。

事务边界该放在服务层的方法上,不是 Mapper/Repository 上,也不是 Controller 上:

@Service
public class OrderService {
    @Transactional(rollbackFor = Exception.class)
    public void create(CreateOrderCmd cmd) { ... }
}

三个常见失效:同类内部调用(自调用不走代理)、private 方法上的注解、异常被 catch 后没重新抛出。readOnly = true 对 MySQL 收益有限(省部分 flush 与脏检查),但能提示框架不写回。


6. 排错:六种持久层异常

现象 原因 处理
列表接口慢、日志里几十条相同 SQL N+1(<collection select> 或懒加载) 改嵌套结果 / @EntityGraph / 两段查询
排序参数一改就报 SQL 语法错 ${} 直接拼了前端参数 服务端白名单映射
多实例下读到旧数据 MyBatis 二级缓存各存各的 关闭二级缓存,改集中式缓存
LazyInitializationException 事务外访问未初始化代理 事务内转 DTO
更新一条记录却写了全表字段 JPA 默认全列 UPDATE @DynamicUpdate 或定向 UPDATE
批量插入仍逐条执行 没开 rewriteBatchedStatements 连接串加参数并分批
逻辑删除后唯一键冲突 唯一索引没纳入删除标记 唯一键加 deleted 或用时间戳

7. 边界:框架解决不了的部分

场景 该在哪解决 理由
深分页性能 SQL 层(游标/覆盖索引) 框架只拼 LIMIT,不改执行计划
跨库聚合 数仓 / 汇总表 ORM 管不了跨数据源 join
高并发写入 批量 + 分片 + 队列削峰 持久层框架不承担吞吐设计
复杂报表 原生 SQL / 只读副本 Criteria 表达力不足
缓存一致性 应用层缓存策略 二级缓存不是分布式缓存
领域模型与表结构解耦 显式转换(DTO/Assembler) 让实体兼做领域对象会把两者焊死

实体该不该做领域对象:小项目可以;一旦聚合根有行为、有不落库字段、有跨表不变量,实体就既背 ORM 注解又背业务规则,改表等于改模型。


8. 自测

  1. #{} 与 ${} 在 JDBC 层分别变成了什么?为什么排序列必须白名单?
  2. MyBatis 一级缓存的作用域是什么?为什么二级缓存不适合多实例部署?
  3. <collection select="..."> 为什么会产生 N+1?换成嵌套结果的代价是什么?
  4. 逻辑删除为什么会让原来的唯一索引失效?(email, deleted) 能容忍几次删除重建?
  5. Hibernate 的脏检查默认为什么会更新所有列?两种收窄方式是什么?
  6. ExecutorType.BATCH 不加 rewriteBatchedStatements 会怎样?为什么还要分批?
S11-X18 · 持久层边界:MyBatis 与 ORM 的取舍
https://www.clxhxhhr.top/posts/5937/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。