雪花算法 — 集群高并发情况下如何保证分布式唯一全局ID生成?

雪花算法

问题

为什么需要分布式全局唯一ID以及分布式ID的业务需求

在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识:

  • 如在美团点评的金融、支付、餐饮、酒店
  • 猫眼电影等产品的系统中数据逐渐增长,对数据库分库分表后需要有一个唯一ID来标识一条数据或信息;
  • 特别一点的订单、骑手、优惠券都需要有唯一ID做标识

此时一个能够生成全局唯一ID的系统是非常必要的。

image-20230712162743217

ID生成规则部分硬性要求

  1. 全局唯一:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。
  2. 趋势递增:在MySQL的InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用Btree的数据结构来存储索引,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
  3. 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。
  4. 信息安全:如果ID是连续,恶意用户的爬取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就危险了,竞争对手可以直接知道我们一天的单量。所以在一些应用场景下,需要ID无规则/不规则,让竞争对手不好猜。
  5. 含时间戳:这样能够快速在开发中了解这个分布式ID生成的时间。

ID号生成系统的可用性要求

  1. 高可用:发布一个获取分布式ID请求,服务器就要保证99.999%的情况下给我创建一个唯一分布式ID。
  2. 低延迟:发一个获取分布式ID的请求,服务器就要快,极速。
  3. 高QPS:例如并发一口气10万个创建分布式ID请求同时杀过来,服务器要顶得住且一下子成功创建10万个分布式ID。

一般通用方案

UUID

UUID.randomUUID(),UUID(Universally Unique Identifier)的标准形式包含 32 个 16 进制数字,以连字号分为 5 段,形式为 8-4-4-4-12 的 36 个字符,示例:550e8400-e29b-41d4-a716-446655440000

性能非常高:本地生成(java.util 包下),没有网络消耗。

问题:如果只考虑唯一性,没问题,但是UUID入数据库的性能非常差,因为它是无序的。

  1. 无序,无法预测他的生成顺序,不能生成递增有序的数字

    首先分布式id一般都会作为主键,但是按照MySQL官方推荐主键尽量越短越好,UUID每一个都很长,所以不是很推荐。

  2. 主键,ID作为主键时,在特定的环境下会存在一些问题

    比如做DB主键的场景下,UUID就非常不适用,MySQL官方有明确的建议主键尽量越短越好,36个字符长度的UUID不符合要求。
    image-20230712183255432

  3. 索引,B+树索引的分裂

    既然分布式ID是主键,然后主键是包含索引的,而MySQL的索引是通过B+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的B+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键的B+树进行很大的修改,这一点很不好,插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。

UUID只能保证全局唯一性,不满足后面的趋势递增、单调递增,所以不推荐。

数据库自增主键

单机✅

在分布式里面,数据库的自增ID机制的主要原理是:数据库自增ID和MySQL数据库的 replace into 实现的。

这里的 replace into 跟 insert 功能类似,不同点在于:replace into 首先尝试插入数据列表中,如果发现表中已经有此行数据(根据主键或唯一索引判断)则先删除,再插入,否则直接插入新数据。

REPLACE INTO 的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。

CREATE TABLE t_test(
	id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
	stub CHAR(1) NOT NULL DEFAULT '',
	UNIQUE KEY stub (stub)
)

SELECT * FROM t_test;

REPLACE INTO t_test (stub) VALUES('b');

创建好表,先执行第一次 REPLACE INTO,影响的行数为一行:

image-20230712190309092

进行查询:

image-20230712190131703

再执行第二次 REPLACE INTO,此时它影响的行数为两行:

image-20230712190219088

进行查询:

image-20230712190418175

原来的数据被删除了,新插入了主键为2的数据。

此时该方案满足了:唯一性递增性单调性

在分布式情况下,且并发量不多,可以使用这种方案来解决,获得一个全局的唯一ID。

集群分布式❌

