公司新来的技术总监上来第一刀,就是一句:以后建表,主键一律禁止用 UUID 和雪花 ID。 当时我和好几个同事心里都“咯噔”一下:这不是跟现在互联网常规做法反着来吗?
但你别说,等他把理由摊开聊了一个下午,我回去想了几天,越想越觉得有道理。
一、先把场景说清楚:他到底在“禁”什么
技术总监的原话大概是这样的:
“UUID、雪花 ID 你们要用可以,用在业务字段上,不要拿来当数据库主键。尤其是 MySQL 这类聚簇索引的库。”
注意几个关键点:
-
禁的是主键选型,不是说 UUID / 雪花 ID 这种 ID 生成方式就一无是处; -
他说的是通用规范,不是说小玩具项目; -
默认数据库是 MySQL 或 Postgres 这种有聚簇索引概念的库,线上高并发、高数据量场景。
我之前在线上就遇到过主键选错,把热点行更新和索引搞得一塌糊涂,最后还被迫考虑换 Postgres 来扛写入和 UPDATE 的压力。那次之后我就对“主键这玩意”格外敏感。
二、UUID / 雪花 ID 看起来很香,对吧?
先把两个主角交代一下。
UUID(一般指 v4 版本)
-
典型字符串形式: 550e8400-e29b-41d4-a716-446655440000 -
36 个字符(包含 4 个 -),通常存在CHAR(36)或VARCHAR(36)里 -
完全随机分布,看上去就两个字:离散
雪花 ID(Snowflake)
-
典型是 64 位长整型 BIGINT,类似:1677722748710662144 -
内部通常是:时间戳 + 机房 ID + 机器 ID + 自增序列 -
单机或单机组内是趋势递增的(大部分实现如此)
这俩东西的共同优点:
-
不依赖数据库,自生成,天然全局唯一; -
分库分表、微服务拆分时很方便,带着 ID 到处跑; -
对外暴露时也比较安全,看不出系统内的自增量。
听起来简直是“新时代 ID 标配”,所以这几年很多系统干脆直接拿它们做主键。再加上 SpringBoot 默认配置随便一跑就能插数据,我之前就见过有人直接在实体类上这样写:
@Entity
@Table(name = "t_order")
public class OrderEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id = UUID.randomUUID().toString();
// 其它字段省略
}
看起来“够用就行”,但问题也从这儿埋下了。
三、为什么说它们不适合作为主键?(重点)
技术总监说的核心就一句话:
“你们别忘了:主键就是聚簇索引的键。”
以 InnoDB 为例(Postgres 也有类似的行为),主键索引是 B+Tree,数据行就挂在这棵树上。你每插一行,就是往这棵树里插一个节点。
这时候,主键长什么样,就决定了这棵树长成什么样。
1. UUID:把索引打碎的罪魁祸首之一
UUID v4 是完全随机的,你可以想象一下 B+Tree:
-
如果主键是递增的,就像往书本最后一直塞纸,页是顺着长的,插入都是 append; -
如果主键是随机的,就像每次都把书翻到中间某一页撕开塞纸, 久而久之,这本书就被你拆得七零八落。
落到 MySQL 上就是几个具体问题:
-
页分裂频繁随机插入导致中间页经常被塞满、拆分; 页分裂又会引起大量数据移动、碎片,性能肉眼可见地下滑。
-
缓存局部性极差InnoDB Buffer Pool 里缓存的是数据页。 递增 ID 的话,热点页集中在尾部,缓存命中率高; UUID 这种随机 ID,每次都可能访问不同页,缓存命中率下降。
-
索引更宽,二级索引也跟着更宽
-
UUID 字符串: CHAR(36)至少 36 字节; -
BIGINT 主键:8 字节。 二级索引的叶子节点里会把主键冗余带一份。 主键越宽,所有索引都变胖,内存占用直线上升。
再加上线上很多同学还喜欢开各种默认配置不改,比如默认连接数、线程池大小、超时时间,都能踩出事故来。这种情况下,再用 UUID 当主键,就是给数据库再套一层脚镣。
2. 雪花 ID:看起来顺序,实际上也有坑
“那我用雪花 ID 呀,BIGINT,而且还是趋势递增的,这总行了吧?”
很多人会这么反驳。技术总监当时是这么解释的:
-
理论上没问题,实现得好的雪花 ID 当主键是可以的;
-
但现实里,大部分项目是这样用的:
-
雪花 ID 用 String存 -
或者建表时随手就写成 VARCHAR(64)之类; -
甚至雪花 ID 里塞业务含义,导致后面迁移很痛苦。
更大的问题是——你在数据库外生成了主键,很多事情就跟着变味了:
-
上层业务开始把“ID 长什么样”当成理所当然,写满各种判断; -
其他系统开始依赖这个 ID 的“格式”和“增长趋势”; -
将来如果要换 ID 策略(比如改为号段、改为数据库序列),全链路都要改。
他说了一句挺扎心的话:
“主键应该是数据库内部的技术细节,而不是整个系统对外承诺的一部分。”
说白了,就是把技术细节藏起来,而不是暴露出去。
四、那主键到底应该怎么选?
技术总监给了一个主键选型的“默认策略”,我现在也基本照着这个思路做。
1. 数据库主键:首选自增 / 序列型 BIGINT
-
字段类型: BIGINT(或者 Postgres 的BIGSERIAL) -
特性:短、递增、有序 -
作用:只用于表内部、索引、JOIN
它的优点很朴素:
-
占空间小,对 Buffer Pool 和索引都友好; -
插入是顺序写,大多数情况下页分裂少; -
JOIN 时,ON 条件里的键短小精悍。
2. 业务 ID:UUID / 雪花 ID 放在单独字段
一般会多一个字段,比如 biz_id 或 order_no:
-
使用 UUID / 雪花 ID / 号段服务生成; -
对外接口、日志、跨系统调用都用这个字段; -
当成“用户看到的单号”,跟数据库主键解绑。
这样一来,你后面爱怎么改主键策略都行:
-
换库:MySQL 换 Postgres,或者换分布式 NewSQL; -
换分片方案:按用户分片、按时间分片; -
改 ID 宽度:从 BIGINT改到BIGINT UNSIGNED或其它形式。
系统外部对你“单号”的预期完全不变。
五、Java 里怎么落地?来看几个代码片段
说完理念,回到代码。
-
实体类的设计:主键和业务 ID 分离
下面是一个比较推荐的写法(以 JPA 为例):
import jakarta.persistence.*;
@Entity
@Table(name = "t_order")
public class OrderEntity {
// 数据库主键,只在库里用
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
// 对外暴露的业务单号,可以用雪花 ID / UUID
@Column(name = "order_no", nullable = false, unique = true, length = 32)
private String orderNo;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "amount", nullable = false)
private Long amount;
// 省略 getter/setter
}
这里有几个点要注意:
-
主键 id用的是数据库自增,BIGINT; -
order_no单独建唯一索引; -
业务里永远不要拿 id 对外说话,全部用 order_no。
-
雪花 ID 生成器一个简单版本
如果你们公司还没有统一的 ID 服务,可以在项目里先放一个 Snowflake 实现,之后再替换成远程服务的调用就行。
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private final long sequenceBits = 12L;
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long maxWorkerId = ~(-1L << workerIdBits);
private final long maxDatacenterId = ~(-1L << datacenterIdBits);
private final long workerIdShift = sequenceBits;
private final long datacenterIdShift = sequenceBits + workerIdBits;
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private final long sequenceMask = ~(-1L << sequenceBits);
private final long twepoch = 1609459200000L; // 2021-01-01
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("workerId out of range");
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId out of range");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
// 时钟回拨处理可以复杂一点,这里先简单抛异常
throw new IllegalStateException("Clock moved backwards");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 当前毫秒内序列溢出,等下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
private long waitNextMillis(long lastTs) {
long ts = System.currentTimeMillis();
while (ts <= lastTs) {
ts = System.currentTimeMillis();
}
return ts;
}
}
在业务代码里用的时候,不要直接当主键,而是给 orderNo 之类的字段赋值:
@Service
public class OrderService {
private final SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1, 1);
@Autowired
private OrderRepository orderRepository;
public OrderEntity createOrder(Long userId, Long amount) {
OrderEntity order = new OrderEntity();
order.setUserId(userId);
order.setAmount(amount);
// 业务单号用雪花 ID
order.setOrderNo(String.valueOf(idGenerator.nextId()));
return orderRepository.save(order);
}
}
以后你要把 orderNo 改成 UUID、改成别的生成策略,只要改这一小段逻辑,数据库主键一点不动。
六、那是不是 UUID / 雪花 ID 做主键“绝对不行”?
也不是这么绝对,技术总监也强调过:
-
内部系统、小表、低并发场景,UUID 做主键问题不大; -
甚至有些日志表、审计表,为了方便追踪跨系统,加 UUID 当主键也可以; -
关键是:你要知道这么做在底层付出了什么代价,是“有意识地选”,不是“顺手就这么写”。
真正的问题往往出在:场景慢慢长大了,数据上亿了,流量上去了,你的 ID 策略和表结构没改过,结果各种奇怪的性能问题、数据错位、超时、写入卡死,一个个冒出来。 我之前就遇到过一次 TCP 数据包刚好卡在 1024 字节边界导致协议解析错位的事故,排查过程相当折磨人。主键选型这事也是类似——前期不注意,后期全是“考古式排查”。
那位技术总监后来又补了一句,我印象挺深:
“不要为了省几行代码,把整个数据库当成 ID 生成器和日志仓库。 主键是你数据世界的地基,地基最忌讳又高又歪。”
所以,他定的规范其实可以简化成三条思路:
-
主键优先选短小递增的 BIGINT,自增或序列即可; -
UUID / 雪花 ID 用在业务 ID 上,对外暴露,对内索引时谨慎使用; -
不要让业务逻辑依赖主键具体长相,把它当成纯技术细节。
照着这个思路去设计表结构和 Java 实体类,系统长到几年之后,你会特别感谢当初这点“自律”。
至于你们公司要不要立同样的“禁令”,可以先找两张真实的业务表,按现在的主键设计跑一跑压测,再按“自增主键 + 业务 ID 字段”的设计跑一遍,对比完数字,团队内部再吵都来得及。
-END-
我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html
🔥东哥私藏精品🔥
东哥作为一名老码农,整理了全网最全《Java高级架构师资料合集》。总量高达650GB。点击下方公众号回复关键字java 全部免费领取

