记一次临时起意的UUID探索及v7的Java实现

2025 年 6 月 18 日 星期三(已编辑)
/
98
8
这篇文章上次修改于 2025 年 6 月 18 日 星期三,可能部分内容已经不适用,如有疑问可询问作者。

记一次临时起意的UUID探索及v7的Java实现

事情是这样——

由于工作原因,和项目组的小伙伴协调唯一标识符的方案,最终选择使用UUID v7。

这使用Java实现起来当然很简单,甚至可以说是基本功。

但是作为野路子起家的人,我还并未真正深入了解过UUID发展史。

于是,在一个闲来无聊的夜晚,我稍微研究了一下,并写下了这篇文章——


UUID是什么?

开始之前问一个问题:

你在构建分布式系统时,是否为如何生成全局唯一的标识符而感到苦恼?

UUID正是为了解决这一痛点问题而诞生的。

UUIDUniversally Unique Identifier的缩写,它的大名叫通用唯一识别码。在全球互联网化的今天,它的贡献功不可没。

作为使用128位数据,由16个8位字节构成的标识符,它的碰撞概率低到完全可以被认为是具有唯一性的

我们可以使用生日悖论(Birthday Paradox) 来对其碰撞概率进行计算:

生日悖论

先来思考一个问题:

在一个房间里,有多少人时,有超过50%的概率,至少有两个人生日相同?

很多人会下意识的以为需要100多个人才可能出现重复的生日,但是如果我说

答案是23个人呢?

我们先来做一下基本假设,在这个房间内——

  • 每年的生日均匀分布(忽略闰年等特殊情况)

  • 每个人生日相互独立

做好了基本假设,我们我们用小学二年级的知识便可以开始进行计算。

设房间内有$n$个人,那么我们可以计算 "没有任何两人生日相同的概率",然后用1减去这个概率,即

P至少一对相同=1P全部不同P_{\text{至少一对相同}} = 1 - P_{\text{全部不同}}
P全部不同=365365×364365×363365××365n+1365P_{\text{全部不同}} = \frac{365}{365} \times \frac{364}{365} \times \frac{363}{365} \times \cdots \times \frac{365 - n + 1}{365}

我们用阶乘可以写成如下形式:

365!365n(365n)!\frac{365!}{365^n(365-n)!}

即:

P至少一对相同=1P全部不同=1365!365n(365n)!P_{\text{至少一对相同}} = 1 - P_{\text{全部不同}} = 1 - \frac{365!}{365^n(365-n)!}

注意到当$n = 23$时:

P至少一对相同10.4927=0.5073>50%P_{\text{至少一对相同}} \approx 1 - 0.4927 = 0.5073 > 50\%

问题迎刃而解。

生日悖论在UUID v4碰撞概率推算中的应用

我们选用最为经典的UUID v4来进行碰撞概率推算。

上文中我们提到:

UUID使用128位数据

UUID v4同样作为使用128位数据生成的内容,它有$2^{128}$种可能,约为:

N=21283.4×1038N = 2^{128} \approx 3.4 \times 10^{38}

设我们随机生成$n$个UUID,求这$n$个中至少有两个重复的概率。

同样,我们计算全部不同的概率,并用1减去它。

使用生日悖论的近似公式可表示为:

P重复1en(n1)2NP_{\text{重复}} \approx 1 - e^{-\frac{n(n-1)}{2N}}

设$P_{\text{重复}} = 0.5$,求解$n$:

0.51en(n1)2Nen(n1)2N0.5n(n1)2Nln20.693 0.5 \approx 1 - e^{-\frac{n(n-1)}{2N}} \\ \Rightarrow e^{-\frac{n(n-1)}{2N}} \approx 0.5 \\ \Rightarrow \frac{n(n-1)}{2N} \approx \ln 2 \approx 0.693

因为$n(n - 1) \approx n^2$当$n$很大时,得:

n2Nln2221280.6931.6×1019n \approx \sqrt{2N \ln 2} \approx \sqrt{2 \cdot 2^{128} \cdot 0.693} \approx 1.6 \times 10^{19}

