分布式 ID 生成方案实战指南:从选型到落地的全场景避坑手册(二)

三、方案 2:数据库分段 ID------ 分库分表场景的稳定选择

3.1 问题场景:电商订单分库分表

某电商订单系统,日均订单 100 万,分 3 个订单库(order_db_1order_db_2order_db_3),每个库分 10 个表(order_0-order_9)。需生成全局唯一的订单 ID,且支持按 ID 排序(查询用户最近订单)。

3.2 方案原理:从 "单库自增" 到 "分段自增"

数据库分段 ID 基于 "步长 + 偏移量" 实现,核心思路:

  1. 多个数据库实例(或表)共享自增 ID,但设置不同 "步长"(如 3 个库,步长 = 3);

  2. 每个库的 ID 起始值不同(偏移量),确保 ID 不重复;

  3. 例如:库 1 生成 ID=1、4、7...,库 2 生成 ID=2、5、8...,库 3 生成 ID=3、6、9...。

3.2.1 架构图:数据库分段 ID 生成架构

3.3 实战代码:数据库分段 ID 配置与实现

3.3.1 1. 数据库配置(MySQL 分库设置)

为 3 个订单库分别配置自增步长和起始值:

sql 复制代码
\-- order\_db\_1(库1):起始值1,步长3

ALTER TABLE order\_0 MODIFY COLUMN id BIGINT AUTO\_INCREMENT PRIMARY KEY;

ALTER TABLE order\_0 AUTO\_INCREMENT = 1;

SET GLOBAL auto\_increment\_increment = 3; -- 全局步长(所有表生效)

SET GLOBAL auto\_increment\_offset = 1;    -- 全局起始偏移量

\-- order\_db\_2(库2):起始值2,步长3

ALTER TABLE order\_0 MODIFY COLUMN id BIGINT AUTO\_INCREMENT PRIMARY KEY;

ALTER TABLE order\_0 AUTO\_INCREMENT = 2;

SET GLOBAL auto\_increment\_increment = 3;

SET GLOBAL auto\_increment\_offset = 2;

\-- order\_db\_3(库3):起始值3,步长3

ALTER TABLE order\_0 MODIFY COLUMN id BIGINT AUTO\_INCREMENT PRIMARY KEY;

ALTER TABLE order\_0 AUTO\_INCREMENT = 3;

SET GLOBAL auto\_increment\_increment = 3;

SET GLOBAL auto\_increment\_offset = 3;
3.3.2 2. 分库路由实现(Java + MyBatis)

通过 MyBatis 的Interceptor实现订单按用户 ID 路由到对应库:

java 复制代码
import org.apache.ibatis.executor.statement.StatementHandler;

import org.apache.ibatis.plugin.\*;

import java.sql.Connection;

import java.util.Properties;

// 分库路由插件:按用户ID取模路由到3个库

@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})

public class OrderShardingPlugin implements Interceptor {

   @Override

   public Object intercept(Invocation invocation) throws Throwable {

       // 1. 获取当前执行的SQL(如INSERT INTO order\_0 (user\_id, ...) VALUES (123, ...))

       StatementHandler statementHandler = (StatementHandler) invocation.getTarget();

       String sql = statementHandler.getBoundSql().getSql();

       // 2. 提取用户ID(假设SQL中user\_id是第1个参数)

       Long userId = (Long) statementHandler.getBoundSql().getParameterObject();

       // 3. 按用户ID取模3,确定路由库(0→db1,1→db2,2→db3)

       int dbIndex = (int) (userId % 3);

       String targetDb = "order\_db\_" + (dbIndex + 1);

       // 4. 修改数据库连接(切换到目标库)

       Connection connection = (Connection) invocation.getArgs()\[0];

       connection.setCatalog(targetDb); // 切换数据库

       // 5. 执行原SQL(此时会在目标库生成自增ID)

       return invocation.proceed();

   }

   @Override

   public Object plugin(Object target) {

       return Plugin.wrap(target, this);

   }

   @Override

   public void setProperties(Properties properties) {}

}
3.3.3 3. 进阶优化:号段模式(减少数据库访问)

数据库分段 ID 的痛点是 "每次生成 ID 都需访问数据库",高并发下数据库压力大。优化方案:号段模式(预申请一段 ID 缓存到本地,用完再申请)。

