🏠 总目录📚 本教程 07 · 窗口函数 ← →
📑 本页目录(点开跳转)

07 · 窗口函数:不塌行的聚合

⏱ 70 分钟 | ⭐ GROUP BY 把十行压成一行;窗口函数让十行还是十行,但每一行都多知道一件关于自己那一组的事


🎯 一句话

OVER (…) 是「给这一行开一扇窗,看看窗里那些行」——算完之后行还在。 所有需要「这一行 vs 它所在的组」的问题(排第几、比上一次涨了多少、占本组多少),都是这一个语法。


🕳️ 一、这一章的地基,站内一块都没有

⭐ 这是本板块最硬的一条立项理由,值得先说清楚。

全站 15 个板块、392 页正文里,窗口函数 / PARTITION BY / ROW_NUMBER / RANK() / DENSE_RANK / LAG( / LEAD( —— 命中数全部是 0。

唯一一处 OVER ( 出现在 模型上线之后 · 04 训练推理一致性: 那里把「训练侧 Spark SQL 写的 AVG(price) OVER (last 7 days)」和「线上 Java 服务写的 sum/count 最近7天」 并排放着,用来说明两套实现看起来一样、其实对不齐。 ⭐ 那是一段有意写成示意的对照伪代码,不是一条可跑的查询 —— 它要表达的东西也不需要可跑。

结论不是「站内写错了」,而是「站内从来没有真正讲过窗口函数」。

⚠️ 而至少三处的正题都在等它:

站内哪里 它要什么
推荐算法 07 特征工程 「统计特征要同时喂 ctr_1h / ctr_1d / ctr_7d / ctr_30d 多个时间窗口」—— 只给结论不给算法
数据这一关 14 数据泄漏的七种来源 「最隐蔽的一种时间穿越:SQL 对每一行样本用的都是同一个固定窗口,正确写法必须让窗口跟着样本时间走」
数据这一关 18 特征存储 point-in-time:「对每一行训练样本,取该样本事件时间之前最后一次可见的特征值」

这三条要的都是「每一行有自己的窗口」,那正是 OVER 的定义。 本章讲窗口函数本身,⭐ 窗口的边界怎么划(帧)整个让给下一章。


🧩 二、三件套:OVER / PARTITION BY / ORDER BY

语法只有一个形状:

函数(参数) OVER ( PARTITION BY 分组列 ORDER BY 排序列 )

三个部件各管一件事,都可以省略:

部件 管什么 省略了会怎样
OVER (…) 声明「这是个窗口函数」 没有它 SUM(x) 就是普通聚合,会塌行
PARTITION BY 把行分成若干组,每组各开各的窗 整张表算一组
ORDER BY 组内的顺序 排名类函数会失去意义;⚠️ 聚合类函数的默认帧会变,这是下一章的正题

⚠️ 窗口里的 ORDER BY 和查询末尾的 ORDER BY 是两个东西:前者决定「窗里怎么排」,后者决定「结果怎么给你」。两者可以完全不同。


📊 三、和 GROUP BY 的根本差别:塌不塌行

同一张表、同一个 AVG,两种写法:

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs (model TEXT, dt TEXT, auc REAL)")
db.executemany("INSERT INTO runs VALUES (?,?,?)", [
    ("rank_v3", "2026-03-01", 0.812), ("rank_v3", "2026-03-08", 0.831),
    ("rank_v3", "2026-03-15", 0.826), ("rank_v3", "2026-03-22", 0.831),
    ("recall_v1", "2026-03-01", 0.774), ("recall_v1", "2026-03-08", 0.769),
    ("recall_v1", "2026-03-15", 0.802),
])

print("原表行数 :", db.execute("SELECT COUNT(*) FROM runs").fetchone()[0])

print("\n-- GROUP BY:七行塌成两行 --")
for r in db.execute("""
    SELECT model, COUNT(*) AS n, ROUND(AVG(auc), 4) AS avg_auc
    FROM runs GROUP BY model ORDER BY model"""):
    print("   ", r)

print("\n-- 窗口函数:七行还是七行,每行多知道自己组里的情况 --")
for r in db.execute("""
    SELECT model, dt, auc,
           COUNT(*)             OVER (PARTITION BY model)      AS n,
           ROUND(AVG(auc)       OVER (PARTITION BY model), 4)   AS avg_auc,
           ROUND(auc - AVG(auc) OVER (PARTITION BY model), 4)   AS diff
    FROM runs ORDER BY model, dt"""):
    print("   ", r)

实测输出:

对照

原表行数 : 7

-- GROUP BY:七行塌成两行 --

('rank_v3', 4, 0.825)

('recall_v1', 3, 0.7817)

-- 窗口函数:七行还是七行,每行多知道自己组里的情况 --

('rank_v3', '2026-03-01', 0.812, 4, 0.825, -0.013)

('rank_v3', '2026-03-08', 0.831, 4, 0.825, 0.006)

('rank_v3', '2026-03-15', 0.826, 4, 0.825, 0.001)

('rank_v3', '2026-03-22', 0.831, 4, 0.825, 0.006)

('recall_v1', '2026-03-01', 0.774, 3, 0.7817, -0.0077)

('recall_v1', '2026-03-08', 0.769, 3, 0.7817, -0.0127)

('recall_v1', '2026-03-15', 0.802, 3, 0.7817, 0.0203)

⭐ 注意最后那一列 diff:它同时用到了「这一行的 auc」和「这一组的均值」。 GROUP BY 写不出这一列 —— 聚合完之后,明细行已经不存在了,你没有 auc 可以减。

GROUP BY 窗口函数
输出行数 每组一行(7 → 2) 和输入一样(7 → 7)
还能看到明细吗 ❌ 没了 ✅ 在
典型问题 「每个模型平均多少」 ⭐ 「这一次比本模型平均高多少 / 排第几 / 占多少」
想要两者 要么 GROUP BY 之后再连回明细表,要么…… ⭐ 一句 OVER (PARTITION BY …)

⭐ 判据一句话:问题里出现「这一行 vs 它那一组」,就是窗口函数。 出现「每组一行的汇总」,就是 GROUP BY(那是 03 章)。


🥇 四、排名三兄弟:区别全在并列上

不并列的时候三个函数结果完全一样,所以必须拿有并列值的数据来看:

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs (model TEXT, dt TEXT, auc REAL)")
db.executemany("INSERT INTO runs VALUES (?,?,?)", [
    ("rank_v3", "2026-03-01", 0.812), ("rank_v3", "2026-03-08", 0.831),
    ("rank_v3", "2026-03-15", 0.826), ("rank_v3", "2026-03-22", 0.831),  # ⭐ 0.831 出现两次
])

print("dt          auc     ROW_NUMBER  RANK  DENSE_RANK")
for r in db.execute("""
    SELECT dt, auc,
           ROW_NUMBER() OVER (ORDER BY auc DESC) AS rn,
           RANK()       OVER (ORDER BY auc DESC) AS rk,
           DENSE_RANK() OVER (ORDER BY auc DESC) AS dk
    FROM runs ORDER BY rn"""):
    print(f"{r[0]}  {r[1]:.3f}      {r[2]}          {r[3]}     {r[4]}")

print("\n三者各自的最大值 :", db.execute("""
    SELECT MAX(rn), MAX(rk), MAX(dk) FROM (
      SELECT ROW_NUMBER() OVER (ORDER BY auc DESC) rn,
             RANK()       OVER (ORDER BY auc DESC) rk,
             DENSE_RANK() OVER (ORDER BY auc DESC) dk
      FROM runs)""").fetchone())

print("\n⭐ 加一个唯一的决胜列,结果就定死了:")
for r in db.execute("""
    SELECT dt, ROW_NUMBER() OVER (ORDER BY auc DESC, dt DESC) rn FROM runs ORDER BY rn"""):
    print("   ", r)

实测输出:

并列时,三个排名函数给出的序号不同

同一组 AUC 的三种排名结果
日期AUCROW_NUMBERRANKDENSE_RANK
2026-03-080.831111
2026-03-220.831211
2026-03-150.826332
2026-03-010.812443

加一个唯一的决胜列后,顺序才被固定:2026-03-22、03-08、03-15、03-01。

函数 并列时 之后 最大值 ⭐ 什么时候用
ROW_NUMBER() 强行分先后(1, 2) 正常 +1 4 去重、每组取一条:它保证「每组恰好一个 1」
RANK() 同名次(1, 1) ⚠️ 跳号(下一个是 3) 4 「并列第一有两个」这种真实名次;跳号是特性不是 bug
DENSE_RANK() 同名次(1, 1) 不跳号(下一个是 2) 3 「一共有几档」—— ⭐ 它的最大值等于不同取值的个数

⚠️ ROW_NUMBER() 在并列时把谁排前面,SQL 语义不做承诺。 上面 ORDER BY auc DESC 里,两条 0.831 谁拿 1 号是数据库自己定的 —— 本机连跑两次都给了 03-08,但这只是本机这一版的行为,不是保证。 ⭐ 解法和 05 章的复合游标是同一件事:排序列末尾补一个唯一列当决胜。 加上 , dt DESC 之后,1 号变成 03-22 并且永远是它。

💀 这个坑最贵的形态是「每组取最新一条」的去重: 用 ROW_NUMBER() … ORDER BY updated_at DESC 取 rn = 1, 如果 updated_at 撞车(AI全栈 06b 实证过:批量导入、脚本回填一秒能写几百条), 你每次跑同一条 SQL 可能拿到不同的那一条 —— 而且它不报错,两次结果都"看起来对"。


🛑 读到这里可以停 —— 前半章讲完了(约 25 分钟)。 后半章还有:LAG / LEAD:和上一行、下一行比 · 窗口函数不能写在 WHERE 里 · 你其实已经在用「帧」了 回来的时候不用重读,直接从下一节接着看就行。


↔️ 五、LAG / LEAD:和上一行、下一行比

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs (model TEXT, dt TEXT, auc REAL)")
db.executemany("INSERT INTO runs VALUES (?,?,?)", [
    ("rank_v3", "2026-03-01", 0.812), ("rank_v3", "2026-03-08", 0.831),
    ("rank_v3", "2026-03-15", 0.826), ("rank_v3", "2026-03-22", 0.831),
    ("recall_v1", "2026-03-01", 0.774), ("recall_v1", "2026-03-08", 0.769),
    ("recall_v1", "2026-03-15", 0.802),
])

q = """
SELECT model, dt, auc,
       LAG(auc)  OVER (PARTITION BY model ORDER BY dt) AS prev_auc,
       ROUND(auc - LAG(auc) OVER (PARTITION BY model ORDER BY dt), 4) AS delta
FROM runs ORDER BY model, dt
"""
for r in db.execute(q):
    print("  ", r)

print("\n⭐ 只看比上一次掉了的那几次重训:")
for r in db.execute(f"WITH x AS ({q}) SELECT model, dt, delta FROM x WHERE delta < 0"):
    print("  ", r)

print("\n每个 model 第一行的 prev_auc :",
      db.execute(f"SELECT COUNT(*) FROM ({q}) WHERE prev_auc IS NULL").fetchone()[0], "个 NULL")

print("\nLAG 的第三个参数可以给默认值(第一行不再是 NULL):")
for r in db.execute("""
    SELECT model, dt, LAG(auc, 1, auc) OVER (PARTITION BY model ORDER BY dt) AS prev_or_self
    FROM runs ORDER BY model, dt LIMIT 3"""):
    print("  ", r)

实测输出:

模型内部按日期比较
model日期aucprev_auc变化
rank_v32026-03-010.812NULLNULL
rank_v32026-03-080.8310.8120.019
rank_v32026-03-150.8260.831-0.005
rank_v32026-03-220.8310.8260.005
recall_v12026-03-010.774NULLNULL
recall_v12026-03-080.7690.774-0.005
recall_v12026-03-150.8020.7690.033

⭐ 只看比上一次掉了的那几次重训:

只保留下降的记录
model日期下降量
rank_v32026-03-15-0.005
recall_v12026-03-08-0.005

每个 model 第一行的 prev_auc : 2 个 NULL

LAG 的第三个参数可以给默认值(第一行不再是 NULL):

第一行的默认值示例
model日期带默认值的 prev_auc
rank_v32026-03-010.812
rank_v32026-03-080.812
rank_v32026-03-150.831

三件事:

  1. ⭐ LAG(x) 取的是「同一个分区里、按窗口 ORDER BY 排在前一行」的 x。 换成 LEAD 就是后一行。 每个分区各自从头开始 —— 所以 recall_v1 的第一行不会去拿 rank_v3 的最后一行。
  2. ⚠️ 每个分区的第一行必然是 NULL(实测 2 个)。于是 WHERE delta < 0 会把这两行静默丢掉 —— NULL < 0 的结果不是「假」,是「不知道」,而 WHERE 只放行「真」(这是 04 章的正题)。 ⭐ 这次丢掉是对的(第一次重训没有"上一次"可比),但你必须是知道了才让它丢。
  3. LAG(auc, 1, auc) 的第三个参数是取不到时的默认值。上面拿「自己」当默认值,于是第一行的环比是 0 而不是 NULL。 ⚠️ 这不总是更好:把"没有上一次"伪装成"没有变化",在做告警时是危险的。

🚦 六、窗口函数不能写在 WHERE 里

这是初学窗口函数百分之百会撞的一堵墙,而它的解释就是 01 章那张求值顺序图:

结果对照

FROM→WHERE→GROUP BY→HAVING→⭐ 窗口函数→SELECT→ORDER BY→LIMIT

⭐ 窗口函数是在 WHERE / GROUP BY / HAVING 全部做完之后才求值的。 WHERE 执行的那一刻,ROW_NUMBER() 还不存在,所以它引用不了。

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs (model TEXT, dt TEXT, auc REAL)")
db.executemany("INSERT INTO runs VALUES (?,?,?)", [
    ("rank_v3", "2026-03-01", 0.812), ("rank_v3", "2026-03-08", 0.831),
    ("rank_v3", "2026-03-15", 0.826), ("rank_v3", "2026-03-22", 0.831),
    ("recall_v1", "2026-03-01", 0.774), ("recall_v1", "2026-03-08", 0.769),
    ("recall_v1", "2026-03-15", 0.802),
])

try:
    db.execute("""SELECT model, dt FROM runs
                  WHERE ROW_NUMBER() OVER (PARTITION BY model ORDER BY dt DESC) <= 2""").fetchall()
except Exception as e:
    print("❌ 写在 WHERE 里 :", type(e).__name__, "|", e)

try:
    db.execute("""SELECT model, dt,
                         ROW_NUMBER() OVER (PARTITION BY model ORDER BY dt DESC) AS rn
                  FROM runs WHERE rn <= 2""").fetchall()
except Exception as e:
    print("❌ 用别名过滤   :", type(e).__name__, "|", e)

print("\n✅ 先开窗再筛 —— 每个 model 最近 2 次:")
for r in db.execute("""
    WITH ranked AS (
      SELECT model, dt, auc,
             ROW_NUMBER() OVER (PARTITION BY model ORDER BY dt DESC) AS rn
      FROM runs
    )
    SELECT model, dt, auc FROM ranked WHERE rn <= 2 ORDER BY model, dt DESC"""):
    print("  ", r)

print("\n⭐ 聚合结果之上再开窗(每个 model 的均值,和它离总体的距离):")
for r in db.execute("""
    SELECT model,
           ROUND(AVG(auc), 4) AS m,
           ROUND(AVG(auc) - AVG(AVG(auc)) OVER (), 4) AS vs_all
    FROM runs GROUP BY model"""):
    print("  ", r)

实测输出:

对照

❌ 写在 WHERE 里 : OperationalError | misuse of window function ROW_NUMBER()

❌ 用别名过滤 : OperationalError | misuse of aliased window function rn

✅ 先开窗再筛 —— 每个 model 最近 2 次:

('rank_v3', '2026-03-22', 0.831)

('rank_v3', '2026-03-15', 0.826)

('recall_v1', '2026-03-15', 0.802)

('recall_v1', '2026-03-08', 0.769)

⭐ 聚合结果之上再开窗(每个 model 的均值,和它离总体的距离):

('rank_v3', 0.825, 0.0217)

('recall_v1', 0.7817, -0.0217)

⭐ 「组内 Top-N」的标准写法就是这个形状:CTE 里开窗,CTE 外面筛 rn <= N。 06 章那个 WITH 在这里不是为了好看,是唯一的办法。

⚠️ 顺带一个真会咬人的推论:WHERE 里的过滤会改变窗口看到的行集。 「先过滤再排名」和「先排名再过滤」是两个不同的问题,而它们的写法差别只是那句 WHERE 写在 CTE 里面还是外面:

想要 写法
只在 3 月的记录里排名次 ⭐ WHERE 写在 CTE 里面(排名之前就把别的月份剔掉)
在全部记录里排名次,只是最后显示 3 月的 ⭐ WHERE 写在 CTE 外面(名次是全量算出来的)

💀 这两条查出来的 rn 完全不同,而两条都不报错。 拿错了,看板上的「第几名」就是错的。

最后那段 AVG(AVG(auc)) OVER () 不是笔误:里层的 AVG 是 GROUP BY 的聚合,外层的 AVG(...) OVER () 是在聚合结果之上再开窗。它能成立,正是因为窗口函数排在 GROUP BY 之后。


🪟 七、你其实已经在用「帧」了

上面所有例子一句帧子句都没写,但它们全都有帧 —— 数据库替你用了默认的那个。

⭐ 下一章专讲这件事:ROWS 与 RANGE 的差别、BETWEEN … PRECEDING AND CURRENT ROW 怎么写、 以及那个默认帧到底默认成了什么。


🔗 这一章连到哪里

相关的地方 为什么
08-窗口帧.html ⭐ 下一章,本章刻意欠着的那一半:所有例子都没写帧,但帧一直在起作用。数据有缺口时「最近 3 行」和「最近 3 天」会给出不同的数
03-聚合与分组.html 塌行的那一半。⭐ 判据:「每组一行的汇总」用 GROUP BY,「这一行 vs 它那一组」用窗口函数
01-一条查询是怎么跑的.html 「窗口函数为什么不能写在 WHERE 里」的完整解释就是那张求值顺序图 —— 它排在 WHERE/GROUP BY/HAVING 之后
06-CTE与递归查询.html 组内 Top-N 必须「CTE 里开窗、CTE 外筛选」,这里 CTE 不是为了好看,是唯一的办法
04-NULL与三值逻辑.html LAG 在每个分区第一行给 NULL,于是 WHERE delta < 0 会静默丢掉那些行 —— 丢得对,但你得知道它在丢
AI全栈 06b 列表接口与分页 ⭐ 那一章实证了「排序列大量撞车是常态」(批量导入一秒几百条)。ROW_NUMBER() 取每组第一条时,这正是让结果不稳定的原因
推荐算法 07 特征工程 它要求「统计特征同时喂 ctr_1h / 1d / 7d / 30d」却不给算法 —— 算法就是本章加下一章的帧
模型上线之后 04 训练推理一致性 那里用一段对照伪代码说明「同一个特征两套实现会对不齐」;⭐ 本章补上训练侧那一半到底该怎么写

✅ 检查点

  1. 窗口函数和 GROUP BY 最根本的差别是什么?实测那张表 7 行,两种写法各输出几行?
  2. diff = auc - AVG(auc) OVER (PARTITION BY model) 这一列,为什么 GROUP BY 写不出来?
  3. OVER 三个部件各管什么?窗口里的 ORDER BY 和查询末尾的 ORDER BY 是一回事吗?
  4. 四行数据里 0.831 出现两次,ROW_NUMBER / RANK / DENSE_RANK 分别给出哪四个数?三者的最大值各是多少?
  5. 什么时候该用 ROW_NUMBER,什么时候该用 DENSE_RANK?
  6. ROW_NUMBER() 遇到并列时把谁排前面?怎么让它确定下来?这和游标分页里的哪个做法是同一件事?
  7. LAG 在每个分区的第一行返回什么?实测有几个?WHERE delta < 0 会怎么处理这些行、为什么?
  8. 为什么 WHERE ROW_NUMBER() OVER (…) <= 2 会报错?报错原文是什么?正确写法是什么形状?
  9. 同一句 WHERE,写在 CTE 里面和外面,算出来的 rn 为什么不同?哪种情况该写在里面?
  10. AVG(AVG(auc)) OVER () 为什么不是笔误?它能成立靠的是什么顺序?
👀 答案
  1. 塌不塌行。GROUP BY 每组输出一行(7 行 → 2 行),窗口函数输出行数和输入一样(7 行 → 7 行),只是每行多几列关于自己那一组的信息。
  2. 因为它同时需要「这一行的 auc」和「这一组的均值」。GROUP BY 聚合完之后明细行已经不存在了,没有 auc 可以拿来相减。
  3. OVER (…) 声明这是窗口函数(没它 SUM 就会塌行);PARTITION BY 分组,每组各开各的窗(省略则整表一组);窗口内 ORDER BY 定组内顺序。⚠️ 不是一回事:前者决定「窗里怎么排」,后者决定「结果怎么给你」,两者可以完全不同。
  4. ROW_NUMBER → 1, 2, 3, 4;RANK → 1, 1, 3, 4(并列后跳号);DENSE_RANK → 1, 1, 2, 3(不跳号)。最大值分别是 4 / 4 / 3。
  5. 去重、每组取一条用 ROW_NUMBER(它保证每组恰好一个 1);「一共有几档」用 DENSE_RANK(它的最大值等于不同取值的个数);「并列第一有两个」这种真实名次用 RANK。
  6. SQL 语义不做承诺,谁在前由数据库决定(本机两次都给了 03-08,但那只是本机这一版的行为)。解法是在排序列末尾补一个唯一列当决胜:加 , dt DESC 之后 1 号永远是 03-22。这和 05 章/AI全栈 06b的复合游标末尾要带唯一列是同一件事。
  7. 返回 NULL,实测 2 个(两个分区各一个)。WHERE delta < 0 会把它们静默丢掉,因为 NULL < 0 的结果是「不知道」而不是「假」,而 WHERE 只放行「真」。这次丢掉是对的(第一次重训没有"上一次"),但必须是知道了才让它丢。
  8. 因为窗口函数排在 FROM → WHERE → GROUP BY → HAVING 之后求值 —— WHERE 执行时它还不存在。实测报错:misuse of window function ROW_NUMBER();换成用别名过滤是 misuse of aliased window function rn。正确形状:CTE 里开窗,CTE 外面 WHERE rn <= N。
  9. 因为 WHERE 在窗口函数之前执行,写在 CTE 里面等于先把行剔掉再排名,窗口看到的行集不同。「只在 3 月的记录里排名次」→ 写在里面;「在全量里排名次、只显示 3 月」→ 写在外面。💀 两条都不报错,拿错了看板上的名次就是错的。
  10. 里层 AVG(auc) 是 GROUP BY 的聚合,外层 AVG(…) OVER () 是在聚合结果之上再开窗。它能成立靠的正是窗口函数排在 GROUP BY 之后这个顺序。实测两组均值 0.825 / 0.7817,离总体各 +0.0217 / −0.0217。

🛑 可以停在这里

⚡ 走神救援

⭐ GROUP BY 把十行压成一行;窗口函数让十行还是十行,但每行都多知道一件关于自己那一组的事。

⭐ 判据:问题里出现「这一行 vs 它那一组」就是窗口函数,出现「每组一行的汇总」就是 GROUP BY。像「这次的 auc 比本模型平均高多少」这一列,GROUP BY 根本写不出来——聚合完明细行就没了。

⚠️ 窗口里的 ORDER BY 和查询末尾的 ORDER BY 是两个东西。

🥇 排名三兄弟的区别全在并列上:ROW_NUMBER 不并列、RANK 并列且跳号(是特性不是 bug)、DENSE_RANK 并列不跳号。⭐ 去重和「每组取一条」只能用 ROW_NUMBER,因为只有它保证每组恰好一个 1。

⚠️ 并列时 ROW_NUMBER 把谁排前面,SQL 不做承诺——⭐ 解法是排序列末尾补一个唯一列当决胜,和游标分页的复合游标是同一件事。

↔️ LAG / LEAD 每个分区各自从头开始,⚠️ 每个分区第一行必然是 NULL,于是 WHERE delta < 0 会静默丢掉它们(NULL < 0 是「不知道」不是「假」)。⚠️ 而 LAG 的第三个参数(取不到时的默认值)把「没有上一次」伪装成「没有变化」,做告警时是危险的。

🚦 窗口函数不能写在 WHERE 里——因为按执行顺序,WHERE 跑的时候它还不存在。所以「组内 Top-N」的标准写法是 CTE 里开窗、CTE 外面筛,这里 CTE 不是为了好看,是唯一的办法。

⚠️ 推论会咬人:同一句 WHERE 写在 CTE 里面还是外面,算出来的名次完全不同——里面是「先剔掉再排名」,外面是「全量排名只显示一部分」,💀 两条都不报错。

下一节 👉 08-窗口帧.md

打卡记录保存在你的浏览器里,首页能看到总进度