分布式集群:分布式ID解决⽅案

1,202 阅读7分钟

尺有所短,寸有所长;不忘初心,方得始终。

请关注公众号:星河之码

在业务开发中,大量场景需要唯一 ID 来进行标识:用户需要唯一身份标识、商品需要唯一标识、消息需要唯一标识、事件需要唯一标识等,都需要全局唯一ID,尤其是复杂的分布式业务场景中全局唯一 ID 更为重要。

  • 那么,分布式唯一 ID 有哪些特性或要求呢?

    1. 唯一性:生成的 ID 全局唯一,在特定范围内冲突概率极小。
    2. 有序性:生成的 ID 按某种规则有序,便于数据库插入及排序。
    3. 可用性:可保证高并发下的可用性, 确保任何时候都能正确的生成 ID。
    4. 自主性:分布式环境下不依赖中心认证即可自行生成 ID。
    5. 安全性:不暴露系统和业务的信息, 如:订单数,用户数等。
  • 分布式唯一 ID 有哪些生成方法呢?

    • UUID 生成

    • 数据库自增 ID

    • snowflake雪花算法

    • Redis的Incr命令获取全局唯⼀ID

      另外,⼀些互联⽹公司也基于上述的⽅案封装了⼀些分布式ID⽣成器,⽐如滴滴的tinyid(基于数据库实现)、百度的uidgenerator(基于SnowFlake)和美团的leaf(基于数据库和SnowFlake)等

一、 UUID 生成

核心思想:结合机器的网卡(基于名字空间/名字的散列值 MD5/SHA1)、当地时间(基于时间戳&时钟序列)、一个随记数来生成 UUID。 其结构:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee (包含 32 个 16 进制数字,以连字号-分为五段,最终形成“8-4-4-4-12”的 36 个字符的字符串,即 32 个英数字母+4 个连字号)。 例如: 550e8400-e29b-41d4-a716-446655440000

  • 优点:
    • 本地生成,没有网络消耗,生成简单,没有高可用风险。
  • 缺点:
    • 不易于存储: UUID 太长, 16 字节 128 位,通常以 36 长度的字符串表示,很多场景不适用。
    • 信息不安全:基于 MAC 地址生成 UUID 的算法可能会造成 MAC 地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
    • 无序查询效率低:由于生成的 UUID 是无序不可读的字符串,所以其查询效率低。

二、 数据库自增 ID

核心思想:使用数据库的 id 自增策略(如: Mysql 的 auto_increment)。

案例:

⽐如A表分表为A1表和A2表,那么肯定不能让A1表和A2表的ID⾃增,那么ID怎么获取呢?我们可 以单独的创建⼀个Mysql数据库,在这个数据库中创建⼀张表,这张表的ID设置为⾃增,其他地⽅ 需要全局唯⼀ID的时候,就模拟向这个Mysql数据库的这张表中模拟插⼊⼀条记录,此时ID会⾃ 增,然后我们可以通过Mysql的select last_insert_id() 获取到刚刚这张表中⾃增⽣成的ID.

⽐如,我们创建了⼀个数据库实例global_id_generator,在其中创建了⼀个数据表,表结构如 下:

DROP TABLE IF EXISTS `DISTRIBUTE_ID`;
CREATE TABLE `DISTRIBUTE_ID` (
`id` bigint(32) NOT NULL AUTO_INCREMENT COMMENT '主键',
`createtime` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

当分布式集群环境中哪个应⽤需要获取⼀个全局唯⼀的分布式ID的时候,就可以使⽤代码连接这个 数据库实例,执⾏如下sql语句即可

insert into DISTRIBUTE_ID(createtime) values(NOW());
select LAST_INSERT_ID();

注意:

1)这⾥的createtime字段⽆实际意义,是为了随便插⼊⼀条数据以⾄于能够⾃增id。

2)使⽤独⽴的Mysql实例⽣成分布式id,虽然可⾏,但是性能和可靠性都不够好,因为你需要代码连接到数据库才能获取到id,性能⽆法保障,另外mysql数据库实例挂掉了,那么就⽆法获取分布式id了。

3)有⼀些开发者⼜针对上述的情况将⽤于⽣成分布式id的mysql数据库设计成了⼀个集群架构,那么其实这种⽅式现在基本不⽤,因为过于麻烦了。

  • 优点:简单,天然有序。
  • 缺点:
    • 并发性不好。
    • 数据库写压力大。
    • 数据库故障后不可使用。
    • 存在数量泄露风险。

