Skip to content

ORM 商业场景训练营

这页把 ORM 放到真实商业项目里学习。目标不是“会写 Mapper”或“会调用 Repository”,而是能解释一次数据库访问从 Controller 到 Service、事务、Mapper/Repository、SQL、参数绑定、结果映射、缓存、连接池、提交回滚、慢 SQL 排查的完整过程。

训练目标

学完这页,你要能做到:

  1. 解释 ORM 为什么不能替代 SQL、索引和事务知识。
  2. 解释 MyBatis 从 Mapper 接口到 SQL 执行、结果映射的全过程。
  3. 解释 #{}${} 的区别,为什么 ${} 拼用户输入会 SQL 注入。
  4. 解释动态 SQL、批量写入、分页、缓存、插件拦截器如何工作。
  5. 解释 JPA/Hibernate 的持久化上下文、脏检查、懒加载、N+1 问题。
  6. 解释 ORM 事务为什么应该放在 Service 层,而不是 Mapper 层。
  7. 能按订单、采集入库、资产检索、报表查询设计 ORM 访问方案。
  8. 能排查慢 SQL、批量导入慢、连接池耗尽、事务不回滚、N+1 查询。

商业场景总览

以“医疗数据采集与资产平台”为例,ORM 常见任务:

场景ORM 重点风险
资产列表查询动态条件、分页、索引、排序慢 SQL、深分页、全表扫描
采集数据入库批量插入、唯一键、事务边界大事务、锁等待、重复数据
订单支付回调状态机更新、幂等、事务状态乱跳、重复更新
后台 CRUDMyBatis-Plus/JPA 快速开发Wrapper 滥用、Entity 泄露
报表统计手写 SQL、聚合、临时表ORM 自动生成 SQL 不可控

核心原则:

ORM 是帮你把对象和 SQL 组织得更工程化,不是让你不用理解 SQL。

一次 MyBatis 查询全过程

mermaid
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

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 层

java
@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 要谨慎:

  1. 条件组合多,最终 SQL 很容易和你想的不一样。
  2. like '%keyword%' 很可能用不上普通 B+Tree 索引。
  3. order by updated_at desc limit 100000, 20 深分页会慢。
  4. 条件字段和排序字段要结合索引设计。
  5. 多租户条件 tenant_id 必须后端强制带上,不能信前端。

#{}${} 的区别

写法原理风险
#{name}使用占位符,走 PreparedStatement 参数绑定安全,防 SQL 注入
${name}字符串直接拼进 SQL危险,用户输入会注入

安全写法:

xml
where asset_name = #{assetName}

危险写法:

xml
where asset_name = '${assetName}'

如果用户传入:

text
' or '1'='1

拼接 SQL 可能变成:

sql
where asset_name = '' or '1'='1'

${} 不是绝对不能用,但只能用于白名单控制的 SQL 片段,例如排序字段:

java
private static final Map<String, String> ORDER_COLUMN = Map.of(
        "updatedAt", "updated_at",
        "assetCode", "asset_code"
);
xml
order by ${orderColumn} desc

这里的 orderColumn 必须来自后端白名单映射,不能直接来自用户输入。

Demo 二:采集数据批量入库

采集系统常见一次导入几千到几十万条数据。不能一条一条 insert,也不能一个无限大事务硬塞。

mermaid
flowchart TD
    A["读取采集数据"] --> B["清洗和校验"]
    B --> C["按 500/1000 分批"]
    C --> D["批量 insert"]
    D --> E{"是否唯一键冲突"}
    E -- "否" --> F["提交本批事务"]
    E -- "是" --> G["记录失败明细"]
    G --> H["继续下一批或人工处理"]

Mapper

xml
<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

java
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);
}

注意:如果 importRecordsimportOneBatch 在同一个类里直接 this.importOneBatch(),Spring 事务可能因为自调用失效。要么拆到另一个 Bean,要么用 TransactionTemplate

事务边界为什么放 Service 层

错误理解:Mapper 方法上加事务就行。

实际业务通常是多个表一起变化:

mermaid
flowchart TD
    A["创建订单"] --> B["写订单表"]
    B --> C["写订单明细"]
    C --> D["扣库存"]
    D --> E["写本地消息表"]
    E --> F["提交事务"]

