2401 字
约 8 分钟
19
S08-A13 · SQL 与代码的边界:什么该进 SQL、什么在代码里做

一条统计需求最初写成"查全表再在 Java 里过滤",数据涨到两千万行后接口超时。工程师改写成 SQL 里的 WHERE 与 GROUP BY,接口从 8 秒降到 200 毫秒。半年后同一条 SQL 涨到 300 行,嵌套四层子查询,没人敢改——性能问题解决了,可维护性问题开始了。

这个来回说明一件事:"该在 SQL 里做还是代码里做"不是风格问题,而是两种代价的交换。数据库擅长过滤与聚合,不擅长表达业务分支;代码擅长表达规则,不擅长搬运数据。


1. 两种代价

在 SQL 里做 在代码里做
传输的数据量小 传输的数据量大
表达力弱(分支、循环难写) 表达力强
难以单元测试 易于测试
改动风险高(牵连执行计划) 改动局限在方法内

两种代价还有一个不对称:SQL 的代价是可观测的(慢查询、执行计划),代码的代价是不可观测的(半年后没人敢改)。所以"看数据说话"的团队天然倾向于把逻辑往 SQL 里推,而恶果要很久之后才显现。

第一行是 SQL 的核心优势,第二行是它的核心短板。当数据量不大时,SQL 的优势用不上,短板先出现——这就是为什么小表上的复杂 SQL 往往得不偿失。

2. 三个该进 SQL 的判断

情形 理由
过滤能减少一个数量级以上 传输是主要成本
聚合结果远小于明细 分组在库里做更省
需要数据库的事务与锁语义 应用层无法保证

第三项最硬:"读取-判断-写回"必须在数据库里一次完成,否则并发下必然出现覆盖。这时候用一句 UPDATE ... WHERE version = ? 或 INSERT ... ON DUPLICATE KEY 是唯一正确的做法,与"代码更可读"的偏好无关。

3. 三个该在代码里做的判断

情形 理由
规则分支多(>5 个条件组合) SQL 表达力不足
需要调用外部服务或缓存 数据库做不到
结果要被多处复用、需可测 测试成本

第一项的量化门槛很实用:条件组合超过五个,SQL 的可读性会断崖式下降,而改一个条件就要重新评估执行计划。

还有一个常被忽略的成本:SQL 里表达的分支难以复用。同一套规则在第二个接口里需要时,通常的做法是复制那段 SQL——于是规则出现两份,改一处忘一处。

第三项在长期维护里权重很高:写在 SQL 里的业务规则无法被单元测试覆盖,只能靠集成测试,而集成测试通常跑得慢、覆盖少。

// 代码里表达分支:可测、可读、改一个规则不动 SQL
List<Order> candidates = orderMapper.selectRecent(userId, since);   // SQL 只做过滤
return candidates.stream()
    .filter(o -> ruleA(o) && ruleB(o))
    .sorted(comparator)
    .limit(20)
    .toList();
// 代价:候选集必须足够小(selectRecent 已把范围压到百级)

4. 分页与排序的边界

做法 问题
数据库分页 + 复杂排序 深翻页代价高
代码里排序 必须先取全量
数据库排序 + 代码分页 无法真正分页
索引支持的排序 + 游标分页 需要索引配合

第三项是常见错误:把数据全部取出、在代码里切页,看起来简单,实际把"分页"退化成"全量传输"。

offset 分页的问题不在"翻页慢",而在**"越翻越慢"**:第 1000 页要扫过前 20000 行再丢弃。这与数据量是超线性关系,所以它能从"还行"突然变成"不可用"。

-- offset 分页:代价随页码线性增长
SELECT ... FROM orders WHERE status='PAID' ORDER BY created_at DESC LIMIT 20 OFFSET 20000;
-- 数据库仍要生成并丢弃前 20000 行

-- 游标分页:代价与页码无关
SELECT ... FROM orders WHERE status='PAID' AND created_at < ? ORDER BY created_at DESC LIMIT 20;
-- 直接定位到索引位置,与第几页无关

第四项是正解,但前提是排序字段与过滤条件能组成一个复合索引,否则游标分页也会退化为全表扫。

