qinghuan
← 数据系统 / 全部笔记

单元 46 / 数据系统

P0 · 必会主线L1 · 基础

聚合、子查询与 CTE:把统计问题拆成可验证的阶段

先确定一行结果代表什么,再分步筛选、分组和汇总,避免连接展开或 NULL 让统计悄悄出错。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能将用户消费统计拆成可核对的查询阶段,正确处理零记录和重复关联。

学习理由、准备要求与知识点

为什么学它

统计前要说清一行结果代表什么(统计粒度);聚合和子查询用错,会得到看似合理的错误报表。

先读这些内容

知识点清单

  • 分组粒度
  • COUNT 与 SUM
  • WHERE 与 HAVING
  • ONLY_FULL_GROUP_BY
  • EXISTS
  • NOT IN 与 NULL
  • 标量子查询
  • CTE 作用域

运行前先确认MySQL8.4与Java17;Spring源码固定v6.2.11。算法用内存记录实现筛选、分组与HAVING的受限语义,不模拟SQL优化器或完整三值逻辑。

01先弄懂它是什么

写统计 SQL 前,先说清结果一行代表什么。例如“每个用户的已付款总额”最终应是每用户一行。若把订单连到多条明细后再把订单总金额 SUM,相同订单金额就可能被算多次。先确认金额来自哪张表、连接后每行代表什么,再决定怎样 GROUP BY,才能避免 SQL 能运行却算错账。

WHERE 在分组前挑选原始行,HAVING 在分组后挑选统计结果。只统计已付款订单,应先过滤状态;只显示总金额超过门槛的用户,则要检查汇总后的值。COUNT(*) 数行,COUNT(amount) 不数空金额,SUM 也忽略 NULL;没有可相加的非空值时通常返回 NULL。是否用 COALESCE 显示成 0,要按业务含义决定。

子查询可以用来回答“有没有符合条件的记录”。例如只想找出下过单的用户,EXISTS 检查该用户是否有匹配订单,不必把每张订单都展开成一行用户结果。查“没有匹配记录”时,可以考虑带明确关联条件的 NOT EXISTS。NOT IN 则要小心:子查询中有 NULL 时,判断可能得到 UNKNOWN,也就是无法确定,而 WHERE 不会保留这种行。两种写法不能不看空值规则就直接互换。

文中这几个词,先认一认

分组粒度
统计结果每一组代表什么,例如每个用户,或每个用户的每个月。
相关子查询
里面的查询用到外层当前行的字段,例如查“这个用户”是否有已付款订单。
CTE
用 WITH 给一段查询结果起名,便于分步阅读;不保证一定保存中间结果,也不保证更快。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

可把统计写为WITH paid AS (SELECT user_id,amount FROM orders WHERE status='PAID'), totals AS (SELECT user_id,COUNT(*) AS n,SUM(amount) AS total FROM paid GROUP BY user_id) SELECT user_id,n,total FROM totals WHERE total>=100。这里把筛选、聚合和结果筛选分成可单独核对的阶段,金额单位应与列定义一致。

CTE 用 WITH 给一段查询起名字,方便把复杂 SQL 拆开阅读,但数据库不一定照书写顺序逐段执行。MySQL 可能把其中一段合进外层查询,也可能先保存中间结果再使用,后者叫物化。实际选哪条路,要看 EXPLAIN。递归 CTE 会反复使用前一步的结果继续查询,还必须规定何时停止、最多走多深;写成 WITH 并不意味着计算量就小。先核对结果,再比较实际执行了多少工作。

分组后若一组里有多个不同状态,报表究竟该显示哪一个?ONLY_FULL_GROUP_BY 会帮助拒绝这类没有说清取值规则的查询;但如果 MySQL 能按规则判断分组字段已经确定了其他字段,也可能允许,这叫识别函数依赖。不要为了让 SQL 运行就关闭该模式,接受组内任意值。想取每组最新记录,应明确先后顺序,再用窗口函数或可核对的关联查询选出对应记录,不能随手选一个时间和状态。

正在加载流程图…

这是逻辑解释顺序,优化器实际执行可重写;CTE提供命名边界而不是强制物化。
查看流程图源码(Mermaid)
flowchart TD
 A["原始记录与正确粒度"] --> B["WHERE筛选输入"]
 B --> C["GROUP BY形成分组"]
 C --> D["COUNT与SUM等聚合"]
 D --> E["HAVING或外层条件"]
 E --> F["输出与NULL展示策略"]
 G["EXISTS只判断存在"] --> B

