分布式唯一ID各算法分析实践

本文详细探讨了分布式系统中生成唯一ID的几种常见方案,包括数据库自增、UUID和雪花算法。分析了各自优缺点,如数据库方案的单点故障风险,UUID的无序性和存储空间问题,雪花算法对时钟的依赖。并提出了一种基于Redis的有序递增方案和改造数据库主键自增的双buffer策略,以解决并发和突发阻塞问题,提高系统的容错性和效率。
摘要由CSDN通过智能技术生成

导读

分布式系统中,我们会对一些数据量大的业务进行分析,如:订单表,待处理的对列表。因为数据量巨大,一张表无法承接,就会对其进行分库分表。但是一旦涉及到分库分表,就会引申出分布式系统中唯一主键ID的生成问题,唯一ID可以标识数据的唯一性,在分布式系统中生成唯一ID的方案有很多,常见的方式大概有以下三种:
1.依赖数据库,使用MYSQL的自增列或者ORACLE的序列等
2.UUID随机数
3.snowflake雪花算法

方案分析

一.各方案的优缺点
算法优点缺点
数据库数字化,ID递增,查询效率高,具有一定的业务可读性,只需配置数据库采用数据库的自增序列,读写分离时,只有主节点可以进行写操作,可能有单点故障的风险
UUID随机数代码实现简单 ,本机生成,没有性能问题,因为是全球唯一的ID,迁移数据容易采用无意义字符串,没有排序,采用字符串形式存储,数据量大时查询效率比较低,存储空间大
雪花算法每一毫秒能产生4096个ID,性能快,时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增,灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求依赖机器的时钟,如果服务器时钟回拨,会导致重复ID的生成
Redis生成方案有序递增,可读性强占用带宽,每次要向redis进行请求

各方案分析

UUID
public class UUIDUtil {

    public static String[] chars = new String[] { "a", "b", "c", "d", "e", "f",
            "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
            "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5",
            "6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H", "I",
            "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V",
            "W", "X", "Y", "Z" };


    public static String generateShortUuid(String prefix) {
        StringBuffer shortBuffer = new StringBuffer();
        String uuid = UUID.randomUUID().toString().replace("-", "");
        for (int i = 0; i < 8; i++) {
            String str = uuid.substring(i * 4, i * 4 + 4);
            int x = Integer.parseInt(str, 16);
            shortBuffer.append(chars[x % 0x3E]);
        }
        return prefix + shortBuffer.toString();

    }

    public static void main(String[] args) {
        Integer a = 100, b = 100;
        Integer c = 128, d = 128;
        System.out.println(a == b);
        System.out.println(c.intValue() == d.intValue());
    }

    public static String ramdomUUID(){
        return UUID.randomUUID().toString().replace("-","");
    }
}

优点

  • 代码实现简单
  • 本机生成,没有性能问题
  • 因为是全球唯一ID,所以数据迁移比较容易

缺点

  • 每次生成的ID是无序的,无法保证趋势递增
  • UUID的字符串存储,查询效率慢
  • 存储空间大
  • ID本身无业务含义,不可读
MySQL主键自增

这个方案就是利用了MySQL的主键自增auto_increment ,默认每次ID加1
优点

  • 数字化,ID递增
  • 查询效率高
  • 具有一定的业务可读性
    缺点
  • 存在单点问题,如果mysql挂了,就没法生成ID了
  • 数据库压力大,高并发扛不住
MySQL多实例主键自增

这个方案就是解决MySQL的单点问题,再auto_increment基本上面,设置step步长
MySQL多实例自增主键
优点:解决了单点问题
缺点:一旦把步长定好后,就无法扩容;而且单个数据库压力大,数据库自身性能无法满足高并发
应用场景:数据不需要扩容的场景

雪花算法snowflake

雪花算法生成64位的二进制的正整数,然后转化为10进制的数。

/**
 * @description: 雪花算法
 * SnowFlake的结构 如下
 * 0- 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
 * 1位标识位 ,由于long基本类型在Java中是带符号的,最高位是符号位, 正数是 0 ,负数 是1 ,所以一般ID是正数 ,最高位是0
 * 41位时间戳(毫秒级) 41位时间戳存储的是当前时间与开始时间的差值
 * 10位的数据机器位,可以部署在1024个节点,包括5位 dataCenterId 和 5位 workerID
 * 12位的序列,毫秒内的计数,12位的计数顺序号支持每一个节点每毫秒(同一机器;同一时间戳)产生4096个ID序号
 * 加起来64位,为一个Long类型
 * 优点:整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID)
 */
public class SnowFlakeIdWorker {

    /** 开始时间戳(2020-01-01 00:00:01)*/
    private final long xinChou = 1609430401000L;

