摘要:从一次"插入性能暴跌90%"的线上故障出发,深度剖析UUID做主键的四大性能问题。通过B+树页分裂的图解、100万数据的真实压测对比、以及自增ID、UUID、雪花算法的全面评测,揭秘为什么UUID会导致索引碎片、回表代价高、存储空间浪费。给出分布式场景下的最佳实践:雪花算法的完整实现与优化方案。
💥 翻车现场
周一早上,哈吉米收到了DBA的告警邮件。
主题:线上数据库性能告警
内容:
- 订单表插入性能从 5000 TPS 下降到 500 TPS
- 磁盘IO使用率从 30% 飙升到 95%
- 查询响应时间从 50ms 增加到 800ms
哈吉米:"卧槽,上周刚改的订单表主键,不会有问题吧?"
紧急回滚后,发现罪魁祸首:
-- 旧表结构(自增ID)
CREATE TABLE `order` (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 自增ID
order_no VARCHAR(32),
user_id BIGINT,
amount DECIMAL(10, 2),
create_time DATETIME
);
-- 新表结构(UUID)
CREATE TABLE `order` (
id VARCHAR(36) PRIMARY KEY, -- UUID:550e8400-e29b-41d4-a716-446655440000
order_no VARCHAR(32),
user_id BIGINT,
amount DECIMAL(10, 2),
create_time DATETIME
);
哈吉米:"UUID不是全局唯一吗?为什么性能这么差?"
下午,南北绿豆和阿西噶阿西来了。
南北绿豆:"UUID做主键有四大性能问题,我一个个给你讲。"
🤔 问题1:页分裂导致性能暴跌
阿西噶阿西:"这是最致命的问题!"
自增ID的插入过程(顺序插入)
B+树结构(简化):
初始状态:
页1 [1, 2, 3]
页2 [4, 5, 6]
页3 [7, 8, 9]
插入 id=10:
页1 [1, 2, 3]
页2 [4, 5, 6]
页3 [7, 8, 9, 10] ← 顺序插入到最后一页
优点:
- ✅ 不需要移动数据
- ✅ 不需要分裂页
- ✅ 磁盘顺序写入(快)
UUID的插入过程(随机插入)
UUID特点:完全随机,无序。
示例UUID:
550e8400-e29b-41d4-a716-446655440000
7c9e6679-7425-40de-944b-e07fc1f90ae7
a3bb189e-8bf9-3888-9912-ace4e6543002 ← 完全随机
B+树结构:
初始状态:
页1 [UUID_1, UUID_5, UUID_9]
页2 [UUID_3, UUID_7, UUID_11]
页3 [UUID_2, UUID_6, UUID_10]
插入 UUID_4(值在UUID_3和UUID_5之间):
需要插入到页1和页2之间的某个位置
问题:页1已满(假设只能存3条)
↓
触发页分裂!
↓
页1 [UUID_1, UUID_4] ← 分裂成两页
页1_new [UUID_5, UUID_9]
页2 [UUID_3, UUID_7, UUID_11]
页3 [UUID_2, UUID_6, UUID_10]
页分裂的代价:
1. 分配新页(内存操作)
2. 复制数据到新页(内存拷贝)
3. 更新父节点指针(修改索引)
4. 写磁盘(随机IO,5-10ms)
总耗时:10-20ms(是顺序插入的100倍!)
南北绿豆:"UUID是随机的,几乎每次插入都可能触发页分裂!"
页分裂的真实影响
测试场景:插入100万条数据
| 主键类型 | 插入时间 | 页分裂次数 | 磁盘IO |
|---|---|---|---|
| 自增ID | 12秒 | 0次 | 顺序写入 |
| UUID | 185秒 | 约50万次 | 随机写入 |
性能差距:UUID慢了15倍!
哈吉米:"卧槽,难怪插入性能暴跌!"
🤔 问题2:索引碎片率高
阿西噶阿西:"页分裂还会导致索引碎片。"
什么是索引碎片?
理想状态(自增ID):
页1 [1, 2, 3] → 页2 [4, 5, 6] → 页3 [7, 8, 9]
物理上连续存储,磁盘顺序读取
碎片状态(UUID):
页1 [UUID_A, UUID_D, UUID_G]
页2 [UUID_B, UUID_E, UUID_H] ← 物理上不连续
页3 [UUID_C, UUID_F, UUID_I]
问题:扫描索引时需要随机IO,性能差
查看索引碎片
-- 查看表的碎片率
SELECT
table_name,
data_free / (data_length + index_length) * 100 AS fragmentation_pct
FROM information_schema.TABLES
WHERE table_schema = 'your_db' AND table_name = 'order';
-- 结果
table_name | fragmentation_pct
-----------|------------------
order | 45.6% ← UUID表,碎片率很高
碎片的影响
-- 范围查询性能对比
SELECT * FROM order WHERE id > 'xxx' LIMIT 1000;
自增ID表:
- 数据物理连续
- 顺序IO
- 耗时:50ms
UUID表:
- 数据物理分散
- 随机IO
- 耗时:800ms(慢16倍)
南北绿豆:"碎片率高会导致范围查询性能极差!"
🤔 问题3:索引占用空间大
主键长度对比
| 主键类型 | 存储大小 | 示例 |
|---|---|---|
| BIGINT | 8字节 | 1234567890 |
| UUID(VARCHAR(36)) | 36字节 | 550e8400-e29b-41d4-a716-446655440000 |
UUID是自增ID的4.5倍!
二级索引的影响
阿西噶阿西:"主键大,会影响所有二级索引!"
原理:InnoDB的二级索引叶子节点存储的是主键值。
表结构:
CREATE TABLE `order` (
id VARCHAR(36) PRIMARY KEY, -- UUID主键
user_id BIGINT,
create_time DATETIME,
INDEX idx_user_id(user_id) -- 二级索引
);
二级索引 idx_user_id 的结构:
user_id | 主键值(UUID)
--------|----------------------------------
10086 | 550e8400-e29b-41d4-a716-446655440000 ← 36字节
10087 | 7c9e6679-7425-40de-944b-e07fc1f90ae7 ← 36字节
10088 | a3bb189e-8bf9-3888-9912-ace4e6543002 ← 36字节
如果主键是BIGINT:
user_id | 主键值(BIGINT)
--------|------------------
10086 | 1234567890 ← 8字节
10087 | 1234567891 ← 8字节
10088 | 1234567892 ← 8字节
存储空间对比(100万行数据):
| 主键类型 | 主键索引大小 | 二级索引大小(3个) | 总大小 |
|---|---|---|---|
| BIGINT | 200MB | 600MB | 800MB |
| UUID | 900MB | 2700MB | 3600MB |
UUID占用空间是自增ID的4.5倍!
哈吉米:"卧槽,不仅插入慢,还这么占空间!"
🤔 问题4:回表代价高
什么是回表?
-- 查询SQL
SELECT * FROM `order` WHERE user_id = 10086;
-- 执行过程
1. 走二级索引 idx_user_id,找到 user_id=10086 的所有主键
2. 拿着主键回到主键索引(聚簇索引)查询完整数据(回表)
回表次数 = 匹配的行数
UUID回表的问题
假设 user_id=10086 有100条订单:
自增ID回表:
1. 二级索引找到100个主键:[1, 5, 9, 12, 18, ...](8字节 * 100 = 800字节)
2. 回表100次(主键是顺序的,物理位置接近,缓存命中率高)
UUID回表:
1. 二级索引找到100个UUID:[550e8400-..., 7c9e6679-..., ...](36字节 * 100 = 3600字节)
2. 回表100次(UUID是随机的,物理位置分散,缓存命中率低,随机IO)
性能差距:UUID回表慢3-5倍
性能测试:
SELECT * FROM `order` WHERE user_id = 10086;
自增ID表:
- 回表100次(顺序IO)
- 耗时:10ms
UUID表:
- 回表100次(随机IO)
- 耗时:50ms(慢5倍)
📊 完整性能对比测试
测试环境:
- MySQL 8.0
- InnoDB引擎
- 4核8G
- SSD硬盘
测试数据:100万条订单
测试1:插入性能
-- 插入100万条数据
INSERT INTO `order` (id, order_no, user_id, amount) VALUES (...);
| 主键类型 | 插入时间 | TPS | 磁盘写入 |
|---|---|---|---|
| 自增ID | 12秒 | 83333 | 顺序写 |
| UUID | 185秒 | 5405 | 随机写 |
结论:UUID插入性能是自增ID的 6.5%!
测试2:查询性能(主键查询)
SELECT * FROM `order` WHERE id = ?;
| 主键类型 | 平均响应时间 |
|---|---|
| 自增ID | 0.5ms |
| UUID | 0.8ms |
结论:主键查询差距不大(UUID稍慢)
测试3:查询性能(二级索引 + 回表)
SELECT * FROM `order` WHERE user_id = 10086 LIMIT 100;
| 主键类型 | 平均响应时间 |
|---|---|
| 自增ID | 12ms |
| UUID | 58ms |
结论:UUID回表慢 4.8倍!
测试4:范围查询
SELECT * FROM `order` WHERE id > ? LIMIT 1000;
| 主键类型 | 平均响应时间 |
|---|---|
| 自增ID | 25ms |
| UUID | 320ms |
结论:UUID范围查询慢 12.8倍!
测试5:存储空间
| 主键类型 | 表大小 |
|---|---|
| 自增ID | 850MB |
| UUID | 3800MB |
结论:UUID占用空间是自增ID的 4.5倍!
🎯 那什么时候可以用UUID?
哈吉米:"UUID这么差,为什么还有人用?"
南北绿豆:"因为有些场景必须用!"
场景1:分布式系统,需要全局唯一ID
问题:多个数据库节点,自增ID会冲突
节点A:id=1, 2, 3, ...
节点B:id=1, 2, 3, ... ← 重复了!
解决方案:用UUID(全局唯一)
场景2:数据合并
场景:两个分公司的订单表合并到总公司
分公司A:id=1, 2, 3, ...
分公司B:id=1, 2, 3, ... ← 重复了!
解决方案:用UUID
场景3:安全性要求
自增ID的问题:
- 可预测(知道id=1,可以猜id=2、3、4...)
- 可以推测业务量(id从1到100万,说明有100万订单)
UUID的优点:
- 不可预测
- 无法推测业务量
🚀 最佳方案:雪花算法(Snowflake)
阿西噶阿西:"既要全局唯一,又要高性能,用雪花算法!"
雪花算法的结构
64位的BIGINT:
0 - 00000000 00000000 00000000 00000000 00000000 0 - 00000 - 00000 - 000000000000
符号位 时间戳(41位) 数据中心ID 机器ID 序列号(12位)
1位 支持69年 (5位) (5位) 支持4096/ms
特点:
- ✅ 全局唯一
- ✅ 趋势递增(时间戳递增)
- ✅ 高性能(本地生成,不依赖数据库)
- ✅ 占用空间小(8字节)
雪花算法实现
/**
* 雪花算法生成器
*/
public class SnowflakeIdGenerator {
// 起始时间戳(2024-01-01 00:00:00)
private final long epoch = 1704038400000L;
// 机器ID(5位,最大31)
private final long workerId;
// 数据中心ID(5位,最大31)
private final long datacenterId;
// 序列号(12位,最大4095)
private long sequence = 0L;
// 上次生成ID的时间戳
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > 31 || workerId < 0) {
throw new IllegalArgumentException("Worker ID 超出范围");
}
if (datacenterId > 31 || datacenterId < 0) {
throw new IllegalArgumentException("Datacenter ID 超出范围");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 生成ID(线程安全)
*/
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成ID");
}
// 同一毫秒内
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 序列号+1,最大4095
if (sequence == 0) {
// 序列号用完,等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// 新的毫秒,序列号重置
sequence = 0L;
}
lastTimestamp = timestamp;
// 组装ID
return ((timestamp - epoch) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
/**
* 等待下一毫秒
*/
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
使用示例
@Configuration
public class SnowflakeConfig {
@Bean
public SnowflakeIdGenerator snowflakeIdGenerator() {
// 机器ID和数据中心ID从配置文件读取
long workerId = 1;
long datacenterId = 1;
return new SnowflakeIdGenerator(workerId, datacenterId);
}
}
@Service
public class OrderService {
@Autowired
private SnowflakeIdGenerator idGenerator;
@Autowired
private OrderMapper orderMapper;
public void createOrder(Order order) {
// 生成ID
long orderId = idGenerator.nextId();
order.setId(orderId);
// 插入数据库
orderMapper.insert(order);
}
}
雪花算法 vs UUID vs 自增ID
| 特性 | 自增ID | UUID | 雪花算法 |
|---|---|---|---|
| 全局唯一 | ❌ | ✅ | ✅ |
| 趋势递增 | ✅ | ❌ | ✅ |
| 插入性能 | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
| 查询性能 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 存储空间 | 8字节 | 36字节 | 8字节 |
| 页分裂 | 无 | 频繁 | 极少 |
| 适用场景 | 单机 | 分布式(不推荐) | 分布式(推荐) |
结论:分布式场景下,雪花算法是最佳选择!
🎓 面试标准答案
题目:为什么不推荐用UUID做主键?
答案:
UUID做主键有4个问题:
- 页分裂频繁:UUID是无序的,插入时频繁触发页分裂,性能下降90%
- 索引碎片率高:数据物理分散,范围查询性能差10倍以上
- 存储空间大:UUID占36字节,是BIGINT的4.5倍,所有二级索引都会变大
- 回表代价高:UUID随机分布,回表时缓存命中率低,性能差5倍
推荐方案:
- 单机:用自增ID
- 分布式:用雪花算法(Snowflake)
🎉 结束语
晚上7点,三人终于把UUID的问题讲透了。
哈吉米:"原来UUID的问题这么严重,以后分布式场景就用雪花算法!"
南北绿豆:"对,雪花算法既有UUID的优点(全局唯一),又有自增ID的优点(趋势递增)。"
阿西噶阿西:"记住:能用自增ID就用自增ID,分布式场景就用雪花算法,UUID是最差选择!"
哈吉米:"明白了!我明天就改生产环境的表!"
南北绿豆:"别!生产环境改主键很危险,要做好数据迁移方案……"
记忆口诀:
UUID无序页分裂,索引碎片性能低
存储空间占四倍,回表代价随机高
自增ID单机用,雪花算法分布好
趋势递增最优解,千万别用UUID搞
希望这篇文章能帮你彻底理解为什么不推荐UUID做主键!下次面试被问到,你就能从原理到方案讲得清清楚楚!💪