分布式ID选型:从Snowflake到UUIDv7,安全与性能的权衡

去年做项目时,导师说过一句话让我印象深刻:”ID 不能用自增的 int,当年有家公司就是这样被别人爬走了全部数据。”于是 UUID 成了我的默认选项。但 UUID 真的完美吗?Snowflake 又快在哪里?UUIDv7 又是什么?这篇博客记录我对分布式 ID 选型的一次系统性梳理。

一、一个真实的安全教训:为什么自增 ID 是灾难?

在讨论 Snowflake 和 UUID 之前,有必要先理解为什么自增 ID(Auto Increment)在暴露给外部时必须被淘汰

自增 ID 的问题在于:连续且可预测。攻击者只需在 URL 中把 ?id=10001 改成 ?id=10002,就能遍历你的全部数据。这不仅是数据泄露,还会直接暴露业务规模——竞品从 ID 的最大值就能估算你的用户量、订单量。

更糟的是,很多系统的权限校验只依赖”用户是否登录”,而不是”这条数据是否属于这个用户”。水平越权漏洞(IDOR)配上自增 ID,就是一场数据泄露的完美风暴。

所以导师说得对:暴露给前端的 ID,绝不能是可预测的自增数字。

但问题是——替代方案也不是只有一种。接下来我们从三个核心维度来审视:Snowflake(雪花算法)、UUIDv4(随机 UUID)、以及近年来备受关注的UUIDv7

二、Snowflake:分布式时代的”精准时钟”

2.1 核心结构

Snowflake 是 Twitter 开源的分布式 ID 生成算法。它的核心思想是:将一个 64 位的 Long 型整数按位划分,不同区段携带不同信息

1
2
3
4
5
+-----------+-----------+-----------+
| 41 bit | 10 bit | 12 bit |
| 时间戳 | 机器标识 | 序列号 |
+-----------+-----------+-----------+
高位 低位
字段 位数 说明
时间戳 41 bit 毫秒级时间戳,从自定义起始时间(epoch)开始算,可用约 69 年
机器标识 10 bit 数据中心 ID(5 bit)+ 机器 ID(5 bit),最多支持 1024 个节点
序列号 12 bit 同一毫秒内的递增序列号,支持每毫秒 4096 个 ID

2.2 Python 简易实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import time
import threading

class Snowflake:
def __init__(self, datacenter_id: int, worker_id: int, epoch: int = 1700000000000):
"""
datacenter_id: 数据中心 ID (0-31)
worker_id: 机器 ID (0-31)
epoch: 起始时间戳(毫秒),默认 2023-11-01 左右的某个时间点
"""
self.datacenter_id = datacenter_id & 0x1F # 5 bit
self.worker_id = worker_id & 0x1F # 5 bit
self.epoch = epoch

self.sequence = 0
self.last_timestamp = -1
self.lock = threading.Lock()

def _current_millis(self) -> int:
return int(time.time() * 1000)

def _wait_next_millis(self, last_timestamp: int) -> int:
timestamp = self._current_millis()
while timestamp <= last_timestamp:
timestamp = self._current_millis()
return timestamp

def next_id(self) -> int:
with self.lock:
timestamp = self._current_millis()

# 时钟回拨处理:等待直到时间追上
if timestamp < self.last_timestamp:
timestamp = self._wait_next_millis(self.last_timestamp)

if timestamp == self.last_timestamp:
# 同一毫秒内,序列号递增
self.sequence = (self.sequence + 1) & 0xFFF # 12 bit mask
if self.sequence == 0:
# 序列号用尽,等待下一毫秒
timestamp = self._wait_next_millis(self.last_timestamp)
else:
self.sequence = 0

self.last_timestamp = timestamp

# 位运算拼接:时间戳 << 22 | 机器 << 17 | 数据中心 << 12 | 序列号
return (
((timestamp - self.epoch) << 22) |
(self.datacenter_id << 17) |
(self.worker_id << 12) |
self.sequence
)


# 用法
snowflake = Snowflake(datacenter_id=1, worker_id=1)
new_id = snowflake.next_id()
print(f"ID: {new_id}")
print(f"二进制: {bin(new_id)}")
print(f"十进制位数: {len(str(new_id))}") # 通常 18-19 位

2.3 Snowflake 的核心优势

① 纯内存位运算,性能极高

整个生成过程只有位移(<<)和按位或(|)操作,在 CPU 层面是纳秒级的。不需要网络调用,不需要磁盘 I/O,不依赖数据库自增锁。单机 QPS 轻松突破百万。对比 UUIDv4 需要调用密码学安全的随机数生成器(如 os.urandom),Snowflake 的 CPU 开销几乎可以忽略不计。

