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. 映射框架不该承载业务逻辑
三条理由,任何一条都足以否掉这个做法:
- 不可测——TypeHandler 里的逻辑要靠真实数据库才能验证,单测只测得到壳。
- 不可复用——同样的规则在写入路径上还得再写一遍,两份实现注定漂移。
- 变更放大——规则改一次要同时改映射配置、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 >= #{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. 自测
- 一个列表接口打出 200 条 SQL,你会先改索引还是先改查询方式?
- 事务边界应该由"一次数据库操作"决定,还是由"一次业务不变量变更"决定?
- 为什么说"复杂查询用 ORM 表达"最终一定退化成字符串拼接?
- 深分页除了改游标,还有哪种既保留跳页能力又控制扫描量的写法?
- 映射层做业务判断的三条反对理由中,哪一条最难在事后补救?
- 什么时候"不抽象"才是正确的架构决策?
S08-A05 · 持久层的边界:什么时候不写 SQL
https://www.clxhxhhr.top/posts/6085/ 评论
0 条
还没有评论,先写一条吧。