分布式 ID 方案(详细版)

分布式 ID 方案(详细版)

目标:在多机房/多实例/高并发 场景下,生成全局唯一高性能 、(可选)大致有序 、(可选)可追踪的业务主键/流水号。


1. 你到底需要什么样的 ID?

先把需求"说人话":

  • 唯一性:必须全局唯一(最基本)。
  • 性能:QPS 目标?10k / 100k / 1M?
  • 有序性:是否需要"按时间大致递增"(利于 MySQL B+Tree、范围查询、冷热分离)。
  • 长度 :数据库用 BIGINT 还是字符串?
  • 可读性/业务含义:要不要带日期、业务线、地区码?
  • 多中心容灾:跨机房/跨地域是否必须可用?
  • 可用性:ID 服务挂了能否继续下单?能否本地降级?
  • 安全性:是否怕"爬虫看出订单量/时间"?(需要混淆/加密/映射)
  • 成本:是否接受引入中间件/运维一套 ID 服务?

把这些问题确定后,你的选型会非常清晰。


2. 常见方案总览(优缺点一眼看懂)

方案 形态 性能 有序 依赖 优点 缺点/坑 适用
数据库自增 DB 低~中 单库 简单 分库分表难、扩展差、写热点 单体/小规模
数据库号段(Segment/Leaf) DB + 缓存 DB 高性能、可控、成本低 号段表设计、时钟无关但要处理并发 电商/交易(强烈推荐)
Redis INCR Redis Redis 快、实现简单 Redis 故障/切换、持久化/回放、集群槽迁移 业务流水号、短期
Snowflake(雪花) 本地算法 极高 ✅(大致) 不依赖中心、低延迟 时钟回拨、机器号分配 大多数场景(强烈推荐)
UUID(v4) 本地算法 极高 超简单、绝对唯一概率极高 太长、无序、索引膨胀 日志、追踪ID
UUID v1/v7/ULID 本地算法 极高 ✅(大致) 比 v4 更有序 实现差异、字符串更大 需要可排序字符串
Zookeeper/Etcd 递增节点 中心服务 低~中 ZK/Etcd 强一致递增 性能一般、依赖重 小规模强一致序号
MQ/时间序列服务 中心服务 MQ 解耦 延迟、复杂 特殊业务

结论(经验主义但很实用):

  • 你要 BIGINT 且高并发 :优先 Snowflake号段(Segment/Leaf)
  • 你要"绝对递增且强一致":通常只能中心化(DB/ZK),但代价很高。
  • 你只是要"唯一":UUID v4 最省事,但别用在 MySQL 主键(除非你接受性能代价)。

3. 方案一:Snowflake(雪花)------最常用的本地算法

3.1 经典 64-bit 结构

典型结构(可调整):

  • 1 bit:符号位(固定 0)
  • 41 bit:时间戳(毫秒)
  • 10 bit:机器标识(数据中心 + 机器)
  • 12 bit:序列号(同毫秒内自增)

特点:

  • 无需中心服务
  • 性能极高
  • 大致递增(按时间)
  • 需要解决两个关键问题:
    1. 机器号怎么分配
    2. 时钟回拨怎么办

3.2 机器号分配策略(落地做法)

推荐顺序:

  1. K8s StatefulSet :用 pod ordinal 作为 workerId(最稳、最省事)
  2. 配置中心(Nacos/Apollo)按实例配置
  3. ZK/Etcd 临时节点抢占(自动分配,复杂一些)
  4. IP hash(不推荐:IP 变动、冲突风险)

只要能保证同一时间不会有两个实例拿到相同 workerId,就行。

3.3 时钟回拨处理策略

常见策略:

  • 直接拒绝:检测到回拨就抛异常(简单但会影响业务)
  • 等待追平:回拨不大时 sleep 等待(常用)
  • 使用逻辑时钟:维护 lastTimestamp,回拨时继续用 lastTimestamp(会牺牲"真实时间")
  • 切换 workerId:回拨时临时切换机器号(需要机器号池)

生产建议:

  • 小回拨(如 < 5ms)等待追平;
  • 大回拨报警 + 拒绝或降级(例如切到号段方案)。

4. 方案二:号段(Segment/Leaf)------交易/订单的"稳健之王"

4.1 核心思想

