snowflake原理解析

Snowflake

世界上没有两片完全相同的雪花。

​ — twitter

Snowflake原理

这种方案把64-bit分别划分成多段,分开来标示机器、时间等,比如在snowflake中的64-bit分别表示如下图所示:

在java里,64bit正好是long类型的大小。

41-bit的时间可以表示(1L<<41)/(1000ms * 60s * 60m * 24h * 365d)=69年的时间,10-bit机器可以分别表示1024台机器。如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,可以根据自身需求定义。12个自增序列号可以表示212个ID,理论上snowflake方案的QPS约为212=4096/ms, 也就是409.6w/s,这种分配方式可以保证在任何一个IDC的任何一台机器在任意毫秒内生成的ID都是不同的。

+-----------+--------------------------+---------------------+----------------------+----------------+

| sign | time_stamp | datacenter | worker node | sequence |

+-----------+--------------------------+---------------------+----------------------+----------------+

​ 1bit 41 bits 5bits 5bits 12bits

+-----------+--------------------------+---------------------+----------------------+----------------+

  • 1位,不用。二进制中最高位为1的都是负数,但是我们生成的id一般都使用整数,所以这个最高位固定是0

  • 41位,用来记录时间戳(毫秒)。

    • 41位可以表示$2^{41}-1$个数字,
    • 如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0 至 $2^{41}-1$,减1是因为可表示的数值范围是从0开始算的,而不是1。
    • 也就是说41位可以表示$2{41}-1$个毫秒的值,转化成单位年则是$(2-1) / (1000 * 60 * 60 * 24 * 365) = 69$年
  • 10位,用来记录工作机器id。

    • 可以部署在$2^{10} = 1024$个节点,包括5位datacenterId5位workerId
    • 5位(bit)可以表示的最大正整数是$2^{5}-1 = 31$,即可以用0、1、2、3、....31这32个数字,来表示不同的datecenterId或workerId
  • 12位,序列号,用来记录同毫秒内产生的不同id。

    • 12位(bit)可以表示的最大正整数是$2^{12}-1 = 4095$,即可以用0、1、2、3、....4094这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号

位操作解释

    /**
     * define the initial bit for each part of 64 bits of one Id
     */
    private final long sequenceBits = 12L;
    private final long datacenterIdBits = 5L;
    private final long workerIdBits = 5L;
    private final long timestampBits = 41L;

    /*
     * max values of workerId, datacenterId and sequence
     * 11111111 11111111 11111111 11111111  // -1 in binary format
     */
    // 2^5-1 = 31
    private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    // 2^10-1 = 1023
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
    // 2^12-1 = 4095
    private final long maxSequence = -1L ^ (-1L << sequenceBits);

先看snowflake里面定义成员变量的一个神操作,为什么这么定义呢?需要先了解下二进制位操作的原理。

负数的二进制表示

在计算机中,负数的二进制是用补码来表示的。
假设我是用Java中的int类型来存储数字的,
int类型的大小是32个二进制位(bit),即4个字节(byte)。(1 byte = 8 bit)
那么十进制数字3在二进制中的表示应该是这样的:

00000000 00000000 00000000 00000011
// 3的二进制表示,就是原码

那数字-3在二进制中应该如何表示?
我们可以反过来想想,因为-3+3=0,
在二进制运算中把-3的二进制看成未知数x来求解
求解算式的二进制表示如下:


   00000000 00000000 00000000 00000011 //3,原码
+  xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx //-3,补码
-----------------------------------------------
   00000000 00000000 00000000 00000000

反推x的值,3的二进制加上什么值才使结果变成00000000 00000000 00000000 00000000?:

  00000000 00000000 00000000 00000011 //3,原码                         
+ 11111111 11111111 11111111 11111101 //-3,补码
-----------------------------------------------
 1 00000000 00000000 00000000 00000000

反推的思路是3的二进制数从最低位开始逐位加1,使溢出的1不断向高位溢出,直到溢出到第33位。然后由于int类型最多只能保存32个二进制位,所以最高位的1溢出了,剩下的32位就成了(十进制的)0

补码的意义就是可以拿补码和原码(3的二进制)相加,最终加出一个“溢出的0”

以上是理解的过程,实际中记住公式就很容易算出来:

  • 补码 = 反码 + 1
  • 补码 = (原码 - 1)再取反码

因此-1的二进制应该这样算:

00000000 00000000 00000000 00000001 //原码:1的二进制
11111111 11111111 11111111 11111110 //取反码:1的二进制的反码
11111111 11111111 11111111 11111111 //加1:-1的二进制表示(补码)

用位运算 得出n个bit能存储最大值

private long workerIdBits = 5L;
// 2^5-1 = 31
private long maxWorkerId = -1L ^ (-1L << workerIdBits);      

其中:

^ 操作符是 异或 操作, 即:如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0。

