分布式id生成的常用方法

一、为什么要用分布式ID?

在说分布式ID的具体实现之前,我们来简单分析一下为什么用分布式ID?分布式ID应该满足哪些特征?

1、什么是分布式ID?

对mysql来说,在我们业务数据量不大的时候,单库单表完全可以支撑现有业务,数据再大一点搞个MySQL主从同步读写分离也能对付。

但随着数据日渐增长,主从同步也扛不住住了,就需要对数据库进行分库分表,但分库分表后需要有一个唯一ID来标识一条数据,数据库的自增ID显然不能满足需求;特别一点的如订单、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的。那么这个全局唯一ID就叫分布式ID

 

2、全局唯一id特点

  1. 全局唯一性整个分布式服务系统中不能出现重复的ID。
  2. 单调递增:保证生成的下一个ID一定大于上一个ID,单调递增会顺序添加到数据索引的节点的后续位置,当一页写满,就会自动开辟一个新的页,不需要移动数据重构索引树,减少碎片。
  3. 趋势递增:在一段时间内,生成的ID是递增的趋势,提高写入性能。如:在一段时间内生成的ID在【0,1000】之间,过段时间生成的ID在【1000,2000】之间。但在【0-1000】区间内的时候,ID生成有可能第一次是12,第二次是10,第三次是14。
  4. 信息安全:如果 ID 是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞争对手可以直接知道我们一天的单量。所以在一些应用场景下,会需要无规则、不规则的 ID。

第2、4两个需求是互斥的,无法同时满足。 同时,在大型分布式网站架构中,除了需要满足ID生成自身的需求外,还需要ID生成系统可用性极高。因此,做一个全局唯一id生成系统必须满足以下特点:

  1. 高可用:高可用低延时,ID生成响应要块,否则反倒会成为业务瓶颈
  2. 高QPS。

 

 

二、分布式ID的实现技术

常见的分布式ID的生成如下

  • UUID
  • 数据库自增ID
  • 数据库多主模式
  • 号段模式
  • Redis
  • 雪花算法(SnowFlake)

 

1、基于UUID

想要得到一个具有唯一性的ID,首先被想到可能就是UUID,毕竟它有着全球唯一的特性。那么UUID可以做分布式ID吗?答案是可以的,但是并不推荐

1 public static void main(String[] args) { 
2        String uuid = UUID.randomUUID().toString().replaceAll("-","");
3        System.out.println(uuid);
4  }

UUID的生成简单到只有一行代码,核心思想是使用「机器的网卡、当地时间、一个随机数」来生成UUID。

优点:

  • 生成足够简单,本地生成无网络消耗,具有唯一性

缺点:

  • 无序的字符串,不具备趋势自增特性
  • 没有具体的业务含义
  • 长度过长16 字节128位,36位长度的字符串,存储以及查询对MySQL的性能消耗较大,MySQL官方明确建议主键要尽量越短越好,作为数据库主键 UUID 的无序性会导致数据位置频繁变动,严重影响性能。

 

2、基于数据库自增ID

基于数据库的auto_increment自增ID完全可以充当分布式ID,具体实现:需要一个单独的MySQL实例用来生成ID,建表结构如下:

1 CREATE DATABASE `SEQ_ID`;
2 CREATE TABLE SEQID.SEQUENCE_ID (
3     id bigint(20) unsigned NOT NULL auto_increment, 
4     value char(10) NOT NULL default '',
5     PRIMARY KEY (id),
6 ) ENGINE=MyISAM;

 

1 insert into SEQUENCE_ID(value) VALUES ('values');

当我们需要一个ID的时候,向表中插入一条记录返回主键ID。不推荐。

优点:

  • 实现简单,ID单调自增,数值类型查询速度快

缺点:

  • DB单点存在宕机风险,访问量激增时MySQL本身就是系统的瓶颈,无法扛住高并发场景

 

3、基于数据库集群模式

前边说了单点数据库方式不可取,那对上边的方式做一些高可用优化,换成主从模式集群。害怕一个主节点挂掉没法用,那就做双主模式集群,也就是两个Mysql实例都能单独的生产自增ID。

那这样还会有个问题,两个MySQL实例的自增ID都从1开始,会生成重复的ID怎么办?

