大数跨境
0
0

公司新来一个技术总监:禁止将 UUID 和雪花 ID 列入主键选型!

公司新来一个技术总监:禁止将 UUID 和雪花 ID 列入主键选型! Java技术图谱
2025-12-01
0

公司新来的技术总监上来第一刀,就是一句:以后建表,主键一律禁止用 UUID 和雪花 ID。 当时我和好几个同事心里都“咯噔”一下:这不是跟现在互联网常规做法反着来吗?

但你别说,等他把理由摊开聊了一个下午,我回去想了几天,越想越觉得有道理。

一、先把场景说清楚:他到底在“禁”什么

技术总监的原话大概是这样的:

“UUID、雪花 ID 你们要用可以,用在业务字段上,不要拿来当数据库主键。尤其是 MySQL 这类聚簇索引的库。”

注意几个关键点:

  1. 禁的是主键选型,不是说 UUID / 雪花 ID 这种 ID 生成方式就一无是处;
  2. 他说的是通用规范,不是说小玩具项目;
  3. 默认数据库是 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 上就是几个具体问题:

  1. 页分裂频繁随机插入导致中间页经常被塞满、拆分; 页分裂又会引起大量数据移动、碎片,性能肉眼可见地下滑。

  2. 缓存局部性极差InnoDB Buffer Pool 里缓存的是数据页。 递增 ID 的话,热点页集中在尾部,缓存命中率高; UUID 这种随机 ID,每次都可能访问不同页,缓存命中率下降。

  3. 索引更宽,二级索引也跟着更宽

    • UUID 字符串:CHAR(36) 至少 36 字节;
    • BIGINT 主键:8 字节。 二级索引的叶子节点里会把主键冗余带一份。 主键越宽,所有索引都变胖,内存占用直线上升。

再加上线上很多同学还喜欢开各种默认配置不改,比如默认连接数、线程池大小、超时时间,都能踩出事故来。这种情况下,再用 UUID 当主键,就是给数据库再套一层脚镣。

2. 雪花 ID:看起来顺序,实际上也有坑

“那我用雪花 ID 呀,BIGINT,而且还是趋势递增的,这总行了吧?”

很多人会这么反驳。技术总监当时是这么解释的:

  • 理论上没问题,实现得好的雪花 ID 当主键是可以的;

  • 但现实里,大部分项目是这样用的:

    • 雪花 ID 用 String 存
    • 或者建表时随手就写成 VARCHAR(64) 之类;
    • 甚至雪花 ID 里塞业务含义,导致后面迁移很痛苦。

更大的问题是——你在数据库外生成了主键,很多事情就跟着变味了:

  1. 上层业务开始把“ID 长什么样”当成理所当然,写满各种判断;
  2. 其他系统开始依赖这个 ID 的“格式”和“增长趋势”;
  3. 将来如果要换 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 里怎么落地?来看几个代码片段

说完理念,回到代码。

  1. 实体类的设计:主键和业务 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
  1. 雪花 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(11);

    @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 生成器和日志仓库。 主键是你数据世界的地基,地基最忌讳又高又歪。”

所以,他定的规范其实可以简化成三条思路:

  1. 主键优先选短小递增的 BIGINT,自增或序列即可
  2. UUID / 雪花 ID 用在业务 ID 上,对外暴露,对内索引时谨慎使用
  3. 不要让业务逻辑依赖主键具体长相,把它当成纯技术细节

照着这个思路去设计表结构和 Java 实体类,系统长到几年之后,你会特别感谢当初这点“自律”。

至于你们公司要不要立同样的“禁令”,可以先找两张真实的业务表,按现在的主键设计跑一跑压测,再按“自增主键 + 业务 ID 字段”的设计跑一遍,对比完数字,团队内部再吵都来得及。

-END-

我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html


🔥东哥私藏精品🔥


东哥作为一名老码农,整理了全网最全《Java高级架构师资料合集》。总量高达650GB点击下方公众号回复关键字java 全部免费领取

【声明】内容源于网络
0
0
Java技术图谱
回复 java,领取Java面试题。分享AI编程,AI工具,Java教程,Java下载,Java技术栈,Java源码,Java课程,Java技术架构,Java基础教程,Java高级教程,idea教程,Java架构师,Java微服务架构。
内容 1111
粉丝 0
Java技术图谱 回复 java,领取Java面试题。分享AI编程,AI工具,Java教程,Java下载,Java技术栈,Java源码,Java课程,Java技术架构,Java基础教程,Java高级教程,idea教程,Java架构师,Java微服务架构。
总阅读62
粉丝0
内容1.1k