
本篇我们会介绍一个重要的通用基础组件唯一ID生成器。此组件的应用范围相当广泛比如淘宝、京东上的用户ID、商品ID、订单号......都需要唯一ID生成器因为这些都是需要唯一标识的。很多人项目里随手用 UUID、数据库自增、Redis 自增但你真的想过它们的坑吗今天我们从传统方案对比入手再深入讲透雪花算法最后聊聊大厂成熟落地方案美团 Leaf帮你彻底搞懂分布式 ID 该怎么选、怎么用。兄弟姐妹们点点赞点点关注谢谢。一、分布式ID到底需要什么我认为一个合格的分布式 ID 生成器至少要满足这几点全局唯一最基础要求多节点、多库绝对不能重复高性能高并发场景下不能成为瓶颈有序友好分为单调递增和趋势递增数据库主键优先趋势递增利于索引高可用宕机、网络抖动、时间异常不崩无安全泄露不能轻易通过 ID 推算出用户量、订单量。注意第三点这里特别区分两个高频概念单调递增ID 严格依次变大比如 1,2,3,4多节点很难实现趋势递增整体随时间变大局部允许乱序雪花算法就是典型分布式首选。二、传统分布式id方案2.1 UUIDJDK1.5在语言层面实现了UUID一行代码UUID.randomUUID()就能轻松生成全球唯一ID本地生成、无网络依赖很香。import java.util.UUID; public class idgenerator{ public static void main(String[] args){ String uuid UUID.randomUUID().toString(); System.out.println(uuid); } }但它最大的问题占用空间大、无序、字符串格式。下面来详细讲一下这三点1. 占用空间大UUID长这样4aaa4aaa-5a5a-66ss-44ss-a5a5a565aa554个 “-” 连接字符32个十六进制数字共36个字符。但是B 树节点容量降低 数据库如 MySQL InnoDB的主键索引使用的是 B 树其每个节点页的大小是固定的默认 16KB。主键的体积越大一个节点能容纳的主键数量就越少。这会导致 B 树的层级变深查询时需要的磁盘 I/O 次数增加直接拖慢查询速度。二级索引体积膨胀 在 InnoDB 中辅助索引非主键索引的叶子节点存储的是主键的值。这意味着如果你有一个 36 字节的 UUID 作为主键那么你表里的每一个二级索引都会多出极大的存储开销。表越大、索引越多浪费的磁盘和内存空间就越惊人。相比之下BIGINT只需要 8 个字节。2. 无序UUID生成的ID是无序的没有任何递增规律。然而 InnoDB 主键使用的是聚簇索引这意味着数据在物理存储上是按照主键的顺序依次排列的。如果主键是自增有序的每次插入新数据数据库只需在当前数据页的末尾“追加”即可效率极高。但如果主键是无序的随机 UUID数据库在插入新数据时往往需要将其强行塞入到之前已经写满或半满的数据页中间。为了腾出空间数据库不得不把满页的数据拆分成两个页这就是页分裂Page Split。大量的页分裂会导致频繁的磁盘数据移动、产生大量内存碎片并极大地降低插入Insert的并发性能。3. 字符串格式在实际业务开发中为了方便调试和展示开发人员通常会将 UUID 直接以VARCHAR(36)或CHAR(36)的字符串格式存储在数据库中。这样会导致比较效率极低在数据库执行查询、排序或多表 Join 时底层需要频繁比对主键的值。CPU 对整型如BIGINT的比较是原生的指令速度极快而对字符串的比较需要逐个字符进行查对。字符集开销字符串处理还会受到数据库字符集如utf8mb4和排序规则Collation的影响这又增加了一层额外的计算开销。在千万级的数据表里字符串比对带来的性能损耗会被无限放大。2.2 数据库自增ID如果是单体架构数据库自增 ID 肯定是非常香的它完美避开了 UUID 的所有缺点体积小、绝对有序、整型比较极快。但在如今的高并发、分布式场景下它不适合。下面来讲一下为什么1.性能瓶颈与单点故障在分布式系统中你的应用服务可以部署几十上百个节点来抗高并发但如果全都依赖同一个数据库实例来生成自增 ID那么这个数据库就成了全村唯一的“独苗”。写并发瓶颈所有的写请求最终都要排队去这一个数据库里拿 ID。不管你前置服务有多牛一旦并发量上来比如双十一秒杀数据库的写 TPS每秒事务处理量上限就是你整个系统的性能天花板。单点宕机风险如果这台生成 ID 的主库宕机了整个系统将无法插入任何新数据直接陷入瘫痪。虽然可以做主从复制但主从切换期间依然会导致服务不可用且可能引发 ID 重复主库刚发了一个 ID 还没同步到从库就挂了。2. 分库分表时的冲突当单表数据量达到千万级比如订单表我们就必须进行分库分表操作。假设你把一张表拆成了table_0和table_1两个物理表如果继续用原生的自增 ID那就不好了。几个例子table_0的第一条数据 ID 是 1table_1的第一条数据 ID 也是 1。当你把这两张表的数据聚合到一起查询或者同步到数据仓库时完全无法区分这两条数据谁是谁。3. 商业机密泄露因为自增 ID 具有极强的规律性和可预测性所以如果你的电商订单号是自增的竞争对手只要今天早上下一单发现订单号是 10000晚上再下一单发现订单号是 15000就能精准算出你今天的真实日单量是 5000 单这跟裸奔有什么区别商业机密全泄露了。2.3 Redis INCRBY 命令Redis提供的 INCRBY 命令可以为键Key的数字值加上指定的增量Increment。INCRBY key increment命令的作用是将key中储存的数字值增加指定的步长increment。如果 key 不存在那么 key 的值会先被初始化为 0 然后再执行INCRBY命令。因为 Redis 单线程处理的机制无论有多少台应用服务器同时向 Redis 发起取号请求Redis 都会排好队依次执行绝对不会返回相同的 ID。简单代码演示在Spring Boot中我们通常会借助StringRedisTemplate来实现。基础玩法每次请求自增 1这种方式相当于把 Redis 当作一个分布式的、极其快速的数据库AUTO_INCREMENT来用。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; Component public class RedisIdGenerator { Autowired private StringRedisTemplate stringRedisTemplate; /** * 生成业务 ID * param businessKey 业务标识例如 order * return 全局唯一的递增 ID */ public long generateId(String businessKey) { String key id: businessKey; // opsForValue().increment 就是底层调用 INCRBY 命令 // 每次增加 1 return stringRedisTemplate.opsForValue().increment(key, 1); } }import org.springframework.beans.factory.annotation.Autowired;进阶玩法号段模式批量获取性能翻倍每次生成一个 ID 都去请求一次 Redis依然存在网络开销。高并发场景下应用服务器可以一次性向 Redis 申请一批 ID比如 1000 个存放在本地内存中本地发完之后再去请求 Redis。/** * 号段模式一次性获取一个步长的 ID 最大值 */ public long getNextIdBatch() { String key id:order:segment; long step 1000L; // 步长设置为 1000 // 比如原本是 0执行后 maxId 变成 1000 Long maxId stringRedisTemplate.opsForValue().increment(key, step); // 应用拿到 maxId 后就可以知道当前应用可用的 ID 范围是: [maxId - step 1, maxId] // 即 [1, 1000]。这段 ID 可以在应用本地直接分配无需再通过网络请求 Redis return maxId; }那来总结一下Redis方案的优缺点吧优点性能极高纯内存操作能抗高并发配合集群性能更高。生成的 ID 趋势递增纯数字且整体呈上升趋势对数据库的 B 树索引极其友好避免了页分裂。支持灵活配置可以按天归零比如生成2026052200001这种带日期的流水号也可以结合号段模式减少网络压力。缺点严重依赖网络如果不使用号段模式每次获取 ID 都要经历完整的网络请求应用 - Redis - 应用。宕机数据丢失导致 ID 冲突最大隐患Redis 是内存数据库它的持久化机制RDB 快照或 AOF 每秒刷盘都存在微小的时间差。2.4 小结那来总结一下这三位UUID伤硬盘数据库扛不住Redis又有网络开销和宕机回退的风险。为了追求绝对的高性能本地生成0 网络延迟和高可用不依赖独立中间件组件宕机下面有请雪花算法Snowflake隆重登场。三、雪花算法Snowflake3.1 雪花算法的 64 位核心结构0 | 41 位时间戳 | 10 位机器 ID | 12 位序列号1 bit符号位不用在 Java 中最高位是符号位。因为我们要生成的 ID 总是正数所以这一位固定为0。41 bit时间戳精确到毫秒用来记录当前时间距离某个初始时间可以是上线时间的毫秒差值。41 个二进制位最大能表示 $$2^{41}-$$ 毫秒换算下来大约可以使用69 年。这也是为什么雪花生成的 ID 是“趋势递增”的因为时间总是在往前走。10 bit机器/工作节点 ID用来标识当前是哪一台服务器在生成 ID。10 个二进制位最大能表示 $$2^{10} 102$$ 台机器。在实际部署中通常会拆分为 5位机房 ID数据中心 5位机器 ID确保不同的机器生成的 ID 绝对不会重复。12 bit序列号用来处理毫秒内的并发碰撞。如果同一台机器在同 1 毫秒内收到了多个获取 ID 的请求就通过递增这个序列号来区分。12 个二进制位最大能表示 $$2^{12} 409$$。意味着单台机器 1 毫秒内最多可以生成4096个不同的 ID单机 QPS 理论峰值可达 410万级别。最终效果是Snowflake算法给出的唯一 ID生成器是一个支持多机房共1024个服务实例规模、单个服务实例每秒可生成410万个long类型唯一 ID的分布式系统且此系统可以正常工作69年。3.2 为什么大厂都爱用它极简且高性能没有任何复杂的中间件依赖纯内存位运算快到飞起。自带时序性41 位时间戳在最前面保证了 ID 整体是随着时间递增的这对于 MySQL B 树索引来说简直是“神仙数据”写入速度极快不会频繁发生页分裂。绝对安全可控完全不依赖网络不存在网络超时或单点故障问题。不过雪花算法也有一些问题机器ID重复与时钟回拨问题。那是怎么解决的呢3.3 机器ID重复与解决方案雪花算法的 64 位中有 10 位是分给机器的最多支持 1024 台机器。 如果你的服务集群里有两台机器比如 Node A 和 Node B拿到了相同的机器 ID那么当它们在同一个毫秒内接收到并发请求时极大概率会生成完全一模一样的序列号。最终结果就是两台机器算出了完全相同的 64 位 ID直接导致数据库主键冲突服务大面积报错。本篇要讲的解决方案ZooKeeper / Etcd 注册中心美团 Leaf 等主流首选机器启动时连接 ZK在一个指定的目录下创建一个临时顺序节点。ZK 会自动为这个节点追加一个递增的序号应用提取这个序号作为自己的机器 ID。 这是极其可靠的解决了容器化动态扩缩容带来的分配难题同时 ZK 还能兼职做时钟回拨的校验中心。3.4 时钟回拨问题与解决方案既然雪花算法强依赖于机器的本地时钟那么机器的时间真的绝对可靠吗这就引出了雪花算法最经典、也是最致命的架构隐患——时钟回拨。1. 问题来源计算机的时间并非凭空而来而是靠主板上的石英晶体振荡器和纽扣电池来模拟的。通常这块晶体的振动频率为 32,768Hz即每振动 32,768 次代表 1 秒过去。 但物理硬件并不完美受极端环境如低温影响晶体振动会出现误差。在正常情况下服务器每天也会产生±1s左右的计时误差这种现象被称为“时钟漂移”。2. NTP 同步导致“时间倒流”既然单台机器的时间会飘集群环境怎么保证时间一致1985 年David L. Mills 设计了NTP网络时间协议它可以将局域网内的机器时钟误差强行拉平到 1ms 以内。 但 NTP 的介入带来了一个极其恐怖的副作用如果某台机器的时钟跑得太快了NTP 会毫不留情地把它的时间往回拨打个比方 假设唯一的 ID 生成服务在2023年1月1日 11:05:05正常发号。此时 NTP 触发时间校准发现机器时间快了直接将其拽回到了11:05:00。 当新的请求打过来时雪花算法拿到的时间戳又回到了 5 秒前。此时生成的 ID将与 5 秒前生成的 ID完全重复瞬间引爆数据库主键冲突3. 解决方案为了防范这种灾难ID 生成器在每次发号时都必须把当前的时间戳存下来记作svr.millisPassed。当新请求到来时计算最新的时间戳millis进行比对一旦发现millis svr.millisPassed就说明时间发生了倒流触发了时钟回拨面对时钟回拨通常有以下三种处理策略解法一短回拨 - 阻塞等待Sleep如果回拨的时间跨度极短比如只有 10ms最简单优雅的做法就是让当前处理请求的线程原地阻塞等待一会儿。等物理时间真实地流逝追平了上次记录的发号时间后再重新执行生成逻辑。解法二长回拨 - 强硬拒绝抛异常如果时间一口气回退了几秒甚至更长阻塞等待会让海量请求瞬间堆积压垮内存。此时必须当机立断直接拒绝发号请求抛出运行时异常将流量路由给集群里的其他健康节点。解法三釜底抽薪 - 关闭NTP同步一种更为极致的做法是让所有充当 ID 生成器的服务实例彻底关闭 NTP 时间同步功能从物理层面上杜绝“时间倒流”的可能。 当然这会导致部分节点的“时钟漂移”越来越严重。配套的兜底方案是引入外部监控一旦发现某台服务实例的漂移幅度超过了设定的阈值直接将其从服务集群中人工或自动摘除。3.5 最终架构基于 Snowflake 算法的唯一 ID 生成器服务的最终架构如下每个机房都单独编号。在每个机房内都部署一个 worker ID 分配器可以基于数据库或 etcd 等。在每个机房内都部署若干唯一 ID 生成器服务实例这些服务实例每次启动时都从本机房的 worker ID 分配器中获取 worker ID。每个服务实例都在本地维护当前毫秒时间戳和毫秒内并发数并与机房编号、worker ID 一起作为输入参数执行 Snowflake 算法生成唯一 ID。四、美团点评开源方案LeafLeaf是美团开源的工业级分布式ID 生成器支撑美团外卖、支付、金融等核心高并发业务。它提供两套互补方案Leaf-segment 号段模式严格单调递增Leaf-snowflake 雪花增强模式高并发安全、防时钟回拨4.1 Leaf-segment 号段模式数据库号段方案4.1.1 核心思想不再逐条向数据库申请 ID而是一次性批量拉取一大段 ID 缓存到本地内存业务直接从内存取号极大降低数据库压力。4.1.2 号段数据表设计表格字段名类型说明biz_tagvarchar业务唯一标识隔离不同业务 IDmax_idbigint当前已分配的最大 IDstepint每次批量拉取的号段步长descvarchar业务描述update_timetimestamp更新时间4.1.3 执行流程示例假设biz_tag ordermax_id 10000step 2000Leaf 执行事务更新max_id max_id step本次拿到可用号段10001 ~ 12000后续请求直接从内存取号无需访问 DBstep 越大DB QPS 越低性能越好。4.1.4 原生方案缺陷号段耗尽瞬间需要同步查库数据库抖动、慢查询会阻塞业务请求4.1.5 Leaf 核心优化双缓存 异步预加载优化逻辑内部维护主、备两个号段缓冲区当前号段消耗10%阈值异步提前加载新号段老号段用尽直接切备用缓存业务零阻塞4.1.6 优缺点总结✅ 优点严格单调递增无时间戳依赖、无时钟回拨问题性能极高、支持超大并发多业务 Tag 隔离互不影响❌ 缺点ID 连续递增会暴露业务订单体量服务重启会浪费当前未用完的号段4.2 Leaf-snowflake 雪花增强模式为了解决号段模式泄露业务量的问题Leaf 基于原生雪花做了生产级增强彻底搞定两大痛点机器 ID 重复、时钟回拨。4.2.1 ID 结构完全兼容雪花0 | 41位时间戳 | 10位机器ID | 12位序列号4.2.2 机器 ID 自动分配基于 ZK原生雪花最大痛点机器 ID 手动配置极易重复。Leaf 采用ZK持久顺序节点自动分配 WorkerId流程Leaf 启动自动在 ZK 创建持久顺序节点ZK 自增序号作为当前实例 WorkerId本地文件缓存 WorkerIdZK宕机也可复用彻底杜绝机器 ID 重复4.2.3 时钟回拨解决方案业界最优校验机制启动校验本机时间 历史最大时间 → 直接启动失败拒绝服务运行监控每 3s 上报时间戳持续检测时间回拨集群时间比对参考集群平均时间识别时间漂移配合服务器关闭 NTP 自动对时可彻底消灭生产时钟回拨问题4.3 Leaf 两种方案选型对比表格方案递增特性泄露业务量时钟回拨风险适用场景Leaf-segment严格单调递增会泄露无日志、流水、内部业务Leaf-snowflake趋势递增安全不泄露几乎为 0订单、支付、用户 ID 核心业务4.4 小结Leaf-segment高性能、稳、无时钟问题适合内部非敏感业务Leaf-snowflake优化版雪花算法解决原生所有生产坑点是互联网高并发核心业务首选企业级开发无需手写雪花算法直接接入 Leaf 即可稳定落地