解决方案:设置起始值和自增步长

MySQL_1 配置:

1 set @@auto_increment_offset = 1; -- 起始值
2 set @@auto_increment_increment = 2; -- 步长

MySQL_2 配置:

1 set @@auto_increment_offset = 2; -- 起始值
2 set @@auto_increment_increment = 2; -- 步长

这样两个MySQL实例的自增ID分别就是:

1、3、5、7、9

2、4、6、8、10

那如果集群后的性能还是扛不住高并发咋办?就要进行MySQL扩容增加节点,这是一个比较麻烦的事。

 

 

从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。

增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。

优点:

  • 解决DB单点问题

缺点:

  • 不利于后续扩容,而且实际上单个数据库自身压力还是大,依旧无法满足高并发场景。

 

4、基于数据库的号段模式 

号段模式是当下分布式ID生成器的主流实现方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存。表结构如下:

1 CREATE TABLE id_generator (
2     id int(10) NOT NULL,
3     max_id bigint(20) NOT NULL COMMENT '当前最大id',
4     step int(20) NOT NULL COMMENT '号段的步长',
5     biz_type    int(20) NOT NULL COMMENT '业务类型',
6     version int(20) NOT NULL COMMENT '版本号',
7     PRIMARY KEY (`id`)
8 ) 

biz_type :代表不同业务类型

max_id :当前最大的可用id

step :代表号段的长度

version :是一个乐观锁,每次都更新version,保证并发时数据的正确性

等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,

1 update max_id= max_id + step

update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]

1 update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX

由于多业务端可能同时操作,所以采用版本号Version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。

 

5、基于Redis模式

Redis也同样可以实现,原理就是利用redis的 incr命令实现ID的原子性自增。

INCR 命令主要有以下2个特征:

  1. Redis的INCR命令具备了"INCR AND GET"的原子操作,即增加并返回结果的原子操作。这个原子性很方便我们实现获取ID.
  2. Redis是单进程单线程架构,INCR命令不会出现id重复。

基于以上2个特性,我们可以采用INCR命令来实现分布式全局ID生成。

1 127.0.0.1:6379> set seq_id 1 // 初始化自增ID为1
2 OK
3 127.0.0.1:6379> incr seq_id // 增加1,并返回递增后的数值
4 (integer) 2

用redis实现需要注意一点,要考虑到redis持久化的问题。redis有两种持久化方式RDB和AOF

  • RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
  • AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。

 

6、基于雪花算法(Snowflake)模式

雪花算法(Snowflake)是twitter公司内部分布式项目采用的ID生成算法,开源后广受国内大厂的好评,在该算法影响下各大公司相继开发出各具特色的分布式生成器。

Snowflake生成的是Long类型的ID,一个Long类型占8个字节,每个字节占8比特,也就是说一个Long类型占64个比特。

Snowflake ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 数据中心(占5比特)+ 自增值(占12比特),总共64比特组成的一个Long类型。

  • 1bit,不用,因为二进制中最高位是符号位,1表示负数,0表示正数。生成的id一般都是用整数,所以最高位固定为0。

  • 41bit-时间戳,用来记录时间戳,毫秒级。

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

  • 10bit-工作机器id,用来记录工作机器id。
    - 可以部署在210 = 1024个节点,包括5位 datacenterId 和5位 workerId
    - 5位(bit)可以表示的最大正整数是25 - 1 = 31,即可以用0、1、2、3、....31这32个数字,来表示不同的datecenterId或workerId

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

 

根据这个算法的逻辑,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。

Twitter官方给出的算法实现 是用Scala写的,这里不做分析,可自行查看。

