Java架构直通车——分布式唯一 ID生成方案

文章目录

  • 分布式ID的几种生成方案
    • UUID
    • MySQL主键自增
      • 数据库自增ID改进方案
    • 雪花算法(SnowFlake)
      • 雪花算法的优化
    • Redis自增id
    • Zookeeper有序节点

最近要做区块链项目,要生成很多唯一ID做业务号之类的,所以趁此机会学习学习。

分布式ID的几种生成方案

UUID

之前一直是用的UUID生成唯一ID,好处显而易见,方便快捷,坏处就是:

  1. 数据库里不好做索引,每次生成的ID是无序的,无法保证趋势递增。
  2. UUID的字符串存储,存储空间大,查询效率慢。
  3. ID本事无业务含义,不可读。

很明显,用来做业务号不是UUID的应用场景,使用UUID应该要保障不要求递增,无确实含义的场景,比如说做令牌Token使用。

MySQL主键自增

这个方案就是利用了MySQL的主键自增auto_increment,默认每次ID加1。

这样做的好处:

  1. id是有序的,能够保证自增。
  2. 查询效率高,具有一定的业务可读。

坏处也是显而易见:单点问题,对于单个数据库压力过大,高并发扛不住。

解决方案是按步长自增:
Java架构直通车——分布式唯一 ID生成方案_第1张图片
这样能够解决一个单点的问题,缺陷是:

  • 一旦把步长定好后,就无法扩容
  • 虽然相比于单机的方式,数据库压力小了很多,但是还是有一定压力的。

数据库自增ID改进方案

