常用的分布式ID解决方案原理解析

目录

前言

一:分布式ID的使用场景

二:分布式ID设计的技术指标

三:常见的分布式ID生成策略

[3.1 UUID](#3.1 UUID)

[3.2 数据库生成](#3.2 数据库生成)

[3.3 数据库的多主模式](#3.3 数据库的多主模式)

[3.4 号段模式](#3.4 号段模式)

[3.5 雪花算法](#3.5 雪花算法)


前言

分布式ID的生成是分布式系统中非常核心的基础性模块,其常用于在分布式环境下作为数据或消息的唯一性的标识。

在互联网发展早期,由于用户量较少,业务需求也比较简单。对于软件应用,我们只需要一台高配置的服务器,把业务所有模块全单机部署,这样的软件架构模式称为单体架构模式。

在这种架构模式下,为了为数据生成唯一性的标识ID,一般有以下两种方案

1) 在服务器层面,主动的填充好ID,再将数据记录保存数据库。

2) 强依赖于数据库表的自增ID,服务器生成的数据记录不需要先填充ID,交由数据库来自动填充自增ID。

随着用户量的增加,客户端请求的并发量越来越大,在这个过程中单体架构因为其单机硬件资源的瓶颈造成以下问题

1)随着并发量的逐渐增大,客户端的请求RT时间越来越大,请求超时失败概率越来越大。

2)单机架构会存在单点故障问题。

为了解决上述的问题,在并发量较高的场景中我们会进行分库分表,将数据库进行集群化部署。

在复杂的分布式系统中,我们需要对大量的数据和消息进行唯一性标识,在数据库进行分库分表后,数据库表的自增ID显然不满足需求。此时急需一个分布式ID的生成方案。


一:分布式ID的使用场景

分布式ID的使用前提就是在复杂的分布式系统中,我们需要对大量的数据和消息进行唯一性标识。

1)用户,会员等成员身份的唯一标识。

2)订单,支付,金融等业务场景。

3) 优惠卷的兑换码

4) 加密文件的密码

总之,需要在复杂分布式系统中,需要对大量数据和消息进行唯一性标识,那么就应当使用分布式ID。


二:分布式ID设计的技术指标

首先,实现分布式全局唯一ID的解决方案有很多,比如说Mysql的主键维护表,数据库的号段模式,Zookeeper的有序节点,MongoDB的ObjectID,Redis的自增ID,UUID,雪花算法........

以上方案自然满足了在分布式环境下生成ID的唯一性的标准,但是在实际生产环境中,考虑一个分布式ID解决方案是否达标,还应该考虑其它的技术指标。

分布式ID设计的技术指标可以用来评估一个分布式ID解决方案是否满足实际的生产环境,也可以用来评估分布式ID解决方案的优劣。

1)全局唯一性:分布式ID的全局唯一性是 最基础的指标 ,是分布式ID解决方案 必须 保证的指标。

2)有序性:分布式ID常用来作为数据库的主键存在,例如Mysql数据库,有序的ID能够显著提升B+树的维护效率,避免因为ID的无序性导致数据新增时造成频繁的页分裂,避免大量数据迁移造成的大量磁盘随机IO的代价。

3)安全性: 分布式ID不一定是无规则,杂乱无章的,通常还会代表一些实际的意义。(比如 生成当前分布式ID的时间 ,用于 标识当前机器的唯一标识 等)。正因为有了这些实际的意义,可以方便于进行统计,数据分析等工作。 但分布式ID不因该携带 敏感信息 ,否则可能被恶意爬取数据造成数据泄露风险。

4)可用性:分布式ID是分布式系统中非常核心的基础模块,众多业务都依赖于分布式ID,来对大量数据和消息进行唯一性标识。因此实际生产环境对ID生成系统的可用性要求极高,一旦ID生成系统出现不可用,可能会导致整个业务系统瘫痪。

5)高性能:分布式唯一ID生成系统需要满足整个公司业务的需求,可能涉及亿级别的调用,对性能要求较高。


