【架构实战】分布式ID生成:从自增ID到雪花算法

【架构实战】分布式ID生成:从自增ID到雪花算法

一、背景:分库分表后,自增ID的噩梦

2019年我们把订单表做了分库分表,按用户ID哈希分了16个库。

上线第二天,运营反馈:后台看到的订单号会跳变。查了一下午才搞清楚:MySQL自增ID在每个库里独立计数,导致不同分库里的订单ID重复了。

复制代码
分库前(单库):
orders 表: AUTO_INCREMENT → 1001, 1002, 1003... 全局唯一

分库后(16个库):
db_00.orders: AUTO_INCREMENT → 1001, 1002, 1003...
db_01.orders: AUTO_INCREMENT → 1001, 1002, 1003... ← 与db_00重复了!

那天我们手动跑脚本给所有订单号加了个库前缀才紧急修复。但这只是临时方案,根本问题没解决。

分库分表之后,数据库自增ID彻底失效,必须要有全局唯一的ID生成方案。


二、分布式ID的核心要求

复制代码
分布式ID必须满足:

1. 全局唯一:最基本要求,任何两个ID不能重复
2. 趋势递增:索引友好,减少B+树页分裂
3. 高性能:生成速度不能成为系统瓶颈
4. 高可用:ID生成服务不能有单点故障
5. 信息安全:不能暴露业务量(如今天第几个订单)

三、方案演进:从青铜到王者

3.1 青铜:UUID

java 复制代码
String id = UUID.randomUUID().toString();
// 例如:550e8400-e29b-41d4-a716-446655440000

优点:本地生成,无网络开销,实现超简单。

缺点(致命)

  • 字符串太长(36字节),占用大量存储空间

  • 完全无序,作为主键会导致B+树频繁页分裂,写入性能下降50%以上

  • 不包含时间信息,不便于排查问题

    UUID作为主键插入B+树:
    每次插入都是随机位置 → 页分裂频繁 → 磁盘碎片 → 性能灾难

    趋势递增ID作为主键插入B+树:
    每次插入到最后 → 极少页分裂 → 顺序写入 → 性能优秀

结论:可以用作业务编号但绝不适合做数据库主键。

3.2 白银:数据库号段模式

原理:用一个DB表集中分配ID号段,服务每次批量获取一段ID,用完后再次申请。

sql 复制代码
-- ID号段表
CREATE TABLE id_generator (
    biz_type VARCHAR(64) PRIMARY KEY COMMENT '业务类型',
    max_id BIGINT NOT NULL COMMENT '当前已分配的最大ID',
    step INT NOT NULL DEFAULT 1000 COMMENT '每次分配的号段长度',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'
);

-- 获取号段的原子操作
UPDATE id_generator
SET max_id = max_id + step, version = version + 1
WHERE biz_type = 'order' AND version = #{oldVersion};

-- 如果update影响行数>0 → 分配成功,号段 = [max_id - step + 1, max_id]
java 复制代码
@Service
public class DbSegmentIdGenerator {
    
    private volatile Segment currentSegment;
    private volatile Segment nextSegment;  // 双Buffer优化
    
    @PostConstruct
    public void init() {
        currentSegment = fetchSegment();
        nextSegment = fetchSegment();  // 预加载下一个号段
        
        // 异步预加载线程
        Executors.newSingleThreadScheduledExecutor()
            .scheduleWithFixedDelay(this::preloadSegment, 0, 1, TimeUnit.SECONDS);
    }
    
    public synchronized long nextId() {
        if (!currentSegment.hasNext()) {
            // 号段用完,切换到预加载的号段
            currentSegment = nextSegment;
            nextSegment = fetchSegment();
        }
        return currentSegment.next();
    }
    
    private Segment fetchSegment() {
        // 执行 UPDATE id_generator SET max_id = max_id + step
        // 返回号段 [newMaxId - step + 1, newMaxId]
    }
}

