【架构实战】分布式ID生成方案:雪花算法与业务ID设计

Snowflake、号段模式、Leaf、实战对比


一、从一个真实的故事说起

2023年某电商大促,订单系统在流量洪峰下突然报错——“主键冲突”。

开发团队排查后发现,问题出在数据库的自增ID上。由于订单表数据量太大,他们采用了分库分表策略,将订单数据分散到16个数据库实例。每个数据库实例配置了不同的自增起始值和步长:

DB1: 起始值1,步长16 → 1, 17, 33, 49...
DB2: 起始值2,步长16 → 2, 18, 34, 50...
...
DB16: 起始值16,步长16 → 16, 32, 48, 64...

这个方案在单机房运行良好,但为了提高可用性,他们在另一个机房部署了相同的16个数据库实例,配置了同样的起始值和步长。结果两个机房的数据库生成了相同的ID,合并数据时发生主键冲突。

“我们不是配置了不同的步长吗?为什么还会冲突?”

“步长只在同一机房内保证不冲突,跨机房没有考虑。而且随着业务增长,如果要扩容到32个分库,现有方案完全无法支持。”

这个故事告诉我们:分布式ID生成不是简单的"唯一性"问题,还需要考虑性能、可用性、扩展性、信息密度等多个维度。


二、分布式ID的核心要求

在设计分布式ID方案之前,我们需要明确核心要求:

2.1 全局唯一性

这是最基本的要求,所有业务场景都必须满足。但"全局"的范围需要明确:

  • 系统级全局:整个系统内唯一(如订单ID)
  • 业务级全局:某个业务范围内唯一(如用户ID)
  • 租户级全局:某个租户范围内唯一(如SaaS系统)

2.2 有序性

有序性影响数据库性能:

  • 完全有序:按生成时间严格递增(如Snowflake)
  • 趋势有序:短时间内生成的ID大致有序(如号段模式)
  • 无序:随机生成(如UUID)

为什么有序性重要?

MySQL InnoDB使用B+树索引,主键有序时,新数据直接追加到索引末尾,性能最高。主键无序时,新数据可能插入到索引中间,导致页分裂,性能下降。

2.3 性能要求

不同业务场景对性能要求不同:

  • 低频业务:每秒生成几十个ID即可(如用户注册)
  • 中频业务:每秒生成几千个ID(如订单创建)
  • 高频业务:每秒生成几十万个ID(如消息推送)

2.4 信息密度

ID是否需要携带业务信息:

  • 纯数字ID:不携带任何信息(如Snowflake)
  • 业务编码ID:携带业务信息(如订单号=时间+渠道+序号)
  • 混合ID:部分携带信息(如用户ID=注册时间+序号)

三、常见方案对比

3.1 UUID

原理:基于随机数生成128位唯一标识符。

String uuid = UUID.randomUUID().toString();
// 结果:550e8400-e29b-41d4-a716-446655440000

优点

  • 本地生成,无网络开销
  • 性能极高,每秒可生成百万级
  • 无单点故障

缺点

  • 无序,导致数据库索引性能差
  • 过长(36字符),存储和传输开销大
  • 无业务语义,难以排查问题

适用场景:临时标识、非主键场景

3.2 数据库自增ID

原理:利用数据库的auto_increment特性生成ID。

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    ...
);

优点

  • 实现简单,数据库原生支持
  • 完全有序,索引性能好
  • 单调递增,便于分页

缺点

  • 单点问题,数据库故障导致ID生成失败
  • 性能瓶颈,受限于数据库写入性能
  • 分库分表场景下难以保证全局唯一

适用场景:单库单表、低并发场景

3.3 号段模式

原理:从数据库批量获取ID号段,本地分配。

public class SegmentIDGenerator {
    private long maxId;      // 当前号段最大值
    private long currentId;  // 当前分配到的ID
    private int step;        // 号段大小
    
