如何生成分布式雪花算法ID
如何生成分布式雪花算法ID
在分布式系统中,常常需要生成全局唯一的ID以防止冲突。尽管36位的UUID能够解决这一问题,但其缺点显而易见:UUID不仅相对较长,而且通常是无序的。
有时我们希望使用一种更为简洁的ID,并且希望这些ID能够按时间顺序生成。
什么是雪花算法
Snowflake,中文称为雪花算法,是Twitter开源的一种分布式ID生成算法。通过该算法生成的ID为64位的长整型数值,其结构中引入了时间戳,从而基本实现了自增。
Snowflake算法的优势
- 高性能高可用:ID生成不依赖数据库,完全在内存中进行。
- 高吞吐量:每秒可以生成数百万个自增ID。
- ID自增:在数据库中存储时,具有较高的索引效率。
Snowflake算法的劣势
- 依赖系统时间的一致性:如果系统时间被回调或修改,可能会导致ID冲突或重复。
雪花算法的结构
Snowflake的结构如下图所示:
Snowflake算法的结构由四个部分组成:
- 不使用位:1bit,最高位是符号位,0表示正,1表示负。在Snowflake算法中,固定为0。
- 时间戳:41bit,表示毫秒级的时间戳(41位的长度可以使用69年)。
- 标识位:5bit的数据中心ID和5bit的工作机器ID,两个标识位组合起来最多可以支持部署1024个节点。
- 序列号:12bit,用于记录同一毫秒内生成的不同ID,支持每毫秒生成4096个不同的ID。
序列号:12bit递增序列号,用于在同一毫秒内生成唯一ID。每个序列号可以在1毫秒内产生4096个ID,因此在1秒钟内可以生成4096 * 1000 = 409万(4.09M)个唯一ID。
默认情况下,雪花算法使用64bit长度,但具体长度可以根据需要进行配置。如果希望系统运行更长时间,可以增加时间戳的位数;如果需要支持更多节点部署,可以增加标识位的长度;如果并发量很高,可以增加序列号的位数。
总结:雪花算法是灵活可定制的,可以根据系统的具体需求进行调整。
2. 雪花算法适用场景
由于雪花算法生成的ID是有序自增的,因此在MySQL中使用B+ Tree索引结构时可以保证高效插入性能。因此,在实际业务中,雪花算法常用于数据库主键ID和业务关联主键的生成。
雪花算法生成ID重复问题
假设:在一个订单微服务中,通过雪花算法生成ID,并部署了三个节点,且标识位一致。在这种情况下,如果有200个并发请求,均匀分布在三个节点上,且这三个节点在同一毫秒内生成的ID序列号相同,就可能产生重复的ID。
从上述假设场景可以看出,雪花算法生成ID冲突需要满足以下条件:
- 服务通过集群方式部署,且部分机器的标识位一致。
- 业务存在一定的并发量;没有并发量则不会触发重复问题。
- 生成ID的时机:同一毫秒内的序列号一致。
1. 标识位如何定义
如果能保证标识位不重复,则生成的雪花ID也不会重复。通过前面的案例可以看出,ID重复的必要条件。如果要避免服务内产生重复的ID,需要从标识位上进行调整。
我们来看一下开源框架中如何使用雪花算法定义标识位。
- Mybatis-Plus v3.4.2:其雪花算法实现类
Sequence
提供了两种构造方法:无参构造方法会自动生成dataCenterId
和workerId
;有参构造方法则可以在创建Sequence
实例时明确指定标识位。 - Hutool v5.7.9:参考了Mybatis-Plus的
dataCenterId
和workerId
生成方案,并提供了默认实现。
下面,我们一起看看Sequence
类的默认无参构造方法是如何生成dataCenterId
和workerId
的。
public static long getDataCenterId(long maxDatacenterId) {
long id = 1L; // 初始化数据中心ID为1L
final byte[] mac = NetUtil.getLocalHardwareAddress(); // 获取本地硬件地址(MAC地址)
if (null != mac) { // 如果MAC地址不为空
// 计算数据中心ID:
// 取MAC地址的倒数第二个字节,并与0x000000FF按位与,保留低8位;
// 取MAC地址的最后一个字节,左移8位后与0x0000FF00按位与,保留低8位,并将结果右移6位;
// 将上述两个结果按位或运算得到的值作为数据中心ID。
id = ((0x000000FF & (long) mac[mac.length - 2])
| (0x0000FF00 & (((long) mac[mac.length - 1]) << 8))) >> 6;
// 将计算出的ID对最大数据中心ID加1取模,确保ID在合法范围内。
id = id % (maxDatacenterId + 1);
}
return id; // 返回最终的数据中心ID
}
入参 maxDatacenterId
是一个固定值,代表数据中心 ID 的最大值,默认值为31。
为什么最大值是31?因为5bit的二进制最大值是11111,对应的十进制数值就是31。
获取 dataCenterId
时存在两种情况:
- 网络接口为空:默认取值为1L。
- 网络接口不为空:通过Mac地址获取
dataCenterId
。
可以看出,dataCenterId
的取值与Mac地址有关。
接下来再看看 workerId。
public static long getWorkerId(long datacenterId, long maxWorkerId) {
final StringBuilder mpid = new StringBuilder(); // 创建一个StringBuilder对象,用于构建唯一标识符
mpid.append(datacenterId); // 将数据中心ID添加到mpid中
try {
mpid.append(RuntimeUtil.getPid()); // 尝试获取当前进程ID并添加到mpid中
} catch (UtilException ignore) {
// 忽略获取进程ID时可能发生的异常
}
// 计算Worker ID:
// 将mpid转换为字符串并计算其哈希码,然后与0xffff按位与,保留低16位;
// 将结果对最大Worker ID加1取模,确保ID在合法范围内。
return (mpid.toString().hashCode() & 0xffff) % (maxWorkerId + 1);
}
入参 maxWorkerId
也是一个固定值,代表工作机器 ID 的最大值,默认值为31;datacenterId
取自上述的 getDatacenterId
方法。
name
变量值为 PID@IP
,因此需要根据 @
分割并获取下标 0 的值,即PID。通过 MAC + PID
的哈希码获取16个低位,进行运算,最终得到 workerId
。
2. 分配标识位
Mybatis-Plus 标识位的获取依赖于 Mac 地址和进程 PID,这虽然能最大程度上避免重复,但仍有小几率发生冲突。那么,如何定义标识位才能保证不重复呢?有两种方案:预分配和动态分配。
预分配
在应用上线前,统计当前服务的节点数,并人工分配标识位。这种方案无需开发额外代码,在服务节点固定或项目数量较少时可以使用。然而,这种方案无法解决服务节点动态扩容的问题。
动态分配
通过将标识位存放在Redis、Zookeeper或MySQL等中间件中,在服务启动时请求标识位,请求后将标识位更新为下一个可用的。
动态分配带来了一个问题:雪花算法生成的ID是服务内唯一还是全局唯一。
以Redis为例,如果要求ID在服务内唯一,则存放标识位的Redis节点仅需在本项目内使用;如果要求全局唯一,则所有使用雪花算法的应用都需要使用同一个Redis节点。
两者的区别在于不同服务间是否共享Redis。如果没有全局唯一的需求,最好使ID在服务内唯一,这样可以避免单点故障。
当服务的节点数超过1024时,需要做额外扩展;可以扩展到10 bit标识位,或者选择开源的分布式ID框架。
动态分配实现方案
在Redis中存储一个Hash结构的Key,包含两个键值对:dataCenterId
和 workerId
。
在应用启动时,通过Lua脚本从Redis获取标识位。dataCenterId
和 workerId
的获取与自增在Lua脚本中完成,调用返回后即可获得可用的标识位。
具体Lua脚本逻辑如下:
- 第一个服务节点在获取时,Redis 可能没有
snowflake_work_id_key
这个Hash。应先判断Hash是否存在,如果不存在则初始化Hash,并将dataCenterId
和workerId
初始化为0。 - 如果Hash已存在,判断
dataCenterId
和workerId
是否等于最大值31,若满足条件则将dataCenterId
和workerId
设置为0并返回。 dataCenterId
和workerId
的排列组合总共有1024种。在进行分配时,优先分配workerId
。- 判断
workerId
是否不等于31,若条件成立则对workerId
自增并返回;如果workerId
等于31,则对dataCenterId
自增并将workerId
设置为0。
dataCenterId
和 workerId
是连续推进的,总体形成一个环状。通过 Lua脚本的原子性,可以保证在1024个节点下生成的雪花算法ID不重复。如果标识位等于1024,则从头开始继续循环推进。
-- Lua 脚本示例
local snowflakeKey = KEYS[1]
-- 初始化Hash结构
if redis.call('EXISTS', snowflakeKey) == 0 then
redis.call('HSET', snowflakeKey, 'dataCenterId', 0)
redis.call('HSET', snowflakeKey, 'workerId', 0)
end
-- 获取当前值
local dataCenterId = redis.call('HGET', snowflakeKey, 'dataCenterId')
local workerId = redis.call('HGET', snowflakeKey, 'workerId')
-- 转换为数字
dataCenterId = tonumber(dataCenterId)
workerId = tonumber(workerId)
-- 更新并返回值
if workerId < 31 then
workerId = workerId + 1
else
workerId = 0
dataCenterId = (dataCenterId + 1) % 32
end
redis.call('HSET', snowflakeKey, 'dataCenterId', dataCenterId)
redis.call('HSET', snowflakeKey, 'workerId', workerId)
return {dataCenterId, workerId}
开源分布式ID框架
Leaf和Uid都实现了雪花算法,其中Leaf还额外提供了号段模式生成ID。
雪花算法可以满足大部分场景,如果没有特殊需求,不建议引入开源方案增加系统复杂度。
回顾总结
本文通过图文并茂的方式帮助读者梳理了雪花算法的概念,以及如何解决雪花算法生成ID冲突的问题。
针对雪花算法生成ID冲突问题,本文提供了一种方案:分配标识位。通过合理分配雪花算法的组成标识位,可以确保在默认的1024个节点下生成唯一的ID。
读者可以查看Hutool或Mybatis-Plus中的雪花算法具体实现,以加深理解。
需要注意的是,雪花算法并非万能,无法适用于所有场景。如果ID要求全局唯一且服务节点数超过1024个,可以选择修改算法的组成部分(如扩展标识位),或者选择开源方案:LEAF或UID。