按顺序看,每一步做了什么

  1. 写明输入和输出粒度,检查连接是否重复度量。
  2. 将状态等逐行条件放到聚合前。
  3. 分别定义计行数、非空值数量和金额空值策略。
  4. 使用HAVING或外层查询筛选组结果。
  5. 检查SQL模式与执行计划,再验证边界数据。

03对照实现代码

单行聚合结果与零行结果的Java边界 (新标签页)↗

Spring Framework v6.2.11 · DataAccessUtils#nullableSingleResult;JdbcTemplate#queryForObject

原创结果数量模型,非源码节选。省略Spring异常类型,只区分零行、多行和一行可空值;不在Java侧执行SQL聚合。

核心机制代码 · java
import java.util.*;
class SourceModel {
    static <T> T singleNullable(List<T> rows) {
        // 零行不是一行NULL,应有不同结果数量信号。
        if (rows==null||rows.isEmpty()) throw new IllegalStateException("zero rows");
        if (rows.size()!=1) throw new IllegalStateException("multiple rows");
        // 唯一结果可以为NULL,例如没有非空输入的SUM结果。
        return rows.get(0);
    }
    // 分组查询可能多行,不能无条件用单对象接口接收。
}

JdbcTemplate.queryForObject先查询映射列表,再通过nullableSingleResult检查结果数量,不能把方法名理解为自动LIMIT1。

nullableSingleResult对零行和多行分别报告数量问题,但允许唯一元素为空。零行与一行NULL属于不同状态。

没有GROUP BY的单个聚合查询常返回一行;加入分组后可能返回零到多行,Java接收接口需要随查询形状变化。

客户端只检查结果有几行,不会修正 SQL 的统计错误。JOIN 把金额重复展开后再 SUM,即使最后恰好一行也会正常返回错总额,需要用已知样本和断言核对。

代码先调用谁,再调用谁

  1. JdbcTemplate.queryForObject
  2. 执行query并映射结果列表
  3. DataAccessUtils.nullableSingleResult
  4. 检查恰好一行
  5. 返回可空值或数量异常

04动手写一个例子

保留NULL语义的分组统计与阈值筛选

订单输入含用户id、状态和可空金额,金额使用整数最小单位。只统计PAID,每组返回订单行数、非空金额数量与可空总额,再按总额阈值筛选;全空金额组不能被当成零参与比较。

输入 1
用户1两张PAID金额50和NULL
输出 1
行数2、非空数1、总额50
输入 2
用户2所有PAID金额均NULL
输出 2
总额NULL,不满足total>=0

先按状态过滤,再为每个用户创建聚合状态。行数每条匹配订单都增加,非空数量和总额只在amount不为空时更新,显式区分COUNT(*)与COUNT(amount)。

累计总额初值为零,但另用非空数量判断SQL式SUM是否存在;因此零金额订单和全空订单可被正确区分。逐次加法检查溢出,不能输出回绕后的错误金额。

阈值筛选发生在分组结束后,只有总额非空且达到阈值才保留。最后按用户id排序提供稳定展示,此排序是结果契约,不是GROUP BY天然保证的顺序。

为什么这样写是对的每组rows等于已处理PAID行数,nonNull等于其中非空金额数,sum是这些非空金额的精确整数和。

时间 均摊O(N+G log G)额外空间 O(G)

N条输入处理一次,G个分组保留状态并在输出时排序。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 50 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record Order(long user,String status,Long amount) {}
    record Summary(long user,long rows,long nonNull,Long sum) {}
    static class Acc { long rows,nonNull,sum; }
    static List<Summary> aggregate(List<Order> orders) {
        Map<Long,Acc> groups=new HashMap<>();
        for (Order order:orders) {
            if (!"PAID".equals(order.status())) continue;
            Acc a=groups.computeIfAbsent(order.user(),k->new Acc());
            a.rows++;
            // COUNT(amount)与SUM都忽略NULL,COUNT(*)仍计此行。
            if (order.amount()!=null) {
                a.sum=Math.addExact(a.sum,order.amount());
                a.nonNull++;
            }
        }
        List<Summary> result=new ArrayList<>();
        for (var entry:groups.entrySet()) {
            Acc a=entry.getValue();
            // 全空组保留NULL,与真实零金额组区分。
            result.add(new Summary(entry.getKey(),a.rows,a.nonNull,a.nonNull==0?null:a.sum));
        }
        result.sort(Comparator.comparingLong(Summary::user));
        return List.copyOf(result);
    }
    static List<Summary> having(List<Summary> groups,long threshold) {
        // NULL比较不能得到TRUE,因此不进入阈值结果。
        return groups.stream().filter(s->s.sum()!=null&&s.sum()>=threshold).toList();
    }
    static void eq(Object expected,Object actual) {
        if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        List<Summary> groups=aggregate(List.of(
            new Order(1,"PAID",50L),new Order(1,"PAID",null),
            new Order(1,"CANCELLED",999L),new Order(2,"PAID",null),
            new Order(3,"PAID",0L)));
        eq(List.of(new Summary(1,2,1,50L),new Summary(2,1,0,null),new Summary(3,1,1,0L)),groups);
        eq(List.of(groups.get(0),groups.get(2)),having(groups,0));
        eq(List.of(groups.get(0)),having(groups,50));
        eq(List.of(),having(groups,51));
        eq(List.of(),aggregate(List.of()));
        try {
            aggregate(List.of(new Order(1,"PAID",Long.MAX_VALUE),new Order(1,"PAID",1L)));
            throw new AssertionError("overflow");
        } catch (ArithmeticException expected) {}
        System.out.println("PASS 6 aggregation tests");
    }
}

