考察点: 页分裂、顺序插入、UUID问题、聚簇索引
🎬 开场:一个关于停车场的故事
想象两个停车场:
停车场A(自增ID): 🅿️
车位编号:1, 2, 3, 4, 5, 6, 7, 8...
停车规则:按顺序停,第一辆停1号,第二辆停2号...
优点:
- 车位紧密排列,不浪费空间
- 找车位超快,下一个就是空的
- 管理简单,井然有序
停车场B(UUID): 🅿️
车位编号:a3f7, 2b9e, f1c4, 5d8a, 9e2b, 1f6c...(随机)
停车规则:随机分配车位号
缺点:
- 车位分散,中间有很多空隙(浪费空间)
- 找车位要到处找(效率低)
- 经常需要重新规划车位(页分裂)
MySQL的InnoDB存储引擎推荐用自增主键,就是这个道理! 🚀
第一部分:InnoDB的存储结构 📚
1.1 聚簇索引(Clustered Index)
关键概念: InnoDB的数据是按主键顺序存储的!
+-------------------+
| InnoDB表结构 |
+-------------------+
|
↓
[B+树结构]
|
+---------+
| 主键索引 | ← 叶子节点直接存储完整的行数据
+---------+
|
+----+----+----+
| 1 | 5 | 9 | ← 主键值
+----+----+----+
| 完整数据 | 完整数据 | 完整数据 |
与MyISAM的区别:
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 索引结构 | 聚簇索引(主键索引包含数据) | 非聚簇索引(索引和数据分离) |
| 主键选择 | 影响极大 | 影响较小 |
| 插入顺序 | 最好按主键顺序 | 无所谓 |
1.2 页(Page)的概念
InnoDB的最小存储单位是"页",默认16KB。
[页1: 1-100] [页2: 101-200] [页3: 201-300]
↓ ↓ ↓
存储ID 1-100 存储ID 101-200 存储ID 201-300
关键: 数据按主键顺序存储在页中!
第二部分:自增主键的优势 ✅
2.1 顺序插入,避免页分裂 🎯
场景1:使用自增主键
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50),
email VARCHAR(100)
);
-- 插入数据
INSERT INTO users (name, email) VALUES ('张三', 'zhang@example.com'); -- id=1
INSERT INTO users (name, email) VALUES ('李四', 'li@example.com'); -- id=2
INSERT INTO users (name, email) VALUES ('王五', 'wang@example.com'); -- id=3
存储过程:
页1: [1, 2, 3, 4, 5, ...]
↑ 新数据追加到末尾
✅ 每次插入都在当前页的末尾
✅ 页满了就创建新页
✅ 顺序写入,性能最优
图示:
初始状态:
[页1: 1-100(100个)] [页2: 101-200(100个)]
插入 id=201:
[页1: 1-100] [页2: 101-200] [页3: 201]
↑ 新页,顺序追加
场景2:使用UUID主键
CREATE TABLE users (
id CHAR(36) PRIMARY KEY, -- UUID: '550e8400-e29b-41d4-a716-446655440000'
name VARCHAR(50),
email VARCHAR(100)
);
-- 插入数据
INSERT INTO users VALUES (UUID(), '张三', 'zhang@example.com');
-- id='f47ac10b-58cc-4372-a567-0e02b2c3d479'
INSERT INTO users VALUES (UUID(), '李四', 'li@example.com');
-- id='3f0f3d2e-9c15-4a7d-b9e3-8f1c2d3e4f5a'
存储过程(灾难):
页1: [a-f...] (已满)
页2: [g-m...] (已满)
页3: [n-z...] (已满)
插入 id='3f0f...'(在中间):
1. 找到应该在页1的位置
2. 但页1已满!
3. 触发"页分裂"
4. 将页1分成两页,数据迁移
5. 插入新数据
❌ 大量随机写入
❌ 频繁页分裂
❌ 性能急剧下降
页分裂图示:
原始页(满):
[页1: a, c, e, g, i, k, m, o, q, s]
插入 f(在中间):
需要分裂!
分裂后:
[页1: a, c, e] [页2: f, g, i, k] [页3: m, o, q, s]
↑ 部分数据 ↑ 新数据 ↑ 部分数据迁移
2.2 性能对比测试 🔥
-- 测试1:自增主键插入100万条数据
CREATE TABLE test_auto (
id INT AUTO_INCREMENT PRIMARY KEY,
data VARCHAR(100)
);
-- 测试2:UUID主键插入100万条数据
CREATE TABLE test_uuid (
id CHAR(36) PRIMARY KEY,
data VARCHAR(100)
);
测试结果:
| 指标 | 自增主键 | UUID主键 | 差异 |
|---|---|---|---|
| 插入时间 | 10秒 | 35秒 | 3.5倍 |
| 表空间大小 | 50MB | 85MB | 1.7倍 |
| 页分裂次数 | 0 | 50000+ | 无数倍 |
| 查询性能 | 快 | 慢 | B+树层级更高 |
2.3 减少内存碎片和磁盘碎片 🧹
自增主键(整齐):
磁盘布局:
[块1][块2][块3][块4][块5]...
↓ ↓ ↓ ↓ ↓
连续存储,无碎片
内存Buffer Pool:
[页1][页2][页3][页4]
连续加载,缓存命中率高
UUID主键(凌乱):
磁盘布局:
[块1][ ][块3][ ][ ][块6][ ][块8]...
↓ 空 ↓ 空 空 ↓ 空 ↓
随机存储,大量碎片
内存Buffer Pool:
需要加载更多页,缓存命中率低
2.4 主键占用空间小 💾
-- 自增INT主键
CREATE TABLE users (
id INT PRIMARY KEY, -- 4字节
name VARCHAR(50),
email VARCHAR(100)
);
-- UUID主键
CREATE TABLE users (
id CHAR(36) PRIMARY KEY, -- 36字节(9倍大!)
name VARCHAR(50),
email VARCHAR(100)
);
影响:
假设有1000万用户:
自增主键:
- 主键索引:1000万 × 4字节 = 40MB
- 二级索引(email):1000万 × (100 + 4) = 1040MB
UUID主键:
- 主键索引:1000万 × 36字节 = 360MB(9倍!)
- 二级索引(email):1000万 × (100 + 36) = 1360MB
为什么二级索引也受影响?
因为InnoDB的二级索引存储的是主键值!
二级索引(email):
email值 → 主键值 → 再回表查完整数据
如果主键是UUID:
email值 → 36字节UUID → 回表
↑ 每个索引记录都大9倍!
2.5 B+树层级更低,查询更快 🌲
自增主键的B+树:
[根节点]
/ | \
[500] [1000] [1500]
/ \ / \ / \
[1] [250] ... ... ... [2000]
特点:
- 主键占用4字节
- 每个节点能存更多索引项
- 树的高度更低
- 查询需要的IO次数少
UUID主键的B+树:
[根节点]
/ | | \
[uuid1] [uuid2] [uuid3] [uuid4]
/ \ / \ / \ / \
... ... ... ... ... ... ... ...
特点:
- 主键占用36字节
- 每个节点存储的索引项少
- 树的高度更高(需要4-5层)
- 查询需要更多IO
计算:
假设页大小16KB:
自增INT主键:
每个索引项 ≈ 4字节 + 6字节(指针) = 10字节
每页可存 16KB / 10B ≈ 1600个索引项
3层B+树可以存:1600 × 1600 × 1600 ≈ 40亿条记录
UUID主键:
每个索引项 ≈ 36字节 + 6字节(指针) = 42字节
每页可存 16KB / 42B ≈ 390个索引项
3层B+树可以存:390 × 390 × 390 ≈ 5900万条记录
结论:相同数据量,UUID需要更多层级!
第三部分:不使用自增主键的问题 ❌
3.1 UUID的问题汇总
问题1:无序插入导致页分裂
-- 插入过程
INSERT INTO users VALUES (UUID(), 'user1', 'email1');
-- UUID: f47ac10b... (插入到中间某页)→ 页分裂
INSERT INTO users VALUES (UUID(), 'user2', 'email2');
-- UUID: 3f0f3d2e... (又插入到另一个中间)→ 又页分裂
-- 大量随机位置插入 → 频繁页分裂 → 性能差
问题2:占用空间大
UUID: 550e8400-e29b-41d4-a716-446655440000
└──────────── 36字节 ────────────┘
INT: 12345
└─ 4字节 ─┘
差距:9倍!
问题3:可读性差
-- 查询1:自增ID
SELECT * FROM orders WHERE id = 12345; ← 直观
-- 查询2:UUID
SELECT * FROM orders WHERE id = '550e8400-e29b-41d4-a716-446655440000';
↑ 又长又难记
问题4:无法排序
-- 自增ID:天然按创建时间排序
SELECT * FROM users ORDER BY id DESC LIMIT 10;
-- 最新的10个用户
-- UUID:完全无序
SELECT * FROM users ORDER BY id DESC LIMIT 10;
-- 随机的10个用户(无意义)
3.2 业务ID做主键的问题
-- 用手机号做主键
CREATE TABLE users (
phone VARCHAR(11) PRIMARY KEY, -- ❌ 不推荐
name VARCHAR(50),
email VARCHAR(100)
);
问题1:业务字段可能会变
-- 用户换手机号了
UPDATE users SET phone = '13900000001' WHERE phone = '13800138000';
-- ❌ 更新主键!这会触发:
-- 1. 删除旧的聚簇索引记录
-- 2. 插入新的聚簇索引记录
-- 3. 更新所有二级索引(因为存的是主键值)
-- 4. 性能极差!
问题2:占用空间大
-- 手机号:11字节
-- INT:4字节
-- 差距:2.75倍
问题3:不是所有用户都有手机号
-- 外国用户可能没有中国手机号
-- 小程序用户可能只有openid
-- ❌ 业务字段不稳定
3.3 字符串做主键的问题
CREATE TABLE articles (
slug VARCHAR(100) PRIMARY KEY, -- ❌ 文章别名做主键
title VARCHAR(200),
content TEXT
);
问题:
1. 字符串比较慢(逐字节比较)
2. 占用空间大
3. 字符串排序规则复杂(不同字符集)
4. 无法保证唯一性(可能重复)
第四部分:什么时候可以不用自增主键? 🤔
4.1 分布式场景(需要全局唯一ID)
问题:
[服务器1] [服务器2] [服务器3]
↓ ↓ ↓
自增1-1000 自增1-1000 自增1-1000
↓
ID冲突!💥
解决方案1:雪花算法(Snowflake)
┌──────────────────────────────────────────────────────────────┐
│ 1bit │ 41bit时间戳 │ 10bit机器ID │ 12bit序列号 │
│ 符号位│ 毫秒级时间戳 │ 最多1024台机器│ 每毫秒4096个ID│
└──────────────────────────────────────────────────────────────┘
生成的ID:1234567890123456789
优点:
- 全局唯一
- 趋势递增(大致有序)
- 生成速度快
- 不依赖数据库
实现:
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
// 生成ID
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 处理时钟回拨
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 12位序列号
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - 1288834974657L) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
}
缺点:
- ID是长整型(19位),可读性差
- 依赖系统时钟(时钟回拨有风险)
解决方案2:美团Leaf(号段模式)
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) PRIMARY KEY,
max_id BIGINT NOT NULL,
step INT NOT NULL,
update_time TIMESTAMP
);
-- 每次取一批ID
-- 第一次:取 1-1000
-- 第二次:取 1001-2000
-- 第三次:取 2001-3000
优点:
- 高性能(批量获取)
- 数据库压力小
- 趋势递增
解决方案3:UUID(不推荐)
CREATE TABLE users (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()), -- ❌ 性能差
name VARCHAR(50)
);
替代方案: 如果必须用UUID,改成有序UUID
// 有序UUID(UUIDv1或改进版)
// 时间戳在前,保证大致有序
public static String generateOrderedUUID() {
long timestamp = System.currentTimeMillis();
String uuid = UUID.randomUUID().toString();
return timestamp + "-" + uuid.substring(14);
}
4.2 需要对外暴露ID的场景
-- ❌ 不要直接暴露自增ID
https://example.com/order/12345
-- 用户可以尝试:12346, 12347... 遍历所有订单!
-- ✅ 使用加密ID或随机ID
https://example.com/order/a7f3d9e2c1b4
实现方案:
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 内部用
order_no VARCHAR(32) UNIQUE NOT NULL, -- 对外展示
customer_id INT,
amount DECIMAL(10,2),
INDEX idx_order_no (order_no)
);
-- order_no生成规则:
-- 时间戳 + 机器ID + 随机数 + 校验位
-- 例如:20241024001234567890
4.3 PostgreSQL的选择
-- PostgreSQL推荐用Serial或UUID
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- 自增
name VARCHAR(50)
);
-- 或者UUID(PostgreSQL的UUID性能更好)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50)
);
注意: PostgreSQL的UUID性能比MySQL好,因为存储机制不同。
第五部分:实战建议 💡
5.1 推荐的表设计
-- ✅ 推荐设计
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 主键(自增)
order_no VARCHAR(32) UNIQUE NOT NULL, -- 订单号(对外)
customer_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_order_no (order_no), -- 订单号索引
INDEX idx_customer_id_created (customer_id, created_at) -- 复合索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
优点:
id:自增,性能最优order_no:对外展示,隐藏内部IDcustomer_id:业务字段,有索引created_at:创建时间,天然排序
5.2 分布式主键生成策略
单机MySQL:
↓
✅ 自增主键(AUTO_INCREMENT)
分布式MySQL(多个数据库):
↓
✅ 雪花算法(Snowflake)
✅ 美团Leaf(号段模式)
✅ 数据库序列号+机器ID
分库分表(ShardingSphere):
↓
✅ 雪花算法
✅ ShardingSphere内置ID生成器
5.3 已有UUID表的优化
-- 如果已经有UUID主键的表,怎么办?
-- 方案1:添加自增ID列(推荐)
ALTER TABLE users ADD COLUMN id BIGINT AUTO_INCREMENT FIRST;
ALTER TABLE users DROP PRIMARY KEY, ADD PRIMARY KEY (id);
ALTER TABLE users ADD UNIQUE KEY uk_uuid (uuid_id);
-- 方案2:使用分区表
ALTER TABLE users PARTITION BY HASH(uuid_id) PARTITIONS 16;
-- 方案3:定期重建表(离线优化)
CREATE TABLE users_new LIKE users;
INSERT INTO users_new SELECT * FROM users ORDER BY uuid_id;
RENAME TABLE users TO users_old, users_new TO users;
第六部分:常见误区 ⚠️
误区1:"自增ID会泄露业务量"
❌ 错误观点:
用户看到订单ID是12345,知道我们只有1万多订单
✅ 正确做法:
- 内部用自增ID(性能)
- 对外展示加密ID或订单号(安全)
误区2:"UUID更安全"
❌ 错误观点:
UUID是随机的,更安全
✅ 事实:
- 安全性应该由权限控制保证
- 不能依赖ID的"不可猜测性"
- UUID牺牲性能换"安全"不值得
误区3:"业务ID做主键更方便"
❌ 错误观点:
用手机号做主键,不需要JOIN
✅ 问题:
- 业务字段会变(换手机号)
- 占用空间大
- 更新主键代价极高
误区4:"我的表数据少,无所谓"
❌ 错误观点:
表只有几万条数据,用什么主键都行
✅ 事实:
- 今天几万,明天可能几百万
- 改主键的成本很高(锁表、数据迁移)
- 养成好习惯,从设计开始
🎓 总结:主键选择决策树
[选择主键类型]
|
↓
[单机还是分布式?]
/ \
单机 分布式
↓ ↓
[自增主键] [需要全局唯一?]
/ \
是 否
↓ ↓
[雪花算法] [自增主键+前缀]
[Leaf号段]
[有序UUID]
黄金法则 🏆
1️⃣ 优先使用自增主键(INT或BIGINT)
2️⃣ 需要对外展示时,增加独立的业务ID字段
3️⃣ 分布式场景用雪花算法或Leaf
4️⃣ 避免使用UUID、字符串、业务字段做主键
5️⃣ 主键占用空间越小越好
6️⃣ 主键值越有序越好
7️⃣ 主键不应该有业务含义
记忆口诀 🎵
自增主键是首选,
顺序插入性能赞。
页分裂少空间省,
B+树低查询快。
UUID随机要慎用,
性能空间双浪费。
业务字段别做键,
改动麻烦后患大。
分布式用雪花号,
趋势递增又唯一。
内外分离是王道,
安全性能两不误!
📚 面试要点
- 聚簇索引:InnoDB数据按主键顺序存储
- 页分裂:无序插入导致性能下降的核心原因
- 空间占用:主键越小,索引越小(包括二级索引)
- UUID问题:无序、占用空间大、页分裂频繁
- 分布式方案:雪花算法、Leaf号段模式
- 实战经验:内外分离设计(内部自增,对外加密)
最后总结:
选择主键就像选地基,地基打得好,房子才稳固! 🏠
- 自增主键 = 坚固的水泥地基
- UUID主键 = 松散的沙土地基
不要为了"看起来酷"而牺牲性能,实用才是王道! 💪
加油,数据库架构师!🚀