那数据库自增ID机制适合做分布式ID吗?答案是不太适合。

  • 系统水平扩展比较困难,比如定义好步长和机器台数之后,如果要添加机器该怎么做?

    假设现在有一台机器发号是:1,2,3,4,5(步长是1),这个时候需要扩容机器一台。可以这样做:把第二台机器的初始值设置得比第一台超过很多,貌似还好,但是假设线上如果有100台机器,这个时候扩容要怎么做,简直是噩梦。所以系统水平扩展方案复杂难以实现。

  • 数据库压力还是很大,每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面的延迟低和高QPS的规则(在高并发下,如果都去数据库里面获取ID,那是非常影响性能的)。

  • 在分库分表场景下使用数据库自增 ID 作为主键可能会遇到以下问题:

    1. 唯一性冲突:在分库分表的环境中,不同的数据库实例或表可能有自己的自增 ID 序列,这可能导致生成的 ID 存在冲突。比如,如果有两个数据库实例都有一个 ID 自增列从 1 开始递增,可能会导致生成相同的 ID,引发唯一性冲突。
    2. 数据一致性难以保证:当数据分散在不同的数据库和表中时,需要跨多个数据库进行查询和事务操作。这样带来了一些挑战,例如维护数据一致性和事务的隔离性。自增 ID 不再是全局唯一的,而是在每个分片(shard)内唯一,因此需要特殊的设计和处理来确保数据的一致性。
    3. 数据迁移和扩展困难:当需要增加分片或迁移数据时,自增 ID 可能会成为一个限制,因为它们被用作数据的标识符。重新分配 ID 范围、解决冲突和维护正确的关联关系可能会变得复杂和耗时。

基于Redis生成全局ID策略

单机✅

因为Redis是单线程,天然保证原子性,可以使用原子操作 INCRINCRBY 来实现。

SET count 10
INCR count    	// 返回 11
INCRBY count 5  // 返回 16

集群分布式✅❌

注意:在Redis集群情况下,同样和MySQL一样需要设置不同的增长步长,同时key一定要设置有效期。

可以使用Redis集群来获取更高的吞吐量。

假设一个集群中有5台Redis,可以初始化每台Redis的值分别是 1,2,3,4,5, 然后设置步长都是5。

各个Redis生成的ID为:

A:1 6 11 16 21
B:2 7 12 17 22
C:3 8 13 18 23
D:4 9 14 19 24
E:5 10 15 20 25

但是存在的问题是,就是Redis集群的维护和配置比较麻烦,因为要设置避免单点故障,哨兵机制等。

如果我们为了一个ID,却需要引入整个Redis集群,有种杀鸡焉用牛刀的感觉,不推荐。

Snowflake - 雪花算法

上面的三个方案:UUID、数据库自增主键、基于Redis生成全局ID策略,都可以落地实现,但各有优缺点。

接下来我们来了解一下雪花算法。

概述

Twitter的分布式自增ID算法——Snowflake。

最初Twitter把存储系统从MySQL迁移到Cassandra(由Facebook开发的一套开源分布式NoSQL数据库系统),因为Cassandra没有顺序ID生成机制,所有开发了这样一套全局唯一ID生成服务。

Twitter的分布式雪花算法SnowFlake,经测试SnowFlake每秒可以产生26万个自增可排序的ID。

  1. Twitter的SnowFlake生成ID能够按照时间有序生成
  2. SnowFlake算法生成ID的结果是一个64Bit大小的整数,为一个Long型(转换成字符串后长度最多19)
  3. 分布式系统内不会产生ID碰撞(由 datacenter数据中心 和 workerID机器码做区分)并且效率较高

分布式系统中,有一些需要全局唯一ID的场景,生成ID的基本要求:

  1. 在分布式环境下,必须全局唯一性
  2. 一般都需要单调递增,因为一般唯一ID都会存在数据库,而InnoDB的特性就是将内容存储在主键索引上的叶子节点,而且是从左往右递增的,所有考虑到数据库性能,一般生成ID也最好是单调递增的。为了防止ID冲突可以使用36位UUID,但是UUID有一些缺点,首先是它相对比较长,另外UUID一般是无序的
  3. 可能还会需要无规则,因为如果使用唯一ID作为订单号这种,为了不让别人知道一天的订单量多少,就需要这种规则

