❄️ 分布式ID生成方案:从雪花算法到美团Leaf、百度UidGenerator

114 阅读12分钟

面试官:分布式ID怎么生成?
候选人:用雪花算法!
面试官:时钟回拨怎么处理?美团Leaf了解吗?
候选人:😰💦(时钟回拨是什么...)

别慌!今天我们深入剖析分布式ID的各种方案,从原理到实战!


🎬 开篇:为什么需要分布式ID?

单机时代的ID生成

-- MySQL自增ID:简单好用
CREATE TABLE user (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50)
);

INSERT INTO user (name) VALUES ('张三');  -- id = 1
INSERT INTO user (name) VALUES ('李四');  -- id = 2

分布式时代的挑战

        DB1              DB2              DB3
         ↓                ↓                ↓
    id=1,2,3...      id=1,2,3...      id=1,2,3...

问题:ID重复!😱

需求:
1. 全局唯一 ✅
2. 趋势递增(有利于数据库索引)✅
3. 高性能(百万/秒级别)✅
4. 高可用(不能单点故障)✅
5. 时间有序(可选)

❄️ 第一章:雪花算法(Snowflake)- 经典之作

核心原理

64位Long型ID结构:

0 - 00000000 00000000 00000000 00000000 00000000 0 - 00000 - 00000 - 000000000000
↑   ↑                                              ↑        ↑        ↑
│   └─────────────── 41位时间戳 ──────────────────┘        │        │
│                                                           │        │
│                                                    10位机器ID    12位序列号
│                                                      (0-1023)  (0-4095)
│
└─ 符号位(0)

总结:
- 1位符号位(固定为0,保证正数)
- 41位时间戳(毫秒级,可用69年)
- 10位机器ID(支持1024台机器)
  - 5位数据中心ID(0-31)
  - 5位工作机器ID(0-31)
- 12位序列号(每毫秒可生成4096个ID)

理论TPS = 4096 * 1000 = 409.6万/秒

🎭 生活比喻:身份证号码

身份证号码:

110101 1990 01 01 001X
  ↑     ↑    ↑  ↑   ↑
地区码  年  月  日  序号

雪花算法ID:

[时间戳] [数据中心] [机器] [序列号]
  ↑         ↑        ↑      ↑
 年月日    地区码    具体地址  编号

作用:
1. 通过ID就能知道大概的生成时间
2. 通过ID就能知道是哪台机器生成的
3. 全球唯一

💻 Java实现

/**
 * 雪花算法实现
 */
public class SnowflakeIdGenerator {
    
    /** 起始时间戳(2020-01-01 00:00:00) */
    private final long twepoch = 1577808000000L;
    
    /** 机器ID所占的位数 */
    private final long workerIdBits = 5L;
    
    /** 数据中心ID所占的位数 */
    private final long datacenterIdBits = 5L;
    
    /** 序列号所占的位数 */
    private final long sequenceBits = 12L;
    
    /** 机器ID最大值 31 */
    private final long maxWorkerId = ~(-1L << workerIdBits);
    
    /** 数据中心ID最大值 31 */
    private final long maxDatacenterId = ~(-1L << datacenterIdBits);
    
    /** 序列号最大值 4095 */
    private final long sequenceMask = ~(-1L << sequenceBits);
    
    /** 机器ID左移12位 */
    private final long workerIdShift = sequenceBits;
    
    /** 数据中心ID左移17位 */
    private final long datacenterIdShift = sequenceBits + workerIdBits;
    
    /** 时间戳左移22位 */
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    /** 工作机器ID(0-31) */
    private long workerId;
    
    /** 数据中心ID(0-31) */
    private long datacenterId;
    
    /** 毫秒内序列(0-4095) */
    private long sequence = 0L;
    