优点:ID趋势递增,本地缓存号段性能高,每次DB交互可分配1000个ID。

缺点:依赖DB,DB宕机时号段耗完后无法分配;多业务需要多张表。

3.3 黄金:Redis生成

java 复制代码
// 使用Redis的INCRBY原子操作
long id = redisTemplate.opsForValue()
    .increment("id:order", 1);  // 每次+1,返回新ID

优点:性能极高,实现简单。

缺点

  • Redis不持久化会丢ID(重启后从0开始)
  • Redis持久化(RDB/AOF)有性能损耗
  • 主从切换可能产生重复ID

3.4 王者:雪花算法(Snowflake)

原理:一个64位Long整数,各段含义如下:

复制代码
┌─┬──────────────────────────────────────────────┬──────────┬────────────┐
│0│              41位时间戳(毫秒)                  │ 10位机器码│ 12位序列号  │
└─┴──────────────────────────────────────────────┴──────────┴────────────┘
 1位        41位                                  10位        12位
(保留)

各部分说明:
- 第1位:固定0(保证正数)
- 41位时间戳:从自定义起始时间到现在的毫秒数,可用69年
- 10位机器码:最多1024个节点(5位机房+5位机器ID)
- 12位序列号:同一毫秒内最多4096个ID

QPS = 4096个/毫秒 × 1000 = 约400万/秒
java 复制代码
public class SnowflakeIdGenerator {
    
    // 起始时间戳(2020-01-01 00:00:00)
    private static final long START_TIMESTAMP = 1577836800000L;
    
    // 各部分位数
    private static final long DATA_CENTER_ID_BITS = 5L;      // 5位机房ID
    private static final long WORKER_ID_BITS = 5L;           // 5位机器ID
    private static final long SEQUENCE_BITS = 12L;           // 12位序列号
    
    // 最大值
    private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS);  // 31
    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);            // 31
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);              // 4095
    
    // 偏移量
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;                              // 12
    private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;       // 17
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; // 22
    
    private final long dataCenterId;
    private final long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator(long dataCenterId, long workerId) {
        if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) {
            throw new IllegalArgumentException("机房ID超出范围: 0-" + MAX_DATA_CENTER_ID);
        }
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("机器ID超出范围: 0-" + MAX_WORKER_ID);
        }
        this.dataCenterId = dataCenterId;
        this.workerId = workerId;
    }
    
    public synchronized long nextId() {
        long currentTimestamp = System.currentTimeMillis();
        
        // 时钟回拨检测
        if (currentTimestamp < lastTimestamp) {
            throw new RuntimeException(
                "时钟回拨: 上次时间=" + lastTimestamp + ", 当前时间=" + currentTimestamp);
        }
        
        if (currentTimestamp == lastTimestamp) {
            // 同一毫秒内,序列号递增
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 序列号用完,等待下一毫秒
                currentTimestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            // 不同毫秒,序列号重置
            sequence = 0L;
        }
        
        lastTimestamp = currentTimestamp;
        
        // 组装ID
        return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
             | (dataCenterId << DATA_CENTER_ID_SHIFT)
             | (workerId << WORKER_ID_SHIFT)
             | sequence;
    }
    
    private long waitNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

3.5 雪花算法的时钟回拨问题

问题:服务器时钟被调回过去,可能产生重复ID。

复制代码
时间线:
10:00:00.000 → 生成了ID=100
10:00:00.001 → 生成了ID=101
突然NTP同步,时钟回拨到 09:59:59.500
10:00:00.000 → 又生成了ID=100  ← 重复了!

解决方案

