如何设计和实现全局唯一性ID?

今天,我将和你一起分析在分布式环境下,如何设计并实现全局唯一性ID。让我们先来考虑这样一个场景。我们知道在传统数据库软件开发中,主键自动生成技术是基本需求。而各个数据库对于该需求也提供了相应的支持,比如MySQL的自增键,Oracle的自增序列等。

现在,我们需要考虑在分布式环境下的应用场景。在分布式环境下,存在多个数据库,应用程序中的同一份业务数据会根据不同的路由策略进行分片处理,也就说数据会保存在不同的数据库中,这样问题就变得有点复杂。显然,我们不能依靠单个数据库实例上的自增键来实现不同数据节点之间的全局唯一主键。分布式主键的需求就应运而生,这是全局唯一性ID的一个典型应用场景。例如下图中,系统存在一张user表,并对该表进行了分库操作,这样在db0和db1这两个数据库中都将保存user表信息,并需要确保这些表中的主键userid全局唯一。


在明确了全局唯一性ID的典型应用场景之后,我们来讨论它的实现方式。在日常开发过程中,存在一些实现全局唯一性ID的解决方案,常见的包括基于UUID的实现机制、采用Snowflake算法,以及改进版的LeafSegment和LeafSnowflake算法。事实上,在本课程后面将要介绍的主流分库分表中间件ShardingSphere中,正是基于这些全局唯一性ID解决方案来实现了它的分布式自增主键。

我们首先介绍最简单的UUID方案。UUID是Universally Unique Identifier的缩写,代表的是一种通用的唯一识别码。UUID的实现方式非常直接,基于Java语言,我们甚至只需要一句代码就可以使用UUID来提供全局唯一的ID:

UUID.randomUUID().toString();

然后,我们再来看SnowFlake(雪花)算法。SnowFlake算法是由Twitter开源的分布式ID生成算法,其核心思想是使用一个64bit的long型数字来作为全局唯一ID,且ID 的生成过程中引入了时间戳,基本上能够保持自增。SnowFlake算法在分布式系统中的应用十分广泛,该算法中64bit的详细结构存在一定的规范。


在上图中,我们可以把64bit分成了四个部分:

  1. 符号位

第一个部分即第一个bit,值为0,没有实际意义。

  1. 时间戳位

第二个部分是41个bit,表示的是时间戳。41位的时间戳可以容纳的毫秒数是2的41次幂,一年所使用的毫秒数是365 * 24 * 60 * 60 * 1000,即69.73年。也就是说,假设我们从2020年一月一日的零点开始,那么SnowFlake算法的时间纪元可以使用到2089年,相信能满足绝大部分系统的要求。

  1. 工作进程位

第三个部分是10个bit,表示的是工作进程位,其中前5个bit代表机房id,后5个bit代表机器id。

  1. 序号位

第四个部分是12个bit,表示的是序号,就是某个机房某台机器上这一毫秒内同时生成的ID序号。如果在这个毫秒内生成的数量超过4096(即2的12次幂),那么生成器会等待到下个毫秒继续生成。

因为SnowFlake算法依赖于时间戳,所以还有一种场景我们需要考虑,即时钟回拨。所谓的时钟回拨,是指服务器因为时间同步,而导致某一部分机器的时钟回到了过去的一个时间点。显然,时间戳的回滚会导致生成一个已经使用过的ID,因此默认分布式主键生成器提供了一个最大容忍的时钟回拨毫秒数。如果时钟回拨的时间超过最大容忍的毫秒数阈值,则程序报错;如果在可容忍的范围内,默认分布式主键生成器会等待时钟同步到最后一次主键生成的时间后再继续工作。

了解了SnowFlake算法的基本概念之后,我们来看它的具体实现过程,这里我们先给出该算法的伪代码实现。

    generateSnowflakeId() {

     //获取当前时间戳

        

        //如果出现了时钟回拨,则抛出异常或进行时钟等待

        if (当前时间 < 上次记录时间) {

//抛出异常

            //或者重置最新时间

        }

        

        //如果上次的生成时间与本次的是同一毫秒

        if (上次记录时间毫秒数 == 当前时间毫秒数) {

         //计算同一毫秒内的序号

//如果在同一毫秒内无法获取下一个序号

            if (同一毫秒内序号溢出) {

             //则需要等待下一个毫秒继续生成

            }

        } else { 

            //如果不是同一毫秒,则生成新的序号

        }

        

//更新次记录时间毫秒数

        

        //生成64bit的唯一性ID

        return 全局唯一性ID

}

