📑 本页目录(点开跳转)
03 · 聚合与分组:把那张 calls 表的看板真写出来
⏱ 140 分钟 | ⭐ COUNT(*) 是 3,COUNT(city) 是 2——差的那一行去哪了
🎯 一句话
所有聚合函数都当 NULL 不存在,只有 COUNT(*) 数的是「行」。
这一条外加「GROUP BY 是一道单向门」(01 章),就能解释本章几乎所有「数怎么对不上」。
🚦 一、差的那一行去哪了
三行数据,city 缺一个、score 缺一个。把八个聚合一次问出来:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE u(id INT, city TEXT, score REAL)")
db.executemany("INSERT INTO u VALUES(?,?,?)",
[(1, "BJ", 1.0), (2, "SH", None), (3, None, 1.0)])
print("表里一共 3 行,city 缺 1 个,score 缺 1 个")
for sql in ["COUNT(*)", "COUNT(city)", "COUNT(score)", "COUNT(DISTINCT city)",
"SUM(score)", "AVG(score)", "MIN(score)", "MAX(score)"]:
print(f" {sql:22s} =", db.execute(f"SELECT {sql} FROM u").fetchone()[0])
print()
print("⭐ AVG 和「总和除以总行数」不是一回事:")
print(" AVG(score) =", db.execute("SELECT AVG(score) FROM u").fetchone()[0])
print(" SUM(score)/COUNT(*) =", db.execute("SELECT SUM(score)*1.0/COUNT(*) FROM u").fetchone()[0])
print(" SUM(score)/COUNT(score)=", db.execute("SELECT SUM(score)*1.0/COUNT(score) FROM u").fetchone()[0])
print()
print("把缺失当 0 算(如果这才是你要的):")
print(" AVG(COALESCE(score,0)) =", db.execute("SELECT AVG(COALESCE(score,0)) FROM u").fetchone()[0])
实跑输出:
表里一共 3 行,city 缺 1 个,score 缺 1 个
COUNT(*) = 3
COUNT(city) = 2
COUNT(score) = 2
COUNT(DISTINCT city) = 2
SUM(score) = 2.0
AVG(score) = 1.0
MIN(score) = 1.0
MAX(score) = 1.0
⭐ AVG 和「总和除以总行数」不是一回事:
AVG(score) = 1.0
SUM(score)/COUNT(*) = 0.6666666666666666
SUM(score)/COUNT(score)= 1.0
把缺失当 0 算(如果这才是你要的):
AVG(COALESCE(score,0)) = 0.6666666666666666
⭐ COUNT(*) 数的是【行】,其余所有聚合数的是【值】。
| 你写的 | 它到底在数什么 | 上面为什么是这个数 |
|---|---|---|
COUNT(*) |
行,不看任何列 | 表里 3 行 → 3 |
COUNT(city) |
city 不是 NULL 的行 |
第 3 行 city 是 NULL → 2 |
COUNT(DISTINCT city) |
不同的非 NULL 值 |
BJ / SH,NULL 不算一种 → 2 |
SUM(score) |
非 NULL 的值加起来 |
1.0 + 1.0 → 2.0 |
AVG(score) |
分母是 COUNT(score),不是 COUNT(*) |
2.0 / 2 → 1.0 |
MIN / MAX |
非 NULL 的值里取极值 |
只剩两个 1.0 → 1.0 |
💀 AVG 那一行是本章最贵的一条。AVG(score) = 1.0 读起来像「这批人的平均分是 1.0」,
但它真正的意思是「填了分的那些人的平均分是 1.0」。
到底哪个才是你要的,取决于「没填分」意味着什么 —— 是「他没考」(该排除,AVG 对)
还是「他考了 0 分但没录进来」(该算 0,AVG(COALESCE(score,0)) = 0.6667 才对)。
⚠️ SQL 不会替你决定,它只是默默选了前者。
COUNT(*) / COUNT(1) 和一列全空的时候
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE calls(user_id TEXT, feature TEXT, cents REAL, ms INT)")
db.executemany("INSERT INTO calls VALUES(?,?,?,?)", [
("u1", "chat", 0.056, 120),
("u1", "summary", 2.64, 900),
("u2", "summary", 10.2, 3100),
("u2", "chat", 0.03, None), # 这次调用失败了,耗时没记上
("u3", "translate", 0.40, 450),
])
print("COUNT(*) / COUNT(1) / COUNT(常量) 完全一样,只有 COUNT(列) 跳过 NULL:")
for e in ["COUNT(*)", "COUNT(1)", "COUNT('x')", "COUNT(ms)"]:
print(f" {e:12s} =", db.execute(f"SELECT {e} FROM calls").fetchone()[0])
print()
print("⚠️ 一列【全是 NULL】时,SUM 返回的不是 0:")
db.execute("CREATE TABLE allnull(v REAL)")
db.executemany("INSERT INTO allnull VALUES(?)", [(None,), (None,)])
print(" COUNT(*), COUNT(v), SUM(v), AVG(v) =",
db.execute("SELECT COUNT(*), COUNT(v), SUM(v), AVG(v) FROM allnull").fetchone())
print(" SUM(v) + 100 =", db.execute("SELECT SUM(v)+100 FROM allnull").fetchone()[0])
实跑输出:
COUNT(*) / COUNT(1) / COUNT(常量) 完全一样,只有 COUNT(列) 跳过 NULL:
COUNT(*) = 5
COUNT(1) = 5
COUNT('x') = 5
COUNT(ms) = 4
⚠️ 一列【全是 NULL】时,SUM 返回的不是 0:
COUNT(*), COUNT(v), SUM(v), AVG(v) = (2, 0, None, None)
SUM(v) + 100 = None
⭐ COUNT(1) 不比 COUNT(*) 快 —— 这是一个流传很广的迷信。
两者语义完全相同(都是「数行」),主流数据库都会把它们优化成同一件事。
⚠️ 真正影响 COUNT(*) 成本的是过滤条件命不命中索引,
那是 AI 全栈 · 06b 列表接口与分页 第五节的题目(实测慢三千多倍),本教程不重讲。
💀 SUM 在「一个值都没有」时返回 NULL 而不是 0,于是 SUM(v) + 100 得到 None。
一条本该显示 100 的指标,就这样变成了空白格。
⭐ 判据:任何要拿去做算术、或者要显示给人看的 SUM,都套一层 COALESCE(SUM(x), 0)。
(为什么 NULL + 100 是 NULL 而不是 100,是 04 章的正题。)
🧩 二、GROUP BY 之后,SELECT 里能放什么
01 章那句「GROUP BY 是一道单向门」,门后最具体的后果就是你不能再随便写列名了。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE calls(user_id TEXT, feature TEXT, model TEXT, cents REAL)")
db.executemany("INSERT INTO calls VALUES(?,?,?,?)", [
("u1", "chat", "small", 0.056),
("u1", "summary", "big", 2.64),
("u2", "summary", "big", 10.2),
("u2", "chat", "small", 0.03),
("u3", "translate", "big", 0.40),
])
print("① 分组键 + 聚合(合法,三个库都对):")
for r in db.execute("SELECT feature, COUNT(*), ROUND(SUM(cents),3) FROM calls GROUP BY feature"):
print(" ", r)
print()
print("② SELECT 里放了一个既不是分组键、也没被聚合的列 —— SQLite 照跑不误:")
for r in db.execute("SELECT feature, user_id, COUNT(*) FROM calls GROUP BY feature"):
print(" ", r)
print(" ⚠️ user_id 是组里【随便挑的一行】的值,不是「这个 feature 的用户」")
print()
print("③ 想知道「这一组里有几个不同用户」,要问的是这个:")
for r in db.execute("SELECT feature, COUNT(DISTINCT user_id), COUNT(*) FROM calls GROUP BY feature"):
print(" ", r)
print()
print("④ SQLite 的一个特例:MAX/MIN 会让同一行的裸列跟着走(其他库不保证)")
for r in db.execute("SELECT feature, user_id, MAX(cents) FROM calls GROUP BY feature"):
print(" ", r)
实跑输出:
① 分组键 + 聚合(合法,三个库都对):
('chat', 2, 0.086)
('summary', 2, 12.84)
('translate', 1, 0.4)
② SELECT 里放了一个既不是分组键、也没被聚合的列 —— SQLite 照跑不误:
('chat', 'u1', 2)
('summary', 'u1', 2)
('translate', 'u3', 1)
⚠️ user_id 是组里【随便挑的一行】的值,不是「这个 feature 的用户」
③ 想知道「这一组里有几个不同用户」,要问的是这个:
('chat', 2, 2)
('summary', 2, 2)
('translate', 1, 1)
④ SQLite 的一个特例:MAX/MIN 会让同一行的裸列跟着走(其他库不保证)
('chat', 'u1', 0.056)
('summary', 'u2', 10.2)
('translate', 'u3', 0.4)
⭐ 判据(背下来):
GROUP BY之后,SELECT里的每一列要么是分组键,要么被聚合函数包着。 没有第三种。一列既不分组也不聚合,就叫裸列(bare column), 它在问一个没有答案的问题:「这一组的user_id是什么?」——组里有两个用户,凭什么是u1?
💀 ('chat', 'u1', 2) 这一行的危险在于它长得完全正常。
chat 这一组明明有 u1 和 u2 两个人,SQLite 挑了 u1 给你,不报错、不提示、不加注释。
报表上就写着「chat 功能的用户:u1,调用 2 次」——一半的用户当场蒸发了。
⚠️ 三个库的态度完全不同,这是一个典型的「本地跑通 ≠ 上线能跑」:
| 库 | 裸列会怎样 |
|---|---|
| SQLite | ⚠️ 放行,返回组里随便一行的值(上面的 u1) |
| MySQL | 看 ONLY_FULL_GROUP_BY 这个 SQL 模式:5.7 起默认开启会报错,关掉就变成 SQLite 那样放行 🗓️ 未实跑 —— sqlite 无此设置 |
| PostgreSQL | ❌ 直接拒绝(除非分组键已经是主键,那时其他列在逻辑上被唯一确定)🗓️ 未实跑 —— sqlite 行为不同 |
⭐ 第④条那个 SQLite 特例值得单独警惕:SELECT feature, user_id, MAX(cents) … GROUP BY feature
里的 user_id 会跟着 MAX 命中的那一行走(summary 组给出 u2,正是 10.2 那行的人)。
这看起来太好用了 —— 「每组花得最多的那个人是谁」一句就写出来了。
💀 但这是 SQLite 的私货,别的库不保证,换库就静默变错。
⭐ 「每组里最大/最新的那一整行」的可移植工具是窗口函数 ROW_NUMBER(),那是 07 章的正题。
🛑 读到这里可以停 —— 已经读了约 30 分钟。 后面还有(约 37 分钟):
WHERE筛行,HAVING筛组 —— 以及第三个选项 · 把那张calls表的看板真写出来 回来的时候不用重读,直接从下一节接着看就行。
📋 三、WHERE 筛行,HAVING 筛组 —— 以及第三个选项
01 章从执行顺序讲了它们为什么不同(WHERE 在第②步、HAVING 在第④步)。这一节看它们筛出来的东西差在哪。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE calls(user_id TEXT, feature TEXT, model TEXT, cents REAL)")
db.executemany("INSERT INTO calls VALUES(?,?,?,?)", [
("u1", "chat", "small", 0.056),
("u1", "summary", "big", 2.64),
("u2", "summary", "big", 10.2),
("u2", "chat", "small", 0.03),
("u3", "translate", "big", 0.40),
])
print("① WHERE 筛【行】—— 先扔掉 small 的调用,再分组:")
for r in db.execute("SELECT feature, ROUND(SUM(cents),3) FROM calls "
"WHERE model='big' GROUP BY feature"):
print(" ", r)
print()
print("② HAVING 筛【组】—— 全部行都参与分组,再扔掉总额不够的组:")
for r in db.execute("SELECT feature, ROUND(SUM(cents),3) FROM calls "
"GROUP BY feature HAVING SUM(cents) > 1"):
print(" ", r)
print()
print("③ 两个都要(顺序:先 WHERE 后 HAVING):")
for r in db.execute("SELECT feature, ROUND(SUM(cents),3) FROM calls "
"WHERE model='big' GROUP BY feature HAVING SUM(cents) > 1"):
print(" ", r)
print()
print("④ ⚠️ 同一句话「只看 big 的花费」,写 WHERE 和写条件聚合结果不同:")
print(" WHERE model='big' 的 chat 总额 =",
db.execute("SELECT ROUND(SUM(cents),3) FROM calls "
"WHERE model='big' AND feature='chat'").fetchone()[0])
print(" 条件聚合(不筛行,只在求和时挑) =",
db.execute("SELECT ROUND(SUM(CASE WHEN model='big' THEN cents ELSE 0 END),3) "
"FROM calls WHERE feature='chat'").fetchone()[0])
print(" 两者的差别:前者【整行没了】,chat 这一组直接消失;后者【组还在】,只是值为 0")
print(" 分组版对比:")
for r in db.execute("SELECT feature, ROUND(SUM(cents),3) FROM calls WHERE model='big' GROUP BY feature"):
print(" WHERE 版 ", r)
for r in db.execute("SELECT feature, ROUND(SUM(CASE WHEN model='big' THEN cents ELSE 0 END),3) "
"FROM calls GROUP BY feature"):
print(" 条件聚合版", r)
实跑输出:
① WHERE 筛【行】—— 先扔掉 small 的调用,再分组:
('summary', 12.84)
('translate', 0.4)
② HAVING 筛【组】—— 全部行都参与分组,再扔掉总额不够的组:
('summary', 12.84)
③ 两个都要(顺序:先 WHERE 后 HAVING):
('summary', 12.84)
④ ⚠️ 同一句话「只看 big 的花费」,写 WHERE 和写条件聚合结果不同:
WHERE model='big' 的 chat 总额 = None
条件聚合(不筛行,只在求和时挑) = 0.0
两者的差别:前者【整行没了】,chat 这一组直接消失;后者【组还在】,只是值为 0
分组版对比:
WHERE 版 ('summary', 12.84)
WHERE 版 ('translate', 0.4)
条件聚合版 ('chat', 0.0)
条件聚合版 ('summary', 12.84)
条件聚合版 ('translate', 0.4)
⭐ 第④条是本节的正题。 同一句中文「只看大模型的花费」,两种写法给出两张不同形状的表:
| 写法 | chat 这一行 |
什么时候要它 |
|---|---|---|
WHERE model='big' |
⚠️ 整个消失(chat 没有 big 调用,行全被扔了,组就不存在了) | 「大模型的账单」—— 没花过就不该出现 |
SUM(CASE WHEN model='big' THEN cents ELSE 0 END) |
⭐ 还在,值是 0.0 | 「每个功能在大模型上花了多少」—— 0 也是一个答案 |
💀 这就是「看板上少了一行」最常见的来路:产品说「加个筛选,只看大模型」,
你顺手在 WHERE 里加了一条,整个 chat 功能从报表上消失了,
而看报表的人只会以为「chat 这个月没人用」。
⭐ 判据:想让某些行不参与计算,用【条件聚合】;想让某些行连同它所在的组一起消失,才用 WHERE。
这个 CASE WHEN 套在聚合函数里的写法叫条件聚合,是下一节看板的主力零件。
HAVING 不带 GROUP BY
⭐ 一个容易忘的合法写法:HAVING 可以单独出现,此时整张表被当成【一个组】判一次。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE calls(feature TEXT, cents REAL)")
db.executemany("INSERT INTO calls VALUES(?,?)", [
("chat", 0.056), ("summary", 2.64), ("summary", 10.2),
("chat", 0.03), ("translate", 0.40)])
print("HAVING 不带 GROUP BY —— 全表当【一个组】,判一次:")
print(" HAVING SUM(cents) > 1 ->", db.execute(
"SELECT COUNT(*), ROUND(SUM(cents),3) FROM calls HAVING SUM(cents) > 1").fetchall())
print(" HAVING SUM(cents) > 99 ->", db.execute(
"SELECT COUNT(*), ROUND(SUM(cents),3) FROM calls HAVING SUM(cents) > 99").fetchall())
实跑输出:
HAVING 不带 GROUP BY —— 全表当【一个组】,判一次:
HAVING SUM(cents) > 1 -> [(5, 13.326)]
HAVING SUM(cents) > 99 -> []
⭐ 判为真给你一行,判为假给你零行 —— 注意不是「一行 0」,是一行都没有。
这是第六节那个坑的第一个来源,也是写告警查询时很好用的一个形状:
「总额超了就返回一行,没超就什么都不返回」,调用方只要判断结果集空不空。
🧮 四、把那张 calls 表的看板真写出来
前面都是零件,这一节把它们装成一张真看板。八次模型调用,其中两次失败(一次耗时没记上、一次计费没记上):
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE calls(
id INTEGER PRIMARY KEY, user_id TEXT, feature TEXT,
model TEXT, cents REAL, ms INT, ok INT, dt TEXT)""")
db.executemany("INSERT INTO calls(user_id,feature,model,cents,ms,ok,dt) VALUES(?,?,?,?,?,?,?)", [
("u1", "chat", "small", 0.056, 120, 1, "2026-08-01"),
("u1", "summary", "big", 2.64, 900, 1, "2026-08-01"),
("u2", "summary", "big", 10.2, 3100, 1, "2026-08-02"),
("u2", "chat", "small", 0.03, None, 0, "2026-08-02"), # 失败,耗时没记上
("u3", "translate", "big", 0.40, 450, 1, "2026-08-02"),
("u3", "chat", "small", 0.02, 90, 1, "2026-08-03"),
("u1", "chat", "small", None, 110, 0, "2026-08-03"), # 失败,计费没记上
("u4", "summary", "big", 1.10, 700, 1, "2026-08-03"),
])
print("=== 按 feature 的看板(一条查询六个指标)===")
sql = """
SELECT feature,
COUNT(*) AS 调用数,
COUNT(DISTINCT user_id) AS 用户数,
ROUND(SUM(COALESCE(cents,0)), 3) AS 总花费,
SUM(CASE WHEN ok=0 THEN 1 ELSE 0 END) AS 失败数,
ROUND(AVG(CASE WHEN ok=0 THEN 1.0 ELSE 0.0 END)*100, 1) AS 失败率百分比,
COUNT(ms) AS 有耗时的行数
FROM calls
GROUP BY feature
ORDER BY 总花费 DESC
"""
for r in db.execute(sql):
print(" ", r)
print()
print("=== 同一件事用 FILTER 写(SQLite 3.30+ / Postgres 支持,MySQL 不支持)===")
for r in db.execute("""
SELECT feature,
COUNT(*) AS 调用数,
COUNT(*) FILTER (WHERE ok=0) AS 失败数,
ROUND(SUM(cents) FILTER (WHERE model='big'), 3) AS big花费
FROM calls GROUP BY feature ORDER BY feature"""):
print(" ", r)
print()
print("=== 占比:分母是全表,不是本组 ===")
for r in db.execute("""
SELECT feature,
ROUND(SUM(COALESCE(cents,0)), 3) AS 花费,
ROUND(SUM(COALESCE(cents,0)) * 100.0 /
(SELECT SUM(COALESCE(cents,0)) FROM calls), 1) AS 占比
FROM calls GROUP BY feature ORDER BY 花费 DESC"""):
print(" ", r)
print()
print("=== 多键分组:一行 = 一个 (dt, model) 组合 ===")
for r in db.execute("SELECT dt, model, COUNT(*), ROUND(SUM(COALESCE(cents,0)),3) "
"FROM calls GROUP BY dt, model ORDER BY dt, model"):
print(" ", r)
实跑输出:
=== 按 feature 的看板(一条查询六个指标)===
('summary', 3, 3, 13.94, 0, 0.0, 3)
('translate', 1, 1, 0.4, 0, 0.0, 1)
('chat', 4, 3, 0.106, 2, 50.0, 3)
=== 同一件事用 FILTER 写(SQLite 3.30+ / Postgres 支持,MySQL 不支持)===
('chat', 4, 2, None)
('summary', 3, 0, 13.94)
('translate', 1, 0, 0.4)
=== 占比:分母是全表,不是本组 ===
('summary', 13.94, 96.5)
('translate', 0.4, 2.8)
('chat', 0.106, 0.7)
=== 多键分组:一行 = 一个 (dt, model) 组合 ===
('2026-08-01', 'big', 1, 2.64)
('2026-08-01', 'small', 1, 0.056)
('2026-08-02', 'big', 2, 10.6)
('2026-08-02', 'small', 1, 0.03)
('2026-08-03', 'big', 1, 1.1)
('2026-08-03', 'small', 2, 0.02)
⭐ 六个指标,六种零件,各解决一个问题:
| 指标 | 零件 | 为什么不能写成别的 |
|---|---|---|
| 调用数 | COUNT(*) |
⚠️ 写成 COUNT(cents) 会漏掉那次「计费没记上」的失败调用(chat 就变成 3 了) |
| 用户数 | COUNT(DISTINCT user_id) |
⚠️ 写成 COUNT(user_id) 数的是调用次数:chat 有 4 次调用但只有 3 个人 |
| 总花费 | SUM(COALESCE(cents,0)) |
⭐ 套 COALESCE 是为了「全组都缺时给 0 而不是空白」(第一节那条判据) |
| 失败数 | SUM(CASE WHEN ok=0 THEN 1 ELSE 0 END) |
⭐ 条件计数的标准形状:满足就加 1,不满足加 0 |
| 失败率 | AVG(CASE WHEN ok=0 THEN 1.0 ELSE 0.0 END) |
⭐ AVG 一列 0/1,得到的就是比例 —— chat 是 2/4 = 50.0% |
| 有耗时的行数 | COUNT(ms) |
⭐ 这次故意用 COUNT(列):它就是在问「有几行记上了耗时」,chat 4 次调用只有 3 次有耗时 |
⭐ AVG(CASE WHEN 条件 THEN 1.0 ELSE 0.0 END) 这个形状值得单独记:
它把「满足条件的比例」压成一个聚合,比「先算分子再算分母再相除」短得多,也不会写错分母。
⚠️ FILTER 版的 ('chat', 4, 2, None) 里那个 None 又是同一件事:
chat 没有任何 model='big' 的行,SUM 在零个值上返回 NULL。
⭐ FILTER (WHERE …) 是 CASE WHEN 的语法糖,读起来干净得多 ——
但 MySQL 不支持,跨库的代码还是老老实实写 CASE WHEN。
⭐ 「占比」那段有一个结构上的要点:分母 (SELECT SUM(…) FROM calls) 是一个独立的子查询,
和外面的 GROUP BY 无关。如果你直接写 SUM(cents)/SUM(cents) 得到的永远是 1,因为两个 SUM 都是本组的。
⚠️ 分母要跨出当前组,就得另开一个标量子查询(05 章的第一种形状),
或者用 SUM(...) OVER ()(07 章)—— 后者写起来短得多。
⭐ 多键分组的判据仍然是 01 章那句「现在一行代表什么」:
GROUP BY dt, model 之后,一行 = 一个 (日期, 模型) 组合,六行对应三天 × 两种模型。
⚠️ 组合数会乘起来,加一个分组键就多一个维度,很容易从「12 行的表」变成「三千行的表」。
⭐ 顺带回收 02 章的一个伏笔: 那一章治扇出的修法②「先把右表聚合成一行一键再连」, 写的正是
(SELECT order_id, COUNT(*) FROM tags GROUP BY order_id)—— 本章讲的这个GROUP BY,就是那个修法的零件本身。
🛑 读到这里可以停 —— 前半章讲完了(约 67 分钟)。 后半章还有:
AVG会骗你的两件事 · 五个安静的坑 回来的时候不用重读,直接从下一节接着看就行。
🧯 五、AVG 会骗你的两件事
① 比率的平均,不等于平均的比率
三天的曝光和点击,其中一天量特别大:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE daily(dt TEXT, shows INT, clicks INT)")
db.executemany("INSERT INTO daily VALUES(?,?,?)", [
("2026-08-01", 100, 20), # 20%
("2026-08-02", 100, 10), # 10%
("2026-08-03", 10000, 300), # 3%
])
print("每天的 CTR:")
for r in db.execute("SELECT dt, shows, clicks, ROUND(clicks*100.0/shows,2) FROM daily"):
print(" ", r)
print()
print("① AVG(每天的 CTR) =",
round(db.execute("SELECT AVG(clicks*100.0/shows) FROM daily").fetchone()[0], 4), "%")
print("② SUM(clicks)/SUM(shows) =",
round(db.execute("SELECT SUM(clicks)*100.0/SUM(shows) FROM daily").fetchone()[0], 4), "%")
print(" 总点击 =", db.execute("SELECT SUM(clicks) FROM daily").fetchone()[0],
" 总曝光 =", db.execute("SELECT SUM(shows) FROM daily").fetchone()[0])
实跑输出:
每天的 CTR:
('2026-08-01', 100, 20, 20.0)
('2026-08-02', 100, 10, 10.0)
('2026-08-03', 10000, 300, 3.0)
① AVG(每天的 CTR) = 11.0 %
② SUM(clicks)/SUM(shows) = 3.2353 %
💀 11.0% 和 3.24%,差了三倍多,两条 SQL 都没报错。
AVG(clicks/shows) 把「只有 100 次曝光的那天」和「有 10000 次曝光的那天」当成同等重要的两票,
于是小样本的 20% 被放大成了三分之一的权重。真实的整体点击率是 330 / 10200 = 3.24%。
⭐ 判据(背下来):
比率不能求平均。要整体比率,就【先加分子、先加分母、最后相除】:
SUM(a) * 1.0 / SUM(b)。AVG(a/b)只在一种情况下是对的:你就是想让每个分组等权(比如「每个用户的平均命中率,每人一票」)—— 而那时你应该在注释里写清楚这是故意的。
⚠️ 别忘了那个 * 1.0:两个整数相除会截断。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE ab(a INT, b INT)")
db.executemany("INSERT INTO ab VALUES(?,?)", [(1, 3), (1, 3), (0, 3)])
print("SUM(a)/SUM(b) =", db.execute("SELECT SUM(a)/SUM(b) FROM ab").fetchone()[0])
print("SUM(a)*1.0/SUM(b) =", db.execute("SELECT SUM(a)*1.0/SUM(b) FROM ab").fetchone()[0])
实跑输出:
SUM(a)/SUM(b) = 0
SUM(a)*1.0/SUM(b) = 0.2222222222222222
💀 一个真实的转化率 22.2% 被显示成 0,而 SQL 一个字都没错。
(Postgres 和 MySQL 的整数除法行为也各有各的脾气 🗓️ 未实跑 —— 见附录 A。
⭐ 无脑对策:任何要当小数看的除法,分子先乘 1.0。)
② 平均值把「形状」藏起来了
import sqlite3
db = sqlite3.connect(":memory:")
print("SQLite 版本:", sqlite3.sqlite_version)
db.execute("CREATE TABLE calls(dt TEXT, ms INT)")
db.executemany("INSERT INTO calls VALUES(?,?)", [
("2026-07-30", 120), ("2026-08-01", 900), ("2026-08-02", 3100),
("2026-08-02", 450), ("2026-08-03", 90), ("2026-08-03", 110)])
print("全部 ms 排序 =", [r[0] for r in db.execute("SELECT ms FROM calls ORDER BY ms")])
print("AVG(ms) =", db.execute("SELECT AVG(ms) FROM calls").fetchone()[0])
print()
print("SQLite 有没有 MEDIAN / PERCENTILE_CONT:")
for f in ["MEDIAN(ms)", "PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ms)"]:
try:
print(f" {f} ->", db.execute(f"SELECT {f} FROM calls").fetchone()[0])
except Exception as e:
print(f" {f} ->", type(e).__name__, ":", e)
print()
print("中位数的可移植写法(排序取中间那一行):")
print(" 中位数 =", db.execute(
"SELECT AVG(ms) FROM (SELECT ms FROM calls ORDER BY ms "
"LIMIT 2 - (SELECT COUNT(*) FROM calls) % 2 "
"OFFSET (SELECT (COUNT(*)-1)/2 FROM calls))").fetchone()[0])
print()
print("按【表达式】分组:一行 = 一个月")
for r in db.execute("SELECT substr(dt,1,7) AS ym, COUNT(*), SUM(ms) "
"FROM calls GROUP BY substr(dt,1,7) ORDER BY ym"):
print(" ", r)
实跑输出:
SQLite 版本: 3.50.4
全部 ms 排序 = [90, 110, 120, 450, 900, 3100]
AVG(ms) = 795.0
SQLite 有没有 MEDIAN / PERCENTILE_CONT:
MEDIAN(ms) -> OperationalError : no such function: MEDIAN
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ms) -> OperationalError : near "(": syntax error
中位数的可移植写法(排序取中间那一行):
中位数 = 285.0
按【表达式】分组:一行 = 一个月
('2026-07', 1, 120)
('2026-08', 5, 4650)
⭐ 平均耗时 795 ms,中位数 285 ms —— 六次调用里有四次都在 120 ms 以内,
那个 3100 ms 的离群值一个人把平均值抬高了近三倍。
只看 AVG 的看板会告诉你「这个接口慢」,而真相是「它平时很快,偶尔卡一次」——两种情况该做的事完全不同。
⚠️ SQLite 连 MEDIAN 都没有(no such function: MEDIAN),PERCENTILE_CONT 更是直接语法错。
Postgres 有 PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ms) 🗓️ 未实跑 —— sqlite 无此语法。
可移植的替身就是上面那句「排序之后取中间那一行(偶数个就取中间两行求平均)」。
⭐ 顺带一个常用能力:GROUP BY 的键不必是列,可以是任意表达式。
GROUP BY substr(dt,1,7) 就把「按天」变成了「按月」,2026-08 那组把五行合成了 4650。
⚠️ 但表达式分组用不上 dt 上的索引 —— 索引里存的是 dt 本身的值,不是 substr(dt,1,7)。
「列被函数包住,索引就用不上」这条原理是 09 章第四到六节的正题
(那里是 WHERE 上的实测,CAST 那例慢了约 3800 倍),大表上分组也要留神。
🛑 第二个休息点 —— 中段讲完了(约 21 分钟)。 最后一段还有:五个安静的坑 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。
🛑 读到这里可以停 —— 已经读了约 88 分钟。 最后一段还有(约 35 分钟):五个安静的坑 回来的时候不用重读,直接从下一节接着看就行。
🔎 六、五个安静的坑
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE calls(feature TEXT, cents REAL)")
db.executemany("INSERT INTO calls VALUES(?,?)",
[("chat", 0.056), ("summary", 2.64), ("translate", None)])
print("坑① 空结果集上:COUNT 是 0,SUM 是 NULL")
print(" 不分组,无匹配行:",
db.execute("SELECT COUNT(*), SUM(cents), AVG(cents) FROM calls WHERE feature='nope'").fetchone())
print(" 分组,无匹配行 :",
db.execute("SELECT feature, COUNT(*), SUM(cents) FROM calls WHERE feature='nope' GROUP BY feature").fetchall())
print(" ⭐ 不分组时【一定返回一行】,分组时【一行都不返回】")
print(" 兜底:", db.execute("SELECT COALESCE(SUM(cents),0) FROM calls WHERE feature='nope'").fetchone()[0])
print()
print("坑② GROUP BY 只造【数据里存在的组】—— 零调用的 feature 不会出现")
db.execute("CREATE TABLE dim(feature TEXT)")
db.executemany("INSERT INTO dim VALUES(?)", [("chat",), ("summary",), ("translate",), ("ocr",)])
print(" 直接分组:", db.execute("SELECT feature, COUNT(*) FROM calls GROUP BY feature").fetchall())
print(" 维度表 LEFT JOIN:",
db.execute("SELECT d.feature, COUNT(c.feature) FROM dim d "
"LEFT JOIN calls c ON c.feature=d.feature GROUP BY d.feature").fetchall())
print(" ⚠️ 注意是 COUNT(c.feature) 不是 COUNT(*):")
print(" 写成 COUNT(*):",
db.execute("SELECT d.feature, COUNT(*) FROM dim d "
"LEFT JOIN calls c ON c.feature=d.feature GROUP BY d.feature").fetchall())
print()
print("坑③ GROUP BY 里 NULL 自成一组 —— 和「NULL = NULL 判不出真」不一致")
db.execute("CREATE TABLE t(city TEXT)")
db.executemany("INSERT INTO t VALUES(?)", [("BJ",), (None,), (None,), ("SH",)])
print(" GROUP BY city:", db.execute("SELECT city, COUNT(*) FROM t GROUP BY city").fetchall())
print(" ⭐ 两个 NULL 被分进【同一组】,可 JOIN 时它们却配不上对")
print(" DISTINCT 也一样:", db.execute("SELECT DISTINCT city FROM t").fetchall())
print()
print("坑④ COUNT(DISTINCT a, b) —— SQLite 不支持")
try:
print(db.execute("SELECT COUNT(DISTINCT city, city) FROM t").fetchone())
except Exception as e:
print(" ->", type(e).__name__, ":", e)
print(" 可移植写法:子查询里先 DISTINCT 再数")
print(" ->", db.execute("SELECT COUNT(*) FROM (SELECT DISTINCT city, city AS c2 FROM t)").fetchone()[0])
实跑输出:
坑① 空结果集上:COUNT 是 0,SUM 是 NULL
不分组,无匹配行: (0, None, None)
分组,无匹配行 : []
⭐ 不分组时【一定返回一行】,分组时【一行都不返回】
兜底: 0
坑② GROUP BY 只造【数据里存在的组】—— 零调用的 feature 不会出现
直接分组: [('chat', 1), ('summary', 1), ('translate', 1)]
维度表 LEFT JOIN: [('chat', 1), ('ocr', 0), ('summary', 1), ('translate', 1)]
⚠️ 注意是 COUNT(c.feature) 不是 COUNT(*):
写成 COUNT(*): [('chat', 1), ('ocr', 1), ('summary', 1), ('translate', 1)]
坑③ GROUP BY 里 NULL 自成一组 —— 和「NULL = NULL 判不出真」不一致
GROUP BY city: [(None, 2), ('BJ', 1), ('SH', 1)]
⭐ 两个 NULL 被分进【同一组】,可 JOIN 时它们却配不上对
DISTINCT 也一样: [('BJ',), (None,), ('SH',)]
坑④ COUNT(DISTINCT a, b) —— SQLite 不支持
-> OperationalError : wrong number of arguments to function COUNT()
可移植写法:子查询里先 DISTINCT 再数
-> 3
⭐ 坑① 是本章卖点的另一半:「一个 0」和「一行都没有」是两件事。
| 你写的 | 没有任何匹配行时 | 前端会看到什么 |
|---|---|---|
SELECT COUNT(*), SUM(cents) FROM calls WHERE … |
⭐ 一定返回一行:(0, None) |
一个 0 和一个空白 |
同一句 加上 GROUP BY feature |
⚠️ 一行都不返回:[] |
整个图表区域空掉 |
💀 写取数接口时这个区别会直接变成 bug:不分组那句返回一行,你的代码 rows[0][0] 拿到 0,一切正常;
哪天加了 GROUP BY,同一段代码在没数据的日子里 rows[0] 直接 IndexError。
⭐ 判据:只要写了 GROUP BY,调用方就必须能处理「零行」。
不写 GROUP BY 时,则要能处理 SUM 返回 NULL(COALESCE(SUM(cents), 0) 兜底成 0)。
坑② GROUP BY 只会造出【数据里存在的组】。
表里没有 ocr 的调用,ocr 就不会出现在结果里 —— 不是「0 次」,是「没这一行」。
⭐ 要让「零调用」的项目也占一行,得从一张维度表 LEFT JOIN 过来。
⚠️⚠️ 而这时必须写 COUNT(c.feature) 不能写 COUNT(*):
实跑里 COUNT(*) 把 ocr 数成了 1 —— 因为 LEFT JOIN 给它补了一行全 NULL,那也是一行,COUNT(*) 照数不误。
(这正好回收第一节那句:COUNT(*) 数行,COUNT(列) 数值。)
坑③ GROUP BY 把所有 NULL 归进【同一组】,实跑 (None, 2),DISTINCT 也一样只留一个 None。
⚠️ 这和你在 02 章第六节看到的恰好相反:那里两个 NULL 键配不上对。
⭐ 不矛盾,只是两套规则:JOIN 用的是 =(NULL = NULL 判不出真),
GROUP BY / DISTINCT 用的是「是不是同一个值」这套判断。04 章会把这两套摆在一起讲清楚。
坑④ COUNT(DISTINCT a, b) 在 SQLite 上直接报错(wrong number of arguments to function COUNT()),
MySQL 支持、Postgres 要写成 COUNT(DISTINCT (a, b)) 🗓️ 未实跑 —— sqlite 无此语法。
⭐ 三个库都能跑的写法是包一层子查询先 DISTINCT 再数,实跑得到 3。
⚠️ 常见的偷懒写法 COUNT(DISTINCT a || '|' || b) 有风险:分隔符本身出现在数据里就会撞('a|b' + 'c' 和 'a' + 'b|c' 拼出同一个串)。
坑⑤ SQLite 没有 ROLLUP / GROUPING SETS,合计行要手工拼。
import sqlite3
db = sqlite3.connect(":memory:")
print("SQLite 版本:", sqlite3.sqlite_version)
db.execute("CREATE TABLE calls(feature TEXT, model TEXT, cents REAL)")
db.executemany("INSERT INTO calls VALUES(?,?,?)", [
("chat", "small", 0.056), ("summary", "big", 2.64),
("summary", "big", 10.2), ("chat", "small", 0.03)])
try:
print(db.execute("SELECT feature, SUM(cents) FROM calls GROUP BY ROLLUP(feature)").fetchall())
except Exception as e:
print("ROLLUP ->", type(e).__name__, ":", e)
try:
print(db.execute("SELECT feature, SUM(cents) FROM calls "
"GROUP BY GROUPING SETS ((feature),())").fetchall())
except Exception as e:
print("GROUPING SETS ->", type(e).__name__, ":", e)
print()
print("可移植替身:UNION ALL 手工拼一条合计行")
for r in db.execute("""
SELECT feature, ROUND(SUM(cents),3) FROM calls GROUP BY feature
UNION ALL
SELECT '__合计__', ROUND(SUM(cents),3) FROM calls"""):
print(" ", r)
print()
print("GROUP_CONCAT:把组里的值拼成一串(Postgres 叫 STRING_AGG)")
print(" ", db.execute("SELECT feature, GROUP_CONCAT(model, '/') FROM calls GROUP BY feature").fetchall())
print()
print("GROUP BY 用列序号 / SELECT 别名:")
print(" GROUP BY 1 ->", db.execute("SELECT feature, COUNT(*) FROM calls GROUP BY 1").fetchall())
try:
print(" GROUP BY 别名 ->",
db.execute("SELECT feature AS f, COUNT(*) FROM calls GROUP BY f").fetchall())
except Exception as e:
print(" GROUP BY 别名 ->", type(e).__name__, ":", e)
实跑输出:
SQLite 版本: 3.50.4
ROLLUP -> OperationalError : no such function: ROLLUP
GROUPING SETS -> OperationalError : near "SETS": syntax error
可移植替身:UNION ALL 手工拼一条合计行
('chat', 0.086)
('summary', 12.84)
('__合计__', 12.926)
GROUP_CONCAT:把组里的值拼成一串(Postgres 叫 STRING_AGG)
[('chat', 'small/small'), ('summary', 'big/big')]
GROUP BY 用列序号 / SELECT 别名:
GROUP BY 1 -> [('chat', 2), ('summary', 2)]
GROUP BY 别名 -> [('chat', 2), ('summary', 2)]
⭐ GROUP BY 1 和 GROUP BY 别名 在 SQLite 上都能跑 —— 呼应 01 章那条:
ORDER BY 用别名一定安全,GROUP BY 用别名各库不同 —— ⚠️ Postgres 允许 GROUP BY 用输出列名、反而是 HAVING 不允许,和多数人的直觉相反(🗓️ 未实跑,据 PG 官方文档)。
⚠️ 列序号(GROUP BY 1)尤其别写进要长期维护的 SQL:有人在 SELECT 里插了一列,1 就指向了别的东西,不报错。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 01-一条查询是怎么跑的.html | ⭐ 本章第二、三节全是那张八步表的具体后果:GROUP BY 那道「单向门」门后为什么不能写裸列,WHERE(②)和 HAVING(④)为什么筛的不是同一种东西 |
| 02-JOIN的几种语义.html | 那一章治扇出的修法②「先把右表聚合成一行一键再连」,写的就是本章的 GROUP BY;⭐ 反过来,扇出之后再聚合,本章所有的数都会被放大 |
| 04-NULL与三值逻辑.html | ⭐ 本章三处欠账都在那里还:为什么聚合跳过 NULL、为什么 SUM(v)+100 是 NULL、为什么 GROUP BY 把两个 NULL 归成一组而 JOIN 却配不上对 |
| 05-子查询的三种形状.html | 第四节那个「占比」的分母是一个独立子查询,跨出了当前组。子查询的三种形状和什么时候该用哪种,在那一章 |
| 07-窗口函数.html | ⭐ GROUP BY 把十行压成一行,窗口函数让十行还是十行。本章两处「只能靠 SQLite 私货办到」的事(每组最大的那一整行、跨组的分母)在那一章有可移植的正规解法 |
| 09-计划里的连接与索引失效.html | 第五节那个 GROUP BY substr(dt,1,7) 用不上 dt 上的索引。「列被函数包住,索引就用不上」的完整实测在那一章第四到六节 |
| AI 全栈 · 06b 列表接口与分页 | 本章只讲 COUNT 的语义;COUNT(*) 在大表上要花多少钱(过滤命不中索引时慢三千多倍)是那一章第五节的实测 |
| 模型上线之后 · 14 辛普森悖论与七个陷阱 | ⭐ 本章第五节说「比率不能求平均」,那一章是这条规则失效时最贵的形态:两个人群里都更好,合起来却更差,解法是分层加权 |
| 模型上线之后 · 05 该监控什么 | 本章说「平均值把形状藏起来了」,那一章把它变成一条监控原则:只存聚合值不存直方图,均值没变但分布形状变了完全看不出 |
✅ 检查点
- 三行数据(
city缺一个、score缺一个)那组实测里,COUNT(*)/COUNT(city)/COUNT(DISTINCT city)/AVG(score)各是多少?用一句话概括这四个数背后的同一条规则。 AVG(score)是 1.0,而SUM(score)/COUNT(*)是 0.6667。哪个是对的?判据是什么?COUNT(1)比COUNT(*)快吗?真正影响COUNT(*)成本的是什么,在哪一章讲?- 一列全是
NULL时,SUM(v)返回什么?SUM(v) + 100返回什么?该怎么兜底? - 什么是「裸列」?
SELECT feature, user_id, COUNT(*) … GROUP BY feature在 SQLite 上会怎样,在 Postgres 上会怎样?那条判据是什么? - 同一句「只看大模型的花费」,写
WHERE model='big'和写条件聚合,chat这一行分别会怎样?什么时候该用哪个? HAVING不带GROUP BY时会发生什么?实跑的两个结果分别是什么?- 看板里「失败率」用了
AVG(CASE WHEN ok=0 THEN 1.0 ELSE 0.0 END),chat 得到多少?为什么「用户数」不能写COUNT(user_id)? - 三天曝光/点击那组实测里,
AVG(每天CTR)和SUM(clicks)/SUM(shows)各是多少?为什么差这么多?判据是什么? - 无匹配行时,带
GROUP BY和不带GROUP BY的返回有什么区别?这个区别会在取数接口里变成什么 bug? - 为什么「零调用的 feature」不会出现在
GROUP BY结果里?维度表LEFT JOIN的写法里,为什么必须是COUNT(c.feature)而不是COUNT(*)?
👀 答案
COUNT(*)= 3、COUNT(city)= 2、COUNT(DISTINCT city)= 2、AVG(score)= 1.0。 同一条规则:COUNT(*)数的是【行】,其余所有聚合数的是【值】,而值里的NULL全部被跳过。- 两个都"对",只是回答了不同的问题。
AVG(score)=1.0 是「填了分的那些人的平均分」(分母是COUNT(score)=2),SUM/COUNT(*)=0.6667 是「把没填当 0 算」。判据:取决于「缺失」意味着什么 —— 「他没考」→AVG对;「他考了 0 分没录进来」→AVG(COALESCE(score,0))=0.6667 才对。⚠️ SQL 默默替你选了前者。 - 不快,这是个迷信。实跑
COUNT(*)/COUNT(1)/COUNT('x')都是 5,语义相同、优化成同一件事 (只有COUNT(ms)是 4,因为跳过了那个NULL)。真正影响成本的是过滤条件命不命中索引, 在《AI 全栈》06b 第五节(命不中索引时慢三千多倍)。 SUM(v)返回None(NULL)而不是 0,于是SUM(v) + 100也是None—— 一条该显示 100 的指标变成空白格。 兜底:COALESCE(SUM(x), 0)。- 裸列 = 既不是分组键、也没被聚合函数包着的列。 SQLite 放行,返回组里随便一行的值
(实跑
('chat', 'u1', 2),而 chat 组其实有 u1、u2 两个人,一半用户当场蒸发且不报错); Postgres 直接拒绝。判据:GROUP BY之后SELECT里每一列要么是分组键,要么被聚合包着,没有第三种。 WHERE版:chat 整行整组消失(它没有 big 调用,行被扔光,组就不存在了); 条件聚合版:chat 还在,值是 0.0。判据:想让某些行不参与计算用条件聚合;想让某些行连同它的组一起消失才用WHERE。 💀 「看板上少了一行」最常见的来路就是顺手往WHERE里加了个筛选。- 整张表被当成【一个组】判一次。 实跑
HAVING SUM(cents) > 1→[(5, 13.326)](一行),> 99→[](一行都没有)。写告警查询很好用:超了返回一行,没超什么都不返回。 - chat 是 2 次失败 / 4 次调用 = 50.0%。
AVG一列 0/1 得到的就是比例。 ⚠️ 「用户数」写COUNT(user_id)数的是调用次数:chat 有 4 次调用但只有 3 个人,必须COUNT(DISTINCT user_id)。 AVG(每天CTR)= 11.0%,SUM(clicks)/SUM(shows)= 3.2353%(330 / 10200),差三倍多且都不报错。 因为AVG把「100 次曝光那天」和「10000 次曝光那天」当成同等重要的两票,小样本的 20% 被放大成三分之一权重。 判据:比率不能求平均,要先加分子、先加分母、最后相除(SUM(a)*1.0/SUM(b)); ⚠️ 别忘*1.0,实跑SUM(a)/SUM(b)整数截断成 0,真实值是 0.2222。- 不带
GROUP BY一定返回一行(0, None, None);带GROUP BY一行都不返回[]。 💀 取数接口里:不分组时rows[0][0]拿到 0 一切正常,加了GROUP BY之后没数据的日子rows[0]直接IndexError。 判据:写了GROUP BY,调用方必须能处理零行;不写时要能处理SUM返回NULL。 - 因为
GROUP BY只造【数据里存在的组】 —— 没有ocr的调用行,就没有ocr这一组(不是「0 次」,是「没这一行」)。 要让零调用也占一行,得从维度表LEFT JOIN过来。⚠️⚠️ 这时写COUNT(*)会把ocr数成 1, 因为LEFT JOIN给它补了一行全NULL,那也是一行;COUNT(c.feature)才是 0。(又一次:COUNT(*)数行,COUNT(列)数值。)
🛑 可以停在这里
⚡ 走神救援
⭐ 全章一句话:
COUNT(*)数的是【行】,其余所有聚合数的是【值】,而值里的NULL全部被跳过。💀
AVG那条最贵:它的分母是COUNT(该列)不是COUNT(*),所以它算的是「填了值的那些人的平均」。把缺失当 0 是另一个答案,哪个对取决于「缺失」意味着什么——⚠️ 而 SQL 默默替你选了前者。💀 一列全是
NULL时SUM返回NULL不是 0,再加一百也还是NULL——该显示数字的格子变空白。兜底写COALESCE(SUM(x), 0)。⭐ 第二条判据:
GROUP BY之后,SELECT里每一列要么是分组键、要么被聚合包着,没有第三种。裸列在 SQLite 会放行并返回组里随便一行的值(一半用户当场蒸发),Postgres 直接拒绝——⚠️ 又一个「本地跑通 ≠ 上线能跑」。⭐ 第三条判据:
WHERE筛的是行,HAVING筛的是组,第三个选项是条件聚合。同一句「只看大模型的花费」,写WHERE会让整组消失,写SUM(CASE WHEN …)会让它留下、值是 0。💀 「看板上少了一行」最常见的来路,就是顺手加了个WHERE。⭐ 第四条判据:比率不能求平均。 「每天 CTR 的平均」和「总点击 ÷ 总曝光」实测差三倍多,两个都不报错——因为一百次曝光那天和一万次曝光那天被当成了同等的两票。要整体比率就先加分子、先加分母,最后再除。
⚠️
COUNT(1)不比COUNT(*)快,真正花钱的是过滤命不命中索引。
下一节 👉 04-NULL与三值逻辑.md