要使UUID v4的碰撞概率超过50%,需要生成约$1.6 \times 10^{19}$个UUID,更不用提基于时间戳计算得出的UUID v7等后续改进版本了。

所以我们完全可以认定它具有唯一性。

有点听累了?没关系,我们来看点轻松的——


UUID发展史

上世纪80年代,Apollo公司的软件工程师Roedy Green在1987年首次提出了UUID。

为什么Roedy Green要提出这么一种东西?

Apollo作为最早进军工作站市场的公司之一,深刻的意识工作站与大型机的鲜明对比,分布式计算的理念将成为主流。

Apollo早期的工作站宣传页

Apollo早期的工作站宣传页

为了识别这么多设备,我们当然需要一种统一的标准来帮助我们完成这件事。

NCS(网络计算系统)最早引入了UID(通用标识符)的概念,作为唯一识别身份。UID是一组64位数字,将单调时钟与永久嵌入其所有工作站硬件中的唯一主机ID相结合。在这种方案下,标识符可以在每台主机上每秒生成数千次,并始终保持全局唯一,没有扩展瓶颈。

而唯一的问题发生在了Apollo——在那里,一个标识符会永远跟随一台设备。

很显然,当Apollo开始为NCA(网络计算架构)引入NCS的这一标准时,UID数量多少有点捉襟见肘。

根据历史的车轮,我们都能猜到,UUID诞生了。

第一个UUID

在分布式系统中,不同的计算节点都可能需要创建资源、对象或文件等实体,而这些实体需要有唯一的标识符。为了避免不同机器之间命名冲突,Roedy Green设计了一种无中心协调的唯一标识符生成方法,这就是UUID的雏形。

UUID建立在UID的基础之上,在拓展空间为128位后,Roedy Green又提出了一些核心思想来帮助UUID更为完善和唯一。

Roedy Green的设计思路包含以下核心思想:

  • 利用每台机器唯一的MAC地址

  • 利用当前的时间戳(精确到100纳秒)。

  • 添加随机数或序列号以避免冲突。

  • 生成128位的标识符,确保在天文数量级创建ID的情况下,也不会重复。

而他的这一思想,后来也成为了UUID v1的核心。

第一个UUID没有被正式记录下来,因为它最初是由Apollo公司内部系统自动生成的,并没有像今天这样被标准化或归档。

但也许我们可以简单还原出来一个典型的"第一个 UUID"长什么样子?

让我们先了解一下UUID v1的格式结构:

字段 大小 描述
time-low 32 时间戳低位
time-mid 16 时间戳中位
time-high-and-version 16 时间戳高位和UUID版本号
clock-seq-and-reserved 8 时钟序列高位和保留位(variant 标志)
clock-seq-low 8 时钟序列低位
node 48 设备MAC地址,也可能是个伪随机数

我们再来详细了解一下里面的内容:

  • UUID v1的时间戳是从1582年10月15日00:00:00UTC(即格里高利历的起始时间,为了最大限度避免与Unix时间重叠,并避免冲突)到现在所经过的100纳秒为单位的计数值,总大小为60,分布在:

    • time-low 32

    • time-mid 16

    • time-high-and-version 前12

  • UUID版本号即为字面意思,在UUID v1内固定为1,以此类推。

  • 时钟序列总大小为14,用于在系统时间发生倒退或设备重启时防止UUID重复,分布在:

    • clock-seq-and-reserved 前6

    • clock-seq-low 8

  • variant 标识包含在clock-seq-and-reserved中,用于标志该UUID遵循的标准。UUID v1遵循RFC 4122,使用10。

  • node使用的设备MAC地址可以保证设备唯一性,但如果没有可用的MAC地址,拿伪随机数来凑合凑合也不是不可以。

很简单,不是吗?

我们可以根据上面的格式尝试还原出来一个典型的"第一个 UUID"的大概样子——

f81d4fae-7dec-11d0-a765-00a0c91e6bf6

这是一个历史上著名的UUID示例,它来自 Microsoft的DCE示例。

深挖完UUID的开家史,我们再来跟随历史的车轮往前看。

黏附在历史车轮上的UUID

历史车轮滚滚向前,UUID因其特性在全球范围开始大范围应用。