如果事务放在单个 Mapper 方法上,只能保证单条 SQL 或单表操作,不能保证整个业务原子性。事务应该放在 Service 方法上,围住完整业务规则。

java
@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 状态。

mermaid
flowchart TD
    A["查询 Entity"] --> B["进入持久化上下文"]
    B --> C["业务修改字段"]
    C --> D["事务提交前 flush"]
    D --> E["脏检查生成 update SQL"]
    E --> F["提交事务"]

示例:

java
@Transactional
public void renameAsset(Long assetId, String newName) {
    Asset asset = assetRepository.findById(assetId)
            .orElseThrow();
    asset.setAssetName(newName);
}

这段代码没有显式调用 save,但事务提交时可能会更新数据库,因为 Entity 是 managed 状态。

如果不了解这个机制,可能出现:

  1. 以为没调用 save 就不会更新,结果数据被改了。
  2. 在 Controller 返回 Entity,触发懒加载和 JSON 序列化问题。
  3. 循环访问关联对象,产生 N+1 查询。

N+1 查询问题

mermaid
flowchart TD
    A["查 100 个订单"] --> B["返回订单列表"]
    B --> C["循环访问 order.items"]
    C --> D["每个订单再查一次明细"]
    D --> E["1 + 100 次 SQL"]

解决方式:

  1. 使用 fetch join。
  2. 使用 EntityGraph。
  3. 分两次批量查询,再在内存组装。
  4. DTO 投影查询。
  5. 对复杂报表直接写 SQL。

MyBatis 里也会有 N+1:循环里调用 Mapper 查询详情就是同样问题。

MyBatis-Plus 使用边界

MyBatis-Plus 适合快速 CRUD:

java
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:

  1. 多表 Join 报表不清晰。
  2. 复杂条件容易生成不可控 SQL。
  3. 性能分析困难。
  4. 数据权限条件容易漏。

建议:单表 CRUD 用 MyBatis-Plus;复杂查询、报表、性能敏感 SQL 用 XML 或手写 SQL。

生产排查流程

慢 SQL

mermaid
flowchart TD
    A["接口慢"] --> B["打印最终 SQL 和参数"]
    B --> C["EXPLAIN 执行计划"]
    C --> D["看 type、key、rows、Extra"]
    D --> E["检查索引和排序"]
    E --> F["检查返回行数和分页方式"]
    F --> G["优化 SQL 或索引"]

连接池耗尽

mermaid
flowchart TD
    A["连接池耗尽"] --> B["看活跃连接数"]
    B --> C["查慢 SQL"]
    C --> D["查长事务"]
    D --> E["查事务里外部调用"]
    E --> F["查连接泄漏"]
    F --> G["调整连接池和代码"]

事务不回滚

mermaid
flowchart TD
    A["事务没回滚"] --> B["方法是否被代理调用"]
    B --> C["异常是否被 catch"]
    C --> D["异常类型是否默认回滚"]
    D --> E["是否跨线程执行"]
    E --> F["数据库引擎是否支持事务"]

面试标准回答

MyBatis 一次查询怎么执行

text
业务代码调用 Mapper 接口时,实际调用的是 MyBatis 创建的代理对象。代理根据接口方法找到对应的 MappedStatement,解析动态 SQL,生成 BoundSql,再由 Executor 执行。执行过程中 StatementHandler 创建 PreparedStatement,ParameterHandler 绑定参数,数据库返回 ResultSet 后由 ResultSetHandler 映射成 Java 对象。

#{}${} 区别

text
`#{}` 会使用 PreparedStatement 占位符进行参数绑定,可以防 SQL 注入;`${}` 是字符串直接拼接到 SQL 中,用户输入如果不经过白名单校验就可能造成 SQL 注入。生产中用户输入值用 `#{}`,只有排序字段、表名这类必须拼接的 SQL 片段才考虑 `${}`,而且必须后端白名单控制。

JPA 为什么会有脏检查

text
JPA/Hibernate 有持久化上下文,事务内查询出来的 Entity 处于 managed 状态。业务代码修改 Entity 字段后,Hibernate 会在 flush 或事务提交前进行脏检查,对比快照并生成 update SQL。所以 JPA 不一定需要显式 save 才会更新数据库,这也是它和 MyBatis 的重要差异。

关联知识点