2852 字
约 9 分钟
16
S08-A06 · ORM 边界:什么时候该写 SQL

一个列表页打开要两秒,日志里同一张表被查了两百次。这类问题常被描述成"ORM 性能差",其实 ORM 本身不慢:它把单表读写做得很好,慢的是用 ORM 表达它不擅长的事——关联加载、批量、聚合、执行计划控制。

所以边界问题不是"用不用 ORM",而是"哪一层用哪种表达"。判据只有一条:这件事的形状是否在 ORM 的建模能力之内。


1. ORM 提供的四件事,以及各自的失效点

能力 擅长 失效点
对象映射 单表 CRUD、字段类型转换、乐观锁 多表结果形状不固定时映射成本反超收益
查询构造 条件组合、分页、排序 复杂聚合、窗口函数、CTE 表达力不足
事务与生命周期 声明式事务、脏检查、级联 批量写入时逐条 flush,事务过长
缓存 一级缓存、查询缓存 失效粒度粗,跨实例难一致

四件事里最容易被高估的是缓存。ORM 缓存省的是同进程内的重复查询,而真正吃性能的往往是跨请求、跨实例的热点数据,那属于集中缓存的地盘。


2. N+1:三种触发方式,同一个成因

N+1 的成因不是"写法不小心",而是关联边界与查询边界不重合:拿到 N 个主对象后,再逐个访问关联属性,每次访问触发一次查询。

触发方式 典型形态 识别信号
懒加载 实体关联配 LAZY,模板里遍历 order.getItems() 查询次数 ≈ 主表行数
循环里查 for (id : ids) mapper.selectById(id) 单请求内同 SQL 重复 N 次
关联未预取 用了 JOIN 但没配抓取策略,仍逐条回查 SQL 里既有 join 又有 N 次单查
// 反例:一次请求 1 + N 次查询
List<Order> orders = orderMapper.selectByUser(userId);
for (Order o : orders) {
    o.getItems().size();          // 每次触发一条 SQL
}
// 正例一:批量预取,查询数固定为 2
List<Order> orders = orderMapper.selectByUser(userId);
List<Long> ids = orders.stream().map(Order::getId).toList();
Map<Long, List<Item>> byOrder = itemMapper.selectByOrderIds(ids)
        .stream().collect(groupingBy(Item::getOrderId));

正例二是让持久层一次给足形状(JOIN + 结果映射或多结果集),适合"列表页要展示关联摘要"的场景;正例三是只查需要的列,适合"关联只用于筛选"的场景。三者选哪个,看页面是否需要关联明细,而不是看哪个写法更省事。

判断 N+1 不要靠读代码,靠单请求 SQL 计数:一次请求内同一条 SQL 出现次数超过个位数,就按 N+1 处理。


3. 该写 SQL 的四类场景

场景 判据 用 ORM 的代价
多表聚合报表 3 表以上 + 分组 + 窗口函数 多次查询后在内存聚合,数据量与内存同时放大
批量写入 单批 > 数百行,或需要 ON DUPLICATE KEY 逐条 flush,往返次数与事务时长线性增长
执行计划敏感 需要 index hint、固定 join 顺序、强制索引 优化器选错时无法干预
复杂子查询/CTE 递归、相关子查询、EXISTS 组合 只能用内存过滤替代,语义容易走样

判据可以写成一个很土但有效的规则:当"用 ORM 表达这件事"需要超过一次查询编排时,直接写 SQL。 报表和批量几乎总在这个区间;简单筛选几乎总不在。


4. 不该写 SQL 的场景

场景 理由
单表 CRUD ORM 生成的 SQL 与手写等价,还带类型检查
条件组合多的后台列表 查询构造器的可读性与防注入优势明显
需要审计/软删除/多租户过滤 框架拦截点统一,手写 SQL 容易漏条件
模型频繁变更的早期 手写 SQL 的字段同步成本最高

第三条最容易被忽略:软删除、租户隔离、数据权限这类全局条件,靠人写 SQL 迟早漏一处,而漏的那一处就是越权。


5. 混合组织:SQL 放哪、怎么复用

边界清晰的组织方式是按"谁拥有这段查询"分层,而不是按"谁写起来快":

repository/
  OrderRepository.java          # 接口,返回领域对象/投影
  impl/
    OrderRepositoryJpa.java     # 简单 CRUD 走 ORM
    OrderReportQuery.java       # 报表走原生 SQL
  sql/
    order_report.sql            # 可读、可单独跑、可被 DBA 审

三条纪律:

  1. 原生 SQL 只出现在 sql/ 与对应 Query 类里,不散落在 Service。
  2. 接口返回投影对象(DTO),不返回实体,避免两套加载语义混用。
  3. 同一查询只有一个实现入口,ORM 版与 SQL 版不并存——并存必然出现两边结果不一致。

6. 维护代价:不编译报错的部分最贵

原生 SQL 的字段名、表名、列别名都是字符串,改表结构时编译器不会提醒。这是它唯一的、也是最大的代价。

