【架构实战】分布式ID生成:从自增ID到雪花算法
【架构实战】分布式ID生成:从自增ID到雪花算法
一、背景:分库分表后,自增ID的噩梦
2019年我们把订单表做了分库分表,按用户ID哈希分了16个库。
上线第二天,运营反馈:后台看到的订单号会跳变。查了一下午才搞清楚:MySQL自增ID在每个库里独立计数,导致不同分库里的订单ID重复了。
分库前(单库): orders 表: AUTO_INCREMENT → 1001, 1002, 1003... 全局唯一 分库后(16个库): db_00.orders: AUTO_INCREMENT → 1001, 1002, 1003... db_01.orders: AUTO_INCREMENT → 1001, 1002, 1003... ← 与db_00重复了!那天我们手动跑脚本给所有订单号加了个库前缀才紧急修复。但这只是临时方案,根本问题没解决。
分库分表之后,数据库自增ID彻底失效,必须要有全局唯一的ID生成方案。
二、分布式ID的核心要求
分布式ID必须满足: 1. 全局唯一:最基本要求,任何两个ID不能重复 2. 趋势递增:索引友好,减少B+树页分裂 3. 高性能:生成速度不能成为系统瓶颈 4. 高可用:ID生成服务不能有单点故障 5. 信息安全:不能暴露业务量(如今天第几个订单)三、方案演进:从青铜到王者
3.1 青铜:UUID
Stringid=UUID.randomUUID().toString();// 例如:550e8400-e29b-41d4-a716-446655440000优点:本地生成,无网络开销,实现超简单。
缺点(致命):
- 字符串太长(36字节),占用大量存储空间
- 完全无序,作为主键会导致B+树频繁页分裂,写入性能下降50%以上
- 不包含时间信息,不便于排查问题
UUID作为主键插入B+树: 每次插入都是随机位置 → 页分裂频繁 → 磁盘碎片 → 性能灾难 趋势递增ID作为主键插入B+树: 每次插入到最后 → 极少页分裂 → 顺序写入 → 性能优秀结论:可以用作业务编号但绝不适合做数据库主键。
3.2 白银:数据库号段模式
原理:用一个DB表集中分配ID号段,服务每次批量获取一段ID,用完后再次申请。
-- ID号段表CREATETABLEid_generator(biz_typeVARCHAR(64)PRIMARYKEYCOMMENT'业务类型',max_idBIGINTNOTNULLCOMMENT'当前已分配的最大ID',stepINTNOTNULLDEFAULT1000COMMENT'每次分配的号段长度',versionINTNOTNULLDEFAULT0COMMENT'乐观锁版本号');-- 获取号段的原子操作UPDATEid_generatorSETmax_id=max_id+step,version=version+1WHEREbiz_type='order'ANDversion=#{oldVersion};-- 如果update影响行数>0 → 分配成功,号段 = [max_id - step + 1, max_id]@ServicepublicclassDbSegmentIdGenerator{privatevolatileSegmentcurrentSegment;privatevolatileSegmentnextSegment;// 双Buffer优化@PostConstructpublicvoidinit(){currentSegment=fetchSegment();nextSegment=fetchSegment();// 预加载下一个号段// 异步预加载线程Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay(this::preloadSegment,0,1,TimeUnit.SECONDS);}publicsynchronizedlongnextId(){if(!currentSegment.hasNext()){// 号段用完,切换到预加载的号段currentSegment=nextSegment;nextSegment=fetchSegment();}returncurrentSegment.next();}privateSegmentfetchSegment(){// 执行 UPDATE id_generator SET max_id = max_id + step// 返回号段 [newMaxId - step + 1, newMaxId]}}优点:ID趋势递增,本地缓存号段性能高,每次DB交互可分配1000个ID。
缺点:依赖DB,DB宕机时号段耗完后无法分配;多业务需要多张表。
3.3 黄金:Redis生成
// 使用Redis的INCRBY原子操作longid=redisTemplate.opsForValue().increment("id:order",1);// 每次+1,返回新ID优点:性能极高,实现简单。
缺点:
- Redis不持久化会丢ID(重启后从0开始)
- Redis持久化(RDB/AOF)有性能损耗
- 主从切换可能产生重复ID
3.4 王者:雪花算法(Snowflake)
原理:一个64位Long整数,各段含义如下:
┌─┬──────────────────────────────────────────────┬──────────┬────────────┐ │0│ 41位时间戳(毫秒) │ 10位机器码│ 12位序列号 │ └─┴──────────────────────────────────────────────┴──────────┴────────────┘ 1位 41位 10位 12位 (保留) 各部分说明: - 第1位:固定0(保证正数) - 41位时间戳:从自定义起始时间到现在的毫秒数,可用69年 - 10位机器码:最多1024个节点(5位机房+5位机器ID) - 12位序列号:同一毫秒内最多4096个ID QPS = 4096个/毫秒 × 1000 = 约400万/秒publicclassSnowflakeIdGenerator{// 起始时间戳(2020-01-01 00:00:00)privatestaticfinallongSTART_TIMESTAMP=1577836800000L;// 各部分位数privatestaticfinallongDATA_CENTER_ID_BITS=5L;// 5位机房IDprivatestaticfinallongWORKER_ID_BITS=5L;// 5位机器IDprivatestaticfinallongSEQUENCE_BITS=12L;// 12位序列号// 最大值privatestaticfinallongMAX_DATA_CENTER_ID=~(-1L<<DATA_CENTER_ID_BITS);// 31privatestaticfinallongMAX_WORKER_ID=~(-1L<<WORKER_ID_BITS);// 31privatestaticfinallongMAX_SEQUENCE=~(-1L<<SEQUENCE_BITS);// 4095// 偏移量privatestaticfinallongWORKER_ID_SHIFT=SEQUENCE_BITS;// 12privatestaticfinallongDATA_CENTER_ID_SHIFT=SEQUENCE_BITS+WORKER_ID_BITS;// 17privatestaticfinallongTIMESTAMP_SHIFT=SEQUENCE_BITS+WORKER_ID_BITS+DATA_CENTER_ID_BITS;// 22privatefinallongdataCenterId;privatefinallongworkerId;privatelongsequence=0L;privatelonglastTimestamp=-1L;publicSnowflakeIdGenerator(longdataCenterId,longworkerId){if(dataCenterId>MAX_DATA_CENTER_ID||dataCenterId<0){thrownewIllegalArgumentException("机房ID超出范围: 0-"+MAX_DATA_CENTER_ID);}if(workerId>MAX_WORKER_ID||workerId<0){thrownewIllegalArgumentException("机器ID超出范围: 0-"+MAX_WORKER_ID);}this.dataCenterId=dataCenterId;this.workerId=workerId;}publicsynchronizedlongnextId(){longcurrentTimestamp=System.currentTimeMillis();// 时钟回拨检测if(currentTimestamp<lastTimestamp){thrownewRuntimeException("时钟回拨: 上次时间="+lastTimestamp+", 当前时间="+currentTimestamp);}if(currentTimestamp==lastTimestamp){// 同一毫秒内,序列号递增sequence=(sequence+1)&MAX_SEQUENCE;if(sequence==0){// 序列号用完,等待下一毫秒currentTimestamp=waitNextMillis(lastTimestamp);}}else{// 不同毫秒,序列号重置sequence=0L;}lastTimestamp=currentTimestamp;// 组装IDreturn((currentTimestamp-START_TIMESTAMP)<<TIMESTAMP_SHIFT)|(dataCenterId<<DATA_CENTER_ID_SHIFT)|(workerId<<WORKER_ID_SHIFT)|sequence;}privatelongwaitNextMillis(longlastTimestamp){longtimestamp=System.currentTimeMillis();while(timestamp<=lastTimestamp){timestamp=System.currentTimeMillis();}returntimestamp;}}3.5 雪花算法的时钟回拨问题
问题:服务器时钟被调回过去,可能产生重复ID。
时间线: 10:00:00.000 → 生成了ID=100 10:00:00.001 → 生成了ID=101 突然NTP同步,时钟回拨到 09:59:59.500 10:00:00.000 → 又生成了ID=100 ← 重复了!解决方案:
// 方案1:短期回拨等待privatesynchronizedlongnextIdWithFallback(){longcurrentTimestamp=System.currentTimeMillis();if(currentTimestamp<lastTimestamp){longoffset=lastTimestamp-currentTimestamp;if(offset<=5){// 回拨5ms以内,等待追平try{Thread.sleep(offset+1);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}currentTimestamp=System.currentTimeMillis();}else{// 回拨超过5ms,使用备用workerIdreturngenerateWithBackupWorkerId(currentTimestamp);}}// ... 正常生成逻辑}// 方案2:记录最后生成的ID,时钟回拨时用历史最大ID+1// 方案3:放弃本节点ID生成,切换到备用ID生成器生产环境推荐:使用美团Leaf或百度UidGenerator,它们已解决时钟回拨问题。
四、方案对比
| 方案 | 唯一性 | 趋势递增 | 性能(QPS) | 高可用 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|---|---|
| UUID | ✅ | ❌ | 极高 | ✅ | 0 | 非DB主键场景 |
| DB号段 | ✅ | ✅ | 中(依赖DB) | ❌ | 低 | 中小规模 |
| Redis | ✅ | ✅ | 高 | 中等 | 低 | 已有Redis |
| 雪花原生 | ✅ | ✅ | 极高(400万/s) | ✅ | 中 | 大型分布式 |
| 美团Leaf | ✅ | ✅ | 极高 | ✅ | 高 | 企业级 |
五、美团Leaf方案简介
美团开源了两种模式:
Leaf-Segment(号段模式): ┌─────────┐ ┌─────────┐ │ Leaf-Svr│ │ Leaf-Svr│ │ (主) │ ←─→ │ (从) │ └────┬────┘ └────┬────┘ │ │ ▼ ▼ ┌─────────┐ ┌─────────┐ │ 服务A │ │ 服务B │ │ (获取 │ │ (获取 │ │ 号段) │ │ 号段) │ └─────────┘ └─────────┘ - 双Buffer预加载:当前号段用完前已预加载下一个 - 高可用:多个Leaf Server组成集群 Leaf-Snowflake(雪花模式): - 通过ZooKeeper自动注册workerId - 内置时钟回拨解决方案六、总结
方案选择决策树:
你的规模? │ ├── 单库单表 → 直接MySQL自增ID │ ├── 分库分表 < 10个节点 → DB号段模式 │ └── 优点:实现简单,趋势递增,索引友好 │ ├── 分库分表 > 10个节点 → 雪花算法 │ └── 优点:高性能,高可用,本地生成 │ └── 企业级 > 100个节点 → 美团Leaf └── 优点:成熟方案,高可用,时钟回拨已解决三条关键经验:
- 不要用UUID做主键:B+树的噩梦,写入性能下降50%以上
- 雪花算法的workerId不能手动配置:机器多了必出错,用ZK或DB自动分配
- 时钟回拨是真实存在的问题:NTP同步、虚拟机迁移都可能触发,生产环境必须处理
分布式ID看起来简单,却是分库分表后第一个遇到的坑。方案选对,事半功倍;方案选错,数据迁移就是噩梦。
个人观点,仅供参考