如何生成分布式雪花算法ID

如何生成分布式雪花算法ID

在分布式系统中,常常需要生成全局唯一的ID以防止冲突。尽管36位的UUID能够解决这一问题,但其缺点显而易见:UUID不仅相对较长,而且通常是无序的。

有时我们希望使用一种更为简洁的ID,并且希望这些ID能够按时间顺序生成。

什么是雪花算法

Snowflake,中文称为雪花算法,是Twitter开源的一种分布式ID生成算法。通过该算法生成的ID为64位的长整型数值,其结构中引入了时间戳,从而基本实现了自增。

Snowflake算法的优势

  1. 高性能高可用:ID生成不依赖数据库,完全在内存中进行。
  2. 高吞吐量:每秒可以生成数百万个自增ID。
  3. ID自增:在数据库中存储时,具有较高的索引效率。

Snowflake算法的劣势

  1. 依赖系统时间的一致性:如果系统时间被回调或修改,可能会导致ID冲突或重复。

雪花算法的结构

Snowflake的结构如下图所示:

Alt
Snowflake算法的结构由四个部分组成:

  1. 不使用位:1bit,最高位是符号位,0表示正,1表示负。在Snowflake算法中,固定为0。
  2. 时间戳:41bit,表示毫秒级的时间戳(41位的长度可以使用69年)。
  3. 标识位:5bit的数据中心ID和5bit的工作机器ID,两个标识位组合起来最多可以支持部署1024个节点。
  4. 序列号:12bit,用于记录同一毫秒内生成的不同ID,支持每毫秒生成4096个不同的ID。

Alt
序列号: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冲突需要满足以下条件:

  1. 服务通过集群方式部署,且部分机器的标识位一致。
  2. 业务存在一定的并发量;没有并发量则不会触发重复问题。
  3. 生成ID的时机:同一毫秒内的序列号一致。

1. 标识位如何定义

如果能保证标识位不重复,则生成的雪花ID也不会重复。通过前面的案例可以看出,ID重复的必要条件。如果要避免服务内产生重复的ID,需要从标识位上进行调整。

我们来看一下开源框架中如何使用雪花算法定义标识位。

  • Mybatis-Plus v3.4.2:其雪花算法实现类Sequence提供了两种构造方法:无参构造方法会自动生成dataCenterIdworkerId;有参构造方法则可以在创建Sequence实例时明确指定标识位。
  • Hutool v5.7.9:参考了Mybatis-Plus的dataCenterIdworkerId生成方案,并提供了默认实现。

下面,我们一起看看Sequence类的默认无参构造方法是如何生成dataCenterIdworkerId的。

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 时存在两种情况:

  1. 网络接口为空:默认取值为1L。
  2. 网络接口不为空:通过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,包含两个键值对:dataCenterIdworkerId

Alt

在应用启动时,通过Lua脚本从Redis获取标识位。dataCenterIdworkerId 的获取与自增在Lua脚本中完成,调用返回后即可获得可用的标识位。

Alt

具体Lua脚本逻辑如下:

  1. 第一个服务节点在获取时,Redis 可能没有 snowflake_work_id_key 这个Hash。应先判断Hash是否存在,如果不存在则初始化Hash,并将 dataCenterIdworkerId 初始化为0。
  2. 如果Hash已存在,判断 dataCenterIdworkerId 是否等于最大值31,若满足条件则将 dataCenterIdworkerId 设置为0并返回。
  3. dataCenterIdworkerId 的排列组合总共有1024种。在进行分配时,优先分配 workerId
  4. 判断 workerId 是否不等于31,若条件成立则对 workerId 自增并返回;如果 workerId 等于31,则对 dataCenterId 自增并将 workerId 设置为0。

dataCenterIdworkerId 是连续推进的,总体形成一个环状。通过 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。

  • 34
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值