异或[1]也叫半加运算,其运算法则相当于不带进位的二进制加法:二进制下用1表示真,0表示假,则异或的运算法则为:0⊕0=0,1⊕0=1,0⊕1=1,1⊕1=0(同为0,异为1),这些法则与加法是相同的,只是不带进位,所以异或常被认作不进位加法。

延伸:巧用异或算法

1-1000放在含有1001个元素的数组中,只有唯一的一个元素值重复,其它均只出现
一次。每个数组元素只能访问一次,设计一个算法,将它找出来;不用辅助存储空
间,能否设计一个算法实现?
解法一、显然已经有人提出了一个比较精彩的解法,将所有数加起来,减去1+2+…+1000的和。
这个算法已经足够完美了,相信出题者的标准答案也就是这个算法,唯一的问题是,如果数列过大,则可能会导致溢出。

解法二、异或就没有这个问题,并且性能更好。
将所有的数全部异或,得到的结果与1231000的结果进行异或,得到的结果就是重复数。

google面试题的变形:一个数组存放若干整数,一个数出现奇数次,其余数均出现偶数次,找出这个出现奇数次的数?

Leecode:https://leetcode-cn.com/problems/single-number/solution/

@Test
public void fun() {
	int a[] = { 22, 38,38, 22,22, 4, 4, 11, 11 };
	int temp = 0;
	for (int i = 0; i < a.length; i++) {
		temp ^= a[i];
	}
	System.out.println(temp);
}

解法有很多,但是最好的和上面一样,就是把所有数异或,最后结果就是要找的,原理同上!!

<<左移 操作

private long workerIdBits = 5L;
private long maxWorkerId = -1L ^ (-1L << workerIdBits);       

所以long maxWorkerId = -1L ^ (-1L << 5L)的二进制运算过程如下:

  • -1 左移 5,得结果a
      11111111 11111111 11111111 11111111 //-1的二进制表示(补码)
11111 11111111 11111111 11111111 11100000 //高位溢出的不要,低位补0
      11111111 11111111 11111111 11100000 //结果a
  • -1 异或 a
  11111111 11111111 11111111 11111111 //-1的二进制表示(补码)
^ 11111111 11111111 11111111 11100000 //两个操作数的位中,相同则为0,不同则为1
  ---------------------------------------------------------------------------
  00000000 00000000 00000000 00011111 //最终结果31

最终结果是31,二进制00000000 00000000 00000000 00011111转十进制可以这么算:

2^4 + 2^3 + 2^2 + 2^1 + 2^0 = 16 + 8 + 4 +2 +1 = 31

所以这一通操作其实是通过位运算计算出来5bit的work id 最多能存储31个值,也就是最多支持31个节点。

位与 操作防止溢出

我们在跨毫秒时,序列号总是归0,会使得序列号为0的ID比较多,导致生成的ID取模后不均匀。优化的点是,序列号不是每次都归0,而是归一个0到100的随机数。

