起因是一个很小的代码评审问题:password_reset_codes 表用随机重置码 code 做主键,这会给性能带来什么影响?答案绕不开 B 树的物理存储结构。这篇把页分裂的机制、“自增主键 + UNIQUE 索引”这个经典模式的查询链路、以及 UNIQUE 冲突的处理策略一次讲清楚。
B 树的数据存在固定大小的页里
B 树的数据存储在固定大小的**页(page)**中,SQLite 默认每页 4KB。页内的行按主键值有序排列:
Page #7(容量 4KB,已用 ~3KB)
┌──────────────────────────────────────────┐
│ id=101 │ id=102 │ id=103 │ id=104 │ ... │ ← 按主键有序
└──────────────────────────────────────────┘
↑ 剩余空间,新行可以追加
理解了”页内有序”这一点,后面的一切都是推论。
自增主键:永远追加,从不搬移
自增 ID 总是大于已有最大值,新行永远落到最后一个页的末尾:
插入 id=105:
Page #7: [101][102][103][104] → [101][102][103][104][105] 追加,零搬移
页满了?开新页继续追加,旧页不动:
Page #7(满): [101..104] Page #8(新建): [105][106]...
整个过程没有任何数据搬移,写入路径退化成顺序追加。这是自增主键最大的价值。
随机主键:插入落点随机,触发页分裂
随机值插入时,落点可能在 B 树的任何位置。假设要把 code=d 插进已有 a, c, e, g 的页,它必须排在 c 和 e 之间:
页有空间——就地腾位置,把后面的行往后挪。行数多时搬移成本不低。
页满了——真正的页分裂(page split),一个页劈成两个:
分裂前(Page #7 已满):
┌──────────────────────────────────────────────────┐
│ code=a │ code=c │ code=e │ code=g │ code=i │
└──────────────────────────────────────────────────┘
分裂后:
Page #7(前半) Page #8(后半,新建)
┌──────────────┐ ┌──────────────────────────┐
│ code=a│code=c│ │ code=d│code=e│code=g│i │
└──────────────┘ └──────────────────────────┘
一次分裂的代价有三层:
- 分配新页——磁盘 I/O
- 搬移半页数据——内存拷贝
- 更新父节点指针——父节点也满了就向上递归分裂
写入放大系数可以从理想的 1x 膨胀到 2-3x。这就是为什么高频写入的表默认选自增主键:它让 B 树写入退化成顺序追加,几乎零开销。
自增主键 + UNIQUE 索引:两棵 B 树,一次回表
现在回到开头的问题。“自增主键 + 随机 code 做 UNIQUE 索引”的物理结构是两棵独立的 B 树,数据只存一份,在主键树里:
主键 B 树(聚簇,数据按 id 有序,行数据存在这里)
┌────┬──────────────────────┐
│ id │ row data... │
├────┼──────────────────────┤
│ 1 │ code=a3f8..., user=2 │
│ 2 │ code=7b1c..., user=5 │
└────┴──────────────────────┘
UNIQUE 索引 B 树(二级索引,按 code 有序)
┌──────────┬────┐
│ code │ id │ ← 叶子只存 code → id 映射
├──────────┼────┤
│ 7b1c... │ 2 │
│ a3f8... │ 1 │
└──────────┴────┘
按 code 查询一次,完整链路是两步:
SELECT * FROM password_reset_codes WHERE code = 'a3f8...';
- 走 UNIQUE 索引树:从索引根节点二分查找
code,落到叶子拿到id = 1,一次 O(log n)。 - 回表(bookmark lookup):拿
id = 1回主键树再走一次 O(log n),取完整行。
总共两次 B 树查找。这就是回表的代价——二级索引不存完整行,只存索引列加主键值。
对比直接用 code 做主键:查询只需一次 B 树查找,直达行数据,没有回表。所以随机主键在查询路径上反而更短,它的代价全在写入端的页分裂上。两个方案本质是一个取舍:
| 方案 | 查询 | 写入 |
|---|---|---|
| 随机值做主键 | 一次查找,无回表 | 随机插入,页分裂 |
| 自增主键 + UNIQUE 索引 | 两次查找,有回表 | 主键树顺序追加 |
选哪个取决于读写比:低频写、按 key 查的表(如 password_reset_codes),随机主键是合理选择;高频写的表,自增主键 + UNIQUE 索引才能压住页分裂成本。
UNIQUE 的两件事:约束与索引
UNIQUE 声明其实同时做了两件事:
- 唯一性约束——保证不存在两行该列值相同,这是数据完整性规则
- 唯一索引——在该列上建一棵索引树,让按此列查找变成 O(log n) 而不是全表扫描
冲突检测就发生在索引层:INSERT 时先在索引树二分查找,找到就触发冲突策略,不会先插入数据再校验。所以冲突处理的代价是一次索引查找,不是全表扫描。
冲突之后:四种解决策略
约束只声明”不能重复”,重复了怎么办由 INSERT 时的策略决定:
ABORT(默认)——报错回滚这条语句,应用层拿到异常自行处理:
INSERT INTO users (email) VALUES (?); -- 假设该 email 已存在
-- 冲突 → IntegrityError: UNIQUE constraint failed: users.email
INSERT OR IGNORE——静默跳过,适合幂等场景:
INSERT OR IGNORE INTO t (code) VALUES ('x');
-- 已存在 → 什么都不发生,不报错
INSERT OR REPLACE——删旧行插新行。注意是删除加插入,不是更新:自增 ID 会变,指向旧行的外键全部失效,谨慎使用。
ON CONFLICT(UPSERT)——最灵活,可精确指定冲突后更新哪些列:
INSERT INTO t (code, name) VALUES ('x', 'new')
ON CONFLICT(code) DO UPDATE SET name = excluded.name;
ON CONFLICT(code) DO NOTHING; -- 或者什么都不做
在真实代码里怎么选
以 mini-agent 的两个表为例,它们选了不同策略,依据是业务语义:
execution_tasks的UNIQUE(owner_user_id, request_id)实现幂等提交:同一请求重复提交时直接返回已有任务,不报错、不覆盖。靠的是约束天然去重,而不是在代码里维护”已处理集合”。users.email的 UNIQUE 用默认 ABORT:注册时邮箱重复必须报错,静默跳过或覆盖都是错误行为。
结论:选择矩阵
低频写 + 按随机 key 查询 → 随机值直接做主键(省回表,分裂可忽略)
高频写 + 需要按随机 key 查 → 自增主键 + UNIQUE 索引(主键树顺序追加)
幂等去重 → UNIQUE 约束 + OR IGNORE / 先查后插
注册类唯一字段 → UNIQUE 约束 + ABORT,重复即错误
回到开头的问题:password_reset_codes 用 code 做主键,页分裂的理论代价存在,但管理员偶尔触发一次重置、表里常年个位数行,分裂根本不会发生——这个设计不需要迁移。真正值得盯的是那些写入量会随用户数线性增长的表,它们才是”自增主键 + UNIQUE 索引”模式的用武之地。