针对以上缺点,有以下几种优化方案

  1. 数据库水平拆分,设置不同的初始值和相同的自增步长

    核心思想:将数据库进行水平拆分,每个数据库设置不同的初始值和相同的自增步长 。

    如图所示,可保证每台数据库生成的 ID 是不冲突的,但这种固定步长的方式也会带来扩容的问题,很容易想到当扩容时会出现无 ID 初始值可分的窘境。解决方案有:

    • 根据扩容考虑决定步长

    • 增加其他位标记区分扩容。

      这其实都是在需求与方案间的权衡,根据需求来选择最适合的方式。

  2. 批量缓存自增 ID .

    核心思想:如果使用单台机器做 ID 生成,可以避免固定步长带来的扩容问题(方案 1 的缺点)。 具体做法是:

    每次批量生成一批 ID 给不同的机器去慢慢消费,这样数据库的压力也会减小到 N 分之一,且故障后可坚持一段时间 。

如图所示,但这种做法的缺点是服务器重启、单点故障会造成 ID 不连续没有最好的方案,只有最适合的方案。

三、 snowflake雪花算法

核心思想:把 64-bit 分别划分成多段,分开来标示机器、时间、某一并发序列等,从而使每台机器及同一机器生成的 ID 都是互不相同。

PS:这种结构是雪花算法提出者 Twitter 的分法,但实际上这种算法使用可以很灵活,根据自身业务的并发情况、机器分布、使用年限等,可以自由地重新决定各部分的位数,从而增加或减少某部分的量级。比如:滴滴的tinyid(基于数据库实现)、百度的uidgenerator(基于SnowFlake)和美团的leaf(基于数据库和SnowFlake)等,都是基于雪花算法做一些适合自身业务的变化

其结构如下:

说明:全部结构标识(1+41+10+12=64)加起来刚好 64 位,刚好凑成一个 Long 型

分段描述
1 位标识由于 long 基本类型在 Java 中是带符号的,最高位是符号位,正数是 0,负数是 1,所以 id 一般是正数,最高位是 0。
41 位时间截(毫秒级)需要注意的是, 41 位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)得到的值,这里的开始时间截,一般是指我们的 id 生成器开始使用的时间截,由我们的程序来指定。41 位的毫秒时间截,可以使用 69 年(即 T =(1L << 41) /(1000 * 60 * 60 *24 * 365) = 69)。
10 位的数据机器位包括 5位数据中心标识 Id(datacenterId)、 5 位机器标识 Id(workerId),最多可以部署 1024 个节点(即 1 << 10 = 1024)。超过这个数量,生成的 ID 就有可能会冲突。
10 位的数据机器位包括 5 位数据中心标识 Id(datacenterId)、 5 位机器标识 Id(workerId),最多可以部署 1024 个节点(即 1 << 10 = 1024)。超过这个数量,生成的 ID 就有可能会冲突。
  • 优点

    • 整体上按照时间按时间趋势递增,后续插入索引树的时候性能较好。
    • 整个分布式系统内不会产生 ID 碰撞(由数据中心标识 ID、机器标识 ID 作区分)。
    • 本地生成,且不依赖数据库(或第三方组件)没有网络消耗,所以效率高(每秒能够产生 26 万 ID 左右)。
  • 缺点

    由于雪花算法是强依赖于时间的,在分布式环境下,如果发生时钟回拨,很可能会引起 ID 重复、 ID 乱序、服务会处于不可用状态等问题。

  • 解决方案有:

    • 将 ID 生成交给少量服务器,并关闭时钟同步。
    • 直接报错,交给上层业务处理。
    • 如果回拨时间较短,在耗时要求内,比如 5ms,那么等待回拨时长后再进行生成。
    • 如果回拨时间很长,那么无法等待,可以匀出少量位(1~2 位)作为回拨位,一旦时钟回拨,将回拨位加 1,可得到不一样的 ID, 2 位回拨位允许标记 3 次时钟回拨,基本够使用。如果超出了,可以再选择抛出异常。

雪花算法Java源代码

/**
 * 官方推出,Scala编程语言来实现的
 * Java前辈用Java语言实现了雪花算法
 */
public class IdWorker{

    //下面两个每个5位,加起来就是10位的工作机器id
    private long workerId;    //工作id
    private long datacenterId;   //数据id
    //12位的序列号
    private long sequence;

