原理解析
雪花算法实现简单、适配性强,无论是电商订单、日志追踪还是分布式存储,都能满足 “唯一、有序、高效、可扩展” 的核心需求,因此成为分布式id主流选择。雪花算法生成的id是一个64位的整数,由多段不同意义的数字拼接而成,这种分段设计让每个id既带着时间印记,又能规避多机器冲突,就像身份证通过地址码、出生日期码、顺序码等分段信息实现全国唯一标识,既有序又精准。
符号位 + 时间戳 + 数据中心id + 机器id + 序列号
- 符号位(1位):始终为0(表示正数)。这保证了生成的 id 是正整数。
- 时间戳(41位):雪花算法的核心部分, 记录生成id时的毫秒级时间戳(当前时间减去起始时间的差值),该部分保证了id的大体有序性。41位能表示的时间范围约为 2^41 毫秒 ≈ 69年。使用一个最近的起始时间(如 2025-01-01
00:00:00),可以大幅减少时间戳占用的位数。 - 数据中心id(5位):用于标识生成id的逻辑数据中心,允许最多 2^5 = 32个数据中心。
- 工作节点id(5位):用于标识数据中心内的具体工作节点(机器、服务进程、pod 等),允许每个数据中心最多 2^5 =
32个工作节点。在实际开发中,数据中心(高位)+
工作节点(低位)经常被视为一个整体10位的机器id,用于标识集群中的唯一节点(机器/服务实例),最多允许 2^10 =
1024个唯一节点。 - 序列号(12位):用来解决同一节点在同一毫秒内生成多个 id
时的冲突问题。每个节点在每毫秒内都可以独立地从0开始递增生成序列号,当序列号用完(达到
4095)后,会强制等待到下一毫秒再继续生成。对于12位序列号,单节点每毫秒最多生成4096个id,要达到这个并发量很极端(单节点超过400万qps),现实中很难溢出。
以下为java实现的雪花算法代码示例(未考虑时钟回拨),起始时间决定了算法能生成id的有效时长,通常将起始时间设为项目上线日期。
public class snowflakeidgenerator {
// 起始时间戳,这里以2025-07-01 00:00:00为基准
private final long starttimestamp = 1751299200000l;
// 机器id所占位数
private final long workeridbits = 5l;
// 数据中心id所占位数
private final long datacenteridbits = 5l;
// 序列号所占位数
private final long sequencebits = 12l;
// 机器id最大值 31
private final long maxworkerid = -1l ^ (-1l << workeridbits);
// 数据中心id最大值 31
private final long maxdatacenterid = -1l ^ (-1l << datacenteridbits);
// 机器id向左移位数
private final long workeridshift = sequencebits;
// 数据中心id向左移位数
private final long datacenteridshift = sequencebits + workeridbits;
// 时间戳向左移位数
private final long timestampshift = sequencebits + workeridbits + datacenteridbits;
// 序列号掩码 4095
private final long sequencemask = -1l ^ (-1l << sequencebits);
// 工作机器id
private final long workerid;
// 数据中心id
private final long datacenterid;
// 序列号
private long sequence = 0l;
// 上次生成id的时间戳
private long lasttimestamp = -1l;
// 构造函数
public snowflakeidgenerator(long workerid, long datacenterid) {
if (workerid > maxworkerid || workerid < 0) {
throw new illegalargumentexception("worker id 不能大于 " + maxworkerid + " 或小于 0");
}
if (datacenterid > maxdatacenterid || datacenterid < 0) {
throw new illegalargumentexception("数据中心 id 不能大于 " + maxdatacenterid + " 或小于 0");
}
this.workerid = workerid;
this.datacenterid = datacenterid;
}
// 生成下一个id
public synchronized long nextid() {
long currenttimestamp = system.currenttimemillis();
if (currenttimestamp == lasttimestamp) {
sequence = (sequence + 1) & sequencemask;
if (sequence == 0) {
// 当前毫秒内序列号已用完,等待下一毫秒
currenttimestamp = waitnextmillis(lasttimestamp);
}
} else {
// 时间戳改变,重置序列号
sequence = 0l;
}
lasttimestamp = currenttimestamp;
// 按规则组合生成id
return ((currenttimestamp - starttimestamp) << timestampshift) |
(datacenterid << datacenteridshift) |
(workerid << workeridshift) |
sequence;
}
// 等待下一毫秒
private long waitnextmillis(long lasttimestamp) {
long timestamp = system.currenttimemillis();
while (timestamp <= lasttimestamp) {
timestamp = system.currenttimemillis();
}
return timestamp;
}
// 测试示例
public static void main(string[] args) {
snowflakeidgenerator idgenerator = new snowflakeidgenerator(1, 1);
for (int i = 0; i < 10; i++) {
system.out.println(idgenerator.nextid());
}
}
}
为什么会出现重复id?
雪花算法虽代码量少、实现简单,却并非万无一失。不少研发人员常常直接从网上拷贝现成的工具类,或是用大模型生成代码后直接用于生产环境 —— 直到某天突然收到用户反馈:自己账号的数据出现了错乱,明明只买了一件衣服,订单却显示多个其他辣眼的商品,还附带陌生的收货地址。手忙脚乱一顿排查后,竟发现数据库中出现了少量订单sn重复的异常数据,不由得心生疑惑:雪花算法不是每毫秒能生成4096个不重复编号吗?订单服务部署了十几个节点,但业务量真有这么大吗?到底为什么会出现重复呢?我们一起来一探究竟。
机器id重复
为什么重复:
多个运行的节点使用相同的数据中心id(datacenter-id)和工作节点id(worker-id)。即在同一毫秒内,如果多个节点的机器id相同、系统时间戳相同,序列号就可能从相同起点开始分配并重叠,导致生成完全相同的id三元组
(时间戳, 机器id, 序列号)。
典型现象
多数研发人员会将数据中心id和工作节点id硬编码在代码中,或在配置文件里设置了相同的
datacenter-id与worker-id,这直接导致无论部署多少个节点,机器id都完全一致。
如何解决:
核心原则必须确保整个分布式集群中,任何两个同时工作的节点,它们的 (数据中心id, 工作节点id) 二元组(或者将二者视为10位合并的“机器id”)必须是唯一的!
(1)手动配置文件:在启动服务前,为每个节点的配置文件(如 application.properties, application.yml, configmap 等)显式配置一个唯一的 datacenter-id 和 worker-id。该方案简单直观,但繁琐,易出错(配置冲突),适合小型、静态集群,不适用于节点动态伸缩的集群。
(2)系统环境变量:在部署节点(物理机、虚拟机、容器)时,通过启动脚本、容器编排系统(如k8s deployment/statefulset 的env)为每个实例设置唯一的 snowflake_datacenter_id和snowflake_worker_id环境变量,服务启动时读取这些环境变量。
(3)利用基础设施的唯一性:
kubernetes statefulset会为每个 pod
分配一个固定且有序的唯一索引(从0开始)。比如名为snowflake-app的statefulset有3个pod:snowflake-app-0,
snowflake-app-1, snowflake-app-2。应用程序可以读取 spec.podname(通常是
hostname环境变量),解析末尾的数字索引,将这个索引直接用作工作节点id。若业务需要扩容至超过worker-id最大阈值(如32个以上pod),直接使用索引会导致worker-id重复,需结合数据中心id(datacenter-id)拆分(如用 statefulset 名称哈希作为datacenter-id)。公有云(如阿里云、华为云、腾讯云ecs)会为每个虚拟机实例分配一个唯一id,pod(如deployment)运行时也有自己的id。应用程序可以在启动时通过查询实例/容器的元数据服务获取这个唯一id,然后对这个较长的id进行哈希并取模,映射到可用的datacenter-id和worker-id范围内(如总id%1024,得到 0-1023的一个值)。该方案需要依赖特定平台的 api/服务。哈希取模存在极小冲突风险,需要设计好映射逻辑。
利用ip地址 (网络标识):应用程序直接获取其运行环境(pod、容器、虚拟机、物理机)的ip地址,对整个ip地址字符串或二进制表示计算哈希值取模,然后取模,映射为datacenter-id和worker-id。在kubernetes 中,在kubernetes中,pod通常可以通过status.podip获得,deployment pod重建通常会获得新ip;虚拟机/物理机ip也可能因维护、迁移或网络配置变更而改变。该方案同样存在极小概率id冲突,且需容忍获取ip的性能开销和失败风险。
// 获取机器id
private static long getnodeid() {
try {
inetaddress address = findfirstnonloopbackaddress();
string ip = address.gethostaddress();
int hash = ip.hashcode();
// 确保非负数并取模最大节点id
long nodeid = (hash & 0x7fffffff) % (max_node_id + 1);
system.out.println("使用ip地址: " + ip + " 生成机器id: " + nodeid);
return nodeid;
} catch (exception e) {
// 异常时随机生成节点id
long nodeid = new random().nextint((int) (max_node_id + 1));
system.out.println("获取ip失败,随机生成机器id: " + nodeid);
return nodeid;
}
}
// 查找第一个非环回ipv4地址
private static inetaddress findfirstnonloopbackaddress() throws socketexception {
enumeration<networkinterface> interfaces = networkinterface.getnetworkinterfaces();
while (interfaces.hasmoreelements()) {
networkinterface iface = interfaces.nextelement();
if (iface.isloopback() || iface.isvirtual() || !iface.isup()) {
continue;
}
enumeration<inetaddress> addresses = iface.getinetaddresses();
while (addresses.hasmoreelements()) {
inetaddress addr = addresses.nextelement();
if (addr instanceof inet4address && !addr.isloopbackaddress()) {
return addr;
}
}
}
throw new runtimeexception("未找到非环回ipv4地址");
}
(4)外部协调服务:使用分布式协调服务(如zookeeper, etcd, redis, 数据库)来注册节点并分配唯一的机器 id。如leaf-snowflake改进了雪花算法,机器id由zookeeper协调分配、百度uid generator启动时向db注册节点分配唯一worker_id。
流程示例:
- 节点启动时,连接到协调服务。
- 如果节点宕机或与协调服务断开连接(session超时),协调服务会自动删除其对应的临时节点,该机器id被释放,可以被新节点申请使用。
- 节点将这个唯一的序号作为它的机器id(或从中计算 datacenter-id 和 worker-id,如序号 % 1024)。序号在服务运行期间保持不变。
- 节点读取自己创建的节点的序号(如 0000000005)。
- 协调服务保证创建的有序节点的名称(包含一个单调递增的序号)是唯一的。
- 节点尝试在一个预设的路径下(如 /snowflake/workers)创建一个临时有序节点。
优点: 无需预配置,自动处理节点加入/离开,id分配唯一且可靠,支持大规模集群。
缺点: 增加了外部依赖和复杂度。
(5)设计机器id位数的考虑:默认10位能支持 1024 个节点,对大多数公司规模通常够用。可依据业务规模灵活调整:
- 并发量高但集群规模不大(节点少): 可以减少datacenter-id和worker-id 总位数(比如降到8位甚至更少),把节省出来的位数加到sequence序列号上。这样每个节点每毫秒可以生成更多的id。
- 集群规模巨大(超过1024节点): 需要增加datacenter-id和worker-id总位数(比如设为12位)。这时需要牺牲timestamp或sequence 的位数(如时间戳减到40,序列号减到11位)。牺牲时间戳位数会缩短系统的可用年限;牺牲序列号会降低单节点/毫秒的最大并发量。
时钟回拨
为什么重复:系统时间因为ntp同步失败、闰秒调整、虚拟机/容器挂起恢复、人为设置错误等原因发生了向后跳跃,导致雪花算法生成id时使用了之前已生成id的时间戳部分,进而可能产生重复id。
如何解决:大部分雪花算法的优秀实现都包含了时钟回拨检测和处理机制,如抛出异常、短暂等待、使用备用逻辑。
(1)预防为主:禁止手动时间修改;ntp通过频率调整、分散度控制、时钟筛选、步进限制等机制防止时间回拨,如使用chrony进行平滑时间调整(stepping → slewing)、配置clock slew而非 jump避免突变。
(2)抛出异常:当检测到时钟回拨时,直接抛出异常,停止生成id,等待人工干预或时间恢复正常。该方案简单安全,但影响业务连续性。
//处理时钟回拨
if (currenttimestamp < lasttimestamp) {
throw new clockbackwardexception(
"clock moved backwards. refusing to generate id for " +
(lasttimestamp - currenttimestamp) + " milliseconds");
}
(3)等待时钟恢复(适合毫秒级轻度回拨):若发现回拨,不立即报错,而是阻塞等待 ,直到系统时间 ≥ lasttimestamp。该方案短暂阻塞,可能影响性能。
// 处理时钟回拨
if (currenttimestamp < lasttimestamp) {
long offset = lasttimestamp - currenttimestamp;
// 回拨时间小于1秒,阻塞等待
if (offset <= max_backward_time) {
currenttimestamp = waitforclockrecovery(lasttimestamp);
} else {
// 回拨时间超过1秒,抛出异常
throw new runtimeexception("clock moved backwards too much: " + offset + "ms");
}
}
private long waitforclockrecovery(long lasttimestamp) {
long timestamp = system.currenttimemillis();
while (timestamp < lasttimestamp) {
//短暂休眠避免cpu空转
try {
thread.sleep(1);
} catch (interruptedexception e) {
thread.currentthread().interrupt();
throw new runtimeexception("interrupted while waiting for clock recovery", e);
}
timestamp = system.currenttimemillis();
}
system.out.println("clock recovered after " + (timestamp - lasttimestamp) + "ms");
return timestamp;
}
(4)回拨补偿:通过累积所有历史回拨时间,使生成器内部时间永远领先于系统时间,可避免使用thread.sleep()造成的性能瓶颈。
// 可容忍的最大时钟回拨(毫秒)
private static final long max_backward_ms = 1000;
// 发生时钟回拨
if (currenttimestamp < lasttimestamp) {
long backwardms = lasttimestamp - currenttimestamp;
// 超过容忍阈值则抛出异常
if (backwardms > max_backward_ms) {
throw new illegalstateexception("clock moved backwards by " + backwardms + " ms, exceeding maximum allowed value");
}
// 记录回拨时间用于补偿
clockoffset += backwardms;
// 补偿当前时间戳
currenttimestamp = system.currenttimemillis()+clockoffset;
}
(5)扩展位机制(秒级以上严重回拨):修改雪花算法结构,预留几位用于表示“是否处于回拨状态”或“回拨次数”。当发生回拨时,增加“回拨版本号”,即使时间戳相同,版本不同也能区分id。
到此这篇关于java中雪花算法重复id问题的文章就介绍到这了,更多相关java雪花算法id重复内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论