009-如何生成分布式ID

Java知识点总结系列目录

  • 作用
在分布式集群系统中对数据和信息的唯一标识
  • 目标
  1. 全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。
  2. 趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
  3. 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。
  4. 信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。
  5. 分布式id里面最好包含时间戳,这样就能够在开发中快速了解这个分布式id的生成时间
  6. 可用性高:就是我用户发了一个获取分布式id的请求,那么你服务器就要保证99.999%的情况下给我创建一个分布式id
  7. 延迟低:就是我用户给你一个获取分布式id的请求,那么你服务器给我创建一个分布式id的速度就要快
  8. 高QPS:这个就是用户一下子有10万个创建分布式id请求同时过去了,那么你服务器要顶的住,你要一下子给我成功创建10万个分布式id
  • UUID
  1. 定义:Universally Unique Identifier。它是一串唯一随机32位长度数据,它是无序的一串数据,按照开放软件基金会(OSF)制定的标准计算,UUID的生成用到了以太网卡地址、纳秒级时间、芯片ID码和许多可能的数字。UUID的底层是由一组32位数的16进制数字构成。
  2. 格式:形式为 8-4-4-4-12,总共有 36个字符(即三十二个英数字母和四个连字号),如a23e4567-e79b-12d3-a456-426655440001(xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx)。其中M可选值为1, 2, 3, 4, 5,分表表示5个不同的版本
版本1:0001。基于时间和 MAC 地址。由于使用了 MAC 地址,因此能够确保唯一性,但是同时也暴露了 MAC 地址,私密性不够好。
版本2:0010。DCE 安全的 UUID。该版本在规范中并没有仔细说明,因此并没有具体的实现。
版本3:0011。基于名字空间 (MD5)。用户指定一个名字空间和一个字符串,通过 MD5 散列,生成 UUID。字符串本身需要是唯一的。
版本4:0100。基于随机数。虽然是基于随机数,但是重复的可能性可以忽略不计,因此该版本也是被经常使用的版本。
版本5:0101。基于名字空间 (SHA1)。跟 Version 3 类似,但是散列函数编程了 SHA1。

N开头的四个位表示 UUID 变体( variant ),变体是为了能兼容过去的 UUID,以及应对未来的变化,目前已知的变体有如下几种,因为目前正在使用的 UUID 都是 variant1,所以取值只能是 8,9,a,b 中的一个(分别对应1000,1001,1010,1011)

variant 0:0xxx。为了向后兼容预留。
variant 1:10xx。当前正在使用的。
variant 2:11xx。为早期微软 GUID 预留。
variant 3:111x。为将来扩展预留。目前暂未使用。
  1. 使用场景

因为UUID能保证唯一性,所以它能够唯一标识某个东西的存在,比如阿里云就用它作为每条短信发送的唯一id。但是它不适合做分布式ID,理由如下:

- 首先分布式id一般都会作为主键,但是安装mysql官方推荐主键要尽量越短越好,UUID每一个都很长,所以不是很推荐
- 既然分布式id是主键,然后主键是包含索引的,然后mysql的索引是通过b+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的b+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键地城的b+树进行很大的修改,这一点很不好
- 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
  • MySQL数据库自增ID

对于单击版的MySQL,只要设置字段为auto_increment就可以了,但对于分布式数据库就需要根据机器的数量来分别设置起始值和步长了。

对于N台机器的分布式集群数据库为例。编号从1到N,起始值可以分别设置为1,2,3…N,步长设置为N。这样对于编号1的服务器ID的值分别是1,N+1, 2N+1…对于编号为2的服务器ID值分别是2,N+2,2N+2…对于编号为N的服务器ID值则分别为N, N+N,N+2N…这样就实现了分布式数据库集群中ID的不重复自增。


Server 1
auto-increment-increment = 1
auto-increment-offset = N

Server 2
auto-increment-increment = 2
auto-increment-offset = N

...

Server N
auto-increment-increment = N
auto-increment-offset = N

但数据库的自增ID还是不适合作为分布式ID的处理方式,理由如下

1. 不利于集群的水平扩展,随着业务量的增长需要增加机器数量时,这种方案就会变得非常麻烦。
2. 每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面的延迟低和要高QPS的规则(在高并发下,如果都去数据库里面获取id,那是非常影响性能的)
3. ID为自增连续的,很容易被猜测攻击,安全方面得不到保障。
  • Redis生成分布式ID

单机的情况只要采用redis的incr原子操作就好了,这样产生的ID也是连续递增的。然而实际生产中Redis一般都采用集群的方式部署,我们这里主要讲一下分布式集群环境中分布式ID的生成方式。