    /** 上次生成ID的时间戳 */
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(
                String.format("worker Id不能大于%d或小于0", maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(
                String.format("datacenter Id不能大于%d或小于0", maxDatacenterId));
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }
    
    /**
     * 生成下一个ID(线程安全)
     */
    public synchronized long nextId() {
        long timestamp = timeGen();
        
        // 🔥 处理时钟回拨
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(
                String.format("时钟回拨。拒绝生成ID,回拨了%d毫秒", 
                    lastTimestamp - timestamp));
        }
        
        // 同一毫秒内
        if (lastTimestamp == timestamp) {
            // 序列号递增
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                // 序列号溢出,等待下一毫秒
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 不同毫秒,序列号重置为0
            sequence = 0L;
        }
        
        // 更新上次生成ID的时间戳
        lastTimestamp = timestamp;
        
        // 移位并组合成64位ID
        return ((timestamp - twepoch) << timestampLeftShift)
                | (datacenterId << datacenterIdShift)
                | (workerId << workerIdShift)
                | sequence;
    }
    
    /**
     * 阻塞到下一毫秒
     */
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    
    /**
     * 获取当前时间戳
     */
    private long timeGen() {
        return System.currentTimeMillis();
    }
    
    /**
     * 解析ID(反向工程)
     */
    public static SnowflakeInfo parse(long id) {
        long timestamp = (id >> 22) + 1577808000000L;
        long datacenterId = (id >> 17) & 0x1F;
        long workerId = (id >> 12) & 0x1F;
        long sequence = id & 0xFFF;
        
        return new SnowflakeInfo(timestamp, datacenterId, workerId, sequence);
    }
}

// ID信息
@Data
@AllArgsConstructor
public class SnowflakeInfo {
    private long timestamp;      // 时间戳
    private long datacenterId;   // 数据中心ID
    private long workerId;       // 机器ID
    private long sequence;       // 序列号
}

// 使用示例
public class IdGeneratorDemo {
    public static void main(String[] args) {
        SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1, 1);
        
        // 生成10个ID
        for (int i = 0; i < 10; i++) {
            long id = generator.nextId();
            System.out.println(id);
            
            // 解析ID
            SnowflakeInfo info = SnowflakeIdGenerator.parse(id);
            System.out.println("  时间:" + new Date(info.getTimestamp()));
            System.out.println("  数据中心:" + info.getDatacenterId());
            System.out.println("  机器:" + info.getWorkerId());
            System.out.println("  序列号:" + info.getSequence());
        }
    }
}

🔥 核心问题:时钟回拨

什么是时钟回拨?

时间线:
T0: 系统时间 2024-01-01 10:00:00.000
T1: 系统时间 2024-01-01 10:00:00.001  生成ID(时间戳=T1)
T2: NTP服务器同步,系统时间被调整为 2024-01-01 09:59:59.999
T3: 再次生成ID,时间戳=T2 < T1  可能导致ID重复!😱

解决方案对比

方案1:抛出异常(Snowflake原始方案)

if (timestamp < lastTimestamp) {
    throw new RuntimeException("时钟回拨,拒绝生成ID");
}

优点:简单
缺点:服务不可用

方案2:等待时钟追上(简单改进)

if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;
    if (offset <= 5) { // 回拨5ms以内,等待
        Thread.sleep(offset << 1); // 等待2倍时间
        timestamp = System.currentTimeMillis();
    } else {
        throw new RuntimeException("时钟回拨超过5ms,拒绝生成ID");
    }
}

优点:解决小幅回拨
缺点:大幅回拨仍然失败

方案3:使用扩展位(美团Leaf方案)

// 预留2位作为时钟回拨标识
// 正常:00
// 回拨1次:01
// 回拨2次:10
// 回拨3次:11

优点:可以容忍3次回拨
缺点:ID空间变小

方案4:使用本地时钟文件(最佳)

// 将最后的时间戳写入文件
// 启动时读取文件,用文件中的时间戳而不是系统时间

private long getLastTimestamp() {
    try {
        String content = Files.readString(Path.of("last_timestamp.txt"));
        return Long.parseLong(content);
    } catch (Exception e) {
        return System.currentTimeMillis();
    }
}

private void saveTimestamp(long timestamp) {
    try {
        Files.writeString(Path.of("last_timestamp.txt"), 
            String.valueOf(timestamp));
    } catch (Exception e) {
        log.error("保存时间戳失败", e);
    }
}

优点:彻底解决时钟回拨
缺点:需要磁盘IO

🍃 第二章:美团Leaf - 双模式ID生成

Leaf-Segment(号段模式)

