分布式ID生成方案:雪花算法、Leaf、美团方案对比

引言

去年双十一大促前夜,我们团队接到一个紧急工单:订单中心在凌晨扩容后,出现了大量主键冲突。排查发现,某台机器的系统时钟被 NTP 回拨了 8 毫秒——就这么不起眼的 8 毫秒,让 Snowflake 算法在同一毫秒内产生了重复 ID,直接导致上千笔订单写入失败。

这不是个例。在分布式系统中,ID 生成看似是最基础的功能,却往往是踩坑最多的地方之一:

  • 用数据库自增?单点瓶颈,分库分表后直接失效。
  • 用 UUID?无序性导致 InnoDB 的 B+ 树页分裂严重,写入性能断崖式下跌。
  • 用 Redis 的 INCR?多一次网络 RTT,还要考虑持久化丢失的问题。
  • 用 Snowflake?时钟回拨、workerId 分配、序列号耗尽,每一个都是坑。

这篇文章,我会从源码级别拆解三种主流方案——原生 Snowflake、美团 Leaf、百度 UidGenerator(附带对比美团 Leaf-snowflake 的改进),并结合我们线上真实的踩坑经验,给出选型建议。读完你应该能清楚地知道:在什么场景下用哪个方案,以及为什么。

核心概念:ID 生成器就像餐厅的取号机

先打个比方。

想象一家网红餐厅,门口有一台取号机。理想情况下,它要满足几个要求:

  1. 不重号:两个人不能拿到同一个号(唯一性)
  2. 号码递增:叫号时按顺序来,不能乱跳(有序性)
  3. 出号快:顾客多的时候不能卡住(高吞吐、低延迟)
  4. 机器坏了能换:不能因为一台取号机坏了整个餐厅瘫痪(高可用)
  5. 号码不能太长:太长的号码顾客记不住,也印不下(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;
    }
}

三个致命缺陷:

  1. 时钟回拨直接抛异常。生产环境 NTP 校时、虚拟机迁移都可能造成回拨,抛异常意味着服务不可用。
  2. workerId 靠人工配置。1024 个节点,靠配置文件维护?容器化环境下 Pod 频繁重启,根本无法保证唯一。
  3. 序列号耗尽时自旋。虽然只有 4096/ms 以上的极端场景才会触发,但自旋期间持有 synchronized 锁,会阻塞所有调用线程。

二、美团 Leaf:两种模式,各有取舍

美团的 Leaf 提供了两个方案,我先讲架构:

graph TD A[业务客户端] --> B[Leaf Server 集群] B --> C{模式选择} C -->|Leaf-segment| D[(MySQL 号段表)] C -->|Leaf-snowflake| E[ZooKeeper 分配 workerId] E --> F[本地 Snowflake 生成] D --> G[双 Buffer 异步预加载] G --> H[内存号段池] style D fill:#ffe0b2 style E fill:#c8e6c9 style H fill:#b3e5fc

#### 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(开区间,取不到),保证不越界。

方案对比与选型决策

决策流程图

graph TD A[需要分布式ID] --> B{是否有强时序要求?} B -->|是, 需反推时间| C{能否容忍时钟回拨?} B -->|否, 递增即可| D{DB压力大吗?} C -->|能容忍少量| E[Leaf-snowflake] C -->|不能| F[Leaf-segment] D -->|是| F D -->|否| G{已有Redis?} G -->|是| H[Redis号段模式] G -->|否| F E --> I[部署ZK集群] F --> J[部署MySQL主从] H --> K[Redis哨兵/集群] style E fill:#c8e6c9 style F fill:#b3e5fc style H fill:#ffe0b2

关键场景的选型建议

场景 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);
    }
}

最佳实践清单

  1. 监控指标:ID 生成延迟 P99、时钟偏移量、号段剩余比例、ZK/DB 连接状态。
  2. 降级预案:Snowflake 服务不可用时,降级到本地 UUID 或 Redis 兜底。
  3. 压测基线:上线前压测单机 QPS 上限,预留 30% 余量。
  4. ID 审计:定期抽样检查重复 ID,用 DB 唯一索引做最后一道防线。
  5. 时间纪元选择:起始纪元选得越晚,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% 不重复,唯一索引才是最后的底线。把它当作第一道防线,而不是唯一防线。