我们采用64二进制位来表示一个ID,结果如下
009-如何生成分布式ID_第1张图片
其中第一位表示正数,然后41位表示毫秒数,然后12位表示节点数量,最后10位表示每毫秒内的序列号。从结构中我们可以计算出,采用上面结构的分布式ID,41位的毫秒数大致还可以用20年,集群节点数量可以达到4096个,每毫秒内每个节点可以产生1024个ID。采用java可以通过如下方法实现

/**
 * 传入当前时间的毫秒数,节点编号和序列号生成ID
 * 
 * @param miliSecond 毫秒数
 * @param shardId    所在节点编号(0-4095)
 * @param seq        每个节点每毫秒内的序列号(0-1023)
 * @return ID
*/
public static long generateId(long miliSecond, long shardId, long seq) {
	return (miliSecond << (12 + 10)) | (shardId << 10) | seq;
}

seq参数可以通过redis的incr命令来得到。若同一节点同一毫秒内(比如当前毫秒数为1592902770010, 节点编号为99)多次调用这个方法来生成ID时,可以设置key为99:1592902770010,通过对该key的incr操作的返回值来取到当前节点在当前时间下的seq值,范围是0-1023。如果涉及到原子操作问题,可以通过redis执行(eval或者evalsha)lua脚本来将多个redis操作放在同一个脚本中。miliSecond参数和seq参数都从redis返回,这样就保证了时间上是一致的。

采用Redis集群方式生成分布式ID在性能上已经大大超越了数据库自增ID,缺点在于

1. 集群横向扩展时也会比较麻烦。
2. 依赖于新的组件Redis,对于Redis的引入以及引入后带来的维护,高可用等等方面需要一定的成本(当然原有系统已经用了Redis的话这部分就捎带的意思了)。
  • 雪花算法Snowflake

009-如何生成分布式ID_第2张图片
雪花算法生成的ID和上面的Redis分布式ID大致相同,只是节点数量变成了10位来表示(也可以由5位数据中心+5位机器编号表示),每节点每毫秒内的序列号变成了12位来表示。这样节点数减少了,序列号增加了,每节点每毫秒内产生的ID的总数是一样的。

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;
import java.util.concurrent.atomic.AtomicLongArray;

/**
 * 雪花算法实现
 * 采用64表示的结构为:
 *  第一位为0表示正数
 *  然后41位表示时间戳毫秒数
 *  然后10位表示机器编号
 *  自后12位表示序列号
 *  
 *  为了防止时钟回拨问题,代码中保存了一个ID环
 */
public class SnowflakeTest {

    /**
     * 机器编号所占位数
     */
    private static final long MACHINE_BITS = 10;

    /**
     * 序列号所占位数
     */
    private static final long SEQUENCE_BITS = 12;

    /**
     * 时间戳在ID中左移的位数:机器位数+序列号位数
     */
    private static final long TIMESTAMP_SHIFT_COUNT = MACHINE_BITS + SEQUENCE_BITS;

    /**
     * 机器编号在ID中左移的位数:序列号位数
     */
    private static final long MACHINE_ID_SHIFT_COUNT = SEQUENCE_BITS;

    /**
     * 机器编号掩码
     */
    private static final long MACHINE_MASK = ~(-1L << MACHINE_BITS);