上述这段伪代码已经给出了如何基于SnowFlake算法生成全局唯一性ID的整体流程。我们首先获取系统的当前时间,然后判断当前时间与上次记录时间之间的时间间隔,如果当前之间小于上次记录时间,那就意味了已经出现了时钟回拨现象。这时候有两种处理方法,一种是直接抛出异常,一种则是重置最新时间,也就是说把上次记录时间更新为当前时间。

然后,如果上次生成唯一ID的时间与本次的是同一毫秒,那么就获取这一毫秒下的下一个序号。这时候同样可能会有两种情况,一种是在这一毫秒内已经无法获取下一个序号(因为序号位已经超过了2的12次幂),那么系统需要等待下一个毫秒继续进行生成;反之,则生成新的序号。当然,如果上次生成唯一ID的时间与本次的并不是在同一毫秒,那么可以直接生成新的序号。

一旦获取了新的序号之后,系统就需要更新上次记录时间毫秒数,并生成唯一性ID。这个生成方法会根据当前时间戳、工作进程、以及所获取的序号拼接成一个64位的字符串。

目前市面上关于SnowFlake算法的实现代码也基本上是遵循这个流程。这里,我们以分库分表中间件ShardingSphere为例来介绍SnowFlake算法的代码实现以及它作为分布式主键的应用方式。ShardingSphere是一款知名的开源软件,目前已经成为Apache顶级项目,Github上超过一万星。

在ShardingSphere中,我们先来分析ShardingKeyGenerator接口,该接口用于提供分库分表环境下的分布式主键:

public interface ShardingKeyGenerator extends TypeBasedSPI {    

    Comparable<?> generateKey();

}

ShardingSphere中的SnowflakeShardingKeyGenerator类实现了这个ShardingKeyGenerator接口。SnowFlake是ShardingSphere默认的分布式主键生成策略。

首先在SnowflakeShardingKeyGenerator类中存在一批常量的定义,用于维护SnowFlake算法中各个bit之间的关系,同时还存在一个TimeService用于获取当前的时间戳。而SnowflakeShardingKeyGenerator的核心方法generateKey负责生成具体的全局唯一性ID,我们这里给出详细的代码,并为每行代码都添加注释:

@Override

    public synchronized Comparable<?> generateKey() {

     //获取当前时间戳

        long currentMilliseconds = timeService.getCurrentMillis();

        

        //如果出现了时钟回拨,则抛出异常或进行时钟等待

        if (waitTolerateTimeDifferenceIfNeed(currentMilliseconds)) {

            currentMilliseconds = timeService.getCurrentMillis();

        }

        

        //如果上次的生成时间与本次的是同一毫秒

        if (lastMilliseconds == currentMilliseconds) {

         //这个位运算保证始终就是在4096这个范围内,避免你自己传递的序号超过了4096这个范围

            if (0L == (sequence = (sequence + 1) & SEQUENCE_MASK)) {

                //如果位运算结果为0,则需要等待下一个毫秒继续生成

                currentMilliseconds = waitUntilNextTime(currentMilliseconds);

            }

        } else {//如果不是,则生成新的序号

            vibrateSequenceOffset();

            sequence = sequenceOffset;

        }

        lastMilliseconds = currentMilliseconds;

        

        //先将当前时间戳左移放到完成41bit,然后将工作进程为左移到10bit,再将序号为放到最后的12bit最后拼接起来成一个64 bit的二进制数字

        return ((currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) | sequence;

}

可以看到这里遵循了前面提到的Snowflake算法的整体流程,综合考虑了时钟回拨、同一个毫秒内请求等设计要素,从而完成了SnowFlake算法的具体实现。ShardingSphere中最大容忍的时钟回拨毫秒数的默认值为0,开发人员可以通过属性进行设置。

可以看到,如果我们自己实现类似SnowflakeShardingKeyGenerator中提供的Snowflake算法实际上是很有难度的,而且也属于重复造轮子。在日常开发过程中,我不建议你采用这种方法来实现全局唯一性ID。那么,有没有更简单的实现方法呢?答案是肯定的。美团为我们提供了Leaf开源框架来实现同样的功能。

Leaf框架提供了两种生成全局唯一性ID的方式,一种是号段(Segment)模式,一种是前面介绍的Snowflake模式。无论使用哪种模式,我们都需要提供一个leaf.properties文件,并设置对应的配置项。同时,应用程序都需要设置一个leaf.key:

# for keyGenerator key

leaf.key=mytestkey

如果使用号段模式,Leaf框架需要依赖于一张数据库表来存储运行时数据,因此需要在leaf.properties文件中添加数据库的相关配置:

# for LeafSegment

leaf.jdbc.url=jdbc:mysql://127.0.0.1:3306/test?serverTimezone=UTC&useSSL=false

leaf.jdbc.username=root

leaf.jdbc.password=123456

基于这些配置,我们就可以创建对应的DataSource,并进一步创建用于生成分布式ID的IDGen实现类,这里创建的是基于号段模式的SegmentIDGenImpl实现类:

//通过DruidDataSource构建数据源并设置属性

DruidDataSource dataSource = new DruidDataSource();

                dataSource.setUrl(properties.getProperty(LeafPropertiesConstant.LEAF_JDBC_URL));

                dataSource.setUsername(properties.getProperty(LeafPropertiesConstant.LEAF_JDBC_USERNAME));

                dataSource.setPassword(properties.getProperty(LeafPropertiesConstant.LEAF_JDBC_PASSWORD));

dataSource.init();

                

//构建数据库访问Dao组件

IDAllocDao dao = new IDAllocDaoImpl(dataSource);

//创建IDGen实现类

this.idGen = new SegmentIDGenImpl();

//Dao组件绑定到IDGen实现类

 ((SegmentIDGenImpl) this.idGen).setDao(dao);

this.idGen.init();

this.dataSource = dataSource;

一旦我们成功创建了IDGen实现类,可以通过该类来生成目标ID,代码实现上通过构建一个Result对象并调用它的getId()方法就可以了:

Result result = this.idGen.get(properties.getProperty(LeafPropertiesConstant.LEAF_KEY));

result.getId();

介绍完LeafSegment模式之后,我们再来看LeafSnowflake。LeafSnowflake的实现依赖于分布式协调框架Zookeeper,所以在配置文件中需要指定Zookeeper的目标地址:

# for LeafSnowflake

leaf.zk.list=localhost:2181

创建用于LeafSnowflake的IDGen实现类SnowflakeIDGenImpl相对比较简单,我们直接在构造函数中设置Zookeeper地址就可以了:

IDGen idGen = new SnowflakeIDGenImpl(properties.getProperty(LeafPropertiesConstant.LEAF_ZK_LIST), 8089);

同样,通过IDGen获取模板ID的方式是一致的: 

Result result = this.idGen.get(properties.getProperty(LeafPropertiesConstant.LEAF_KEY));

result.getId();

显然,基于Leaf框架实现Segment模式和Snowflake模式下的分布式ID生成方式非常简单,Leaf框架为我们屏蔽了内部实现的复杂性。在ShardingSphere中,同样也基于这两种模式分别提供了LeafSegmentKeyGenerator和LeafSnowflakeKeyGenerator类。

在日常开发过程中,我们可以使用Snowflake算法以及Leaf框架来提供分布式环境下的全局唯一性ID。作为总结,我们来梳理一下与全局唯一性ID相关的各个知识点。我们首先介绍了全局唯一性ID的一个典型应用场景,也就是分库分表下的分布式自增主键。然后,我们给出了生成全局唯一性ID的代表性算法,也就是Snowflake算法的实现流程,并基于ShardingSphere这款知名开源框架给出了这个流程的具体代码实现。最后,我们进一步引出了美团的Leaf框架,基于这个框架实现LeafSegment算法和LeafSnowflake算法将变得非常简单。

在日常开发过程中,我们一般建议直接使用Leaf框架来实现全局唯一性ID。当然,我们也可以采用像ShardingSphere一样从零实现Snowflake算法,根据需要提炼一些控制算法运行效果的配置参数,并将这些算法嵌入到具体的业务场景中。

  • 31
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值