三:常见的分布式ID生成策略

3.1 UUID

UUID(Universally Unique Identifier),通用唯一识别码。UUID到目前为止总共迭代了五个版本,每个版本生成UUID的算法都不一样,适用于不同的场景。

UUID 是由一组32位数的16进制数字所构成,以连字号分隔的五组来显示,形式为 8-4-4-4-12,总共有 36个字符(即三十二个英数字母和四个连字号),占36个字节的存储空间。例如:

aefbbd3a-9cc5-4655-8363-a2a43e6e6c80
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

UUID是基于当前时间、计数器(counter)和硬件标识(通常为无线网卡的MAC地址)等数据计算生成的。

此版本的UUID由以下几个部分组成:

1)当前日期和时间,可以保证在不同日期和时间生成的ID一定不同。

2)自增的计数器,可以保证在并发环境下的性能,相同时间内并发生成的多个UUID的计数器是自增的。

3) 当前机器的唯一标识码,全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。可以保证不同机器生成的UUID一定不同。

当然UUID也有根据 随机数,基于名字空间/名字的散列值 (MD5/SHA1) 生成等生成的版本。

如果仅仅只是要求生成的ID唯一性,那么UUID也是可以使用的。但根据上面讲的分布式ID设计的技术指标,UUID其实是不能作为分布式ID的,原因如下:

  1. 首先分布式id一般都会作为主键,但是安装mysql官方推荐主键要尽量越短越好,UUID每一个都很长,所以不是很推荐

  2. 既然分布式id是主键,然后主键是包含索引的,然后mysql的索引是通过b+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的b+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键生成的b+树进行很大的修改,这一点很不好

  3. 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。

那么哪些情况下使用UUID也是可以的呢?

**一般满足下面三个条件才建议使用UUID

  1. 不用ID做数据库的主键(Mysql的主键)
  2. 只要求生成ID的唯一性
  3. 对ID的顺序性不做要求
  4. 并发量不是很高的场景
    例如:
  5. 做优惠卷的兑换码
  6. 身份唯一标识(会员)
  7. 需要解密才能打开的文件的解密密码**

但一般场景中对于分布式唯一ID的使用场景都不会去考虑UUID


3.2 数据库生成

针对于数据库表结构的主键,在单机情况下,我们可以在创建表的时候,设置auto_increment参数,依赖于数据库本身表结构的自增来保证ID的唯一性。

但在复杂的分布式场景中,面对越来越高的并发请求,一般都会进行分库分表的配置,而数据库的auto_increment只能保证单个数据库的单个表ID的唯一性,无法实现全局唯一ID。

解决方案就是创建一个主键维护表,在将消息和数据进行存储之前,先向统一的主键维护表获取一个ID,封装好数据记录后再向分库分表的数据库进行存储,不依赖于单个数据库的自增来保证ID的唯一性。

案例讲解

1. 主键维护表的创建

sql 复制代码
CREATE TABLE `test_order_id`  (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `title` char(1) NOT NULL,
  PRIMARY KEY (`id`),
	UNIQUE KEY `title` (`title`)
) ENGINE = InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET =utf8;

2. 向主键维护表中获取ID

sql 复制代码
BEGIN;

REPLACE INTO test_order_id (title) values ('p') ;
SELECT LAST_INSERT_ID();

COMMIT;

这种方案的优缺点如下:

优点:

1) 代码简单,实现轻量化,可以在原有的数据库系统实现,成本较低。

2)可以生成全局唯一且递增的ID,满足需要ID递增的特殊业务场景。

缺点:

1)存在单点故障的问题,一旦主键维护表所在的数据库服务器发生单点故障,可能会导致整个业务系统瘫痪。

2)存在较为明显的性能瓶颈,仅能支持并发量较小的场景。基于磁盘做数据存储的数据库,其性能受制于磁盘IO。当前方案性能受制于单台Mysql数据库的IO能力。