把"生成 ID"的压力从"每次请求都打 DB"变成:

  • DB 里维护每个业务的 max_id(或 next_id
  • 应用一次从 DB 取一段(比如 10,000 个)
  • 应用内存里自增发号,发完再取下一段

你会得到:

  • 高性能:请求 ID 基本不访问 DB
  • 强可控:递增、短、适合 BIGINT
  • 时钟无关:不怕机器时钟问题
  • 故障可控:DB 挂了还能用完手里这段

4.2 表结构(推荐)

sql 复制代码
CREATE TABLE id_segment (
  biz_tag      VARCHAR(64)  NOT NULL PRIMARY KEY COMMENT '业务标识,如 order, pay, refund',
  max_id       BIGINT       NOT NULL COMMENT '当前已分配到的最大ID',
  step         INT          NOT NULL COMMENT '每次分配号段长度',
  version      BIGINT       NOT NULL COMMENT '乐观锁版本',
  update_time  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  remark       VARCHAR(128) NULL
);

取号段关键:乐观锁更新

sql 复制代码
UPDATE id_segment
SET max_id = max_id + step,
    version = version + 1
WHERE biz_tag = ?
  AND version = ?;

读取当前 max_idstepversion,update 成功则获得号段:

  • oldMaxId + 1 ~ oldMaxId + step

4.3 双 buffer(推荐)

Leaf 的经典做法:准备两段号段

  • 当前段快用完时,后台线程预取下一段
  • 切换时无阻塞

好处:更稳、更平滑,基本不会"卡一下"。


5. 方案三:Redis INCR ------简单粗暴但要想清楚一致性

5.1 最简用法

  • key:id:order
  • 命令:INCR id:order

问题在于:

  • Redis 宕机/切换时,是否会"回退"?
  • Redis AOF/RDB 策略不当会导致"重复"或"跳号"
  • 跨业务线需要不同 key
  • 如果你要"日期前缀 + 当日递增",还要 id:order:20260109

建议:

  • 如果是订单主键这种核心数据:慎用 Redis INCR 作为唯一来源(除非你把 Redis 做到非常强的可用与持久策略)。
  • 但作为"展示用流水号/短期序列"很香。

6. 方案四:UUID / ULID / UUIDv7 ------字符串 ID 的选择

  • UUID v4:随机,极难碰撞,但无序,MySQL 主键不友好
  • ULID:可排序字符串(基于时间 + 随机)
  • UUID v7:新标准方向(时间有序更强)

如果你:

  • 数据库不是强依赖 B+Tree 主键性能(如文档库、日志)
  • 或者你愿意用"业务自增主键 + 外部展示ID"
    那么字符串 ID 就很舒服。

7. 生产选型建议(很实用)

7.1 订单/交易主键(强推荐)

  • 优先:号段(Segment/Leaf)
    • 稳定、递增、时钟无关
  • 或者:Snowflake
    • 依赖更少,性能更高,但要处理时钟回拨与机器号

7.2 分库分表场景

  • 主键最好是 全局唯一且大致递增(避免热点可以加入业务 hash/分片键)
  • 常用:
    • Snowflake:ID 自带时间,分片通常用 id % N 或按业务字段
    • 号段:也是递增,且可控

7.3 对外展示的订单号

别直接把内部主键暴露出去(容易被猜测订单量/时间)。

做法:

  • 内部:BIGINT 主键(Snowflake / 号段)
  • 外部:展示号 = base62(主键) 或 加盐混淆 或 映射表

8. Java 落地代码:Snowflake(可直接用)

说明:这是一个"单机安全 + 线程安全"的实现,包含基本的时钟回拨处理(等待追平)。

java 复制代码
import java.util.concurrent.ThreadLocalRandom;

public class SnowflakeIdGenerator {

    // 起始时间戳(可自定义,建议上线日)
    private final long epoch = 1704067200000L; // 2024-01-01 00:00:00

    private final long workerIdBits = 10L;
    private final long sequenceBits = 12L;

    private final long maxWorkerId = ~(-1L << workerIdBits);
    private final long workerIdShift = sequenceBits;
    private final long timestampShift = sequenceBits + workerIdBits;
    private final long sequenceMask = ~(-1L << sequenceBits);

    private final long workerId;

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

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > maxWorkerId) {
            throw new IllegalArgumentException("workerId out of range: 0 ~ " + maxWorkerId);
        }
        this.workerId = workerId;
    }

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

        // 时钟回拨处理:等待追平(适合小回拨)
        if (ts < lastTimestamp) {
            long offset = lastTimestamp - ts;
            if (offset <= 5) {
                try {
                    Thread.sleep(offset);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                ts = currentTimeMillis();
                if (ts < lastTimestamp) {
                    throw new IllegalStateException("Clock moved backwards. offset=" + offset);
                }
            } else {
                throw new IllegalStateException("Clock moved backwards too much. offset=" + offset);
            }
        }

        if (ts == lastTimestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                // 同毫秒内序列用尽,等到下一毫秒
                ts = waitNextMillis(lastTimestamp);
            }
        } else {
            // 新毫秒,序列可以从 0 或随机起步(减少同毫秒碰撞风险感知)
            sequence = ThreadLocalRandom.current().nextLong(0, 2);
        }

        lastTimestamp = ts;

        return ((ts - epoch) << timestampShift)
                | (workerId << workerIdShift)
                | sequence;
    }

    private long waitNextMillis(long lastTs) {
        long ts = currentTimeMillis();
        while (ts <= lastTs) {
            ts = currentTimeMillis();
        }
        return ts;
    }

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