    /** 机器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 = workerIdBits + dataCenterIdBits + sequenceBits;

    /** 生成的序列掩码 这里为 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;

    private static SnowFlakeIdWorker idWorker;

    static {
        idWorker = new SnowFlakeIdWorker(getWorkId(),getDataCenterId());
    }

    /**
     * 构造函数
     * @param workerId
     * @param dataCenterId
     */
    public SnowFlakeIdWorker(long workerId, long dataCenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("workerId can not be greater than %d or less than 0",maxWorkerId));
        }
        if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
            throw new IllegalArgumentException(String.format("dataCenterId can not be greater than %d or less than 0",maxDataCenterId));
        }
        this.workerId = workerId;
        this.dataCenterId = dataCenterId;
    }

    /**
     * 获取下一个ID(该方法是线程安全的)
     * @return SnowFlakeId
     */
    public synchronized long nextId() {
        long timestamp = timeGen();
        //如果当前时间戳小于上次时间戳,则表示时间戳的获取出现异常
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",lastTimestamp - timestamp));
        }

        //如果当前时间戳等于上次时间戳(同一毫秒内),则序列号+1;否则序列号赋值为0,从0开始
        if (lastTimestamp == timestamp) {
            sequence =(sequence+1) & sequenceMask;
            //毫秒内序列溢出
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        }
        //时间戳改变,毫秒内序列重置
        else {
            sequence = 0L;
        }
        // 上次生成ID的时间戳
        lastTimestamp = timestamp;

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

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


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

    private static Long getWorkId() {
        try {
            String hostAddress = Inet4Address.getLocalHost().getHostAddress();
            int[] ints = StringUtils.toCodePoints(hostAddress);
            int sums = 0;
            for (int b: ints) {
                sums += b;
            }
            return (long) (sums % 32);
        } catch (UnknownHostException e) {
            // 如果获取失败,则使用随机数备用
            return RandomUtils.nextLong(0,31);
        }
    }

    private static Long getDataCenterId() {
        int[] ints = StringUtils.toCodePoints(SystemUtils.getHostName());
        int sums = 0;
        for (int i : ints) {
            sums += 1;
        }
        // ID不能超过32只能是取余数
        return (long)(sums%32);
    }

    /**
     * 静态工具类
     * @return
     */
    public static synchronized Long generatorId() {
        long id = idWorker.nextId();
        return id;
    }

    public static void main(String[] args) {
        System.out.println(System.currentTimeMillis());
        long startTime = System.nanoTime();
        for (int i =0; i<50000;i++) {
            long id = SnowFlakeIdWorker.generatorId();
            System.out.println(id);
        }

        System.out.println((System.nanoTime() - startTime) / 1000000+"ms");
    }
}

snowflake
雪花算法描述
优点

  • 此方案能每毫秒产生4096个ID,性能好
  • 时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增
  • 灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求。
    缺点
  • 依赖机器的时钟,如果服务器时钟回拨,会导致重复ID生成。

在分布式场景中,服务器时钟回拨会经常遇到,一般存在10ms之间的回拨,此算法就是建立在毫秒级别的生成方案,一旦回拨,就很有可能存在重复ID

Redis生成方案

利用redis的incr原子性操作,一般算法为:
年份+当天距离当年第多少天+天数+小时+redis自增

优点:

  • 有序递增,可读性强

缺点

  • 占用带宽,每次要向redis进行请求
改造数据主键自增

利用数据库的自增主键的特性,可以实现分布式ID;这个ID比较简短明了,适合做UsrId。
缺点:

  • 一旦步长确定下来,不容易扩容
  • 数据库压力较大:每次获取ID都需要请求数据库

优化:获取数据库ID的时候,可设计为获取一个区间段的ID
区间ID获取
上图展示了ID规则表:

1、id表示为主键,无业务含义。

2、biz_tag为了表示业务,因为整体系统中会有很多业务需要生成ID,这样可以共用一张表维护

3、max_id表示现在整体系统中已经分配的最大ID

4、desc描述

5、update_time表示每次取的ID时间

整体流程:
1、【用户服务】在注册一个用户时,需要一个用户ID;会请求【生成ID服务(是独立的应用)】的接口

2、【生成ID服务】会去查询数据库,找到user_tag的id,现在的max_id为0,step=1000

3、【生成ID服务】把max_id和step返回给【用户服务】;并且把max_id更新为max_id = max_id + step,
即更新为1000

4、【用户服务】获得max_id=0,step=1000;

5、 这个用户服务可以用ID=【max_id + 1,max_id+step】区间的ID,即为【1,1000】

6、【用户服务】会把这个区间保存到jvm中

7、【用户服务】需要用到ID的时候,在区间【1,1000】中依次获取id,可采用AtomicLong中的getAndIncrement方法。

8、如果把区间的值用完了,再去请求【生产ID服务】接口,获取到max_id为1000,
即可以用【max_id + 1,max_id+step】区间的ID,即为【1001,2000】

这个方案就非常完美的解决了数据库自增的问题,而且可以自行定义max_id的起点,和step步长,非常方便扩容。

而且也解决了数据库压力的问题,因为在一段区间内,是在jvm内存中获取的,而不需要每次请求数据库。即使数据库宕机了,系统也不受影响,ID还能维持一段时间。

竞争问题

以上方案中,如果是多个用户服务,同时获取ID,同时去请求【ID服务】,在获取max_id的时候会存在并发问题。

如用户服务A,取到的max_id=1000 ;用户服务B取到的也是max_id=1000,那就出现了问题,Id重复了。那怎么解决?
数据库事务
突发阻塞问题
突发阻塞问题

改造数据库主键自增双buffer方案(解决突发阻塞问题)

在一般的系统设计中,双buffer经常会看到
双Buffer
在设计的时候,采用双buffer方案,上图的流程:
1、当前获取ID在buffer1中,每次获取ID在buffer1中获取

2、当buffer1中的Id已经使用到了100,也就是达到区间的10%

3、达到了10%,先判断buffer2中有没有去获取过,如果没有就立即发起请求获取ID线程,此线程把获取到的ID,设置到buffer2中。

4、如果buffer1用完了,会自动切换到buffer2

5、buffer2用到10%了,也会启动线程再次获取,设置到buffer1中

6、依次往返

双buffer的方案,小伙伴们有没有感觉很酷,这样就达到了业务场景用的ID,都是在jvm内存中获得的,从此不需要到数据库中获取了。允许数据库宕机时间更长了。

因为会有一个线程,会观察什么时候去自动获取。两个buffer之间自行切换使用。就解决了突发阻塞的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值