Java架构直通车——分布式唯一 ID生成方案_第2张图片

  1. 【用户服务】在注册一个用户时,需要一个用户ID;会请求【生成ID服务(是独立的应用)】的接口。
  2. 【生成ID服务】会去查询数据库,找到user_tag的id,现在的max_id为0,step=1000。(可以加上行锁,防止两个事务请求到相同的结果
  3. 【生成ID服务】把max_id和step返回给【用户服务】;并且把max_id更新为max_id = max_id + step,即更新为1000。
  4. 【用户服务】获得max_id=0,step=1000;这个用户服务可以用ID=【max_id + 1,max_id+step】区间的ID,即为【1,1000】
  5. 【用户服务】会把这个区间保存到jvm中。用户服务】需要用到ID的时候,在区间【1,1000】中依次获取id,可采用AtomicLong中的getAndIncrement方法。
  6. 如果把区间的值用完了,再去请求【生产ID服务】接口,获取到max_id为1000,即可以用【max_id + 1,max_id+step】区间的ID,即为【1001,2000】

这个方案就非常完美的解决了数据库自增的问题,而且可以自行定义max_id的起点,和step步长,非常方便扩容

而且也解决了数据库压力的问题,因为在一段区间内,是在jvm内存中获取的,而不需要每次请求数据库。即使数据库宕机了,系统也不受影响,ID还能维持一段时间。

雪花算法(SnowFlake)

SnowFlake算法生成id的结果是一个64bit大小的整数(也就是long类型,或者说bigInt类型),它的结构如下图:
Java架构直通车——分布式唯一 ID生成方案_第3张图片

  • 1bit-不用:
    因为二进制中最高位是符号位,1表示负数,0表示正数。生成的id一般都是用整数,所以最高位固定为0。
  • 41bit-时间戳:
    用来记录时间戳,毫秒级。如果只是用来记录整数的时间戳的话,那么41bit实际上可以记载:
    2 41 / ( 365 ∗ 24 ∗ 60 ∗ 60 ∗ 1000 m s ) 2^{41}/(365*24*60*60*1000ms) 241/(3652460601000ms),约为69年。
  • 10bit-工作机器id:
    用来记录工作机器id,那么可以部署的机器数目为 2 10 = 1024 2^{10}=1024 210=1024台机器,包括5位datacenterId(机房id)和5位workerId(机器id)。
  • 12bit-序列号:
    用来记录同毫秒内产生的不同id,也就是一台机器上同一毫秒的并发量是 2 12 = 4096 2^{12}=4096 212=4096次。

SnowFlake可以保证:

  1. 所有生成的id按时间趋势递增
  2. 整个分布式系统内不会产生重复id(因为有datacenterId和workerId来做区分)

这里分析一下生成ID的函数。

	//下一个ID生成算法
	//使用了synchronized生成ID,做一个阻塞,防止同一毫秒内生成相同的12位序列号
    public synchronized long nextId() {
        long timestamp = timeGen();//获取到当前的时间戳

        //获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        //获取当前时间戳如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始。
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }
        
        //将上次时间戳值刷新
        lastTimestamp = timestamp;

        /**
          * 返回结果:
          * (timestamp - twepoch) << timestampLeftShift) 表示将时间戳减去初始时间戳,再左移相应位数
          * (datacenterId << datacenterIdShift) 表示将数据id左移相应位数
          * (workerId << workerIdShift) 表示将工作id左移相应位数
          * | 是按位或运算符,例如:x | y,只有当x,y都为0的时候结果才为0,其它情况结果都为1。
          * 因为个部分只有相应位上的值有意义,其它位上都是0,所以将各部分的值进行 | 运算就能得到最终拼接好的id
        */
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

优点:

  • 此方案每秒能够产生409.6万个ID,性能快
  • 时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增
  • 灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求

缺点:

  • 依赖机器的时钟,如果服务器时钟回拨,会导致重复ID生成。
    在分布式场景中,服务器时钟回拨会经常遇到(时间校准,以及其他因素,可能导致服务器时间回退),一般存在10ms之间的回拨;小伙伴们就说这点10ms,很短可以不考虑吧。但此算法就是建立在毫秒级别的生成方案,一旦回拨,就很有可能存在重复ID。

雪花算法的优化


  • synchronized关键字:

此锁的目的是为了保证在多线程的情况下,只有一个线程进入方法体生成ID,保证并发情况下生成ID的唯一性,如果在竞争激烈情况下,自旋锁+ CAS原子变量的方式或许是更为合理的选择,可以达到优化部分性能的目的。


  • 时钟回拨问题:

UidGenerator是百度开源的Java语言实现,基于Snowflake算法的唯一ID生成器。另外,它通过消费未来时间克服了雪花算法的并发限制。UidGenerator提前生成ID并缓存在RingBuffer中。

RingBuffer,如下图所示,它本质上是一个数组,数组中每个项被称为slot。UidGenerator设计了两个RingBuffer,一个保存唯一ID,一个保存flag。RingBuffer的尺寸是2^n,n必须是正整数:
Java架构直通车——分布式唯一 ID生成方案_第4张图片

  • RingBuffer of Flag:
    保存flag这个RingBuffer的每个slot的值都是0或者1,0是CAN_PUT_FLAG的标志位,1是CAN_TAKE_FLAG的标识位。也就是可以放置UID或者拿取UID的标识。

  • RingBuffer of UID:
    保存唯一ID的RingBuffer有两个指针,Tail指针和Cursor指针。
    Tail指针表示最后一个生成的唯一ID。如果这个指针追上了Cursor指针,意味着RingBuffer已经满了。这时候,不允许再继续生成ID了。
    Cursor指针表示最后一个已经给消费的唯一ID。如果Cursor指针追上了Tail指针,意味着RingBuffer已经空了。这时候,不允许再继续获取ID了。


初始化阶段

  1. 根据boostPower的值确定RingBuffer的size。bufferSize= 2 13 2^{13} 213, 扩容后bufferSize = 2 13 2^{13} 213<
  2. 构造RingBuffer,默认paddingFactor为50。这个值的意思是当RingBuffer中剩余可用ID数量少于50%的时候,就会触发一个异步线程往RingBuffer中填充新的唯一ID。
  3. 初始化PUT和TAKE的拒绝策略,也就是满了或者空了之后应该怎么做。
  4. 初始化填满RingBuffer中所有slot(填满所有ID)。

百度UidGenerator的优势:

  • 不依赖系统时间:
    传统的雪花算法实现都是通过System.currentTimeMillis()来获取时间并与上一次时间进行比较,这样的实现严重依赖服务器的时间。而UidGenerator的时间类型是AtomicLong,且通过incrementAndGet()方法获取下一次的时间,从而脱离了对服务器时间的依赖,也就不会有时钟回拨的问题(这种做法也有一个小问题,即分布式ID中的时间信息可能并不是这个ID真正产生的时间点,例如:获取的某分布式ID的值为3200169789968523265,它的反解析结果为{“timestamp”:“2019-05-02 23:26:39”,“workerId”:“21”,“sequence”:“1”},但是这个ID可能并不是在"2019-05-02 23:26:39"这个时间产生的)。
  • 使用缓存

Redis自增id

利用redis的incr原子性操作自增,一般算法为:年份 + 当天距当年第多少天 + 天数 + 小时 + redis自增。

优点:

  • 有序递增,可读性强。
  • 性能还可以。

缺点:

  • 占用带宽,每次要向redis进行请求,并发强依赖了Redis
  • ID安全性的问题,如:Redis方案中,用户是可以预测下一个ID号是多少,因为算法是递增的。(当然自增的ID都存在这样的问题
    比如,竞争对手第一天中午12点下个订单,就可以看到平台的订单ID是多少,第二天中午12点再下一单,又平台订单ID到多少。这样就可以猜到平台1天能产生多少订单了。

Zookeeper有序节点

通过创建ZK的顺序模式的节点,可以生成全局唯一的ID。

你可能感兴趣的:(Java架构直通车)