    public IdWorker(long workerId, long datacenterId, long sequence){
        // sanity check for workerId
        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));
        }
        System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
                timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);

        this.workerId = workerId;
        this.datacenterId = datacenterId;
        this.sequence = sequence;
    }

    //初始时间戳
    private long twepoch = 1288834974657L;

    //长度为5位
    private long workerIdBits = 5L;
    private long datacenterIdBits = 5L;
    //最大值
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    //序列号id长度
    private long sequenceBits = 12L;
    //序列号最大值
    private long sequenceMask = -1L ^ (-1L << sequenceBits);
    
    //工作id需要左移的位数,12位
    private long workerIdShift = sequenceBits;
   //数据id需要左移位数 12+5=17位
    private long datacenterIdShift = sequenceBits + workerIdBits;
    //时间戳需要左移位数 12+5+5=22位
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    //上次时间戳,初始值为负数
    private long lastTimestamp = -1L;

    public long getWorkerId(){
        return workerId;
    }

    public long getDatacenterId(){
        return datacenterId;
    }

    public long getTimestamp(){
        return System.currentTimeMillis();
    }

     //下一个ID生成算法
    public synchronized long nextId() {
        long timestamp = timeGen();

        //获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        //获取当前时间戳如果等于上次时间戳
        //说明:还处在同一毫秒内,则在序列号加1;否则序列号赋值为0,从0开始。
        if (lastTimestamp == timestamp) {  // 0  - 4095
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }
        
        //将上次时间戳值刷新
        lastTimestamp = timestamp;

        /**
          * 返回结果:
          * (timestamp - twepoch) << timestampLeftShift) 表示将时间戳减去初始时间戳,再左移相应位数
          * (datacenterId << datacenterIdShift) 表示将数据id左移相应位数
          * (workerId << workerIdShift) 表示将工作id左移相应位数
          * | 是按位或运算符,例如:x | y,只有当x,y都为0的时候结果才为0,其它情况结果都为1。
          * 因为个部分只有相应位上的值有意义,其它位上都是0,所以将各部分的值进行 | 运算就能得到最终拼接好的id
        */
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    //获取时间戳,并与上次时间戳比较
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    //获取系统时间戳
    private long timeGen(){
        return System.currentTimeMillis();
    }




    public static void main(String[] args) {
        IdWorker worker = new IdWorker(21,10,0);
        for (int i = 0; i < 100; i++) {
            System.out.println(worker.nextId());
        }
    }

}

四、Redis的Incr命令获取全局唯⼀ID

核心思想: Redis 的所有命令操作都是单线程的,本身提供像 incr 和 increby这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。

Redis Incr 命令将 key 中储存的数字值增⼀。如果 key 不存在,那么 key 的值会先被初始化为 0,然后再执⾏INCR 操作。

  • 优点:

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

    • 如果系统中没有 Redis,还需要引入新的组件,增加系统复杂度。
    • 需要编码和配置的工作量比较大。
  • 优化方案: 考虑到单节点的性能瓶颈,可以使用 Redis 集群来获取更高的吞吐量,

    并利用上面的方案来配置集群

    • 库水平拆分,设置不同的初始值和相同的步长;

    • 批量缓存自增 ID

    PS: 比较适合使用 Redis 来生成每天从 0 开始的流水号。比如:“订单号=日期+当日自增长号”,则可以每天在 Redis 中生成一个 Key,使用 INCR 进行累加。

案例:

Java代码中使⽤Jedis客户端调⽤Reids的incr命令获得⼀个全局的id

1.引⼊jedis客户端jar

<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>

2.Java代码(此处我们就是连接单节点,也不使⽤连接池)

Jedis jedis = new Jedis("127.0.0.1",6379);
try {
	long id = jedis.incr("id");
	System.out.println("从redis中获取的分布式id为: " + id);
} finally {
    if (null != jedis) {
   	 jedis.close();
    }
}

Redis安装(安装单节点)

  • 官⽹下载redis-3.2.10.tar.gz

  • 上传到linux服务器解压 tar -zxvf redis-3.2.10.tar.gz

     tar -zxvf redis-3.2.10.tar.gz
    
  • cd 解压⽂件⽬录,对解压的redis进⾏编译

    make
    
  • 然后cd 进⼊src⽬录,执⾏make install

    make install
    
  • 修改解压⽬录中的配置⽂件redis.conf,关掉保护模式

  • 在src⽬录下执⾏ ./redis-server ../redis.conf 启动redis服务
#../redis.conf 为redis.conf的目录
./redis-server ../redis.conf