分布式全局唯一ID生成策略及算法

分布式全局唯一id介绍

系统唯一id是我们在设计阶段常常遇到的问题。在复杂的分布式系统中,几乎都需要对大量的数据和消息进行唯一标识。在设计初期,我们需要考虑日后数据量的级别,如果可能会对数据进行分库分表,那么就需要有一个全局唯一id来标识一条数据或记录。生成唯一id的策略有多种,但是每种策略都有它的适用场景、优点以及局限性。

全局唯一id特点:

全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求;
趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能;
**单调递增:**保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求;
信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则;
高可用性:同时除了对ID号码自身的要求,业务还对ID号生成系统的可用性要求极高,想象一下,如果ID生成系统瘫痪,这就会带来一场灾难。所以不能有单点故障;
分片支持:可以控制ShardingId。比如某一个用户的文章要放在同一个分片内,这样查询效率高,修改也容易;
长度适中。

1、 用数据库的 auto_increment 来生成

优点:

  1. 此方法使用数据库原有的功能,所以相对简单
  2. 能够保证唯一性
  3. 能够保证递增性
  4. id 之间的步长是固定且可自定义的

缺点:

1、强依赖DB。不同数据库语法和实现不同,数据库迁移的时候、多数据库版本支持的时候、或分表分库的时候需要处理,会比较麻烦。当DB异常时整个系统不可用,属于致命问题。

2、单点故障。在单个数据库或读写分离或一主多从的情况下,只有一个主库可以生成。有单点故障的风险。

3、数据一致性问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。

4、难于扩展。在性能达不到要求的情况下,比较难于扩展。ID发号性能瓶颈限制在单台MySQL的读写性能。

改进方案:

  • 冗余主库,避免写入单点
  • 数据水平切分,保证各主库生成的ID不重复

2、使用UUID或者GUID

常见的生成id方式,利用程序生成。

扫描二维码关注公众号,回复: 14830202 查看本文章

UUID (Universally Unique Identifier) 的目的,是让分布式系统中的所有元素,都能有唯一的辨识资讯,而不需要透过中央控制端来做辨识资讯的指定。如此一来,每个人都可以建立不与其它人冲突的 UUID。在这样的情况下,就不需考虑数据库建立时的名称重复问题。

UUID的标准形式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符,示例:550e8400-e29b-41d4-a716-446655440000,到目前为止业界一共有5种方式生成UUID,详情见IETF发布的UUID规范 A Universally Unique IDentifier (UUID) URN Namespace。

在Java中我们可以直接使用下面的API生成UUID:

UUID uuid  =  UUID.randomUUID(); String s = UUID.randomUUID().toString();

优点

非常简单:本地生成,代码方便,API调用方便。
性能非高:生成的id性能非常好,没有网络消耗,基本不会有性能问题。
全球唯一:在数据库迁移、系统数据合并、或者数据库变更的情况下,可以 从容应对。
缺点

存储成本高:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用。如果是海量数据库,就需要考虑存储量的问题。
信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
不适用作为主键:ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用。UUID往往是使用字符串存储,查询的效率比较低。
UUID是无序的:不是单调递增的,而现阶段主流的数据库主键索引都是选用的B+树索引,对于无序长度过长的主键插入效率比较低。
传输数据量大
不可读

解决方案

为了解决UUID不可读, 可以使用UUID to Int64的方法 。

为了解决UUID无序的问题, NHibernate在其主键生成方式中提供了Comb算法(combined guid/timestamp)。保留GUID的10个字节,用另6个字节表示GUID生成的时间(DateTime)

3、Redis生成ID

当使用数据库来生成ID性能不够要求的时候,我们可以尝试使用Redis来生成ID。这主要依赖于Redis是单线程的,所以也可以用生成全局唯一的ID。可以用Redis的原子操作 INCRINCRBY 来实现。

优点:

  • 依赖于数据库,灵活方便,且性能优于数据库。
  • 数字ID天然排序,对分页或者需要排序的结果很有帮助。

缺点:

  • 如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。
  • 需要编码和配置的工作量比较大。

首先,要知道redis的EVAL,EVALSHA命令:

原理

利用redis的lua脚本执行功能,在每个节点上通过lua脚本生成唯一ID。

生成的ID是64位的:

使用41 bit来存放时间,精确到毫秒,可以使用41年。

使用12 bit来存放逻辑分片ID,最大分片ID是4095

【优点】

  1. 不依赖于数据库,灵活方便,且性能优于数据库。
  2. 数字ID天然排序,对分页或者需要排序的结果很有帮助。

【缺点】

  1. 如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。
  2. 需要编码和配置的工作量比较大。
  3. Redis单点故障,影响序列服务的可用性。

4、zookeeper生成ID

​ zookeeper主要通过其znode数据版本来生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号。

很少会使用zookeeper来生成唯一ID。主要是由于需要依赖zookeeper,并且是多步调用API,如果在竞争较大的情况下,需要考虑使用分布式锁。因此,性能在高并发的分布式环境下,也不甚理想。

5、Twitter 开源的 Snowflake 算法

