凌晨两点新上线的结算对账任务第一次全量跑跑了没十分钟就炸了主键冲突刷屏Duplicate entry xxx for key PRIMARY一条接一条事务回滚得像下饺子。查了半天不是表结构问题是我自己写的 ID 生成器在分布式环境里翻了车。这事之后我决定把脚手架里的 ID 生成从能跑就行换成真正能扛的雪花实现踩了一圈坑今天把最终版本和过程都记下来。qkl-boot 里现在用的这套雪花 ID 生成器核心就一句话64 位 long 拆成 1 位符号 41 位毫秒时间戳 10 位机器标识 12 位序列号。单机一毫秒能出 4096 个 ID够大多数业务用了。真正麻烦的不是位分配是三个边角时钟回拨、机器位分配、序列号溢出。一个一个说。先看基础版SnowflakeIdGenerator的核心生成逻辑publicsynchronizedlongnextId(){longcurrentSystem.currentTimeMillis();if(currentlastTimestamp){// 时钟回拨后面单独说thrownewIllegalStateException(Clock moved backwards, refuse to generate id);}if(currentlastTimestamp){sequence(sequence1)SEQUENCE_MASK;if(sequence0){// 当前毫秒序列号用完了自旋等下一毫秒currentwaitNextMillis(lastTimestamp);}}else{// 新的一毫秒序列号重置sequence0L;}lastTimestampcurrent;return((current-START_EPOCH)TIMESTAMP_SHIFT)|(workerIdWORKER_SHIFT)|sequence;}这里SEQUENCE_MASK是4095LTIMESTAMP_SHIFT是 22WORKER_SHIFT是 12。序列号用取模回绕比%快一个量级这种细节在 QPS 高的接口上是有意义的。waitNextMillis就是Thread.sleep或者忙等我用的忙等因为 sleep 的精度在 Windows 上不可靠这坑后面细说。时钟回拨先别急着抛异常网上十篇雪花文章八篇教你在回拨时直接抛异常但我实际跑下来发现抛异常在回拨超过 5ms 的场景里会把一批请求直接打挂特别是容器里跑着 NTP 自动校时的环境回拨 30-50ms 家常便饭。我们的处理是分两档privatelonglastTimestamp-1L;privatestaticfinallongMAX_BACKWARD_MS5L;publicsynchronizedlongnextId(){longcurrentSystem.currentTimeMillis();if(currentlastTimestamp){longbackwardlastTimestamp-current;if(backwardMAX_BACKWARD_MS){// 回拨超过 5ms说明时钟被明显调了抛异常让上层降级thrownewIllegalStateException(Clock moved backwards backwardms);}// 小回拨不生成新时间戳序列号继续在 lastTimestamp 上推进currentlastTimestamp;sequence(sequence1)SEQUENCE_MASK;}// ... 后面逻辑同前}小回拨把current锁在lastTimestamp上序列号接着用不浪费毫秒也不产生重复。只有超过 5ms 的才抛异常配合拦截器统一返回ID_GENERATE_FAILED。这套分级策略上线后校时导致的报错基本绝迹。机器位分配别再用 IP 哈希了说句实在话选型的时候我也犹豫过 UUID 和数据库自增。UUID 字符串 36 位当主键InnoDB 的聚簇索引直接废一半——随机字符串插入时叶子节点频繁分裂5 万行数据之后写入性能肉眼可见往下掉。数据库自增简单但 qkl-boot 是微服务多实例部署每个服务独立库的话自增就重复了还要引入号段或者改双主配置麻烦。雪花 ID 是分布式环境下全局唯一 趋势递增 不带网络请求三个要求平衡下来的选择代价是代码里多一点位运算值得。10 位 workerId 最多 1024 台机器。刚开始图省事用 IP 末两段拼 hash 当 workerId结果两台机器 hash 撞了ID 重复排查了一下午。后来改成配置中心下发# application.ymlqkl:id:worker-id:12# 部署时由运维在配置中心按实例分配0-1023启动时从配置读读不到就抛错快速失败绝不自动生成。1024 台以内的集群够用真超了就把 41 位时间戳和 10 位机器位重新分配那是另一个故事了。序列号耗尽与并发单毫秒 4096 个看着够但预热接口或者秒杀瞬间真的会撞。我们压测过一次注册接口瞬时 5000 QPS直接触发waitNextMillis自旋单机 CPU 拉高 20%。后来把序列号生成换成了 AtomicLong 而不是 synchronized自旋的时候用CAS重试privatefinalAtomicLongsequencenewAtomicLong(0L);publiclongnextId(){// ...longseqsequence.incrementAndGet()SEQUENCE_MASK;if(seq0){currentwaitNextMillis(lastTimestamp);}// ...}synchronized换AtomicLong在高并发下锁竞争直接没了压测数据好看不少。序列号回绕归零就等下一毫秒逻辑上等价实现上轻了。踩坑实录Windows 上 Thread.sleep 的时间漂移现象本地联调时发现 ID 里时间戳部分偶发倒退间隔 1 秒生成的 ID 反而比上一批小主键插入直接报重复。排查过程先看是不是时钟回拨逻辑没生效——打日志发现waitNextMillis里Thread.sleep(1)后System.currentTimeMillis()经常只走了 0.5ms 甚至 0.2ms忙等循环里Thread.yield()也时快时慢。再查是不是多实例 workerId 重复——日志里 workerId 明明不同。最后拿System.nanoTime()对照currentTimeMillis发现是 Windows 上 JVM 的时钟精度问题currentTimeMillis在 Windows 上的分辨率约 10ms 甚至更差sleep(1) 之后时间戳可能原地踏步甚至感知到倒退。定位思路这根本不是时钟回拨是操作系统时间精度 JVM 实现差异导致的假回拨。Linux 上currentTimeMillis精度到毫秒级Windows 上粗得多开发机和生产机的行为天然不一致。最终解决waitNextMillis改用System.nanoTime()做忙等基准循环里Thread.onSpinWait()Java 9JDK 17 正好用上privatelongwaitNextMillis(longlastTimestamp){longtargetlastTimestamp1;while(System.currentTimeMillis()target){Thread.onSpinWait();}returnSystem.currentTimeMillis();}onSpinWait会告诉 CPU 这是自旋等待能省一点功耗也比 yield 稳定。这个问题排查了大半天结论是别在 Windows 上验证时间敏感逻辑生产环境一切以 Linux 为准——听起来像废话但真的踩了才知道。总结清单可直接抄ID 结构1411012START_EPOCH选个固定的历史时间点比如 2023-01-01别用项目启动时间否则 41 位撑不到 2079 年也至少被压缩了使用年限。回拨处理≤5ms 锁时间戳续序列号5ms 抛异常统一降级别一刀切。机器位配置中心下发IP 哈希是给自己埋雷。高并发AtomicLong CAS 替代synchronizedwaitNextMillis用nanoTime忙等。自增主键替代场景雪花 ID 适合写多读少、需要全局唯一、不想依赖 DB 生成器的场景纯单机小项目auto_increment完全够用别为了炫技引入复杂度。项目源码https://gitee.com/gzqkl/qkl-boot