//如果当前操作落在同一个ms(timestamp位相同)的话
if (lastTimestamp == currentTimestamp) {
  					//sequence++ 且 保证不溢出,下面测试可知道如果溢出了maxSequence就会变成0
            sequence = (sequence + 1) & maxSequence;
            if (sequence == 0) {
                // overflow: greater than max sequence
                sequence = RANDOM.nextInt(100);
                currentTimestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            //如果是新的ms开始,防止并发不够每次都是0
            //sequence = 0L;
            sequence = RANDOM.nextInt(100);

        }

关于这里的溢出操作分别用不同的值测试一下,就知道原理了:

long seqMask = -1L ^ (-1L << 12L); //计算12位能耐存储的最大正整数,相当于:2^12-1 = 4095
System.out.println("seqMask: "+seqMask);
System.out.println(1L & seqMask);
System.out.println(2L & seqMask);
System.out.println(3L & seqMask);
System.out.println(4L & seqMask);
System.out.println(4095L & seqMask);
System.out.println(4096L & seqMask);
System.out.println(4097L & seqMask);
System.out.println(4098L & seqMask);

/**
* 输出结果是:
    seqMask: 4095
    1
    2
    3
    4
    4095
    0
    1
    2
*/

因为maxSequence 我们选用12bit最大值是4095,这段代码通过位与运算保证计算的结果范围始终是 0-4095 !

生成id的核心代码

long id = ((currentTimestamp - epoch) << timestampShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;

计算分为2部分:

  1. << 每个对应部分的id 左移对应的bits;

  2. 管道符号|在Java中也是一个位运算符。其含义是:
    x的第n位和y的第n位 只要有一个是1,则结果的第n位也为1,否则为0,因此,我们对四个数的位或运算如下:

1  |                    41                        |  5  |   5  |     12      
    
  0|0001100 10100010 10111110 10001001 01011100 00|00000|0 0000|0000 00000000
  0|000000‭0 00000000 00000000 00000000 00000000 00|10001|0 0000|0000 00000000
  0|0000000 00000000 00000000 00000000 00000000 00|00000|1 1001|0000 00000000
or0|0000000 00000000 00000000 00000000 00000000 00|00000|0 0000|‭0000 00000000‬
------------------------------------------------------------------------------------------
  0|0001100 10100010 10111110 10001001 01011100 00|10001|1 1001|‭0000 00000000‬ 
  //结果:910499571847892992

位或运算角度简化看是这样的视角

1  |                    41                        |  5  |   5  |     12      
    
  0|0001100 10100010 10111110 10001001 01011100 00|     |      |      //la
  0|                                              |10001|      |      //lb
  0|                                              |     |1 1001|      //lc
or0|                                              |     |      |‭0000 00000000‬ //seq
------------------------------------------------------------------------------------------
   0|0001100 10100010 10111110 10001001 01011100 00|10001|1 1001|‭0000 00000000‬ 
   //结果:910499571847892992

上面的64位我按1、41、5、5、12的位数截开了,方便观察。

  • 纵向观察发现:
    • 在41位那一段,除了la一行有值,其它行(lb、lc、seq)都是0
    • 在左起第一个5位那一段,除了lb一行有值,其它行都是0
    • 在左起第二个5位那一段,除了lc一行有值,其它行都是0
    • 按照这规律,如果seq是0以外的其它值,12位那段也会有值的,其它行都是0
  • 横向观察发现:
    • 在la行,由于左移了5+5+12位,5、5、12这三段都补0了,所以la行除了41那段外,其它肯定都是0
    • 同理,lb、lc、seq行也以此类推
    • 正因为左移的操作,使四个不同的值移到了SnowFlake理论上相应的位置,然后四行做位或运算(只要有1结果就是1),就把4段的二进制数合并成一个二进制数。

结论:
所以,在这段代码中左移运算是为了将数值移动到对应的段(41、5、5,12那段因为本来就在最右,因此不用左移)。然后对每个左移后的值(la、lb、lc、seq)做位或运算,是为了把各个短的数据合并起来,合并成一个二进制数。最后转换成10进制,就是最终生成的id。

延伸:long和double底层

问题: java中 long 和double都是64位。为什么double表示的范围大那么多呢?

标准答案是这样子的:

double是n*2^m(n乘以2的m次方)这种形式存储的,只需要记录n和m两个数就行了,m的值影响范围大,所以表示的范围比long大。
但是m越大,n的精度就越小,所以double并不能把它所表示的范围里的所有数都能精确表示出来,而long就可以。

贴上一些整数类型的范围:
1.整型 (一个字节占8位)
类型 存储需求 bit数 取值范围 备注
int 4字节 48 (32) -231~231-1
short 2字节 2
8 (16) -215~215-1
long 8字节 88 (64) -263~263-1
byte 1字节 1
8 (8) -27~27-1 = -128~127

2.浮点型
类型 存储需求 bit数 取值范围 备注
float 4字节 48 (32) 3.4028235E38 ~= 3.410^38
double 8字节 88 (64) 1.7976931348623157E308 ~=1.710^308

从范围来看double和long完全不是一个级别的了吧?long最大为=263-1,而double为21024。

Snowflake的优缺点

优点:

  • 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
  • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。
  • 可以根据自身业务特性分配bit位,非常灵活。

缺点:

  • 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
  • 我们通过5ms的等待来兼容ntp的同步,如果大于5ms就报错。

服务部署

部署模式支持2种

  1. 部署zookeeper, 配置zk地址,然后启动Spring boot

  2. 也可以把snowflake单独作为包引入项目,独立使用

配置方式

  1. Rest Server的配置在server/src/main/resources/application.properties中:
配置项 含义 默认值
spring.application.name web服务名 default
server.port web服务注册端口
snowflake.zk.address zk地址
  1. 如果只是配置snowflake这个单独的模块,可以参考snowflake/src/test/resources/snowflake.properties中:
    | 配置项 | 含义 | 默认值 |
    | ------------------------- | ----------------------------- | ------ |
    | snowflake.name | snowflake服务名 | default|
    | snowflake.node.port | snowflake服务注册端口 | |
    | snowflake.zk.address | zk地址 | |

远程调用方式

  1. HTTP调用拿一个id:

curl http://localhost:8789/api/snowflake/get

response

{
    "code":0,
    "message":"ok",
    "content":{
        "id":9546332062617603,
        "status":"SUCCESS"
    }
}
  1. HTTP调用解析一个id:

curl http://localhost:8789/api/snowflake/decode?snowflakeId=9546332062617603

response

{
    "code":0,
    "message":"ok",
    "content":{
        "format":"2019-10-27 08:13:42.926, #3, @(0,1)",
        "timestamp":"2019-10-27 08:13:42.926",
        "datacenterId":0,
        "workerId":1,
        "sequenceId":3
    }
}

欢迎关注我的公众号:好奇心森林
Wechat


  1. https://www.cnblogs.com/fuck1/p/5899402.html ↩︎

posted @ 2020-05-30 21:27  好奇心森林  阅读(1031)  评论(0编辑  收藏  举报