结构

雪花算法的几个核心组成部分:

image-20230712193219410

号段解析:

  1. 1bit - 符号位

    不用,因为二进制中最高位是符号位,1表示负数,0表示正数。生成的ID一般都是用正数,所以最高位固定为0。

  2. 41bit - 时间戳位,用来记录时间戳,毫秒级:

    41位可以表示 2^{41}-1 个数字

    如果只用来表示正整数(计算机中正数包含0),可以表示的范围是:02^{41}-1,减1是因为可以表示的数值范围是从0开始计算的,而不是从1。

    也就是说41位可以表示 2^{41}-1 个毫秒的值,转换成单位年则是 (2^{41}-1)/(1000*60*60*24*365) = 69

  3. 10Bit - 工作机器ID

    可以部署在 2^{10} = 1024 个节点,包括5位 datacenterId(数据中心,机房) 和 5位 workerID(机器码)

    5位可以表示的最大正整数是 2^{5}-1 = 31 个数字,即可以用 [0, 1, 2, 3, …, 31] 这32个数字来表示不同的数据中心 和 机器码

  4. 12位bit - 序列号位

    可以用来表示的正整数是 2^{12}-1 = 4095,即可以用 [0, 1, 2, …, 4094] 这4095个数字来表示同一个机器同一个时间戳(毫秒)内产生的4095个ID序号。

SnowFlake可以保证:

  • 所有生成的ID按时间趋势递增
  • 整个分布式系统内不会产生重复ID,因为有 datacenterId 和 workerId 来做区分

源码

不过Twitter的雪花算法是由 Scala 语言编写的,但已经有前辈用 Java 实现了:GitHub地址

package com.example.demo.util;

/**
 * @author: yuanyuan.jing
 * @date: 2018/11/30 11:09
 * @description: 雪花算法生成全局唯一id
 */
public class SnowflakeIdWorker {
    
    
    /**
     * Twitter_Snowflake<br>
     * SnowFlake的结构如下(每部分用-分开):<br>
     * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
     * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
     * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
     * 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
     * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
     * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
     * 加起来刚好64位,为一个Long型。<br>
     * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万个ID左右。
     */


        // ==============================Fields==============================
        /** 开始时间截 (2015-01-01) */
        private final long twepoch = 1420041600000L;

        /** 机器id所占的位数 */
        private final long workerIdBits = 5L;

        /** 数据标识id所占的位数 */
        private final long datacenterIdBits = 5L;

        /** 支持的最大机器id,结果是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位 */
        private final long workerIdShift = sequenceBits;

        /** 数据标识id向左移17位(12+5) */
        private final long datacenterIdShift = sequenceBits + workerIdBits;

        /** 时间截向左移22位(5+5+12) */
        private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

        /** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
        private final long sequenceMask = -1L ^ (-1L << sequenceBits);

        /** 工作机器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 SnowflakeIdWorker(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 (该方法是线程安全的)
         * @return SnowflakeId
         */
        public synchronized long nextId() {
    
    
            long timestamp = timeGen();

            // 如果当前时间小于上一次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;
                // 毫秒内序列溢出
                if (sequence == 0) {
    
    
                    // 阻塞到下一个毫秒,获得新的时间戳
                    timestamp = tilNextMillis(lastTimestamp);
                }
            }
            // 时间戳改变,毫秒内序列重置
            else {
    
    
                sequence = 0L;
            }

            // 上次生成ID的时间截
            lastTimestamp = timestamp;

