ORM 商业场景训练营
这页把 ORM 放到真实商业项目里学习。目标不是“会写 Mapper”或“会调用 Repository”,而是能解释一次数据库访问从 Controller 到 Service、事务、Mapper/Repository、SQL、参数绑定、结果映射、缓存、连接池、提交回滚、慢 SQL 排查的完整过程。
训练目标
学完这页,你要能做到:
- 解释 ORM 为什么不能替代 SQL、索引和事务知识。
- 解释 MyBatis 从 Mapper 接口到 SQL 执行、结果映射的全过程。
- 解释
#{}和${}的区别,为什么${}拼用户输入会 SQL 注入。 - 解释动态 SQL、批量写入、分页、缓存、插件拦截器如何工作。
- 解释 JPA/Hibernate 的持久化上下文、脏检查、懒加载、N+1 问题。
- 解释 ORM 事务为什么应该放在 Service 层,而不是 Mapper 层。
- 能按订单、采集入库、资产检索、报表查询设计 ORM 访问方案。
- 能排查慢 SQL、批量导入慢、连接池耗尽、事务不回滚、N+1 查询。
商业场景总览
以“医疗数据采集与资产平台”为例,ORM 常见任务:
| 场景 | ORM 重点 | 风险 |
|---|---|---|
| 资产列表查询 | 动态条件、分页、索引、排序 | 慢 SQL、深分页、全表扫描 |
| 采集数据入库 | 批量插入、唯一键、事务边界 | 大事务、锁等待、重复数据 |
| 订单支付回调 | 状态机更新、幂等、事务 | 状态乱跳、重复更新 |
| 后台 CRUD | MyBatis-Plus/JPA 快速开发 | Wrapper 滥用、Entity 泄露 |
| 报表统计 | 手写 SQL、聚合、临时表 | ORM 自动生成 SQL 不可控 |
核心原则:
ORM 是帮你把对象和 SQL 组织得更工程化,不是让你不用理解 SQL。
一次 MyBatis 查询全过程
flowchart TD
A["Service 调用 Mapper 接口"] --> B["Mapper 代理对象"]
B --> C["根据 namespace + method 找 MappedStatement"]
C --> D["解析动态 SQL"]
D --> E["参数绑定生成 BoundSql"]
E --> F["Executor 执行查询"]
F --> G["StatementHandler 创建 PreparedStatement"]
G --> H["ParameterHandler 设置参数"]
H --> I["数据库执行 SQL"]
I --> J["ResultSetHandler 映射结果"]
J --> K["返回 Java 对象"]如果面试问 MyBatis 原理,不要只说“动态代理”。动态代理只是入口,真正链路还包括 MappedStatement、动态 SQL、参数绑定、Executor、StatementHandler、ResultSetHandler。
Demo 一:资产列表动态查询
Mapper XML
<select id="listAssets" resultType="AssetListVO">
select
id,
asset_code,
asset_name,
hospital_id,
status,
updated_at
from t_asset
where deleted = 0
<if test="tenantId != null">
and tenant_id = #{tenantId}
</if>
<if test="hospitalId != null">
and hospital_id = #{hospitalId}
</if>
<if test="status != null and status != ''">
and status = #{status}
</if>
<if test="keyword != null and keyword != ''">
and asset_name like concat('%', #{keyword}, '%')
</if>
order by updated_at desc
limit #{offset}, #{pageSize}
</select>Service 层
@Service
public class AssetQueryService {
private final AssetMapper assetMapper;
public PageResult<AssetListVO> listAssets(AssetQuery query, CurrentUser user) {
AssetQueryCondition condition = new AssetQueryCondition();
condition.setTenantId(user.tenantId());
condition.setHospitalId(query.hospitalId());
condition.setStatus(query.status());
condition.setKeyword(query.keyword());
condition.setOffset((query.pageNo() - 1) * query.pageSize());
condition.setPageSize(query.pageSize());
List<AssetListVO> list = assetMapper.listAssets(condition);
long total = assetMapper.countAssets(condition);
return PageResult.of(list, total);
}
}为什么动态 SQL 要谨慎:
- 条件组合多,最终 SQL 很容易和你想的不一样。
like '%keyword%'很可能用不上普通 B+Tree 索引。order by updated_at desc limit 100000, 20深分页会慢。- 条件字段和排序字段要结合索引设计。
- 多租户条件
tenant_id必须后端强制带上,不能信前端。
#{} 和 ${} 的区别
| 写法 | 原理 | 风险 |
|---|---|---|
#{name} | 使用占位符,走 PreparedStatement 参数绑定 | 安全,防 SQL 注入 |
${name} | 字符串直接拼进 SQL | 危险,用户输入会注入 |
安全写法:
where asset_name = #{assetName}危险写法:
where asset_name = '${assetName}'如果用户传入:
' or '1'='1拼接 SQL 可能变成:
where asset_name = '' or '1'='1'${} 不是绝对不能用,但只能用于白名单控制的 SQL 片段,例如排序字段:
private static final Map<String, String> ORDER_COLUMN = Map.of(
"updatedAt", "updated_at",
"assetCode", "asset_code"
);order by ${orderColumn} desc这里的 orderColumn 必须来自后端白名单映射,不能直接来自用户输入。
Demo 二:采集数据批量入库
采集系统常见一次导入几千到几十万条数据。不能一条一条 insert,也不能一个无限大事务硬塞。
flowchart TD
A["读取采集数据"] --> B["清洗和校验"]
B --> C["按 500/1000 分批"]
C --> D["批量 insert"]
D --> E{"是否唯一键冲突"}
E -- "否" --> F["提交本批事务"]
E -- "是" --> G["记录失败明细"]
G --> H["继续下一批或人工处理"]Mapper
<insert id="batchInsert">
insert into collect_record (
task_id,
source_id,
record_no,
payload,
created_at
)
values
<foreach collection="list" item="item" separator=",">
(
#{item.taskId},
#{item.sourceId},
#{item.recordNo},
#{item.payload},
now()
)
</foreach>
</insert>Service
public void importRecords(List<CollectRecord> records) {
int batchSize = 1000;
for (int i = 0; i < records.size(); i += batchSize) {
List<CollectRecord> batch = records.subList(i, Math.min(i + batchSize, records.size()));
importOneBatch(batch);
}
}
@Transactional
public void importOneBatch(List<CollectRecord> batch) {
collectRecordMapper.batchInsert(batch);
}注意:如果 importRecords 和 importOneBatch 在同一个类里直接 this.importOneBatch(),Spring 事务可能因为自调用失效。要么拆到另一个 Bean,要么用 TransactionTemplate。
事务边界为什么放 Service 层
错误理解:Mapper 方法上加事务就行。
实际业务通常是多个表一起变化:
flowchart TD
A["创建订单"] --> B["写订单表"]
B --> C["写订单明细"]
C --> D["扣库存"]
D --> E["写本地消息表"]
E --> F["提交事务"]如果事务放在单个 Mapper 方法上,只能保证单条 SQL 或单表操作,不能保证整个业务原子性。事务应该放在 Service 方法上,围住完整业务规则。
@Transactional
public void createOrder(CreateOrderCommand command) {
orderMapper.insert(order);
orderItemMapper.batchInsert(items);
stockMapper.lockStock(command.skuId(), command.quantity());
localMessageMapper.insert(orderCreatedMessage);
}事务常见坑:
| 坑 | 后果 | 正确做法 |
|---|---|---|
| catch 异常不抛 | Spring 认为方法成功,事务提交 | 重新抛出或标记 rollbackOnly |
| 自调用 | 事务代理不生效 | 拆 Bean 或 TransactionTemplate |
| checked 异常默认不回滚 | 业务失败但提交 | 配置 rollbackFor |
| 事务太大 | 锁、undo、连接长期占用 | 拆批、缩短事务 |
| 事务里调用慢外部接口 | 数据库锁被长时间持有 | 外部调用移出事务或做补偿 |
JPA/Hibernate 持久化上下文
JPA/Hibernate 和 MyBatis 最大差异:JPA 有持久化上下文,会跟踪 Entity 状态。
flowchart TD
A["查询 Entity"] --> B["进入持久化上下文"]
B --> C["业务修改字段"]
C --> D["事务提交前 flush"]
D --> E["脏检查生成 update SQL"]
E --> F["提交事务"]示例:
@Transactional
public void renameAsset(Long assetId, String newName) {
Asset asset = assetRepository.findById(assetId)
.orElseThrow();
asset.setAssetName(newName);
}这段代码没有显式调用 save,但事务提交时可能会更新数据库,因为 Entity 是 managed 状态。
如果不了解这个机制,可能出现:
- 以为没调用 save 就不会更新,结果数据被改了。
- 在 Controller 返回 Entity,触发懒加载和 JSON 序列化问题。
- 循环访问关联对象,产生 N+1 查询。
N+1 查询问题
flowchart TD
A["查 100 个订单"] --> B["返回订单列表"]
B --> C["循环访问 order.items"]
C --> D["每个订单再查一次明细"]
D --> E["1 + 100 次 SQL"]解决方式:
- 使用 fetch join。
- 使用 EntityGraph。
- 分两次批量查询,再在内存组装。
- DTO 投影查询。
- 对复杂报表直接写 SQL。
MyBatis 里也会有 N+1:循环里调用 Mapper 查询详情就是同样问题。
MyBatis-Plus 使用边界
MyBatis-Plus 适合快速 CRUD:
LambdaQueryWrapper<Asset> wrapper = Wrappers.<Asset>lambdaQuery()
.eq(Asset::getTenantId, tenantId)
.eq(status != null, Asset::getStatus, status)
.like(StringUtils.hasText(keyword), Asset::getAssetName, keyword)
.orderByDesc(Asset::getUpdatedAt);但不要把复杂业务 SQL 全塞进 Wrapper:
- 多表 Join 报表不清晰。
- 复杂条件容易生成不可控 SQL。
- 性能分析困难。
- 数据权限条件容易漏。
建议:单表 CRUD 用 MyBatis-Plus;复杂查询、报表、性能敏感 SQL 用 XML 或手写 SQL。
生产排查流程
慢 SQL
flowchart TD
A["接口慢"] --> B["打印最终 SQL 和参数"]
B --> C["EXPLAIN 执行计划"]
C --> D["看 type、key、rows、Extra"]
D --> E["检查索引和排序"]
E --> F["检查返回行数和分页方式"]
F --> G["优化 SQL 或索引"]连接池耗尽
flowchart TD
A["连接池耗尽"] --> B["看活跃连接数"]
B --> C["查慢 SQL"]
C --> D["查长事务"]
D --> E["查事务里外部调用"]
E --> F["查连接泄漏"]
F --> G["调整连接池和代码"]事务不回滚
flowchart TD
A["事务没回滚"] --> B["方法是否被代理调用"]
B --> C["异常是否被 catch"]
C --> D["异常类型是否默认回滚"]
D --> E["是否跨线程执行"]
E --> F["数据库引擎是否支持事务"]面试标准回答
MyBatis 一次查询怎么执行
业务代码调用 Mapper 接口时,实际调用的是 MyBatis 创建的代理对象。代理根据接口方法找到对应的 MappedStatement,解析动态 SQL,生成 BoundSql,再由 Executor 执行。执行过程中 StatementHandler 创建 PreparedStatement,ParameterHandler 绑定参数,数据库返回 ResultSet 后由 ResultSetHandler 映射成 Java 对象。#{} 和 ${} 区别
`#{}` 会使用 PreparedStatement 占位符进行参数绑定,可以防 SQL 注入;`${}` 是字符串直接拼接到 SQL 中,用户输入如果不经过白名单校验就可能造成 SQL 注入。生产中用户输入值用 `#{}`,只有排序字段、表名这类必须拼接的 SQL 片段才考虑 `${}`,而且必须后端白名单控制。JPA 为什么会有脏检查
JPA/Hibernate 有持久化上下文,事务内查询出来的 Entity 处于 managed 状态。业务代码修改 Entity 字段后,Hibernate 会在 flush 或事务提交前进行脏检查,对比快照并生成 update SQL。所以 JPA 不一定需要显式 save 才会更新数据库,这也是它和 MyBatis 的重要差异。