Skip to content

分布式ID:UUID、数据库号段、Snowflake与生产治理

分库分表和微服务拆分后,单库自增不再天然提供全局唯一标识。分布式 ID 不只是“生成一个不重复的 long”,还要考虑吞吐、趋势递增、索引局部性、可用性、回拨、节点身份、容量寿命、安全泄露、跨地域和故障恢复。

一、学习目标

学完后应能:

  1. 区分技术主键、业务单号、幂等键和 TraceId。
  2. 解释数据库自增、Sequence、Redis INCR、ZooKeeper 顺序节点、UUID、号段和 Snowflake 的内部路径。
  3. 计算 Snowflake 位宽、寿命和单节点理论吞吐。
  4. 设计 WorkerId 分配、冲突检测和时钟回拨策略。
  5. 解释随机 UUID 对 B+Tree 索引局部性的影响,以及 UUID v7/ULID 的边界。
  6. 设计 ID 服务的双 Buffer、降级、监控、迁移与生产 Runbook。

二、先区分四种标识

标识主要用途是否可预测示例
技术主键数据库行关联和内部引用可以,但不应直接作为授权依据order_id bigint
业务单号客服、用户、渠道可识别通常带规则或校验位M202607180001
幂等键判断两次请求是否同一业务意图应由业务操作稳定生成或客户端提供支付请求号
TraceId串联一次分布式调用高概率唯一,不表达业务身份W3C Trace ID

不要把技术主键直接当访问凭证。即使 ID 难猜,接口仍必须做租户和资源授权。业务单号也不应承担数据库主键的全部职责:规则可能变化、长度更长、还可能需要补号和人工录入。

三、ID 方案要评估哪些指标

指标要问的问题
唯一性范围单表、单库、单地域还是全球唯一
吞吐峰值每秒生成多少,突发持续多久
延迟是否允许每次远程取号
可用性发号器故障时业务是否必须继续
顺序性只要趋势递增,还是严格连续
索引友好主键插入是否集中在 B+Tree 右侧
信息泄露是否暴露时间、节点、业务量
寿命时间位、序列位和节点位何时耗尽
跨地域各地域如何避免号段或 WorkerId 冲突
可观测能否定位由谁、何时、按哪个配置生成

“全局唯一、严格递增、绝不跳号、无中心、高可用、无限吞吐”通常不能同时免费获得。严格连续尤其昂贵:事务回滚、缓存号段和节点故障都会产生空洞。发票等受监管号码应按专门业务规则和审计设计,不要拿普通技术主键冒充。

四、数据库自增与 Sequence

4.1 单库自增

数据库在插入时原子分配值,简单且索引局部性好。问题是:多库各自从 1 开始会冲突;每次生成都依赖数据库;跨地域集中访问延迟高。

可以为不同库设置不同起点和步长,例如奇偶号,但扩容和库数量变化会使配置复杂,并且步长规划错误会直接冲突。

4.2 Sequence

Oracle、PostgreSQL、SQL Server 等提供 Sequence。Sequence 与业务事务通常不是同一个回滚语义:调用 nextval 后事务回滚,号码通常也不会归还;开启 Cache 后实例故障还可能丢失未使用号码。因此 Sequence 保证唯一生成,不保证无空洞。

mermaid
flowchart TD
    A["事务请求nextval"] --> B["Sequence原子增加"]
    B --> C["返回新值"]
    C --> D{"业务事务是否提交"}
    D -- "提交" --> E["ID被业务使用"]
    D -- "回滚" --> F["ID通常不会退回,形成空洞"]

五、Redis INCR 与 ZooKeeper 顺序节点

5.1 Redis INCR

Redis 在单个主节点执行命令时可原子递增,适合中等规模的集中序号。但必须考虑:

  • 主从切换时已确认计数是否可能丢失,取决于持久化和复制确认策略。
  • 跨地域访问一个 Redis 的延迟和故障域。
  • 热点 key 的单分片吞吐上限。
  • 恢复旧备份后计数回退造成冲突。

可以一次 INCRBY 1000 领取本地号段,降低远程频率,但也会接受节点宕机后号段空洞。

