← blog 技术原理与实践 · 2026-08-31

从 B 树页分裂到 UNIQUE 索引:主键设计的两个侧面

从一个真实问题出发——随机 code 能不能做主键——讲清 B 树页分裂的机制、自增主键为何免于分裂、自增主键加 UNIQUE 索引的回表查询链路,以及 UNIQUE 冲突的四种解决策略。

7 min read

起因是一个很小的代码评审问题: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   │
└──────────────┘      └──────────────────────────┘

一次分裂的代价有三层:

  1. 分配新页——磁盘 I/O
  2. 搬移半页数据——内存拷贝
  3. 更新父节点指针——父节点也满了就向上递归分裂

写入放大系数可以从理想的 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...';
  1. 走 UNIQUE 索引树:从索引根节点二分查找 code,落到叶子拿到 id = 1,一次 O(log n)。
  2. 回表(bookmark lookup):拿 id = 1 回主键树再走一次 O(log n),取完整行。

总共两次 B 树查找。这就是回表的代价——二级索引不存完整行,只存索引列加主键值。

对比直接用 code 做主键:查询只需一次 B 树查找,直达行数据,没有回表。所以随机主键在查询路径上反而更短,它的代价全在写入端的页分裂上。两个方案本质是一个取舍:

方案查询写入
随机值做主键一次查找,无回表随机插入,页分裂
自增主键 + UNIQUE 索引两次查找,有回表主键树顺序追加

选哪个取决于读写比:低频写、按 key 查的表(如 password_reset_codes),随机主键是合理选择;高频写的表,自增主键 + UNIQUE 索引才能压住页分裂成本。

UNIQUE 的两件事:约束与索引

UNIQUE 声明其实同时做了两件事:

  1. 唯一性约束——保证不存在两行该列值相同,这是数据完整性规则
  2. 唯一索引——在该列上建一棵索引树,让按此列查找变成 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 索引”模式的用武之地。

Sources

No external sources for this entry.

Related