号段模式原理

  • 从数据库申请一段 ID(如start=1,end=1000),缓存到订单服务本地;

  • 服务生成 ID 时从本地缓存取,无需访问数据库;

  • 当本地 ID 用到阈值(如 800)时,异步申请下一段 ID(start=1001,end=2000),避免断流。

号段模式实战代码(Java + Spring Boot)

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Service;

import java.util.concurrent.atomic.AtomicLong;

@Service

public class OrderIdGenerator {

   // 本地缓存的号段(start:起始ID,end:结束ID)

   private volatile long currentStart;

   private volatile long currentEnd;

   // 原子类:确保多线程下ID生成线程安全

   private AtomicLong currentId;

   // 阈值:当用到80%时申请下一段(避免断流)

   private static final double THRESHOLD = 0.8;

   @Autowired

   private IdSegmentMapper idSegmentMapper; // MyBatis mapper,操作号段表

   // 初始化号段(服务启动时调用)

   public void initSegment() {

       // 从数据库申请第一段号段(假设order业务的segment\_type=1)

       IdSegment segment = idSegmentMapper.getNextSegment(1);

       currentStart = segment.getStart();

       currentEnd = segment.getEnd();

       currentId = new AtomicLong(currentStart);

   }

   // 生成订单ID(核心方法)

   public Long generateOrderId() {

       // 1. 原子递增获取当前ID

       long id = currentId.getAndIncrement();

       // 2. 检查是否达到阈值,若达到则异步申请下一段

       if (id >= currentStart + (currentEnd - currentStart) \* THRESHOLD) {

           asyncApplyNextSegment();

       }

       // 3. 检查ID是否超出当前号段(异常情况,如申请下一段失败)

       if (id > currentEnd) {

           throw new RuntimeException("订单ID生成失败:号段耗尽");

       }

       return id;

   }

   // 异步申请下一段号段(避免阻塞ID生成)

   private void asyncApplyNextSegment() {

       // 用线程池异步执行(避免单线程阻塞)

       IdGeneratorThreadPool.execute(() -> {

           // 从数据库申请下一段(数据库通过乐观锁确保号段不重复)

           IdSegment nextSegment = idSegmentMapper.getNextSegment(1);

           // 更新本地号段

           currentStart = nextSegment.getStart();

           currentEnd = nextSegment.getEnd();

           // 重置currentId(从新start开始)

           currentId = new AtomicLong(currentStart);

       });

   }

}

号段表结构(MySQL)

sql 复制代码
CREATE TABLE id\_segment (

   id BIGINT AUTO\_INCREMENT PRIMARY KEY,

   segment\_type INT NOT NULL COMMENT '业务类型(1=订单,2=物流)',

   start BIGINT NOT NULL COMMENT '号段起始ID',

   end BIGINT NOT NULL COMMENT '号段结束ID',

   version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',

   create\_time DATETIME NOT NULL DEFAULT CURRENT\_TIMESTAMP,

   UNIQUE KEY uk\_segment\_type (segment\_type)

) COMMENT '号段表';

\-- 初始化订单业务号段(start=1,end=1000)

INSERT INTO id\_segment (segment\_type, start, end, version) VALUES (1, 1, 1000, 0);

号段获取 SQL(MyBatis Mapper)

sql 复制代码
\<update id="getNextSegment">

   UPDATE id\_segment

   SET start = end + 1,

       end = end + 1000,  -- 每次申请1000个ID(可根据并发调整)

       version = version + 1

   WHERE segment\_type = #{segmentType}

     AND version = #{version}  -- 乐观锁:避免多服务节点重复申请

\</update>

3.4 故障案例:号段模式 "ID 重复" 排查

3.4.1 问题背景

某支付系统用号段模式生成支付流水号,部署 3 个服务节点。大促期间发现 2 笔支付流水 ID 重复(ID=1567),导致下游对账系统报错。

3.4.2 根因分析
  1. 号段表的version字段未加乐观锁,3 个服务节点同时申请号段时,均执行UPDATE id_segment SET start=end+1...

  2. 由于无版本控制,3 个节点均获取到相同号段(start=1501,end=2500),导致生成重复 ID;

  3. 服务节点本地缓存未设置 "防重复校验",无法发现重复号段。