8.1 Spring Boot 接入示例

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class IdConfig {

    @Bean
    public SnowflakeIdGenerator idGenerator() {
        // 你需要保证 workerId 全局唯一:比如从配置中心、StatefulSet ordinal
        long workerId = 1L;
        return new SnowflakeIdGenerator(workerId);
    }
}

9. Java 落地代码:号段(Segment)核心实现(可直接改造上线)

说明:下面给的是"单 buffer"版本(最容易落地),再附一个"预取思想"的升级建议。

9.1 Mapper(MyBatis)

java 复制代码
public interface IdSegmentMapper {

    IdSegmentDO selectForUpdate(String bizTag);

    int updateMaxIdWithVersion(String bizTag, long oldVersion);
}

对应 SQL 示例(MyBatis XML / 注解都行):

sql 复制代码
-- select
SELECT biz_tag, max_id, step, version
FROM id_segment
WHERE biz_tag = #{bizTag};

-- update(乐观锁)
UPDATE id_segment
SET max_id = max_id + step,
    version = version + 1
WHERE biz_tag = #{bizTag}
  AND version = #{oldVersion};

注意:这里的 select 不一定要 for update,因为我们依赖乐观锁。

但如果你希望降低更新冲突次数,也可以 select for update(吞吐会下降)。

9.2 号段对象

java 复制代码
public class Segment {
    private final long start;  // inclusive
    private final long end;    // inclusive
    private long current;

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

    public synchronized long next() {
        if (current > end) {
            return -1;
        }
        return current++;
    }

    public boolean isExhausted() {
        return current > end;
    }
}

9.3 服务实现(带重试)