    /**
     * 序列号掩码
     */
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);

    /**
     * 开始的时间戳
     */
    private static long START_THE_WORLD_MILLIS;

    /**
     * 机器编号
     */
    private long machineId;


    /**
     * ID环,大小为200。
     * 可保存200毫秒内,每个毫秒数上一次的ID,时间回退的时候依赖与此。
     * 解决时间回退的关键,亦可在多线程情况下减少毫秒数切换的竞争。
     */
    private AtomicLongArray idCycle = new AtomicLongArray(200);


    static {
        //2020-01-01 00:00:00
        START_THE_WORLD_MILLIS = 1577808000000L;
    }

    /**
     * init方法中通过一起的方法获取本机的machineId
     *
     * @throws Exception
     */
    public void init() throws Exception {
        if (machineId == 0L) {
            //这里暂时随机取
            Random random = new Random();
            random.setSeed(System.currentTimeMillis());

            machineId = random.nextInt((int)MACHINE_MASK);
        }
        //获取的machineId 不能超过最大值
        if (machineId < 0L || machineId > MACHINE_MASK) {
            throw new Exception("the machine id is out of range,it must between 0 and 1023");
        }
    }

    /**
     * 生成分布式ID
     */
    public long genID() {
        do {
            // 获取当前时间戳,此时间戳是当前时间减去start the world的毫秒数
            long timestamp = System.currentTimeMillis() - START_THE_WORLD_MILLIS;

            // 获取当前时间在idCycle中的下标,用于获取环中上一个ID
            int index = (int) (timestamp % idCycle.length());

            long idInCycle = idCycle.get(index);

            //通过在idCycle获取到的idInCycle,计算上一个ID的时间戳
            long timestampInCycle = idInCycle >> TIMESTAMP_SHIFT_COUNT;

            // 如果timestampInCycle并没有设置时间戳,或时间戳小于当前时间,认为需要设置新的时间戳
            if (idInCycle == 0 || timestampInCycle < timestamp) {
                long id = timestamp << TIMESTAMP_SHIFT_COUNT | machineId << MACHINE_ID_SHIFT_COUNT;
                // 使用CAS的方式保证在该条件下,ID不被重复
                if (idCycle.compareAndSet(index, idInCycle, id)) {
                    return id;
                }
            }

            // 如果当前时间戳与idCycle的时间戳相等,表示是同一毫秒内产生的ID的情况
            // 如果当前时间戳小于idCycle的时间戳,表示时钟回拨的情况
            if (timestampInCycle >= timestamp) {
                long sequence = idInCycle & SEQUENCE_MASK;
                if (sequence >= SEQUENCE_MASK) {
                	//当前时间戳毫秒内序列号满了,则推迟到下一个毫秒内生成ID
                    System.out.println("over sequence mask :" + sequence);
                    continue;
                }
                long id = idInCycle + 1L;

                // 使用CAS的方式保证在该条件下,ID不被重复
                if (idCycle.compareAndSet(index, idInCycle, id)) {
                    return id;
                }
            }
        } while (true);
    }

    /**
     * 通过分布式ID获取其生成所在的机器编号
     *
     * @param id
     * @return
     */
    public static long getMachineId(long id) {
        return id >> MACHINE_ID_SHIFT_COUNT & MACHINE_MASK;
    }

    /**
     * 通过分布式ID获取其生成所在的序列号
     *
     * @param id
     * @return
     */
    public static long getSequence(long id) {
        return id & SEQUENCE_MASK;
    }

    /**
     * 通过分布式ID获取其生成所在的时间戳
     *
     * @param id
     * @return
     */
    public static long getTimestamp(long id) {
        return (id >>> TIMESTAMP_SHIFT_COUNT) + START_THE_WORLD_MILLIS;
    }
}

测试代码

	//测试代码
    public static void main(String[] args) {
        SnowflakeTest test = new SnowflakeTest();

        try {
            test.init();
        } catch (Exception e) {
            e.printStackTrace();
        }
		//10个线程内循环5次,共生成50个ID
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                for (int j = 0; j <5 ; j++) {
                    parseId(test.genID());
                }
            }, "thread_name"+String.valueOf(i)).start();
//            parseId(test.genID());
        }

    }

    private static void parseId(long id) {
        long miliSecond = getTimestamp(id);
        long machineId = getMachineId(id);
        long seq = getSequence(id);

        String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss S").format(new Date(miliSecond));


        System.out.println(Thread.currentThread().getName()+" [ID:"+id+"] [MachineID:"+machineId+"] [Sequence:"+seq+"] [Date:"+date+"]");
    }

由于雪花算法依赖与系统时钟,我们采用ID环来解决这个问题。

缩小版雪花算法

009-如何生成分布式ID_第3张图片
如上图,我们可以采用53位来构成ID,第一位表示正数,然后33位表示秒数(注意这里是秒数),然后用4位来表示节点数量,最后用15位来表示每秒内每节点产生的ID序列。这样对于中小公司是完全够用了。当然也可以根据实际的业务需要进行调整来契合业务需求。生成ID的区别也就在于各个部分左移的位数不一样,以上面53位分布式ID为例,得到当前系统的秒数左移19位,机器节点数量左移15位。

雪花算法生成的ID不依赖任何第三方组件,使用和扩展都比较方便

  • 总结

综上所述几种分布式ID的生成算法,根据分布式ID的几个目标我们可以看出雪花算法是最适合的一种方案。对于时钟回拨问题,采用上面的方式来解决,如果系统重启了会不会存在问题,需不需要持久化ID环,还有没有其他更好的解决方案,欢迎讨论。

你可能感兴趣的:(Java知识点总结系列,分布式ID,分布式,雪花算法)