② 局部单调递增,对数据库索引极度友好

这是 Snowflake 战胜 UUIDv4 的最关键优势。Snowflake 的高位是时间戳,这意味着ID 天然按时间排序。当插入数据库时,新记录总是落在 B-Tree(PostgreSQL)或 B+Tree(MySQL)索引的最右侧叶子节点上。

③ 空间紧凑

Snowflake ID 是一个 8 字节的 BIGINT。UUIDv4 是 16 字节(PostgreSQL 的 UUID 类型)或 36 字节(字符串形式)。对于千万级数据量,索引大小的差距是数倍的——而更小的索引意味着更高的 Buffer Pool 命中率。

三、UUIDv4:安全但”暴力”的随机方案

3.1 为什么之前我一直用 UUID

回到导师的警告——自增 ID 会被遍历。UUIDv4 的核心价值在于:完全不可预测

1
2
3
4
5
import uuid

# UUIDv4:基于随机数(或伪随机数)
id_v4 = uuid.uuid4()
print(id_v4) # 例如:550e8400-e29b-41d4-a716-446655440000

UUIDv4 的 128 位中有 122 位由随机数生成(剩余的 6 位是固定的版本和变体位)。攻击者无法从 550e8400-e29b-41d4-a716-446655440000 推算出 550e8400-e29b-41d4-a716-446655440001,因为下一个 ID 是另一个完全随机的值。这满足了安全 ID 设计的核心原则:不可预测、无法遍历、不携带业务信息

3.2 UUIDv4 的致命伤:B-Tree 索引的”页分裂灾难”

然而,UUIDv4 的安全性是有代价的。代价就在于它对数据库索引的写入性能造成了严重破坏。

要理解这个问题,需要先回顾 PostgreSQL 的 B-Tree 索引结构:

  • B-Tree 的叶子节点按 key 值有序排列,使用双向链表连接
  • 每个节点(Page)大小固定为 8KB(PostgreSQL 默认)
  • 当向一个已满的节点中间插入新 key 时,需要页分裂(Page Split)

UUIDv4 是完全随机的。这意味着新插入的 ID 可能落在 B-Tree 的任何位置——可能是第一个叶子节点,也可能是中间某个已满的节点。

1
2
3
4
5
6
7
8
9
页分裂示意(UUIDv4 随机插入):

插入前:[10, 20, 30] (满) → [40, 50, 60]

插入 UUID: 25 → 页分裂!

分裂后:[10, 20] → [25, 30] → [40, 50, 60]
↑ ↑
旧页(数据搬迁) 新页

每次页分裂都涉及:

  • 分配新的数据页(8KB)
  • 将原页一半的数据搬迁到新页
  • 更新父节点的指针
  • 产生磁盘碎片和 WAL 日志膨胀

在写入密集的场景下,UUIDv4 的随机性会导致大量的页分裂,写入 TPS 可能比有序 ID 低 30%~50%。而且页分裂会导致索引页的填充率下降(通常在 65%~75% 之间),B-Tree 变”胖”,Buffer Pool 命中率随之降低。

3.3 PostgreSQL 中的 UUID 存储

有一点值得注意:PostgreSQL 有原生的 UUID 类型,存储为 16 字节的二进制格式,而不是 36 字符的字符串。但这仍然比 Snowflake 的 8 字节 BIGINT 大了一倍。更大的 key 意味着更少的索引条目能放进一个 8KB 的页中,树的高度增加,每次查询的磁盘 I/O 更多。

主键类型 单 key 大小 每页约可容纳索引条目 千万级数据树高
BIGINT (Snowflake) 8 bytes ~800 3 层
UUID (PostgreSQL) 16 bytes ~400 3-4 层
UUID 字符串 (CHAR(36)) 36 bytes ~190 4 层

四、Snowflake 的安全隐患:被忽视的信息泄露

这正是那篇CSDN 博客的核心论点。Snowflake 并非银弹——它在安全性上存在两个严重缺陷:

4.1 ID 可解析,暴露元数据

由于 Snowflake 的 ID 结构是公开的,任何人都可以逆向解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def parse_snowflake(snowflake_id: int, epoch: int = 1700000000000):
"""从 Snowflake ID 中解析出时间戳和机器信息"""
timestamp = (snowflake_id >> 22) + epoch
datacenter_id = (snowflake_id >> 17) & 0x1F
worker_id = (snowflake_id >> 12) & 0x1F
sequence = snowflake_id & 0xFFF

import datetime
create_time = datetime.datetime.fromtimestamp(timestamp / 1000)

return {
'create_time': str(create_time),
'datacenter_id': datacenter_id,
'worker_id': worker_id,
'sequence': sequence,
}