5.2 ZooKeeper 顺序节点

ZooKeeper 可创建带单调序号后缀的 Sequential ZNode。它适合选举、排序和协调元数据,不适合作为高吞吐业务 ID 发号器:每次创建经过 Leader 与 Quorum 持久化,节点和 Watch 还会增加控制面负担。

原则是:不要因为组件“能生成顺序号”,就把控制面组件拿来承载交易数据面的高 QPS。

六、UUID、UUID v7 与 ULID

6.1 UUID v4

Java 8 的 UUID.randomUUID() 常见实现是随机 UUID v4。它无需中心协调,碰撞概率极低,但字符串长、随机插入会降低聚簇索引局部性,而且概率唯一不等于数学上绝对不碰撞;数据库仍应保留唯一约束。

java
import java.util.UUID;

public class UuidDemo {
    public static void main(String[] args) {
        UUID id = UUID.randomUUID();
        System.out.println(id.toString());
    }
}

数据库存储 UUID 时,二进制 BINARY(16) 通常比带连字符字符串更紧凑,但要统一字节序和转换约定,避免不同语言读写不一致。

6.2 UUID v1

UUID v1 使用时间和节点相关信息,具备一定时间属性,但可能暴露生成时间和节点标识。是否包含真实 MAC 取决于实现,不能把所有 v1 都简单说成必然暴露物理 MAC。

6.3 UUID v7

UUID v7 是 RFC 9562 定义的基于 Unix Epoch 毫秒时间的时间有序 UUID,后续位包含随机/实现定义的单调增强部分。它改善按字典/二进制顺序插入的局部性,但同一毫秒内是否严格单调取决于生成器实现。

Java 8 标准库没有直接提供 UUID v7 生成 API,需要经过验证的库或自研实现;不能把 UUID v7 写成 JDK 8 自带能力。

6.4 ULID

ULID 也是 128 位标识,通常由 48 位毫秒时间和 80 位随机部分组成,并使用可排序 Base32 文本表示。它不是 UUID 版本。普通随机模式下同一毫秒内不保证生成顺序;Monotonic ULID 需要实现额外递增规则,并处理同毫秒随机空间溢出和并发安全。

七、数据库号段模式

号段模式把“每个 ID 都访问数据库”改成“每批 ID 访问一次数据库”。数据库只负责原子分配区间,应用在内存中逐个发放。

号段表:

sql
create table id_segment (
    biz_tag varchar(64) primary key,
    max_id bigint not null,
    step int not null,
    version bigint not null,
    updated_at timestamp not null
);

分配 [oldMax + 1, oldMax + step] 的事务过程:

sql
begin;
select max_id, step, version
from id_segment
where biz_tag = ?
for update;

update id_segment
set max_id = max_id + step,
    version = version + 1,
    updated_at = current_timestamp
where biz_tag = ?;
commit;
mermaid
flowchart TD
    A["本地当前号段接近耗尽"] --> B["事务锁定biz_tag行"]
    B --> C["数据库max_id原子增加step"]
    C --> D["提交并返回新区间"]
    D --> E["应用在内存逐个发放"]
    E --> F["达到预取阈值后异步加载下一段"]

7.1 双 Buffer

当前 Segment 使用到一定比例时,后台预取 Next Segment。当前段耗尽后原子切换,减少切段时等待数据库的毛刺。

必须处理:

  • 多线程发号的原子游标。
  • Next Segment 只能有一个加载任务或能够合并重复加载。
  • 数据库不可用且两个 Buffer 都耗尽时明确失败,不能重新使用旧号段。
  • 节点宕机会浪费剩余区间,但不能回收后再次分配。
  • Step 可以按消费速率动态调整,但要设置上下限,避免一次囤积过大。

八、JDK 8 Demo:线程安全本地号段

java
import java.util.concurrent.atomic.AtomicLong;

public final class LocalSegment {
    private final long endInclusive;
    private final AtomicLong next;

