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

【架构实战】分布式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位唯一标识符。

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

优点

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

缺点

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

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

3.2 数据库自增ID

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

sql 复制代码
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    ...
);

优点

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

缺点

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

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

3.3 号段模式

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

java 复制代码
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++;
    }
}

数据库表设计:

sql 复制代码
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
java 复制代码
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预加载

java 复制代码
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使用主从数据库,主库故障时自动切换到从库:

yaml 复制代码
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

java 复制代码
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对时钟回拨的处理更加友好:

java 复制代码
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 代码实现

java 复制代码
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解析

java 复制代码
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重复。

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

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

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

解决方案

java 复制代码
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浪费。

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

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

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

java 复制代码
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重复。

yaml 复制代码
# application.yml
snowflake:
  workerId: 1  # 所有机器都配置为1

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

java 复制代码
// 使用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位,超出数据库字段长度。

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

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

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

java 复制代码
// 标准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字

相关推荐
光泽雨10 小时前
ADO.NET 进阶知识与实战坑位深度解析
性能优化·架构·.net
SoftLipaRZC11 小时前
C语言字符完全指南:字符函数与字符串函数
c语言·开发语言·算法
嗝o゚11 小时前
CANN hixl 单边通信库——PD 分离架构下的跨设备通信优化实践
架构·cann·hixl
墨白曦煜11 小时前
算法实战笔记:链表的底层逻辑与指针的高阶玩法(二)
笔记·算法·链表
AOwhisky11 小时前
Ceph系列第一期:Ceph分布式存储核心概念与架构初识
linux·运维·笔记·分布式·ceph·学习·架构
wuweijianlove11 小时前
算法复杂度评估的实验统计方法与可视化的技术7
算法
名不经传的养虾人11 小时前
从0到1:企业级AI项目迭代日记 Vol.35|追问比演示重要——技术团队问出的五个工程缺口
人工智能·算法·机器学习·ai编程·ai工作流·企业ai
上海云盾第一敬业销售11 小时前
深入了解WAF防护机制的架构解析与实战经验
安全·web安全·架构·ddos
zavoryn11 小时前
大模型入门:面试必会 RoPE,从位置编码到旋转位置嵌入
算法·面试