2980 字
约 9 分钟
17
S08-A05 · 持久层的边界:什么时候不写 SQL

持久层出问题极少是因为 SQL 写得丑,而是因为它悄悄接管了不该它管的职责:权限判断、状态流转、缓存击穿兜底、跨服务编排。等到某天要改一个业务规则,你发现在 mapper.xml 里、在 TypeHandler 里、在一条 CASE WHEN 里都有一份它——这时讨论"用 ORM 还是手写"已经没有意义了。

这篇只谈边界:哪些活该给持久层,哪些必须留在服务层,以及每种写法的账单长什么样。


1. 四类职责与越界的具体表现

职责 该做的事 越界表现 后果
映射 行 ↔ 对象、列 ↔ 字段 在 TypeHandler 里算业务状态 读一次数据触发一次业务逻辑
查询组装 把条件拼成语句 在 DAO 里判断权限后拼不同 where 权限规则散落在字符串里
事务边界 声明一次原子操作 DAO 方法上开事务 事务过碎,跨多次写无法合并
领域转换 DTO ↔ 领域对象 直接返回持久化实体给上层 表结构变更穿透到业务层
// 越界:DAO 里做权限判断,调用方既不可复用也不可单测
public List<Order> list(Long uid, boolean canSeeAll) {
    if (canSeeAll) return mapper.selectAll();
    return mapper.selectByUid(uid);   // 有人忘了传 canSeeAll,越权就发生
}

越界的判据很朴素:这段代码需要知道"业务规则"才能写对吗? 需要,它就不该在持久层。


2. 四种写法的适用判据

写法 适合 不适合 可移植性 调试成本
手写 SQL(XML / 注解) 复杂聚合、批量、性能敏感路径 大量简单 CRUD 中,方言绑定 低,语句可见
全自动 ORM(JPA / Hibernate) 模型稳定、写后读为主 报表、复杂 join 高 高,语句生成不可见
DSL / 条件构造器 条件组合多、结构简单 子查询、窗口函数、OR 分组 中 中
存储过程 极热路径、就地批量计算 需要灰度发布与版本的逻辑 低,绑定数据库 最高

判据只看四条:查询复杂度、团队熟悉度、可移植性、调试成本。只要查询复杂度高,其余全让位——硬用 ORM 表达,最后一定退化成字符串拼接。

-- 复杂性一旦越过条件构造器的表达力,就是纯 SQL 的场合
SELECT c.id, COUNT(*) AS cnt,
       SUM(CASE WHEN o.status = 'PAID' THEN o.amount ELSE 0 END) AS paid
FROM customer c LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.created_at >= :from
GROUP BY c.id HAVING cnt > 3
ORDER BY paid DESC LIMIT 100;

3. ORM 的隐式行为与排查手段

隐式行为 触发条件 表现 排查手段
N+1 循环里访问关联对象 1 条主查询 + N 条子查询 开 SQL 日志数语句条数
懒加载 事务外访问未初始化关联 异常或额外查询 显式 join fetch 或提前初始化
一级/二级缓存 同一会话重复查同一主键 读到旧值,看不到刚写的变更 关二级缓存,必要时 clear
批量插入 saveAll 逐条执行 网络往返被放大 配 batch size 或改多值插入
# 先让语句可见,再谈优化;键名按版本核对
spring:
  jpa:
    show-sql: true
    properties:
      hibernate.jdbc.batch_size: 50
logging.level.org.hibernate.SQL: debug
mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

N+1 的代价不是"慢",而是"慢得不可预测":数据翻倍,语句也翻倍,扩容时它先撞墙。判断只有一条:数语句。列表接口打出 200 条 SQL 时,索引优化都是白工。


4. 什么时候必须手写 SQL

场景 框架为什么表达不了 写法要点
多表聚合、窗口函数 条件构造器无对应 API 独立 mapper 方法,返回专用 DTO
批量 upsert 框架逐条执行 按方言写 ON DUPLICATE KEY / MERGE
性能敏感路径 需要精确控制执行计划 手写 + EXPLAIN 固化 + 回归用例
一次性数据修复 不该混进业务代码 独立脚本,走代码评审
-- 一次往返完成的批量累加;MySQL 8.0.20 起 VALUES() 写法已标记弃用,按版本核对
INSERT INTO item_stat (item_id, pv, updated_at) VALUES
  (1001, 1, NOW()), (1002, 1, NOW())
ON DUPLICATE KEY UPDATE pv = pv + VALUES(pv), updated_at = VALUES(updated_at);

反过来的边界:手写 SQL 的成本是没人替你保证映射正确。列名或类型改了,编译期不报错,只有运行时才发现,所以必须配一条覆盖它的集成测试。


5. 映射框架不该承载业务逻辑

三条理由,任何一条都足以否掉这个做法:

  1. 不可测——TypeHandler 里的逻辑要靠真实数据库才能验证,单测只测得到壳。
  2. 不可复用——同样的规则在写入路径上还得再写一遍,两份实现注定漂移。
  3. 变更放大——规则改一次要同时改映射配置、SQL 与读出的对象,漏改即静默错误。
// 正例:映射只做"格式转换"(枚举 ↔ 码值),业务判断回到领域对象
public enum Status {
    NEW("N"), PAID("P"), CLOSED("C");
    private final String code;
    Status(String code) { this.code = code; }
}