    public LocalSegment(long startInclusive, long endInclusive) {
        if (startInclusive > endInclusive) {
            throw new IllegalArgumentException("号段起点不能大于终点");
        }
        if (endInclusive == Long.MAX_VALUE) {
            throw new IllegalArgumentException("号段终点必须小于Long.MAX_VALUE");
        }
        this.next = new AtomicLong(startInclusive);
        this.endInclusive = endInclusive;
    }

    public long nextId() {
        for (;;) {
            long value = next.get();
            if (value > endInclusive) {
                throw new IllegalStateException("号段已耗尽");
            }
            if (next.compareAndSet(value, value + 1L)) {
                return value;
            }
        }
    }

    public long remaining() {
        long value = next.get();
        return value > endInclusive ? 0L : endInclusive - value + 1L;
    }

    public static void main(String[] args) {
        LocalSegment segment = new LocalSegment(1001L, 1003L);
        System.out.println(segment.nextId());
        System.out.println(segment.nextId());
        System.out.println(segment.nextId());
        System.out.println("remaining=" + segment.remaining());
    }
}

Demo 只表示本地发放。真正的唯一性来自数据库对区间的原子分配;如果多个实例错误获得同一区间,本地 AtomicLong 无法补救。

8.1 JDK 8 Demo:模拟数据库乐观号段分配

真实商业系统通常不会只靠本地 AtomicLong,还要有一张权威分配表。下面用内存对象模拟数据库行和 version 乐观更新,帮助理解“多个发号服务同时抢号段为什么不会拿到同一段”。

java
import java.util.concurrent.atomic.AtomicLong;

public class SegmentAllocatorDemo {

    static class Segment {
        final long start;
        final long end;

        Segment(long start, long end) {
            this.start = start;
            this.end = end;
        }

        public String toString() {
            return "[" + start + ", " + end + "]";
        }
    }

    static class FakeDbRow {
        private long maxId = 0L;
        private long version = 0L;
        private final int step = 1000;

        synchronized Segment allocate(String bizTag) {
            long oldMax = maxId;
            long oldVersion = version;

            // 对应真实SQL中的 where biz_tag = ? and version = ?
            boolean updated = compareAndUpdate(oldVersion, oldMax + step);
            if (!updated) {
                throw new IllegalStateException("并发更新失败,请重试");
            }
            return new Segment(oldMax + 1L, maxId);
        }

        private boolean compareAndUpdate(long expectedVersion, long newMaxId) {
            if (version != expectedVersion) {
                return false;
            }
            maxId = newMaxId;
            version++;
            return true;
        }
    }

    static class IdService {
        private final FakeDbRow dbRow;
        private volatile LocalSegment current;

        IdService(FakeDbRow dbRow) {
            this.dbRow = dbRow;
            Segment first = dbRow.allocate("order");
            this.current = new LocalSegment(first.start, first.end);
        }

        long nextId() {
            for (;;) {
                LocalSegment segment = current;
                try {
                    return segment.nextId();
                } catch (IllegalStateException exhausted) {
                    synchronized (this) {
                        if (current == segment) {
                            Segment next = dbRow.allocate("order");
                            current = new LocalSegment(next.start, next.end);
                        }
                    }
                }
            }
        }
    }

    static final class LocalSegment {
        private final long endInclusive;
        private final AtomicLong next;

        LocalSegment(long startInclusive, long endInclusive) {
            this.next = new AtomicLong(startInclusive);
            this.endInclusive = endInclusive;
        }

        long nextId() {
            for (;;) {
                long value = next.get();
                if (value > endInclusive) {
                    throw new IllegalStateException("号段已耗尽");
                }
                if (next.compareAndSet(value, value + 1L)) {
                    return value;
                }
            }
        }
    }

    public static void main(String[] args) {
        FakeDbRow db = new FakeDbRow();
        IdService serviceA = new IdService(db);
        IdService serviceB = new IdService(db);

        System.out.println("serviceA id=" + serviceA.nextId());
        System.out.println("serviceB id=" + serviceB.nextId());
    }
}

真实 SQL 通常写成:

sql
update id_segment
set max_id = max_id + step,
    version = version + 1,
    updated_at = current_timestamp
where biz_tag = ?
  and version = ?;