    public synchronized long nextId() {
        if (currentId >= maxId) {
            // 号段用完,从数据库获取新号段
            Segment segment = db.getSegment(step);
            currentId = segment.getStart();
            maxId = segment.getEnd();
        }
        return currentId++;
    }
}

数据库表设计:

CREATE TABLE id_segment (
    biz_tag VARCHAR(64) PRIMARY KEY,  -- 业务标识
    max_id BIGINT NOT NULL,           -- 当前最大ID
    step INT NOT NULL,                -- 号段大小
    version INT NOT NULL              -- 乐观锁版本号
);

-- 获取号段
UPDATE id_segment 
SET max_id = max_id + step, version = version + 1
WHERE biz_tag = 'order' AND version = ?;

优点

  • 性能高,本地分配无网络开销
  • 趋势有序,对数据库友好
  • 支持多业务独立配置

缺点

  • 单点问题,数据库故障导致ID生成失败
  • 号段内ID可能浪费(服务重启)
  • 不适合单机高并发场景(号段竞争)

适用场景:中频业务、多业务场景

3.4 雪花算法(Snowflake)

原理:将64位long类型ID划分为多个部分:

0 - 41位时间戳 - 10位机器ID - 12位序号

| 1位符号位 | 41位时间戳 | 10位机器ID | 12位序号 |
|----------|-----------|-----------|---------|
|    0     |  41位     |   10位    |  12位   |
  • 符号位:1位,始终为0
  • 时间戳:41位,毫秒级时间戳,可用69年
  • 机器ID:10位,支持1024台机器
  • 序号:12位,每毫秒最多生成4096个ID
public class SnowflakeIDGenerator {
    private final long workerId;       // 机器ID
    private final long datacenterId;   // 数据中心ID
    private long sequence = 0;         // 序号
    private long lastTimestamp = -1L;  // 上次生成时间戳
    
    // 各部分的位数
    private final long sequenceBits = 12L;
    private final long workerIdBits = 5L;
    private final long datacenterIdBits = 5L;
    
    // 各部分的偏移量
    private final long workerIdShift = sequenceBits;
    private final long datacenterIdShift = sequenceBits + workerIdBits;
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        
        // 时钟回拨检测
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨,拒绝生成ID");
        }
        
        if (timestamp == lastTimestamp) {
            // 同一毫秒内,序号递增
            sequence = (sequence + 1) & ((1 << sequenceBits) - 1);
            if (sequence == 0) {
                // 序号溢出,等待下一毫秒
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 新的一毫秒,序号重置
            sequence = 0;
        }
        
        lastTimestamp = timestamp;
        
        // 组装ID
        return ((timestamp - twepoch) << timestampLeftShift)
             | (datacenterId << datacenterIdShift)
             | (workerId << workerIdShift)
             | sequence;
    }
    
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

优点

  • 本地生成,无网络开销
  • 性能极高,单机每秒可生成400万ID
  • 完全有序,按时间递增
  • 无单点故障

缺点

  • 时钟回拨问题,可能导致ID重复
  • 机器ID分配需要外部协调
  • ID长度固定,无法携带业务信息

适用场景:高频业务、分布式场景


四、Leaf:美团开源的分布式ID方案

Leaf是美团开源的分布式ID生成系统,结合了号段模式和雪花算法的优点。

4.1 Leaf Segment(号段模式)

Leaf对号段模式进行了优化:

优化一:双Buffer预加载

public class SegmentIDGenerator {
    private Segment[] segments = new Segment[2];  // 双Buffer
    private int currentSegment = 0;               // 当前使用的Buffer
    private volatile boolean isNextSegmentReady = false;  // 下一个Buffer是否就绪
    