虽然第一个缺点,我们可以配置数据库主从高可用模式来解决,但是数据库主从架构模式下,怎么去保证数据的一致性是一个很难办的问题。可能会存在主从切换时,因为数据不一致,从而导致Mysql数据维护表发出的ID出现重复(而这是分布式ID生成器绝对不能容忍的问题)。

哪怕我们通过数据主从高可用模式解决了单点故障问题,但其性能仍然受制于单台Mysql数据库的IO能力,满足不了并发量稍高的生产环境。


3.3 数据库的多主模式

单节点数据库方式存在明显的性能问题,我们可以采用数据库的多主集群模式,既可以解决单机数据库存在的单点故障问题,又可以在一定程度上解决单点数据库存在的性能瓶颈问题。

所谓的多主集群模式,指的是用多台数据库服务器作为主键维护表,都可以单独生产自增ID,且各自生产的自增ID不会重复。

这里就以数据库的双主集群模式为例子,讲讲如何实现数据库的多主集群模式。

实现数据库的多主集群模式主要关注以下两个参数

**1)**auto-increment-increment 自增的步长

2)auto-increment-offset 自增的起始值

核心思路就是设置多台数据库以一定的步长和不同的起始自增值。

那么实现双主集群模式只需要让其中一台生产偶数趋势递增的ID,让另外一台生产奇数趋势递增的ID。也就是进行如下配置:

TicketServer1:

auto-increment-increment = 2

auto-increment-offset = 1

TicketServer2:

auto-increment-increment = 2

auto-increment-offset = 2

这种架构模式貌似满足了性能的需求,且实现了高可用,但任然存在以下的缺点:

1) 在并发量比较高的时候,可能需要进行扩容,但此架构设计进行水平拓展的代价十分高昂。

2)在高并发的情况下显得难以为继,基于磁盘存储的数据库受制于磁盘IO性能,有其天生的性能缺陷。虽然可以通过堆机器在一定程度上解决性能瓶颈,但代价十分高昂。

3)每次获取分布式唯一ID的时候,都需要进行访问数据库,对Mysql压力过大。


3.4 号段模式

单纯的使用数据库主键维护表生产分布式ID会有性能瓶颈问题,哪怕使用数据库多主集群模式来提升性能,仍然会出现在高并发情况下,每次获取分布式唯一ID的时候,都需要进行数据库访问,对Mysql的压力过大。

数据库的号段模式可以理解为从数据库中批量的获取自增ID,每次从数据库中获取一个号段范围,例如[1,1000]代表1000个ID,具体的分布式ID发号器将号段缓存到内存中,然后通过自增的方式来发放分布式ID。

实现案例讲解:

1. 数据库中创建业务号段维护表

sql 复制代码
CREATE TABLE id_generator (
  id int(10) NOT NULL AUTO_INCREMENT,
  max_id bigint(20) NOT NULL COMMENT '当前最大id',
  step int(20) NOT NULL COMMENT '号段的布长',
  biz_type    int(20) NOT NULL COMMENT '业务类型',
  version int(20) NOT NULL COMMENT '版本号',
  PRIMARY KEY (`id`)
) 

下面解释业务号段维护表中各个字段的含义

id:自增主键标识唯一记录

max_id: 当前业务可用的最大ID的编号,每次领取号段的时候都会更新此字段

step: 当前业务单次领取号段的步长

biz_type: 业务的类型编号

version:乐观锁,每次更新 max_id 时都会更新版本号,用来保证并发环境下的正确性

2. 分布式ID发号器从业务号段表中获取新的号段

sql 复制代码
BEGIN;

select max_id,version,step from id_generator
where biz_type=current_group ;# current_group 为传入的参数

update id_generator set max_id=max_id+step # step 为传入的参数
and version=version+1 where version=excpet_version # excpet_version 为期望的版本号

COMMIT;

3.分布式ID发号器通过CAS领取号段

分布式ID发号器在向数据库业务号段维护表请求新的号段的时候可能会因为以下两个原因失败:

