🏠 总目录📚 本教程 03 · 聚合与分组 ← →
📑 本页目录(点开跳转)

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 该监控什么 本章说「平均值把形状藏起来了」,那一章把它变成一条监控原则:只存聚合值不存直方图,均值没变但分布形状变了完全看不出

✅ 检查点

  1. 三行数据(city 缺一个、score 缺一个)那组实测里,COUNT(*) / COUNT(city) / COUNT(DISTINCT city) / AVG(score) 各是多少?用一句话概括这四个数背后的同一条规则。
  2. AVG(score) 是 1.0,而 SUM(score)/COUNT(*) 是 0.6667。哪个是对的?判据是什么?
  3. COUNT(1) 比 COUNT(*) 快吗?真正影响 COUNT(*) 成本的是什么,在哪一章讲?
  4. 一列全是 NULL 时,SUM(v) 返回什么?SUM(v) + 100 返回什么?该怎么兜底?
  5. 什么是「裸列」?SELECT feature, user_id, COUNT(*) … GROUP BY feature 在 SQLite 上会怎样,在 Postgres 上会怎样?那条判据是什么?
  6. 同一句「只看大模型的花费」,写 WHERE model='big' 和写条件聚合,chat 这一行分别会怎样?什么时候该用哪个?
  7. HAVING 不带 GROUP BY 时会发生什么?实跑的两个结果分别是什么?
  8. 看板里「失败率」用了 AVG(CASE WHEN ok=0 THEN 1.0 ELSE 0.0 END),chat 得到多少?为什么「用户数」不能写 COUNT(user_id)?
  9. 三天曝光/点击那组实测里,AVG(每天CTR) 和 SUM(clicks)/SUM(shows) 各是多少?为什么差这么多?判据是什么?
  10. 无匹配行时,带 GROUP BY 和不带 GROUP BY 的返回有什么区别?这个区别会在取数接口里变成什么 bug?
  11. 为什么「零调用的 feature」不会出现在 GROUP BY 结果里?维度表 LEFT JOIN 的写法里,为什么必须是 COUNT(c.feature) 而不是 COUNT(*)?
👀 答案
  1. COUNT(*) = 3、COUNT(city) = 2、COUNT(DISTINCT city) = 2、AVG(score) = 1.0。 同一条规则:COUNT(*) 数的是【行】,其余所有聚合数的是【值】,而值里的 NULL 全部被跳过。
  2. 两个都"对",只是回答了不同的问题。 AVG(score)=1.0 是「填了分的那些人的平均分」(分母是 COUNT(score)=2), SUM/COUNT(*)=0.6667 是「把没填当 0 算」。判据:取决于「缺失」意味着什么 —— 「他没考」→ AVG 对;「他考了 0 分没录进来」→ AVG(COALESCE(score,0))=0.6667 才对。⚠️ SQL 默默替你选了前者。
  3. 不快,这是个迷信。实跑 COUNT(*) / COUNT(1) / COUNT('x') 都是 5,语义相同、优化成同一件事 (只有 COUNT(ms) 是 4,因为跳过了那个 NULL)。真正影响成本的是过滤条件命不命中索引, 在《AI 全栈》06b 第五节(命不中索引时慢三千多倍)。
  4. SUM(v) 返回 None(NULL)而不是 0,于是 SUM(v) + 100 也是 None —— 一条该显示 100 的指标变成空白格。 兜底:COALESCE(SUM(x), 0)。
  5. 裸列 = 既不是分组键、也没被聚合函数包着的列。 SQLite 放行,返回组里随便一行的值 (实跑 ('chat', 'u1', 2),而 chat 组其实有 u1、u2 两个人,一半用户当场蒸发且不报错); Postgres 直接拒绝。判据:GROUP BY 之后 SELECT 里每一列要么是分组键,要么被聚合包着,没有第三种。
  6. WHERE 版:chat 整行整组消失(它没有 big 调用,行被扔光,组就不存在了); 条件聚合版:chat 还在,值是 0.0。判据:想让某些行不参与计算用条件聚合;想让某些行连同它的组一起消失才用 WHERE。 💀 「看板上少了一行」最常见的来路就是顺手往 WHERE 里加了个筛选。
  7. 整张表被当成【一个组】判一次。 实跑 HAVING SUM(cents) > 1 → [(5, 13.326)](一行), > 99 → [](一行都没有)。写告警查询很好用:超了返回一行,没超什么都不返回。
  8. chat 是 2 次失败 / 4 次调用 = 50.0%。AVG 一列 0/1 得到的就是比例。 ⚠️ 「用户数」写 COUNT(user_id) 数的是调用次数:chat 有 4 次调用但只有 3 个人,必须 COUNT(DISTINCT user_id)。
  9. 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。
  10. 不带 GROUP BY 一定返回一行 (0, None, None);带 GROUP BY 一行都不返回 []。 💀 取数接口里:不分组时 rows[0][0] 拿到 0 一切正常,加了 GROUP BY 之后没数据的日子 rows[0] 直接 IndexError。 判据:写了 GROUP BY,调用方必须能处理零行;不写时要能处理 SUM 返回 NULL。
  11. 因为 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

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