缓解手段按性价比排:

  • 用结果映射显式声明列名,启动时做一次校验(框架大多支持 resultMap 严格校验)。
  • 每个原生查询配一个集成测试,跑在真实库上,改表结构时测试先红。
  • 列名常量集中定义,禁止在 SQL 字符串里散写。
  • 上线前用 EXPLAIN 固化一次执行计划基线,结构变更时对比。
<select id="reportByMonth" resultType="OrderReportRow">
  SELECT o.id, o.amount, i.sku, i.qty
  FROM orders o JOIN order_items i ON i.order_id = o.id
  WHERE o.created_at &gt;= #{from} AND o.created_at &lt; #{to}
  ORDER BY o.created_at DESC
</select>

7. 动态查询:三种写法与各自的注入面

写法 可读性 注入风险 适用
查询构造器 / Criteria 中 低(参数绑定) 条件组合多、结构稳定
模板化动态 SQL(<if>、<where>) 高 低(#{} 绑定) 后台列表、报表筛选
字符串拼接 低 高(${} 直接拼) 基本没有正当用途
// 危险:${} 或字符串拼接,排序字段来自前端
String sql = "SELECT * FROM orders ORDER BY " + sortField;
// 安全:白名单映射到固定列名
String col = SORT_WHITELIST.getOrDefault(sortField, "created_at");

排序字段、表名、列名这类不能参数化的位置,只能用白名单,不能用转义。转义在标识符位置永远不可靠。


8. 批量、分页与缓存:三个具体陷阱

操作 逐条 批量 多值 insert
往返次数 N 1–N/批大小 1
事务时长 长(N 次 flush) 中 短
失败粒度 单行 单批 整条语句
适用 需要单行钩子 通用 数据已清洗、无自增依赖

批量配置的关键不是 batch_size 越大越好,而是要让驱动真正合并语句(JDBC rewriteBatchedStatements=true 一类开关),否则只是把 N 次往返包在一个循环里。

分页的陷阱在深翻页:LIMIT offset, n 在 offset 很大时退化为扫描,ORM 的分页插件通常照搬这个写法。深翻页要么改成基于游标(created_at < last),要么先取 ID 再回表。ORM 层看不到这个差别,只有执行计划能看出来。

缓存与 ORM 的交互有两个坑:一级缓存是事务/会话级的,同一事务里读到旧值很常见;二级缓存按实体区域失效,跨表更新(如库存表改了商品可售状态)不会自动失效。结论很简单——跨表一致性要求高的数据,不要交给 ORM 二级缓存。


9. 测试:内存库与真实库的差异

维度 内存库(H2/SQLite) 真实库
语法方言 部分兼容,函数名常不同 准
执行计划 无参考价值 可 EXPLAIN
锁与隔离 简化 可复现死锁
迁移脚本 常因方言失败 真实校验
启动速度 快 慢(用容器缓解)

策略:单元测试可以用内存库跑 CRUD;原生 SQL、批量、分页、锁相关的测试必须跑真实库(Testcontainers 一类方案),否则测的是方言兼容性,不是行为。


10. 排错:五个"看起来像 ORM 慢"的现象

现象 真实原因 处理
列表页慢,SQL 数上百 懒加载触发 N+1 批量预取或一次 join 取形状
批量导入越跑越慢 逐条 flush,事务膨胀 开批处理 + 分段提交
同样 SQL 时快时慢 优化器选错计划 / 参数嗅探 固化计划、必要时 hint
改表结构后线上报列不存在 原生 SQL 未同步 启动校验 + 集成测试
二级缓存下读到旧数据 跨表更新不触发区域失效 缩短 TTL 或去掉该区域缓存

11. 边界:这些情况就别引入 SQL 了

情况 判断 做法
模型简单、查询固定 ORM 足够,且更安全 坚持 ORM,不预置 SQL 通道
团队 SQL 能力弱 手写 SQL 的维护风险高于收益 用查询构造器,配代码评审
单表数据量小 优化收益低于复杂度 先解决索引,不加自定义 SQL
强类型/多数据库支持 方言差异会成倍放大 留在 ORM 抽象内
只有一处慢查询 局部处理 单点原生 SQL,不改成全局架构

反过来的边界同样成立:报表、批量、执行计划敏感这三类一旦出现,越早划出 SQL 通道越省事——等到 ORM 层堆满内存聚合再改,成本是重写半个持久层。

一句话判据:ORM 管"一条记录的读写",SQL 管"一批数据的形状"。 分不清形状归谁,就会在两个极端之间反复重构。


12. 自测

  1. N+1 的成因是写法问题还是边界问题?判断它该看什么指标?
  2. 三类该写 SQL 的场景里,哪一类最容易被误判成"ORM 也能做"?
  3. 为什么软删除、多租户过滤这类条件不适合手写 SQL?
  4. 批量插入只调大 batch_size 为什么可能没有效果?
  5. 深翻页在 ORM 里为什么退化,需要看什么才能发现?
  6. 什么情况下 ORM 二级缓存不该用于跨表一致性要求高的数据?
S08-A06 · ORM 边界:什么时候该写 SQL
https://www.clxhxhhr.top/posts/6142/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。