logo

如何实现可靠的雪花分布式 ID

Jul 28, 2022 · 5min

为什么需要分布式 ID ?

需要分布式 ID 的原因有这几个:

  • 分库后,数据库自增 ID 没办法保证唯一
  • 自增 ID 有被推测的风险(更容易被爬虫、能被推测出数据量)
  • 分布式 ID 一般可以包含一些额外的信息,比如时间戳

分布式 ID 需要具备什么特点?

  • 全局唯一
  • 趋势递增
  • 单调递增
  • 信息安全

为什么分布式环境下,雪花算法生成 ID 可能不是单调递增的?

比如有服务器 A、B 使用雪花算法生成 ID,A 的机器时间比 B 的机器时间慢了 1 秒钟。 首先,B 在 T1 时刻生成了一个 ID,接着 A 在 T1+200ms 时生成一个 ID,由于 A 的时间比 B 的时间慢 1 秒, 所以实际上 A 生成的 ID 的时间戳还要减去 1 秒,即 T1+200ms-1s,这个值小于 T1,所以没能保证单调递增。

Snowflake 是什么?

看图秒懂(图片来源:tech.meituan.com):

Snowflake

  • 1bit 的符号位不使用
  • 41bit 的时间戳,可以表示(1L<<41)/(1000L*3600*24*365)=69年 的时间
  • 10bit 的机器 ID,每个机器唯一
  • 12bit 的自增或随机序号,2^12=4096,即理论上 Snowflake 的 QPS 上限约为 409.6w/s

现成的 Snowflake 实现

  • 薄雾算法:使用自增 ID 代替时间戳,所以没有时钟回拨问题,但自增 ID 可以被推测出数据量,且维护自增 ID 需要引入 Redis,有成本
  • 百度的 UIDGenertor:使用了 MySQL 来管理 workerId ,多引入了一个中间件,有成本
  • 美团的 Leaf Snowflake :使用的是 Zookeeper 配置中心来管理 workerId,我们使用的是 Nacos,只好自己实现一个 Nacos 版本的

时钟回拨问题

实现可靠的雪花 ID,主要需要解决时钟回拨问题。服务器存在两种情况的时钟回拨:

  • 由于 NTP 服务的校准或机器本身的原因,System.currentTimeMillis() 获取的系统时间可能存在时间回退问题, 即后面获取的时间戳可能小于前面获取的时间戳
  • 服务器重启,由于各种复杂情况(可以是更换 RTC 晶振的电池之类的情况),在服务器启动后时间发生回退

如何使用 Nacos 解决时钟回拨?

解决方法就一句话,记录 workerId 和使用了这个 workerId 的最新时间戳,限制 ID 的时间戳大于最新的时间戳。下面我讲讲具体的实现方法。

第一、

内存中存储 lastMillis 表示上传发号的时间戳,每次发号都对比该时间戳,保证 System.currentTimeMillis() > lastMillis

第二、

Nacos 使用 MySQL 来做持久化(一般都会这么做,不算引入新依赖),使用代码实现自动地在 Nacos 配置中心添加一份配置来记录 WorkerInfo 列表, 每个 WorkerInfo 包含如下信息:

  • ip:port:获得该 worker 的服务器的 IP 和 端口,为 null 时表示该 worker 没被分配
  • workerId:作为雪花算法的机器 ID
  • lastMillis:最新的机器时间

分配 WorkerInfo 的方法是,遍历 WorkerInfo 列表,找出 ip:port 与当前服务器相同的 WorkerInfo,若没找到,则分配一个列表中未被使用的或新建一个,确保 workerId 不超出 10bit 即可。

发号服务器会定时向 Nacos 上传它的机器时间存储为 WorkerInfo 的 lastMillis ,并在服务器启动时拿到所属的 WorkerInfo,检查 机器时间 是否大于 最新时间 + 心跳周期,若大于,将 lastMillis 作为第一步中的 lastMillis,若小于等于,抛出异常结束运行。

另外有一些要注意的点:

  • 更新 WorkerInfo 列表需要使用 CAS 操作来保证并发安全
  • Nacos 通过雪花算法生成的实例 ID 并不能使用,因为我们存储机器 ID 的空间只有 10bit,还有 Nacos 2.x 之后的版本不再为每个服务实例提供整数的实例 ID 了

其他相关的问题

1. 为什么不使用 UUID ?

  • 无法保证趋势递增
  • UUID 过长,是一个128比特的数值
  • 信息不安全,基于 MAC 地址生成 UUID 的算法可能会造成 MAC 地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置

2. 有效使用年限的检测

使用位运算自动检测是否超过使用年限,超过了就抛异常,做到 fail-fast:

if ((time & (-1L << BITS_TIME)) > 0)
    throw new IllegalStateException("snowflake id exceeded available years");

3. 使用 MAC 地址分配 workerId 存在的问题

MAC 地址确实有唯一性,但是雪花算法的机器 ID 只有 10bit,根本存不下 48bit 的 MAC 地址,截取 MAC 地址作为机器 ID 存在重复的风险。同样的道理,使用 MAC 地址和 PID 计算 机器 ID 也存在重复的风险。

MyBatis Plus 支持使用雪花算法生成主键 ID, 它是对 MAC 和 PID 进行哈希和位运算后,取余得到机器 ID,这么生成机器 ID,分布式场景下 ID 重复的风险是很大的。

MyBatis Plus 生成雪花 ID 的源码:Sequence.java,Hutool 的实现与 MyBatis Plus 类似。我们可以在 GitHub 上看到 MyBatis Plus 雪花 ID 出现重复的 issues :#4550#4520

4. 关于前端 JS 会丢失精度

可以认为前端的数字都是 Java 中的 double,存不下 64bit 整数, 如果后端是使用 Jackson,可以通过注解自动将 64bit 的 id 转换成字符串传给前端。

public class Something {

    // 无需加 @JsonDeserialize,Jackson 默认会将字符串解析成 Long
    @JsonSerialize(using = ToStringSerializer.class)
    Long id;
}

5. ID 显示太长,不美观

反正都要把 ID 转换字符串,再进一步使用 Base58 编码将 ID 转换成较短的字符串刚好可以解决显示长度太长的问题。Base58 的 Java 实现可参考:Base58.java

参考

CC BY-NC-SA 4.0 2021-PRESENT © Edsuns