如果更新影响行数为 1,说明本实例抢号段成功;如果为 0,说明版本已经被其他实例改掉,应重新查询再重试。不要在应用内自己计算一个区间然后直接使用,必须让数据库原子提交成为唯一事实源。

九、Snowflake 位布局与容量计算

经典 64 位布局常见为:

text
0 | 41位时间差 | 10位节点 | 12位序列
  • 最高符号位固定 0,保持正数。
  • 41 位毫秒时间差约可使用 2^41 毫秒,约 69.7 年。
  • 10 位节点最多 1024 个 Worker 身份。
  • 12 位序列每毫秒最多 4096 个 ID。
  • 单 Worker 理论上约 409.6 万 ID/秒,但真实吞吐受锁、时钟读取、CPU 和调用方式影响。

位宽不是固定标准。可以拆成地域位、机房位、进程位,但每多给节点一位,就少一位给时间或序列。自定义 Epoch 必须文档化且不可随意修改,否则同一部署中的 ID 解释会变化。

十、Snowflake 一次生成过程

mermaid
flowchart TD
    A["读取当前毫秒now"] --> B{"now与lastTimestamp比较"}
    B -- "大于" --> C["序列归零"]
    B -- "等于" --> D["序列加一并应用掩码"]
    D --> E{"序列是否回到零"}
    E -- "是" --> F["等待下一毫秒"]
    E -- "否" --> G["继续组装ID"]
    B -- "小于" --> H["进入时钟回拨策略"]
    C --> G
    F --> G
    H --> I{"回拨是否在允许窗口"}
    I -- "短回拨" --> J["有界等待并再次读取"]
    I -- "长回拨" --> K["拒绝、告警或安全切换节点空间"]
    J --> G
    G --> L["时间差、WorkerId和序列按位或"]

生成器通常需要同步或 CAS 保护 lastTimestampsequence。每个请求新建一个生成器会让序列重新从零开始,同一毫秒极易重复;生成器必须是进程内共享且 WorkerId 唯一的长期对象。

十一、WorkerId 怎样分配

分配方式优点风险
静态配置简单明确人工复制配置造成冲突
数据库注册可审计、可分配注册库可用性、租约与回收竞态
ZooKeeper/etcd 临时身份与会话结合控制面故障、旧身份恢复与 Fencing
Kubernetes StatefulSet 序号稳定易理解多集群/地域重名、扩缩容生命周期
IP/MAC 哈希无中心截断碰撞、容器地址复用,不可靠

WorkerId 回收必须等待旧进程不可能再生成 ID,或使用 Epoch/启动代际隔离。仅看到租约过期就立即把同一 WorkerId 给新进程,旧进程从长 GC 恢复后仍可能与新进程并发生成。

可选防线:

  1. 注册中心分配 workerId + generation
  2. 生成器定期续租,失联主动停止。
  3. 长回拨或租约丢失后进入不可恢复停止状态,而不是自动继续。
  4. 中央系统检测同一 WorkerId 多实例心跳并告警。
  5. 必要时把启动代际编码进节点位或迁移到集中号段方案。

十二、时钟回拨不能只写“等待一下”

回拨来源包括 NTP Step、人工校时、虚拟机恢复和宿主机异常。策略必须区分:

  • 短回拨:在请求 Deadline 和阈值内等待到 lastTimestamp
  • 长回拨:拒绝生成并告警,防止线程无限挂起。
  • 逻辑时间:使用持久化最大时间,但要评估未来真实时间追赶和重启恢复。
  • 备用节点空间:必须证明不会与仍存活实例冲突,不能随机换 WorkerId。

把较小时间强行提升为 lastTimestamp 可以保持唯一性,但会持续消耗同一逻辑毫秒的序列;序列耗尽后仍需等待或失败。任何“自动修复”都必须写清容量和恢复边界。

十三、JDK 8 Demo:最小 Snowflake 生成器

下面 Demo 用 synchronized 保护 lastTimestampsequence,重点演示位运算、同毫秒序列、序列耗尽等待下一毫秒和时钟回拨处理。它适合学习原理,不等于生产级组件。

