2021 年我做一个订单系统,要给每一笔订单生成一个全局唯一的订单号。第一版我做得很省事:订单表是 MySQL,我直接用了表的自增主键当订单号。本地一测——完美:每插一条订单,ID 自动 +1,从不重复。我心里很踏实:"生成唯一 ID 嘛,数据库自增不就行了,要么随手 UUID 一下。"可等系统真正长大、扛着真实的业务量,一串问题冒了出来。第一种最先把我打懵:订单量上来后我做了分库分表,把订单拆到两个库——结果两个库各自从 1 开始自增,很快就生成了一模一样的订单号。第二种:我改用 UUID,这下全局唯一了,可订单表的写入肉眼可见地变慢、索引文件飞快膨胀——UUID 是完全无序的,拿它做主键,B+ 树索引每次插入都在页中间乱捅,频繁分裂。第三种:我上了雪花算法,跑了一阵挺好,可某天两台机器又生成了一样的订单号——排查半天发现,是两台机器配了同一个机器号。第四种最隐蔽:运维做了一次 NTP 校时,有台机器的系统时钟往回跳了几秒,雪花算法立刻吐出了重复 ID。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"生成唯一 ID,就是用数据库自增,或者随手 UUID 一下"。这句话把"本地唯一"和"分布式下全局唯一且高性能"当成了一回事。可它不是。数据库自增只在单表里唯一,分库就撞;UUID 全局唯一却完全无序,做主键会拖垮写入;雪花算法解决了这两点,但它依赖两个必须由你保证的外部前提——每台机器有唯一的机器号、机器时钟不会往回跳。真正用好分布式 ID,核心不是"抄一个雪花算法实现",而是理解一个好的分布式 ID 要同时满足全局唯一、趋势递增、高性能,并把机器号分配和时钟回拨这两个隐藏前提守住。这篇文章就把分布式 ID 生成梳理一遍:为什么自增和 UUID 都不够、雪花算法怎么用 64 位塞下时间机器和序列、怎么自己实现一个生成器、时钟回拨这个最危险的坑怎么处理、机器号怎么分配才不撞,以及序列号耗尽、位数权衡、ID 反解析这些把分布式 ID 真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:用数据库自增、再换 UUID、再换雪花算法之后,一串问题接连冒出来:分库分表后两个库生成了相同的自增 ID;UUID 做主键导致写入变慢、索引膨胀(完全无序、频繁页分裂);雪花算法下两台机器配了相同机器号、生成重复 ID;一次 NTP 校时让时钟回拨,雪花算法立刻吐出重复 ID。
我当时的错误认知:"生成唯一 ID,就是用数据库自增,或者随手 UUID 一下就行了。"
真相:一个能用在分布式系统里的 ID,要同时满足三个要求:全局唯一(任何机器、任何时刻都不重复)、趋势递增(后生成的大致比先生成的大,这样做数据库主键才对索引友好)、高性能(生成快、不依赖中心节点)。数据库自增满足递增和唯一,但它的唯一只在单表范围内,分库就撞,而且依赖数据库这个中心点。UUID 满足全局唯一,却完全无序——拿它做主键,会让 B+ 树索引不停页分裂,写入越来越慢。雪花算法(Snowflake)三个要求都满足,但它有两个必须由你来保证的前提:每台机器分到唯一的机器号、机器的系统时钟不会回拨。这两个前提有一个没守住,它就会生成重复 ID。
要把分布式 ID 做对,需要几块认知:
- 为什么自增 ID 和 UUID 在分布式下都不够——唯一性、有序性、性能三者难全;
- 雪花算法的结构——64 位里怎么塞下时间戳、机器号、序列号;
- 怎么自己实现一个生成器——同毫秒靠序列号、跨毫秒靠时间戳;
- 时钟回拨——雪花算法最危险的坑,必须主动检测;
- 机器号分配、序列号耗尽、位数权衡这些工程坑怎么处理。
一、为什么"自增 ID"和"随手 UUID"都不够
先把这件最根本的事钉死:一个 ID 方案好不好,要放在"分布式"这个前提下,用三把尺子量:它在所有机器之间是不是真的唯一?它生成出来是不是趋势递增的(这决定了它做数据库主键时,对 B+ 树索引友不友好)?它生成的时候快不快、要不要依赖一个中心节点?数据库自增和 UUID,各自都只能过这三把尺子里的一部分——而恰恰是它们过不了的那部分,会在系统长大之后,变成真正要命的问题。
下面这两段代码,就是我那两个先后失败的版本:
import uuid
# 反面教材一:用数据库自增主键当全局 ID
# INSERT INTO orders (...) VALUES (...); -- 拿 last_insert_id() 当订单号
# 破绽:自增只在"这一张表"里唯一。一旦分库分表,
# order_db_0 和 order_db_1 各自从 1 开始增,必然撞号。
# 而且每生成一个 ID 都要碰一次数据库,数据库成了中心瓶颈。
# 反面教材二:用 UUID 当全局 ID
def make_id_uuid():
return uuid.uuid4().hex # 形如 "9b1deb4d3b7d4bad9bdd2b0d7b3dcb6d"
# 破绽:UUID 确实全局唯一,但它是"完全无序"的随机串。
# - 拿它做 MySQL 主键,新行会插到 B+ 树索引的随机位置,
# 导致索引页频繁分裂、写入越来越慢;
# - 它有 32 个十六进制字符,比一个 64 位整数占用大得多;
# - 它对人不可读,你没法从 ID 里看出任何信息。
这两段代码在本地、在系统还小的时候,都跑得好好的。它们的问题不在代码本身,而在一个被忽略的前提:第一版默认"系统永远是单库",第二版默认"无序的 ID 做主键没关系"。可这两个默认都会被系统的成长打破。于是那串问题就有了解释:分库后撞号,是因为自增的"唯一"只是单表范围的;写入变慢,是因为 UUID 的无序,和 B+ 树"按主键顺序存放"的机制是天生冲突的。问题的根子清楚了:我们需要的,是一个既全局唯一、又趋势递增、还不依赖中心节点的 ID。这正是雪花算法要解决的。
二、雪花算法:64 位里塞下时间、机器、序列
雪花算法(Snowflake)是 Twitter 提出的一个方案,它的思路极其精巧:用一个 64 位的整数(正好是 Java 的 long、也能塞进数据库的 BIGINT),把它的二进制位切成几段,每段存一样东西:
# 雪花算法:把一个 64 位整数的比特位,切成四段
#
# 1 位 41 位 10 位 12 位
# +------+----------------+-------------+--------------+
# | 符号 | 时间戳(毫秒) | 机器号 | 序列号 |
# +------+----------------+-------------+--------------+
#
# - 符号位:固定为 0,保证生成的 ID 是正数
# - 41 位时间戳:存"当前毫秒 - 自定义纪元",够用约 69 年
# - 10 位机器号:最多支持 1024 台机器各不相同
# - 12 位序列号:同一毫秒内,单台机器最多生成 4096 个 ID
EPOCH = 1700000000000 # 自定义起始纪元(毫秒),一旦定下不可改
WORKER_ID_BITS = 10 # 机器号占 10 位
SEQUENCE_BITS = 12 # 序列号占 12 位
MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 # = 1023
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1 # = 4095
WORKER_SHIFT = SEQUENCE_BITS # 机器号左移 12 位
TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS # 时间戳左移 22 位
理解了这个结构,雪花算法为什么能同时满足三个要求就清楚了:趋势递增,是因为最高的有效位是时间戳——时间往前走,ID 整体就在变大;全局唯一,是因为同一毫秒内靠"机器号 + 序列号"区分:不同机器机器号不同,同一机器同一毫秒靠序列号区分;高性能,是因为生成 ID 只是几个位运算和取时间戳,纯本地计算,完全不碰数据库、不碰网络。这里的认知要点是:雪花算法不是什么魔法,它只是把"时间 + 空间(机器) + 同一时刻的计数(序列)"这三个维度,巧妙地编码进了一个 64 位整数里——三个维度任意一个都相同,才会撞,而这几乎不可能。把这个结构变成能跑的代码,就是下一步。
三、自己实现一个雪花算法生成器
现在来实现核心的 next_id。它的逻辑是一个清晰的分情况讨论:取当前毫秒,和上一次生成 ID 的毫秒比一比——如果还在同一毫秒,就把序列号 +1;如果进入了新的毫秒,序列号归零重新开始:
import time
import threading
class SnowflakeGenerator:
def __init__(self, worker_id):
if worker_id < 0 or worker_id > MAX_WORKER_ID:
raise ValueError(f"机器号必须在 0~{MAX_WORKER_ID} 之间")
self.worker_id = worker_id
self.last_ts = -1 # 上一次生成 ID 的时间戳
self.sequence = 0 # 当前毫秒内的序列号
self.lock = threading.Lock() # 多线程下必须加锁
def _now(self):
return int(time.time() * 1000)
def next_id(self):
with self.lock:
ts = self._now()
if ts == self.last_ts:
# 还在同一毫秒内:序列号 +1
self.sequence = (self.sequence + 1) & MAX_SEQUENCE
if self.sequence == 0:
# 序列号 4096 个用完了,自旋等到下一毫秒
ts = self._wait_next_ms(self.last_ts)
else:
# 进入了新的一毫秒:序列号归零
self.sequence = 0
self.last_ts = ts
# 把三段拼成最终的 64 位 ID
return (((ts - EPOCH) << TIMESTAMP_SHIFT)
| (self.worker_id << WORKER_SHIFT)
| self.sequence)
def _wait_next_ms(self, last_ts):
"""自旋等待,直到进入下一毫秒。"""
ts = self._now()
while ts <= last_ts:
ts = self._now()
return ts
这段代码里有三个细节值得说清楚。第一,加锁:next_id 会读写 last_ts 和 sequence 这两个共享状态,多线程同时调用必须加锁,否则两个线程可能读到同一个序列号、生成重复 ID。第二,序列号耗尽的处理:同一毫秒内序列号只有 4096 个,(self.sequence + 1) & MAX_SEQUENCE 在第 4096 个之后会回绕成 0,这时候不能继续发(会和这一毫秒前面的撞),必须 _wait_next_ms 自旋等到下一毫秒。第三,拼接:最后那个位运算,就是把时间戳、机器号、序列号各自移到自己的位段上,再用按位或拼起来。这里的认知要点是:雪花算法的唯一性,靠的是"同一毫秒内,序列号绝不重复"——而这恰恰建立在一个假设上:时间戳是单调向前的。一旦这个假设被打破,整个唯一性就崩了。那个打破假设的元凶,就是时钟回拨。
四、时钟回拨:雪花算法最危险的坑
开头那个"NTP 校时后吐出重复 ID",就是时钟回拨。机器的系统时钟并不是绝对可靠、绝对单调的:它会因为 NTP 时间同步而被校准,这个校准可能是往前跳,也可能是往回拨。一旦时钟往回拨,问题就来了:假设上一个 ID 是在时刻 T 生成的,时钟回拨到了 T-3 这个时刻——那么接下来在 T-3 到 T 之间生成的 ID,就会和之前在这段时间里已经生成过的 ID,撞个正着。上面那版 next_id 对此毫无防备,因为它的判断只有 ts == self.last_ts 和 else——当 ts < self.last_ts 时(也就是回拨发生时),它会走进 else 分支,把序列号归零、照常发号,直接制造重复。所以必须显式地检测时钟回拨:
def next_id_safe(self):
with self.lock:
ts = self._now()
# 关键:检测时钟回拨 —— 当前时间居然比上次还早
if ts < self.last_ts:
offset = self.last_ts - ts
if offset <= 5:
# 回拨幅度很小(5 毫秒内):等一会儿,让时间追上来
time.sleep(offset / 1000.0)
ts = self._now()
if ts < self.last_ts:
raise RuntimeError("时钟回拨,等待后仍未追上")
else:
# 回拨幅度大:绝不能继续发号,直接抛错让上层处理
raise RuntimeError(f"时钟回拨 {offset}ms,拒绝生成 ID")
if ts == self.last_ts:
self.sequence = (self.sequence + 1) & MAX_SEQUENCE
if self.sequence == 0:
ts = self._wait_next_ms(self.last_ts)
else:
self.sequence = 0
self.last_ts = ts
return (((ts - EPOCH) << TIMESTAMP_SHIFT)
| (self.worker_id << WORKER_SHIFT)
| self.sequence)
这个 next_id_safe 的核心,就是开头新增的那段 if ts < self.last_ts 检测。它的处理策略是分级的:小幅回拨(几毫秒内),就 sleep 一下等时间自己追上来;大幅回拨,就宁可抛错、拒绝发号,也绝不生成可能重复的 ID。这背后是一个重要的取舍:对一个 ID 生成器来说,"暂时发不出号"是一个可以容忍的故障(上层重试、降级即可),而"发出了重复的号"是一个会污染数据、极难追查的灾难——所以宁可停,不可错。这里的认知要点是:雪花算法的正确性,挂在"机器时钟单调递增"这根弦上;时钟回拨检测,不是锦上添花的可选项,而是用雪花算法就必须配上的安全带。时钟的坑守住了,还有另一个前提:机器号。
五、机器号怎么分配:别让两台机器撞号
开头那个"两台机器配了同一个机器号",是雪花算法的另一个隐藏前提没守住。雪花算法保证唯一,大前提是"每台机器的机器号都不一样"——如果两台机器机器号相同,它们在同一毫秒、又恰好生成到同一个序列号时,就会产出完全相同的 ID。我第一版的机器号是在配置文件里手写死的,部署一多,难免复制粘贴时漏改,撞号就这么来了。机器号的分配,不能靠人手填,要自动且能保证唯一。一种简单的办法是从机器的内网 IP 推导:
import socket
def worker_id_from_ip():
"""用内网 IP 的最后一段推导机器号(适合机器数不多的小集群)。"""
ip = socket.gethostbyname(socket.gethostname())
last_segment = int(ip.split(".")[-1])
# 10 位机器号最大 1023,IP 末段是 0~255,够用
return last_segment & MAX_WORKER_ID
# 局限:如果两台机器 IP 末段相同(比如跨网段),还是会撞。
# 小集群够用,严肃场景要用下面的"注册式分配"。
更严肃、可靠的办法,是引入一个中心来"发号":让每台机器启动时,去一个中心服务(Redis、ZooKeeper、数据库都行)申请一个还没被人占用的机器号。下面用 Redis 演示这个"注册式分配":
import redis
_r = redis.Redis(host="127.0.0.1", port=6379)
def acquire_worker_id():
"""启动时向 Redis 申请一个全局唯一、且没人占用的机器号。"""
for candidate in range(MAX_WORKER_ID + 1):
key = f"snowflake:worker:{candidate}"
# SET NX:只有这个号还没被占用,才能占成功
# EX 60:占用带 60 秒过期,需要靠心跳续期,防止机器宕机后号被永久占住
if _r.set(key, "occupied", nx=True, ex=60):
print(f"申请到机器号 {candidate}")
return candidate
raise RuntimeError("机器号已分配满,无法再申请")
# 申请到之后,要起一个后台心跳,定期给这个 key 续期(EXPIRE),
# 让别的机器知道"这个号还活着、别来抢";
# 机器正常下线时,主动 DEL 掉这个 key,把号还回去。
这里的认知要点是:雪花算法把"全局唯一"这件事,拆成了"机器之间靠机器号唯一"和"机器内部靠时间+序列唯一"两半;机器号的唯一性,雪花算法自己保证不了,必须由你用一套可靠的分配机制(注册 + 心跳续期 + 下线归还)从外部兜住。下面这张图,把一次安全的 ID 生成流程串起来:
六、工程坑:序列号耗尽、位数权衡与 ID 反解
五块设计之外,还有几个工程坑,不处理就会让分布式 ID 不稳或踩雷。坑 1:序列号耗尽不是罕见事,要想清楚怎么办。同一毫秒内单机只能发 4096 个 ID,对高并发系统,这不难撞到。上面的实现是自旋等下一毫秒——这意味着那一瞬间生成 ID 会有微小阻塞。如果你的并发极高,要么调整位数分配(给序列号多分几位),要么预生成一批 ID 缓存起来。坑 2:位数分配不是铁板一块,可按业务调。标准的 41+10+12 不是唯一答案:如果你的机器数很少,可以压缩机器号位数、把位数让给序列号,提高单机每毫秒的产量:
# 位数分配可以按业务特点调整,只要三段加起来不超过 63 位
#
# 标准版:41 位时间 + 10 位机器 + 12 位序列
# -> 1024 台机器,单机每毫秒 4096 个
#
# 高并发版:41 位时间 + 5 位机器 + 17 位序列
# -> 只支持 32 台机器,但单机每毫秒能发 131072 个
WORKER_ID_BITS_HIGH_QPS = 5
SEQUENCE_BITS_HIGH_QPS = 17
# 取舍很直白:机器号的位 和 序列号的位,是此消彼长的,
# 机器多就给机器号多分位,并发高就给序列号多分位。
坑 3:雪花 ID 是可以反解析的,这是它的一个隐藏福利。因为 ID 的每一段都有确定的含义,你随时能从一个 ID 反推出它是什么时候、由哪台机器生成的——这在排查问题时极其有用:
def parse_id(snowflake_id):
"""从一个雪花 ID 反解出:生成时间、机器号、序列号。"""
sequence = snowflake_id & MAX_SEQUENCE
worker_id = (snowflake_id >> WORKER_SHIFT) & MAX_WORKER_ID
timestamp = (snowflake_id >> TIMESTAMP_SHIFT) + EPOCH
return {
"生成时间(毫秒)": timestamp,
"机器号": worker_id,
"毫秒内序列号": sequence,
}
# 拿到一个有问题的订单号,parse 一下就知道
# 它是哪台机器、在哪个毫秒生成的 —— 排查问题的利器。
坑 4:EPOCH 一旦定下,永远不能改。时间戳段存的是"当前毫秒减去 EPOCH",如果你上线后改了 EPOCH,新生成的 ID 会和老 ID 的大小关系全乱,趋势递增当场失效,甚至可能和历史 ID 撞号。坑 5:别忘了给生成器留降级方案。万一遇到无法处理的大幅时钟回拨、或者机器号申请不到,生成器会抛错——调用方必须接住,并想好降级:是短暂重试,还是临时切到一个备用的发号服务?一个健壮的 ID 系统,既要保证发出的号永不重复,也要想清楚"发不出号时怎么办"。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 分布式 ID 三要求 | 全局唯一、趋势递增、生成高性能不依赖中心 |
| 数据库自增 | 单表内唯一,分库分表后会撞,依赖数据库中心点 |
| UUID | 全局唯一但完全无序,做主键导致索引页分裂、写慢 |
| 雪花算法 | 64 位 = 符号位 + 时间戳 + 机器号 + 序列号 |
| 趋势递增原理 | 时间戳在高位,时间前进 ID 整体变大 |
| 序列号 | 同一毫秒内单机的计数,12 位即 4096 个,耗尽要等下一毫秒 |
| 时钟回拨 | 系统时钟往回跳,会让雪花算法生成重复 ID |
| 回拨处理策略 | 小幅回拨等待,大幅回拨拒绝发号,宁停不错 |
| 机器号分配 | 注册式分配加心跳续期,保证每台机器号唯一 |
| ID 反解析 | 从 ID 反推生成时间和机器号,排查问题利器 |
避坑清单
- 数据库自增只在单表唯一,分库分表后必然撞号。
- UUID 全局唯一但无序,做主键会让 B+ 树索引频繁页分裂。
- 分布式 ID 要同时满足全局唯一、趋势递增、生成高性能三点。
- 雪花算法用 64 位塞下时间戳、机器号、序列号,纯本地计算。
- next_id 读写共享状态,多线程调用必须加锁。
- 同毫秒序列号只有 4096 个,耗尽要自旋等到下一毫秒。
- 时钟回拨会生成重复 ID,必须显式检测 ts 小于 last_ts。
- 回拨小幅就等待,大幅就拒绝发号,宁可停也不发重复号。
- 机器号不能手填,要用注册式分配加心跳续期保证唯一。
- EPOCH 定下后永不能改,生成器抛错时调用方要有降级。
总结
回头看那串"分库撞号、UUID 拖慢写入、机器号撞车、时钟回拨吐重复"的问题,以及我后来在分布式 ID 上接连踩的坑,最该记住的不是某一段位运算,而是我动手前那个想当然的判断——"生成唯一 ID,就是用数据库自增,或者随手 UUID 一下"。这句话错在它把"唯一"这个词,想得太简单了。我以为"唯一"就是"不重复",可在分布式系统里,一个能用的 ID,要的不只是"不重复":它还要趋势递增(否则做主键会拖垮数据库),还要生成时不依赖中心节点(否则那个中心就是瓶颈和单点)。数据库自增丢了"跨库的唯一",UUID 丢了"递增"——它们都只满足了"唯一"这个要求的一部分,而我却以为满足了全部。
所以做分布式 ID,真正的工程量不在"抄一份雪花算法的实现"那几十行代码上。那几十行,网上到处都是。真正的工程量,在于你要看懂雪花算法那份精巧背后,藏着两个它自己保证不了、必须由你来兜住的前提:它假设每台机器的机器号都不同——所以你要搭一套注册式的机器号分配,配上心跳续期和下线归还;它假设机器的时钟单调向前——所以你要加上时钟回拨检测,小幅回拨等一等、大幅回拨干脆拒绝发号。这篇文章的几节,其实就是顺着这条线展开的:先想清楚自增和 UUID 为什么不够,再讲雪花算法怎么用 64 位编码三个维度、怎么实现核心的发号逻辑、时钟回拨这根弦怎么守、机器号怎么分配才不撞,最后是序列号耗尽、位数权衡、ID 反解析这几个把分布式 ID 做扎实的工程细节。
你会发现,分布式 ID 生成,和现实里"一家有很多门店的连锁店,怎么给每一张小票编号"完全相通。一个没想清楚的老板会怎么做?他让每家店都从 1 号小票开始编(这就是数据库自增)——单看一家店没问题,可全公司一汇总,无数张"1 号小票"撞在一起。他改让每张小票编一长串随机乱码(这就是 UUID)——是不撞了,可这串乱码没法排序、占地方、谁也看不懂。而一个真想明白的老板怎么做?他给每张小票号设计一个有结构的编码:开头是日期时间(这就是时间戳,保证了大致有序)、中间是门店编号(这就是机器号,保证了店与店之间不撞)、结尾是这家店这一刻的流水号(这就是序列号)。而且他知道,这套编码能用,有两个前提必须他自己盯死:每家店的门店编号绝不能发重(这就是机器号分配),每家店墙上的钟不能往回拨(这就是时钟回拨)。
最后想说,分布式 ID 做没做对,差距永远不会在"本地生成几千个都不重复"时暴露——本地你只有一台机器、一个进程、时钟也老老实实地走,你会觉得"抄个雪花算法、传个机器号"已经是全部。它只在真实的、有很多台机器、有分库分表、运维偶尔就要校一次时的线上环境里才显形。那时候它会用最难追查的方式给你结账:做不好,你会像我一样,在某天发现两笔毫不相干的订单,挂着同一个订单号——下游对账对不平、用户投诉、数据被悄悄覆盖,而你几乎无法复现,因为它只在两台机器、同一毫秒、同一序列号这种极端巧合下才发生;而做对了,你的 ID 生成会稳得让人忘记它的存在:每一个号全局唯一、趋势递增,做数据库主键对索引温柔,拿到任何一个 ID 都能反解出它的来历,哪怕运维校了时,回拨检测也会悄悄把它挡下。所以别等"两笔订单撞了同一个号"找上门,在你写下那行 worker_id = 1 的那一刻就该想清楚:我用的不是一个能自动保证一切的魔法算法,而是一个把"唯一"拆成了时间、机器、序列三个维度的精巧编码——它的机器号唯不唯一、它的时钟回不回拨,这两根弦,我是不是都替它绷住了?这些问题有了答案,你拿到的才不只是一串"看起来不会重复"的数字,而是一套真正全局唯一、趋势递增、经得起分布式考验的 ID。
—— 别看了 · 2026