)
老炮踩坑录 · D08 · 技术深挖系列· 基于「企业融合平台」真实源码· 逐行拆解手写 Snowflake ID 生成器· 关键词3大坑 · 位运算 · 时钟回拨 · 序列溢出 · workerId撞车 · CPU假死引子面试必问雪花算法这个项目里居然有人手写了2026 年Java 21 LTS 成了生产标配Java 26 都发布了。分布式 ID 这个老话题依然是面试高频考点——分库分表后全局 ID 怎么生成十有八九会引出雪花算法然后面试官追一句“时钟回拨怎么处理”很多人背过 Twitter Snowflake 的 64 位结构面试时能默写1 41 5 5 12 64但真让你打开源码逐行看位运算是怎么拼装的、时钟回拨是怎么检测的、序列溢出是怎么处理的——大部分人都停在 “看懂结构图” 这一步没真正读过一行实现。巧了我翻的这个 2022 年的老项目里有人手写了完整的雪花算法从位结构设计到 Spring 集成一个都不落(la)。今天这篇我就拿着项目里真实的SnowflakeIdGenerator.java逐行拆给你看64 位 long 是怎么一段一段拼出来的synchronized为什么必须加时钟回拨 3 毫秒就能让线上报 500错误先看全貌雪花算法长什么样子打开SnowflakeIdGenerator.java类注释直接画了一张结构图/** * Twitter_Snowflake * SnowFlake的结构如下(每部分用-分开): * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 * 1位标识由于long基本类型在Java中是带符号的最高位是符号位正数是0负数是1 * 41位时间截(毫秒级)注意41位时间截不是存储当前时间的时间截而是存储时间截的差值 * 41位的时间截可以使用69年 * 10位的数据机器位可以部署在1024个节点 * 12位序列毫秒内的计数支持每个节点每毫秒产生4096个ID序号 * 加起来刚好64位为一个Long型。 */publicclassSnowflakeIdGenerator{// ...}注释写得规规矩矩一看就是从 Twitter 原版翻译过来的。但光看注释不够我们得一段一段拆开看。64 位 long 的位结构设计每一比特都不浪费五段划分图示如下段位数作用容量符号位1保证 ID 为正Java long 带符号固定 0时间戳41毫秒级时间差值约 69 年数据中心 ID5区分机房0~31机器 ID5区分同机房节点0~31序列号12同毫秒内递增0~4095起始时间戳为什么是 2015 年privatefinallongtwepoch1420041600000L;// 2015-01-01 00:00:00 UTC注意41 位存的不是System.currentTimeMillis()而是当前时间 - twepoch的差值。为什么因为 41 位最多表示2^41 - 1 2,199,023,255,551毫秒换算成年2,199,023,255,551 / 1000 / 60 / 60 / 24 / 365 ≈ 69.7 年如果从 1970 年算起到 2039 年就溢出了。但从 2015 年算起能撑到2084 年。这就是为什么twepoch很重要——它决定了 ID 生成器的寿命。你完全可以把它设成项目上线的那天最大化利用这 41 位。位移量低位段位数之和 高位段移位量privatefinallongsequenceBits12L;privatefinallongworkerIdShiftsequenceBits;// 12privatefinallongdatacenterIdShiftsequenceBitsworkerIdBits;// 17privatefinallongtimestampLeftShiftsequenceBitsworkerIdBitsdatacenterIdBits;// 22发现规律了吗每一段的左移量等于它右边所有段的位数之和。序列号不移位最右边 机器ID左移 12 位右边有 12 位序列号 数据中心左移 17 位右边有 12 5 17 位 时间戳左移 22 位右边有 12 5 5 22 位这样设计的好处是每段在 64 位 long 中各占各的位置互不干扰最后用按位或|拼装即可。就像五条铁轨每列火车走自己的道永远不会撞车。掩码的妙用用位运算代替 if 判断最大值计算privatefinallongmaxWorkerId-1L^(-1LworkerIdBits);// 31privatefinallongmaxDatacenterId-1L^(-1LdatacenterIdBits);// 31privatefinallongsequenceMask-1L^(-1LsequenceBits);// 4095第一次看这段代码的人大概率会懵-1L ^ (-1L 5)是什么鬼别急拆成二进制看就清楚了-1L 的二进制 11111111 11111111 ... 1111111164 个 1 -1L 5 11111111 11111111 ... 11100000低 5 位变 0 异或^ 00000000 00000000 ... 00011111只有低 5 位是 1 31所以-1L ^ (-1L n)的效果就是生成一个低 n 位全为 1 的掩码。5 位掩码 0b11111 31workerId / datacenterId 的最大值12 位掩码 0b111111111111 4095序列号的最大值序列号溢出检测这是整段代码里最精妙的一行sequence(sequence1)sequenceMask;当sequence 4095时(4095 1) 4095 4096 4095 0b1000000000000 0b111111111111 0加 1 后与掩码做按位与超过 4095 自动归零。不需要if (sequence 4095) sequence 0一条位运算搞定。这就是位运算的魅力把 “判断 赋值” 两步操作压缩成了一步这在高频调用的场景下性能差异是实打实的高。核心算法 nextId()逐行拆解终于到了分析最核心的逻辑方法了。核心源码我把完整源码贴出来并逐行加上注释publicsynchronizedlongnextId(){// ① 获取当前时间戳毫秒longtimestamptimeGen();// ② 时钟回退检测当前时间 上次生成 ID 的时间 → 抛异常if(timestamplastTimestamp){thrownewRuntimeException(String.format(Clock moved backwards. Refusing to generate id for %d milliseconds,lastTimestamp-timestamp));}// ③ 同一毫秒内的序列处理if(lastTimestamptimestamp){// 同一毫秒 → 序列号 1掩码保证不超过 4095sequence(sequence1)sequenceMask;// 序列溢出回到 0→ 自旋等到下一毫秒if(sequence0){timestamptilNextMillis(lastTimestamp);}}// ④ 新的毫秒 → 序列重置为 0else{sequence0L;}// ⑤ 记录本次时间戳lastTimestamptimestamp;// ⑥ 位运算拼装 64 位 IDreturn((timestamp-twepoch)timestampLeftShift)// 时间戳差值左移 22 位|(datacenterIddatacenterIdShift)// 数据中心 ID 左移 17 位|(workerIdworkerIdShift)// 机器 ID 左移 12 位|sequence;// 序列号不移位}六个步骤nextId()方法的六大步骤流程图举个计算例子假设timestamp - twepoch 1000, datacenterId 20, workerId 10, sequence 5时间戳部分1000 22 4,194,304,000 数据中心 20 17 2,621,440 机器 ID 10 12 40,960 序列号 5 5 ──────────────────────────────────────── 按位或拼装 4,196,966,405每段井水不犯河水按位或 拼装。因为每段的二进制位互不重叠所以|运算等价于加法但语义更清晰——我是在组装不是在相加。自旋等待tilNextMillis()protectedlongtilNextMillis(longlastTimestamp){longtimestamptimeGen();while(timestamplastTimestamp){timestamptimeGen();// CPU 空转浪费时间片}returntimestamp;}序列号用完了怎么办死循环等等到下一毫秒再来。这个while循环叫busy-wait忙等待——CPU 在空转什么都不干就反复问到下一毫秒了没。在正常情况下一毫秒很快就过去了循环个几十到几百次就拿到新时间戳。但在高并发场景下如果每毫秒都在溢出这个忙等待会吃掉 CPU 时间片是个性能隐患。如何解决呢 可以参考美团 Leaf 在生产环境验证过的方案。你有其它更好的方案吗欢迎在评论区讨论。Spring 集成四层配置链光有算法不够还得接入 Spring。这个项目的做法是标准的三层架构。完整调用链路和架构图第一层yml 配置# application.ymlpubframe:id:workId:10# 当前机器的工作 IDcenterId:20# 当前数据中心 ID第二层配置属性类ConfigurationProperties(prefixpubframe.id)publicclassIdGenProperties{privatelongworkId0;// 工作机器ID (0~31)privatelongcenterId0;// 数据中心ID (0~31)// getter/setter/toString...}ConfigurationProperties把 yml 里的值自动绑到 Java 对象上。注意默认值都是 0——如果 yml 没配两台机器都会拿到workId0, centerId0。第三层自动注册 BeanSlf4jConfigurationEnableConfigurationProperties(IdGenProperties.class)publicclassAutoConfiguration{BeanConditionalOnMissingBean(IdGenProperties.class)publicSnowflakeIdGeneratorsnowflakeIdWorker(IdGenPropertiesproperties){SnowflakeIdGeneratorgeneratornewSnowflakeIdGenerator(properties.getWorkId(),// workId 10properties.getCenterId()// centerId 20);log.info(bean [{}] properties [{}],generator,properties);returngenerator;}}Bean把SnowflakeIdGenerator注册为 Spring 容器里的 BeanConditionalOnMissingBean保证如果你没自定义就用默认的。看起来很美对吧配置、绑定、注册三层各司其职开箱即用。看到这里你可能觉得这个实现挺规范的。别急坑在后面我们一个一个的分析并解决。三个坑一个比一个狠⚠️ 坑 1时钟回拨直接抛异常3 毫秒就 500if(timestamplastTimestamp){thrownewRuntimeException(String.format(Clock moved backwards. Refusing to generate id for %d milliseconds,lastTimestamp-timestamp));}时钟一回拨直接throw RuntimeException。你知道这意味着什么吗生产环境的 NTP 服务每隔一段时间会校准时间回拨几毫秒是家常便饭。只要回拨发生任何调用nextId()的业务代码就会收到一个RuntimeException如果没有 catch就是 500 错误。正确的做法是什么分级处理if(timestamplastTimestamp){longoffsetlastTimestamp-timestamp;if(offset5){// 5ms 以内的微小回拨等它追上来Thread.sleep(offset1);// 等两倍时间留余量timestamptimeGen();if(timestamplastTimestamp){thrownewRuntimeException(Clock still moving backwards);}}elseif(offset100){// 100ms 以内的中等回拨用上次的时间戳继续生成序列号递增timestamplastTimestamp;}else{// 超过 100ms 的大回拨真的出问题了抛异常thrownewRuntimeException(Clock moved backwards too much: offsetms);}}总结小回拨等一等中回拨借用上次时间戳硬撑大回拨才抛异常。这才是生产级的处理方式。⚠️ 坑 2workerId 写在 yml 里两台机器配了一样的 IDpubframe:id:workId:10#运维拷配置的时候忘记改了centerId:20workId和centerId直接写死在配置文件里。想象一下这个场景运维要部署两台机器拷了一份配置文件改改数据库地址就上了。结果workId忘了改——两台机器都是workId10, centerId20。后果是什么两台机器在同一毫秒内用相同的序列号生成完全一样的 ID。雪花算法的 “全局唯一” 保证是建立在 “每台机器的 workerId 不同” 这个前提上。workerId 撞了唯一性就崩了。正确的做法是什么让 workerId 自动分配而不是靠人记着改配置方案一从 ZooKeeper / Redis 自动获取启动时注册临时节点拿到全局唯一的 workerIdBeanpublicSnowflakeIdGeneratorsnowflakeIdWorker(IdGenPropertiesproperties,ZookeeperClientzkClient){// 启动时从 ZK 占一个临时节点longworkerIdzkClient.createEphemeralSequential(/snowflake/worker-);longdatacenterIdproperties.getCenterId();// 机房级别还是配returnnewSnowflakeIdGenerator(workerId,datacenterId);}方案二基于 IP 地址末两段计算int workerId (ip[2] 0x1F); // 取 IP 第三段低 5 位 int centerId (ip[3] 0x1F); // 取 IP 第四段低 5 位总之凡是靠人记着改的配置迟早会有人忘记修改。⚠️ 坑 3synchronized 全局锁高并发下排队publicsynchronizedlongnextId(){注意这个synchronized——它锁的是整个SnowflakeIdGenerator实例对象。也就是说不管有多少个线程同时调用nextId()都得排队一个一个来。在单机低并发场景下这没什么问题。但如果你的服务每秒要生成几万个 ID所有线程都在这一把锁上排队这里就成了整个系统的吞吐量瓶颈。有没有更好的方案有——用 CASCompare-And-Swap无锁实现思路用 AtomicLong 代替 synchronized。把 lastTimestamp 和 sequence 打包成一个 long 用 CAS 操作原子更新失败就重试。publiclongnextId(){longcurrent;longnext;do{currentlastTimestamp.get();// ... 计算 next}while(!lastTimestamp.compareAndSet(current,next));returnnext;}但 CAS 方案实现复杂度高很多需要压测验证是否值得做。对于这个项目的并发量synchronized其实就够用——但是你要知道它的天花板在哪里。雪花算法面试怎么答面试官问说说雪花算法你可以这样答第一层结构Snowflake 把 64 位 long 分成五段——1 位符号位、41 位时间戳差值、5 位数据中心、5 位机器 ID、12 位序列号。时间戳保证趋势递增机器 ID 保证分布式不撞车序列号保证同毫秒内不重复。第二层核心操作每段左移量等于右边所有段的位数之和最后按位或拼装。序列号溢出用掩码(seq1) mask检测归零后自旋等下一毫秒。第三层生产问题最大风险是时钟回拨。生产环境 NTP 校时可能导致几毫秒的回拨直接抛异常会导致 500错误。正确做法是分级处理——小回拨等待、中回拨借用时间戳、大回拨降级到号段模式。另外 workerId 不能写在配置文件要靠注册中心自动分配。生产环境 Checklist检查项常见坑正确做法时钟回拨直接抛异常 → 500分级处理等待 / 借用 / 降级workerId 分配yml 写死 → 撞 IDZK/Redis 自动分配起始时间戳用 1970 年 → 2039 溢出设为项目上线日期线程安全无锁 → ID 重复synchronized 或 CAS启动校验不检查 → 大回拨流入线上持久化 lastTimestamp启动时比对监控告警无感知 → 出问题才知道回拨次数/幅度接入 Prometheus要不要自己写我的建议是不要自己写。美团 Leaf号段模式 Snowflake 双模式生产验证ShardingSphere 内置配合分库分表直接用MyBatis-PlusIdType.ASSIGN_ID默认就是雪花算法Spring 生态spring-context-support里有现成实现自己写适合学习和面试生产环境还是用成熟的开源方案——它们帮你处理了你没想到的边界情况。老炮点评这篇文章我们从一个真实项目的手写雪花算法出发我们看到了位结构设计的精妙41 位时间戳 10 位机器标识 12 位序列号64 位 long 刚好用完每一比特都不浪费。位运算的性能之美掩码检测溢出、移位拼装 ID用代替if用|代替一条指令搞定。时钟回拨的残酷现实算法假设时间是单调向前的但物理世界的时钟会被 NTP 校准、被闰秒打乱、被运维手动改。3 毫秒的回拨就能让线报 500错误。配置化 workerId 的隐患靠人记着改的配置迟早有人忘记改。synchronized 的天花板简单有效但高并发下是瓶颈。最后送一句话分布式系统里没有银弹雪花算法也不例外。它用对时间的依赖换来了简单和高效但时间本身是不可靠的——这就是工程世界的永恒矛盾。在·下一篇预告《workerId 配错两台机器生成了一样的 ID》这篇文章里我说了workerId 写在 yml 里迟早有人忘记改——这不是假设是真实发生过的生产事故。下一篇我把这个坑的前因后果完整讲一遍怎么发现的、怎么排查的、怎么修的。我是老炮18 年 Java 老兵仍在一线。关注「老炮踩坑录」真实案例帮你少踩坑。