1. 网络发生故障或者拥塞,导致请求出现任意量的丢包或者延迟。

2. 当分布式ID发号器进行水平拓展时,可能会有多个分布式ID发号器并发向数据库业务号段表去申请同一个业务下的号段,可能会出现并发错误;因此需要CAS(compare and swap),比较版本号是否与第一个查询语句查出的版本号一致,如果一致再执行第二个update语句。

因此再执行完上述SQL语句后,得判断update语句是否修改了记录,如果没有修改说明CAS失败,需要重试,直到成功修改记录说明向数据库业务号段表领取号段成功。

这种方式的优缺点如下:

优点:

1. 高性能:由于批量的从数据库获取自增ID,其性能摆脱了基于磁盘存储的数据库的限制,一般分布式ID发号器会通过RPC对外提供服务,那么其性能瓶颈主要在网络带宽。但其性能一般都能到达几十万QPS,在实际高并发业务场景中完全足够。

2. ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。

3. 容灾性高,虽然强依赖于数据库,但是分布式ID发号器可以内存级别缓存一定的号段,即使数据库宕机也能提供一段时间的服务。(建议号段的步长step设置为QPS的600倍,数据库宕机后也可以用10min)。

缺点:

1. 强依赖于数据库,要求数据库有很高的可用性。数据库主从架构模式下,怎么去保证数据的一致性是一个很难办的问题。可能会存在主从切换时,因为数据不一致,从而导致Mysql数据维护表发出的ID出现重复。需要主从同步复制来保证。

2. ID号码不够随机,能够泄露发号数量的信息,不太安全。不适合做订单的ID。

3. 当号段使用完后,需要向数据库请求新的号段,此时数据库的性能会卡在数据库的IO性能上。


3.5 雪花算法

Snowflake,雪花算法是有Twitter开源的分布式ID生成算法,以划分命名空间的方式将64bit位分割成了多个部分,每个部分都有具体的不同含义,在Java中64Bit位的整数是Long类型,所以在Java中Snowflake算法生成的ID就是long来存储的。具体如下:

雪花算法是基于时间戳和自增序列号的方式来保证单机生成的ID的唯一性,通过对不同机器设置不同的工作机器id,来确保在复杂的分布式环境中,生成的ID是满足全局唯一性的。

第一部分:符号位,占用1个比特位,用来标识是正负数,但一般情况下均是正数

第二部分:41bit位的时间戳,用来标识生成此ID的时间到起始时间的差值(单位是ms)。那么雪花算法的使用年限:(2^41)/(1000*60*60*24*365)=69年

第三部分:占用10bit位,表示雪花算法支持的机器数,即最多支持 2^10=1024台机器,但通常不会部署这么多台机器。注意一个业务组下多台分布式ID发号器的工作机器id必须不同。

第四部分:占用12bit位,表示的是毫秒内的自增序号,由于 2^12=4096个 ,所以雪花算法最多1ms内生成4096个ID。

下面给出用Java语言实现的Twitter雪花算法

java 复制代码
public interface IIdGenerator {

    /**
     * ID生成器策略接口
     * 获取生成的下一个ID
     * @return
     */
    public long nextId();

}
java 复制代码
/**
 * @ClassName SnowFlake
 * @Description TODO: 雪花算法ID生成策略
 * 雪花算法是有Twitter开源的分布式ID生成算法,
 * 以划分命名空间的方式将64bit位分割成了多个部分,
 * 每个部分都有具体的不同含义
 *
 * 当前代码是雪花算法用Java语言实现的版本
 *
 *
 * 想要确保生成的分布式ID是全局唯一的
 * 当前类应该在进程中是单例的
 * 且生成ID是串行生成的
 * @Author 快乐的星球
 * @Date 2023/9/23 10:37
 * @Version 1.0
 **/
public class Snowflake implements IIdGenerator {

    /**
     * 硬件机器的编号
     */
    private long workId;

    /**
     * 工作机器的编号占用5个比特位
     * 0-31
     */
    private static final long WORK_ID_BITS=5;