snowflake 是 twitter 开源的分布式ID生成算法,其核心思想为,一个long型的ID:

  • 41 bit 作为毫秒数 - 41位的长度可以使用69年
  • 10 bit 作为机器编号 (5个bit是数据中心,5个bit的机器ID) - 10位的长度最多支持部署1024个节点
  • 12 bit 作为毫秒内序列号 - 12位的计数顺序号支持每个节点每毫秒产生4096个ID序号

image-20210830112559600

其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。具体实现的代码可以参看github。

snowflake算法可以根据自身项目的需要进行一定的修改。比如估算未来的数据中心个数,每个数据中心的机器数以及统一毫秒可以能的并发数来调整在算法中所需要的bit数。

java实现方法

public class SnowflakeIdGenerator {
    
    

    private final long startTime = 1498608000000L;
    // 机器id所占的位数
    private final long workerIdBits = 5L;
    // 数据标识id所占的位数
    private final long dataCenterIdBits = 5L;
    // 支持的最大机器id(十进制),结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
    // -1L 左移 5位 (worker id 所占位数) 即 5位二进制所能获得的最大十进制数 - 31
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
    // 支持的最大数据标识id - 31
    private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
    // 序列在id中占的位数
    private final long sequenceBits = 12L;
    // 机器ID 左移位数 - 12 (即末 sequence 所占用的位数)
    private final long workerIdMoveBits = sequenceBits;
    // 数据标识id 左移位数 - 17(12+5)
    private final long dataCenterIdMoveBits = sequenceBits + workerIdBits;
    // 时间截向 左移位数 - 22(5+5+12)
    private final long timestampMoveBits = sequenceBits + workerIdBits + dataCenterIdBits;
    // 生成序列的掩码(12位所对应的最大整数值),这里为4095 (0b111111111111=0xfff=4095)
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);
    //=================================================Works's Parameter================================================
    /**
     * 工作机器ID(0~31)
     */
    private long workerId;
    /**
     * 数据中心ID(0~31)
     */
    private long dataCenterId;
    /**
     * 毫秒内序列(0~4095)
     */
    private long sequence = 0L;
    /**
     * 上次生成ID的时间截
     */
    private long lastTimestamp = -1L;
    //===============================================Constructors=======================================================
    /**
     * 构造函数
     *
     * @param workerId     工作ID (0~31)
     * @param dataCenterId 数据中心ID (0~31)
     */
    public SnowflakeIdGenerator(long workerId, long dataCenterId) {
    
    
        if (workerId > maxWorkerId || workerId < 0) {
    
    
            throw new IllegalArgumentException(String.format("Worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
    
    
            throw new IllegalArgumentException(String.format("DataCenter Id can't be greater than %d or less than 0", maxDataCenterId));
        }
        this.workerId = workerId;
        this.dataCenterId = dataCenterId;
    }
    // ==================================================Methods========================================================
    // 线程安全的获得下一个 ID 的方法
    public synchronized long nextId() {
    
    
        long timestamp = currentTime();
        //如果当前时间小于上一次ID生成的时间戳: 说明系统时钟回退过 - 这个时候应当抛出异常
        if (timestamp < lastTimestamp) {
    
    
            throw new RuntimeException(
                    String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }
        //如果是同一时间生成的,则进行毫秒内序列
        if (lastTimestamp == timestamp) {
    
    
            sequence = (sequence + 1) & sequenceMask;
            //毫秒内序列溢出 即 序列 > 4095
            if (sequence == 0) {
    
    
                //阻塞到下一个毫秒,获得新的时间戳
                timestamp = blockTillNextMillis(lastTimestamp);
            }
        }
        //时间戳改变,毫秒内序列重置
        else {
    
    
            sequence = 0L;
        }
        //上次生成ID的时间截
        lastTimestamp = timestamp;
        //移位并通过或运算拼到一起组成64位的ID
        return ((timestamp - startTime) << timestampMoveBits) //
                | (dataCenterId << dataCenterIdMoveBits) //
                | (workerId << workerIdMoveBits) //
                | sequence;
    }
    // 阻塞到下一个毫秒 即 直到获得新的时间戳
    protected long blockTillNextMillis(long lastTimestamp) {
    
    
        long timestamp = currentTime();
        while (timestamp <= lastTimestamp) {
    
    
            timestamp = currentTime();
        }
        return timestamp;
    }
    // 获得以毫秒为单位的当前时间
    protected long currentTime() {
    
    
        return System.currentTimeMillis();
    }
    //====================================================Test Case=====================================================
    public static void main(String[] args) {
    
    
        SnowflakeIdGenerator idWorker = new SnowflakeIdGenerator(0, 0);
        for (int i = 0; i < 1000; i++) {
    
    
            long id = idWorker.nextId();
            System.out.println(Long.toBinaryString(id));
            System.out.println(id);
        }
    }
}  

优点

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

强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

ID可能不是全局递增。在单机上是递增的,但是由于涉及到分布式环境,每台机器上的时钟不可能完全同步,也许有时候也会出现不是全局递增的情况。
关注我的微信公众号

​​
​​​​在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/CharlesYooSky/article/details/120075054