分布式全局唯一ID生成核心方案:雪花算法原理与Java实现

📅 2026/8/27 5:49:16
分布式全局唯一ID生成核心方案:雪花算法原理与Java实现
看到一句很有意思的吐槽“努力了一下午终于把最难的 id 写完了剩下的明天继续。”评论区有同行回复真的业务系统里一个不起眼的 id能把人折腾一下午。这句话没有夸张。在单机时代数据库自增主键就是标准答案到了分布式系统面对高并发、多机房、分库分表、数据迁移id 的生成方案直接决定系统的性能和可维护性。这篇文章我就围绕“id”这个主题从概念、方案、原理、手写实现到实际场景排错完整梳理一套全局唯一 ID 的实战笔记。本文主要面向后端开发、全栈开发也适合需要处理主键、分库分表、数据导出、缓存键设计的同学。我会先用通俗的语言讲清楚为什么 id 难写然后对比几种主流方案再手写一个可运行的雪花算法 Java 实现最后补充业务开发中常见的 id 场景与排查思路。1. 为什么“最难写的是 id”1.1 单机 ID 与分布式 ID 的差异先看单机场景。在传统单体项目里id 通常由数据库自增主键解决CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, PRIMARY KEY (id) );这时 id 的生成逻辑非常简单每次插入数据库帮我生成一个 1、2、3 连续递增的整数。因为只有一台数据库自增序列天然不重复而且顺序递增。但系统一旦拆分微服务、分库分表情况就变了。假设用户表被拆分到 8 个库每库再拆 16 张表这时如果每张表各自从 1 自增最终会产生大量重复 id。不同表的唯一性没有了又需要全局唯一标识于是“分布式全局唯一 ID”的问题就出现了。1.2 好 ID 的标准唯一性、有序性、性能与安全我们评价一个 id 方案好不好不能只看“会不会重复”。实际生产环境通常关注四个方面唯一性全局绝对不能重复这是底线。有序性如果 id 能大致有序对数据库索引写入非常友好。有序 id 可以充分利用 InnoDB 聚簇索引的 B 树特性减少页分裂提升插入性能。高性能生成 id 的组件不能成为系统瓶颈并发场景下要保证低延迟、高吞吐。安全性id 不能轻易被推测。如果 id 是连续自增外部可以通过 id 遍历数据产生越权风险。雪花算法生成的 id 虽然包含时间戳但整体趋势递增随机性也不强需要结合权限控制来规避遍历风险。1.3 为什么说“最难的 id”不是自增 id自增 id 虽然简单但分布式场景下直接使用会遇到几个明显痛点单点数据库的写入压力大自增镜头会成为性能瓶颈。分库分表后无法保证全局唯一即使使用不同的初始步长也需要提前规划扩展困难。自增 id 容易暴露业务规模允许外部猜测数据量。数据迁移、合并时需要处理 id 冲突。所以当项目复杂度上升到一定程度我们就需要一套独立的、不依赖具体存储的 ID 生成方案。这也是很多团队花一个下午甚至更久去写一个“id 服务”的原因。2. 全局唯一 ID 的主流方案2.1 UUID 与 UUIDv7UUID 的全称是 Universally Unique Identifier标准长度为 128 位通常用 32 个十六进制字符表示。最常见的 UUIDv4 直接用随机数生成使用非常简单import java.util.UUID; public class UuidDemo { public static void main(String[] args) { System.out.println(UUID.randomUUID().toString()); } }UUID 的优点本地生成、不依赖网络、不依赖数据库、全局重复概率极低、接入成本极低。但它有一个非常致命的问题无序。作为数据库主键时随机字符串会让 InnoDB 的索引页频繁随机写入产生大量页分裂和碎片插入性能明显下降。于是出现了 UUIDv7。UUIDv7 是一种按时间有序的 UUID 方案结构上把 48 位 Unix 时间戳毫秒级放在前面后面填充随机位。相比 UUIDv4它兼顾了“本地生成”和“时间有序”两个优点作为数据库主键时对索引更友好。不过要注意UUIDv7 不等于雪花算法。两者的时间戳精度、整体位数、可解构性都有区别。UUIDv7 适合对 ID 不做严格反解、只需要大体有序的场景如果希望从 id 中直接解析出时间戳、机器信息雪花算法会更直接。2.2 数据库自增与 Redis 自增数据库自增的原理是维护一张专门的序号表每次插入通过 SQL 获取新 idCREATE TABLE sequence ( name VARCHAR(32) NOT NULL, current_value BIGINT NOT NULL, step INT NOT NULL DEFAULT 1, PRIMARY KEY (name) ) ENGINE InnoDB; UPDATE sequence SET current_value current_value step WHERE name user_id;这种方案实现简单事务可靠但数据库成为单点并发高时会锁表。很多团队会让每个节点一次性取一段号比如取 1000 个号在内存中用用完再取来降低数据库压力。Redis 自增是另一种常见方案INCR id:user性能高、实现简单但需要注意 Redis 持久化问题。如果 Redis 发生回滚id 可能重复如果 Redis 集群模式下没有做合理配置也需要注意原子性和主从切换后的风险。2.3 雪花算法雪花算法Snowflake是 Twitter 开源的一种分布式 ID 生成方案。通过一个 64 位 long 类型的整数把时间戳、机器 ID、序列号组合在一起既保证全局唯一又保证整体有序。它的优点非常突出本地生成不依赖数据库和 Redis。64 位整数相对 UUID 更省空间做索引更高效。时间戳占高位生成结果整体趋势递增。可以反解析出生成时间和机器信息利于排查问题。但雪花算法也是最容易让开发人员“写一下午”的方案原因在于位运算、溢出处理、时钟回拨等细节。下面单独用一整章来拆解。2.4 方案对比方案长度有序性依赖性能适用场景UUIDv4128 位字符串无序无高客户端生成、日志链路、临时标识UUIDv7128 位字符串时间有序无高需要本地生成且希望有序的主键数据库自增64 位整数连续递增数据库中低单机或低并发单体系统Redis INCR64 位整数连续递增Redis高中高并发业务号段雪花算法64 位整数时间有序无高微服务、分库分表、分布式系统3. 雪花算法原理拆解3.1 64 位的布局标准雪花算法把 64 位 long 划分为 5 个部分第 1 位符号位固定为 0保证 id 为正数。第 2 到 42 位41 位时间戳记录相对于某个起始时间的毫秒差。第 43 到 47 位5 位数据中心 ID。第 48 到 52 位5 位机器 ID。第 53 到 64 位12 位序列号。用 ASCII 简图来表达就是0 | 41 bit timestamp | 5 bit datacenter | 5 bit machine | 12 bit sequence41 位时间戳可以表示约 69 年的时间所以每个系统都需要一个“起始时间”从起始时间开始计算。只要不超过 69 年这 41 位就是够用的。3.2 41 位时间戳为什么用“差量”有人会问为什么不直接放 System.currentTimeMillis()因为当前毫秒数大约 1700000000000换算成二进制已经超过 41 位直接放会溢出。所以实现时要把当前时间减去起始时间long timestamp System.currentTimeMillis() - START_TIMESTAMP;这样得到的是一个相对时间数值变小可以放进 41 位里。需要注意这个起始时间一旦确定就不能随意修改否则可能出现 ID 回溯。3.3 数据中心 ID 与机器 ID数据中心 ID 和机器 ID 用来区分不同的部署节点。如果只有一台机器可以固定成 1如果部署多个节点需要由配置中心、注册中心或环境变量注入。每个字段 5 位取值范围是 0 到 31。如果服务实例超过 1024 个32 个数据中心 × 32 台机器就要考虑调整位数分配。3.4 12 位序列号序列号的作用是解决“同一毫秒内多次生成”的冲突。12 位最多表示 4096 个值也就是说同一台机器同一毫秒最多生成 4096 个 id。超出后只能阻塞等待下一毫秒。这里有一个很容易踩的坑序列号归零时要使用位与运算而不是判断等于 4096。代码如下sequence (sequence 1) MAX_SEQUENCE;MAX_SEQUENCE是 4095也就是 12 位全 1。当 sequence 从 4095 再加 1 时位与 4095 的结果为 0自然回到下一毫秒。3.5 位运算实现的关键写法把各部分拼成一个 64 位整数时需要把各个部分左移到各自的位置return ((timestamp - START_TIMESTAMP) TIMESTAMP_SHIFT) | (datacenterId DATACENTER_ID_SHIFT) | (machineId MACHINE_ID_SHIFT) | sequence;这里要注意左移运算符的优先级低于加减法所以(timestamp - START_TIMESTAMP)必须加括号。不同部分用按位或|拼接互不影响。如果位数分配不当可能出现负数也就是符号位被占用。4. 手写一个可运行的雪花算法 Java 实现4.1 项目结构这里我使用最纯粹的 Java 工程不需要 Spring只需要 JDK 8 以上环境。snowflake-demo ├── src │ └── com │ └── example │ └── id │ ├── SnowflakeIdGenerator.java │ └── SnowflakeDemo.java └── README.md如果你使用 IDEA直接新建一个普通 Java 项目放入两个类即可。4.2 核心代码实现先写SnowflakeIdGenerator.java这是完整的可运行类package com.example.id; /** * 雪花算法 ID 生成器 */ public class SnowflakeIdGenerator { // 起始时间戳2024-01-01 00:00:00 private static final long START_TIMESTAMP 1704067200000L; // 各部分占位数 private static final long SEQUENCE_BITS 12L; private static final long MACHINE_ID_BITS 5L; private static final long DATACENTER_ID_BITS 5L; // 各部分最大值 private static final long MAX_MACHINE_ID ~(-1L MACHINE_ID_BITS); private static final long MAX_DATACENTER_ID ~(-1L DATACENTER_ID_BITS); private static final long MAX_SEQUENCE ~(-1L SEQUENCE_BITS); // 各部分左移位数 private static final long MACHINE_ID_SHIFT SEQUENCE_BITS; private static final long DATACENTER_ID_SHIFT SEQUENCE_BITS MACHINE_ID_BITS; private static final long TIMESTAMP_SHIFT SEQUENCE_BITS MACHINE_ID_BITS DATACENTER_ID_BITS; private final long datacenterId; private final long machineId; private long sequence 0L; private long lastTimestamp -1L; public SnowflakeIdGenerator(long datacenterId, long machineId) { if (datacenterId MAX_DATACENTER_ID || datacenterId 0) { throw new IllegalArgumentException(datacenterId 超出范围); } if (machineId MAX_MACHINE_ID || machineId 0) { throw new IllegalArgumentException(machineId 超出范围); } this.datacenterId datacenterId; this.machineId machineId; } public synchronized long nextId() { long currentTimestamp System.currentTimeMillis(); // 时钟回拨直接拒绝生成 if (currentTimestamp lastTimestamp) { throw new IllegalStateException(时钟回拨拒绝生成 ID); } if (currentTimestamp lastTimestamp) { // 同一毫秒序列号累加 sequence (sequence 1) MAX_SEQUENCE; if (sequence 0) { // 当前毫秒序列号用尽等待下一毫秒 currentTimestamp tilNextMillis(lastTimestamp); } } else { // 新的一毫秒序列号归零 sequence 0L; } lastTimestamp currentTimestamp; return ((currentTimestamp - START_TIMESTAMP) TIMESTAMP_SHIFT) | (datacenterId DATACENTER_ID_SHIFT) | (machineId MACHINE_ID_SHIFT) | sequence; } private long tilNextMillis(long lastTimestamp) { long timestamp System.currentTimeMillis(); while (timestamp lastTimestamp) { timestamp System.currentTimeMillis(); } return timestamp; } }这段代码的核心逻辑是三个分支当前时间大于上次生成时间说明进入新的毫秒序列号归零。当前时间等于上次生成时间说明同一毫秒内并发请求序列号加 1超过 4095 则自旋等待下一毫秒。当前时间小于上次生成时间说明系统时钟发生了回拨这是最危险的情况直接抛出异常。4.3 测试与运行写一个简单的main方法验证package com.example.id; public class SnowflakeDemo { public static void main(String[] args) { SnowflakeIdGenerator generator new SnowflakeIdGenerator(1, 1); for (int i 0; i 10; i) { System.out.println(generator.nextId()); } } }输出结果类似如下880510896948004864 880510896948004865 880510896948004866 880510896948004867 880510896948004868 880510896948004869 880510896948004870 880510896948004871 880510896948004872 880510896948004873由于时间戳一直在变这里的数值只是一个示例。你可以看到同一毫秒内生成的 id 最后一位连续递增整体数值随时间和机器信息变化。如果是在多线程环境下测试可以在main里用ExecutorService模拟并发并使用SetLong判断是否有重复package com.example.id; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class SnowflakeConcurrentDemo { public static void main(String[] args) throws InterruptedException { SnowflakeIdGenerator generator new SnowflakeIdGenerator(1, 1); SetLong idSet ConcurrentHashMap.newKeySet(); int threadCount 8; int countPerThread 10000; ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch latch new CountDownLatch(threadCount); long start System.currentTimeMillis(); for (int t 0; t threadCount; t) { pool.submit(() - { try { for (int i 0; i countPerThread; i) { idSet.add(generator.nextId()); } } finally { latch.countDown(); } }); } latch.await(); pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); System.out.println(生成 ID 总数 threadCount * countPerThread); System.out.println(去重后数量 idSet.size()); System.out.println(耗时 (System.currentTimeMillis() - start) ms); } }正常情况下生成总数和去重后数量相同说明没有重复 id。4.4 时钟回拨处理的两种策略上面代码选择了“直接抛异常”。这是最保守的方案适合对 ID 正确性要求极高的场景。缺点是机器时钟一旦回拨服务会短暂不可用。另一种常用策略是“等待追赶”。当时钟回拨时记录回拨时间然后在内存中等待直到当前时间追上之前的lastTimestamp。代码如下if (currentTimestamp lastTimestamp) { long offset lastTimestamp - currentTimestamp; if (offset 1000) { throw new IllegalStateException(时钟回拨超过 1 秒拒绝生成 ID); } try { Thread.sleep(offset); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(等待时钟追赶时被中断, e); } currentTimestamp System.currentTimeMillis(); }工程上更推荐结合 NTP 时钟配置避免生产环境时钟大幅回拨。比如使用chronyd或ntpd做好时间同步并配置合理的最大误差。5. 业务开发中处理 ID 的常见场景5.1 后端新增数据后返回 id即使使用数据库自增 id我们也经常遇到“插入数据后怎么拿到刚生成的 id”的问题。在 ThinkPHP 5 中可以使用insertGetIduse think\Db; $userId Db::name(user)-insertGetId([ username zhangsan, status 1 ]); echo $userId; // 返回自增 id在 MyBatis 中则要配置useGeneratedKeys和keyPropertyinsert idinsertUser parameterTypecom.example.entity.User useGeneratedKeystrue keyPropertyid INSERT INTO user (username, status) VALUES (#{username}, #{status}) /insert调用时传入的user对象的id会被自动回填User user new User(); user.setUsername(zhangsan); user.setStatus(1); userMapper.insertUser(user); System.out.println(user.getId());一个常见的坑是某些数据库驱动默认不会返回自增 id需要显式配置useGeneratedKeys还有的团队在批量插入时只配置了单条插入导致批量场景拿不到正确的 id 列表。5.2 前端根据 id 删除 localStorage 数据localStorage 是一种前端本地存储方案数据以“键值对”形式保存在浏览器中。很多业务会把某个 id 对应的内容缓存到 localStorage例如用户 ID、订单 ID、请求任务 ID。删除指定 id 的数据核心是拼接 key然后调用removeItem// 写入时约定 key 的规则 const userId 9527; localStorage.setItem(user_ userId, JSON.stringify({ name: 张三 })); // 根据 id 删除 localStorage.removeItem(user_ userId);如果需要根据 id 批量删除可以遍历所有 keyconst prefix user_; Object.keys(localStorage).forEach((key) { if (key.startsWith(prefix)) { localStorage.removeItem(key); } });这种做法的关键是设计好 key 的命名规则避免误删。比如前缀中带上业务模块名比单独用纯 id 更安全。5.3 数据库报警表按 alarmtime 分区后按 id 查询为什么变慢这是生产环境一个很常见的性能问题。假设有一张报警表alarm主键是id但因为数据量大我们按照alarmtime字段做了 RANGE 分区比如每天一个分区。按alarmtime查询时MySQL 可以通过分区裁剪只扫描对应分区性能很好。但按id精确查询时由于id不是分区键MySQL 无法预判数据在哪个分区只能扫描所有分区。如果分区很多一次WHERE id ?就会变成全分区扫描性能远不如不分区时的主键查询。更麻烦的是InnoDB 要求主键必须包含分区键。如果你直接执行ALTER TABLE alarm PARTITION BY RANGE (TO_DAYS(alarmtime)) (...);很可能会报错A PRIMARY KEY must include all columns in the tables partitioning function解决方案通常是调整主键为复合主键PRIMARY KEY (id, alarmtime)这样分区才能创建。但注意查询时如果只带id条件依然无法精准裁剪分区。更合理的查询方式是同时带上时间范围SELECT * FROM alarm WHERE id 12345 AND alarmtime 2025-07-01 00:00:00 AND alarmtime 2025-07-02 00:00:00;这样既满足分区裁剪又能利用主键索引快速定位数据。在设计阶段就需要提前想清楚高频查询是按 id 还是按时间如果是按 id分区键选 alarmtime 可能不合适如果必须按时间分区就要在查询时强制带上时间范围。5.4 工具DBeaver 导出数据没有主键 id 怎么办DBeaver 是常用的数据库客户端工具。数据导出时发现没有主键 id常见原因有三种查询语句本身没有SELECT id。表本身就是无主键表例如历史日志表、中间表。导出的是视图或自定义查询结果视图里没有包含主键字段。排查思路先确认表结构在 DBeaver 左侧展开表查看“列”和“约束/键”确认是否存在主键。如果表有主键但查询结果没有检查 SQLSELECT id, alarmtime, message FROM alarm WHERE alarmtime 2025-07-01;如果表确实没有主键也可以使用记录的rowid来定位。Oracle 表查询时可以使用ROWIDSELECT ROWID AS row_id, t.* FROM alarm t;MySQL InnoDB 表如果没有主键会生成隐藏的聚簇索引 rowid但外部 SQL 不能直接读取这个隐藏列只能通过添加主键来保证数据可唯一标识。导出数据前最好先确认导出需求是为了备份、迁移还是为了分析如果是数据迁移建议先补充主键约束避免下游无法做增量同步。6. 常见问题与排查思路6.1 明明加了 synchronized为什么序列号还是重复很多人手写雪花算法时会遇到一个现象代码已经加了synchronized并发测试仍然出现 ID 重复。排查思路确认是否多个SnowflakeIdGenerator实例。如果每次请求都new一个对象每个对象各自维护自己的sequence和lastTimestamp那么同一个毫秒内不同实例可能生成相同的时间戳和序列号组合。确认数据中心 ID 和机器 ID 是否相同。如果所有节点都用(1, 1)两个实例即使时间戳不同也可能出现组合冲突。确认返回值是否被人为截断。例如数据库字段是int而雪花算法返回值是long整数溢出会造成重复。解决方案保证一台机器一个单例通过 Spring 单例模式或静态方式持有生成器机器 ID 从配置中心读取保证整片集群内不重复。6.2 时钟回拨导致拒绝生成 ID生产环境中手动调整服务器时间、NTP 同步异常都可能导致时钟回拨。如果回拨范围很小系统可能短暂报错如果回拨较大生成器会持续拒绝服务。排查步骤检查系统时间确认是否发生了时间回拨date -R查看 NTP 同步状态确认时间源是否可靠。根据回拨范围选择策略小范围回拨可以等待大范围回拨建议从注册中心摘除该节点避免影响主流程。如果是容器环境确认宿主机时钟是否稳定考虑挂载宿主机时间配置。6.3 ID 重复导致的隐性问题ID 重复不一定立刻报错更多时候表现为数据覆盖、关联错乱、对账不平。比如订单表主键冲突时应用层会收到 DuplicateKeyException但如果是在缓冲队列中异步落库重复 id 可能会被静默丢弃。排查这类问题建议从三个层面入手应用层面抓取日志中的 id 生成时间点和机器 ID。数据库层面检查主键冲突次数查看告警。中间件层面确认 Redis 是否发生主从切换、持久化恢复后的偏移量是否丢失。6.4 排查清单问题现象常见原因解决思路插入数据时主键重复数据库自增 id 冲突或多个生成器重复检查分库分表策略确认 id 生成器实例唯一雪花算法返回负数位运算时符号位被占用检查时间戳是否为负检查左移位数同一毫秒生成的 id 不连续序列号溢出或发生阻塞查看 sequence 归零逻辑优化并发策略按 id 查询非常慢分区表未使用分区键在查询中补充时间范围条件DBeaver 导出缺少 id查询 SQL 未包含主键列显式 SELECT id 或查看表结构localStorage 误删数据key 前缀设计混乱统一 key 规则增加业务模块前缀7. 选型与工程最佳实践7.1 什么时候用自增、UUIDv7、雪花算法没有万能方案只有适合场景的方案。我的建议是单体系统、数据量不大、无分库分表需求优先数据库自增简单可靠。前端需要离线生成、不需要强有序UUIDv4 就够了。分布式系统、微服务、分库分表、需要趋势递增雪花算法或 UUIDv7。有严格的时间回拨控制能力希望从 ID 中反解时间和机器信息雪花算法。不想维护机器 ID 分配只需要“比 UUIDv4 有序”的分布式主键UUIDv7。7.2 工程实践机器 ID 管理、时钟监控与日志雪花算法落地时机器 ID 管理往往是最大的运维问题。建议从这几方面做好工程保障。机器 ID 统一分配不要硬编码使用配置中心或注册中心下发确保全局不重复。时钟监控为运行雪花算法的节点增加时钟回拨监控回拨超过阈值时及时告警。日志记录记录生成 ID 时的时间戳、机器 ID、序列号方便问题回溯。版本管理起始时间戳一旦确定不要随意修改如果修改必须评估旧的 ID 是否还有效。测试环境验证在高并发场景上线前先用并发脚本验证 ID 唯一性。7.3 安全与合规意识ID 是一个很容易被忽略的安全入口。使用连续自增 ID 时要防止越权遍历使用雪花算法时虽然 ID 不是连续递增但趋势可预测也必须在接口层面做权限校验。还需要注意不能把内部机器 ID、数据中心信息直接暴露给客户端避免被用来推测内部架构。涉及用户数据的增删改操作必须校验当前用户是否有操作该 ID 的权限。生产环境的删除、批量更新、分区变更操作先备份并确认影响范围。8. 总结与下一步学习建议8.1 核心收获这篇文章从“最难写的 id”出发梳理了全局唯一 ID 的核心指标、主流方案对比、雪花算法的位运算原理并给出了一个完整的 Java 手写实现。之后又补充了业务开发中非常常见的数据返回 id、localStorage 删除、报警表分区、DBeaver 导出缺 id 等场景。读完你应该掌握区分单机自增 ID 和分布式全局唯一 ID。理解 UUID、UUIDv7、数据库自增、Redis、雪花算法的优缺点。能读懂并修改雪花算法的位运算逻辑。知道时钟回拨和实例冲突是雪花算法的主要风险。遇到分区表按 ID 查询慢时能从主键设计角度定位问题。8.2 下一步可以继续学习什么如果还想深入可以考虑这几个方向号段模式把思路从“实时生成”转为“批量取号”继续压榨性能。Leaf、Tinyid 等开源方案对比业界成熟 ID 生成服务的设计理解推模式与拉模式的区别。分库分表中间件学习 ShardingSphere 中主键生成策略的集成方式。分布式链路追踪理解 traceId、spanId 这类“非业务主键 ID”的生成规则。8.3 认真写 ID 的人运气不会太差回到开头那句“努力了一下午终于把最难的 id 写完了”。其实每个认真写过 ID 的人大概率都会经历窗口期位运算没加括号、机器 ID 重复、时钟回拨、局部变量和成员变量没分清……这些问题看起来小真正排查起来却很花时间。但只要沉下心把原理弄懂把完整实现跑通把边界场景考虑完整以后再遇到 ID 相关的问题就不会心虚了。剩下的部分明天继续。