一条统计需求最初写成"查全表再在 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. 自测
- 两种代价的核心差别是什么?为什么"小表上的复杂 SQL"往往得不偿失?
- 哪一类逻辑必须放在 SQL 里?为什么"代码更可读"的偏好在这里不成立?
- 规则分支超过几个时 SQL 的可读性会断崖下降?为什么"写在 SQL 里"会推高测试成本?
- "数据库排序 + 代码分页"为什么不是分页?游标分页的前提是什么?
- 两段式分工的分工线在哪里?两个反例各造成什么后果?
- 判断"放错地方代价是什么"的那个问题是什么?