这些特殊情况也要试一试

  • 空输入的分组查询返回零组,不等同无GROUP BY聚合的一行结果。
  • 全NULL金额与金额总和恰为零需要不同结果。
  • 未付款订单不建组;累计溢出受控失败。
进阶选读 · 流式多阶段聚合与增量修正

场景数据量大到无法每次扫描所有订单,且存在退款和状态变更。

做法按稳定业务键与事件版本生成增量,维护分组统计,同时支持撤销旧贡献再加入新贡献;定期从权威表重算对账,处理乱序和重复事件。

复杂度单事件可常数次更新,状态至少按分组与去重范围增长;全量对账仍需扫描相应数据。

好处与代价增量提高读取效率,但一致性和修复复杂度明显增加,不能仅对新订单加金额而忽略退款。

05项目里怎么用

核对统计结果时,别只看返回了几行,还要验计数和总额。准备几组小数据:没有订单、金额全没填、金额确实为 0、有多张订单、同时连接两组明细。金额要用能精确表示的类型,并写清币种和单位;人民币与美元不能不换算就直接相加。

只想知道“有没有匹配记录”,可以用 EXISTS 表达。数据库可能把这种写法改造成更合适的执行方式,所以不能只看 SQL 外形,就断言它会逐行跑完整个子查询。用 NOT IN 排除记录时,要专门试试数据中有 NULL 的情况;关联键为空的记录究竟应不应该留下,也要先把业务规则说清楚。

CTE 能把 SQL 写得更好读,但中间结果是否真被保存、复用或放进临时空间,要看执行计划。大报表要限制数据量和查询时间,避免一直占着事务等客户端下载。若报表要求同一时点的数据,要定义快照或统计批次;几次独立查询拼起来不自动代表同一时刻。

容易踩的坑

  • 对已被连接放大的金额再次SUM。
  • 用COUNT(*)代表非空金额数或右侧订单数。
  • 关闭ONLY_FULL_GROUP_BY来接受不确定组内字段。
  • 把CTE默认视为临时表或性能优化保证。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
GROUP BY聚合数据库集中计算,传输量较小。大数据下需扫描和分组资源。清楚粒度的实时统计。
EXISTS判断表达存在而不展开外层重复行。仍需合适关联索引和计划。有无订单等筛选。
增量汇总表读取快速且结果规模稳定。需幂等、修正和对账。高频大规模统计。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01WHERE与HAVING的区别?

展开参考答案与推导

WHERE筛选分组前输入,HAVING筛选组结果,优化器可在语义允许时重写。

02全NULL金额的SUM一定为0吗?

展开参考答案与推导

不是,通常为NULL,是否展示为零应明确选择。

03CTE一定被物化吗?

展开参考答案与推导

不一定,MySQL可能合并或物化,要看实际计划。

换个情况,还会处理吗

01订单总金额因明细连接变大怎么办?

展开参考答案与推导

先按订单正确粒度计算,避免订单金额在明细展开后重复累计。

02NOT IN突然没有结果怎么办?

展开参考答案与推导

检查子查询是否含NULL并推演三值逻辑,按需求改为明确NOT EXISTS关系。

03queryForObject突然报多行怎么办?

展开参考答案与推导

查询加入分组可能改变结果基数,改为列表或重新定义单行聚合契约。

写代码试一试

01如何区分全空与零金额组?

输入 / 条件
一组全NULL,一组金额0
输出 / 预期
sum分别NULL和0

提示 保留非空数量。

展开参考答案与推导

以nonNull是否为零决定SUM是否存在,不能只看累计数值。

02阈值等于总额时是否保留?

输入 / 条件
总额50,阈值50
输出 / 预期
保留

提示 条件为大于等于。

展开参考答案与推导

