UUID 格局已经变了
如果你从 RFC 4122(2005)学习 UUID,接触的是 v1 到 v5。2024 年,RFC 9562 取代 RFC 4122,并标准化 v6、v7 和 v8。系统需要去中心化生成且近似按创建时间排序的标识符时,可以评估 UUID v7。RFC 9562 建议在可能时以 v7 代替 v1 或 v6,并没有要求所有应用用 v7 代替 v4。
**直接回答:**需要随机标识符且不希望嵌入时间戳时使用 UUID v4;关注索引局部性和时间顺序时评估 UUID v7,并测试时钟、并发、存储格式与隐私。两者都不保证绝对唯一,也不授予访问权限;数据库应设置唯一约束,授权必须单独执行。
UUID 解剖
UUID 是一个 128 位的值,显示为 32 位十六进制数字,以 8-4-4-4-12 格式排列:
550e8400-e29b-41d4-a716-446655440000
^^^^
版本半字节(位置 13)
^
变体位(位置 17,前 2 位 = 10)
128 位的分配:
- 4 位:版本指示器
- 2 位:变体指示器(RFC 9562 始终为
10) - 122 位:版本特定内容
UUID 版本完整参考
UUID v1 — 时间 + MAC 地址
不要用于新系统。
编码 60 位格里高利时间戳(自 1582-10-15 起的 100ns 间隔)和生成机器的 48 位 MAC 地址。
问题:
- 泄露机器的 MAC 地址(隐私/安全)
- 泄露创建时间戳(隐私)
- 时间戳位分散在非连续字段中,无法按字典序排序
- 需要访问真实 MAC 地址(在容器/VM 中有问题)
UUID v4 — 随机
122 个随机位。不嵌入任何信息。过去十年的主力。
f47ac10b-58cc-4372-a567-0e02b2c3d479
^ 变体 = 10
4 = 版本
优势:
- 生成简单
- 无隐私顾虑
- 无硬件依赖
弱点:
- 非时间有序 — 用作数据库主键时导致 B-tree 页分裂
UUID v6 — 重排时间(v1 修复)
取 v1 的 60 位格里高利时间戳,重新排列位使字典序排序等于时间顺序。仍包含节点字段(MAC 或随机)。
主要作为已使用 v1 的系统需要排序性时的迁移路径。
UUID v7 — Unix 时间戳 + 版本特定随机字段
RFC 9562 §5.7 定义 v7,并建议实现条件允许时以它代替 v1 或 v6。
结构:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms (48 位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms | ver | rand_a (12) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var| rand_b (62 位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| rand_b |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- 48 位:Unix 毫秒时间戳(可用到公元 10889 年)
- 4 位:版本(
0111) - 12 位:随机数据或可选单调构造(
rand_a) - 2 位:变体(
10) - 62 位:随机(
rand_b)
团队为何会为数据库评估 v7:
- 时间前缀布局:规范字节序会按 Unix 毫秒大致聚集数值。
- 可能改善 B-tree 局部性:时钟与生成器符合预期时,插入位置比均匀随机 v4 更集中。
- 字典序体现时间:规范 v7 字符串先按时间戳排序,同毫秒顺序取决于生成器。
- 时间戳可见:有助于诊断,但会泄露创建时间,也不能取代权威
created_at字段。 - 单调行为可配置:RFC 9562 描述了可选方法;严格顺序是实现属性,不是 v7 的内在保证。
UUID v8 — 自定义/实验
允许实现者在 122 个可用位(版本 + 变体之后)中定义自己的布局。用于领域特定方案。
为什么随机 UUID 破坏 B-tree 性能
B-tree 索引(PostgreSQL、MySQL InnoDB、SQLite 使用)维护有序结构。当你插入随机 UUID v4 作为主键时:
- 新值落在索引的随机位置
- 目标叶子页很可能不在缓冲池中(冷读取)
- 页面可能已满,需要页分裂
- 随时间推移,页面平均只有 ~69% 的填充率(碎片化)
UUID v7 的时间前缀可以让近期插入集中在更窄的索引区域,但它不保证新值大于所有旧值:时钟可能回拨,不同节点可能不一致,同毫秒随机字段也未必单调。页分裂、填充率、WAL、缓存和吞吐还取决于数据库实现、索引设置、行宽、并发和负载。应在代表性数据上比较两种方案,不能套用统一填充率或加速倍数。
碰撞概率(生日问题)
UUID v4 有 122 个随机位。生日问题公式给出生成 n 个 UUID 后至少一次碰撞的概率:
P(碰撞) ≈ 1 - e^(-n² / (2 × 2^122))
| 已生成 UUID 数量 | 碰撞概率 |
|---|---|
| 10 亿 (10⁹) | ~9.4 × 10⁻²⁰ |
| 1 万亿 (10¹²) | ~9.4 × 10⁻¹⁴ |
| 2.71 × 10¹⁸ | ~50% |
| 10¹⁸ | ~8.98% |
UUID v7 的碰撞分析取决于实现如何填充 rand_a 和 rand_b。完全随机布局在同一毫秒内最多有 74 个随机位;RFC 9562 也允许用计数器或增强时间戳换取部分随机位的单调方法。计数器本身不能消除跨进程、跨节点、重启或时钟回拨冲突。应遵循库文档,并保留唯一约束。
代码示例
JavaScript / TypeScript
// 原生:crypto.randomUUID() — UUID v4(所有现代运行时)
const v4 = crypto.randomUUID();
// UUID v7:使用库(uuid@10+)
import { v7 as uuidv7 } from 'uuid';
const id = uuidv7();
// 从 v7 提取时间戳
function extractTimestamp(uuidV7: string): Date {
const hex = uuidV7.replace(/-/g, '');
const ms = parseInt(hex.substring(0, 12), 16);
return new Date(ms);
}
Python
import uuid
# UUID v4(标准库)
id_v4 = uuid.uuid4()
# UUID v7(Python 3.14+)
id_v7 = uuid.uuid7()
# 从 v7 提取时间戳
def extract_timestamp_v7(u: uuid.UUID) -> float:
ms = u.int >> 80 # 取顶部 48 位
return ms / 1000.0
Go
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
// UUID v4
v4 := uuid.New()
fmt.Println(v4)
// UUID v7
v7, _ := uuid.NewV7()
fmt.Println(v7)
// 从 v7 提取时间戳
ts, _ := uuid.TimestampFromV7(v7)
fmt.Println(ts.Time())
}
PostgreSQL
-- UUID v4(PG 13+ 内置)
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payload JSONB
);
-- UUID v7(需要 pg_uuidv7 扩展或应用层生成)
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
payload JSONB
);
存储格式
| 格式 | 大小 | 可排序 | 索引效率 | 适用场景 |
|---|---|---|---|---|
| UUID 类型 (PG) | 16 字节 | 是 (v7) | 最优 | PostgreSQL |
| BINARY(16) (MySQL) | 16 字节 | 是 (v7) | 好 | MySQL InnoDB |
| CHAR(36) | 36 字节 | 否(随机) | 差 | 永远不要用于主键 |
| VARCHAR(36) | 37 字节 | 否(随机) | 差 | 仅用于展示/API |
| BIGINT | 8 字节 | 是 | 最优 | 64 位够用时 |
MySQL 可用 BINARY(16) 按规范字节序存储 v7。除非 Schema 有特殊理由,不要对 v7 应用面向旧 v1 布局的时间字节交换:
-- MySQL:按规范字节序存储 v7,不执行 v1 字节交换
CREATE TABLE events (
id BINARY(16) PRIMARY KEY,
created_at TIMESTAMP(3) GENERATED ALWAYS AS
(FROM_UNIXTIME(CONV(HEX(LEFT(id, 6)), 16, 10) / 1000)) STORED
);
决策框架:选择 ID 方案
| 需求 | UUID v4 | UUID v7 | ULID | Snowflake | KSUID | 自增 |
|---|---|---|---|---|---|---|
| 全局唯一(无协调) | 是 | 是 | 是 | 否(需 worker ID) | 是 | 否 |
| 时间可排序 | 否 | 是 | 是 | 是 | 是 | 是 |
| B-tree 友好 | 否 | 是 | 是 | 是 | 是 | 是 |
| 标准 (RFC) | 是 | 是 | 否 | 否 | 否 | N/A |
| 可提取时间戳 | 否 | 是 | 是 | 是 | 是 | 否 |
| 隐私(无时间信息) | 是 | 否 | 否 | 否 | 否 | 否 |
| 128 位 | 是 | 是 | 是 | 否(64 位) | 160 位 | 32/64 位 |
| 数据库原生类型 | 是 | 是 | 否 | BIGINT | 否 | SERIAL |
何时用什么
- UUID v7:需要标准化时间前缀标识符且能接受时间戳暴露的新系统可重点评估。
- UUID v4:需要不可提取时间信息时(隐私要求),或向后兼容。
- ULID:与 v7 类似但早于 RFC 9562。生态已在使用 ULID 时继续使用。
- Snowflake/Twitter ID:需要 64 位 ID(可放入 JavaScript
Number)且能容忍集中式 worker ID 分配时。 - 自增:单数据库、无合并/复制、最大存储效率、可接受顺序枚举的安全风险。
安全考量
UUID v1 泄露信息
UUID v1 嵌入了 MAC 地址和精确创建时间。给定一个 v1 UUID,攻击者可以:
- 识别生成它的物理机器
- 确定精确的创建时间
- 关联 UUID 以追踪活动模式
这在文档元数据取证中导致过真实的隐私事件。
UUID v4 不是安全令牌
由 CSPRNG 生成的 UUID v4 具有较高随机熵,但 UUID 形式本身不提供认证或授权语义;若用 Math.random() 等弱随机源生成,还可能被预测。安全令牌需要明确的熵目标、生命周期、存储、传输、撤销、比较和授权设计,不能假设标识符格式自动具备这些属性。
UUID v7 时间戳暴露
UUID v7 以毫秒精度暴露创建时间。对于创建时间敏感的应用:
- 使用 UUID v4
- 或通过经过评审的映射/令牌方案暴露独立的不透明公开标识符
Nil 和 Max UUID
RFC 9562 定义了两个特殊值:
Nil UUID: 00000000-0000-0000-0000-000000000000(全零)
Max UUID: ffffffff-ffff-ffff-ffff-ffffffffffff(全一)
Nil 用作"无值"哨兵(类似 NULL 但类型安全)。Max UUID 可用于范围查询的上界。
试用不同格式
UUID 生成器可在浏览器本地创建符合 RFC 9562 的 v4/v7 测试值,并支持批量格式化输出;UUID 术语页提供精简版本概览。生成器不能替代数据库唯一约束或应用使用的维护中依赖库。
一手标准
- RFC 9562:Universally Unique IDentifiers (UUIDs) — 当前布局、版本、单调性、碰撞、安全与最佳实践要求
总结
UUID v7 是现代 RFC 中的时间前缀 UUID,并在条件允许时优先于 v1 或 v6。随机标识符且不希望提取创建时间的需求仍适合 UUID v4。
是否采用 v7 取决于负载:随机键可能降低 B-tree 局部性,时间前缀键可能改善局部性;v7 并不会消除时钟、并发、碰撞、隐私或授权问题。
根据协调需求、排序要求、存储预算、隐私约束和生态兼容性来选择你的 ID 方案。