java
public final class SnowflakeIdGenerator {
    private static final long EPOCH = 1704067200000L; // 2024-01-01 00:00:00 UTC

    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);
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);

    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;

    private final long workerId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException("workerId范围必须是0到" + MAX_WORKER_ID);
        }
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long now = currentTimeMillis();

        if (now < lastTimestamp) {
            long offset = lastTimestamp - now;
            if (offset <= 5L) {
                sleep(offset);
                now = currentTimeMillis();
                if (now < lastTimestamp) {
                    throw new IllegalStateException("时钟回拨仍未恢复,拒绝生成ID");
                }
            } else {
                throw new IllegalStateException("时钟回拨过大,offset=" + offset + "ms");
            }
        }

        if (now == lastTimestamp) {
            sequence = (sequence + 1L) & SEQUENCE_MASK;
            if (sequence == 0L) {
                now = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = now;
        return ((now - EPOCH) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    public ParsedId parse(long id) {
        long sequenceValue = id & SEQUENCE_MASK;
        long workerValue = (id >> WORKER_ID_SHIFT) & MAX_WORKER_ID;
        long timestamp = (id >> TIMESTAMP_SHIFT) + EPOCH;
        return new ParsedId(timestamp, workerValue, sequenceValue);
    }

    private long waitNextMillis(long last) {
        long now = currentTimeMillis();
        while (now <= last) {
            now = currentTimeMillis();
        }
        return now;
    }

    private long currentTimeMillis() {
        return System.currentTimeMillis();
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("等待时钟恢复被中断", e);
        }
    }

    static class ParsedId {
        final long timestamp;
        final long workerId;
        final long sequence;

        ParsedId(long timestamp, long workerId, long sequence) {
            this.timestamp = timestamp;
            this.workerId = workerId;
            this.sequence = sequence;
        }

        public String toString() {
            return "ParsedId{timestamp=" + timestamp
                    + ", workerId=" + workerId
                    + ", sequence=" + sequence + '}';
        }
    }

    public static void main(String[] args) {
        SnowflakeIdGenerator generator = new SnowflakeIdGenerator(17L);
        long id = generator.nextId();
        System.out.println(id);
        System.out.println(generator.parse(id));
    }
}

这段代码里有几个关键点:

代码点原理
workerId 构造时校验节点位只有 10 位,超过 1023 会污染时间位
synchronized nextId()保护同一生成器的 lastTimestampsequence
sequence = sequence + 1 & mask同一毫秒内递增,超过 4095 后回到 0
waitNextMillis同毫秒序列耗尽后等待下一毫秒,不能继续复用序列
短回拨等待小范围 NTP 校正可以有界等待
长回拨拒绝继续生成可能复用历史时间和序列组合

生产级 Snowflake 还需要:

  1. WorkerId 分配中心和冲突检测。
  2. 时钟回拨次数、偏移量和拒绝次数监控。
  3. NTP 策略和禁止人工随意校时。
  4. 生成器单例化,不能每次请求 new。
  5. 服务重启、长 GC、容器迁移和多机房演练。
  6. 对外订单号脱敏,不能把可解码内部 ID 当安全边界。

十四、ID 对数据库 B+Tree 的影响

聚簇主键决定数据页物理组织时,随机 UUID 会把插入分散到树的不同位置,增加随机写、页分裂、缓存失效和索引体积;趋势递增 ID 通常集中写右侧叶子页,局部性更好。

但单调热点也可能在极高并发分布式存储中形成单分片热点。应结合具体数据库:MySQL InnoDB 聚簇索引、PostgreSQL Heap + B-Tree、LSM 系统和分片键的行为不同,不能把一个结论套到所有存储。

二级索引叶子通常还会存主键,主键越宽,全部二级索引越大。因此 UUID 字符串作为主键的代价不只在主键索引。

十五、趋势递增 ID 的安全与隐私边界

连续或可解码 ID 可能泄露:

  • 业务量和增长速度。
  • 大致创建时间。
  • 机房或节点信息。
  • 可枚举资源路径。

应对方式不是把 ID 当密码,而是:

  1. 每个接口做资源级授权和租户过滤。
  2. 对外可使用独立随机 PublicId,内部保留数值主键。
  3. 日志和监控避免不必要暴露节点位设计。
  4. 业务单号可加入随机段或校验位,但这不替代鉴权。

十六、商业场景:订单、支付与采集数据如何选 ID

数据推荐思路原因
订单表主键Snowflake 或号段 bigint高吞吐、索引相对友好
对外订单号日期/渠道/随机或序列规则单独生成客服可识别,不暴露内部主键
支付请求号稳定业务幂等键重试必须复用同一业务意图
采集明细单元位 + 趋势 ID,或按设备分区的号段支持路由和批量写入
TraceId128 位高概率唯一随机值不进入业务唯一约束
对账批次号业务日期 + 渠道 + 批次序号可审计、可重跑

一个请求可能同时拥有这几种 ID,不能为了“统一”强迫它们全部相同。

十七、生产级 ID 服务架构

mermaid
flowchart TD
    A["业务SDK请求ID"] --> B["本地批量缓存或生成器"]
    B --> C{"本地资源是否充足"}
    C -- "是" --> D["返回ID并记录指标"]
    C -- "否" --> E["向号段服务预取"]
    E --> F["数据库原子分配新区间"]
    F --> G["装载Next Buffer"]
    G --> D
    E --> H{"数据库不可用"}
    H -- "当前Buffer仍有余量" --> I["短期继续并告警"]
    H -- "全部耗尽" --> J["明确失败,禁止复用旧号段"]

应监控:每业务 Tag 发号速率、剩余比例、预取延迟、数据库失败、号段切换、回拨次数、WorkerId 冲突、序列溢出、拒绝次数和生成延迟分位数。

十八、从自增 ID 迁移到全局 ID

  1. 新增 global_id 列和唯一索引,不立即替换旧主键。
  2. 新写入同时生成全局 ID,旧接口继续兼容旧 ID。
  3. 按主键范围回填历史数据,记录断点和冲突。
  4. 下游事件和 API 同时携带新旧 ID。
  5. 消费者逐个切换,并通过契约与数据对账验证。
  6. 查询流量切到新 ID 后保留旧 ID 映射回滚窗口。
  7. 所有消费者退出后再决定是否调整主键,避免在线直接重建超大聚簇索引。

不要在一个发布中同时改主键、分库路由和业务单号规则,否则出现错路由时无法确定问题来自哪一层。

十九、ID 冲突或发号失败 Runbook

  1. 保存冲突 ID、业务 Tag、生成实例、地域、WorkerId、启动代际和生成时间。
  2. 解码 Snowflake 的时间、节点和序列位,检查是否同 WorkerId/同毫秒重复。
  3. 查看时钟回拨、NTP Step、虚拟机恢复和长 GC 记录。
  4. 检查多个实例是否共享静态 WorkerId,租约回收是否过早。
  5. 号段模式检查数据库区间分配记录、版本、Buffer 和是否错误回收旧段。
  6. Redis/Sequence 检查恢复旧备份、主从切换和计数回退。
  7. 立即隔离冲突生成器,禁止靠删除唯一索引继续写。
  8. 按业务事实确认冲突行归属,建立 ID 映射或人工修复。
  9. 修复后做高并发、回拨、重启、双实例同 WorkerId 和数据库故障演练。

二十、常见错误

错误后果正确方向
全局唯一就等于严格递增误判方案能力分别定义唯一、趋势、连续要求
回滚后 Sequence 应归还号码追求无意义连续导致耦合接受技术主键空洞
UUID 绝不碰撞放弃数据库约束仍保留唯一索引和冲突处理
每次请求新建 Snowflake序列状态丢失,同毫秒重复进程内共享生成器
WorkerId 用 IP 后几位容器复用或截断碰撞受控分配和冲突检测
回拨时直接继续生成可能与历史 ID 冲突有界等待、逻辑时间或拒绝
号段剩余可以回收宕机旧节点恢复后重复发放已分配区间永不重新使用
用主键代替鉴权ID 可枚举导致越权资源级授权和租户隔离

二十一、关联知识点