3.4.3 解决方案
  1. 在号段表UPDATE语句中添加乐观锁(WHERE version = #{version}),确保同一时间只有一个节点能申请号段;

  2. 服务节点申请号段后,记录本地号段的startend,并与其他节点通过配置中心(如 Nacos)同步,避免重复;

  3. 生成 ID 时添加 "节点标识"(如在 ID 末尾加 1 位节点编号),即使号段重复,ID 也不会重复(如1567-11567-2)。

3.5 避坑总结

适用场景:分库分表、中低并发(日均 100 万以内)、需强有序 ID 的场景(订单 / 物流);

不适用场景:超高并发(秒杀 10 万 QPS)、无数据库依赖的场景;

⚠️ 必避坑点

  1. 分库步长需等于库数量(如 3 个库→步长 3),避免 ID 断层;

  2. 号段模式必须用乐观锁控制号段申请,防止重复;

  3. 号段大小需合理(并发高→号段大,如 1 万 / 段;并发低→号段小,如 1 千 / 段),避免内存浪费或频繁申请。

四、方案 3:雪花算法(Snowflake)------ 高并发场景的首选

4.1 问题场景:秒杀系统订单 ID 生成

某电商秒杀系统,峰值 QPS 达 5 万,需生成全局唯一、有序、高性能的订单 ID,且支持水平扩容(新增服务器节点)。此时雪花算法是最优选择。

4.2 方案原理:雪花算法的结构设计

雪花算法生成 64 位 Long 类型 ID,结构如下(从高位到低位):

sql 复制代码
1位(符号位):固定为0(确保ID为正数)

41位(时间戳):当前时间戳 - 起始时间戳(单位:毫秒),可支持约69年(2^41 / 365 / 24 / 3600 / 1000 ≈ 69)

10位(机器ID):分为"数据中心ID(5位)+ 机器节点ID(5位)",支持32个数据中心×32个节点=1024个节点

12位(序列号):同一毫秒内的ID序号,支持每毫秒生成4096个ID(2^12=4096)
4.2.1 结构示意图:雪花算法 ID 组成

4.3 实战代码:雪花算法实现(含时钟回拨处理)

雪花算法的核心痛点是 "时钟回拨"(服务器时钟因同步导致时间倒退,生成重复 ID),以下代码包含时钟回拨解决方案。

4.3.1 雪花算法核心实现(Java)
java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class SnowflakeIdGenerator {

   // 1. 常量配置

   private static final long SIGN\_BIT = 0L; // 符号位(固定0)

   private static final int TIMESTAMP\_BITS = 41; // 时间戳位数

   private static final int DATA\_CENTER\_BITS = 5; // 数据中心ID位数

   private static final int WORKER\_BITS = 5; // 机器节点ID位数

   private static final int SEQUENCE\_BITS = 12; // 序列号位数

   // 2. 位移量计算

   private static final long DATA\_CENTER\_SHIFT = SEQUENCE\_BITS + WORKER\_BITS;

   private static final long WORKER\_SHIFT = SEQUENCE\_BITS;

   private static final long TIMESTAMP\_SHIFT = SEQUENCE\_BITS + WORKER\_BITS + DATA\_CENTER\_BITS;

   // 3. 最大值限制(避免溢出)

   private static final long MAX\_DATA\_CENTER\_ID = (1L << DATA\_CENTER\_BITS) - 1; // 31

   private static final long MAX\_WORKER\_ID = (1L << WORKER\_BITS) - 1; // 31

   private static final long MAX\_SEQUENCE = (1L << SEQUENCE\_BITS) - 1; // 4095

   // 4. 起始时间戳(自定义:2020-01-01 00:00:00)

   private static final long START\_TIMESTAMP = 1577836800000L;

   // 5. 实例变量(数据中心ID、机器ID、序列号、上次生成时间戳)

   private final long dataCenterId;

   private final long workerId;

   private final AtomicInteger sequence = new AtomicInteger(0);

   private volatile long lastTimestamp = -1L;

   // 6. 构造方法(校验数据中心ID和机器ID)

   public SnowflakeIdGenerator(long dataCenterId, long workerId) {

       if (dataCenterId < 0 || dataCenterId > MAX\_DATA\_CENTER\_ID) {

           throw new IllegalArgumentException("数据中心ID超出范围(0-31):" + dataCenterId);

       }

       if (workerId < 0 || workerId > MAX\_WORKER\_ID) {

           throw new IllegalArgumentException("机器节点ID超出范围(0-31):" + workerId);

       }

       this.dataCenterId = dataCenterId;

       this.workerId = workerId;

   }

   // 7. 生成ID(核心方法,线程安全)

   public synchronized long generateId() {

       // 7.1 获取当前时间戳(毫秒)

       long currentTimestamp = System.currentTimeMillis();

       // 7.2 处理时钟回拨(当前时间 < 上次时间)

       if (currentTimestamp < lastTimestamp) {

           // 方案1:等待时钟追赶(适合回拨时间短,如<10ms)

           long waitTime = lastTimestamp - currentTimestamp;

           if (waitTime <= 10) { // 回拨<10ms,等待

               try {

                   Thread.sleep(waitTime);

                   currentTimestamp = System.currentTimeMillis();

               } catch (InterruptedException e) {

                   throw new RuntimeException("时钟回拨等待被中断", e);

               }

           } else {

               // 方案2:回拨时间长,抛出异常(避免重复ID)

               throw new RuntimeException("时钟回拨超出阈值(10ms):当前时间=" + currentTimestamp + ",上次时间=" + lastTimestamp);

           }

       }

       // 7.3 同一毫秒内:序列号递增

       if (currentTimestamp == lastTimestamp) {

           sequence.compareAndSet(MAX\_SEQUENCE, 0); // 序列号超出4095,重置为0

           int currentSequence = sequence.getAndIncrement();

           // 同一毫秒内序列号耗尽(理论上4096个/ms,高并发下可能发生)

           if (currentSequence > MAX\_SEQUENCE) {

               throw new RuntimeException("同一毫秒内ID生成超出上限(4096个)");

           }

       } else {

           // 7.4 不同毫秒:序列号重置为0

           sequence.set(0);

       }

       // 7.5 更新上次时间戳

       lastTimestamp = currentTimestamp;

       // 7.6 拼接ID(按位或运算)

       return SIGN\_BIT

               \| ((currentTimestamp - START\_TIMESTAMP) << TIMESTAMP\_SHIFT)

               \| (dataCenterId << DATA\_CENTER\_SHIFT)

               \| (workerId << WORKER\_SHIFT)

               \| sequence.get();

   }

   // 测试:生成10个ID

   public static void main(String\[] args) {

       SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1, 2); // 数据中心1,机器2

       for (int i = 0; i < 10; i++) {

           System.out.println(generator.generateId());

           // 输出示例:1234567890123456789(64位Long,有序递增)

       }

   }

}
4.3.2 机器 ID 分配方案(ZooKeeper 实现)