Java版算法实现:搬运自煲煲菜的博客

  1 package com.study.uuid;
  2 
  3 public class IdWorker {
  4 
  5     //下面两个每个5位,加起来就是10位的工作机器id
  6     private long workerId;    //工作id
  7     private long datacenterId;   //数据id
  8     //12位的序列号
  9     private long sequence;
 10 
 11     public IdWorker(long workerId, long datacenterId, long sequence) {
 12         // sanity check for workerId
 13         if (workerId > maxWorkerId || workerId < 0) {
 14             throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
 15         }
 16         if (datacenterId > maxDatacenterId || datacenterId < 0) {
 17             throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
 18         }
 19         System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
 20                 timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);
 21 
 22         this.workerId = workerId;
 23         this.datacenterId = datacenterId;
 24         this.sequence = sequence;
 25     }
 26 
 27     //初始时间戳
 28     private long twepoch = 1288834974657L;
 29 
 30     //长度为5位
 31     private long workerIdBits = 5L;
 32     private long datacenterIdBits = 5L;
 33     //最大值
 34     private long maxWorkerId = -1L ^ (-1L << workerIdBits);
 35     private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
 36     //序列号id长度
 37     private long sequenceBits = 12L;
 38     //序列号最大值
 39     private long sequenceMask = -1L ^ (-1L << sequenceBits);
 40 
 41     //工作id需要左移的位数,12位
 42     private long workerIdShift = sequenceBits;
 43     //数据id需要左移位数 12+5=17位
 44     private long datacenterIdShift = sequenceBits + workerIdBits;
 45     //时间戳需要左移位数 12+5+5=22位
 46     private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
 47 
 48     //上次时间戳,初始值为负数
 49     private long lastTimestamp = -1L;
 50 
 51     public long getWorkerId() {
 52         return workerId;
 53     }
 54 
 55     public long getDatacenterId() {
 56         return datacenterId;
 57     }
 58 
 59     public long getTimestamp() {
 60         return System.currentTimeMillis();
 61     }
 62 
 63     //下一个ID生成算法
 64     public synchronized long nextId() {
 65         long timestamp = timeGen();
 66 
 67         //获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
 68         if (timestamp < lastTimestamp) {
 69             System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
 70             throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
 71                     lastTimestamp - timestamp));
 72         }
 73 
 74         //获取当前时间戳如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始。
 75         if (lastTimestamp == timestamp) {
 76             sequence = (sequence + 1) & sequenceMask;
 77             if (sequence == 0) {
 78                 timestamp = tilNextMillis(lastTimestamp);
 79             }
 80         } else {
 81             sequence = 0;
 82         }
 83 
 84         //将上次时间戳值刷新
 85         lastTimestamp = timestamp;
 86 
 87         /**
 88          * 返回结果:
 89          * (timestamp - twepoch) << timestampLeftShift) 表示将时间戳减去初始时间戳,再左移相应位数
 90          * (datacenterId << datacenterIdShift) 表示将数据id左移相应位数
 91          * (workerId << workerIdShift) 表示将工作id左移相应位数
 92          * | 是按位或运算符,例如:x | y,只有当x,y都为0的时候结果才为0,其它情况结果都为1。
 93          * 因为个部分只有相应位上的值有意义,其它位上都是0,所以将各部分的值进行 | 运算就能得到最终拼接好的id
 94          */
 95         return ((timestamp - twepoch) << timestampLeftShift) |
 96                 (datacenterId << datacenterIdShift) |
 97                 (workerId << workerIdShift) |
 98                 sequence;
 99     }
100 
101     //获取时间戳,并与上次时间戳比较
102     private long tilNextMillis(long lastTimestamp) {
103         long timestamp = timeGen();
104         while (timestamp <= lastTimestamp) {
105             timestamp = timeGen();
106         }
107         return timestamp;
108     }
109 
110     //获取系统时间戳
111     private long timeGen() {
112         return System.currentTimeMillis();
113     }
114 
115     //---------------测试---------------
116     public static void main(String[] args) {
117         IdWorker worker = new IdWorker(1, 1, 1);
118         for (int i = 0; i < 30; i++) {
119             System.out.println(worker.nextId());
120         }
121     }
122 
123 }
View Code

 

 

三、其他方法

除了上述的几种方法,国内很多大厂也基于上述一些算法实现了自己的分布式ID生成算法,

如:

感兴趣的可以自行查看。

 

总结

本文只是简单介绍一下每种分布式ID生成器,旨在给大家做一个简单的了解,每种生成方式都有它自己的优缺点,具体如何使用还要看具体的业务需求。

posted @ 2022-01-09 16:01  r1-12king  阅读(273)  评论(0编辑  收藏  举报