# 攻击者可以这样分析
id_sample = 728649384756383744
info = parse_snowflake(id_sample)
print(info)
# 输出:{'create_time': '2026-07-31 12:00:00', 'datacenter_id': 1, 'worker_id': 1, 'sequence': 0}

攻击者可以从你的 ID 中得知:

  • 每一条数据的精确创建时间(毫秒级)
  • 你的系统可能有多少个数据中心、多少台机器
  • 通过采样可以推算出业务量的时间分布(每个时间段的 ID 序列号密度)

更重要的是,Snowflake ID 的递增性虽然不如自增 ID 那样”连续”,但依然是 可预测的趋势递增。攻击者可以大致猜测出下一批 ID 的范围,降低了遍历的难度。

4.2 Snowflake 满足安全四原则吗?

回顾一下安全 ID 设计的四个核心原则:

原则 Snowflake UUIDv4 UUIDv7
不可预测性 ❌ 趋势递增,可预测范围 ✅ 完全随机 ⚠️ 前缀可预测,后缀随机
不携带业务信息 ❌ 包含时间戳、机器 ID ✅ 纯随机 ⚠️ 包含时间戳
无法遍历 ⚠️ 稀疏但可测范围 ✅ 无法遍历 ✅ 后缀加密级随机,无法遍历
加密友好 ❌ 原始值可被解析

Snowflake 在四个原则中最多及格一条半。对于面向公网的系统,直接使用 Snowflake 存在信息泄露风险。

五、UUIDv7:鱼与熊掌的”最优解”

UUIDv7(RFC 9562)是一个专门为平衡安全性和数据库性能而设计的新标准。

5.1 UUIDv7 的结构

1
2
3
4
5
+------------------+-----------------------+
| 48 bit | 74 bit |
| Unix 时间戳(ms) | 密码学安全随机数 |
+------------------+-----------------------+
高位(有序) 低位(随机)
  • 前 48 位:Unix 时间戳(毫秒),保证 ID 按时间排序 → 数据库顺序写入
  • 后 74 位:由密码学安全的随机数生成器产生,保证 ID 不可预测 → 安全性
  • 版本位标记为 7

5.2 Python 生成 UUIDv7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import os
import time
import uuid

def uuid7() -> uuid.UUID:
"""生成 UUIDv7(RFC 9562 兼容)"""
# 48 位 Unix 时间戳(毫秒)
timestamp_ms = int(time.time() * 1000) & 0xFFFFFFFFFFFF

# 74 位随机数(密码学安全)
rand_bytes = os.urandom(10) # 10 bytes = 80 bits,取 74 bits

# 构造 128 位的 UUID 字节序列
# 将 timestamp_ms 拆分为 6 bytes(48 bits),rand_bytes 取 10 bytes(80 bits)
uuid_bytes = bytearray(16)

# 写入时间戳(大端序,前 6 字节)
uuid_bytes[0:6] = timestamp_ms.to_bytes(6, byteorder='big')

# 写入随机数(后 10 字节)
uuid_bytes[6:16] = rand_bytes

# 设置 UUID 版本位(4 bits)和变体位(2 bits)
uuid_bytes[6] = (uuid_bytes[6] & 0x0F) | 0x70 # version 7
uuid_bytes[8] = (uuid_bytes[8] & 0x3F) | 0x80 # variant 10xx

return uuid.UUID(bytes=bytes(uuid_bytes))


# 使用示例
for _ in range(5):
uid = uuid7()
print(uid)
# 输出示例:
# 01936c8a-1a00-7d0e-a06e-8d8c3f4a5b12
# 01936c8a-1a00-7a1f-b1c2-d3e4f5a6b7c8
# 01936c8a-1a01-7c3d-e5f6-a7b8c9d0e1f2
# 注意:前缀(高 48 位 时间戳部分)是递增的,后缀(低 74 位)完全随机

# 验证有序性
ids = [uuid7() for _ in range(1000)]
assert sorted(ids) == ids, "UUIDv7 IDs must be sortable by time"
print("✅ 所有 ID 均按时间递增")

5.3 UUIDv7 如何同时解决安全和性能问题

安全性方面:

UUIDv7 的后 74 位是用 os.urandom(Python)/ SecureRandom(Java)生成的密码学级别随机数。攻击者即使知道你的 ID 前缀(时间戳部分),也无法从 01936c8a-1a00-7d0e-a06e-8d8c3f4a5b12 推算出同秒内的下一个 ID——因为后缀的随机空间高达 2⁷⁴ ≈ 1.89 × 10²²。遍历是完全不可行的。

