分布式 ID 生成方案对比:雪花算法、号段模式与数据库自增
一、分库分表后,你的主键还是自增的?
在一个单体应用的数据库里,AUTO_INCREMENT是最自然的主键生成方式。插入一条记录,数据库帮你在上一行 ID 的基础上加 1,简单可靠。但当系统分库分表之后,这个方案立即失效——不同的分表会产生相同的 ID,全局唯一性被打破。
在分布式系统中,全局唯一的 ID 生成是一个看似简单、实则充满工程细节的问题。它不仅要保证唯一,还要考虑趋势递增(利于索引)、高性能(不成为瓶颈)、以及高可用(单点故障不影响 ID 生成)。
当前业界有三个主流方案:雪花算法、号段模式、数据库自增的特殊应用。这篇文章从原理到实现,完整对比这三种方案的优劣。
flowchart TB A[分布式 ID 生成需求] --> B{选择方案} B --> C[雪花算法 Snowflake] B --> D[号段模式 Segment] B --> E[数据库自增变体] C --> C1[41bit 时间戳 + 10bit 机器ID + 12bit 序列号] C --> C2[优点: 高性能, 无外部依赖] C --> C3[缺点: 时钟回拨问题] D --> D1[每次从数据库取一段号] D --> D2[优点: 简单可控, 可定制业务ID] D --> D3[缺点: 依赖数据库, 号段耗尽需处理] E --> E1[多主模式, 步长不同] E --> E2[优点: 绝对递增, 无空洞] E --> E3[缺点: 扩展困难, 写入瓶颈]二、雪花算法:高性能的解耦之美
雪花算法(Snowflake)是 Twitter 开源的一种分布式 ID 生成方案。它在单机内生成 64 位长整型,完全无需外部依赖,是性能最高的方案。
ID 结构(64 位):
| 1bit 符号位 | 41bit 时间戳(毫秒) | 10bit 工作机器ID | 12bit 序列号 |- 41bit 时间戳:从自定义纪元开始(如 2020-01-01),可覆盖约 69 年
- 10bit 机器 ID:支持 1024 个节点
- 12bit 序列号:同一毫秒内可生成 4096 个 ID
每秒理论最大 QPS = 4096 × 1000 = 4,096,000,完全能满足绝大部分场景。
雪花算法的致命缺陷:时钟回拨。如果服务器时钟因为 NTP 校准向后调整了 1 毫秒,雪花算法可能生成与前一毫秒重复的 ID。这是雪花算法在工程实践中最需要小心处理的问题。主流解决方案包括:等待时钟追上(阻塞直到时间恢复)、使用备用 Worker ID(检测回拨后切换到备用机器号)、或记录最大时间戳(保存上次生成 ID 的时间,回拨时拒绝生成)。
三、三种方案的完整工程实现
/** * 雪花算法实现——带时钟回拨保护 * * 为什么需要时钟回拨保护:NTP 校时可能导致服务器时间倒退, * 如果不处理,会产生重复 ID,破坏全局唯一性。 */ public class SnowflakeIdGenerator { // 起始时间戳(2024-01-01 00:00:00) // 为什么用固定纪元:让 41bit 时间戳可用更久 private static final long EPOCH = 1704067200000L; // 各部分位数 private static final long WORKER_ID_BITS = 10L; private static final long SEQUENCE_BITS = 12L; // 最大值 private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); // 偏移量 private static final long TIMESTAMP_SHIFT = WORKER_ID_BITS + SEQUENCE_BITS; private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; private final long workerId; private long sequence = 0L; private long lastTimestamp = -1L; /** * 时钟回拨容忍阈值(毫秒) * 为什么设 5ms:NTP 校时通常不会回退超过 5ms, * 超过此值说明时钟严重异常,应拒绝生成。 */ private static final long MAX_BACKWARD_MS = 5L; public SnowflakeIdGenerator(long workerId) { if (workerId > MAX_WORKER_ID || workerId < 0) { throw new IllegalArgumentException( String.format("Worker ID 必须在 0-%d 之间", MAX_WORKER_ID)); } this.workerId = workerId; } public synchronized long nextId() { long currentTimestamp = System.currentTimeMillis(); // 时钟回拨检测与处理 if (currentTimestamp < lastTimestamp) { long backwardMs = lastTimestamp - currentTimestamp; // 回拨在容忍范围内:等待时钟追上 if (backwardMs <= MAX_BACKWARD_MS) { try { // 为什么等待而不是直接拒绝:短暂回拨大概率是 NTP 微调, // 等待恢复比拒绝更符合业务需求 Thread.sleep(backwardMs + 1); currentTimestamp = System.currentTimeMillis(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("时钟回拨等待被中断", e); } } else { // 回拨超过容忍上限:拒绝生成 // 为什么必须拒绝:大量回拨说明系统时钟严重异常, // 生成的 ID 有极高重复风险 throw new RuntimeException( String.format("时钟回拨 %d ms,超过容忍上限 %d ms,拒绝生成 ID", backwardMs, MAX_BACKWARD_MS)); } } // 同一毫秒内序列号递增 if (currentTimestamp == lastTimestamp) { sequence = (sequence + 1) & MAX_SEQUENCE; // 序列号用完(同一毫秒内生成了 4096 个 ID) if (sequence == 0) { // 忙等待下一毫秒 // 为什么用忙等待而非 sleep:毫秒级等待用 sleep 不够精确, // 可能等的时间过长 while (currentTimestamp <= lastTimestamp) { currentTimestamp = System.currentTimeMillis(); } } } else { sequence = 0L; // 新的毫秒,序列号归零 } lastTimestamp = currentTimestamp; // 组装 ID:时间戳左移 | 机器ID左移 | 序列号 return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; } } /** * 号段模式——基于数据库的批量 ID 生成 * * 为什么需要号段模式:雪花算法生成的 ID 不是严格递增的, * 对于需要连续 ID 的业务场景(如订单号), * 号段模式更可控。 */ @Service public class SegmentIdGenerator { @Autowired private JdbcTemplate jdbcTemplate; // 内存中的当前号段 private final AtomicLong currentId = new AtomicLong(0); private volatile long maxId = 0; // 每次从数据库获取的号段大小 // 为什么设 1000:每获取一次号段就够 1000 次请求使用, // 数据库压力降到 1/1000 private static final int SEGMENT_SIZE = 1000; /** * 获取下一个 ID * 为什么用 double-check 锁:高频调用时, * 大部分情况只需 AtomicLong 自增,不需要进锁。 */ public long nextId(String bizTag) { long id = currentId.incrementAndGet(); // 号段用完(或首次使用),从数据库获取新号段 if (id > maxId) { synchronized (this) { // double-check:进入锁后再次判断, // 防止多个线程同时等待锁后重复获取 if (currentId.get() > maxId) { fetchSegment(bizTag); } } id = currentId.incrementAndGet(); } return id; } /** * 从数据库获取新号段 * 使用事务保证原子性:UPDATE 在同一事务中完成读取和递增 */ private void fetchSegment(String bizTag) { // UPDATE segment SET max_id = max_id + SEGMENT_SIZE // WHERE biz_tag = ? AND version = ? // 返回更新后的 max_id String sql = "UPDATE id_segment SET max_id = max_id + ? " + "WHERE biz_tag = ? RETURNING max_id"; Long newMaxId = jdbcTemplate.queryForObject( sql, Long.class, SEGMENT_SIZE, bizTag); if (newMaxId == null) { throw new RuntimeException("获取号段失败,bizTag=" + bizTag); } // 号段范围:(newMaxId - SEGMENT_SIZE, newMaxId] currentId.set(newMaxId - SEGMENT_SIZE); maxId = newMaxId; } } /** * 数据库自增变体——多主模式 * * 适合小规模系统( < 10 个分库) * 核心思路:不同数据库实例设置不同的自增步长 */ @Configuration public class MultiMasterIdConfig { /** * 为什么设计成这样:MySQL 支持 auto_increment_increment * 和 auto_increment_offset,让多个主库生成的 ID 交错而不重复。 * * 实例 1:步长 2,偏移 1 → ID: 1, 3, 5, 7, ... * 实例 2:步长 2,偏移 2 → ID: 2, 4, 6, 8, ... * * 限制:扩容时需要修改所有实例的步长,复杂度随实例数增加。 */ // 实例 1 的 MySQL 配置 // SET GLOBAL auto_increment_increment = 2; // SET GLOBAL auto_increment_offset = 1; // 实例 2 的 MySQL 配置 // SET GLOBAL auto_increment_increment = 2; // SET GLOBAL auto_increment_offset = 2; }四、三种方案的对比与选型决策
| 维度 | 雪花算法 | 号段模式 | 数据库自增变体 |
|---|---|---|---|
| 唯一性 | 强(需处理时钟回拨) | 强(依赖数据库) | 强(MySQL 内保证) |
| 趋势递增 | 是(但不连续) | 严格递增 | 严格递增 |
| 性能 | 极高(纯内存) | 高(批量取号) | 中(每次写 MySQL) |
| 外部依赖 | 无 | MySQL | MySQL |
| 部署复杂度 | 中(需分配 Worker ID) | 低 | 低 |
| 可读性 | 差(64位Long) | 好(可带业务前缀) | 好(纯数字) |
选型建议:
- 雪花算法适合高并发、大规模分布式系统(如订单系统),但其趋势递增而非严格连续的特性不适合对连续 ID 有强需求的场景。
- 号段模式适合需要业务可读 ID 的场景(如订单号
202407120001),且数据库压力可控。 - 数据库自增变体适合分库数量少(< 10)、不想引入额外组件的系统。
一个重要的架构权衡:雪花算法虽然无外部依赖、性能最高,但引入了对系统时钟的依赖——这在分布式系统中是一个新的故障点。号段模式依赖数据库,但数据库本身就是系统的基础设施,额外故障风险反而更低。
五、总结
分布式 ID 生成的本质矛盾是:唯一性要求串行化,高性能要求并行化。三种方案各用不同的方式解这个"串行与并行"的矛盾——雪花算法用时间戳做天然分区、号段模式用批量分配减少串行化频率、数据库变体用步长交错实现伪并行。
没有银弹方案。选型需要权衡性能、一致性、可读性和运维复杂度四个维度。理解每种方案"牺牲了什么来换取什么",比记住它们的实现细节更重要。