雪花算法的机器 ID(数据中心 ID + 节点 ID)需手动分配,避免重复。生产环境推荐用 ZooKeeper 自动分配:

  1. 服务启动时,向 ZooKeeper 的/snowflake/worker节点下创建临时顺序节点(如/snowflake/worker/worker-0000000001);

  2. 取节点的顺序号(如 1)作为机器 ID,确保全局唯一;

  3. 服务下线时,临时节点自动删除,释放机器 ID。

ZooKeeper 机器 ID 分配代码(Java + Curator)

java 复制代码
import org.apache.curator.framework.CuratorFramework;

import org.apache.curator.framework.CuratorFrameworkFactory;

import org.apache.curator.retry.ExponentialBackoffRetry;

import org.apache.zookeeper.CreateMode;

public class ZkWorkerIdAllocator {

   // ZooKeeper地址(集群用逗号分隔)

   private static final String ZK\_ADDRESS = "192.168.1.100:2181,192.168.1.101:2181";

   // 机器ID节点路径

   private static final String WORKER\_ID\_PATH = "/snowflake/worker/worker-";

   // 会话超时时间

   private static final int SESSION\_TIMEOUT = 5000;

   // 连接超时时间

   private static final int CONNECTION\_TIMEOUT = 3000;

   // 分配机器ID(返回0-31的ID)

