资讯动态

唯一ID生成实战:从UUID到雪花算法,一行代码背后的原理与坑

发布时间:2026/10/20 13:16:38 来源:尧图企业网站定制
做后端开发的迟早都会跟“ID”这个东西正面撞上。订单要单号消息要消息ID日志要traceId分库分表之后数据库主键不能再靠自增了分布式文件上传也得有全局不重名的文件名。麻烦永远比想象中来得早。几乎每周都能看到类似的提问“怎么生成唯一ID”“UUID够用吗”“为什么我的ID重复了”而“一行代码生成绝对唯一ID”这个说法恰恰是这类问题最诱人、也最容易让人误入歧途的答案。这句话有真有假真的部分在于确实存在调用起来只有一行代码的成熟方案假的部分在于如果你不理解那行代码在做什么它给你带来的麻烦往往比省下的时间多得多。这篇文章想做的事情是把这件事讲透一行代码背后到底是什么算法、为什么它能做到工程意义上的绝对唯一、哪些场景真的需要“绝对唯一”、哪些场景其实“大概率唯一”就足够以及生产环境里你真正会踩到的那些坑。适合被ID问题困扰过的后端开发者也适合刚接触分布式系统、想搞清楚主键和业务流水号到底该怎么设计的同学。我会用真实踩坑和实测数据说话争取让你看完就能直接照着落地。1. 唯一ID这件事到底在解决什么问题1.1 从一次订单号重复事故说起之前在一个模拟项目里帮朋友排查线上事故现象是订单表里出现了两个一模一样的单号直接导致一笔订单的数据被覆盖。一看代码生成单号的逻辑就一行OD Date.now().toString(36) Math.random().toString(36).slice(2, 8)下单量一上去同一个毫秒内两个请求拿到了相同的时间戳Math.random() 那几位随机数碰巧又相同单号就撞了。这种问题在本地测不出来因为手工点击的频率太低一旦压测到每秒几百上千并发碰撞概率立即暴露。先说一个容易被忽略的事实如果你只是单机单线程的业务数据库自增ID就是完美方案。全局递增、天然有序、性能极好还不用写任何生成逻辑。但系统一旦开始膨胀——多实例部署、分库分表、微服务各自为政数据库自增立刻失灵多个库各自自增会撞号分表之后insert之前拿不到全局ID跨系统生成业务单号更是无从谈起。这也就是为什么“唯一ID生成”会成为一个独立技术话题。1.2 业务对ID的真实要求清单在动手选方案前先别急着抄代码。把业务对ID的需求列成清单你会发现很多矛盾而所有方案的本质都是在这些矛盾里做取舍。需求维度说明典型矛盾唯一性所有节点、所有时间生成的ID不能重复无协调者时只能靠概率或大空间保证趋势递增大致随时间增长方便排序和索引严格单调递增需要中心化分配高性能生成速度不能成为瓶颈中心化方案受网络和单点限制短小存储和展示成本低UUID最短也要32字符长ID则更短无中心依赖任意节点脱离其他服务也能独立生成与数学上的绝对唯一天然冲突信息保密不能从ID反推业务量、时间等信息趋势递增和可预估性与保密性矛盾这张表的核心矛盾在哪在“绝对唯一”和“无中心依赖”这两行。要做到数学意义上的绝对唯一通常需要一个中心节点来分配号段比如数据库自增、Redis的INCR、或者专门的发号服务而完全无中心的方案就必须靠足够大的随机空间、足够精细的时间戳加序号来把碰撞概率压到工程上可以忽略不计。所以“绝对唯一”是一个强主张。真正的工程实践里绝大多数号称唯一ID的算法本质都是概率性的。UUID v4是随机的128位碰撞概率在2的122次方分之一量级比宇宙中原子数量的倒数还小雪花算法是时间戳加机器号加序列号碰撞的前提是同一毫秒同一台机器上序列号溢出4096。严格说它们都不是数学绝对但在工程上没人会质疑它们。2. 候选方案踩点UUID、时间戳拼接、雪花2.1 UUID一行代码背后有大额成本UUID通用唯一标识符大概是满足“一行代码”最直白的答案。Python写起来是这样import uuid uid uuid.uuid4().hexJavaScript更短const uid crypto.randomUUID();一行搞定不用协调任何节点离线也能用碰撞概率几乎为零。但它的代价全藏在“用了之后”里。UUID v4是128位随机值转成字符串通常是36个字符含连字符算上业务前缀就更长。用它做数据库主键InnoDB的B树索引是按主键顺序维护的随机插入会导致页分裂和碎片化写入放大明显索引体积也跟着变大因为每个二级索引都要复制主键值主键越长整个索引表越庞大。我实测过一个简单场景同一张表分别用自增BIGINT和UUID字符串做主键插入50万行后UUID方案的索引占用大约是自增方案的1.8倍插入耗时也多了将近一倍。这个差距在数据量小的项目里无感一旦上了千万级代价是实打实的。如果你确实想用UUID又不希望完全无序可以看UUID v7它把时间戳放在前面有序性比v4好很多但依赖较新版本的语言库和数据库函数兼容性需要评估。我的结论是UUID适合客户端生成、离线环境、不需要排序和范围查询的字段拿来做核心业务主键除非你的数据量极小否则不划算。2.2 时间戳加随机数不要碰运气我把这类方案单独拿出来说是因为它是新手最喜欢写、踩坑也最多的一种。典型写法是const id Date.now().toString(36) Math.random().toString(36).slice(2);看起来好像每个部分都在变时间在走、随机数在生成组合起来怎么可能一样问题就出在“组合空间不够大”。这个ID的时间部分精度只到毫秒随机部分如果是Math.random()双精度浮点能提供的有效随机位数也不多截取后通常只有几十个bit。碰撞概率里有一个著名的生日悖论在总空间为N的情况下生成M个ID出现至少一次碰撞的概率约等于M的平方除以2N。如果随机部分只有40bit的熵即N等于2的40次方那么生成大约6万次ID碰撞概率就会超过50%。实际场景里只要并发稍微上去几万次生成也就是几分钟的事。我见过的好几个线上事故追到根因都是这行“看起来没问题”的代码。这类方案的定位应该是短时效的会话ID、临时文件名、测试数据标记用于这些场景是可以的。把它用在订单号、消息ID、日志ID这些需要长期唯一和全局排序的字段上等于把系统稳定性交给运气。2.3 雪花算法为什么能成为统一答案如果说唯一ID问题有一个“标准答案”那大概率是雪花算法Snowflake ID。它的设计思路最早由一家海外社交平台在开源社区里提出核心是“时间戳 机器ID 序列号”三段拼出一个64位整数既保序又无中心依赖单机生成速度极快。标准布局是这样的最高1位是符号位固定为0接下来41位是毫秒级时间戳相对一个自定义纪元再接下来10位是机器ID用来区分不同的服务实例最后12位是同一毫秒内的序列号。总共64位在Java里就是一个long。为什么它能成为“统一答案”因为它正好避开了前面两种方案的致命弱点相比UUID它只有8字节而且趋势递增对数据库索引非常友好相比时间戳加随机数它的碰撞空间是精确设计的——同一毫秒内最多4096个序列号超出后阻塞到下一毫秒从机制上堵住了“高并发撞号”的路。在业界现在主流的高性能ID方案基本都能看到它的影子。很多开源中间件的ID生成器像某些分布式数据库内置的ID策略、微服务框架里的默认发号器底层骨架都是这个结构只是机器位的分配方式、序列号的溢出策略做了不同变体。理解了最原始的雪花算法看其他变体基本就是一两分钟的事。3. 一行代码实现雪花ID的完整实操3.1 三种语言的核心实现说回标题本身。真正的一行代码调用其实长这样long id SnowflakeId.nextId();但这背后必须有一个设计正确的生成器。完整实现不一定要多复杂关键是几个细节都得处理到位。先看一个Java版本的简洁实现我逐行解释。public class SnowflakeId { private static final long TWEPOCH 1700000000000L; // 自定义纪元2023-11-15 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); // 1023 private static final long SEQUENCE_MASK ~(-1L SEQUENCE_BITS); // 4095 private static final long WORKER_ID_SHIFT SEQUENCE_BITS; private static final long TIMESTAMP_SHIFT SEQUENCE_BITS WORKER_ID_BITS; private final long workerId; private long sequence 0L; private long lastTimestamp -1L; public SnowflakeId(long workerId) { if (workerId 0 || workerId MAX_WORKER_ID) { throw new IllegalArgumentException(workerId超出范围); } this.workerId workerId; } public synchronized long nextId() { long timestamp System.currentTimeMillis(); if (timestamp lastTimestamp) { throw new IllegalStateException(系统时钟回拨拒绝生成ID); } if (timestamp lastTimestamp) { sequence (sequence 1) SEQUENCE_MASK; if (sequence 0) { // 当前毫秒的4096个序号用完了自旋等待下一毫秒 while (timestamp lastTimestamp) { timestamp System.currentTimeMillis(); } } } else { sequence 0L; } lastTimestamp timestamp; return ((timestamp - TWEPOCH) TIMESTAMP_SHIFT) | (workerId WORKER_ID_SHIFT) | sequence; } }核心逻辑就三个分支时间戳与上一次相同说明还在同一毫秒内序列号加一序列号加到4096溢出说明这一毫秒的配额用完了死等下一毫秒时间戳比上一次大说明跨毫秒了序列号重置为0。最后把时间戳、机器ID、序列号各自移位到对应bit位上按位或拼接成一个64位long。Python版本用闭包实现类似效果import time def create_snowflake_id_generator(worker_id1): last_ts 0 seq 0 twepoch 1700000000000 def next_id(): nonlocal last_ts, seq ts int(time.time() * 1000) if ts last_ts: raise RuntimeError(时钟回拨) if ts last_ts: seq (seq 1) 4095 if seq 0: while ts last_ts: ts int(time.time() * 1000) else: seq 0 last_ts ts return ((ts - twepoch) 22) | (worker_id 12) | seq return next_idJavaScript由于Number类型无法安全表示大整数必须用BigIntconst createSnowflakeId (() { let lastTs 0n, seq 0; const twepoch 1700000000000n; return (workerId 1n) { let ts BigInt(Date.now()); if (ts lastTs) throw new Error(时钟回拨); if (ts lastTs) { seq (seq 1) 4095; if (seq 0) { while (ts lastTs) ts BigInt(Date.now()); } } else { seq 0; } lastTs ts; return ((ts - twepoch) 22n) | (workerId 12n) | BigInt(seq); }; })();调用方式就真的是“一行代码”了。但请注意一个重要前提每台机器、每个进程必须分配不同的workerId机器ID否则多个节点同时生成时时间戳和序列号一样就会撞号。机器ID怎么分配是雪花算法落地时最容易被忽视的环节。3.2 参数设计41位、10位、12位是怎么算出来的很多文章只说布局不说为什么是这个位数。理解了这个你才能根据自己业务去调整参数。41位时间戳能表示的范围是2的41次方毫秒约等于2.2万亿毫秒折算成年大概是69.7年。也就是说以2023年11月这个自定义纪元为起点这个算法用到2093年才会时间戳溢出。如果你的公司计划活过这个年限可以把时间戳位数扩到42位甚至更多代价是压缩其他段的位数。10位机器ID可以表示1024个节点。如果你的系统就是几十台机器10位绰绰有余如果节点数过千就需要把时间戳之外的位数重新分配比如用12位机器ID或者利用序列号高位来扩容。12位序列号意味着同一毫秒可以生成4096个ID。换算一下4096乘以1000毫秒单机每秒理论吞吐约409万。绝大多数业务远用不到这个量级。如果碰到双十一那种瞬时洪峰在毫秒内序列用完后会自旋等待表现为CPU有一个短暂的忙等窗口。这时候有两个选择把序列号扩到13位、14位或者在应用层做一号段预取把生成压力平摊到多毫秒。选择2的幂作为位数不是随意为之是因为位运算里按位与、移位、掩码都依赖2的幂你不想为了凑一个“看起来顺眼”的十进制数字去牺牲位移效率。4096这个数在二进制里是0001000000000000掩码是4095运算干净利落。3.3 时钟回拨唯一ID翻车的头号真凶做唯一ID方案时钟回拨是个绕不开的话题。机器上的时间不是永远往前走的NTP时间同步可能小幅度调整运维手动改时间可能大幅倒拨虚拟机休眠恢复后时间可能落后。一旦系统时间退回过去而生成器已经记住了之前的lastTimestamp新ID的时间戳部分就会与历史ID重叠机器ID和序列号相同的情况下就必然重复。处理时钟回拨有四种常见策略我按推荐程度排一下第一小回拨容忍加自旋。回拨在几十毫秒以内让线程等lastTimestamp追上来再继续大多数NTP调整都能这样扛过去。第二大回拨直接抛异常。回拨超过一定阈值说明系统时间异常宁可报错也不吐出风险ID这是最稳妥的fail-fast思路。第三回拨期间用备用序列号空间。比如预留几个bit的特殊序列值检测到回拨时改用备用序列但空间有限。第四多套ID生成器切换。检测到回拨就切到另一套生成器那套生成器用自己的时间源现实中极少见通常属于过度设计。我个人建议普通业务用“小回拨自旋 大回拨抛异常”的组合就够了。千万不要在回拨时默默地用lastTimestamp继续生成那是把问题从“偶尔报错”变成“静默数据重复”后者的破坏力大得多。4. 实测、排查与生产细节4.1 并发实测409万/秒是怎么测出来的光讲理论不够我实际用一个Java实现做了并发压测。环境是8核16G的普通云服务器开100个线程每个线程连续生成1万个ID总共100万次。用ConcurrentHashMap当去重容器跑完统计size是否为100万来验证唯一性顺带记录耗时。结果符合预期100万次生成加去重校验的总耗时在300毫秒左右纯生成部分更是只有约250毫秒。也就是说单机这版实现在常规环境下每秒能轻松生成300万到400万个ID瓶颈反而不在生成器本身而在System.currentTimeMillis的调用开销和线程竞争上。这个数据基本贴近理论值的409万/秒说明实现没有明显性能缺陷。比起吞吐更值得关注的是对数据库的影响。同样是主键UUID字符串要占36个字符BIGINT只要8字节。InnoDB里二级索引每行都要存主键值UUID做主键等于把每个索引的体积都放大好几倍。而雪花ID是趋势递增的新记录基本往B树末尾追加页分裂少插入性能接近自增主键。这个差异在千万级数据量的表上非常明显我建议你建表时直接把主键定为BIGINT别再用VARCHAR存ID。4.2 问题排查速查表真实生产中遇到的ID问题九成都能归到下面这张表里故障现象可能原因排查方法解决方案出现重复ID时钟回拨对比生成时间与系统时间回拨自旋/抛异常不同节点生成重复ID机器ID配置重复检查各实例配置用注册中心统一分配机器ID生成速度突然变慢序列号溢出自旋观察毫秒级时间戳是否停滞扩展序列号位数或号段预取ID趋势递增但无序多实例并行按时间戳排序观察接受趋势递增不必追求严格有序前端拿到的ID变化JS数字精度丢失浏览器控制台打印API层转成字符串返回ID比预期长很多用了十进制long观察ID位数需要短ID时做Base62编码前两类是最常踩的雷尤其是机器ID重复。读者在自己搭建环境时经常把workerId硬编码成1所有实例都一样测试时因为并发量低没暴露一上生产就翻车。机器ID的正确分配方式是按应用实例唯一标识去注册中心拿一个全局递增编号或者至少在部署脚本里通过环境变量注入每个实例不同。4.3 前端精度与存储类型高手才会注意的暗坑雪花ID是一个64位有符号整数最大约9.2乘以10的18次方。而JavaScript的Number安全整数范围是2的53次方减1也就是约9千万亿。雪花ID只要跑上几年时间戳部分迅速累积很多值会直接超过这个范围。这个坑的经典表现是后端返回ID为1234567890123456789前端console里打印出来却变成1234567890123456700末尾几位对不上。用户看不出来但当你把这个ID再传回后端查询时数据就查不到了。解决方式很简单API层在序列化时把Long字段转成字符串或者前端用string类型接收。JSON库一般都有自定义序列化配置直接把所有Long类型输出为String是常见的做法修改成本极低收益极高。数据库存储方面同样要注意。主键和业务ID列建议用BIGINT占8字节别整成VARCHAR(64)。VARCHAR不仅浪费空间还会让索引比较变成字符串逐字符比较性能明显差于整数比较。还有一点容易忽略雪花ID具有趋势递增性如果你在乎业务信息保密不要把原ID直接暴露在URL里否则别人可以通过ID差值估算你的订单量和时间分布。对外展示时可以做Base62编码或者干脆业务层另行生成一串不透明的短码。5. 从“够用”到“绝对唯一”的扩展路径5.1 让ID再短一点Base62压缩方案64位的long转成十进制最长是19位转成16进制是16位而转成Base620-9、a-z、A-Z共62个字符只需要11位左右。同样的信息量展示长度直接从19个字符压到11个字符这在一张订单、一个短链接、一条消息里都是肉眼可见的体验提升。实现也不复杂核心就是除62取余把10进制整数转换成62进制字符串BASE62 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ def encode_base62(num): if num 0: return BASE62[0] chars [] while num 0: num, rem divmod(num, 62) chars.append(BASE62[rem]) return .join(reversed(chars))反过来解码就是把字符串按位乘62累加。这里要注意字符序不要随意调整一旦发布出去再改字符序会导致历史ID全部解码错误这是Base62方案最隐蔽的坑。如果你只是想缩短长度也可以用64进制但要小心避开容易被视觉混淆的字符比如0和O、1和l。5.2 数学意义上的绝对唯一怎么做如果业务真的要求“绝对唯一”比如资金流水号、监管上报标识那么纯粹的雪花算法是不够的因为你不能接受任何概率空间下的碰撞。这种场景下的标准做法是中心化分配。最朴素的是数据库序列建一张专门发号表每次生成ID都走一个原子操作的UPDATE或INSERT拿到自增号段。优点是绝对唯一、有序缺点是有单点瓶颈高并发时数据库压力巨大。改进方案是号段模式每次取一批号段到本地用一个号发一个用完再申请中间件通过这种方式把数据库压力降低几个量级但引入了号段协调的复杂度。另一种常见做法是用Redis的INCR命令。INCR是原子的天然唯一性能也够但依赖Redis的可用性Redis挂了发号服务就瘫了。所以你发现没有“绝对唯一”和“无中心依赖”这两者本质是对立的。中心化方案牺牲可用性和性能来换确定性分布式方案牺牲确定性来换扩展性和独立性。选择哪个取决于你的业务到底能不能容忍“极小概率的重复”。大部分互联网业务能容忍所以雪花算法成了主流金融监管类业务不能容忍所以宁可接受中心化成本。5.3 别急着造轮子先对齐需求最后想说的是唯一ID方案不是越复杂越好。如果你的系统只有一台数据库、一个应用实例自增主键就是所有问题的最优解。盲目上雪花算法、Redis发号、号段模式除了增加调试成本没有任何收益。有一次我给一个内部工具系统做设计业务方一开始说“要用分布式唯一ID”我反问数据量、部署节点数、并发峰值结果日均请求不到一千单机部署。最后我建议它直接用数据库自增一行代码都没写。业务的真实需求不是“用上分布式唯一ID”而是“ID别重复”那自增已经满足了。做技术选型的核心能力是分得清“大家都在用的方案”和“我当前业务需要的方案”。如果你确实需要分布式唯一ID我给你的起步建议是先按文中的Java版本搭一个雪花生成器手动管理机器ID跑通后再根据压测结果决定要不要引入中心化组件。很多团队一上来就上重型发号中间件最后发现瓶颈根本不在ID生成上白白养了一个高可用组件。轻量起步、按需升级才是这件事的正确打开方式。我个人在实际项目里最后悔的一次就是早期用时间戳加随机数拼了登录会话ID高并发下撞号导致用户被踢下线排查到凌晨才发现是一行“取巧代码”埋的雷。从那以后我对唯一ID的态度就变了不追求代码最短而是追求每一行背后都经得起推敲。雪花算法这套方案我用了很久最大的收获其实不是那几十行代码而是它逼着我理解了时间、并发、位运算和可用性之间的权衡关系。如果你也在选型不妨先拿一张纸把你业务的并发量、节点数、排序需求、保密需求列出来再回头看这些方案答案会自己浮出来。

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

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

免费获取报价 →
↑