原理

数据库表:
CREATE TABLE id_segment (
    biz_tag VARCHAR(50) NOT NULL,  -- 业务标识
    max_id BIGINT NOT NULL,        -- 当前最大ID
    step INT NOT NULL,             -- 步长
    PRIMARY KEY (biz_tag)
);

工作流程:
1. 应用启动时,从数据库获取一个号段
   UPDATE id_segment 
   SET max_id = max_id + step 
   WHERE biz_tag = 'order' 
   RETURNING max_id;
   
   假设返回:max_id=10000, step=1000
   → 应用可用号段:[9001, 10000]

2. 应用在内存中分配ID(900190029003...)

3. 号段用完前(比如还剩10%),异步获取下一个号段
   → 下一号段:[10001, 11000]

4. 双buffer机制:
   Buffer1: [9001, 10000]  ← 正在使用
   Buffer2: [10001, 11000] ← 预加载好了
   
5. Buffer1用完,切换到Buffer2,同时异步加载Buffer1

🎭 生活比喻:银行取号机

传统方式(雪花算法):
- 每次都现场计算号码
- 需要联网、需要时钟

号段模式:
- 取号机预先从总部领取一叠号码(1-100)
- 现场直接发号,不需要联网
- 发到第90号时,后台自动去领下一叠(101-200)
- 无缝切换,用户无感知

优点:
1. 高性能(内存分配,不需要网络调用)
2. 数据库压力小(批量获取)
3. 高可用(号段内不依赖数据库)

💻 代码实现

@Service
public class LeafSegmentIdGenerator {
    
    @Autowired
    private IdSegmentMapper segmentMapper;
    
    // 双buffer
    private final Map<String, SegmentBuffer> bufferMap = new ConcurrentHashMap<>();
    
    /**
     * 生成ID
     */
    public long nextId(String bizTag) {
        SegmentBuffer buffer = bufferMap.computeIfAbsent(
            bizTag, 
            k -> new SegmentBuffer(bizTag)
        );
        
        return buffer.nextId();
    }
    
    /**
     * 号段Buffer(双buffer)
     */
    private class SegmentBuffer {
        private final String bizTag;
        private Segment current;  // 当前使用的号段
        private Segment next;     // 预加载的号段
        private volatile boolean isLoadingNext = false;
        
        public SegmentBuffer(String bizTag) {
            this.bizTag = bizTag;
            this.current = loadSegment(); // 加载第一个号段
        }
        
        public synchronized long nextId() {
            // 检查是否需要预加载下一个号段
            if (current.getRemainPercent() < 0.1 && !isLoadingNext && next == null) {
                // 剩余10%时,异步加载下一个号段
                CompletableFuture.runAsync(() -> {
                    isLoadingNext = true;
                    next = loadSegment();
                    isLoadingNext = false;
                });
            }
            
            // 当前号段用完,切换到下一个
            if (current.isExhausted()) {
                if (next == null) {
                    // 下一个号段还没加载好,同步加载
                    next = loadSegment();
                }
                current = next;
                next = null;
            }
            
            return current.nextId();
        }
        
        private Segment loadSegment() {
            // 从数据库获取号段
            IdSegmentDO segment = segmentMapper.updateAndGet(bizTag);
            long maxId = segment.getMaxId();
            int step = segment.getStep();
            
            return new Segment(maxId - step + 1, maxId);
        }
    }
    
    /**
     * 单个号段
     */
    @Data
    private static class Segment {
        private final long start;        // 起始ID
        private final long end;          // 结束ID
        private final AtomicLong current; // 当前ID
        
        public Segment(long start, long end) {
            this.start = start;
            this.end = end;
            this.current = new AtomicLong(start);
        }
        
        public long nextId() {
            long id = current.getAndIncrement();
            if (id > end) {
                throw new IllegalStateException("号段已用完");
            }
            return id;
        }
        
        public boolean isExhausted() {
            return current.get() > end;
        }
        
        public double getRemainPercent() {
            long total = end - start + 1;
            long used = current.get() - start;
            return (total - used) * 1.0 / total;
        }
    }
}

// Mapper
@Mapper
public interface IdSegmentMapper {
    
