分布式ID生成方案:雪花算法、Leaf、美团方案对比
引言
去年双十一大促前夜,我们团队接到一个紧急工单:订单中心在凌晨扩容后,出现了大量主键冲突。排查发现,某台机器的系统时钟被 NTP 回拨了 8 毫秒——就这么不起眼的 8 毫秒,让 Snowflake 算法在同一毫秒内产生了重复 ID,直接导致上千笔订单写入失败。
这不是个例。在分布式系统中,ID 生成看似是最基础的功能,却往往是踩坑最多的地方之一:
- 用数据库自增?单点瓶颈,分库分表后直接失效。
- 用 UUID?无序性导致 InnoDB 的 B+ 树页分裂严重,写入性能断崖式下跌。
- 用 Redis 的 INCR?多一次网络 RTT,还要考虑持久化丢失的问题。
- 用 Snowflake?时钟回拨、workerId 分配、序列号耗尽,每一个都是坑。
这篇文章,我会从源码级别拆解三种主流方案——原生 Snowflake、美团 Leaf、百度 UidGenerator(附带对比美团 Leaf-snowflake 的改进),并结合我们线上真实的踩坑经验,给出选型建议。读完你应该能清楚地知道:在什么场景下用哪个方案,以及为什么。
核心概念:ID 生成器就像餐厅的取号机
先打个比方。
想象一家网红餐厅,门口有一台取号机。理想情况下,它要满足几个要求:
- 不重号:两个人不能拿到同一个号(唯一性)
- 号码递增:叫号时按顺序来,不能乱跳(有序性)
- 出号快:顾客多的时候不能卡住(高吞吐、低延迟)
- 机器坏了能换:不能因为一台取号机坏了整个餐厅瘫痪(高可用)
- 号码不能太长:太长的号码顾客记不住,也印不下(ID 长度可控)
分布式 ID 生成器的本质,就是把这个"取号机"做成一个分布式的、可水平扩展的系统。技术语言描述:
| 特性 | 说明 |
|---|---|
| 全局唯一 | 分布式环境下绝不产生重复 ID |
| 趋势递增 | 大致按时间递增,利于数据库索引 |
| 高可用 | 单点故障不影响整体服务 |
| 高并发 | 支撑每秒数万到数十万的生成量 |
| 信息安全 | 不暴露业务量、不连续可猜测 |
Snowflake 的核心思想:把 64 位切成几块
Snowflake 的精妙之处在于,它把 64 位长整型切成了几个功能块:
┌─┬─────────────────────────────┬──────────┬──────────┐
│0│ 41 bit 时间戳 │ 10 bit │ 12 bit │
│ │ (毫秒级,可用 69 年) │ 机器ID │ 序列号 │
└─┴─────────────────────────────┴──────────┴──────────┘- 1 bit 符号位:永远为 0,保证 ID 为正数。
- 41 bit 时间戳:存储的是"当前时间 - 起始纪元"的毫秒差。41 位能表示 2^41 毫秒 ≈ 69 年。
- 10 bit 机器 ID:最多支持 1024 个节点。这就是为什么扩容时要小心——workerId 分配错了,直接重复。
- 12 bit 序列号:同一毫秒内同一机器最多生成 4096 个 ID,也就是单机理论峰值 409.6 万/秒。
为什么这个设计能保证唯一? 因为三个维度构成了一个"三维坐标":(时间, 机器, 序列号)。只要任意一维不同,ID 就不会重复。时间保证了跨毫秒的唯一,机器保证了同毫秒跨节点的唯一,序列号保证了同节点同毫秒内的唯一。
源码级深度分析
一、原生 Snowflake:Twitter 的实现与它的致命缺陷
Twitter 官方 Scala 版本的核心逻辑(我把它翻译成 Java 便于理解):
public class Snowflake {
private final long twepoch = 1288834974657L; // 起始纪元 2010-11-04
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long sequenceBits = 12L;
private final long maxWorkerId = -1L ^ (-1L << workerIdBits); // 31
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); // 31
private final long sequenceMask = -1L ^ (-1L << sequenceBits); // 4095
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = timeGen();
// 【关键点1】时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException(
"Clock moved backwards. Refusing to generate id for "
+ (lastTimestamp - timestamp) + " milliseconds");
}
if (lastTimestamp == timestamp) {
// 同一毫秒内,序列号自增
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 【关键点2】序列号耗尽,自旋等待下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 新的毫秒,序列号归零
sequence = 0L;
}
lastTimestamp = timestamp;
// 拼装 ID
return ((timestamp - twepoch) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
}三个致命缺陷:
- 时钟回拨直接抛异常。生产环境 NTP 校时、虚拟机迁移都可能造成回拨,抛异常意味着服务不可用。
- workerId 靠人工配置。1024 个节点,靠配置文件维护?容器化环境下 Pod 频繁重启,根本无法保证唯一。
- 序列号耗尽时自旋。虽然只有 4096/ms 以上的极端场景才会触发,但自旋期间持有 synchronized 锁,会阻塞所有调用线程。
二、美团 Leaf:两种模式,各有取舍
美团的 Leaf 提供了两个方案,我先讲架构:
#### Leaf-segment:用"批发"代替"零售"
核心思想是批量从数据库取号,把压力从"每次请求一次 DB"降到"每 N 个 ID 才请求一次 DB"。
数据库表结构:
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) NOT NULL DEFAULT '',
max_id BIGINT NOT NULL DEFAULT 1,
step INT NOT NULL,
description VARCHAR(256),
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (biz_tag)
);双 Buffer 优化是 Leaf 的精髓。传统号段模式是"用完了才去取下一段",取号段的网络延迟会造成请求毛刺。Leaf 用双 Buffer:当前号段消耗到 10% 时,就异步去加载下一个号段。
public class SegmentBuffer {
private Segment[] segments = new Segment[2]; // 双 buffer
private volatile int currentPos = 0;
private volatile boolean nextReady = false;
private volatile boolean initOk = false;
// 当前号段消耗到 10% 时触发预加载
public boolean isNextReady() {
return nextReady;
}
public synchronized void switchPos() {
if (!nextReady) return;
currentPos = 1 - currentPos; // 切换 buffer
segments[currentPos].setId(segments[currentPos].getMin());
nextReady = false;
}
}优点:ID 严格递增、性能高(QPS 可达数万)、DB 依赖弱。
缺点:ID 有"跳跃"(每次换号段会跳一大截),会暴露业务量;强依赖 DB,DB 挂了只能撑到号段用完。
#### Leaf-snowflake:解决 workerId 和时钟回拨
Leaf-snowflake 在原生 Snowflake 基础上做了两个关键改进:
改进 1:workerId 由 ZooKeeper 自动分配
利用 ZK 的顺序持久节点特性:
// 启动时在 ZK 的 /snowflake/forever 下创建顺序节点
String path = zkClient.create()
.withMode(CreateMode.PERSISTENT_SEQUENTIAL)
.forPath("/snowflake/forever/worker-");
// 从节点名中解析出序号作为 workerId
// 例如 worker-0000000012 -> workerId = 12
long workerId = Long.parseLong(path.substring(path.lastIndexOf("-") + 1));改进 2:弱依赖 ZK + 本地文件缓存 workerId
关键设计是 ZK 只是启动时用一次。如果 ZK 挂了,重启的服务会读取本地文件缓存的 workerId,避免重新分配导致冲突。
改进 3:容忍小幅时钟回拨
这是 Leaf 最实用的改进。源码逻辑(简化):
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 【关键】回拨 5ms 以内,等待两倍时间追平
try {
wait(offset << 1);
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("wait failed");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} else {
// 回拨超过 5ms,直接失败(需要人工介入)
throw new RuntimeException("Clock moved backwards by " + offset);
}
}
// ... 后续同原生 Snowflake
}为什么是 5ms 和 2 倍等待? 因为 NTP 校时通常是渐进式的,小幅回拨是常态。等待 2×offset 是为了确保追平后还能保持趋势递增。这个阈值可以根据业务对时钟精度的容忍度调整。
三、百度 UidGenerator:用"未来时间"换性能
百度的 UidGenerator 有个非常聪明的设计——借用未来时间。
原生 Snowflake 的序列号只有 12 位,单毫秒只能生成 4096 个。UidGenerator 的 CachedUidGenerator 版本把序列号扩到 13 位(8192 个),并且预先生成一批 ID 放在 RingBuffer 里。
// RingBuffer 的核心:用位运算实现高性能环形队列
public class RingBuffer {
private final int bufferSize; // 必须是 2 的幂
private final long indexMask;
private final long[] slots;
private final PaddedAtomicLong[] flags; // 填充避免伪共享
public long take() {
long nextCursor = cursor.updateAndGet(
old -> old >= tail ? 0 : old + 1
);
// 等待对应 slot 填充完成
while (flags[(int)(nextCursor & indexMask)].get() == 0) {}
long uid = slots[(int)(nextCursor & indexMask)];
flags[(int)(nextCursor & indexMask)].set(0L);
return uid;
}
}"借用未来时间"是怎么回事? 当 RingBuffer 中的 ID 消耗过快时,生成器会把时间戳向前拨,预支下一秒的时间,从而在序列号耗尽前继续生成。因为是预生成的,消费时不需要等待,吞吐量能达到单机 600 万/秒。
但这也是它的代价——ID 不再严格对应真实时间,如果按 ID 反推创建时间会有偏差。
三种方案的本质对比
| 维度 | 原生 Snowflake | 美团 Leaf-segment | 美团 Leaf-snowflake | 百度 UidGenerator |
|---|---|---|---|---|
| ID 趋势 | 严格递增 | 段内递增 | 严格递增 | 严格递增 |
| 单机 QPS | ~400 万 | 依赖 DB 批量 | ~400 万 | ~600 万 |
| 时钟回拨 | 直接失败 | 无影响 | 容忍 5ms | 容忍一定范围 |
| workerId | 手工配置 | 无需 | ZK 自动分配 | 启动时分配 |
| 依赖组件 | 无 | MySQL | ZK + 无 | 无 |
| ID 可反推时间 | 是 | 否 | 是 | 近似 |
| 号段浪费 | 无 | 有(重启) | 无 | 无 |
实战代码:三个可运行的完整示例
示例 1:生产级 Snowflake(含时钟回拨自愈)
这是我在生产环境用的版本,核心改进是时钟回拨时自旋等待而不是抛异常:
import java.util.concurrent.locks.ReentrantLock;
/**
* 生产级 Snowflake 实现
* 改进点:
* 1. 时钟回拨小于阈值时自旋等待,而不是直接抛异常
* 2. 使用 ReentrantLock + tryLock 避免长时间阻塞
* 3. 提供 workerId 合法性校验
*/
public class RobustSnowflake {
private static final long EPOCH = 1704067200000L; // 2024-01-01 00:00:00
private static final long WORKER_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 1023
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // 4095
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; // 12
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 22
private static final long MAX_BACKWARD_MS = 10L; // 最大容忍回拨 10ms
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
private final ReentrantLock lock = new ReentrantLock();
public RobustSnowflake(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("workerId must be in [0, " + MAX_WORKER_ID + "]");
}
this.workerId = workerId;
}
public long nextId() {
lock.lock();
try {
long timestamp = currentTime();
long offset = lastTimestamp - timestamp;
if (offset > 0) {
// 发生了时钟回拨
if (offset <= MAX_BACKWARD_MS) {
// 小幅回拨,自旋等待追平
timestamp = spinUntil(lastTimestamp);
} else {
// 大幅回拨,无法自愈,抛异常让上层告警
throw new IllegalStateException(
"Clock moved backwards by " + offset + "ms, refuse to generate id");
}
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
timestamp = spinUntil(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
} finally {
lock.unlock();
}
}
/** 自旋等待直到时间超过指定值 */
private long spinUntil(long lastTimestamp) {
long ts = currentTime();
while (ts <= lastTimestamp) {
Thread.onSpinWait(); // JDK9+ 自旋提示,减少 CPU 空转
ts = currentTime();
}
return ts;
}
private long currentTime() {
return System.currentTimeMillis();
}
/** 从 ID 反解生成时间,用于排查问题 */
public static long extractTimestamp(long id) {
return (id >> TIMESTAMP_SHIFT) + EPOCH;
}
// ---------- 测试 ----------
public static void main(String[] args) throws InterruptedException {
RobustSnowflake sf = new RobustSnowflake(1L);
long start = System.currentTimeMillis();
int count = 100_000;
java.util.Set<Long> ids = new java.util.HashSet<>(count * 2);
for (int i = 0; i < count; i++) {
long id = sf.nextId();
if (!ids.add(id)) {
throw new IllegalStateException("Duplicate ID: " + id);
}
}
long cost = System.currentTimeMillis() - start;
System.out.printf("生成 %d 个 ID,耗时 %d ms,QPS ≈ %d%n",
count, cost, count * 1000L / Math.max(cost, 1));
System.out.println("样例 ID 反解时间: " +
new java.util.Date(extractTimestamp(ids.iterator().next())));
}
}示例 2:Leaf-segment 的双 Buffer 完整实现
这个示例去掉 DB 依赖,用内存模拟数据库,方便你直接跑起来理解双 Buffer 的调度逻辑:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* Leaf-segment 双 Buffer 核心实现
* 用内存模拟 DB 取号段,重点展示双 Buffer 切换机制
*/
public class LeafSegmentDemo {
/** 号段:一段连续的 ID 区间 */
static class Segment {
private final AtomicLong value;
private final long max;
private final long step;
Segment(long min, long step) {
this.value = new AtomicLong(min);
this.max = min + step;
this.step = step;
}
long getId() { return value.getAndIncrement(); }
boolean isExhausted() { return value.get() >= max; }
boolean shouldPreload() { return value.get() >= max - step * 0.1; } // 剩 10% 预加载
}
/** 模拟 DB 取号段 */
static class FakeDb {
private final AtomicLong maxId = new AtomicLong(1);
private final long step;
FakeDb(long step) { this.step = step; }
synchronized Segment nextSegment() {
long min = maxId.get();
maxId.addAndGet(step);
System.out.printf("[DB] 分配号段 [%d, %d)%n", min, min + step);
return new Segment(min, step);
}
}
static class DoubleBufferLeaf {
private final Segment[] buffers = new Segment[2];
private volatile int current = 0;
private volatile boolean nextReady = false;
private final FakeDb db;
private final ExecutorService loader = Executors.newSingleThreadExecutor();
DoubleBufferLeaf(FakeDb db) {
this.db = db;
this.buffers[0] = db.nextSegment();
}
public long nextId() {
Segment cur = buffers[current];
if (cur.shouldPreload() && !nextReady) {
// 异步预加载下一个号段
synchronized (this) {
if (!nextReady) {
nextReady = true;
loader.submit(() -> {
buffers[1 - current] = db.nextSegment();
});
}
}
}
if (cur.isExhausted()) {
switchBuffer();
return nextId();
}
return cur.getId();
}
private synchronized void switchBuffer() {
// 等待预加载完成
while (!nextReady) {
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
}
current = 1 - current;
nextReady = false;
System.out.println("[Leaf] 切换到 buffer " + current);
}
}
public static void main(String[] args) {
FakeDb db = new FakeDb(1000); // 每次取 1000 个
DoubleBufferLeaf leaf = new DoubleBufferLeaf(db);
for (int i = 0; i < 3500; i++) {
long id = leaf.nextId();
if (i % 500 == 0 || i == 3499) {
System.out.printf("生成第 %d 个 ID: %d%n", i, id);
}
}
}
}运行结果会看到:DB 只在开始时分配了 3 次号段,其余全部走内存,并且切换 Buffer 时没有阻塞——这就是双 Buffer 的价值。
示例 3:基于 Redis 的高可用 ID 生成器(Lua 脚本保证原子性)
当你的系统已经依赖 Redis 时,用 Lua 脚本做批量取号是最轻量的方案:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicLong;
/**
* 基于 Redis + Lua 的号段模式
* 核心:用 Lua 脚本的原子性保证"取号段"操作不并发冲突
*/
public class RedisSegmentIdGenerator {
private static final String LUA_SCRIPT =
"local key = KEYS[1] " +
"local step = tonumber(ARGV[1]) " +
"local current = redis.call('GET', key) " +
"if not current then " +
" redis.call('SET', key, step) " +
" return {0, step} " + // 返回 {起始值, 结束值}
"else " +
" local start = tonumber(current) " +
" local endVal = start + step " +
" redis.call('SET', key, endVal) " +
" return {start, endVal} " +
"end";
private final JedisPool jedisPool;
private final String bizKey;
private final long step;
private final AtomicLong currentId = new AtomicLong(-1);
private volatile long maxId = -1;
private final Object lock = new Object();
public RedisSegmentIdGenerator(JedisPool pool, String bizKey, long step) {
this.jedisPool = pool;
this.bizKey = "id_gen:" + bizKey;
this.step = step;
}
public long nextId() {
// 快路径:无锁读取
long id = currentId.incrementAndGet();
if (id < maxId) {
return id;
}
// 慢路径:需要取新号段
synchronized (lock) {
if (currentId.get() < maxId) {
return currentId.incrementAndGet();
}
loadNewSegment();
return currentId.getAndIncrement();
}
}
@SuppressWarnings("unchecked")
private void loadNewSegment() {
try (Jedis jedis = jedisPool.getResource()) {
Object result = jedis.eval(LUA_SCRIPT,
Collections.singletonList(bizKey),
Collections.singletonList(String.valueOf(step)));
java.util.List<Long> range = (java.util.List<Long>) result;
long start = range.get(0);
long end = range.get(1);
currentId.set(start);
maxId = end;
System.out.printf("[Redis] 取到号段 [%d, %d)%n", start, end);
}
}
public static void main(String[] args) {
// 假设本地有 Redis
JedisPool pool = new JedisPool("127.0.0.1", 6379);
RedisSegmentIdGenerator gen = new RedisSegmentIdGenerator(pool, "order", 1000);
java.util.Set<Long> ids = new java.util.HashSet<>();
for (int i = 0; i < 5000; i++) {
long id = gen.nextId();
if (!ids.add(id)) {
throw new IllegalStateException("Duplicate: " + id);
}
}
System.out.println("生成 5000 个唯一 ID 成功");
pool.close();
}
}注意 loadNewSegment 里的一个细节:Lua 脚本返回的是 {start, endVal},其中 start 是本次号段的起始值,endVal 是下一个号段的起始值。所以本地 currentId 从 start 开始,maxId 是 endVal(开区间,取不到),保证不越界。
方案对比与选型决策
决策流程图
关键场景的选型建议
场景 1:电商订单号,需要按时间排序和查询
选 Leaf-snowflake。ID 趋势递增,能反推时间,方便按时间范围分页查询。同时 ZK 自动分配 workerId,容器化部署友好。
场景 2:日志追踪 ID,只要唯一不重复
选 UUID v7 或 Redis 号段。日志 ID 不需要严格递增,UUID v7 内嵌时间戳,既有序又无需中心化组件。
场景 3:金融交易流水号,绝对不能重复
选 Leaf-segment + 数据库唯一索引兜底。号段模式 ID 是"批发"的,即使生成器全部宕机,DB 里也不会产生重复。而且 ID 严格递增,方便对账。
场景 4:超高性能场景(>500 万 QPS)
选 百度 UidGenerator 的 CachedUidGenerator。预生成 + RingBuffer 是性能天花板。
最佳实践与避坑指南
坑 1:Snowflake 的 workerId 在 K8s 环境下冲突
问题:Pod 重启后 IP 变化,用 IP 哈希算 workerId 可能碰撞。
解法:用 StatefulSet + 序号作为 workerId,或者接入 ZK/etcd 做注册分配。千万不要用 Pod IP 后 10 位做 workerId——IP 会复用。
坑 2:NTP 校时导致批量回拨
问题:默认 NTP 配置下,如果时钟偏差过大,会一次性大步长调整(step),造成几十毫秒甚至秒级的回拨。
解法:配置 NTP 使用 slew 模式(渐进调整):
# /etc/chrony.conf
makestep 0.1 -1 # 只有偏差超过 0.1s 才 step,否则 slew同时 Snowflake 实现里保留小幅回拨自愈能力。
坑 3:Leaf-segment 重启导致的号段浪费
问题:服务每次重启都会丢弃内存中未用完的号段,如果 step=10000,重启 100 次就是 100 万个浪费的 ID。
解法:
- 用动态 step:根据当前 QPS 调整,闲时小 step,忙时大 step。
- 用持久化当前号段:重启时先尝试从本地文件恢复(类似 Leaf 的
leaf_alloc表记录)。
坑 4:序列号耗尽时的性能毛刺
问题:Snowflake 在 1ms 内超过 4096 个请求会自旋等待,synchronized 锁下所有线程阻塞。
解法:
- 用
ReentrantLock.tryLock(timeout)替代 synchronized,避免无限阻塞。
- 提高序列号位数(牺牲时间位),或者用 UidGenerator 的 RingBuffer 预生成。
坑 5:ID 泄露业务量
问题:连续递增的 ID 会让竞争对手轻松估算你的日订单量。
解法:对外暴露的 ID 用混淆算法(如 Feistel 网络),内部保持有序,外部看起来随机但可逆。或者直接用不连续的号段模式。
// 简单的 ID 混淆(基于乘法逆元,保证一一映射)
public class IdObfuscator {
private static final long PRIME = 1_000_000_007L; // 大质数
private static final long MOD = 1L << 62; // 2^62
public static long encode(long id) {
return (id * PRIME) % MOD;
}
public static long decode(long encoded) {
// 计算 PRIME 在模 2^62 下的逆元
return (encoded * modInverse(PRIME, MOD)) % MOD;
}
private static long modInverse(long a, long m) {
// 扩展欧几里得算法(此处省略实现)
return 0L;
}
}坑 6:多语言环境下的 ID 兼容
Java 的 long 是 64 位有符号,JS 的 Number 只有 53 位精度。Snowflake 生成的 19 位 ID 直接传给前端会精度丢失。
解法:后端序列化时把 long 类型的 ID 转成 String。Jackson 配置:
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToString() {
return builder -> builder
.serializerByType(Long.class, ToStringSerializer.instance)
.serializerByType(Long.TYPE, ToStringSerializer.instance);
}
}最佳实践清单
- 监控指标:ID 生成延迟 P99、时钟偏移量、号段剩余比例、ZK/DB 连接状态。
- 降级预案:Snowflake 服务不可用时,降级到本地 UUID 或 Redis 兜底。
- 压测基线:上线前压测单机 QPS 上限,预留 30% 余量。
- ID 审计:定期抽样检查重复 ID,用 DB 唯一索引做最后一道防线。
- 时间纪元选择:起始纪元选得越晚,ID 越小,但可用年限越短。一般选项目启动时间。
总结
回到文章开头那个双十一的故事。我们最终的方案是:Leaf-snowflake + NTP slew 模式 + workerId 由 K8s StatefulSet 序号分配。上线后半年,再没出现过重复 ID。
三种方案的核心取舍可以浓缩成一张表:
| 你的核心诉求 | 推荐方案 | 一句话理由 |
|---|---|---|
| 强时序 + 时间可反查 | Leaf-snowflake | 时钟回拨自愈 + ZK 自动分配 |
| 严格递增 + 抗压 | Leaf-segment | 双 Buffer 削峰,DB 兜底 |
| 极致性能 | 百度 UidGenerator | RingBuffer 预生成 |
| 轻量无依赖 | 原生 Snowflake(改进版) | 但要自己解决 workerId |
| 已重度依赖 Redis | Redis 号段 + Lua | 最少组件 |
延伸思考:分布式 ID 的本质是"在无中心化时钟的环境下,构造一个全局单调的坐标系"。Snowflake 用 (时间, 机器, 序列) 三维,号段模式用 (号段, 段内偏移) 二维。理解了这一点,你就能根据业务对"时间精度、机器规模、单调性"的不同要求,自己设计出合适的方案——而不是盲目套用某个开源实现。
最后一句忠告:任何 ID 生成方案都不能保证 100% 不重复,唯一索引才是最后的底线。把它当作第一道防线,而不是唯一防线。