   public static int allocateWorkerId() throws Exception {

       // 1. 创建ZooKeeper客户端

       CuratorFramework client = CuratorFrameworkFactory.builder()

               .connectString(ZK\_ADDRESS)

               .sessionTimeoutMs(SESSION\_TIMEOUT)

               .connectionTimeoutMs(CONNECTION\_TIMEOUT)

               .retryPolicy(new ExponentialBackoffRetry(1000, 3)) // 重试策略

               .build();

       client.start();

       // 2. 创建临时顺序节点(自动释放)

       String nodePath = client.create()

               .creatingParentsIfNeeded() // 父节点不存在则创建

               .withMode(CreateMode.EPHEMERAL\_SEQUENTIAL) // 临时顺序节点

               .forPath(WORKER\_ID\_PATH);

       // 3. 提取机器ID(如nodePath=/snowflake/worker/worker-0000000001 → ID=1)

       String nodeName = nodePath.substring(WORKER\_ID\_PATH.length());

       int workerId = Integer.parseInt(nodeName);

       // 4. 校验机器ID范围(0-31)

       if (workerId < 0 || workerId > 31) {

           throw new RuntimeException("机器ID超出范围(0-31):" + workerId);

       }

       System.out.println("ZooKeeper分配机器ID成功:" + workerId);

       return workerId;

   }

   // 测试:分配机器ID

   public static void main(String\[] args) throws Exception {

       allocateWorkerId();

   }

}

4.4 故障案例:雪花算法时钟回拨导致 ID 重复

4.4.1 问题背景

某支付系统用雪花算法生成支付流水号,部署 10 个服务节点。某天凌晨 2 点,由于服务器同步 NTP 时间,2 个节点的时钟回拨了 50ms,导致生成 100 + 个重复流水号,引发对账异常。

4.4.2 根因分析
  1. 雪花算法依赖服务器本地时间戳,时钟回拨后,currentTimestamp < lastTimestamp

  2. 原代码未处理时钟回拨,直接用回拨后的时间戳生成 ID,导致与回拨前的 ID 重复;

  3. 2 个节点的机器 ID 相同(手动分配时配置错误),进一步加剧重复问题。

4.4.3 解决方案
  1. 在雪花算法中添加时钟回拨处理(如代码中 "等待时钟追赶" 或 "抛出异常");

  2. 改用 ZooKeeper 自动分配机器 ID,避免手动配置错误;

  3. 服务启动时校验本地时间与 NTP 服务器时间差,若差 > 10ms 则拒绝启动;

  4. 生成 ID 后添加 "冗余校验"(如存入 Redis,检查 ID 是否已存在,避免重复)。

4.5 避坑总结

适用场景:高并发(秒杀 / 大促)、需有序 ID、水平扩容的场景(订单 / 支付 / 秒杀);

不适用场景:无 ZooKeeper / 配置中心、对时钟敏感的场景;

⚠️ 必避坑点

  1. 必须处理时钟回拨(核心风险点),避免 ID 重复;

  2. 机器 ID 需全局唯一(推荐用 ZooKeeper/Nacos 分配);

  3. 起始时间戳需固定(避免不同服务节点起始时间不同导致 ID 重复);

  4. 高并发下需注意 "同一毫秒序列号耗尽"(可增大序列号位数或拆分业务)。

相关推荐
thginWalker3 小时前
分布式协议与算法实战-实战篇
分布式·算法
DBWYX3 小时前
ShardingSphere 分布式数据库中间件生态
数据库·分布式·中间件
koping_wu4 小时前
【分布式】分布式事务方案:两阶段、TCC、SEATA
分布式
小王努力学编程4 小时前
brpc远程过程调用
linux·服务器·c++·分布式·rpc·protobuf·brpc
想你依然心痛4 小时前
Spark大数据分析与实战笔记(第五章 HBase分布式数据库-05)
数据库·分布式·spark
zcz16071278215 小时前
从 ZooKeeper 到 ELK:分布式中间件与日志分析系统全解析
分布式·elk·zookeeper
EmmaXLZHONG6 小时前
Hadoop分布式计算平台
大数据·hadoop·分布式
在未来等你8 小时前
Elasticsearch面试精讲 Day 19:磁盘IO与存储优化
大数据·分布式·elasticsearch·搜索引擎·面试
星环科技TDH社区版9 小时前
星环科技TDH社区版详解:从零搭建企业级大数据平台
大数据·数据库·分布式·数据存储与处理