你有没有这种经历: 刚学 Spring Boot 那会儿,新建个表,第一个问题就是——主键用啥? 看别人博客一顿吹 UUID,多酷啊,全局唯一、分布式友好、不怕合并数据冲突,于是你在项目里也来一句:
@Id
@Column(columnDefinition = "char(36)")
private String id = UUID.randomUUID().toString();
然后项目刚上线的时候,一切风平浪静。 等到数据上了几百万、几千万,索引一查巨慢,磁盘疯长,DBA 叫你去开会,你才开始怀疑: “是不是哪里…有点不对劲?”
今天就好好唠唠:主键这事儿,UUID 到底坑在哪,自增 / 雪花这类 ID 又好在哪,顺便都用 Java 代码说人话讲明白。
一、为啥刚入行的时候大家都爱 UUID?
这个心路历程基本都差不多:
-
分布式听起来就该配 UUID微服务一多,你第一反应就是:每个服务一台库,用自增肯定会冲突,那 UUID 不就完美解决了吗?
-
生成简单,写码的时候很爽
String id = UUID.randomUUID().toString();
// 直接塞进去,谁都不用协调
不需要连数据库拿序列,不需要雪花算法组件,一个静态方法全搞定。
-
理论上“全局唯一”,听着就很安全虽然“理论上”也是有碰撞概率的,但是面试的时候说这话很有气势对吧……
所以早期很多人直接一刀切: “以后我所有表,主键一律 UUID 字符串!”
——真正的坑,从这句话开始埋。
二、UUID 真正的问题,其实跟“随机”和“胖”有关
UUID 最大的两个特点:又大又乱。
1. 又大:一个 UUID 至少要 16 字节
你爱用的那种带横杠的字符串 UUID,长这样:
550e8400-e29b-41d4-a716-446655440000
一般数据库里要么 char(36),要么 varchar(36),存储时:
-
字符串本身 36 字节 -
再加上编码、索引开销
而你要是用自增 id:
id BIGINT UNSIGNED PRIMARY KEY
8 字节搞定。
一个表几行数据的时候,这点差距没感觉。 等到你有一张 5000 万行的订单表,主键索引就会肥得离谱:
-
索引页更大 -
能放的 key 更少 -
需要更多层 B+ 树 -
内存缓存不住,经常回表、频繁 IO
你再一查订单详情,慢 SQL 就跟定时炸弹一样躺在那儿。
简单感受一下对比:
-- UUID 作为主键
CREATETABLE t_order_uuid (
idCHAR(36) PRIMARY KEY,
user_id BIGINTNOTNULL,
amount DECIMAL(10,2) NOTNULL,
created_at DATETIME NOTNULL
);
-- 自增 BIGINT 作为主键
CREATETABLE t_order_inc (
idBIGINTUNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINTNOTNULL,
amount DECIMAL(10,2) NOTNULL,
created_at DATETIME NOTNULL
);
相同数据量下,t_order_uuid 的主键索引体积往往是 t_order_inc 的几倍,你备份一次数据、跑一次统计,就会被它折磨到怀疑人生。
2. 又乱:对 B+ 树来说,UUID 是最糟糕的插入方式
MySQL 的 InnoDB,主键索引是 B+ 树结构,简单理解就是“有序的多层目录”。
自增 id 是一直往后长的,那插入新记录,基本长这样:
-
找到最后一个叶子节点 -
发现空间够,直接 append -
偶尔分裂一下叶子节点,成本可控
UUID 就不一样了,它是随机分布的,每次插入都像猴子扔飞镖:
-
可能插到树中间某个叶子页 -
那个页已经 80% 满了,还要硬塞进去 -
塞不下就分裂,连锁调整父节点 -
大量页被打碎,页分裂、页迁移特别频繁
结果就是:
-
写入变慢 -
索引严重碎片化 -
缓存命中率下降
你可能看过这种现象: 同样插入一百万条数据,自增主键那张表插几秒,UUID 那张表插几十秒甚至更久——就是它的锅。
如果你用的是 JPA + Hibernate,再加个 UUID 主键,配上批量插入,性能劣化会特别明显。
三、再说说“自增”,它为什么反而这么香?
先说一句我现在非常相信的话:“绝大多数业务表,用自增 bigint 做主键就够了。”
1. 对数据库来说,自增是最友好的写入模式
刚才说了,自增 id 对 B+ 树特别友好:
-
一直往后 append -
页分裂少 -
索引结构非常紧凑 -
内存可缓存更多热页
所以在高写入场景下,自增主键通常能顶很久,很久。
简单一个实体举个例子(用 JPA):
@Entity
@Table(name = "t_order")
publicclass Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // bigint 主键
private Long userId;
private BigDecimal amount;
private LocalDateTime createdAt;
// getter / setter ...
}
数据库建表:
CREATE TABLE t_order (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_user_created (user_id, created_at)
);
这一套放到生产,用个几年问题都不大。 哪怕单表上亿,更多的问题也是出在业务索引和 SQL 写法,而不是这个自增主键本身。
2. “会重复”其实没你想得那么可怕
很多人嫌弃自增是因为“跨库容易冲突”。 但你仔细想一下,大部分时候你要的是:
-
库内唯一:这个库里的订单 id 不重复即可 -
业务上可追踪:知道 1000 比 10 早创建
真正需要“全局唯一”的,往往是业务标识,而不是数据库主键本身。
常见做法就是“双 ID” 模式:
-
主键: id BIGINT AUTO_INCREMENT -
业务 ID: biz_id VARCHAR(64)或者CHAR(36)存 UUID / 雪花号
举个例子:
CREATE TABLE t_payment (
idBIGINTUNSIGNED PRIMARY KEY AUTO_INCREMENT,
biz_id CHAR(36) NOTNULLCOMMENT'业务唯一标识,比如对接第三方',
order_id BIGINTUNSIGNEDNOTNULL,
amount DECIMAL(10,2) NOTNULL,
statusTINYINTNOTNULL,
created_at DATETIME NOTNULL,
UNIQUEKEY uk_biz_id (biz_id),
KEY idx_order (order_id)
);
在 Java 里你可以这么用:
Payment payment = new Payment();
payment.setBizId(UUID.randomUUID().toString());
payment.setOrderId(orderId);
payment.setAmount(new BigDecimal("99.99"));
// ...
paymentRepository.save(payment);
-
数据库内部:靠 id作为主键,保证性能 -
对外系统 / 日志 / 回调:用 biz_id来对齐
这样既享受了自增的索引性能,又保留了 UUID 带来的业务便利,谁也没得罪。
四、那分布式怎么办?自增又不是“全局唯一”
说实话,真到了强分布式、多机写入的场景,纯数据库自增肯定不够用了,这时候你有几个更稳妥的选项:
1. 雪花算法(Snowflake)一类的长整型 ID
雪花 ID 的典型特点:
-
类型还是 long(BIGINT) -
但高几位是时间戳 + 机器号 + 自增序列 -
大体有序,对 B+ 树也比较友好 -
在集群里基本可认为“全局唯一”
Java 里常见的雪花实现一大把,找个简单版本的自己写一个也行:
public class SnowflakeIdGenerator {
privatefinallong workerId;
privatefinallong datacenterId;
privatelong sequence = 0L;
// 各种位移、掩码省略...
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时间回拨、自增逻辑略...
return ((timestamp - EPOCH) << TIMESTAMP_LEFT_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
}
结合 Spring 使用时,你只需要:
-
实体主键用 Long -
保存前生成好 ID 塞进去(用 @PrePersist或者自己在 Service 里生成)
@Entity
publicclass Order {
@Id
private Long id;
// ...
@PrePersist
public void prePersist() {
if (id == null) {
id = SnowflakeHolder.nextId();
}
}
}
这类 ID 生成方式的核心优势就是:既是 bigint,又大体有序,对数据库友好,还能全局唯一。对高并发、分库分表来说,是个非常折中的好方案。
2. 数据库序列 / 号段模式
如果你嫌自己搞雪花麻烦,也可以用“数据库 + 号段缓存”的方式:
-
在一个专门的“号段表”里维护区间,比如 [100000, 199999] -
Java 服务一次申请一段,放到内存里 -
用完了再去 DB 取下一段
好处:
-
ID 依然是长整型有序 -
数据库压力只体现在“取号”这几个点 -
业务服务挂了,号段浪费一点无所谓
缺点是实现上略复杂一点,但也比你全项目 UUID 乱飞要好太多。
五、什么时候 UUID 真的可以当主键?
也不是说 UUID 绝对不能做主键,只是你要清楚自己在干嘛。
大致可以接受的场景有:
-
表数据量永远不大 配置表、小字典表、一天也就增加几十条的审计日志——你就算上 UUID,问题也不大。
-
主键几乎不参与关联和查询 比如你永远用别的字段查,只把主键当“技术型 ID”,那索引带来的性能问题会小很多。
-
用的是“有序 UUID” 比如基于时间戳的 UUID v1/v7,或者手工把时间戳编码进高位,让整体趋势上是递增的,这样可以缓解 B+ 树页分裂的问题。
不过,哪怕这些场景,我也更推荐:bigint 做主键,UUID 做业务键。真的没必要为了“看着高级”,强行用 UUID 当主键。
七、怎么从 UUID 主键平滑迁移到自增 / 雪花?
很多老项目已经被 UUID 绑死了,这个时候也不是说一夜重构完事儿,可以这么搞一个渐进式方案:
-
先加一列新的长整型主键字段
ALTER TABLE t_order
ADD COLUMN new_id BIGINT UNSIGNED NULL;
-- 再加一个唯一索引
ALTER TABLE t_order
ADD UNIQUE KEY uk_new_id (new_id);
-
写一个 Java 脚本,批量填充新 ID
大致长这样:
public void backfillNewId() {
int size = 1000;
long lastPk = 0L;
while (true) {
List<Order> list = orderRepository.findBatch(lastPk, size);
if (list.isEmpty()) break;
for (Order o : list) {
if (o.getNewId() == null) {
o.setNewId(SnowflakeHolder.nextId());
}
}
orderRepository.saveAll(list);
lastPk = list.get(list.size() - 1).getOldUuidPkAutoInc(); // 旧主键或分页字段
}
}
-
新代码全部改成用新主键做关联
慢慢把所有 join、外键、接口都切到 new_id。
-
等确认没有老依赖之后,再把新列升为主键
这一步要小心操作,可以新建表 + 数据迁移,也可以在线修改,视具体数据库能力而定。
整个过程没必要一口吃成胖子,但方向一定要明确:主键从 UUID 慢慢回归到长整型。
如果你现在在设计一个新系统,脑子里蹦出这个问题:
“我主键用 UUID 好不好?”
你可以直接这么定:
-
99% 的业务表:
-
主键: BIGINT(自增或雪花) -
需要对外暴露的单据、业务唯一标识,再搞个 biz_id用 UUID / 雪花字符串都行 -
只有当你非常确认有多数据中心、多活合并、跨库迁移等变态要求,且你清楚地理解 UUID 带来的索引和存储成本的时候,再考虑把 UUID 拉到主键层面。
一句话收个尾:
年少不知自增好,是因为你当时还没见过几千万行的表长什么样。 真在生产被 UUID 索引干穿过一次之后,你就会开始老老实实用 bigint 了。
行了,差不多就这样,你要是正好有一张 UUID 主键的“遗产”想改,可以跟我说下具体表结构,我帮你顺一顺迁移思路。
-END-
我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html
🔥东哥私藏精品🔥
东哥作为一名老码农,整理了全网最全《Java高级架构师资料合集》。总量高达650GB。