java 复制代码
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SegmentIdService {

    private final IdSegmentMapper mapper;
    private final Map<String, Segment> cache = new ConcurrentHashMap<>();

    public SegmentIdService(IdSegmentMapper mapper) {
        this.mapper = mapper;
    }

    public long nextId(String bizTag) {
        Segment seg = cache.computeIfAbsent(bizTag, k -> loadSegment(k));

        long id = seg.next();
        if (id != -1) {
            return id;
        }

        synchronized (this) {
            // double-check
            Segment current = cache.get(bizTag);
            long retryId = current.next();
            if (retryId != -1) {
                return retryId;
            }
            Segment newSeg = loadSegment(bizTag);
            cache.put(bizTag, newSeg);
            return newSeg.next();
        }
    }

    private Segment loadSegment(String bizTag) {
        for (int i = 0; i < 10; i++) {
            IdSegmentDO row = mapper.selectForUpdate(bizTag);
            if (row == null) {
                throw new IllegalStateException("bizTag not found: " + bizTag);
            }
            int updated = mapper.updateMaxIdWithVersion(bizTag, row.getVersion());
            if (updated == 1) {
                long oldMaxId = row.getMaxId();
                long step = row.getStep();
                long start = oldMaxId + 1;
                long end = oldMaxId + step;
                return new Segment(start, end);
            }
            // 乐观锁冲突,短暂退避重试
            try { Thread.sleep(5L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
        throw new IllegalStateException("loadSegment failed after retries, bizTag=" + bizTag);
    }
}

9.4 升级到"双 buffer"的思路(建议)

  • 当前 Segment 剩余量低于阈值(例如 10%)时,后台异步预取 nextSegment
  • 当前用完立刻切换,避免请求线程去打 DB

实现要点:

  • 每个 bizTag 维护:
    • currentSegment
    • nextSegment(可能为 null)
    • loadingNext 标记(CAS/锁避免重复加载)
  • 异步线程池负责预取

10. 常见坑(踩过一次就会记一辈子)

10.1 MySQL 用 UUID 当主键

会让索引页频繁分裂,写入随机 IO 增加,性能很容易崩。

(除非你用 UUIDv7/ULID 并且做了顺序化)

10.2 Snowflake 的时钟回拨

  • 容器/虚拟机时间漂移
  • NTP 调整
    解决:等待追平 + 报警 + 降级(号段)

10.3 workerId 冲突

这是雪花"最致命"的坑:一冲突就是直接重复 ID。

解决:StatefulSet ordinal / 配置中心强约束 / 自动抢占(ZK/Etcd)

10.4 号段 step 设置过小

step 太小会导致频繁打 DB;太大导致"跳号"更明显(通常可接受)。

经验:

  • 10k QPS:step 10k~100k
  • 100k QPS:step 100k~1M

10.5 号段跨库/读写分离

号段的 update 必须走主库 ,而且要保证强一致读到最新 version/max_id。

如果你走读库读到旧 version,会导致更新失败重试增加甚至异常。


11. 推荐组合(强实战)

组合 A(最常见):

  • 内部主键:Snowflake(BIGINT)
  • 对外订单号:base62(snowflakeId) + 校验位/加盐

组合 B(交易稳健派):

  • 内部主键:号段(BIGINT)
  • 对外订单号:日期 + 业务码 + base36(递增号)

组合 C(全链路追踪):

  • traceId:UUID/ULID
  • 业务主键:Snowflake/号段

12. 快速落地清单(照着做就能上线)

Snowflake 上线清单

  • 确定 epoch
  • 设计 workerId 分配方式(最重要)
  • 处理时钟回拨(等待追平 + 报警)
  • 压测:同毫秒序列溢出时的表现
  • 监控:回拨次数、生成失败次数、QPS

号段上线清单

  • 建表 id_segment,初始化 biz_tag
  • update 必须走主库
  • step 合理配置 + 可动态调整
  • 预取(双 buffer)可作为二期
  • 监控:DB update 冲突次数、取段耗时、缓存命中率

13. 选型一句话建议

  • 你是电商/支付/订单 :优先 号段(Segment/Leaf)(稳、递增、时钟无关)。
  • 你想极简、极快、不想依赖中心服务 :用 Snowflake(但一定把 workerId 和回拨搞定)。
  • 你只是要唯一,不在乎索引与长度 :用 UUID/ULID

相关推荐
利刃大大20 小时前
【RabbitMQ】安装详解 && 什么是MQ && RabbitMQ介绍
分布式·中间件·消息队列·rabbitmq·mq
QQ_43766431420 小时前
kafka
分布式·kafka
是一个Bug20 小时前
Java后端开发面试题清单(50道) - 分布式基础
java·分布式·wpf
大猫和小黄20 小时前
Java ID生成策略全面解析:从单机到分布式的最佳实践
java·开发语言·分布式·id
ZePingPingZe20 小时前
CAP—ZooKeeper ZAB协议:从理论到实践的一致性与可用性平衡之道
分布式·zookeeper
掘金-我是哪吒20 小时前
完整的Kafka项目启动流程
分布式·kafka
无心水20 小时前
【分布式利器:腾讯TSF】4、TSF配置中心深度解析:微服务动态配置的终极解决方案
分布式·微服务·架构·wpf·分布式利器·腾讯tsf·分布式利器:腾讯tsf
无心水1 天前
【分布式利器:腾讯TSF】7、TSF高级部署策略全解析:蓝绿/灰度发布落地+Jenkins CI/CD集成(Java微服务实战)
java·人工智能·分布式·ci/cd·微服务·jenkins·腾讯tsf
Yeats_Liao1 天前
MindSpore开发之路(二十四):MindSpore Hub:快速复用预训练模型
人工智能·分布式·神经网络·机器学习·个人开发