    @Update("UPDATE id_segment " +
            "SET max_id = max_id + step " +
            "WHERE biz_tag = #{bizTag}")
    @Select("SELECT * FROM id_segment WHERE biz_tag = #{bizTag}")
    IdSegmentDO updateAndGet(@Param("bizTag") String bizTag);
}

Leaf-Snowflake(改进版雪花算法)

改进点

1. 自动生成workerId(不需要手动配置)
   - 启动时向Zookeeper注册一个临时节点
   - Zookeeper自动分配一个序号作为workerId
   - 服务重启后重新分配(临时节点特性)

2. 解决时钟回拨
   - 记录最后的时间戳到文件
   - 启动时检查系统时间是否回拨
   - 回拨则等待追上

3. 弱依赖Zookeeper
   - Zookeeper只用于分配workerId
   - 运行时不依赖Zookeeper

💻 代码实现

@Service
public class LeafSnowflakeIdGenerator {
    
    @Autowired
    private CuratorFramework zkClient;
    
    private SnowflakeIdGenerator snowflake;
    
    @PostConstruct
    public void init() throws Exception {
        // 1. 从Zookeeper获取workerId
        long workerId = getWorkerIdFromZk();
        
        // 2. 初始化雪花算法
        snowflake = new SnowflakeIdGenerator(workerId, 1);
        
        // 3. 检查时钟回拨
        checkClockBackward();
    }
    
    /**
     * 从Zookeeper获取workerId
     */
    private long getWorkerIdFromZk() throws Exception {
        String zkPath = "/snowflake/workers";
        
        // 确保父节点存在
        if (zkClient.checkExists().forPath(zkPath) == null) {
            zkClient.create()
                .creatingParentsIfNeeded()
                .forPath(zkPath);
        }
        
        // 创建临时顺序节点
        String localIp = InetAddress.getLocalHost().getHostAddress();
        String nodePath = zkClient.create()
            .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
            .forPath(zkPath + "/worker_", localIp.getBytes());
        
        // 从节点路径中提取序号作为workerId
        String nodeNumber = nodePath.substring(nodePath.lastIndexOf('_') + 1);
        long workerId = Long.parseLong(nodeNumber);
        
        log.info("从Zookeeper获取workerId: {}, 节点路径: {}", workerId, nodePath);
        
        return workerId % 1024; // 确保workerId在0-1023范围内
    }
    
    /**
     * 检查时钟回拨
     */
    private void checkClockBackward() throws Exception {
        long lastTimestamp = getLastTimestampFromFile();
        long currentTimestamp = System.currentTimeMillis();
        
        if (currentTimestamp < lastTimestamp) {
            long offset = lastTimestamp - currentTimestamp;
            log.warn("检测到时钟回拨:{}ms", offset);
            
            if (offset <= 5000) { // 回拨5秒以内,等待
                log.info("等待时钟追上...");
                Thread.sleep(offset);
            } else {
                throw new RuntimeException(
                    "时钟回拨超过5秒,拒绝启动!请检查系统时间。");
            }
        }
    }
    
    private long getLastTimestampFromFile() {
        try {
            Path file = Paths.get("last_timestamp.txt");
            if (Files.exists(file)) {
                String content = Files.readString(file);
                return Long.parseLong(content);
            }
        } catch (Exception e) {
            log.error("读取时间戳文件失败", e);
        }
        return 0;
    }
    
    public long nextId() {
        long id = snowflake.nextId();
        
        // 异步保存时间戳
        CompletableFuture.runAsync(() -> saveTimestamp(System.currentTimeMillis()));
        
        return id;
    }
    
    private void saveTimestamp(long timestamp) {
        try {
            Files.writeString(
                Paths.get("last_timestamp.txt"),
                String.valueOf(timestamp)
            );
        } catch (Exception e) {
            log.error("保存时间戳失败", e);
        }
    }
}

🌟 第三章:百度UidGenerator - 性能之王

核心改进

1. 时间单位从毫秒改为秒
   - 原因:降低时间戳位数,增加序列号位数
   - 好处:同一秒内可生成更多ID

2. 序列号从12位扩展到13位
   - 原因:提升并发能力
   - 好处:每秒可生成8192个ID(原来4096)