添加边界断言,避免实现误写成严格大于。

03如何加入退款事件?

输入 / 条件
已计入订单发生退款
输出 / 预期
按规则撤销相应贡献

提示 需保存旧贡献和事件版本。

展开参考答案与推导

以业务键和版本去重,先移除旧值再应用新状态,并与权威数据对账。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能将用户消费统计拆成可核对的查询阶段,正确处理零记录和重复关联。

本篇重点加练

分组粒度和否定查询中的 NULL 影响统计对象,重点检验空集合与缺失事实的边界。

  • 分组粒度
  • NOT IN 与 NULL

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    报表同时需要每天订单数、每商户月成交额与每用户客单价。明确每项统计粒度,并为取消订单、退款和空金额声明口径。

    提交内容
    指标字典
    怎样算完成
    • 分子分母和时间范围明确
    • 区分业务实体数与展开行数

    练到的知识点分组粒度 · COUNT 与 SUM

  2. 作业 02代码推演

    筛选“本月已支付金额超过阈值”的商户,同时展示商户名称。写出合法分组契约并比较支付状态筛选与聚合阈值筛选的时机。

    提交内容
    查询草案与执行阶段图
    怎样算完成
    • 注明 ONLY_FULL_GROUP_BY 模式
    • 说明非分组字段的来源

    练到的知识点WHERE 与 HAVING · ONLY_FULL_GROUP_BY

  3. 作业 03代码推演

    报表先按用户与月份汇总,再按城市汇总,但用户可能月中迁居。定义城市归属时间,并评审两阶段汇总可否保留所需信息。

    提交内容
    粒度变化及字段血缘图
    怎样算完成
    • 区分可相加指标和比率
    • 记录被第一阶段丢弃的信息

    练到的知识点分组粒度

  4. 作业 04原理问答

    “未参与任何活动”可能指没有报名行、没有有效报名或已全部取消。为三种语义各准备包含 NULL 状态的样本,审查否定条件。

    提交内容
    语义定义与输入矩阵
    怎样算完成
    • 空集合有专门样本
    • 不能只比较查询文本形状

    练到的知识点NOT IN 与 NULL

综合应用

  1. 作业 05排错实验

    在本地 MySQL 建立用户和黑名单表,黑名单包含 NULL。分别执行存在性和否定成员查询,记录结果与逐行逻辑判断。

    提交内容
    SQL 脚本与逻辑表
    怎样算完成
    • 包含空黑名单和重复项
    • 不以表面语法相似判断等价

    练到的知识点EXISTS · NOT IN 与 NULL

  2. 作业 06代码推演

    报表用标量子查询取商户当前等级,再以 CTE 分层计算月统计。加入等级表零行、多行和两个同名 CTE 作用域,分析失败或解析位置。

    提交内容
    最小查询与作用域图
    怎样算完成
    • 区分标量结果基数和空值
    • 标明 CTE 可见范围

    练到的知识点标量子查询 · CTE 作用域

  3. 作业 07编程实践

    实现本地商户月报:订单事实、退款事实分开保存,输出成交与净额两套口径;用独立小型 Java 汇总器核对自拟数据。

    提交内容
    DDL、SQL、Java 校验器
    怎样算完成
    • 有跨月退款和无订单商户
    • 两种实现以同一口径比较

    练到的知识点分组粒度 · COUNT 与 SUM · WHERE 与 HAVING · ONLY_FULL_GROUP_BY · EXISTS · NOT IN 与 NULL · 标量子查询 · CTE 作用域

  4. 作业 08编程实践

    在本地 MySQL 做小型性质实验:对活动表插入一条无关报名和一条含 NULL 的记录,观察自选报表是否按其声明语义改变。

    提交内容
    SQL 与前后对照报告
    怎样算完成
    • 预先写出需保持的业务性质
    • 记录模式及查询执行范围

    练到的知识点分组粒度 · NOT IN 与 NULL

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    月报查询加入会员标签后运行变慢且金额变化。制定先核对语义再检查性能的调查步骤,提出 CTE 使用范围的评审标准。

    提交内容
    排查顺序与验证用例
    怎样算完成
    • 基数变化有可量化证据
    • 不假定 CTE 自动提供性能隔离

    练到的知识点分组粒度 · WHERE 与 HAVING · EXISTS · CTE 作用域

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    将固定月报改成滚动七日活跃用户率,历史报名可能晚到。探究分组结果与“未参与”判断需要怎样表达观察截止时点。

    提交内容
    时间口径与研究假设
    怎样算完成
    • 区分事件时间和入库时间
    • 包含可修订报表需求

    练到的知识点分组粒度 · NOT IN 与 NULL

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