选 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. 自测
#{}与${}在 JDBC 层分别变成了什么?为什么排序列必须白名单?- MyBatis 一级缓存的作用域是什么?为什么二级缓存不适合多实例部署?
<collection select="...">为什么会产生 N+1?换成嵌套结果的代价是什么?- 逻辑删除为什么会让原来的唯一索引失效?
(email, deleted)能容忍几次删除重建? - Hibernate 的脏检查默认为什么会更新所有列?两种收窄方式是什么?
ExecutorType.BATCH不加rewriteBatchedStatements会怎样?为什么还要分批?