同时,UUIDv7 的前缀只能推断到毫秒级别的时间范围(一秒有 1000 毫秒,同一毫秒内可能有多个 ID),无法精确定位某条数据的创建时刻,信息泄露程度远低于 Snowflake。

性能方面:

UUIDv7 的核心洞察是:B-Tree 的插入性能不要求 key 绝对连续,只要求 key 大致有序。 因为时间戳放在高位,UUIDv7 天然具备宏观有序性——同一秒内新产生的 ID 总是大于前一秒产生的 ID,新插入的记录总是落在索引树的”右边缘区域”。

1
2
3
4
5
6
UUIDv7 的插入行为(示意图):

时间轴 →
t1: [01936c8a-1a00-7xxx-...] → 写入叶子节点 L3(当前最右侧)
t2: [01936c8a-1a01-7xxx-...] → 写入叶子节点 L3(仍最右侧,同秒内略随机)
t3: [01936c8a-1a02-7xxx-...] → 写入叶子节点 L3 或新节点 L4(追加)

同一毫秒内的随机后缀意味着在”右边缘”会有轻微的乱序写入,但这种微乱序的影响极小——页分裂发生在最新的几个节点内部,而不是像 UUIDv4 那样发生在整棵树的任意位置。

有 benchmark 数据显示,UUIDv7 的写入性能接近 Snowflake 的 85%~95%,远优于 UUIDv4(约为 Snowflake 的 50%~70%)。

六、完整对比与选型建议

6.1 四类 ID 方案全景对比

维度 自增 ID Snowflake UUIDv4 UUIDv7
存储大小 4-8 bytes 8 bytes 16 bytes (binary) 16 bytes (binary)
写入性能 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐
查询性能 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
不可预测性 ❌ 完全可预测 ❌ 趋势可预测 ✅ 完全随机 ✅ 后缀不可预测
遍历防护 ❌ 可直接遍历 ⚠️ 可测范围 ✅ 无法遍历 ✅ 无法遍历
信息泄露 ❌ 暴露规模 ⚠️ 暴露时间+架构 ✅ 无泄露 ⚠️ 仅暴露毫秒级时间
无中心化生成 ❌ 依赖DB
全局唯一 ⚠️ 仅单库
PostgreSQL 支持 SERIAL/BIGSERIAL BIGINT(手动) 原生 UUID 类型 手工生成 + UUID 类型

6.2 我的选型建议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────────┐
│ 你的场景是什么? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 内部服务间调用、不暴露给用户 │
│ ├── Snowflake ✅ 高性能、小体积,无安全顾虑 │
│ │
│ 对外暴露的 API,需要防爬取 │
│ ├── UUIDv7 ✅ 安全与性能的最佳平衡 │
│ ├── UUIDv4 ⚠️ 安全但吃性能,小项目可接受 │
│ │
│ 日志/事件/消息队列的 ID │
│ ├── Snowflake ✅ 时间有序、紧凑,天然适合排序和范围查询 │
│ │
│ 已有 PostgreSQL + 安全要求高 │
│ ├── UUIDv7 ✅ 原生 UUID 类型存储,性能优于 v4 │
│ │
│ 简单内部系统,无外部暴露 │
│ ├── 自增 ID ✅ 简单够用,不暴露即可 │
│ │
└─────────────────────────────────────────────────────────────────┘

对于我个人而言:之前用 UUIDv4 的智教未来和启材致学项目,考虑到数据量还未到千万级别,暂时没有迫切的迁移需求。但今后的新项目我会默认使用 UUIDv7——它在引入极小的额外开销下,同时满足了安全性和数据库写入性能两个看似矛盾的需求。

七、写在最后

回顾这次 ID 选型的探索过程,我最大的感受是:技术选型从来不是一个维度的最优解,而是在多个约束条件下的权衡。

  • 导师教会了我:安全性不能妥协——自增 ID 的教训是真金白银的
  • Snowflake 教会了我:性能是可以被设计的——位运算 + 时间有序,巧妙到令人赞叹
  • UUIDv4 教会了我:安全的代价可能是性能——页分裂不是理论问题,是真实存在的
  • UUIDv7 教会了我:工程迭代可以同时逼近两个目标——“时间前缀 + 随机后缀”的思路简洁而深刻

没有绝对的最好方案,只有最适合你当前场景的方案。希望这篇梳理能对同样在纠结 ID 选型的你有所帮助。

写完这篇博客,我又去翻了一下 RFC 9562 的原文。RFC 文档这种看似枯燥的东西,其实藏着很多工程师的智慧——UUIDv7 的设计就是一种典型的工程折衷思维:我不追求极致的性能,也不追求绝对的安全,我追求在满足安全底线的前提下最大化性能。这大概就是”够用就好”的工程哲学吧。