面试官:分布式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(9001、9002、9003...)
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 | 简单、趋势递增 | 依赖时钟、需配置workerId | 400万/秒 |
| Leaf-Segment | 高性能、无时钟依赖 | 不严格递增、数据库单点 | 1000万/秒 |
| Leaf-Snowflake | 自动分配workerId | 依赖Zookeeper | 400万/秒 |
| 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:极致性能 🚀
祝你面试顺利!💪✨