-- 游标分页:用 (sort_key, id) 做稳定游标
SELECT id, amount, created_at FROM orders
WHERE status = 'PAID' AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC LIMIT 20;
-- 需要索引 (status, created_at, id);无此索引则会走 filesort

5. 一条可用的决策流程

步骤 问什么
1 过滤后能剩多少行(比率)
2 规则里有没有外部依赖
3 规则会不会频繁变
4 是否需要事务保证

第一问决定"数据搬多少",第二问与第三问决定"规则放哪里",第四问是硬约束。

决策流程里还有一个隐含问题:"规则会不会频繁变"往往答得过于乐观。经验做法是把最近半年改过的规则列出来——如果一半以上在 SQL 里,说明当初的分工是错的。

实践上常见的结果是两段式:SQL 负责把候选集压到百级,代码负责分支与排序。这个分工在绝大多数业务查询里都是最省的。

// 两段式的分工线
SQL   :WHERE 主条件 + 索引排序 + LIMIT 上限(压到 100–1000 行)
代码 :业务分支、组合排序、最终 LIMIT(20 行)
反例 :把业务分支写进 SQL → 300 行嵌套,改一个规则要重看执行计划
反例 :把主过滤放到代码 → 全表传输,接口随数据量线性变慢

分工还要考虑"谁能改":SQL 的修改通常要过 DBA 或评审,代码的修改在团队内完成。所以变更频率高、责任明确的规则放代码,跨团队共享、需集中管控的逻辑放 SQL——这与性能无关,是组织约束。

// 按"谁能改"划界的三个例子
- 促销规则(每周变)      → 代码 + 配置
- 对账口径(跨部门共识)  → SQL 视图,集中一处
- 权限过滤(安全要求)    → SQL 或统一网关,不可分散

6. 排错:五种"分工错了"

现象 成因 处理方向
接口随数据量线性变慢 过滤在代码里 主条件下推到 SQL
SQL 极长无人敢改 分支写进了 SQL 抽回代码
分页深翻页很慢 用了 offset 改游标分页
并发下数据被覆盖 读写分离在两个系统 合并为一条带条件的 UPDATE
报表慢但业务查询快 聚合在代码里 分组下推到 SQL

第五项(报表慢)常被误当成索引问题:代码里做聚合时,索引帮不上忙,因为它只减少取数代价,不减少计算代价。这类查询下推分组后往往有十倍以上的收益。

第四项是最危险的一类:"先查再改"在低并发下永远正常,问题只在并发峰值出现,而那时往往已经造成了数据不一致。

7. 边界:什么情况不必纠结

场景 为什么 做法
数据量在万级以内 传输不是瓶颈 以可读性优先
一次性数据修复脚本 不重复 怎么快怎么来
纯报表(离线) 无延迟要求 全在 SQL 里做
已有成熟视图/存储过程 团队共识 保持一致

还有一条与团队规模有关的边界:人少时偏好"代码里做"(易改易测),人多时偏好"SQL 里做"(集中、单一实现)。同一个项目在不同阶段的最优解不同,所以这个边界不是一次划定的。

判断口径一句话:把这段逻辑放错地方,代价是"慢"还是"改不动"。前者可以观测并优化,后者会在半年后集中爆发——所以规则复杂时,宁可先慢一点也要放在代码里。


8. 自测

  1. 两种代价的核心差别是什么?为什么"小表上的复杂 SQL"往往得不偿失?
  2. 哪一类逻辑必须放在 SQL 里?为什么"代码更可读"的偏好在这里不成立?
  3. 规则分支超过几个时 SQL 的可读性会断崖下降?为什么"写在 SQL 里"会推高测试成本?
  4. "数据库排序 + 代码分页"为什么不是分页?游标分页的前提是什么?
  5. 两段式分工的分工线在哪里?两个反例各造成什么后果?
  6. 判断"放错地方代价是什么"的那个问题是什么?
S08-A13 · SQL 与代码的边界:什么该进 SQL、什么在代码里做
https://www.clxhxhhr.top/posts/6453/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。