资讯动态

分布式ID生成方案:美团Leaf、UidGenerator、TinyID拆解

发布时间:2026/9/18 16:07:25 来源:尧图企业网站定制
1. 分布式 ID 这个需求到底是怎么冒出来的分布式 ID 生成系统说白了就是一台或者一组专门发号的服务它要保证在几十上百个服务实例同时伸手要号的情况下发出去的每一个号全局唯一、趋势递增、且性能足够高。三五年之前这套东西还只是大厂内部的基础设施现在只要你的业务做过一次分库分表、上过一次微服务拆分几乎都绕不开它。这篇文章想做的事很具体把美团 Leaf、百度 UidGenerator、滴滴 TinyID 这三套业界最常被拿来抄作业的方案从设计动机、位分配、核心数据结构一直到部署踩坑彻底拆开讲一遍然后给出一套可以自己动手跑起来的实现。如果你现在正在被这些问题折磨——订单表拆成 16 个库之后主键怎么发、消息队列里的消息 ID 怎么保证不重复、日志链路追踪的 traceId 想做得短一点但又要唯一——那这篇内容基本能覆盖你的场景。我会尽量少讲空话多讲为什么是这个数、为什么是这个位数、为什么这里要加锁那里不加锁。1.1 单机自增 ID 的三个崩溃现场先说清楚为什么不能用数据库的AUTO_INCREMENT一直苟着。我见过太多项目一开始都是用自增主键跑得好好的直到某一天发生下面三件事之一。第一件是分库分表。当你把一张订单表按用户 ID 取模拆成 16 张表每张表各自AUTO_INCREMENT那 16 张表里就同时存在 id1001 的订单。这在业务上是灾难用户查订单、客服查工单、对账系统拉数据只要任何一环只拿着 ID 去查就会查出多条记录来。有人会说那我用分片序号 自增ID拼一个联合主键不就行了可以但这套规则一旦写进代码后面每次扩容分片数都要改规则而且这个 ID 对外暴露出去之后业务方看到的就是一串带分片信息的长数字既不美观也容易泄露分片策略。第二件是自增主键的性能瓶颈。InnoDB 的自增主键在单表场景下确实是顺序写入性能很好但一旦并发上来自增锁、页分裂带来的抖动会很明显。更麻烦的是自增 ID 天生是可枚举的——竞争对手只要注册一个账号看到自己的用户 ID 是 10086就大概知道你的日活量级和增长速度。这个信息泄露在大促前夜是实打实的风险。第三件是多服务写入同一逻辑实体。微服务拆完之后订单服务、履约服务、结算服务可能都要往同一张流水表里写数据。如果主键各自生成冲突几乎是必然的。这时候你会很自然地想能不能有一个地方谁都别自己造号统一来问它要这就是分布式 ID 生成系统的起点。提示如果你现在只是单库单表、日订单量在十万以内别急着上分布式 IDAUTO_INCREMENT加一张sequence辅助表就够了。过度设计带来的运维成本往往比它解决的问题还大。1.2 一个合格的分布式 ID 生成系统要满足的六个硬指标跟同行聊这个话题最后基本都会收敛到同一份检查清单上。我把这六个指标按重要性排一下后面讲三家方案的时候也是拿这六条去对照评价的。全局唯一是底线不用多解释但要注意唯一的范围是单机房唯一还是跨机房永久唯一这直接决定你要不要引入机房位、要不要引入逻辑分区。趋势递增是工程上最好用的一条性质。注意是趋势递增而不是严格递增。趋势递增带来的好处很实在MySQL InnoDB 的聚簇索引是按主键顺序组织的ID 趋势递增意味着新写入的数据集中在索引的右端页分裂少写入性能自然好。如果是完全随机的 UUID每插入一行都可能在 B 树中间插一刀脏页刷盘压力会明显上升。高性能低延迟单机至少要扛住每秒几万个 ID 的生成请求极端场景下要撑到百万级。而且重点不是平均值是 TP999——大量业务的接口 RT 预算是 50ms如果拿 ID 这一步偶发卡了 30ms整个接口就废了。高可用发号服务挂了所有依赖它的写接口全部挂。这个依赖强度决定了它必须做得比业务服务更稳至少要能容忍数据库主从切换、能容忍网络抖动。信息安全生成的 ID 不能让人一眼看出业务量。我见过一个反面案例某业务的订单号是自增的竞品每天早上抓一次最大订单号就能精确推算出他们的日订单量误差不到 3%。这种 ID 就是不合格的。可读性与运维友好这一点经常被忽略。ID 里如果能带上时间戳出问题的时候你直接肉眼就能看出这条数据大概是哪个时间点写的排查效率能提升一大截。同时ID 生成系统本身要便于监控——发了多少个号、号段还剩多少、有没有时钟回拨报警。指标不达标的典型后果常见应对手段全局唯一数据覆盖、对账错乱位段隔离、workerId 分配趋势递增写入性能下降、页分裂时间戳前缀、号段模式高性能接口 RT 抖动本地缓存、RingBuffer、双 buffer高可用写接口全线不可用降级到本地生成、多数据源信息安全业务量被外部推算随机起始值、加扰动位可读性排查效率低时间戳位、暴露解析接口2. 三家大厂的方案选型逻辑与位分配拆解业内做分布式 ID抛开 UUID 和 RedisINCR这类能用但不够好的方案真正被大规模验证过的路线其实只有两条一条是数据库号段一条是雪花算法。美团、百度、滴滴这三家的方案本质上都是在这两条路线上做工程加固。理解这个分类很重要因为后面所有的细节优化——双 buffer、RingBuffer、时钟回拨处理——都是从这两条路线的固有缺陷里长出来的。2.1 美团 Leaf号段模式加雪花两条腿走路Leaf 最聪明的地方在于它没有赌单一方案而是同时提供了leaf-segment和leaf-snowflake两套实现靠配置切换。leaf-segment走的是数据库号段路线。它的核心表大概是这样CREATE TABLE leaf_alloc ( biz_tag varchar(128) NOT NULL DEFAULT , max_id bigint(20) NOT NULL DEFAULT 1, step int(11) NOT NULL, description varchar(256) DEFAULT NULL, update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (biz_tag) ) ENGINEInnoDB;服务启动时对每个biz_tag执行一次UPDATE leaf_alloc SET max_id max_id step WHERE biz_tag order然后读出max_id就能拿到一段从max_id - step到max_id的号段接下来一段时间内所有请求都在内存里自增不再碰数据库。这里的关键设计是biz_tag作为主键——不同业务用不同的 tag互不干扰扩容的时候只要给新业务插一行就行。而且因为 ID 是连续递增的天然满足趋势递增在分库分表场景下还能按号段做区间路由。leaf-snowflake则是标准雪花算法的加固版位分配是1 位符号位 41 位毫秒时间戳 10 位 workerId 12 位序列号。41 位毫秒时间戳可以用 69 年10 位 workerId 支持 1024 个节点12 位序列号支持单节点每毫秒 4096 个 ID理论单机上限是 409.6 万 QPS——当然实际跑不到但足够说明了。为什么 Leaf 要提供两套因为号段模式虽然简单可靠但它有两个硬伤一是强依赖数据库数据库抖一下发号就卡住二是 ID 是连续递增的容易被推算。雪花算法不依赖任何外部存储性能极高但引入了 workerId 分配和时钟回拨这两个新问题。Leaf 的做法是把选择权交给使用方对 ID 连续性有要求、QPS 不极端的业务用号段对性能有极致要求、能接受 ID 不那么连续的业务用雪花。2.2 百度 UidGenerator把 Snowflake 的并发瓶颈用 RingBuffer 干掉UidGenerator 的出发点和 Leaf-snowflake 一样都是基于 Snowflake但它的取舍完全不同——它几乎把所有精力都花在了如何让单机 QPS 再高一个数量级这件事上。默认的位分配是1 位符号位 28 位秒级时间戳 22 位 workerId 13 位序列号总共 64 位。注意这里和标准 Snowflake 最大的差别时间戳是秒级而不是毫秒级占了 28 位。28 位秒数大约是 2.68 亿秒换算下来约 8.5 年。UidGenerator 默认的 epoch起始时间是 2016-05-20也就是说这套默认配置大概能撑到 2024 年底。注意这是一个非常典型的踩坑点。如果你现在才准备引入 UidGenerator默认配置的时间戳位大概率已经不够用了。要么改 epoch 往后挪要么把时间戳位加宽比如从 28 位加到 30 位同时牺牲 workerId 或序列号的位数这两件事必须在上线前想清楚一旦开始发号就不好改了。22 位 workerId 意味着理论上支持 400 多万个节点这个数字看着夸张实际上是因为 UidGenerator 把 workerId 的分配交给了数据库表worker_node用自增主键来分配所以位数给得很宽。13 位序列号支持单秒 8192 个 ID理论上单机每秒 8192 个——但这显然不够。所以 UidGenerator 的解法是拆成两个实现DefaultUidGenerator是最朴素的实现用synchronized保证同一秒内的序列号自增实现简单但性能就是普通的百万级。CachedUidGenerator才是重点它用RingBuffer环形数组提前把 ID 生成好业务线程来取的时候直接从数组下标拿全程几乎无锁。官方给出的压测数据是 Cached 版本单机每秒可以产生数百万个 UID和 Default 版本差了接近一个数量级。这个设计思路其实很值得学习把生成和消费解耦。生成是慢的涉及时间戳比较、序列号判断、CAS消费是快的数组读那就在后台线程里慢慢生成、把结果塞进缓冲区业务线程只管从缓冲区取。这和 CPU 的指令预取、消息队列的削峰填谷是同一个思想。2.3 滴滴 TinyID号段模式的工程化落地TinyID 走的是纯号段路线但它在两个地方做得比 Leaf 更工程化。第一是客户端缓存优先。TinyID 提供了 HTTP 接口比如/tinyid/next?idorder和 Java SDK 两种使用方式。SDK 模式下客户端会把号段缓存在本地只有当号段快用完时才去服务端请求下一段。这个设计让绝大多数发号请求根本不产生网络调用延迟从毫秒级降到纳秒级。代价是客户端和服务端的状态不一致窗口变大了——如果客户端拿到号段之后进程被 kill那段没发完的号就白白浪费了。这也就是为什么 TinyID 允许你配置delta参数来控制号段步长。第二是多数据源支持。TinyID 的元数据表可以分散在多个数据库实例上通过tinyid.db1、tinyid.db2这样的配置来隔离。这个设计的好处是不同的biz_type可以落在不同的库上避免所有业务都挤在一个库上同时单个数据库故障只影响部分业务不会全站瘫痪。这一点在 Leaf 上是需要你自己改造的。TinyID 的 ID 结构里同样塞进了时间戳官方文档给的结构是符号位加秒级时间戳加序列号序列号占了较大位数。这里我不打算把位数说死因为不同版本的 TinyID 在位数切分上有过调整你真正落地时一定要去翻自己用的那个版本的源码或者文档别照抄博客上的数字。2.4 三家方案横向对比把三家的关键参数摆到一张表里差异一眼就能看出来维度美团 Leaf-segment美团 Leaf-snowflake百度 UidGenerator滴滴 TinyID底层路线数据库号段雪花算法雪花算法数据库号段是否依赖外部存储强依赖 DB依赖 ZK 分配 workerId依赖 DB 分配 workerId依赖 DB单机 QPS 量级数万到十万级百万级数百万级Cached十万级SDK 本地缓存后更高ID 是否连续连续递增趋势递增趋势递增连续递增时钟回拨风险无有有无客户端形式独立服务 HTTP独立服务 HTTP本地 Jar 包服务端 SDK水平扩展方式加 DB 连接或拆 tag加节点workerId 有限加节点拆数据源从这张表能看出一个很有意思的规律凡是依赖外部存储的方案都不怕时钟问题凡是不依赖外部存储的都要为时钟和 workerId 付出代价。这不是巧合而是分布式系统的本质——你要么引入一个外部的一致性来源要么自己在本地做更多假设。3. 核心实现细节双 buffer、RingBuffer 和时钟回拨前面讲的是选什么方案这一段讲的是方案里最难啃的三块骨头。这三块内容在网上很多文章里都被一句采用双 buffer 优化带过了但真到自己实现或者排查问题的时候你会发现魔鬼全在细节里。3.1 双 buffer 是怎么把 TP999 拉平的先看号段模式最朴素的实现内存里存一个当前号段的current和max每次请求就current当current追上max的时候同步去数据库取下一段。这个实现有一个致命问题取号段的那一次请求会特别慢。假设数据库查询加更新要 10ms那在号段用完的那一瞬间恰好在那个时刻请求的线程就要等 10ms。虽然只有一次但如果有几百个线程在排队整个服务的响应时间曲线就会在那个点凸起来——这就是 TP999 抖动。Leaf 的解法是双 buffer内存里同时维护SegmentBuffer里面有两个Segment一个current、一个next。当current被消费到10%的时候就异步启动一个线程去加载下一个号段填进next。等current真的用完了直接把next切成current然后立刻又去异步加载新的next。这样一来取号段的 10ms 发生在请求量还很充裕的时候对用户完全无感。至于为什么是 10% 而不是 50% 或者 1%我理解这是 QPS 和响应时间的权衡阈值设得太高比如 90%留给异步加载的时间窗口就太短万一数据库那一下慢了还是会阻塞设得太低比如 1%意味着大部分时间都在后台预取浪费数据库资源。10% 是一个经验值如果你的数据库 RT 特别高比如跨机房 30ms 以上我建议把这个阈值调到 20% 甚至 30%这是我实际调优中踩过的。还有一个细节容易被忽略加载下一个号段时必须加锁。因为current消费到 10% 之后可能有好几个线程同时判断出该加载了如果每个线程都去数据库取一次号段就被取光了。Leaf 的做法是用一个AtomicBoolean作为开关第一个抢到的线程去加载其他的直接返回。这个小技巧叫双重检查 状态标记在自己实现的时候一定要加上。3.2 RingBuffer 加缓存行填充UidGenerator 的性能密码UidGenerator 的CachedUidGenerator用了一个叫 RingBuffer 的结构本质是一个固定长度的数组用来预存已经生成好的 UID。它的精妙之处在几个地方。首先是两个数组配合使用。一个数组存 UID 值slots另一个数组存这个槽位是否已就绪的标志位flags。消费者要取 UID 的时候先看标志位如果为可读就取走同时把标志位改成可写生产者随后会把新的 UID 填进来。这种生产者-消费者模型用纯数组实现避免了队列的节点分配和 GC 压力。其次是规避伪共享。数组里的每个标志位不是普通的long而是经过填充的PaddedAtomicLong——在它前后各填充若干个 long 变量把每个标志位撑到占满一个缓存行通常 64 字节。为什么要这么做因为 CPU 缓存是以缓存行为单位加载的如果两个标志位落在同一个缓存行里两个 CPU 核心分别修改它们会导致缓存行在两个核心之间来回失效、同步性能断崖式下跌。这个现象叫伪共享在高并发场景下能让性能掉一半以上。再就是自适应扩容。RingBuffer 默认大小是 8192 个槽位如果不够用可以按boostPower默认 3倍数扩容。填充的触发阈值默认是 50%——当缓冲池里可用的 UID 少于一半的时候后台就会启动填充线程把池子填满。这个阈值调高会更平滑但更耗 CPU调低的话一旦业务突发流量超过填充速度就可能要同步等待。提示UidGenerator 的 RingBuffer 有一个容易踩的坑——如果你把缓冲池设得太小、又遇到瞬时流量尖峰消费者可能在池子被填满之前就把 UID 取空了这时候它会退化到同步生成性能曲线会突然掉下来。生产环境我建议缓冲池至少留出 3 到 5 秒的峰值消耗量。3.3 时钟回拨三套方案各自怎么处理时钟回拨是雪花算法的天敌。服务器上的 NTP 服务会周期性对时如果发现本机时间快了就会把时间往回拨一点点。雪花算法强依赖当前时间必须大于上一次生成 ID 时的时间一旦时间倒流就可能生成重复 ID。Leaf-snowflake 的处理是三档回拨幅度在 5ms 以内就原地等待等时间追上再继续生成回拨幅度较大则直接抛异常并报警让上游感知到问题同时 Leaf 在启动时会校验本地缓存的 workerId 和 ZK 上记录的 workerId 是否一致如果本机时间已经落后于上次记录的时间戳就会拒绝启动。这个拒绝启动的设计非常关键——宁可起不来也别发出重复 ID。UidGenerator 的处理相对简单一些它主要是通过workerId的唯一性来兜底时钟回拨主要还是靠运维层面保证 NTP 配置正确。所以在用 UidGenerator 的项目里NTP 的配置检查往往被写进上线检查清单。TinyID 因为走号段路线压根不依赖本机时钟所以不存在这个问题。这也是为什么很多对稳定性要求极高的金融类业务最终还是选了号段模式——它的故障模式更好理解也更容易兜底。我自己的一套判断标准是如果团队运维能力一般、机器时间同步不放心就选号段如果团队有能力做严格的 NTP 监控和时间校验再考虑雪花类方案。别为了那点性能指标把自己陷进一个难以排查的故障模式里。3.4 workerId 分配ZK、DB、还是 Redis只要用雪花算法就必须面对 workerId 怎么分配的问题。三个选项各有优劣。用 ZooKeeper 分配Leaf-snowflake 的做法每个节点启动时在 ZK 上创建一个顺序节点拿到的顺序号对 1024 取模作为 workerId同时把 workerId 写到本地文件中缓存。下次启动先读本地文件再跟 ZK 上的记录做交叉校验。这个方案的优点是 ZK 本身保证了顺序性和唯一性缺点是引入了 ZK 依赖运维成本高——很多中小团队根本不想为了一个发号服务再维护一套 ZK 集群。用数据库分配UidGenerator 的做法建一张worker_node表用自增主键分配 workerId同时记录机器 IP、端口、启动时间。优点是没有额外中间件缺点是每次扩容都要访问数据库而且需要处理节点下线后 workerId 回收的问题。用 Redis 分配INCR worker_id_counter取模简单粗暴但同样引入依赖而且 Redis 重启或者数据丢失会导致 workerId 错乱。还有一个在实际部署中最容易出事的场景容器环境下 workerId 冲突。在 Kubernetes 里Pod 的 IP 是动态的如果你用 IP 哈希来算 workerIdPod 重建之后 IP 变了、workerId 也跟着变——这本来没问题但如果旧 Pod 还没完全退出、新 Pod 已经起来了两个 Pod 可能在短时间内共用同一个 workerId直接产生重复 ID。比较稳妥的做法是在 StatefulSet 里用固定的 Pod 序号来算 workerId或者把 workerId 作为环境变量显式注入别让它自己去猜。4. 手把手搭一套属于自己的 ID 生成服务讲完原理我们来动手。这一节的目标是让你能在半小时内跑起一套最小可用的号段模式服务代码量不大但把该有的保护都加上。4.1 表结构设计与步长计算先把表建好。我习惯在 Leaf 的表结构上再加两个字段version用于乐观锁status用于标记业务是否下线。CREATE TABLE id_alloc ( biz_tag varchar(128) NOT NULL DEFAULT , max_id bigint(20) NOT NULL DEFAULT 1, step int(11) NOT NULL DEFAULT 1000, version int(11) NOT NULL DEFAULT 0, status tinyint(4) NOT NULL DEFAULT 1, description varchar(256) DEFAULT NULL, update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (biz_tag) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO id_alloc (biz_tag, max_id, step, description) VALUES (order, 1, 1000, 订单ID);step到底设多少这是最常被问到的问题我给一个可以套用的计算方法。假设你的业务峰值 QPS 是 5000每次取号段之后的预期存活时长是 10 分钟600 秒那么这段时间内消耗的 ID 数量是5000 × 600 300 万。考虑到突发流量留 2 倍余量step就设成 600 万左右。但这里有个矛盾step越大浪费越多——每次服务重启没发完的那段号就永久丢弃了。如果你的服务一天要发布十几次step设太大会造成明显的 ID 空洞。我的经验公式是step 峰值 QPS × 预期号段存活秒数 × 1.5同时保证单个号段在正常流量下至少能撑 5 分钟。如果你的发布非常频繁可以把存活时间降到 2 分钟step相应调小代价是数据库访问频率上升。这两者的平衡需要根据实际情况调没有标准答案。注意max_id是bigint理论最大值是 9223372036854775807。如果step设得很大比如千万级加上业务跑了几年是有可能逼近上限的。建议加一条监控max_id超过某个阈值比如 10^18时报警提前做号段迁移。4.2 号段模式的最小可用实现下面是我自己写的一个精简版实现去掉了分布式锁那些花哨的东西只保留核心逻辑。整个类大概两百行可以直接抄。public class SegmentIdGenerator { // 号段缓冲区current 正在消费next 是预取的下一个 private volatile Segment current; private volatile Segment next; private final AtomicBoolean loading new AtomicBoolean(false); private final String bizTag; private final int step; private final JdbcTemplate jdbcTemplate; public SegmentIdGenerator(String bizTag, int step, JdbcTemplate jdbcTemplate) { this.bizTag bizTag; this.step step; this.jdbcTemplate jdbcTemplate; this.current loadSegmentFromDb(); } public long nextId() { // 1. 先判断是否需要异步预取下一个号段 if (current.percent() 10 next null loading.compareAndSet(false, true)) { asyncLoadNext(); } // 2. 当前号段用完切换到 next if (current.isExhausted()) { synchronized (this) { if (current.isExhausted()) { if (next null) { // 兜底同步加载宁可慢也不能发重复 next loadSegmentFromDb(); } current next; next null; loading.set(false); } } } return current.nextValue(); } private Segment loadSegmentFromDb() { // 关键UPDATE 和 SELECT 必须在同一个连接、读主库避免主从延迟导致拿到旧值 jdbcTemplate.update( UPDATE id_alloc SET max_id max_id ? WHERE biz_tag ?, step, bizTag); Long maxId jdbcTemplate.queryForObject( SELECT max_id FROM id_alloc WHERE biz_tag ?, Long.class, bizTag); return new Segment(maxId - step, maxId); } private void asyncLoadNext() { Thread t new Thread(() - { try { next loadSegmentFromDb(); } catch (Exception e) { // 加载失败不能忽略要把 loading 复位下次请求重试 loading.set(false); // 这里建议接入监控告警 } }, segment-loader- bizTag); t.setDaemon(true); t.start(); } static class Segment { private final AtomicLong current; private final long max; Segment(long start, long max) { this.current new AtomicLong(start); this.max max; } long nextValue() { return current.incrementAndGet(); } boolean isExhausted() { return current.get() max; } double percent() { return (double) (current.get() - (max - (max - current.get()))) / 1.0; } } }这段代码里有几个地方值得单独说。第一是loadSegmentFromDb()里那个UPDATE加SELECT的组合。这两个操作看起来简单但你要注意两点UPDATE语句本身是原子的加号在数据库里执行所以多个实例并发执行也不会拿到重叠的号段SELECT必须在同一个连接上执行而且必须读主库。如果走了读写分离、读到了从库那从库的复制延迟可能让你读到UPDATE之前的旧值结果就是两个实例拿到同一个号段——这是号段模式最经典的生产事故。第二是loading这个AtomicBoolean的位置。它必须在加载成功后复位我上面的代码放在切换到next的时候复位这是对的。但如果你在加载失败的时候不复位下一次请求就不会再触发预取号段用完就只剩同步兜底那条路了。这个finally里的复位动作很多人第一次写都会漏。第三是兜底逻辑。next null时的同步加载是必要的保命措施。理论上双 buffer 不会让next为空但只要并发足够高、数据库足够慢就一定会有边界情况。宁可在这里多一次同步查询也不能让current用完了还在空转。4.3 压测与验证怎么确认它真的靠谱代码写完只是第一步上线前我一定会做三件事。第一件是并发生成压测。起 32 个线程每个线程生成 10 万个 ID全部塞进一个ConcurrentHashMap或者布隆过滤器最后统计总数和实际入库数量是否一致。这一步能验证唯一性。同时记录 QPS 和 P99 延迟曲线看看有没有明显的凸起——如果每次号段切换时 P99 都会跳一下说明双 buffer 的阈值没调好。第二件是数据库故障演练。在压测中途把主库kill掉观察服务行为。正确表现应该是已经加载进内存的号段继续正常服务新的预取请求失败并告警但整体服务不中断。等数据库恢复后预取自动恢复正常。如果你的实现在这里直接抛异常给上游那就需要改。第三件是重启空洞测试。重启服务然后观察 ID 序列里出现了多大的空洞。这个空洞就是step造成的浪费。如果空洞大得离谱说明step设置得太大或者发布太频繁需要考虑把号段持久化到本地文件再恢复不过这会增加复杂度要权衡。4.4 部署形态独立服务、SDK 还是边车最后一个工程决策是这套东西怎么给业务方用。三种形态我都试过。独立 HTTP 服务是最简单的业务方一个 HTTP 请求拿号接入成本几乎为零。缺点是每次拿号都有一次网络往返QPS 高了之后网络开销不可忽略。适合 ID 需求量不大的场景。SDK 方式TinyID 的做法性能最好号段缓存在业务进程里拿号是纯内存操作。缺点是要维护多个语言的客户端升级麻烦而且每个业务进程都在缓存号段浪费会更大。边车模式是这两年的新选择——把发号服务作为 sidecar 容器和业务容器部署在同一个 Pod 里业务通过 localhost 访问。这样既有 SDK 的低延迟又避免了多语言客户端维护的麻烦。如果你们已经在用容器平台这是我目前比较推荐的方案。5. 踩坑记录与常见问题速查前面讲了太多应该怎么做这一段专门讲实际会怎么翻车。这些都是我在真实项目里见过或者亲手踩过的坑。5.1 号段模式读从库导致 ID 重复这个问题我在两个不同的团队都见过说明它真的很容易犯。场景是这样的为了减轻主库压力团队把SELECT max_id这条查询配到了从库上主从延迟在正常情况下一两毫秒看不出问题。然后某天主库压力大复制延迟涨到了 800ms两个实例在同一个瞬间各自执行了UPDATE但因为读的是从库都读到了同一个旧max_id于是发出了完全重复的一大段 ID。排查这个问题的难点在于它只在主从延迟大的时候才复现平时根本看不出来。所以记住一句话号段模式的取号操作必须走主库而且必须和 UPDATE 在同一个连接上。千万不要为了那点性能去读从库。如果你的数据库架构强制读写分离那就需要给这个连接配置强制走主库的 hint。5.2 容器环境下 workerId 冲突前面提过一次这里展开说。用雪花算法的团队在迁移到 Kubernetes 之后最常见的报错就是检测到重复 ID。原因通常是用 Pod IP 的哈希值算 workerIdPod 滚动更新的时候新旧 Pod 短暂共存两个不同的 IP 算出了相同的 workerId。解决方案有三个层次。最省事的是改用 StatefulSetPod 名字带固定序号比如id-gen-0、id-gen-1直接拿序号当 workerId。如果必须用 Deployment那就把 workerId 从环境变量或者配置中心读进来由发布系统保证同一时刻不冲突。最后一种兜底方案是在 ID 里加入实例的启动时间戳做校验启动时如果发现和 ZK 或数据库里的记录冲突就拒绝启动——这也是 Leaf 的做法。5.3 号段耗尽与 max_id 溢出step设置不仅要考虑性能还要考虑寿命。我见过一个项目step设了 100 万业务跑了三年max_id已经到 3 万亿了。这个数值离bigint上限还很远但如果你用的是 32 位整型的 ID那早就溢出了。还有一个更隐蔽的问题号段被跳过之后ID 会变得不连续但业务如果假设了 ID 是连续的就会出现 bug。比如某些代码里有for (long i startId; i endId; i)这种遍历逻辑一旦 ID 有空洞就会出错或者空转。凡是用 ID 做范围遍历的代码都要先确认 ID 是否连续这个假设在号段模式下是不成立的。5.4 常见问题速查表现象可能原因排查手段处理方式出现重复 ID读从库拿到旧号段检查是否配了读写分离强制读主库、UPDATE 与 SELECT 同连接出现重复 IDworkerId 冲突查各实例的 workerId 记录显式注入 workerId、加启动校验出现重复 ID时钟回拨查 NTP 日志、看时间戳跳变配置 NTP 平滑模式、加回拨拒绝逻辑P99 周期性凸起双 buffer 阈值太低打点记录号段切换时刻提高预取阈值、加长号段QPS 上不去每次都在同步取号段看数据库 QPS 曲线加大 step、启用 SDK 本地缓存数据库连接打满预取线程数过多看连接池活跃数单实例限制预取并发、加锁保护服务启动失败workerId 校验不通过看启动日志清理本地缓存文件、检查 ZK 记录有一个排查技巧值得单独说在你的发号服务里暴露一个/idinfo接口返回当前号段的current、max、percent以及最近一次预取的时间。我做过一次故障复盘业务方报告 ID 有重复我们靠这个接口五分钟就定位到了是号段切换时读到了旧值。如果没有这个接口光看日志可能要查一整天。最后说几句我在实际落地中的体会。分布式 ID 生成系统这个名字听起来很唬人但它本质上是整个技术栈里最简单、最容易被低估的一环。它不涉及复杂的算法代码量也少但它的故障影响面极大——一旦出问题就是全站级别的数据错乱而且事后修复极其困难因为重复的 ID 已经写进各种表、发到各种消息队列里了。所以我的建议是在方案选型上宁可保守在实现上宁可冗余在监控上宁可啰嗦。号段模式比雪花算法少了几个数量级的性能但换来的是一套你能完全理解、完全掌控的故障模型这笔买卖对绝大多数团队来说是划算的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价