判断一句话:映射层的输入应该只有"数据库里存的是什么",不应该需要"业务上应该是什么"。


6. 动态查询与分页:可读性的临界点

组装方式 可读性边界 何时失效
XML if 标签 条件 ≤5 个、无嵌套 组合超过 8 个后分支爆炸
条件构造器 平铺的等值与范围条件 出现子查询、OR 分组、窗口函数
查询对象 + 拼装器 条件来自前端筛选面板 需要类型安全与复用校验时
固定几套手写 SQL 报表类固定维度 维度组合多时维护成本反超
<select id="search" resultType="ItemBrief">
  SELECT id, title FROM item
  <where>
    <if test="kw != null and kw != ''">AND title LIKE CONCAT('%', #{kw}, '%')</if>
    <if test="from != null">AND created_at &gt;= #{from}</if>
  </where>
  ORDER BY id DESC LIMIT #{offset}, #{size}
</select>

深分页的取舍:

分页方式 代价 适用
LIMIT offset, size offset 越大扫描行越多 前若干页
游标(id > last_id) 不能跳页 信息流、导出
延迟关联 多一次 join 必须跳页且有可用索引
预计算分页边界 需要额外维护 报表类固定页码
-- 延迟关联:先在索引上定位主键,再回表取整行
SELECT i.* FROM item i
JOIN (SELECT id FROM item WHERE status = 1 ORDER BY id DESC LIMIT 100000, 20) t
  ON i.id = t.id;

7. 事务边界放在哪一层

位置 粒度 问题
Controller 过大 把远程调用、序列化都包进事务
Service 合适 注意自调用与代理失效
DAO / Mapper 过碎 跨多次写无法合并成一个原子操作
跨库 / 跨服务 本地事务无效 需要消息或补偿,另立方案
@Service
public class OrderService {
    // 边界 = 一次业务不变量变更,而不是一次数据库操作
    @Transactional(rollbackFor = Exception.class)
    public void place(OrderCmd cmd) {
        orderMapper.insert(...);
        stockMapper.decrease(...);
    }
}

判据:事务边界等于"一次业务不变量变更"。跨库时必然失效:本地事务管不到另一个数据源,要么同库,要么上消息与补偿。


8. 多数据源、读写分离与结果集映射

机制 侵入点 代价
注解切换(@DS) 每个方法都要标注 漏标就写到主库或读从库
AOP 按方法名路由 命名约定 约定一变静默失效
显式传 DataSource 调用方感知 污染业务方法签名
中间件代理 配置层 引入新故障域与版本约束

结果集映射的坑:

坑 表现 处理
类型转换 tinyint ↔ boolean、时间精度丢失 显式 TypeHandler,按驱动版本核对
null 语义 基本类型映射成 0 用包装类型并声明可空
字段名不一致 下划线 ↔ 驼峰 统一 map-underscore-to-camel-case
同名列覆盖 join 后两表都有 id 别名或 resultMap 显式指定
mybatis:
  configuration:
    map-underscore-to-camel-case: true
    jdbc-type-for-null: 'NULL'   # 避免驱动对 null 类型推断的差异

9. SQL 可观测:先看得见,再谈调优

指标 采集方式 判据
慢查询数 slow_query_log + 阈值 发布后突增即回归
执行计划 EXPLAIN / EXPLAIN ANALYZE type=ALL 且扫描行数大
语句指纹 参数化后的模板 同一指纹占比高说明可批量优化
连接池等待 池指标 等待上升先查慢 SQL 再看池大小
# MySQL 侧:慢查询与未走索引的语句
slow_query_log = 1
long_query_time = 0.2
log_queries_not_using_indexes = 1

参数化的价值不止防注入:它让同结构语句共享执行计划与指纹。拼接的 SQL 每条都是新指纹,慢查询日志会被淹没。


10. 排错

现象 原因 处理
压测 QPS 上不去,DB CPU 却不高 连接池过小或事务过长 看池等待时间与持锁时长
同一条 SQL 偶发变慢 执行计划随参数值变化 固化计划或改写为稳定形态
更新"丢"了数据 事务边界碎,部分提交 事务上移到服务层
列表越翻越慢 offset 扫描 改游标或延迟关联
批量导入极慢 逐条往返 多值插入或开 batch

11. 边界:这些场景不该抽象

场景 做法 理由
纯 CRUD、字段少 直接写 mapper,不套抽象层 抽象收益小于维护成本
报表类查询 独立 SQL 或数仓 ORM 表达不了聚合与窗口函数
一次性数据修复 直接写 SQL 跑 不留技术债
表结构频繁演进 手写映射,避免全自动 ORM 隐式映射会静默错位
多数据库部署 不用存储过程 方言绑定翻倍迁移成本

12. 自测

  1. 一个列表接口打出 200 条 SQL,你会先改索引还是先改查询方式?
  2. 事务边界应该由"一次数据库操作"决定,还是由"一次业务不变量变更"决定?
  3. 为什么说"复杂查询用 ORM 表达"最终一定退化成字符串拼接?
  4. 深分页除了改游标,还有哪种既保留跳页能力又控制扫描量的写法?
  5. 映射层做业务判断的三条反对理由中,哪一条最难在事后补救?
  6. 什么时候"不抽象"才是正确的架构决策?
S08-A05 · 持久层的边界:什么时候不写 SQL
https://www.clxhxhhr.top/posts/6085/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。