    public long nextId() {
        Segment segment = segments[currentSegment];
        long id = segment.nextId();
        
        // 当使用量达到阈值,异步加载下一个号段
        if (segment.isThresholdReached() && !isNextSegmentReady) {
            asyncLoadNextSegment();
        }
        
        // 当前号段用完,切换到下一个号段
        if (segment.isExhausted()) {
            synchronized (this) {
                if (segment.isExhausted()) {
                    currentSegment = (currentSegment + 1) % 2;
                    isNextSegmentReady = false;
                }
            }
        }
        
        return id;
    }
}

优化二:数据库高可用

Leaf使用主从数据库,主库故障时自动切换到从库:

leaf:
  segment:
    enable: true
    db:
      master:
        url: jdbc:mysql://master:3306/leaf
        username: root
        password: root
      slave:
        url: jdbc:mysql://slave:3306/leaf
        username: root
        password: root

4.2 Leaf Snowflake(雪花算法)

Leaf对雪花算法的优化主要在机器ID分配:

使用ZooKeeper分配机器ID

public class SnowflakeIDGenerator {
    private ZooKeeper zk;
    private String zkPath = "/leaf/snowflake/workers";
    
    public void init() {
        // 在ZooKeeper创建临时顺序节点
        String nodePath = zk.create(zkPath + "/worker-", 
                                    null, 
                                    ZooDefs.Ids.OPEN_ACL_UNSAFE, 
                                    CreateMode.EPHEMERAL_SEQUENTIAL);
        
        // 从节点路径提取workerId
        String nodeName = nodePath.substring(nodePath.lastIndexOf('/') + 1);
        workerId = Integer.parseInt(nodeName.replace("worker-", ""));
    }
}

时钟回拨处理

Leaf对时钟回拨的处理更加友好:

public long nextId() {
    long timestamp = System.currentTimeMillis();
    
    if (timestamp < lastTimestamp) {
        long offset = lastTimestamp - timestamp;
        
        if (offset <= 5) {
            // 小幅回拨,等待时钟追上
            try {
                Thread.sleep(offset * 2);
                timestamp = System.currentTimeMillis();
                if (timestamp < lastTimestamp) {
                    throw new RuntimeException("时钟回拨超过阈值");
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        } else {
            // 大幅回拨,从ZooKeeper获取新的workerId
            reassignWorkerId();
        }
    }
    
    // ... 正常生成ID
}

4.3 Leaf部署架构

                    ┌─────────────┐
                    │   Nginx     │
                    │  负载均衡   │
                    └──────┬──────┘
                           │
         ┌─────────────────┼─────────────────┐
         │                 │                 │
    ┌────▼────┐       ┌────▼────┐       ┌────▼────┐
    │ Leaf-1  │       │ Leaf-2  │       │ Leaf-3  │
    │ Segment │       │ Segment │       │ Segment │
    └────┬────┘       └────┬────┘       └────┬────┘
         │                 │                 │
         └─────────────────┼─────────────────┘
                           │
                    ┌──────▼──────┐
                    │   MySQL     │
                    │  主从复制   │
                    └─────────────┘

五、实战案例:订单ID设计

5.1 业务需求

某电商平台需要设计订单ID,要求:

  1. 全局唯一,支持分库分表
  2. 有序性,便于数据库索引
  3. 携带业务信息,便于排查问题
  4. 长度适中,不超过20位

5.2 ID结构设计

订单ID = 时间戳(8位) + 渠道ID(2位) + 分库ID(2位) + 序号(6位)

示例:202411110101000001
      └──────┘└─┘└─┘└────┘
        时间   渠道 分库  序号
  • 时间戳:8位,格式yyyyMMdd
  • 渠道ID:2位,01=APP,02=Web,03=小程序
  • 分库ID:2位,对应分库编号
  • 序号:6位,每天从1开始,最多100万单

5.3 代码实现

public class OrderIDGenerator {
    private final int channelId;      // 渠道ID
    private final int dbShardId;      // 分库ID
    private final String currentDate; // 当前日期
    private int sequence = 0;         // 当天序号
    
    public OrderIDGenerator(int channelId, int dbShardId) {
        this.channelId = channelId;
        this.dbShardId = dbShardId;
        this.currentDate = formatDate(new Date());
    }
    
    public synchronized String nextId() {
        String date = formatDate(new Date());
        
        // 日期变更,重置序号
        if (!date.equals(currentDate)) {
            this.sequence = 0;
        }
        
        // 序号递增
        this.sequence++;
        
        // 组装ID
        return String.format("%s%02d%02d%06d", 
                            date, channelId, dbShardId, sequence);
    }
    
    private String formatDate(Date date) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
        return sdf.format(date);
    }
}

5.4 ID解析

public class OrderIDParser {
    public static void parse(String orderId) {
        String date = orderId.substring(0, 8);
        int channelId = Integer.parseInt(orderId.substring(8, 10));
        int dbShardId = Integer.parseInt(orderId.substring(10, 12));
        int sequence = Integer.parseInt(orderId.substring(12, 18));
        
        System.out.println("下单日期: " + date);
        System.out.println("下单渠道: " + getChannelName(channelId));
        System.out.println("分库编号: " + dbShardId);
        System.out.println("当天序号: " + sequence);
    }
    
    private static String getChannelName(int channelId) {
        switch (channelId) {
            case 1: return "APP";
            case 2: return "Web";
            case 3: return "小程序";
            default: return "未知";
        }
    }
}

// 示例
OrderIDParser.parse("202411110101000001");
// 输出:
// 下单日期: 20241111
// 下单渠道: APP
// 分库编号: 1
// 当天序号: 1

六、踩坑实录

踩坑一:雪花算法时钟回拨

问题:服务器时钟同步时回拨,导致生成的ID重复。

// 时钟回拨前
lastTimestamp = 1699888888888L;
long id1 = generator.nextId();  // 正常

// 时钟回拨
lastTimestamp = 1699888888888L;
timestamp = 1699888888000L;  // 回拨888ms

// 再次生成ID
long id2 = generator.nextId();  // 可能与id1重复

解决方案

public synchronized long nextId() {
    long timestamp = System.currentTimeMillis();
    
    if (timestamp < lastTimestamp) {
        long offset = lastTimestamp - timestamp;
        
        // 方案一:小幅回拨,等待时钟追上
        if (offset <= 5) {
            try {
                wait(offset << 1);
                timestamp = System.currentTimeMillis();
                if (timestamp < lastTimestamp) {
                    throw new RuntimeException("时钟回拨超过阈值");
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        
        // 方案二:大幅回拨,使用备用workerId
        else {
            workerId = getBackupWorkerId();
        }
    }
    
    // ... 正常生成ID
}

踩坑二:号段模式号段浪费

问题:服务重启时,未使用的ID浪费。

// 服务重启前
maxId = 1000;
currentId = 950;
// 剩余50个ID未使用

// 服务重启后
// 从数据库获取新号段
maxId = 2000;
currentId = 1000;
// 之前的50个ID永久浪费

解决方案:记录已分配的最大ID

public class SegmentIDGenerator {
    private long maxId;
    private long currentId;
    private long allocatedMaxId;  // 已分配的最大ID
    
    public synchronized long nextId() {
        if (currentId >= maxId) {
            // 保存已分配的最大ID
            db.updateAllocatedMaxId(bizTag, allocatedMaxId);
            
            // 获取新号段
            Segment segment = db.getSegment(step, allocatedMaxId);
            currentId = segment.getStart();
            maxId = segment.getEnd();
        }
        
        long id = currentId++;
        allocatedMaxId = Math.max(allocatedMaxId, id);
        return id;
    }
}

踩坑三:机器ID分配冲突

问题:多台机器配置了相同的workerId,导致ID重复。

# application.yml
snowflake:
  workerId: 1  # 所有机器都配置为1

解决方案:使用ZooKeeper或Redis自动分配

// 使用Redis分配workerId
public class WorkerIdAllocator {
    private Jedis jedis;
    
    public int allocateWorkerId() {
        // 使用Redis的INCR命令分配
        Long workerId = jedis.incr("snowflake:worker_id");
        
        // 超过最大值,循环使用
        if (workerId > 1023) {
            jedis.set("snowflake:worker_id", "0");
            workerId = 0L;
        }
        
        return workerId.intValue();
    }
}

踩坑四:ID长度超出预期

问题:Snowflake生成的ID是19位,超出数据库字段长度。

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,  -- BIGINT最大值是9223372036854775807,19位
    ...
);

-- 但业务要求ID不超过18位

解决方案:调整Snowflake的位数分配

// 标准Snowflake:41位时间戳 + 10位机器ID + 12位序号 = 63位
// 调整后:      38位时间戳 + 8位机器ID + 12位序号 = 58位

// 时间戳位数减少,可用时间变短
// 38位时间戳 ≈ 8.5年
// 需要设置起始时间戳

private final long twepoch = 1704067200000L;  // 2024-01-01 00:00:00

七、方案选型指南

方案性能有序性可用性复杂度适用场景
UUID极高无序极高极低临时标识、非主键
数据库自增完全有序极低单库、低并发
号段模式趋势有序中频业务、多业务
雪花算法极高完全有序高频业务、分布式
Leaf Segment趋势有序生产环境、多业务
Leaf Snowflake极高完全有序生产环境、高频业务

选型建议

  1. 低并发单库场景:直接使用数据库自增ID
  2. 中并发多业务场景:使用Leaf Segment
  3. 高并发分布式场景:使用Leaf Snowflake
  4. 需要携带业务信息:自定义ID结构,结合雪花算法或号段模式

八、总结

分布式ID生成看似简单,实则需要考虑多个维度:

  1. 唯一性:全局唯一是基本要求,但需要明确"全局"的范围
  2. 有序性:影响数据库索引性能,需要权衡完全有序和趋势有序
  3. 性能:不同业务场景对性能要求不同,需要选择合适的方案
  4. 可用性:单点故障、时钟回拨等问题需要提前考虑
  5. 扩展性:业务增长时,方案是否支持平滑扩容

九、思考题

  1. 如果你的业务需要支持"短链接"(如t.cn/abc123),你会如何设计ID生成方案?需要考虑哪些约束?

  2. 在多机房部署场景下,如何保证雪花算法生成的ID全局唯一?如果机房之间的时钟不同步怎么办?

  3. 对于金融业务(如交易流水号),ID生成方案需要满足哪些额外要求?如何保证ID生成的审计追溯能力?


十、个人观点

在我参与过的多个项目中,分布式ID最常见的误区是:过度追求性能,忽视可用性

很多团队一上来就选择雪花算法,觉得性能最高。但雪花算法的时钟回拨问题在生产环境中经常发生,一旦发生,可能导致ID重复,后果严重。我的建议是:优先选择号段模式,除非性能确实不满足需求

号段模式的性能已经足够支撑绝大多数业务(单机每秒几万ID),而且没有时钟回拨问题。即使数据库故障,也可以通过双Buffer预加载,保证短时间内的可用性。

另一个误区是:忽视ID的业务语义。纯数字ID虽然简单,但在排查问题时很不方便。比如看到订单号"1699888888888888888",完全不知道是哪个渠道、哪个时间下单的。而看到"202411110101000001",一眼就能看出是2024年11月11日APP渠道的订单。

我的建议是:在满足性能和可用性的前提下,尽量让ID携带业务信息。可以通过自定义ID结构,或者维护ID到业务信息的映射表。

最后,分布式ID是基础设施,一旦上线很难更换。建议在项目初期就充分评估业务需求,选择合适的方案,并预留扩展空间。


作者:架构实战系列 | 字数:约4800字

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