3. 使用RingBuffer缓存ID
   - 原因:提前生成ID,减少生成时的竞争
   - 好处:性能极高,可达600万+/秒

结构(64位):
[1位符号][28位秒级时间戳][22位workerId][13位序列号]

🎭 生活比喻:流水线生产

传统雪花算法(现场制作):
- 客户来了,现场做蛋糕
- 一个一个做,效率低

UidGenerator(流水线):
- 提前做好一批蛋糕放在货架上
- 客户来了直接拿走
- 后台持续生产,保证货架始终有货

RingBuffer:
┌─────┬─────┬─────┬─────┬─────┐
│ ID1 │ ID2 │ ID3 │ ID4 │ ... │  ← 环形队列
└─────┴─────┴─────┴─────┴─────┘
   ↑                         ↑
  读指针                   写指针

优点:
- 极高性能(无锁化)
- 平滑波峰

⚖️ 优缺点对比

方案优点缺点TPS
Snowflake简单、趋势递增依赖时钟、需配置workerId400万/秒
Leaf-Segment高性能、无时钟依赖不严格递增、数据库单点1000万/秒
Leaf-Snowflake自动分配workerId依赖Zookeeper400万/秒
UidGenerator超高性能复杂、依赖时钟600万+/秒

🎯 第四章:生产环境选型建议

场景1:订单ID(需要趋势递增)

// 推荐:Snowflake 或 Leaf-Snowflake
// 理由:
// 1. 趋势递增,有利于数据库索引
// 2. 可以从ID反推生成时间
// 3. 性能足够(400万/秒)

@Service
public class OrderService {
    @Autowired
    private SnowflakeIdGenerator idGenerator;
    
    public Order createOrder() {
        long orderId = idGenerator.nextId();
        // ...
    }
}

场景2:消息ID(超高并发)

// 推荐:UidGenerator
// 理由:
// 1. 性能最高(600万+/秒)
// 2. 消息ID不需要严格递增

@Service
public class MessageService {
    @Autowired
    private UidGenerator uidGenerator;
    
    public void sendMessage(String content) {
        long messageId = uidGenerator.getUID();
        // ...
    }
}

场景3:短链接生成(需要短)

// 推荐:号段模式 + Base62编码
// 理由:
// 1. 号段模式生成的ID更短
// 2. Base62编码后更短

public class ShortUrlService {
    @Autowired
    private LeafSegmentIdGenerator idGenerator;
    
    private static final String BASE62 = 
        "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    
    public String generateShortCode(String longUrl) {
        long id = idGenerator.nextId("short_url");
        return toBase62(id);
    }
    
    private String toBase62(long num) {
        StringBuilder sb = new StringBuilder();
        while (num > 0) {
            sb.append(BASE62.charAt((int) (num % 62)));
            num /= 62;
        }
        return sb.reverse().toString();
    }
}

🎓 第五章:面试高分回答

问题:分布式ID生成有哪些方案?

标准回答(STAR法则):

"我们项目中用过雪花算法和美团Leaf两种方案。

雪花算法

  • 64位Long型ID,包含时间戳、机器ID、序列号
  • 优点:趋势递增,可从ID反推时间
  • 缺点:依赖时钟,有时钟回拨风险
  • 性能:单机400万/秒

美团Leaf号段模式

  • 从数据库批量获取号段,在内存中分配
  • 双buffer机制,无缝切换
  • 优点:性能极高(1000万/秒),不依赖时钟
  • 缺点:不是严格递增,依赖数据库

选型

  • 订单ID用雪花算法(需要趋势递增)
  • 消息ID用号段模式(追求性能)"

常见追问

Q:时钟回拨怎么处理?

A:
1. 小幅回拨(5ms内):等待追上
2. 大幅回拨:抛异常,拒绝生成
3. 最佳方案:记录最后时间戳到文件,用文件时间而非系统时间
4. 美团Leaf方案:使用号段模式,不依赖时钟

🎁 总结

  • Snowflake:经典、简单、够用 ❄️
  • Leaf:性能强、双模式 🍃
  • UidGenerator:极致性能 🚀

祝你面试顺利!💪✨