雪花算法,魔改 Snowflake

806 阅读5分钟

概要

Snoyflake 是索尼公司受 Twitter 的雪花算法 Snowflake 启发所实现的一个分布式架构下生成全局唯一 ID 的生成器。

Sonyflake 的特性:

  • 可以使用 174 年。
  • 最多可同时部署 65536 个 sonyflake 实例。
  • 每 10 毫秒最多可生成 282^8=256 个 ID,也即每秒 2.5K。

单个 Sonyflake 的并发能力没到 10K 级别,但是对于大部分公司来说是足够的了,大体量公司可以通过部署多个实例来解决并发问题。

部署 65536 个 Sonyflake 实例,该集群每秒最大可产出至少 16 万个 ID,足以应付 10w 级别 QPS 流量轰炸。

结合源码探究原理

Sonyflake ID 的结构:

  • 1 bits,二进制下用以标识数值正负的符号位,无实质意义,默认是 0 。
  • 39 bits 长度的「时间片 ID 段」,存储以 10 毫秒为单位的时间戳。
  • 8 bits 长度的「序列号段」,存储从 0 开始自增的数值。
  • 16 bits 作为「机器号段」,存储用以标识不同 Sonyflake 实例的实例 ID 。

如图:

image.png

时间片 ID 段

Sonyflake 以 10 毫秒作为一个时间片,该段就存储生成 ID 时当前时间戳的时间片 ID。

下面是计算时间片 ID 的源码:

const sonyflakeTimeUnit = 1e7 // nsec, i.e. 10 msec

func toSonyflakeTime(t time.Time) int64 {
	return t.UTC().UnixNano() / sonyflakeTimeUnit
}

func currentElapsedTime(startTime int64) int64 {
	return toSonyflakeTime(time.Now()) - startTime
}

简单理解,时间片 ID = int( 当前毫秒时间戳 ÷\div 10毫秒 )

即给时间进行分片,想象一下小时候常用的尺子。

startTime 是 Sonyflake 实例的构造参数,即我们系统初次使用 Sonyflake 的时间,是固定的,默认值是 2014-09-01 00:00:00 +0000 UTC

所以上面还有个细节,就是实际存储的时间片 ID 是减去了 startTime 所在的时间片 ID 的,有什么意义呢?相当于把尺子的 0 刻度调整为 startTime 了。

完整计算方式,时间片 ID = int( 当前毫秒时间戳÷\div10毫秒 ) - int( startTime÷\div10毫秒 )

若不能自定义 startTime,Sonyflake 只能使用到固定的 2146 年了。

时间戳为 0 即 1970 年,1970 + 176 = 2146。

uTools_1673775396049.png

上面是 startTime 等于默认值时,时间片 ID 的具体数值。可以看到,时间戳每增加 10 ms,时间片 ID 则加 1。

序列号段

序列号段存储当前时间片内已分配的 ID 数量,序列号的具体维护逻辑如下:

  • 每切换到下一个时间片,序列号重置为 0 重新开始计数递增。
  • 每分配一个 ID,序列号递增 + 1。
  • 如此往复。

uTools_1673940451811.png

注意的是,因为序列号段的大小为 8 bits,所以一个时间片内序列号的最大值为 255。

问题一:假设当前时间片内,已经分配了第 255 个 ID 了,此时又有一个申请 ID 的请求,Sonyflake 是如何处理的呢?

问题二:Sonyflake 如何处理时钟回拨

看源码,答案显而易见。

答案一:当当前时间片内序列号发生溢出时,Sonyflake 会直接睡眠当前申请 ID 的协程,直到时间到了下一个时间片,在此期间,所有申请 ID 的协程都将被阻塞最长不超过 10 ms。

答案二:Sonyflake 没有解决时钟回拨问题,当发生时钟回拨问题时,可能会生成重复的 ID。

// NextID generates a next unique ID.
// After the Sonyflake time overflows, NextID returns an error.
func (sf *Sonyflake) NextID() (uint64, error) {
	const maskSequence = uint16(1<<BitLenSequence - 1)

	sf.mutex.Lock()
	defer sf.mutex.Unlock()
    
	current := currentElapsedTime(sf.startTime)
        // 时间片正常的递增,重置序列号
	if sf.elapsedTime < current {
		sf.elapsedTime = current
		sf.sequence = 0
	} else { // sf.elapsedTime >= current
                // 两种情况:
                // sf.elapsedTime == current:
                //   时间片内,序列号递增+1
                // sf.elapsedTime > current:
                //   发生时钟回拨,不理会,仍沿用当前时间片,序列号正常递增+1
		sf.sequence = (sf.sequence + 1) & maskSequence
		if sf.sequence == 0 {
			sf.elapsedTime++
			overtime := sf.elapsedTime - current
			time.Sleep(sleepTime((overtime)))
		}
	}

	return sf.toID()
}

机器号段

该段主要用于标识不同的 Sonyflake 实例,存储的是 Sonyflake 实例 ID,主要用于水平扩展 Sonyflake 实例的并发能力

需要注意的是,同时在线的 Sonyflake 实例的机器号一定不能重复,否则会生成重复 ID。

我司目前生产环境有 2 个 Sonyflake Pod,灰度环境每个 k8s 命名空间 2 个 Sonyflake Pod,至少同时在线 10 个 Sonyflake Pod,历史遗留代码在服务启动时没有初始化机器号,使用了 0 作为默认值,后期并发数上来了后,时不时的接口报错 Mysql 主键重复。

后面写了一个简单的申请机器码的小模块:

在服务启动的时候先通过 redis 的 setNX 指令竞争一个机器码,并且设置 4 分钟的 TTL,避免服务因 OOM 、未 recover 的 panic 等异常退出导致机器码未能被归还。

因为我司把机器号段的后 10 bits 作为别的用途,所以上只有 64 个机器码可用,很可能会出现机器码不够用的情况。

后台定时任务每 1 分钟执行一次 setXX 指令重置 TTL 为 4 分钟以续期。

服务正常退出时,监听 signal 主动调用del 指令归还机器码。

代码在这,有兴趣的同学可以看看。

思考

时间片的大小

Sonyflake 以 10ms 作为一个时间片,那能不能以 1s 作为一个时间片呢?

要达到同样的 174 年的可使用时长,1s 作为时间片的时,时间片 ID 段只需要 33 bits 的大小。

那序列号段就从原来的 8 bits 大小变为 14 bits 大小,此时一个时间片内就可以分配 16384 个 ID 了,也就是说 QPS 可以达到 10k 级别。

那为什么最后 Sonyflake 的抉择不是 1s 作为时间片大小呢?我认为是为了实现平滑

假设一共 20k 个请求,1s 的时间片大小下,需要两个时间片,总耗时至少 1s,并且流量都集中在两个时间片的开头,形成锯齿状曲线图。

而 10ms 的时间片大小下,只需要 79 个时间片,总耗时只需要 790ms,并且流量都均匀的分摊在每个 10ms 的窗口中。