ORM 从零到精通验收清单
这页用来验收你是否真的把 ORM 学到了能做项目、能排查、能面试、能解释原理的程度。ORM 不是“会写 Mapper”或“会用 Repository”,而是要能从 JDBC 痛点一路讲到 SQL 执行、结果映射、缓存、事务、批量写入、JPA 持久化上下文、N+1、慢 SQL 和线上排查。
学完后你应该能说清楚:
- JDBC 为什么繁琐,ORM 到底封装了哪些重复工作。
- MyBatis Mapper 接口没有实现类为什么能执行。
- XML 动态 SQL 如何变成数据库真正执行的 SQL。
#{}、${}、TypeHandler、ResultMap分别解决什么问题。- MyBatis 一级缓存、二级缓存为什么可能带来一致性问题。
- MyBatis 插件为什么能做分页、数据权限、慢 SQL 统计。
- MyBatis-Plus Wrapper、分页、逻辑删除、自动填充的本质是什么。
- JPA/Hibernate 为什么不用手写 update 也会更新数据库。
- 持久化上下文、脏检查、flush、commit、懒加载、N+1 的完整原理。
- 事务为什么应该放 Service 层,哪些情况会失效。
- 批量导入、分页查询、复杂报表、数据权限在商业项目里怎么做。
- ORM 出现慢 SQL、字段为空、事务没回滚、缓存旧数据时怎么排查。
总学习路线
flowchart TD
A["阶段1:JDBC 痛点"] --> B["阶段2:ORM 封装边界"]
B --> C["阶段3:MyBatis Mapper 代理和 SQL 执行"]
C --> D["阶段4:动态 SQL、参数绑定、结果映射"]
D --> E["阶段5:缓存、插件、批量写入"]
E --> F["阶段6:MyBatis-Plus 增强机制"]
F --> G["阶段7:JPA/Hibernate 持久化上下文"]
G --> H["阶段8:Spring Data JPA Repository"]
H --> I["阶段9:事务边界和一致性"]
I --> J["阶段10:性能优化和生产排查"]这条路线的核心思想是:先理解数据库访问为什么复杂,再理解框架帮你封装了什么,最后必须回到 SQL、事务和执行计划。ORM 能提高效率,但不能替你理解数据库。
阶段1:JDBC 为什么繁琐
直接使用 JDBC 查询一条订单:
public OrderDO findById(Long id) throws SQLException {
Connection connection = dataSource.getConnection();
PreparedStatement ps = connection.prepareStatement(
"select id, order_no, status, amount from t_order where id = ?"
);
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
OrderDO order = null;
if (rs.next()) {
order = new OrderDO();
order.setId(rs.getLong("id"));
order.setOrderNo(rs.getString("order_no"));
order.setStatus(rs.getInt("status"));
order.setAmount(rs.getBigDecimal("amount"));
}
rs.close();
ps.close();
connection.close();
return order;
}这段代码暴露出数据库访问的重复工作:
| 重复工作 | 不封装会怎样 |
|---|---|
| 获取连接、释放连接 | 容易连接泄漏,连接池被打满 |
| 创建 SQL | 拼接错误、维护困难 |
| 绑定参数 | 类型转换重复,可能 SQL 注入 |
| 处理结果集 | 字段映射重复,列名写错后字段为空 |
| 异常和事务 | 回滚、提交、资源释放复杂 |
| 批量操作 | 批大小、提交频率、失败定位难统一 |
ORM 的第一层价值是把这些重复动作封装起来。
flowchart TD
A["Java 方法参数"] --> B["ORM 参数绑定"]
B --> C["PreparedStatement"]
C --> D["数据库执行 SQL"]
D --> E["ResultSet"]
E --> F["ORM 结果映射"]
F --> G["Java 对象"]但注意:ORM 封装的是访问过程,不是消灭 SQL。数据库最终执行的仍然是 SQL。
阶段2:ORM 家族怎么分工
flowchart TD
A["JDBC"] --> B["MyBatis"]
A --> C["JPA 规范"]
C --> D["Hibernate 实现"]
D --> E["Spring Data JPA"]
B --> F["MyBatis-Plus"]| 技术 | 定位 | 优点 | 风险 |
|---|---|---|---|
| JDBC | Java 数据库访问基础 | 最直接、最可控 | 重复代码多 |
| MyBatis | 半自动 ORM | SQL 可控,适合复杂查询 | SQL 质量依赖开发者 |
| MyBatis-Plus | MyBatis 增强 | 单表 CRUD 效率高 | 复杂 SQL 不能硬套 Wrapper |
| JPA | 持久化规范 | 统一实体映射标准 | 规范本身不执行 SQL |
| Hibernate | JPA 常见实现 | 持久化上下文、脏检查、懒加载 | N+1、懒加载、生成 SQL 要看懂 |
| Spring Data JPA | Repository 封装 | 快速 CRUD 和查询方法 | 方法名查询过长、count 慢 |
选型不能按“哪个高级”决定,而要按业务决定:
| 场景 | 推荐 |
|---|---|
| 订单、支付、库存等核心链路 | MyBatis,SQL 可控 |
| 医疗数据采集批量入库 | MyBatis 批量或 JDBC Batch |
| 后台管理单表 CRUD | MyBatis-Plus 或 Spring Data JPA |
| 复杂报表统计 | MyBatis 手写 SQL |
| 领域模型清晰的普通业务 | JPA/Hibernate |
| 需要跨数据库兼容 | JPA/Hibernate 可以考虑 |
如果团队不看 SQL、不看执行计划,任何 ORM 都可能把问题藏到线上。
阶段3:MyBatis Mapper 为什么能执行
Mapper 接口没有实现类,却能调用:
public interface OrderMapper {
OrderDO selectById(Long id);
}原因是 MyBatis 启动时会解析 XML 或注解,运行时为接口创建代理对象。
flowchart TD
A["启动扫描 Mapper 接口"] --> B["解析 XML 或注解 SQL"]
B --> C["创建 MappedStatement"]
C --> D["注册到 Configuration"]
D --> E["为 Mapper 接口创建代理"]
E --> F["业务调用 Mapper 方法"]
F --> G["MapperProxy 找到 MappedStatement"]
G --> H["执行 SQL 并映射结果"]一次查询全过程:
flowchart TD
A["OrderMapper.selectById"] --> B["MapperProxy.invoke"]
B --> C["MapperMethod.execute"]
C --> D["SqlSession.selectOne"]
D --> E["Executor.query"]
E --> F["StatementHandler 创建 PreparedStatement"]
F --> G["ParameterHandler 绑定参数"]
G --> H["数据库执行 SQL"]
H --> I["ResultSetHandler 处理结果"]
I --> J["ResultMap 映射 OrderDO"]核心对象:
| 对象 | 作用 |
|---|---|
Configuration | MyBatis 全局配置和运行时注册中心 |
MappedStatement | 一条 Mapper SQL 的完整描述 |
SqlSource | SQL 来源,静态 SQL 或动态 SQL |
BoundSql | 最终要执行的 SQL 和参数映射 |
Executor | SQL 执行器,处理查询、更新、缓存 |
StatementHandler | 创建和操作 JDBC Statement |
ParameterHandler | 绑定 Java 参数到 ? |
ResultSetHandler | 把 ResultSet 转成对象 |
TypeHandler | Java 类型和 JDBC 类型转换 |
面试说法:Mapper 接口能执行不是魔法,而是动态代理加 MappedStatement。
阶段4:MyBatis 最小 Demo
Mapper:
public interface AssetMapper {
AssetDO selectByCode(@Param("assetCode") String assetCode);
List<AssetDO> selectPage(@Param("tenantId") Long tenantId,
@Param("status") Integer status,
@Param("keyword") String keyword);
}XML:
<mapper namespace="com.example.asset.AssetMapper">
<resultMap id="AssetMap" type="com.example.asset.AssetDO">
<id column="id" property="id"/>
<result column="asset_code" property="assetCode"/>
<result column="asset_name" property="assetName"/>
<result column="tenant_id" property="tenantId"/>
<result column="status" property="status"/>
</resultMap>
<select id="selectByCode" resultMap="AssetMap">
select id, asset_code, asset_name, tenant_id, status
from data_asset
where asset_code = #{assetCode}
and deleted = 0
</select>
<select id="selectPage" resultMap="AssetMap">
select id, asset_code, asset_name, tenant_id, status
from data_asset
<where>
tenant_id = #{tenantId}
and deleted = 0
<if test="status != null">
and status = #{status}
</if>
<if test="keyword != null and keyword != ''">
and (asset_code like concat('%', #{keyword}, '%')
or asset_name like concat('%', #{keyword}, '%'))
</if>
</where>
order by id desc
</select>
</mapper>你必须理解这些对应关系:
| XML 元素 | 对应含义 |
|---|---|
namespace | Mapper 接口全限定名 |
select id | Mapper 方法名 |
resultMap | 列和 Java 字段的映射规则 |
#{assetCode} | 预编译参数绑定 |
<if> | MyBatis 生成 SQL 前的条件判断 |
如果 namespace、方法名、参数名、列名、属性名任何一个不匹配,就会出现找不到语句、参数为空、字段映射为空等问题。
阶段5:#{} 和 ${} 的本质差异
flowchart TD
A["用户输入"] --> B{"MyBatis 使用方式"}
B -- "#{ }" --> C["SQL 中生成 ?"]
C --> D["PreparedStatement 参数绑定"]
D --> E["输入只作为值"]
B -- "${ }" --> F["直接字符串替换"]
F --> G["输入可能改变 SQL 结构"]#{} 示例:
where username = #{username}最终类似:
where username = ?数据库把用户输入当成值,不会改变 SQL 结构。
${} 示例:
order by ${sortField}如果前端传入:
id desc; delete from user就有 SQL 注入风险。动态字段、排序、表名确实有时需要 ${},但必须白名单:
private static final Map<String, String> SORT_FIELD_MAP = Map.of(
"createdAt", "created_at",
"amount", "amount",
"id", "id"
);
public String safeSortField(String input) {
String field = SORT_FIELD_MAP.get(input);
if (field == null) {
throw new IllegalArgumentException("非法排序字段");
}
return field;
}原则:用户输入不能直接改变 SQL 结构。
阶段6:动态 SQL 如何变成最终 SQL
MyBatis 动态 SQL 标签不是交给数据库执行,而是在 Java 侧生成最终 SQL。
flowchart TD
A["XML SQL 模板"] --> B["参数对象"]
B --> C["OGNL 判断 if/foreach/where"]
C --> D["生成最终 SQL 字符串"]
D --> E["生成参数映射列表"]
E --> F["BoundSql"]
F --> G["PreparedStatement 执行"]foreach 示例:
<select id="selectByIds" resultMap="AssetMap">
select id, asset_code, asset_name
from data_asset
where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>如果 ids 是 [1,2,3],最终 SQL 类似:
select id, asset_code, asset_name
from data_asset
where id in (?, ?, ?)常见坑:
| 坑 | 后果 | 正确做法 |
|---|---|---|
ids 为空 | in () 语法错误 | Java 层提前返回空结果 |
foreach 数量过大 | SQL 太长、参数太多 | 分批或临时表 |
| 动态条件太多 | 优化器难选索引 | 设计常用查询组合 |
动态排序直接 ${} | SQL 注入 | 白名单映射 |
排查动态 SQL 时,不要只看 XML,要看最终 SQL 和参数,也就是 BoundSql。
阶段7:结果映射和 TypeHandler
数据库返回的是 ResultSet,Java 需要对象。映射失败的高频原因是列名和属性名不一致。
select asset_code from data_assetJava 字段:
private String assetCode;解决方式:
- 开启下划线转驼峰。
- SQL 使用别名:
asset_code as assetCode。 - 使用
resultMap显式映射。
自定义 TypeHandler 适合处理枚举、JSON 字段、加密字段。
@MappedTypes(AssetStatus.class)
public class AssetStatusTypeHandler extends BaseTypeHandler<AssetStatus> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
AssetStatus parameter, JdbcType jdbcType)
throws SQLException {
ps.setInt(i, parameter.getCode());
}
@Override
public AssetStatus getNullableResult(ResultSet rs, String columnName)
throws SQLException {
return AssetStatus.of(rs.getInt(columnName));
}
}不要把复杂业务逻辑塞进 TypeHandler。它应该只做类型转换。
阶段8:MyBatis 缓存
MyBatis 一级缓存是 SqlSession 级别,默认开启。
flowchart TD
A["同一 SqlSession 查询"] --> B{"本地缓存是否有结果"}
B -- "有" --> C["直接返回"]
B -- "没有" --> D["执行 SQL"]
D --> E["结果放入一级缓存"]一级缓存命中条件大致与 MappedStatement、SQL、参数、分页等有关。执行 insert/update/delete、commit、rollback、close 通常会清空缓存。
二级缓存是 Mapper namespace 级别,风险更大:
flowchart TD
A["Mapper A 查询订单和用户"] --> B["写入 A namespace 缓存"]
C["Mapper B 更新用户"] --> D["只清 B namespace 缓存"]
D --> E["A namespace 可能仍是旧数据"]为什么生产谨慎使用二级缓存:
| 风险 | 原因 |
|---|---|
| 多表关联旧数据 | 更新某个表不一定清理所有相关 namespace |
| 多实例不一致 | 本地缓存不天然跨 JVM 同步 |
| 外部系统修改数据库 | MyBatis 缓存不知道 |
| 强一致业务风险高 | 订单、支付、库存不能读旧数据 |
建议:业务缓存用 Redis + 明确 key、TTL、失效策略;MyBatis 二级缓存不要当核心业务缓存。
阶段9:MyBatis 插件原理
MyBatis 插件能拦截四类核心对象:
| 拦截对象 | 常见用途 |
|---|---|
Executor | SQL 统计、审计、缓存控制 |
StatementHandler | 分页、SQL 改写、数据权限 |
ParameterHandler | 参数处理、加密字段 |
ResultSetHandler | 结果脱敏、字段转换 |
插件原理是动态代理:
flowchart TD
A["原始 Executor"] --> B["插件包装成代理对象"]
B --> C["业务执行查询"]
C --> D["代理先进入 Interceptor"]
D --> E["执行增强逻辑"]
E --> F["调用原始对象方法"]分页插件本质:
原 SQL:
select id, name from data_asset where tenant_id = ?
改写后:
select id, name from data_asset where tenant_id = ? limit ?, ?插件不能解决所有性能问题。分页插件只能改写 SQL,深分页慢、count 慢、索引不合理仍然需要数据库优化。
阶段10:MyBatis-Plus 的本质
MyBatis-Plus 不是另一个数据库访问框架,而是在 MyBatis 之上注入通用 SQL 和增强能力。
flowchart TD
A["BaseMapper.selectById"] --> B["MyBatis-Plus 注入通用 MappedStatement"]
B --> C["根据实体表名、主键、字段生成 SQL"]
C --> D["交给 MyBatis Executor"]
D --> E["JDBC 执行"]Wrapper 的本质是构造 SQL 条件片段:
LambdaQueryWrapper<AssetDO> wrapper = Wrappers.lambdaQuery();
wrapper.eq(AssetDO::getTenantId, tenantId)
.eq(AssetDO::getDeleted, 0)
.like(StringUtils.hasText(keyword), AssetDO::getAssetName, keyword)
.orderByDesc(AssetDO::getId);
List<AssetDO> list = assetMapper.selectList(wrapper);它最终仍会变成 SQL。Wrapper 只是让简单条件更容易写,不代表 SQL 自动合理。
高频坑:
| 坑 | 后果 | 正确做法 |
|---|---|---|
or() 不分组 | 破坏租户、权限、逻辑删除条件 | 用 and(w -> ...) 包裹 |
last() 拼用户输入 | SQL 注入 | 只允许固定常量 |
| 逻辑删除没设计唯一索引 | 删除后同名新增冲突 | 唯一索引考虑删除标记 |
| 分页 count 很慢 | 大表复杂 join 统计慢 | 自定义 count 或 Slice/游标 |
| Wrapper 写复杂报表 | 可读性差,SQL 不可控 | 回到 XML 手写 SQL |
阶段11:JPA/Hibernate 核心原理
JPA/Hibernate 的核心是持久化上下文,不是“自动 SQL”四个字。
flowchart TD
A["EntityManager.find"] --> B["实体进入持久化上下文"]
B --> C["保存实体快照"]
C --> D["业务修改实体字段"]
D --> E["flush 时脏检查"]
E --> F{"快照是否变化"}
F -- "变化" --> G["生成 update SQL"]
F -- "未变化" --> H["不执行 update"]
G --> I["事务提交或回滚"]Demo:
@Transactional
public void changeAssetName(Long id, String newName) {
Asset asset = entityManager.find(Asset.class, id);
asset.changeName(newName);
}为什么没有调用 save 也会更新:
find返回的实体是托管态。- Hibernate 在持久化上下文中保存快照。
- 事务提交前触发 flush。
- flush 做脏检查。
- 字段变化后生成
update。
实体状态:
| 状态 | 含义 |
|---|---|
| 临时态 | new 出来,还没被持久化上下文管理 |
| 托管态 | 被 EntityManager 管理,变化会被脏检查 |
| 游离态 | 曾经托管,但现在离开上下文 |
| 删除态 | 标记为删除,flush 时执行 delete |
flush 和 commit 区别:
| 概念 | 含义 |
|---|---|
| flush | 把上下文变更同步成 SQL 发给数据库 |
| commit | 提交事务,让数据库变更真正生效 |
flush 之后仍然可以回滚,所以不要把 flush 当成 commit。
阶段12:懒加载和 N+1
懒加载依赖代理对象和持久化上下文。
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;如果事务结束后再访问:
order.getItems().size();可能出现 LazyInitializationException,因为 EntityManager 已经关闭,代理无法再查数据库。
N+1 查询:
flowchart TD
A["查询 100 个订单"] --> B["执行 1 条订单 SQL"]
B --> C["循环访问每个订单的明细"]
C --> D["每个订单再查 1 条明细 SQL"]
D --> E["总计 101 条 SQL"]解决方向:
- 列表页优先 DTO 投影,只查需要字段。
- 明确需要关联时用 fetch join。
- 使用 EntityGraph。
- 批量查询关联数据后在内存组装。
- 不要在 Controller 返回 Entity,让 JSON 序列化触发懒加载。
DTO 投影示例:
public record AssetListItem(Long id, String assetCode, String assetName) {
}
@Query("""
select new com.example.AssetListItem(a.id, a.assetCode, a.assetName)
from Asset a
where a.tenantId = :tenantId
order by a.id desc
""")
List<AssetListItem> findAssetList(Long tenantId);阶段13:Spring Data JPA Repository 原理
Repository 接口也没有实现类:
public interface AssetRepository extends JpaRepository<Asset, Long> {
List<Asset> findByTenantIdAndStatus(Long tenantId, Integer status);
}它能执行是因为 Spring Data JPA 创建了代理对象。
flowchart TD
A["启动扫描 Repository"] --> B["识别实体类型和主键类型"]
B --> C["创建 Repository 代理"]
C --> D["解析方法调用"]
D --> E{"方法类型"}
E -- "内置 CRUD" --> F["SimpleJpaRepository"]
E -- "方法名查询" --> G["解析方法名生成查询"]
E -- "@Query" --> H["执行 JPQL 或 SQL"]
F --> I["EntityManager"]
G --> I
H --> I方法名查询适合简单条件,不适合无限拉长:
findByTenantIdAndStatusAndAssetNameContainingAndDeletedOrderByIdDesc这种方法名可读性差,复杂条件应改用 @Query、Specification、Querydsl 或 MyBatis。
Page 和 Slice:
| 类型 | 特点 |
|---|---|
| Page | 查数据 + 查 count,能返回总页数 |
| Slice | 通常不查 count,只判断是否有下一页 |
大表列表、日志流、采集记录滚动加载,不一定需要总数,Slice 或游标分页更合适。
阶段14:事务为什么放 Service 层
事务应该覆盖一个完整业务动作,而不是一条 Mapper/Repository 调用。
下单可能包含:
- 插入订单。
- 插入订单明细。
- 扣库存。
- 写本地消息表。
- 写审计日志。
这些要么一起提交,要么一起回滚。
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderMapper.insert(command.toOrder());
orderItemMapper.batchInsert(command.toItems());
stockMapper.decrease(command.skuId(), command.count());
localMessageMapper.insert(command.toMessage());
}
}Spring 事务本质是 AOP 代理 + 线程绑定连接:
flowchart TD
A["调用 Service 代理方法"] --> B["事务拦截器开启事务"]
B --> C["从连接池获取 Connection"]
C --> D["绑定到当前线程"]
D --> E["Mapper/JPA 使用同一连接"]
E --> F{"方法是否异常"}
F -- "正常" --> G["commit"]
F -- "异常" --> H["rollback"]
G --> I["释放连接"]
H --> I事务失效高频原因:
| 原因 | 为什么 |
|---|---|
| 同类自调用 | 没经过 Spring 代理 |
| 异常被 catch 吞掉 | 事务拦截器认为成功 |
受检异常未配置 rollbackFor | 默认只回滚运行时异常 |
| 方法不是 public | 代理可能无法拦截 |
| 多数据源事务管理器选错 | 操作不在同一事务管理器 |
| 事务中调用远程接口太久 | 锁和连接占用时间过长 |
阶段15:批量导入怎么设计
不要十万条一个事务,也不要简单循环单条 insert。
错误示例:
@Transactional
public void importAll(List<AssetImportRow> rows) {
for (AssetImportRow row : rows) {
assetMapper.insert(row.toAsset());
}
}问题:
- SQL 次数太多。
- 单事务太大,锁和 undo 压力大。
- 一行失败,全量回滚,定位困难。
- 连接长时间占用。
商业设计:
flowchart TD
A["上传文件"] --> B["创建导入任务"]
B --> C["解析和基础校验"]
C --> D["按批次 500/1000 切分"]
D --> E["每批独立事务写入"]
E --> F{"批次是否失败"}
F -- "成功" --> G["记录成功数"]
F -- "失败" --> H["降级小批或单条定位"]
H --> I["记录失败行和原因"]
G --> J["更新任务进度"]
I --> JMyBatis 批量:
<insert id="batchInsert">
insert into data_asset(asset_code, asset_name, tenant_id, created_at)
values
<foreach collection="list" item="item" separator=",">
(#{item.assetCode}, #{item.assetName}, #{item.tenantId}, now())
</foreach>
</insert>JPA 批量:
@Transactional
public void batchPersist(List<Asset> assets) {
for (int i = 0; i < assets.size(); i++) {
entityManager.persist(assets.get(i));
if (i > 0 && i % 500 == 0) {
entityManager.flush();
entityManager.clear();
}
}
}JPA 批量必须定期 flush 和 clear,否则持久化上下文会堆积大量实体,导致内存上涨。
阶段16:商业场景怎么选 ORM
医疗数据采集入库
特点:字段多、批量大、失败要定位、需要幂等。
建议:
- MyBatis 批量写入。
- 按任务批次提交。
- 唯一索引或业务流水号保证幂等。
- 失败行记录行号和原因。
- 采集原始数据和标准化数据分表保存。
数据资产列表查询
特点:条件多、租户和医院数据权限、分页、排序。
建议:
- MyBatis 或 MyBatis-Plus。
- 权限条件必须进入 SQL。
- 排序字段白名单。
- 大表避免深分页。
- 列表页只查展示字段,详情页再查完整信息。
后台基础字典 CRUD
特点:单表为主、逻辑简单。
建议:
- MyBatis-Plus 或 Spring Data JPA。
- 逻辑删除、自动填充、乐观锁可以使用框架能力。
- 不要为了简单 CRUD 写大量重复 XML。
报表统计
特点:join、group by、聚合、时间范围、性能敏感。
建议:
- MyBatis 手写 SQL。
- 复杂统计考虑汇总表。
- 定时任务预聚合。
- 配合 EXPLAIN 和索引优化。
阶段17:生产排查
字段映射为空
flowchart TD
A["对象字段为空"] --> B["SQL 是否查出该列"]
B --> C["列名是否和属性匹配"]
C --> D["是否开启下划线转驼峰"]
D --> E["resultMap column/property 是否写反"]
E --> F["多表 join 是否有重复列名"]
F --> G["TypeHandler 是否转换失败"]事务没有回滚
flowchart TD
A["事务未回滚"] --> B["方法是否经过 Spring 代理"]
B --> C["是否同类自调用"]
C --> D["异常是否被 catch 吞掉"]
D --> E["异常类型是否需要 rollbackFor"]
E --> F["事务管理器是否正确"]
F --> G["是否存在异步线程或新连接"]SQL 慢
flowchart TD
A["SQL 慢"] --> B["打印最终 SQL 和参数"]
B --> C["执行 EXPLAIN"]
C --> D["看索引、扫描行数、排序、回表"]
D --> E["看分页和 count"]
E --> F["看锁等待和连接池"]
F --> G["优化 SQL、索引或业务查询方式"]JPA N+1
flowchart TD
A["接口 SQL 数量异常"] --> B["开启 SQL 日志"]
B --> C["是否先查列表再循环查关联"]
C --> D["是否 JSON 序列化触发懒加载"]
D --> E["改 DTO 投影或 fetch join"]
E --> F["回归验证 SQL 数量"]批量导入内存上涨
flowchart TD
A["批量导入内存上涨"] --> B["批大小是否过大"]
B --> C["JPA 是否未 clear 持久化上下文"]
C --> D["是否一次性读完整文件"]
D --> E["失败重试是否重复堆积"]
E --> F["分片读取、分批事务、及时释放"]阶段18:常见坑和后果
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 只看 Mapper 不看 SQL | 线上慢查询难定位 | 打印最终 SQL 和参数 |
用户输入直接 ${} | SQL 注入 | 白名单映射 |
| 二级缓存随便开 | 读到旧数据 | 明确一致性边界 |
| Entity 直接返回前端 | 懒加载、循环引用、字段泄露 | 返回 DTO |
| JPA 批量不 clear | 内存上涨 | 定期 flush/clear |
| 事务放 Mapper 层 | 多表业务无法整体回滚 | 放 Service 层 |
| catch 异常不抛 | 事务不回滚 | 抛出异常或手动标记回滚 |
| 远程调用放长事务里 | 锁和连接占用 | 事务内只做数据库关键操作 |
| Wrapper 写复杂 SQL | 逻辑不可读,索引难优化 | 复杂查询手写 SQL |
| 分页插件解决深分页 | 仍然慢 | 游标分页、索引、业务改造 |
阶段19:面试标准回答
问:ORM 是什么?
标准回答:
ORM 是对象关系映射,负责把 Java 对象和关系型数据库之间的参数绑定、SQL 执行、结果映射、事务和缓存等重复工作封装起来。它能提高开发效率,但不等于不用理解 SQL。最终性能仍然取决于表结构、索引、执行计划和事务边界。
问:MyBatis Mapper 接口为什么能执行?
标准回答:
MyBatis 启动时会扫描 Mapper 接口,解析 XML 或注解形成
MappedStatement并注册到Configuration。运行时为 Mapper 接口创建动态代理。调用接口方法时,代理根据 namespace 和方法名找到MappedStatement,动态 SQL 生成BoundSql,再通过Executor、StatementHandler、ParameterHandler、ResultSetHandler完成 SQL 执行和结果映射。
问:MyBatis 和 Hibernate 有什么区别?
标准回答:
MyBatis 是半自动 ORM,SQL 主要由开发者编写和控制,适合复杂 SQL、报表和性能敏感场景;Hibernate/JPA 更强调实体映射、持久化上下文、脏检查和自动 SQL,CRUD 效率高,但必须关注 N+1、懒加载、flush、最终生成 SQL 和事务边界。
问:JPA 为什么修改实体不用手写 update?
标准回答:
事务内查询出来的实体是托管态,Hibernate 会在持久化上下文里保存实体快照。业务修改字段后,事务提交前触发 flush,Hibernate 做脏检查,发现当前实体和快照不同,就生成 update SQL。flush 只是同步 SQL,commit 才是真正提交事务。
问:事务为什么放 Service 层?
标准回答:
事务应该覆盖一个完整业务动作,而不是单条 SQL。Service 层通常会调用多个 Mapper 或 Repository,例如订单、明细、库存、本地消息表要一起成功或一起回滚。事务放 Mapper 层只能保证单次数据访问,无法保证业务一致性。
最终验收题
如果下面问题答不清楚,说明还没真正掌握 ORM:
- JDBC 访问数据库有哪些重复动作,ORM 分别封装了什么?
- MyBatis 的
MappedStatement和BoundSql有什么区别? #{}为什么能防 SQL 注入,${}为什么危险?- 动态 SQL 最终怎么变成数据库执行的 SQL?
- MyBatis 一级缓存什么时候命中,什么时候清空?
- 二级缓存为什么在多表和分布式场景里容易不一致?
- MyBatis 插件拦截哪些对象?分页插件为什么不能解决深分页慢?
- MyBatis-Plus Wrapper 的
or和last有什么风险? - JPA 持久化上下文、实体状态、脏检查、flush、commit 怎么串起来?
- N+1 查询怎么产生,怎么发现,怎么解决?
- Spring Data JPA Repository 接口为什么能执行?
- Page 和 Slice 为什么在大表分页里选择不同?
- 事务失效有哪些原因?怎么排查?
- 批量导入如何控制批大小、事务、失败定位和幂等?
- ORM 线上慢 SQL 应该按什么证据链排查?
关联知识点跳转
- ORM 总览
- ORM 从零到生产级掌握
- ORM 商业场景训练营
- MyBatis
- MyBatis 核心全过程原理
- MyBatis 动态 SQL
- MyBatis 缓存机制
- MyBatis 批量操作
- MyBatis 拦截器
- MyBatis-Plus 核心全过程原理
- MyBatis-Plus Wrapper
- JPA 基础
- Hibernate/JPA 核心全过程原理
- Spring Data JPA 核心全过程原理
- ORM 事务与一致性
- Spring 事务
- MySQL EXPLAIN
- ORM 面试题
本章小结
ORM 的核心不是“少写 SQL”,而是把数据库访问链路工程化:参数绑定、SQL 执行、结果映射、缓存、事务、批量和扩展点。真正掌握 ORM,必须能解释框架如何把方法调用变成 SQL,能看懂最终 SQL 和执行计划,能识别缓存和事务边界,能在商业项目里选择合适框架,并能在生产故障中拿证据排查。
