汇总和明细是两种粒度:一个班一行 vs 一个学生一行,别指望一条 SQL 又当又立。
COUNT(DISTINCT 学生id) 数人,COUNT(成绩id) 数人次,两个数差三倍。
RANK 并列给同名次,ROW_NUMBER 硬排先后——成绩排名该用 RANK。
这是全书的最后一节。 教材把它拆成「模块概述、技术分析、实现过程、按班级汇总与明细的两种查法」, 后面还有一节「小结」。 「小结」不单独成篇,并进这一篇的最后。 这一节的技术点是「同一页面上两种粒度」—— 上面一张班级汇总表,下面一张学生明细表。 两种粒度要不要一条 SQL 出,是个真实的取舍。 沙箱没有 JSP,页面用表格输出复刻; 窗口函数在 SQLite 3.53 里可用,真跑——SQL Server 2005 起也有,写法一致。
一、汇总:一条 SQL 出班级平均分
| 列 | 怎么算 | 注意 |
|---|---|---|
| 班级 | tb_class.cname |
分组键 |
| 人数 | COUNT(DISTINCT s.id) |
不加 DISTINCT 就是人次 |
| 条数 | COUNT(sc.id) |
一人三科算三条 |
| 平均分 | AVG(sc.score) |
分母是行数,不是人数 |
COUNT(DISTINCT s.id) 和 COUNT(sc.id) 两个都要有。
前者是「人数」,后者是「人次」——4 个人 3 科,人数 4、人次 12。
AVG 的分母是「行数」不是「人数」。 所以缺考的学生会影响平均分——
如果某科他缺考没录,平均分是拿剩下的人算的,看起来偏高。
要不要把缺考当 0 分算,是业务口径问题,必须问清楚。
二、明细里的排名:窗口函数一次算完
| 函数 | 并列时给什么 | 适合什么 |
|---|---|---|
RANK() |
2、2、4 | 成绩排名(并列就该并列) |
DENSE_RANK() |
2、2、3 | 分档(不跳号) |
ROW_NUMBER() |
2、3、4 | 分页(必须唯一序号) |
RANK 和 ROW_NUMBER 的区别在并列。
两个人并列第 2,RANK 给 2、2、4,ROW_NUMBER 给 2、3、4。
成绩排名一般用 RANK——并列就该并列,硬排出个先后反而会被质疑。
但分页必须用 ROW_NUMBER——
因为分页要「每行一个不同的序号」,重复的序号会导致翻页时有的行出现两次。
同一个函数,两个场景,选错就出问题。
三、汇总和明细怎么放到一个页面
| 做法 | 优点 | 缺点 |
|---|---|---|
| 两次查询,汇总在上、明细在下 | 简单,SQL 各管一段 | 两次往返 |
一次查询带 GROUPING SETS |
一次往返 | SQL 复杂,代码难读 |
| 明细里带汇总列(窗口函数) | 一行里两个粒度都有 | 汇总值重复出现 |
教材用的是第一种,这一版也是。 理由是「两次查询之间数据可能变」这个问题,在这个场景下可以接受—— 班级平均分差几十毫秒没人看得出来。 但如果是「账户余额和交易明细」,两次查询就不行了—— 余额和明细对不上会被当成 bug。 那种场景必须一次查询,或者一个事务里两次。
四、学生只看自己:一个页面,一个条件
| 角色 | WHERE 加什么 |
|---|---|
| 管理员 | (不加) |
| 教师 | AND cname = (我带的班) |
| 学生 | AND sno = (会话里的学号) |
这一句里的学号从 Session 来,不从 URL 来。
这是全章讲了五遍的那件事,在这里最后一次强调。
同一个页面,管理员进来看到全级、教师看到全班、学生看到自己——
三者的差别只在 WHERE 那一段。 这就是「一个页面 + 数据范围」的全部含义。
五、全书收口:三个项目共用一套骨架
| 骨架 | 酒店(18) | 人事(19) | 成绩(20) |
|---|---|---|---|
| 表设计先行 | 房/客/入住 | 部门/员工/薪酬 | 班级/学生/成绩 |
| 约束兜底 | 房号唯一 | 工号唯一 | 成绩 0~100 |
| 分层 | 界面/业务/数据 | 同 | 同(多一层 JSP) |
| 事务边界 | 开房=改房态+插单 | 入职=员工+账号 | 录分=一批全成 |
| 权限 | 三角色菜单 | 三角色菜单 | 三角色 + 数据范围 |
| 验收 | 冒烟断言 | 八条断言 | 行数乘法+断言 |
三张表里最值得看的是「事务边界」那一行。 三个业务完全不同,但「哪些操作必须一起成功」这个判断是一模一样的思路。 这就是从项目里能带走的东西。 「权限」那一行也值得看: 前两个项目只做到「菜单按角色」, 第三个项目多了一层「数据范围」——因为第一次出现了「同一页面、不同数据」。 这不是设计变复杂了,是需求变复杂了。
六、最后:这一课到底学了什么
| 层次 | 内容 |
|---|---|
| 语言层 | T-SQL 的查询、函数、流程控制、游标 |
| 对象层 | 表、视图、存储过程、触发器、索引 |
| 机制层 | 事务、锁、隔离级别、权限、备份 |
| 工程层 | 分层、公共类、参数化、断言式验收 |
四层里,语言层最容易忘(几个月不写就生疏),工程层最难学(要靠项目攒)。 而中间两层是「一旦懂了就不会忘」的—— 因为它们讲的是「数据库为什么这样设计」,不是「语法怎么写」。 所以复习这一课,别背语法。 翻 14 章的索引、15 章的事务、20 章的那三张骨架表—— 这些换个数据库、换个语言,都还在。
七、动手 Demo:班级汇总、明细排名、三种数据范围
这一段跑三件事: 班级汇总(人数 vs 人次)、明细里的班内排名与全级排名、同一个查询在三种会话下看到的三份数据。 最后那一节是这一课的收口——四层知识、三个项目、一套骨架。
# ① 汇总:人数 vs 人次
sql = '''SELECT c.cname,
COUNT(DISTINCT s.id) AS 人数,
COUNT(sc.id) AS 条数,
ROUND(AVG(sc.score), 2) AS 平均分
FROM tb_class c
JOIN tb_student s ON s.class_id = c.id
JOIN tb_score sc ON sc.stu_id = s.id
GROUP BY c.id ORDER BY 平均分 DESC'''
# ② 明细 + 排名(窗口函数)
sql = '''SELECT cname, sname, score,
RANK() OVER (PARTITION BY cname ORDER BY score DESC) AS 班内,
RANK() OVER (ORDER BY score DESC) AS 全级
FROM v_score WHERE subject = '数学'
ORDER BY cname, 班内'''
# ③ 一个查询,三种数据范围
def scores(sess):
sql, args = BASE_SQL, ['数学']
if sess['role'] == '学生':
sql += ' AND sno = (SELECT sno FROM tb_student WHERE id = ?)'
args.append(sess['stu_id'])
elif sess['role'] == '教师':
sql += ' AND cname = (SELECT cname FROM tb_class WHERE id = ?)'
args.append(sess['class_id'])
return c.execute(sql, args).fetchall()
实跑输出:
--------------------------------------------------------------------
按班级汇总与明细:全书的收口
--------------------------------------------------------------------
① 汇总:一条 SQL 出班级平均分
--------------------------------------------------------------------
1> SELECT c.cname,
COUNT(DISTINCT s.id) AS 人数,
COUNT(sc.id) AS 条数,
ROUND(AVG(sc.score), 2) AS 平均分
FROM tb_class c JOIN tb_student s ON s.class_id = c.id JOIN tb_score sc ON sc.stu_id = s.id
GROUP BY c.id
ORDER BY 平均分 DESC
cname 人数 条数 平均分
-----------------------------------
2026级软件1班 3 9 84.78
2025级计算机1班 2 6 83.33
2026级计算机1班 4 12 82.33
2026级计算机2班 3 9 80.67
(4 行受影响)
COUNT(DISTINCT s.id) 和 COUNT(sc.id) 两个都要有。
前者是「人数」,后者是「人次」——4 个人 3 科,人数 4、人次 12。
AVG 的分母是「行数」不是「人数」。
所以缺考的学生会影响平均分——如果某科他缺考没录,平均分是拿剩下的人算的,看起来偏高。
要不要把缺考当 0 分算,是业务口径问题,必须问清楚。
② 明细里的排名:窗口函数一次算完
--------------------------------------------------------------------
1> SELECT cname,
sname,
score,
RANK() OVER (PARTITION BY cname ORDER BY score DESC) AS 班内排名,
RANK() OVER (ORDER BY score DESC) AS 全级排名
FROM v_score
WHERE subject = '数学'
ORDER BY cname, 班内排名
cname sname score 班内排名 全级排名
--------------------------------------------------
2025级计算机1班 孙志强 88 1 4
2025级计算机1班 马晓雯 79 2 8
2026级计算机1班 张一鸣 92 1 2
2026级计算机1班 李思彤 88 2 4
2026级计算机1班 赵雨欣 78 3 9
2026级计算机1班 王浩然 65 4 12
2026级计算机2班 陈子轩 96 1 1
2026级计算机2班 杨博文 82 2 7
2026级计算机2班 刘佳怡 74 3 10
2026级软件1班 黄梓涵 90 1 3
2026级软件1班 吴俊杰 85 2 6
2026级软件1班 徐梦琪 71 3 11
(12 行受影响)
! RANK() 是 SQL Server 2005 起就有的窗口函数;
SQLite 3.25 起才支持,更老的版本要用自连接数「比我高的人有几个」。
窗口函数一次扫表就能算出排名,比自连接快得多,也好读得多。
PARTITION BY 就是「分组」,ORDER BY 就是「组内按什么排」。
RANK 和 ROW_NUMBER 的区别在并列: 两个人并列第 2,RANK 给 2、2、4,ROW_NUMBER 给 2、3、4。
成绩排名一般用 RANK——并列就该并列,硬排出个先后反而会被质疑。
③ 汇总和明细怎么放到一个页面
--------------------------------------------------------------------
| 做法 | 优点 | 缺点 |
|---|---|---|
| 两次查询,汇总在上、明细在下 | 简单,SQL 各管一段 | 两次往返 |
| 一次查询带 GROUPING SETS | 一次往返 | SQL 复杂,代码难读 |
| 明细里带汇总列(窗口函数) | 一行里两个粒度都有 | 汇总值重复出现 |
教材用的是第一种,这一版也是。
理由是「两次查询之间数据可能变」这个问题,
在这个场景下可以接受——班级平均分差几十毫秒没人看得出来。
但如果是「账户余额和交易明细」,两次查询就不行了——余额和明细对不上会被当成 bug。
那种场景必须一次查询,或者一个事务里两次。
④ 学生只看自己:一个页面,一个条件
--------------------------------------------------------------------
1> SELECT subject, term, score FROM v_score WHERE sno = '20260101' ORDER BY subject
subject term score
---------------------------
数学 2026-2027-1 92
英语 2026-2027-1 79
语文 2026-2027-1 88
(3 行受影响)
-- 页面上这一句才是安全线:
-- WHERE sno = (Session 里的学号),绝不从 URL 取
这一句里的学号从 Session 来,不从 URL 来。 这是本章讲了五遍的那件事,在这里最后一次强调。
同一个页面,管理员进来看到全级、教师看到全班、学生看到自己——三者的差别只在 WHERE 那一段。
这就是「一个页面 + 数据范围」的全部含义。
⑤ 全书收口:三个项目共用一套骨架
--------------------------------------------------------------------
| 骨架 | 酒店(18) | 人事(19) | 成绩(20) |
|---|---|---|---|
| 表设计先行 | 房/客/入住的链 | 部门/员工/薪酬 | 班级/学生/成绩 |
| 约束兜底 | 房号唯一、价格>0 | 工号唯一、职级 1~10 | 成绩 0~100 |
| 分层 | 界面/业务/数据 | 同 | 同(多一层 JSP) |
| 事务边界 | 开房=改房态+插单 | 入职=插员工+插账号 | 录分=一批全成 |
| 权限 | 三角色菜单 | 三角色菜单 | 三角色 + 数据范围 |
| 验收 | 冒烟断言 | 八条断言 | 行数乘法+断言 |
三张表里最值得看的是「事务边界」那一行。
三个业务完全不同,但「哪些操作必须一起成功」这个判断是一模一样的思路。
这就是从项目里能带走的东西。
「权限」那一行也值得看: 前两个项目只做到「菜单按角色」,
第三个项目多了一层「数据范围」——因为第一次出现了「同一页面、不同数据」。
这不是设计变复杂了,是需求变复杂了。
⑥ 最后:这一课到底学了什么
--------------------------------------------------------------------
| 层次 | 内容 |
|---|---|
| 语言层 | T-SQL 的查询、函数、流程控制、游标 |
| 对象层 | 表、视图、存储过程、触发器、索引 |
| 机制层 | 事务、锁、隔离级别、权限、备份 |
| 工程层 | 分层、公共类、参数化、断言式验收 |
四层里,语言层最容易忘(几个月不写就生疏),工程层最难学(要靠项目攒)。
而中间两层是「一旦懂了就不会忘」的——因为它们讲的是「数据库为什么这样设计」,不是「语法怎么写」。
所以复习这一课,别背语法。
翻 14 章的索引、15 章的事务、20 章的那三张骨架表——这些换个数据库、换个语言,都还在。
汇总表里「人数」和「条数」差三倍(4 人 12 条)——因为每人三科。
这两个数经常被混用,混用的结果是「这个班有 12 个人」这种荒谬结论。
排名表里出现了两个第 1(班内排名),
因为那是一个班里的第一名;全级排名里又出现了两个并列第 4(88 分那两位)。
RANK 给的就是这种「并列同名次、后面跳号」的结果。
三种数据范围那一段,代码只有六行,但它挡住了整个系统的数据泄露。
如果这段写错,学生能看到全级成绩——而页面上一切正常,没有任何报错。
30 秒实验
- 把汇总里的
DISTINCT去掉,看「人数」变成几。 会变成人次(12),这就是「人数」「人次」混用的后果。 - 把
RANK()换成ROW_NUMBER(),看并列的两个人变成什么。 会变成 4 和 5,不再并列。 - 想一想:如果把「三种数据范围」那段删掉,学生登录后会看到什么? 全级数学成绩。 而页面上看起来完全正常。
常见误区
- 「一个查询又出汇总又出明细最好」。 两种粒度硬塞一条 SQL,SQL 会难读到没人敢改。 两次查询更清楚。
- 「
COUNT(*)就够了」。 它数的是行(人次),不是人。 要人数得COUNT(DISTINCT 学生id)。 - 「排名用
ROW_NUMBER更整齐」。 并列会被硬拆成先后,家长会质疑。 成绩排名用RANK。
自测三问(点开对答案)
1. 汇总里「人数」和「条数」为什么不一样? —— **每人有三科成绩,`COUNT(sc.id)` 数的是行(人次)。** **要人数得 `COUNT(DISTINCT s.id)`。** 2. `RANK` 和 `ROW_NUMBER` 什么时候各用哪个? —— **成绩排名用 `RANK`(并列同名次)**, **分页用 `ROW_NUMBER`(必须每行不同序号,否则翻页会重复)。** 3. 三个项目共用的骨架是哪几条? —— **表设计先行、约束兜底、分层、事务边界、权限按角色、断言式验收。** **换语言换框架,这六条不变。**全书到这里就完了。 二十章、一百篇、一百个 Demo,从一个库怎么建,到一个完整系统怎么验收。 下次翻这一课时,别从头读——先看 14 章的索引、15 章的事务、20 章的那三张骨架表。
- ☐ (全书完)