java 复制代码
// 方案1:短期回拨等待
private synchronized long nextIdWithFallback() {
    long currentTimestamp = System.currentTimeMillis();
    
    if (currentTimestamp < lastTimestamp) {
        long offset = lastTimestamp - currentTimestamp;
        
        if (offset <= 5) {  // 回拨5ms以内,等待追平
            try {
                Thread.sleep(offset + 1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            currentTimestamp = System.currentTimeMillis();
        } else {
            // 回拨超过5ms,使用备用workerId
            return generateWithBackupWorkerId(currentTimestamp);
        }
    }
    // ... 正常生成逻辑
}

// 方案2:记录最后生成的ID,时钟回拨时用历史最大ID+1
// 方案3:放弃本节点ID生成,切换到备用ID生成器

生产环境推荐:使用美团Leaf或百度UidGenerator,它们已解决时钟回拨问题。


四、方案对比

方案 唯一性 趋势递增 性能(QPS) 高可用 运维复杂度 适用场景
UUID 极高 0 非DB主键场景
DB号段 中(依赖DB) 中小规模
Redis 中等 已有Redis
雪花原生 极高(400万/s) 大型分布式
美团Leaf 极高 企业级

五、美团Leaf方案简介

美团开源了两种模式:

复制代码
Leaf-Segment(号段模式):
┌─────────┐     ┌─────────┐
│ Leaf-Svr│     │ Leaf-Svr│
│ (主)    │ ←─→ │ (从)    │
└────┬────┘     └────┬────┘
     │               │
     ▼               ▼
┌─────────┐     ┌─────────┐
│ 服务A   │     │ 服务B   │
│ (获取    │     │ (获取    │
│  号段)   │     │  号段)   │
└─────────┘     └─────────┘

- 双Buffer预加载:当前号段用完前已预加载下一个
- 高可用:多个Leaf Server组成集群

Leaf-Snowflake(雪花模式):
- 通过ZooKeeper自动注册workerId
- 内置时钟回拨解决方案

六、总结

方案选择决策树

复制代码
你的规模?
│
├── 单库单表 → 直接MySQL自增ID
│
├── 分库分表 < 10个节点 → DB号段模式
│   └── 优点:实现简单,趋势递增,索引友好
│
├── 分库分表 > 10个节点 → 雪花算法
│   └── 优点:高性能,高可用,本地生成
│
└── 企业级 > 100个节点 → 美团Leaf
    └── 优点:成熟方案,高可用,时钟回拨已解决

三条关键经验

  1. 不要用UUID做主键:B+树的噩梦,写入性能下降50%以上
  2. 雪花算法的workerId不能手动配置:机器多了必出错,用ZK或DB自动分配
  3. 时钟回拨是真实存在的问题:NTP同步、虚拟机迁移都可能触发,生产环境必须处理

分布式ID看起来简单,却是分库分表后第一个遇到的坑。方案选对,事半功倍;方案选错,数据迁移就是噩梦。


个人观点,仅供参考

相关推荐
懂AI的老郑2 小时前
面对小众场景的目标检测联合智能体零样本检测可行性分析
人工智能·yolo·架构
明哥聊AI2 小时前
AI视频生成技术全景:Sora2、Veo3、可灵3.0背后的Diffusion架构深度解析
人工智能·架构·音视频
正在走向自律4 小时前
从感知到自主决策:机器人技术的演进、核心架构与产业落地全景解析
架构·机器人·人形机器人·宇数机器人·医疗服务
木木学AI11 小时前
AI客服系统技术选型:Agentic架构与传统规则引擎的能力差异评估
人工智能·架构
满意的星月17 小时前
基于微服务和Docker容器技术的PaaS云平台架构设计(微服务架构实施原理)
docker·微服务·架构
以和为贵17 小时前
前端也能搞懂 Agent:从 Function Calling 到自主编排
前端·人工智能·架构
会周易的程序员18 小时前
使用 pybind11 封装 C++ 日志库 microLog 为 Python 模块
java·c++·python·物联网·架构·日志·aiot
为自己_带盐18 小时前
.NET 项目集成可观测性:从架构思考到落地实践
架构
熊猫钓鱼>_>18 小时前
2026 前端的三场底层革命:当 Vue、React Native 和 Three.js 同时去掉中间层
前端·javascript·vue.js·react native·架构·webgl·three.js