GROUP BY 与 JOIN 回答两个真实问题 ——
① 这个笔记库的"记忆"都堆在哪些分类?② 哪个项目最吃上下文(答案是 23,022 个 tag、1297 万 token);
结尾再第一次看到「同一张表,一种写法走索引、一种写法全表扫」。
0002 你在建表;这一节开始问表。使命里那句「写查询」到这里才真正开始 —— 而查询的价值不在语法,在于它能回答一个你本来只能猜的问题。 下面两个问题都是从这个 321 MB 真库里跑出来的,不是编的示例。
sqlite3 -readonly /tmp/sqlite-练习/ctx.db
SELECT project_path, category, count(*) AS n
FROM memories
GROUP BY project_path, category
ORDER BY n DESC
LIMIT 5;
真实输出(你的库,2026-09-24):
project_path category n
-------------------------------------------- ------------- --
git:13097390f1a057ee154a1591e68fc699dc06a632 ARCHITECTURE 57
git:13097390f1a057ee154a1591e68fc699dc06a632 CONFIG_VALUES 51
git:13097390f1a057ee154a1591e68fc699dc06a632 CONSTRAINTS 49
dir:89720ded51fa CONFIG_VALUES 41
git:08ad12f9f6e7cf164a9dcf52a1c0fcfd8e4014f2 CONFIG_VALUES 25
GROUP BY 只要回答三个问题就写完了:
project_path, category —— 分组键写几列,就是几层分组count(*) —— 也可以是 sum() / avg() / min() / max()ORDER BY n DESC LIMIT 5 —— 不排序的话,顺序是未定义的一个必须现在就分清的点:count(*) 数的是行数,
count(某列) 数的是该列非空的行数。这两者在有 NULL 的列上结果不同 ——
这也是 count 最常被写错的地方。
上面那张表只有 project_path 这种指纹,读起来不直观。而另一张表 tags 里
(67,313 行)记着每次对话消耗的 tag 与 token,它用 session_id 标识会话;
session_projects 表(663 行)则回答「这个会话是在哪个项目上跑的」。把两张表接起来:
SELECT sp.project_path,
count(*) AS tags,
sum(t.token_count) AS tokens
FROM tags t
JOIN session_projects sp ON sp.session_id = t.session_id
GROUP BY sp.project_path
ORDER BY tokens DESC
LIMIT 5;
真实输出:
project_path tags tokens
-------------------------------------------- ----- --------
git:13097390f1a057ee154a1591e68fc699dc06a632 23022 12971694
git:08ad12f9f6e7cf164a9dcf52a1c0fcfd8e4014f2 6728 7347492
dir:89720ded51fa 11138 3923164
git:aa60bb95737d9aa91300434284d4c7e06c08b2d1 6341 3716019
dir:3e9fa1cb9afe 9228 2145190
第一行就是你正在读的这个笔记库:23,022 个 tag、12,971,694 个 token。 这不是"练习数据",这是你自己的工作痕迹。
FROM tags t —— 我关心的主体(消耗记录)JOIN session_projects sp —— 我要补的信息(会话属于哪个项目)ON sp.session_id = t.session_id —— 两边都有的那一列接之前先查一次右表的连接键唯不唯一,这是老手习惯:
SELECT count(*) AS rows, count(DISTINCT session_id) AS uniq FROM session_projects;
-- 663 | 663 → 唯一,不会把行数放大
如果右边一个键对应多行,JOIN 会让左表的行"翻倍",sum() 立刻算错 —— 而且不报任何错。
0004 会专门练,这里先看一眼"数据库怎么读你的查询":
EXPLAIN QUERY PLAN
SELECT * FROM memories WHERE project_path = 'x' AND status = 'active';
EXPLAIN QUERY PLAN
SELECT * FROM memories WHERE category = 'ARCHITECTURE';
-- 第一条:
`--SEARCH memories USING INDEX idx_memories_project_status_expires (project_path=? AND status=?)
-- 第二条:
`--SCAN memories
SEARCH … USING INDEX = 用索引直接跳过去;SCAN = 逐行看完整张表。
差别不在"结果对不对",而在代价:这一节的两条查询都跑得动,
但如果表长到一亿行,第二种写法会让你等很久。
1. count(*) 和 count(某列) 的差别?
对。count(*) 数行,count(列) 只数该列非空的行 —— 列里有 NULL 时两者结果不同,这是聚合里最常见的静默错误。
2. JOIN 之前为什么要先看右表的连接键是否唯一?
对。右表一个键对应多行时,左表每行都会与它笛卡尔式配对,行数放大 → sum()/count() 全部算错,而且不报任何错。所以先跑一次 count(*) vs count(DISTINCT 键)。
3. EXPLAIN QUERY PLAN 输出里的 SCAN memories 说明什么?
对。SCAN = 逐行看完整张表(可能很慢);对应的 SEARCH … USING INDEX 才是用索引定位。注意:它说的是计划,不是"结果对不对" —— 全表扫照样能出正确结果。