            // 移位并通过或运算拼到一起组成64位的ID
            return ((timestamp - twepoch) << timestampLeftShift)
                    | (datacenterId << datacenterIdShift)
                    | (workerId << workerIdShift)
                    | sequence;
        }

        /**
         * 阻塞到下一个毫秒,直到获得新的时间戳
         * @param lastTimestamp 上次生成ID的时间截
         * @return 当前时间戳
         */
        protected long tilNextMillis(long lastTimestamp) {
    
    
            long timestamp = timeGen();
            while (timestamp <= lastTimestamp) {
    
    
                timestamp = timeGen();
            }
            return timestamp;
        }

        /**
         * 返回以毫秒为单位的当前时间
         * @return 当前时间(毫秒)
         */
        protected long timeGen() {
    
    
            return System.currentTimeMillis();
        }

    	// ==============================Test==============================
        public static void main(String[] args) {
    
    
            SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
            for (int i = 0; i < 100; i++) {
    
    
                long id = idWorker.nextId();
                //System.out.println(Long.toBinaryString(id));
                System.out.println(id);
            }
        }

}

但是我们实际工作中是不会用这个代码的,仅仅作为学习使用,我们在工程中会使用 Hutool 工具包。

工程落地经验

Hutool 工具包:https://github.com/looly/hutool

SpringBoot整合雪花算法

  • pom.xml

    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-captcha</artifactId>
        <version>4.6.8</version>
    </dependency>
    
  • 新建工具类

    package com.atguigu.boot.util;
    
    package cn.itedus.lottery.domain.support.ids;
    
    import cn.hutool.core.lang.Snowflake;
    import cn.hutool.core.net.NetUtil;
    import cn.hutool.core.util.IdUtil;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    import javax.annotation.PostConstruct;
    
    @Slf4j
    @Component
    public class IdGeneratorSnowflake {
          
          
    
        private long workerId = 0;
        private long datacenterId = 1;
        private Snowflake snowflake = IdUtil.createSnowflake(workerId, datacenterId);
    
        @PostConstruct
        public void init() {
          
          
            try {
          
          
                workerId = NetUtil.ipv4ToLong(NetUtil.getLocalhostStr());
                log.info("当前机器的workerId:{}", workerId);
            } catch(Exception e) {
          
          
                e.printStackTrace();
                log.warn("当前机器的workerId获取失败", e);
                workerId = NetUtil.getLocalhostStr().hashCode();
            }
        }
    
        public synchronized long snowflakeId() {
          
          
            return snowflake.nextId();
        }
    
        public synchronized long snowflakeId(long workerId, long datacenterId) {
          
          
            Snowflake snowflake = IdUtil.createSnowflake(workerId,datacenterId);
            return snowflake.nextId();
        }
    
        // 测试
        public static void main(String[] args) {
          
          
            System.out.println(new IdGeneratorSnowflake().snowflakeId());
        }
    }
    
    

    image-20230713111923617

  • 在业务类中使用

    @Service
    public class OrderService {
          
          
        
    	@Autowired
        private IdGeneratorSnowflake idGeneratorSnowflake;
    
        public String getIDBySnowFlake() {
          
          
            // 利用线程池 同时创建多个id
            ExecutorService threadPool = Executors.newFixedThreadPool(5);
            for (int i = 1; i <= 20; i++) {
          
          
                threadPool.submit(() -> {
          
          
                    log.info("{}", idGeneratorSnowflake.snowflakeId());
                });
            }
            threadPool.shutdown();
            return "hello snowflake";
        }
    }
    

    image-20230713124911348

    image-20230713124853468

    生成的ID都是唯一的,只不过在多线程场景下我们打印的顺序不同。

优缺点

优点

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

缺点

  • 依赖机器时钟,如果机器时钟回拨,会导致重复ID生成
  • 在单机上是递增的,但由于涉及到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况,(此缺点可以认为无所谓,一般分布式ID只要求趋势递增,并不会严格要求递增,90%的需求只要求趋势递增)

其它补充

如果想彻底解决时钟问题,那么这两家大厂已经开源了成熟的解决方案,并且优化了雪花算法:

参考b站:尚硅谷

猜你喜欢

转载自blog.csdn.net/weixin_53407527/article/details/131700381