    /**
     * 数据中心编码
     */
    private long dataCenter;

    /**
     * 数据中心编码占用5个比特位
     * 0-31
     */
    private static final long DATA_CENTER_BITS=5;

    /**
     * 雪花算法的起始基准时间
     * 2023年10月1日 0时0分0秒
     */
    private static final long START_TIMESTAMP=1696089600000L;

    /**
     * 占用41比特位的时间戳
     * 记录的时 分布式ID发号的时间 到 起始时间 相距的毫秒数
     */
    private static final long TIMESTAMP_BITS=41;

    /**
     * 符号位占的比特位 1
     */
    private static final long FLAG_BITS=1;

    /**
     * 12位的自增序列号
     */
    private static final long SEQUENCE_BITS=12;

    /**
     * 自增序列号的掩码
     * 默认 4095
     */
    private static final long SEQUENCE_MASK=(-1L^(-1L<< SEQUENCE_BITS));

    /**
     * 颁发上
     */
    private long lastTimestamp=-1L;

    /**
     * 工作编码左移的位数
     */
    private static final long WORK_ID_SHIFT=SEQUENCE_BITS;

    /**
     * 数据中心左移的位数
     */
    private static final long DATA_CENTER_SHIFT=SEQUENCE_BITS+WORK_ID_BITS;

    /**
     * 时间戳左移的位数
     */
    private static final long TIMESTAMP_SHIFT=SEQUENCE_BITS+WORK_ID_BITS+DATA_CENTER_BITS;
    /**
     * 当前自增的序列号
     */
    private long sequence;

    public Snowflake(long dataCenter, long workId){
        Assert.checkBetween(dataCenter,0,Math.pow(2,5)-1);
        Assert.checkBetween(workId,0,Math.pow(2,5)-1);

        this.dataCenter=dataCenter;
        this.workId=workId;
    }

    @Override
    public synchronized long nextId() {
        //1: 获取当前时间戳
        long currentTimestamp = currentTimestamp();
        //2: 校验是否出现了时针回拨
        if(currentTimestamp<lastTimestamp){
            throw new RuntimeException("发生了时针回拨!颁发当前ID的时间比颁发" +
                    "上一个ID的时间要小!当前时间:{"+currentTimestamp+"}," +
                    "上一个ID颁发时间:{"+lastTimestamp+"}");
        }

        //3: 确定自增序列号
        if(currentTimestamp==lastTimestamp){
            //确定是同一毫秒生成的ID
            sequence=(sequence+1)&SEQUENCE_MASK;
            if(sequence==0){//当前毫秒内自增序列号已经用完
                //阻塞等待下一毫秒
                tilNextMillis(currentTimestamp);
            }
        }else {
            sequence=0;
        }

        //4:拼接结果
        lastTimestamp=currentTimestamp;

        return (currentTimestamp-START_TIMESTAMP)<<TIMESTAMP_SHIFT |
                (dataCenter<<DATA_CENTER_SHIFT)|
                (workId<<WORK_ID_SHIFT)|
                (sequence);

    }


    /**
     * 当前毫秒内自增序列已经用完需要阻塞等待到下一毫秒
     * @param lastTimestamp
     */
    private void tilNextMillis(long lastTimestamp){
        long currentTimestamp = currentTimestamp();
        while(currentTimestamp<=lastTimestamp){
            currentTimestamp=currentTimestamp();
        }
    }

    /**
     * 获取系统当前的时间戳
     * @return
     */
    public long currentTimestamp(){
        return System.currentTimeMillis();
    }


}

问题一:如果一个业务组下分布式ID发号器集群部署,那么如何进行低成本的工作机器ID的配置?

情况一:机器集群规模较小时,完全可以采用人工配置文件的方式进行配置。

情况二:机器集群规模较大时,如果采用人工配置和维护机器的工作ID,成本过高。可以采用机器启动时向 zookeeper 注册持久且有序的节点。

机器启动顺序如下:

1. 向zookeeper检查是否注册过。

2. 如果注册过获取节点的序列编号,如果没有注册过则进行注册并获取序列ID。

3. 以获取的序列ID作为机器的工作ID启动。

问题二:自增序列号是否会成为雪花算法的性能瓶颈?

雪花算法的自增序列号占12bit位,限制了雪花算法在1ms内最多生成4096个ID,这似乎成为的雪花算法性能的上限。

但是现有的雪花算法性能已经很高了,经测试,SnowFlake单机可以每秒能够产生26万ID左右。这几乎已经满足了绝大多数实际的生产环境所需,一般来说其性能已经完全够用了,所以说自增序列号的限制不会成为雪花算法的性能瓶颈。

问题三:如何打破雪花算法性能的瓶颈呢?

基于"二八原则",80%的并发在20%的时间内产生,按照这样的计算,其实一天并发量主要集中在5个小时中,也就是说并不是每时每刻都需要生成分布式ID,且在同一个ms内其自增序列号很多时候也没有用完。也就是说并没有抓住每一个时刻生成分布式ID,那么其性能自然会有上限。

那么解决方案就简单了,如果能充分利用每一个时刻生成分布式ID,那么其QPS上限能轻松达到百万量级。百度的 UidGenerator 采用了向未来借时间的思路打破了雪花算法性能的瓶颈。

问题四: 雪花算法如何解决时针回拨的问题呢?

雪花算法的时间戳是基于系统时间生成的,但系统时间和北京时间不一定一致,可能会存在系统时间相较于北京时间走快了或者走慢了。

那么如果系统时间原先比北京时间走快了,但系统自动校准会导致时针回拨,时针一旦回拨就有可能出现重复的ID,打破了雪花算法的全局唯一ID的性质,属于致命性错误!

时针一旦回拨几乎是无解的问题,因为实际生产环境中不能冒着生成重复ID的风险继续进行服务。应当根据时针回拨的严重程度做出不同的解决方案。

当然实际生产环境有各种各样的处理时针回拨的解决方案,但一切技术都应该服务于实际的业务场景,所以应当根据实际的业务场景去定制化时针回拨的解决方案。以上方案仅仅作为参考。

雪花算法的优缺点如下:

优点:

1. 高性能:经测试雪花算法单机每秒可生成高达26万个ID左右。可以支撑起整个公司业务每天亿级别的调用,足以满足绝大多数实际业务场景。

2. 有序性:整体上按照生成时间顺序递增排序,是趋势递增的。

3. 安全性:相较于自增ID或者数据库生成的方式,雪花算法生成的ID是趋势递增的,不会被测算出订单量等敏感数据,可以作为订单ID,支付流水记录ID等敏感数据的ID。

缺点:

1. 雪花算法强依赖于时钟的准确性,一旦时针回拨可能会造成服务不可用,因此基于雪花算法的唯一ID生成器必须集群化部署,避免因为单个或者某几个服务发生时针回拨从而造成依赖于分布式ID生成的服务发生雪崩。


这章主要讲了常见常用的分布式ID生成策略,但在实际生产环境中,分布式ID生成器在高可用,高性能,高拓展方面做了很多优化。下章主要讲解实际生产环境中的分布式唯一ID生成器如何基于这篇文章中提到的分布式ID生成策略搭建出能够在复杂分布式环境中,满足"三高"的分布式唯一ID生成系统。

相关推荐
Data跳动7 分钟前
Spark内存都消耗在哪里了?
大数据·分布式·spark
暮湫13 分钟前
泛型(2)
java
南宫生22 分钟前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石30 分钟前
12/21java基础
java
李小白6638 分钟前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp1 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
装不满的克莱因瓶1 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb
n北斗1 小时前
常用类晨考day15
java
骇客野人2 小时前
【JAVA】JAVA接口公共返回体ResponseData封装
java·开发语言
Java程序之猿2 小时前
微服务分布式(一、项目初始化)
分布式·微服务·架构