2005年,UUID由开放软件基金会(Open Software Foundation)定义,并标准化为RFC 4122。2024 年,RFC 9562推出了另外三个UUID版本——6、7、8以解决早期版本的局限性。

在此期间,UUID v2发布,这是一个很冷门的版本,以至于很多UUID介绍中遗漏了这个版本。Wikipedia中这样写道:

RFC 4122 保留了版本2的UUID用于“DCE security”;但并没有提供任何细节。因此,许多 UUID 实现省略了“版本2”。

仔细想来其实也并非是前人在写文时的刻意遗漏,而是UUID v2的变化和解决的问题实在有限。

UUID v2与UUID v1十分相像,除了时钟序列中clock-seq-lowlocal-main替换,也就剩下time-low由在指定本地域内有意义的整数标识符替换

哦,对了,UUID v2的规范也改为由DCE1.1提供。

历史的车轮继续向前。久而久之,在应用范围内,UUID v1和v2的一些问题也随之暴露,包括但不限于:

  • MAC地址带来的隐私问题

  • 安全问题

  • 自定义MAC地址带来的碰撞概率增加

于是,UUID v3和v5也应运而生。

他们相对于v1和v2,最大改动是node从由基于MAC地址转换为基于命名空间名称

命名空间名称其实本身就是一个UUID,RFC 4122规范定义了UUID以表示其用途:

名称 UUID(固定值) 用途
NAMESPACE_DNS 6ba7b810-9dad-11d1-80b4-00c04fd430c8 用于 DNS 域名
NAMESPACE_URL 6ba7b811-9dad-11d1-80b4-00c04fd430c8 用于 URL
NAMESPACE_OID 6ba7b812-9dad-11d1-80b4-00c04fd430c8 用于 OID
NAMESPACE_X500 6ba7b814-9dad-11d1-80b4-00c04fd430c8 用于 X.500 Distinguished Names

将上述UUID转换为string后在后面加上输入内容,再使用MD5进行散列,生成128位,然后对固定值进行替换即可得到一个UUID v3。

UUID v5与UUID v3具有相似性,它们之间不同的是,UUID v5使用SHA1进行散列而非v3使用的MD5。由于SHA1会生成160位,因此在替换前会将其先行截断为128位再进行操作。

这个改动解决了v1和v2长久以来遗留下来的痛点,但是由于其还可能存在由相同输入和命名空间导致的较大碰撞概率,我们还需要对其进行改进。

那么——

掌声有请,目前使用范围最为广泛,随机性最强的UUID v4!

UUID v4除了固定值的所有位都将随机生成,确保不会有任何生成前置条件、相同输入和命名空间的碰撞概率、安全及隐私问题等。

——它就是一串近乎随机生成的HEX!

UUID v4存在两个变体,其中2到3位用于表示变体版本(10用于表示变体1,110用于表示变体2)。因此对于UUID v4的变体1,有足足122位可用于随机生成,即共有$5.3 \times 10^{36}$个UUID v4变体1;而变体2因可用随机位少1位,故其数量只有变体1的一半

这么多种UUID,很难不忽略其为人类计算机发展做出的贡献。那么UUID的问题至此已经完全解决了

吗?

UUID v6/v7/v8——战未来

的确,UUID v1至v5提供了多种选择和标识,但是其历史包袱还没完全放下。

2024年,随着RFC 9562的Published,UUID v6/v7/v8也随之推出。

UUID v6作为v1的重组版本,着重解决了v1的私密性问题,并针对数据库管理中的时间顺序排序进行了优化。详细结构改进如下:

字段 大小 描述
timestamp_hi 32 时间戳高位
version 4 固定为6
timestamp_mid 16 时间戳中间位
timestamp_low 12 时间戳低位
variant 2 标识为RFC标准的UUID
sequence 14 用于避免重复
node 48 通常为设备的MAC地址

而UUID v7由于其对于web应用、数据库等场景的极强友好性,成为了目前最具发展潜力的版本。

UUID v7的时间戳由使用了40年的格里高利历换为Unix时间,便于数据库管理和排序;中位和低位也改为随机生成,保持了唯一性。其结构如下:

