分布式ID方案:雪花算法、Leaf、美团ID生成器的完整对比与实现
分布式ID方案:雪花算法、Leaf、美团ID生成器的完整对比与实现
适读人群:中高级Java工程师 | 阅读时长:约18分钟 | 技术栈:Spring Boot 3.x、MySQL、Zookeeper
开篇故事
2021年我们做分库分表改造,踩了一个让人哭笑不得的坑:历史数据用的是数据库自增 ID,分库分表后用了雪花算法生成新 ID。结果上线后发现,同一张业务表里,历史数据的 ID 是几千万量级,新数据的 ID 是 18 位数字,前端同学用 JavaScript 解析 JSON 时,18 位的雪花 ID 精度丢失了——JavaScript 的 Number 类型只有 53 位精度,无法精确表示 64 位整数,最后几位数字全变成了 0。
一批订单 ID 从 1234567890123456789 变成了 1234567890123456800,用户拿这个 ID 来查订单,后端根本查不到,直接报 404。那一周客服接了将近 800 个工单。
这个教训让我深刻理解了一件事:选分布式 ID 方案,不光要考虑生成速度和唯一性,还要考虑 ID 的位数、类型、排序性、隐私性等等一堆业务因素。今天把这些方案彻底说清楚。
一、核心问题分析
为什么不能用数据库自增 ID
单机数据库自增 ID 在分布式场景下有三个致命问题:
并发瓶颈:所有业务都依赖一个数据库实例生成 ID,这个实例成为单点瓶颈。我们曾经测过,一个 MySQL 实例的自增 ID 生成 QPS 上限大约在 2-3 万,超过这个量就开始排队。
分库分表冲突:多个数据库实例各自自增,同一张逻辑表里会出现重复 ID。
信息泄露:自增 ID 暴露了业务量。竞争对手只需要在两个时间点各下一单,对比订单 ID 就能估算你的日单量。这在竞争激烈的行业是非常严重的问题。
分布式 ID 的核心需求
一个好的分布式 ID 方案需要满足:全局唯一(最基本要求)、单调递增或趋势递增(便于数据库索引,插入时减少页分裂)、高性能(每秒百万级生成能力)、高可用(不能有单点故障)、信息安全(不暴露敏感业务信息)。
二、原理深度解析
雪花算法(Snowflake)
Twitter 开源的雪花算法,生成 64 位长整型 ID,结构如下:
+---+-------------------+----------+------------+
| 1 | 41位时间戳(ms) | 10位机器ID | 12位序列号 |
+---+-------------------+----------+------------+- 1位符号位:固定为 0,保证 ID 为正数。
- 41位时间戳:毫秒级时间戳,可以用约 69 年(2^41 / (365×24×3600×1000))。
- 10位机器ID:通常拆分为 5位数据中心 + 5位机器编号,支持 1024 个节点。
- 12位序列号:同一毫秒内的序列号,支持同一毫秒生成 4096 个 ID。
理论上限:1024 个节点,每秒 4096000 个 ID(每个节点每毫秒 4096 个)。
核心问题:机器 ID 如何分配?在容器化部署下,节点 IP 会动态变化,每次重启 IP 不同,用 IP 来推算机器 ID 不可靠。通常需要一个注册中心(ZK 或 DB)来分配机器 ID。
Leaf(美团 Leaf)
美团开源的 Leaf 提供了两种模式:
Leaf-Segment(号段模式):每次从数据库取一段 ID(比如 [1000, 2000)),在内存里自增使用。用完了再取下一段。双缓冲优化:当当前号段剩余 10% 时,异步预加载下一个号段,避免号段用完后等待数据库的延迟。
Leaf-Snowflake:基于雪花算法,用 ZK 来解决机器 ID 分配的问题。ZK 的持久顺序节点保证了每个实例获得唯一的机器 ID,实例重启时复用同一节点,避免了机器 ID 浪费。
Leaf-Segment 的核心表设计:
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务标识',
max_id BIGINT NOT NULL DEFAULT 1 COMMENT '当前已分配到的最大ID',
step INT NOT NULL COMMENT '每次分配的号段步长',
description VARCHAR(256) DEFAULT NULL,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (biz_tag)
) ENGINE=InnoDB;对比总结
| 方案 | 性能 | 依赖 | 时序性 | 位数 | 适用场景 |
|---|---|---|---|---|---|
| 雪花算法 | 极高(本地生成) | ZK(分配机器ID) | 趋势递增 | 64位 | 高并发,对延迟敏感 |
| Leaf-Segment | 高(内存自增) | MySQL + Leaf服务 | 严格递增 | 可控 | 需要数字ID,量不极端 |
| Leaf-Snowflake | 极高 | ZK + Leaf服务 | 趋势递增 | 64位 | 高并发,需要中心化管理 |
| UUID | 极高(本地) | 无 | 无序 | 128位 | 不用于主键,只用于标识 |
| 数据库自增 | 低 | MySQL | 严格递增 | 可控 | 单机、低并发场景 |
三、完整代码实现
雪花算法核心实现
@Component
@Slf4j
public class SnowflakeIdGenerator {
// 开始时间戳(2020-01-01 00:00:00 UTC)
private static final long START_EPOCH = 1577836800000L;
// 各部分位数
private static final long WORKER_ID_BITS = 5L;
private static final long DATACENTER_ID_BITS = 5L;
private static final long SEQUENCE_BITS = 12L;
// 最大值
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 31
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_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 DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 17
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; // 22
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(@Value("${snowflake.worker-id:0}") long workerId,
@Value("${snowflake.datacenter-id:0}") long datacenterId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException(
"workerId 超出范围 [0, " + MAX_WORKER_ID + "]");
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException(
"datacenterId 超出范围 [0, " + MAX_DATACENTER_ID + "]");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
log.info("雪花ID生成器初始化,workerId={}, datacenterId={}", workerId, datacenterId);
}
/**
* 生成下一个 ID(线程安全)
*/
public synchronized long nextId() {
long currentTimestamp = currentTimeMillis();
// 时钟回拨检测
if (currentTimestamp < lastTimestamp) {
long offset = lastTimestamp - currentTimestamp;
if (offset <= 5) {
// 小于5ms的回拨,等待
try {
Thread.sleep(offset << 1);
currentTimestamp = currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
throw new ClockBackwardsException(
"时钟回拨超过容忍范围,offset=" + offset + "ms");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("时钟等待被中断");
}
} else {
throw new ClockBackwardsException(
"时钟回拨超过容忍范围,offset=" + offset + "ms");
}
}
if (currentTimestamp == lastTimestamp) {
// 同一毫秒内,序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 序列号溢出,等待下一毫秒
currentTimestamp = waitNextMillis(lastTimestamp);
}
} else {
// 不同毫秒,序列号重置(随机1或0,避免单机低并发时序列号固定为0泄露信息)
sequence = ThreadLocalRandom.current().nextLong(2);
}
lastTimestamp = currentTimestamp;
return ((currentTimestamp - START_EPOCH) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = currentTimeMillis();
}
return timestamp;
}
private long currentTimeMillis() {
return System.currentTimeMillis();
}
/**
* 解析 ID 中的时间戳(用于排查问题)
*/
public long parseTimestamp(long id) {
return (id >> TIMESTAMP_SHIFT) + START_EPOCH;
}
}ZK 自动分配机器 ID
@Component
@Slf4j
public class ZkWorkerIdAssigner {
@Autowired
private CuratorFramework curatorClient;
private static final String WORKER_REGISTRY_PATH = "/snowflake/workers";
private static final int MAX_WORKER_ID = 1023; // 10位,共1024个
@Value("${spring.application.name:default}")
private String appName;
/**
* 从 ZK 获取或复用 workerId
*/
public long assignWorkerId() throws Exception {
String localIp = getLocalIp();
String appKey = appName + ":" + localIp;
String nodeBasePath = WORKER_REGISTRY_PATH + "/" + appKey;
// 检查是否有历史节点
if (curatorClient.checkExists().forPath(nodeBasePath) != null) {
byte[] data = curatorClient.getData().forPath(nodeBasePath);
long workerId = Long.parseLong(new String(data));
log.info("复用历史 workerId={}, appKey={}", workerId, appKey);
return workerId;
}
// 创建新节点,分配新 workerId
// 通过列出所有节点找到未被使用的最小 ID
long workerId = allocateNewWorkerId();
curatorClient.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.PERSISTENT)
.forPath(nodeBasePath, String.valueOf(workerId).getBytes());
log.info("分配新 workerId={}, appKey={}", workerId, appKey);
return workerId;
}
private long allocateNewWorkerId() throws Exception {
List<String> existingNodes = curatorClient.getChildren().forPath(WORKER_REGISTRY_PATH);
Set<Long> usedIds = new HashSet<>();
for (String node : existingNodes) {
String path = WORKER_REGISTRY_PATH + "/" + node;
byte[] data = curatorClient.getData().forPath(path);
usedIds.add(Long.parseLong(new String(data)));
}
for (long id = 0; id <= MAX_WORKER_ID; id++) {
if (!usedIds.contains(id)) {
return id;
}
}
throw new RuntimeException("workerId 已全部分配,无可用 ID");
}
private String getLocalIp() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
return "unknown";
}
}
}Leaf-Segment 模式实现
@Service
@Slf4j
public class LeafSegmentIdGenerator {
@Autowired
private LeafAllocMapper leafAllocMapper;
// 双缓冲:当前号段 + 下一个号段
private final Map<String, SegmentBuffer> segmentBufferMap = new ConcurrentHashMap<>();
/**
* 获取下一个 ID
*/
public long nextId(String bizTag) {
SegmentBuffer buffer = segmentBufferMap.computeIfAbsent(bizTag, tag -> {
SegmentBuffer newBuffer = new SegmentBuffer(tag);
// 初始化时立即加载第一个号段
loadNextSegment(newBuffer);
newBuffer.switchToNextSegment();
return newBuffer;
});
return getIdFromBuffer(buffer);
}
private synchronized long getIdFromBuffer(SegmentBuffer buffer) {
// 检查当前号段是否需要预加载下一号段
Segment current = buffer.getCurrent();
if (!buffer.isNextSegmentReady()
&& current.getIdle() < current.getStep() * 0.1
&& buffer.isThreadRunning().compareAndSet(false, true)) {
// 异步加载下一号段
CompletableFuture.runAsync(() -> {
try {
loadNextSegment(buffer);
} finally {
buffer.isThreadRunning().set(false);
}
});
}
long value = current.getValue().getAndIncrement();
if (value < current.getMax()) {
return value;
}
// 当前号段已用完
if (buffer.isNextSegmentReady()) {
buffer.switchToNextSegment();
return buffer.getCurrent().getValue().getAndIncrement();
}
// 都用完了,同步等待(这种情况很少发生)
log.warn("号段都用完了,需要同步等待,bizTag={}", buffer.getBizTag());
waitAndLoadNextSegment(buffer);
buffer.switchToNextSegment();
return buffer.getCurrent().getValue().getAndIncrement();
}
private void loadNextSegment(SegmentBuffer buffer) {
LeafAlloc alloc = leafAllocMapper.updateMaxIdAndGetLeafAlloc(buffer.getBizTag());
if (alloc == null) {
throw new IllegalArgumentException("bizTag 不存在: " + buffer.getBizTag());
}
long max = alloc.getMaxId();
int step = alloc.getStep();
Segment next = new Segment(max - step, max, step);
buffer.setNext(next);
}
private void waitAndLoadNextSegment(SegmentBuffer buffer) {
int waitCount = 0;
while (!buffer.isNextSegmentReady() && waitCount < 50) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
waitCount++;
}
if (!buffer.isNextSegmentReady()) {
loadNextSegment(buffer);
}
}
}Mapper 层(关键原子操作)
@Mapper
public interface LeafAllocMapper {
/**
* 原子更新 max_id 并返回新的 LeafAlloc
* 这里用的是 UPDATE ... SELECT 模式保证原子性
*/
@Update("UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = #{bizTag}")
int updateMaxId(@Param("bizTag") String bizTag);
@Select("SELECT biz_tag, max_id, step FROM leaf_alloc WHERE biz_tag = #{bizTag}")
LeafAlloc selectByBizTag(@Param("bizTag") String bizTag);
@Transactional
default LeafAlloc updateMaxIdAndGetLeafAlloc(String bizTag) {
updateMaxId(bizTag);
return selectByBizTag(bizTag);
}
}ID 生成统一门面
@Service
@Slf4j
public class IdGeneratorFacade {
@Autowired
private SnowflakeIdGenerator snowflakeIdGenerator;
@Autowired
private LeafSegmentIdGenerator leafSegmentIdGenerator;
@Value("${id.generator.mode:snowflake}")
private String mode; // snowflake | leaf-segment
/**
* 生成订单 ID
*/
public long generateOrderId() {
return generateId("order");
}
/**
* 生成用户 ID
*/
public long generateUserId() {
return generateId("user");
}
private long generateId(String bizTag) {
if ("leaf-segment".equals(mode)) {
return leafSegmentIdGenerator.nextId(bizTag);
}
// 默认雪花算法
return snowflakeIdGenerator.nextId();
}
/**
* 前端安全的 ID(转为字符串,避免 JS 精度丢失)
*/
public String generateOrderIdAsString() {
return String.valueOf(generateOrderId());
}
}四、生产调优与配置
雪花算法时钟回拨问题
时钟回拨是雪花算法最头疼的问题,根因是 NTP 时间同步。生产上有几个处理策略:
策略一:小范围回拨(< 5ms)直接等待。上面代码已经实现了。
策略二:大范围回拨抛出异常,报警人工介入。
策略三:使用单调时钟代替系统时钟。Linux 的 CLOCK_MONOTONIC 不会回拨,但不是绝对时间,重启后从 0 开始,需要结合启动时间戳换算。
Leaf-Segment 步长调优
步长(step)的设置直接影响性能和号段浪费:
步长太小(比如 100):数据库更新频繁,当 QPS 很高时,每隔 100 个 ID 就要访问一次 DB,高峰期容易成为瓶颈。
步长太大(比如 100000):服务重启后会浪费大量 ID(号段用了一半就丢弃),ID 不再连续,且泄露了服务重启的信息。
美团 Leaf 的建议:步长设为服务 QPS 的 600 倍(10分钟的量),让数据库每10分钟更新一次就够了,同时双缓冲保证不影响性能。
五、踩坑实录
坑一:JS 精度丢失(开篇故事的续集)
就是开头说的那个事故。修复方案是:所有接口返回 ID 时统一转字符串,前端也统一用字符串处理 ID,不做数值运算。后端接口参数接收时同时支持 Long 和 String 类型。
这个改动涉及面很广,花了将近一周全量回归测试。所以新项目从一开始就要确定:64位 ID 在 JSON 中必须用字符串传输。
坑二:雪花算法机器 ID 重复
容器化之前,我们用机器 IP 的最后两段来推算 workerId(比如 192.168.1.x,取 x%1024 作为 workerId)。容器化之后,IP 是随机分配的,不同容器可能分配到相同的 workerId,生成了重复 ID,导致数据库唯一键冲突。
当时的告警是:订单插入失败率从 0 突然飙到 15%,错误全是 Duplicate entry。
修复方案是引入 ZK 来分配机器 ID,确保每个节点唯一。后来迁移到 K8s 后,用 Pod 序号(StatefulSet 的稳定序号)来做 workerId,更加可靠。
坑三:Leaf 号段数据库成为单点
我们最开始把 Leaf 的 leaf_alloc 表放在主业务数据库里,有一次数据库主从切换(计划内维护),Leaf 服务在切换期间无法获取新号段,内存里还有存量的号段可以用,撑了大约 3 分钟后号段用完,ID 生成开始失败,引发级联错误。
修复方案:Leaf 的 DB 独立部署,与业务 DB 隔离,避免业务 DB 变更影响 ID 服务。同时增加了号段告警:当内存号段剩余量低于阈值时立刻报警,而不是等到真正用完。
六、总结
分布式 ID 选型的决策树:
- 如果系统是全新系统、追求极致性能、能接受趋势递增(非严格递增):选雪花算法,注意解决机器 ID 分配问题。
- 如果需要严格递增的纯数字 ID、有中等并发量(万级 QPS):选 Leaf-Segment,注意 DB 高可用和步长调优。
- 如果已有 Leaf 基础设施、需要中心化管理:选 Leaf-Snowflake。
- 任何方案,前后端交互时 64 位 ID 一律用字符串,这是铁律。
