ORM 从零到生产级掌握
ORM 不能只理解成“少写 SQL”。商业项目里真正要掌握的是:JDBC 为什么繁琐,ORM 帮你封装了什么,MyBatis 为什么 Mapper 接口能执行,动态 SQL 怎么变成最终 SQL,JPA 为什么修改实体不用手写 update,一级缓存和二级缓存为什么有一致性风险,事务为什么放 Service 层,批量写入和分页为什么会慢,线上 SQL 问题怎么定位。
一句话建立主线:
ORM 是把对象模型和关系型数据库之间的参数绑定、SQL 执行、结果映射、事务边界、缓存和扩展点封装起来;但最终性能和一致性仍然取决于 SQL、索引、事务和业务边界设计。
如果你想检查自己是否真的从零基础学懂到能面试、能落地、能排查,按 ORM 从零到精通验收清单 逐项验收。
学习目标
学完这一页,你要能做到:
- 解释 JDBC、ORM、MyBatis、Hibernate、JPA、Spring Data JPA、MyBatis-Plus 的关系。
- 解释一次 Mapper 方法调用到数据库返回对象的全过程。
- 解释 MyBatis 中
MappedStatement、BoundSql、Executor、TypeHandler、ResultMap的作用。 - 解释
#{}和${}为什么安全性不同。 - 解释 MyBatis 一级缓存、二级缓存为什么可能读到旧数据。
- 解释 JPA 持久化上下文、托管态、脏检查、flush、commit。
- 解释 N+1 查询、懒加载异常、分页 count 慢、批量导入内存暴涨。
- 解释事务为什么放 Service 层,为什么同类自调用会让 Spring 事务失效。
- 能写 MyBatis XML、动态 SQL、批量写入、JPA Repository、事务 Demo。
- 能在订单、采集、资产查询、报表统计等商业场景中选择合适 ORM。
- 能排查慢 SQL、字段映射为空、事务未回滚、缓存不一致、分页变慢。
为什么 JDBC 还不够
JDBC 是 Java 访问关系型数据库的基础,但直接使用会很繁琐:
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"select id, username, status from users where id = ?"
);
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
UserDO user = null;
if (rs.next()) {
user = new UserDO();
user.setId(rs.getLong("id"));
user.setUsername(rs.getString("username"));
user.setStatus(rs.getInt("status"));
}
rs.close();
ps.close();
conn.close();这里有很多重复工作:
| 重复点 | 风险 |
|---|---|
| 获取和关闭连接 | 忘记关闭会连接泄漏 |
| 拼 SQL 和绑定参数 | 拼接错误或 SQL 注入 |
| 遍历 ResultSet | 字段映射重复且容易写错 |
| 异常处理 | 事务回滚和资源释放复杂 |
| 批量执行 | 批大小、提交频率难统一 |
ORM 的价值就是把这些重复劳动变成框架能力,但不是替你消灭数据库知识。
ORM 家族怎么分工
flowchart TD
A["JDBC"] --> B["MyBatis"]
A --> C["JPA 规范"]
C --> D["Hibernate 实现"]
D --> E["Spring Data JPA"]
B --> F["MyBatis-Plus"]| 技术 | 定位 | 适合场景 | 风险 |
|---|---|---|---|
| MyBatis | 半自动 ORM,SQL 可控 | 复杂 SQL、报表、批量、性能敏感 | SQL 维护成本在开发者 |
| MyBatis-Plus | MyBatis 增强 | 单表 CRUD、Wrapper 条件、代码生成 | 复杂 SQL 仍要回到 MyBatis |
| JPA | 持久化规范 | 规范化实体映射 | 只是规范,不是具体实现 |
| Hibernate | JPA 常见实现 | 领域模型、CRUD、多数据库兼容 | 最终 SQL 不可忽略 |
| Spring Data JPA | Repository 封装 | 快速 CRUD、方法名查询、Specification | 方法名过长、N+1、懒加载 |
选型原则:
- SQL 复杂、性能强控制:优先 MyBatis 或 MyBatis-Plus + 手写 SQL。
- 领域模型清晰、CRUD 多:可以 JPA/Hibernate。
- 团队 SQL 能力强:MyBatis 更透明。
- 团队不熟悉 Hibernate 原理:不要把核心交易系统直接交给全自动 ORM。
MyBatis 一次执行全过程
flowchart TD
A["业务调用 Mapper 接口"] --> B["MapperProxy 代理"]
B --> C["根据接口方法找到 MappedStatement"]
C --> D["动态 SQL 解析成 BoundSql"]
D --> E["Executor 选择执行策略"]
E --> F["StatementHandler 创建 PreparedStatement"]
F --> G["ParameterHandler 绑定参数"]
G --> H["数据库执行 SQL"]
H --> I["ResultSetHandler 处理结果集"]
I --> J["ResultMap 映射成 Java 对象"]核心对象:
| 对象 | 作用 |
|---|---|
| MapperProxy | Mapper 接口的动态代理 |
| MappedStatement | 一条 SQL 的完整描述 |
| BoundSql | 动态 SQL 解析后的最终 SQL 和参数映射 |
| Executor | 执行器,负责查询、更新、缓存 |
| StatementHandler | 创建和执行 Statement |
| ParameterHandler | 把 Java 参数绑定到 SQL 占位符 |
| ResultSetHandler | 把 ResultSet 映射为对象 |
| TypeHandler | Java 类型和 JDBC 类型转换 |
这就是为什么 Mapper 接口没有实现类也能运行:运行时调用的是代理对象,代理再去找 XML 或注解中的 SQL。
MyBatis 最小 Demo
Mapper:
public interface OrderMapper {
OrderDO selectById(Long id);
List<OrderDO> selectByStatus(@Param("status") Integer status);
}XML:
<mapper namespace="com.example.mapper.OrderMapper">
<resultMap id="OrderMap" type="com.example.domain.OrderDO">
<id column="id" property="id"/>
<result column="order_no" property="orderNo"/>
<result column="status" property="status"/>
<result column="amount" property="amount"/>
</resultMap>
<select id="selectById" resultMap="OrderMap">
select id, order_no, status, amount
from t_order
where id = #{id}
</select>
<select id="selectByStatus" resultMap="OrderMap">
select id, order_no, status, amount
from t_order
where status = #{status}
order by id desc
</select>
</mapper>重点:
namespace要对应 Mapper 接口全限定名。select id要对应接口方法名。- 数据库列名和 Java 字段不一致时,用
resultMap或别名。 #{}走预编译参数,不能被用户输入改变 SQL 结构。
#{} 和 ${} 的底层差异
flowchart TD
A["用户参数"] --> B{"使用方式"}
B -- "#{ }" --> C["生成 ? 占位符"]
C --> D["PreparedStatement 绑定参数"]
D --> E["数据当作值"]
B -- "${ }" --> F["直接拼进 SQL 字符串"]
F --> G["数据可能改变 SQL 结构"]示例:
where username = #{username}最终类似:
where username = ?而:
order by ${sortField}会直接拼接字段名。它不是绝对不能用,但必须做白名单:
private static final Set<String> ALLOW_SORT_FIELDS =
new HashSet<String>(Arrays.asList("created_at", "amount", "id"));
public String checkSortField(String field) {
if (!ALLOW_SORT_FIELDS.contains(field)) {
throw new IllegalArgumentException("非法排序字段");
}
return field;
}动态 SQL 怎么工作
动态 SQL 不是数据库执行 XML 标签,而是 MyBatis 在发送 SQL 前根据参数生成最终 SQL。
<select id="selectPage" resultMap="OrderMap">
select id, order_no, status, amount
from t_order
<where>
<if test="status != null">
and status = #{status}
</if>
<if test="orderNo != null and orderNo != ''">
and order_no = #{orderNo}
</if>
</where>
order by id desc
</select>当 status=1、orderNo=null 时,最终 SQL 只保留 status 条件。这样做的价值是既能灵活拼条件,又能保持参数预编译。
常见坑:
| 坑 | 后果 |
|---|---|
<if> 判断漏空字符串 | 条件异常或查不到数据 |
<foreach> 集合为空 | SQL 语法错误 |
动态表名直接 ${} | SQL 注入 |
| 条件太多没有索引 | 看似灵活,实际全表扫描 |
MyBatis 缓存为什么要谨慎
MyBatis 一级缓存是 SqlSession 级别,默认开启;二级缓存是 namespace 级别,需要配置。
flowchart TD
A["查询请求"] --> B{"一级缓存是否命中"}
B -- "命中" --> C["返回缓存对象"]
B -- "未命中" --> D["执行 SQL"]
D --> E["写入一级缓存"]
E --> F["返回结果"]一级缓存的风险通常来自长 SqlSession 或同一事务中数据变化后的理解偏差。二级缓存风险更大:
- 多表关联查询时,更新 A 表不一定清理 B namespace 缓存。
- 分布式多实例本地缓存不共享。
- 业务绕过 MyBatis 更新数据库,缓存不知道。
生产建议:
- 一级缓存默认理解即可,不要长时间持有 SqlSession。
- 二级缓存谨慎开启,核心交易和复杂关联查询一般不建议依赖它。
- 缓存一致性优先用 Redis + 明确失效策略,而不是把 MyBatis 二级缓存当业务缓存。
JPA/Hibernate 怎么工作
JPA/Hibernate 的核心不是“自动生成 SQL”,而是持久化上下文。
flowchart TD
A["EntityManager 查询实体"] --> B["实体进入持久化上下文"]
B --> C["保存快照"]
C --> D["业务修改实体字段"]
D --> E["flush 前脏检查"]
E --> F{"字段是否变化"}
F -- "是" --> G["生成 update SQL"]
F -- "否" --> H["不发 update"]
G --> I["事务提交或回滚"]Demo:
@Transactional
public void changeOrderAmount(Long id, BigDecimal amount) {
Order order = entityManager.find(Order.class, id);
order.setAmount(amount);
}为什么没有 update 也会更新:
find出来的实体是托管态。- Hibernate 在持久化上下文保存了实体快照。
- 事务提交前 flush。
- flush 时做脏检查。
- 发现字段变化,生成 SQL。
如果实体已经离开事务和 Session,再改字段就不会自动同步,这就是托管态和游离态的差异。
flush 和 commit
| 概念 | 含义 |
|---|---|
| flush | 把持久化上下文中的变化同步成 SQL 发给数据库 |
| commit | 提交数据库事务,让变更真正生效 |
flush 后 SQL 已经执行,但事务还没提交,仍然可以回滚。不要把 flush 理解成提交。
N+1 查询
N+1 是 ORM 里最常见的性能坑。
flowchart TD
A["查询 100 个订单"] --> B["1 条 SQL"]
B --> C["循环访问 order.items"]
C --> D["每个订单再查一次明细"]
D --> E["总共 1 + 100 条 SQL"]解决方式:
- 需要什么字段就写 DTO 查询。
- 用 fetch join 一次加载必要关联。
- 使用批量抓取配置。
- 不要在序列化 JSON 时隐式触发懒加载。
- 接口层不要直接返回 Entity。
事务为什么放 Service 层
Mapper/Repository 是数据访问动作,Service 是完整业务动作。一个下单动作可能包含:
- 插入订单。
- 插入订单明细。
- 扣库存。
- 写操作日志。
- 写本地消息表。
这些要么一起成功,要么一起失败,所以事务应该包在 Service 层。
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderMapper.insert(command.toOrder());
orderItemMapper.batchInsert(command.toItems());
stockMapper.decrease(command.getSkuId(), command.getCount());
localMessageMapper.insert(buildMessage(command));
}
}Spring 事务失效高频原因:
| 原因 | 为什么失效 |
|---|---|
| 同类自调用 | 没经过 Spring 代理 |
| 异常被 catch 不再抛出 | 事务拦截器不知道失败 |
| 默认不回滚受检异常 | 需要 rollbackFor |
| 方法不是 public | 代理可能无法拦截 |
| 多数据源事务管理器选错 | 连接不在同一个事务里 |
| 手动 JDBC 连接 | 绕过 Spring 管理连接 |
批量写入为什么容易出问题
批量导入十万条数据,不能简单循环单条 insert:
for (OrderDO order : orders) {
orderMapper.insert(order);
}问题:
- SQL 次数太多,网络往返高。
- 一个超大事务占用锁和 undo。
- 失败回滚成本高。
- JPA 持久化上下文可能堆积大量对象。
MyBatis 批量思路:
for (List<OrderDO> batch : split(orders, 500)) {
orderMapper.batchInsert(batch);
}JPA 批量思路:
for (int i = 0; i < orders.size(); i++) {
entityManager.persist(orders.get(i));
if (i % 500 == 0) {
entityManager.flush();
entityManager.clear();
}
}商业场景选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| 订单核心链路 | MyBatis | SQL 可控,便于事务和性能排查 |
| 采集数据批量入库 | MyBatis 批量 | 控制批大小、字段映射和写入性能 |
| 后台单表 CRUD | MyBatis-Plus 或 Spring Data JPA | 开发效率高 |
| 复杂报表统计 | MyBatis 手写 SQL | 可控 join、聚合、索引 |
| 领域模型 CRUD | JPA/Hibernate | 实体状态和关系映射方便 |
| 大表分页查询 | MyBatis + 游标/延迟关联 | 避免深分页和 count 慢 |
线上排查流程
flowchart TD
A["ORM 线上问题"] --> B{"表现"}
B -- "SQL 慢" --> C["查慢 SQL 和执行计划"]
B -- "字段为空" --> D["查列名、别名、resultMap、驼峰"]
B -- "事务没回滚" --> E["查代理、异常、rollbackFor、事务管理器"]
B -- "数据不一致" --> F["查缓存、事务边界、消息发送"]
B -- "内存上涨" --> G["查批量大小、JPA 持久化上下文"]
C --> H["索引、扫描行数、排序、回表、锁等待"]排查证据:
| 证据 | 看什么 |
|---|---|
| SQL 日志 | 最终 SQL 和参数 |
| EXPLAIN | 索引、扫描行数、排序、回表 |
| 事务日志 | 方法是否经过代理,异常是否抛出 |
| 数据库连接池 | 是否连接泄漏或等待 |
| GC 日志 | 批量导入是否对象堆积 |
| 业务日志 | 同一个 traceId 下执行了哪些 SQL |
面试标准回答
ORM 是什么
ORM 是对象关系映射,负责把 Java 对象和关系型数据库之间的参数绑定、SQL 执行、结果映射、事务和缓存等重复工作封装起来。它能提高开发效率,但不等于不用理解 SQL,最终性能仍然取决于表结构、索引、执行计划和事务边界。MyBatis 和 Hibernate 区别
MyBatis 是半自动 ORM,SQL 主要由开发者编写和控制,适合复杂 SQL、报表和性能敏感场景;Hibernate/JPA 更强调实体映射、持久化上下文、脏检查和自动 SQL,CRUD 效率高,但要关注 N+1、懒加载和最终生成 SQL。Mapper 接口为什么能执行
MyBatis 启动时会解析 XML 或注解形成 MappedStatement,运行时为 Mapper 接口创建代理。调用接口方法时,代理根据 namespace 和方法名找到对应 MappedStatement,生成 BoundSql,通过 Executor、StatementHandler、ParameterHandler 执行 SQL,再由 ResultSetHandler 映射结果。事务为什么放 Service 层
事务应该覆盖一个完整业务动作,而不是单条 SQL。Service 层通常会调用多个 Mapper 或 Repository,例如订单、明细、库存和消息表要一起提交或回滚。事务放在 Mapper 层只能保证单次数据访问,无法保证业务一致性。关联知识点
| 知识点 | 继续学习 |
|---|---|
| 从零到精通验收 | ORM 从零到精通验收清单 |
| 商业场景训练营 | ORM 商业场景训练营 |
| MyBatis 总览 | MyBatis |
| MyBatis 核心原理 | MyBatis核心全过程原理 |
| 动态 SQL | MyBatis动态SQL |
| 缓存机制 | MyBatis缓存机制 |
| 批量操作 | MyBatis批量操作 |
| MyBatis 插件 | MyBatis拦截器 |
| MyBatis-Plus | MyBatis-Plus核心全过程原理 |
| Hibernate/JPA | Hibernate/JPA核心全过程原理 |
| Spring Data JPA | Spring Data JPA核心全过程原理 |
| ORM 事务 | 事务与一致性 |
| ORM 面试 | ORM面试题 |
本章小结
ORM 学到生产级,关键是把 JDBC、SQL、参数绑定、结果映射、Mapper 代理、动态 SQL、缓存、持久化上下文、脏检查、事务边界、批量写入和慢 SQL 排查串起来。会用框架只是第一步,能解释最终 SQL 为什么这样执行、为什么慢、为什么事务没生效,才算真正掌握。