字段 大小 描述
unix_ts_ms 48 Unix 时间戳(毫秒)
version 4 固定为7
rand_a 12 随机字段A
variant 2 标识为RFC标准的UUID
rand_b 62 随机字段B

而UUID v8有极强的战未来性质。它允许开发者完全自定义122位UUID内容,仅保留6位用于辨识版本和变体。由于其可自定义性过强,在此按下不表。

至此,UUID版本发展史全部结束。


UUID v7的Java实现例

Java提供了UUID标准库java.util.UUID,使用randomUUID()即可生成一个UUID v4:

UUID uuid = UUID.randomUUID();

但由于Java标准库还未跟进RFC 9562,所有我们无法直接使用其创建UUID v7。

我们也可以使用第三方库,比如uuid-creator,来创建UUID v7。

<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>uuid-creator</artifactId>
    <version>6.1.1</version>
</dependency>
UUID uuid = UuidCreator.getTimeOrdered();

但是这看起来依旧不是很优雅,不是吗?

不过没关系,我们可以自己动手丰衣足食。

前文中我们已经了解UUID v7的基本结构,所有我们完全可以自己创建一个UUID v7。

下面的生成过程参考自David Ankin

先初始化16字节的随机内容:

SecureRandom random = new SecureRandom();
byte[] value = new byte[16];
random.nextBytes(value);

并插入时间戳:

ByteBuffer timestamp = ByteBuffer.allocate(Long.BYTES);
timestamp.putLong(System.currentTimeMillis());                // 64位时间戳
System.arraycopy(timestamp.array(), 2, value, 0, 6);          // 取低48位,即后6字节,并覆盖

随后我们设置其版本和变体:

value[6] = (byte) ((value[6] & 0x0F) | 0x70);                 // 保留低4位,并将高四位设为0111,即表示版本为7
value[8] = (byte) ((value[8] & 0x3F) | 0x80);                 // 保留低6位,并将高四位设为10,即表示变体

最后我们便可利用标准库来构造UUID对象了:

ByteBuffer buf = ByteBuffer.wrap(value);
long high = buf.getLong();                                    // 取前8字节作为高位
long low = buf.getLong();                                     // 取后8字节作为低位
return new UUID(high, low);                                   // 构造UUID对象

是不是很简单?

下面是完整方法,希望能帮助到看到这里的每一个人:

public static UUID generateUUIDv7() {
    SecureRandom random = new SecureRandom();

    byte[] value = new byte[16];
    random.nextBytes(value);

    ByteBuffer timestamp = ByteBuffer.allocate(Long.BYTES);
    timestamp.putLong(System.currentTimeMillis());

    System.arraycopy(timestamp.array(), 2, value, 0, 6);

    value[6] = (byte) ((value[6] & 0x0F) | 0x70);
    value[8] = (byte) ((value[8] & 0x3F) | 0x80);
        
    ByteBuffer buf = ByteBuffer.wrap(value);
    long high = buf.getLong();
    long low = buf.getLong();
    return new UUID(high, low);
}

超越UUID

我们也可以不把目光局限在UUID身上,除此之外,我们还有很多选择:

ULID

ULID与UUID v7的设计理念非常接近,它将基于时间戳的排序与随机性相结合,确保其单调性。

Snowflake

Snowflake由Twitter提出,因其64位长整数形式的ID而闻名。但其与UUID不兼容,特别是在数据库迁移等时候需要十分注意。

KSUID

KSUID由Segment开发,旨在解决Snowflake的一些局限性,它对于UUID使用者和Snowflake使用者都十分友好。


无论是UUID的哪个版本,还是ULID、Snowflake、KSUID等替代品,他们的设计背后都有其特定的应用场景和技术权衡。

UUID以其标准化和广泛兼容性成为了事实上的唯一标识符基础设施,但在需要时间排序、分布式生成或更高可读性的现代系统中,新的方案正逐步补位甚至替代。

选择哪种方案,并不是单纯追求更新或更快,而应考虑实际需求:是否需要排序?是否运行在高并发环境中?是否关注数据泄漏风险?理解UUID的过去、现在与未来,是构建健壮系统的第一步。

唯一性,从未如此多样。

全文完。


参考内容及感谢

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...