【分布式】分布式唯一 ID 的 几种生成方案以及优缺点&snowflake优化方案

在互联网的业务系统中,涉及到各种各样的ID,如在支付系统中就会有支付ID、退款ID等。那一般生成ID都有哪些解决方案呢?特别是在复杂的分布式系统业务场景中,我们应该采用哪种适合自己的解决方案是十分重要的。下面我们一一来列举一下,不一定全部适合,这些解决方案仅供参考,或许对你有用。

一、分布式ID

1.什么是分布式ID

日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如 用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订 单 ID 对应且仅对应一个订单。

拿MySQL数据库举个例子:
在我们业务数据量不大的时候,单库单表完全可以支撑现有业务,数据再大一点搞个MySQL主从同步读写分离也能对付。
但随着数据日渐增长,主从同步也扛不住了,就需要对数据库进行分库分表,但分库分表后需要有一个唯一ID来标识一条数据,数据库的自增ID显然不能满足需求;特别一点的如订单、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的。那么这个全局唯一ID就叫分布式ID。

2.分布式ID的特性

  • 唯一性:确保生成的ID是全网唯一的。
  • 有序递增性:确保生成的ID是对于某个用户或者业务是按一定的数字有序递增的。
  • 高可用性:确保任何时候都能正确的生成ID。
  • 带时间:ID里面包含时间,一眼扫过去就知道哪天的交易。

二、分布式ID的生成方案

1. UUID

算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。

优点:

  • 代码实现简单
  • 本地生成,没有性能问题,没有高可用风险
  • 全球唯一的,数据迁移容易

缺点:

  • 长度过长,存储冗余,且无序不可读,查询效率低
  • 每次生成的ID是无序的,不满足趋势递增
  • UUID是字符串,而且比较长,占用空间大,查询效率低
  • ID没有含义,可读性差

2. 数据库自增ID

使用数据库的id自增策略,如 MySQL 的 auto_increment。并且可以使用两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。

  • 优点:数据库生成的ID绝对有序,高可用实现方式简单
  • 缺点:需要独立部署数据库实例,成本高,有性能瓶颈

3. 批量生成ID

一次按需批量生成多个ID,每次生成都需要访问数据库,将数据库修改为最大的ID值,并在内存中记录当前值及最大值。

  • 优点:避免了每次生成ID都要访问数据库并带来压力,提高性能
  • 缺点:属于本地生成策略,存在单点故障,服务重启造成ID不连续

4. Redis生成ID

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

  • 优点:有序递增,可读性强,性能较高。不依赖于数据库,灵活方便,且性能优于数据库;数字ID天然排序,对分页或者需要排序的结果很有帮助。
  • 缺点:占用带宽,依赖Redis:如果系统中没有Redis,还需要引入新的组件,增加系统复杂度;需要编码和配置的工作量比较大。
    考虑到单节点的性能瓶颈,可以使用 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 集群也可以防止单点故障的问题。另外,比较适合使用 Redis 来生成每天从0开始的流水号。比如订单号 = 日期 + 当日自增长号。可以每天在 Redis 中生成一个 Key ,使用 INCR 进行累加。

5. Twitter的snowflake算法

Twitter开源的snowflake,以​时间戳+机器+递增序列​组成,基本趋势递增,且性能很高。

snowflake生成的是一个Long类型的值,Long类型的数据占用8个 字节,也就是64位。SnowFlake将64进行拆分,每个部分具有不同 的含义,当然机器码、序列号的位数可以自定义也可以。

把64-bit分别划分成多段,分开来标示机器、时间等,比如在snowflake中的64-bit分别表示如下图(图片来自网络)所示:

  • 符号位 (1bit):预留的符号位,恒为零。(由于 long 类型在 java 中带符号的,最高位为符号位,正数为 0,负数为 1,且实际系统中所使用的ID一般都是正数,所以最高位为 0)
  • 时间戳位 (41bit):41 位的时间戳可以容纳的毫秒数是 2 的 41 次幂,一年所使用的毫秒数是:365 * 24 * 60 * 60 * 1000。通过计算可知:Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L);结果约等于 69.73 年

ShardingSphere 的雪花算法的时间纪元从 2016 年 11 月 1 日零点开始,可以使用到 2086 年,相信能满足绝大部分系统的要求。

  • 工作进程位 (10bit):该标志在 Java 进程内是唯一的,如果是分布式应用部署应保证每个工作进程的 id 是不同的。该值默认为 0,可通过属性设置。10-bit机器可以分别表示1024台机器。如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,可以根据自身需求定义。
  • 序列号位 (12bit):该序列是用来在同一个毫秒内生成不同的 ID。如果在这个毫秒内生成的数量超过 4096 (2 的 12 次幂),那么生成器会等待到下个毫秒继续生成。这 12 位计数支持每个节点每毫秒(同一台机器,同一时刻)最多生成 1 << 12 = 4096个ID

优点:本地生成,不依赖中间件。 生成的分布式id足够小,只有8个字节,而且是递增的

  • 能满足高并发分布式系统环境下ID不重复
  • 生成效率高
  • 基于时间戳,可以保证基本有序递增
  • 不依赖于第三方的库或者中间件
  • 生成的id具有时序性和唯一性

缺点:时钟回拨问题,强烈依赖于服务器的时间,如果时间出现时间回拨 就可能出现重复的id

6. 百度UidGenerator

具体可以参考官网说明:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md

7. 美团Leaf

Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,里面也提到了几种分布式方案的对比,但也需要依赖关系数据库、ZooKeeper等中间件。

https://github.com/Meituan-Dianping/Leaf/tree/master/leaf-core

具体可以参考官网说明:https://tech.meituan.com/2017/04/21/mt-leaf.html

8.滴滴(Tinyid)

Tinyid由滴滴开发,Github地址:https://github.com/didi/tinyid。
Tinyid是基于号段模式原理实现的与Leaf如出一辙,每个服务获取一个号段(1000,2000]、(2000,3000]、(3000,4000]

在这里插入图片描述

说了这么多,我们今天就主要说一下 Twitter开源的snowflake

三、snowflake

1.流程

2.java 代码实现

 如下示例,41bit给时间戳,5bit给IDC,5bit给工作机器,12bit给序列号,代码中是写死的,如果某些bit需要动态调整,可在成员属性定义。计算过程需要一些位运算基础。

import java.util.Date;

/**
 * @Author allen
 * @Description TODO
 * @Date 2023-07-26 9:51
 * @Version 1.0
 */
public class SnowFlakeUtil {

    private static SnowFlakeUtil snowFlakeUtil;
    static {
        snowFlakeUtil = new SnowFlakeUtil();
    }

    // 初始时间戳(纪年),可用雪花算法服务上线时间戳的值
    // 1650789964886:2022-04-24 16:45:59
    private static final long INIT_EPOCH = 1650789964886L;

    // 时间位取&
    private static final long TIME_BIT = 0b1111111111111111111111111111111111111111110000000000000000000000L;

    // 记录最后使用的毫秒时间戳,主要用于判断是否同一毫秒,以及用于服务器时钟回拨判断
    private long lastTimeMillis = -1L;

    // dataCenterId占用的位数
    private static final long DATA_CENTER_ID_BITS = 5L;

    // dataCenterId占用5个比特位,最大值31
    // 0000000000000000000000000000000000000000000000000000000000011111
    private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS);

    // dataCenterId
    private long dataCenterId;

    // workId占用的位数
    private static final long WORKER_ID_BITS = 5L;

    // workId占用5个比特位,最大值31
    // 0000000000000000000000000000000000000000000000000000000000011111
    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);

    // workId
    private long workerId;

    // 最后12位,代表每毫秒内可产生最大序列号,即 2^12 - 1 = 4095
    private static final long SEQUENCE_BITS = 12L;

    // 掩码(最低12位为1,高位都为0),主要用于与自增后的序列号进行位与,如果值为0,则代表自增后的序列号超过了4095
    // 0000000000000000000000000000000000000000000000000000111111111111
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);

    // 同一毫秒内的最新序号,最大值可为 2^12 - 1 = 4095
    private long sequence;

    // workId位需要左移的位数 12
    private static final long WORK_ID_SHIFT = SEQUENCE_BITS;

    // dataCenterId位需要左移的位数 12+5
    private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;

    // 时间戳需要左移的位数 12+5+5
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS;

    /**
     * 无参构造
     */
    public SnowFlakeUtil() {
        this(1, 1);
    }

    /**
     * 有参构造
     * @param dataCenterId
     * @param workerId
     */
    public SnowFlakeUtil(long dataCenterId, long workerId) {
        // 检查dataCenterId的合法值
        if (dataCenterId < 0 || dataCenterId > MAX_DATA_CENTER_ID) {
            throw new IllegalArgumentException(
                    String.format("dataCenterId 值必须大于 0 并且小于 %d", MAX_DATA_CENTER_ID));
        }
        // 检查workId的合法值
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException(String.format("workId 值必须大于 0 并且小于 %d", MAX_WORKER_ID));
        }
        this.workerId = workerId;
        this.dataCenterId = dataCenterId;
    }

    /**
     * 获取唯一ID
     * @return
     */
    public static Long getSnowFlakeId() {
        return snowFlakeUtil.nextId();
    }

    /**
     * 通过雪花算法生成下一个id,注意这里使用synchronized同步
     * @return 唯一id
     */
    public synchronized long nextId() {
        long currentTimeMillis = System.currentTimeMillis();
        //System.out.println(currentTimeMillis);
        // 当前时间小于上一次生成id使用的时间,可能出现服务器时钟回拨问题
        if (currentTimeMillis < lastTimeMillis) {
            throw new RuntimeException(
                    String.format("可能出现服务器时钟回拨问题,请检查服务器时间。当前服务器时间戳:%d,上一次使用时间戳:%d", currentTimeMillis,
                            lastTimeMillis));
        }
        if (currentTimeMillis == lastTimeMillis) {
            // 还是在同一毫秒内,则将序列号递增1,序列号最大值为4095
            // 序列号的最大值是4095,使用掩码(最低12位为1,高位都为0)进行位与运行后如果值为0,则自增后的序列号超过了4095
            // 那么就使用新的时间戳
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                currentTimeMillis = getNextMillis(lastTimeMillis);
            }
        } else { // 不在同一毫秒内,则序列号重新从0开始,序列号最大值为4095
            sequence = 0;
        }
        // 记录最后一次使用的毫秒时间戳
        lastTimeMillis = currentTimeMillis;
        // 核心算法,将不同部分的数值移动到指定的位置,然后进行或运行
        // <<:左移运算符, 1 << 2 即将二进制的 1 扩大 2^2 倍
        // |:位或运算符, 是把某两个数中, 只要其中一个的某一位为1, 则结果的该位就为1
        // 优先级:<< > |
        return
                // 时间戳部分
                ((currentTimeMillis - INIT_EPOCH) << TIMESTAMP_SHIFT)
                        // 数据中心部分
                        | (dataCenterId << DATA_CENTER_ID_SHIFT)
                        // 机器表示部分
                        | (workerId << WORK_ID_SHIFT)
                        // 序列号部分
                        | sequence;
    }

    /**
     * 获取指定时间戳的接下来的时间戳,也可以说是下一毫秒
     * @param lastTimeMillis 指定毫秒时间戳
     * @return 时间戳
     */
    private long getNextMillis(long lastTimeMillis) {
        long currentTimeMillis = System.currentTimeMillis();
        while (currentTimeMillis <= lastTimeMillis) {
            currentTimeMillis = System.currentTimeMillis();
        }
        return currentTimeMillis;
    }

    /**
     * 获取随机字符串,length=13
     * @return
     */
    public static String getRandomStr() {
        return Long.toString(getSnowFlakeId(), Character.MAX_RADIX);
    }

    /**
     * 从ID中获取时间
     * @param id 由此类生成的ID
     * @return
     */
    public static Date getTimeBySnowFlakeId(long id) {
        return new Date(((TIME_BIT & id) >> 22) + INIT_EPOCH);
    }

    public static void main(String[] args) {
        SnowFlakeUtil snowFlakeUtil = new SnowFlakeUtil();
        long id = snowFlakeUtil.nextId();
        System.out.println(id);
        Date date = SnowFlakeUtil.getTimeBySnowFlakeId(id);
        System.out.println(date);
        long time = date.getTime();
        System.out.println(time);
        System.out.println(getRandomStr());

/*        System.out.println("============================");
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            long id2 = snowFlakeUtil.nextId();
            System.out.println(id2);
        }
        System.out.println(System.currentTimeMillis() - startTime);*/


    }

}

主要就这个: long id = snowFlakeUtil.nextId();

 时钟同步问题解决方案:

雪花算法-Java实现-解决时钟回拨的一种方法_雪花算法时钟回拨_fierys的博客-CSDN博客

这个代码是借鉴另外一位博主的方案,可以借鉴一下,仅供参考

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**相较于标准算法,加入了时钟回拨解决方法,仅单机研究,仅个人思考,仅供参考
 */
public class SnowFlow {
    //因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。

    //机器ID  2进制5位  32位减掉1位 31个
    private long workerId;
    //机房ID 2进制5位  32位减掉1位 31个
    private long datacenterId;
    //代表一毫秒内生成的多个id的最新序号  12位 4096 -1 = 4095 个
    private long sequence;
    //设置一个时间初始值    2^41 - 1   差不多可以用69年
    private long twepoch = 1420041600000L;
    //5位的机器id
    private long workerIdBits = 5L;
    //5位的机房id;。‘
    private long datacenterIdBits = 5L;
    //每毫秒内产生的id数 2 的 12次方
    private long sequenceBits = 12L;
    // 这个是二进制运算,就是5 bit最多只能有31个数字,也就是说机器id最多只能是32以内
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);
    // 这个是一个意思,就是5 bit最多只能有31个数字,机房id最多只能是32以内
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

    private long workerIdShift = sequenceBits;
    private long datacenterIdShift = sequenceBits + workerIdBits;
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

    // -1L 二进制就是1111 1111  为什么?
    // -1 左移12位就是 1111  1111 0000 0000 0000 0000
    // 异或  相同为0 ,不同为1
    // 1111  1111  0000  0000  0000  0000
    // ^
    // 1111  1111  1111  1111  1111  1111
    // 0000 0000 1111 1111 1111 1111 换算成10进制就是4095
    private long sequenceMask = -1L ^ (-1L << sequenceBits);
    //记录产生时间毫秒数,判断是否是同1毫秒
    private long lastTimestamp = -1L;
    public long getWorkerId(){
        return workerId;
    }
    public long getDatacenterId() {
        return datacenterId;
    }
    public long getTimestamp() {
        return System.currentTimeMillis();
    }

    //是否发生了时钟回拨
    private boolean isBackwordsFlag = false;
    //是否是第一次发生时钟回拨, 用于设置时钟回拨时间点
    private boolean isFirstBackwordsFlag = true;
    //记录时钟回拨发生时间点, 用于判断回拨后的时间达到回拨时间点时, 跳过 已经用过的 时钟回拨发生时间点 之后的时间 到 未来时间的当前时间点
    private long backBaseTimestamp = -1L;

    public SnowFlow() {
    }

    public SnowFlow(long workerId, long datacenterId, long sequence) {

        // 检查机房id和机器id是否超过31 不能小于0
        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;
        this.sequence = sequence;
    }

    // 这个是核心方法,通过调用nextId()方法,
    // 让当前这台机器上的snowflake算法程序生成一个全局唯一的id
    public synchronized long nextId() {
        // 这儿就是获取当前时间戳,单位是毫秒
        long timestamp = timeGen();

        //--20220813--1---------------------------------------
        if (isBackwordsFlag) {
            //当回拨时间再次叨叨回拨时间点时, 跳过回拨这段时间里已经使用了的未来时间
            if (timestamp >= backBaseTimestamp && timestamp < lastTimestamp) {
                //直接将当前时间设置为最新的未来时间
                timestamp = lastTimestamp;
            } else if(timestamp > lastTimestamp) {
                //当前时间已经大于上次时间, 重置时钟回拨标志
                isBackwordsFlag = false;
                isFirstBackwordsFlag = true;
                System.out.println("时间已恢复正常-->" + timestamp);
            } else {
                // timestamp == lastTimestamp 等于的情况在后面
            }
        }
        //--20220813--1----------------------------------------

        // 判断是否小于上次时间戳,如果小于的话,就抛出异常
        if (timestamp < lastTimestamp) {

            System.err.printf("lastTimestamp=%d, timestamp=%d, l-t=%d \n", lastTimestamp, timestamp, lastTimestamp - timestamp);
//            throw new RuntimeException(
//                    String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
//                            lastTimestamp - timestamp));

            //--20220813--2---------------------------------------
            //这里不再抛出异常, 改为记录时钟回拨发生时间点

            //发生时钟回拨后, 当前时间 timestamp 就变成了 过去的时间
            //此时将 timestamp 设置为 上一次时间, 即相对于当前时间的未来时间
            timestamp = lastTimestamp;
            isBackwordsFlag = true;

            //记录时钟回拨发生的时间点, 后续需要跳过已经使用的未来时间段
            if (isFirstBackwordsFlag) {
                backBaseTimestamp = timestamp;
                isFirstBackwordsFlag = false;
                System.out.println("时钟回拨已发生-->" + backBaseTimestamp);
            }
            //--20220813--2---------------------------------------
        }

        // 下面是说假设在同一个毫秒内,又发送了一个请求生成一个id
        // 这个时候就得把seqence序号给递增1,最多就是4096
        if (timestamp == lastTimestamp) {

            // 这个意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,
            //这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围
            sequence = (sequence + 1) & sequenceMask;
            //当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
            if (sequence == 0) {
                //timestamp = tilNextMillis(lastTimestamp);

                //--20220813--3---------------------------------------
                //这里也不能阻塞了, 因为阻塞方法中需要用到当前时间, 改为将此时代表未来的时间 加 1
                if (isBackwordsFlag) {
                    lastTimestamp++;

                    //根据博友评论反馈, 这里可能需要重新赋值, 如果有人看到这个, 可以验证
                    //timestamp = lastTimestamp++;

                } else {
                    timestamp = tilNextMillis(lastTimestamp);
                }
                //--20220813--3---------------------------------------
            }

        } else {
            //sequence = 0;
            //每毫秒的序列号都从0开始的话,会导致没有竞争情况返回的都是偶数。解决方法是用时间戳&1,这样就会随机得到1或者0。
            sequence = timestamp & 1;
        }
        // 这儿记录一下最近一次生成id的时间戳,单位是毫秒
        //lastTimestamp = timestamp;

        //--20220813--4---------------------------------------
        if(isBackwordsFlag) {
            //什么都不做
        } else {
            lastTimestamp = timestamp;
        }
        //--20220813--4---------------------------------------

        // 这儿就是最核心的二进制位运算操作,生成一个64bit的id
        // 先将当前时间戳左移,放到41 bit那儿;将机房id左移放到5 bit那儿;将机器id左移放到5 bit那儿;将序号放最后12 bit
        // 最后拼接起来成一个64 bit的二进制数字,转换成10进制就是个long型
        long sn = ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) | sequence;

        if (isBackwordsFlag) {
            System.out.printf("sn=%d\n", sn);
        }
        return sn;
    }

    /**
     * 当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
     * @param lastTimestamp
     * @return
     */
    private long tilNextMillis(long lastTimestamp) {

        long timestamp = timeGen();

        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    //获取当前时间戳
    private long timeGen(){
        return System.currentTimeMillis();
    }

    /**
     *  main 测试类
     * @param args
     */
    public static void main(String[] args) throws IOException, InterruptedException {

        SnowFlow snowFlow = new SnowFlow(1, 1, 1);
        int count = 10000000;
        //int count = 100;
        for (int i = 0; i < count; i++) {
            //实际测试发现遍历太快输出日志过多导致卡顿, 增加睡眠时间, 或输出到文件
            snowFlow.nextId();

            //Thread.sleep(100);

//            System.out.println(snowFlow.nextId());

//            if (i == 1000) {
            //不具有管理员权限的用户, 修改不成功
            //testClockMvoedBackwords(30);
//            }
            //改为 手动修改,  右键cmd,以管理员权限打开后,使用time命令即可, time 16:15:00
        }


        System.out.println(System.currentTimeMillis());
        /**
         * 这里为什么特意输出一个开始时间呢, 其实就是一个运行了两年的程序突然有一天出bug了,导致了严重的生产事件!
         * 那么时间初始化影响什么呢, 答案是 序列的长度
         * 有人就说了, 这个一般是作为 主键用的, 长度貌似影响不大, 确实是这样的
         * 这次的bug不是雪花算法本身的问题, 而是程序里面有个功能是严格长度截取的, 并且只考虑了长度不够的情况, 没有考虑到变长的情况
         * 最根本的原因是 本人截取的时候 序列的长度一直是18位, 然后截取9位的代码是这么写的 substring(9);
         * 当未来的某一天序列长度增加到了19位,那么这个截取就会返回10位长度, 最终导致一个大范围的交易失败......
         * 锅当然是本人背, 这里提出这种情况, 供大家参考.
         * 经过仔细研究所谓的序列可以使用69年, 序列的长度变化是这样的, 假设以当前时间为初始化值
         * 12 13 14 15 16 17 18(约7年) 19(约58年)
         * 长度随时间增加, 长度越长, 保持相同长度的时间越长
         */
        DateTimeFormatter dtf2 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        String dateString = "2015-01-01 00:00:00";
        LocalDateTime localDateTime = LocalDateTime.parse(dateString,dtf2);
        System.out.println(localDateTime.toInstant(ZoneOffset.ofHours(8)).toEpochMilli());

    }

    //windows os 模拟时钟回拨, 将当前时间减去几秒
    private static void testClockMvoedBackwords(long seconds) throws IOException {
        System.out.println(LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")));

        LocalDateTime localDateTime = LocalDateTime.now();
        String backTime = localDateTime.minusSeconds(seconds).format(DateTimeFormatter.ofPattern("HH:mm:ss"));
        System.out.println(backTime);

        if (System.getProperty("os.name").contains("Windows")) {
            String cmd = "cmd /c start time 15:41:56";// + backTime;
            //不具有管理员权限的用户, 修改不生效, 提示 客户端无所需特权
            Runtime.getRuntime().exec(cmd);
//            Runtime.getRuntime().exec("cmd /c notepad");
            System.out.println(LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")));
        }
    }
}

3.Snowflake生产方案 时钟回拨问题解决思路

第一种办法,就是关闭时钟同步,避免产生时钟同步问题,不过这个不太现实,因为强依赖时间的系统,一般都得做时钟同步,避免时间严重错误,在虚拟机上部署一些东西,玩儿虚拟机,休眠,再次恢复之后往往虚拟机里的时间和宿主机的时间是不同步的导致一些大数据的分布式系统会崩溃掉,节点之间通信会依赖时间戳进行比对,心跳过慢,就会导致节点挂掉


第二种办法,记录下来上一次生成ID的时间,如果发现本次生成ID的时候,时间戳小于上次的时间戳,说明时钟回拨了,此时就这个时间内不允许生成ID,一直等,等待到当前时间追上上一次生成时间,问题在于,万一回拨的时间太多了呢?可能要等很久,影响了系统的可用性,所以也不是特别好的办法内存里可以存上一次生成唯一ID的时间戳,时钟回拨了,把当前时间戳会回拨到上次时间戳之前,请求过来,要生成唯一ID,你不要直接就返回一个ID给他,你先做一个比较,如果你发现当前时间戳跟上一次生成唯一ID的时间戳想比,比他小判定,时钟回拨,只要你生成ID,就有可能会发生ID的重复可用性这么差的话,人家业务服务万一此时是要生成一个账单数据,申请一个ID,此时你好不容易等待了几百毫秒之后,你还告诉他你内部异常,没法获取到唯一ID,反复的重试,你会影响他的业务服务的运行。
 

第三种办法,针对第二种办法的优化,如果发现时钟回拨太狠了,比如超过了1分钟,此时直接就报警,同时不再对外提供服务,把自己从集群里摘了,比如你要是基于微服务注册中心进行注册的,就得主动做一个下线当你发现当前时间戳比上一次生成ID的时间戳要小,发现时钟回拨了,判断一下,回拨了多少毫秒,比如说回拨时间在500ms以内,此时可以hang住请求,等待500ms,等到500ms之后,当前时间戳比上一次生成ID的时间戳要大了
此时就可以正常生成唯一ID返回给业务方了,对于业务方而言,仅仅是在个别少数的时钟回拨的情况之下,请求平时只要50ms,500ms,还在接受范围之内,所以说还是可以的,只不过请求慢了一些
如果你要是发现你当前时间戳和上一次生成唯一ID的时间戳想比,你一比较,就发现超过了500ms了,超过了500ms了,但是在5s之内,此时你可以返回一个异常状态+异常持续时间给客户端,不要说有问题,可以通知他自行进行重试
重试机制,最好不要让业务方自己去做,你完全可以去封装一个你的唯一ID生成服务的客户端,基于RPC请求你的接口,但是你在自己的客户端里封装一个自动重试的机制,他一旦发现某台服务器返回的响应说自己短时间内没法提供服务,他自动就去请求其他机器上的服务获取唯一ID了
如果要解决时钟回拨,一般是第二种和第三种结合在一起来用,但是被动等待甚至主动下线,总是影响系统可用性的,都不是特别好
服务端的时钟回拨检测机制 + 客户端自己封装
1s以内:阻塞请求等待,客户端的超时时间,应该也是1s,暴露1s内每一毫秒生成过的唯一ID最大的序号,根据当前时间戳的毫秒,定位到之前生成过ID的这一毫秒的最大ID序号,此时继续生成ID,直接在之前生成过的这一毫秒的最大ID序号基础上递增就可以了,优化之后,就可以保证不需要阻塞等待
1s~10s之间:返回异常码和异常持续时间,客户端在指定时间内不请求这台机器
10s以上:返回故障码,请求服务注册中心让自己下线,客户端收到故障码之后,就直接把这个机器从服务机器列表里剔除掉,不请求他了,后续等到那台机器部署的ID服务他发现自己的时间可能过了几秒钟,缓过来了,恢复了,可用了,就可以再次进行服务注册,你客户端刷新服务注册列表的时候,就会发现他,此时可以再次去请求他
 

第四种办法,要在内存里维护最近几秒内生成的ID值,一般时钟回拨都是几十毫秒到几百毫秒,很少会超过秒的,所以保存最近几秒的就行了,然后如果发生了时钟回拨,此时就看看回拨到了哪一毫秒,因为时间戳是毫秒级的,接着就看那一毫秒
从那一毫秒生产过的ID序号往后继续生成就可以了,后续每一毫秒都是依次类推,这样就可以完美避免重复问题,还不用等待
但是这里也得做一个兜底机制,就是比如你保留最近10s内每一毫秒生成的ID,那么万一时钟回拨碰巧超过了10s呢?此时这种概率很低,你可以跟二三两个方案结合,设置几个阈值,比如说,你保留最近10s的ID,回拨10s内都可以保证不重复,不停顿;如果超过10s,在60s内,可以有一个等待的过程,让他时间前进到你之前保留过的10s范围内去;如果回拨超过了60s,直接下线
上一次生成唯一ID的时间戳也没了,最近1s内每一毫秒的最大ID序号也没了,重启之后,出现了时间回拨,发现不了时间回拨问题,其次也没有办法继续按照之前的思路去生成不重复的唯一ID了

4.时钟回拨优化

1、我们一般需要打开时钟同步功能,这样ID才能够最大化的保证按照时间有序,但是时钟同步打开后,就可能会时钟回拨了,如果时钟回拨了,那么生成的ID就会重复,为此我们一般打开时钟同步的同时关闭时钟回拨功能;
2、序列号的位数有限,能表示的ID个数有限,时钟同步的时候,如果某台服务器快了很多,虽然关闭了时钟回拨,但是在时间追赶上前,ID可能已经用完,当自增序列号用完了,我们可以做如下的工作:停止ID生成服务并告警、如果时钟回拨小于一定的阈值则等待、如大于一定的阈值则通过第三方组件如ZK重新生成一个workerid或者自增时间戳借用下一个时间戳的ID;
3、服务重启后,ID可能会重复,为此我们一般需要定期保存时间戳,重启后的时间戳必须大于保存的时间戳+几倍保存间隔时间(如3倍),为什么要几倍呢,主要是考虑到数据丢失的情况,但是如果保存到本地硬盘且每次保存都fsync,此时1倍即可。重启后如果小于可以像第二点那样类似处理;
4、如果请求ID的QPS不高,比如每毫秒一个,那么每次获取的ID的尾号都是0,那么基于ID做分库分表,可能数据分布就会不均,此时我们可以增加时间戳的时间间隔或者序列号每次从随机一个值开始自增。

主要是生成id的这个方法,我把这个再优化一下,这个是根据美团leaf做了进一步优化,请参考

    /**
     * 通过雪花算法生成下一个id,注意这里使用synchronized同步
     * @return 唯一id
     */
    public synchronized long nextId() {
        long currentTimeMillis = System.currentTimeMillis();
        //System.out.println(currentTimeMillis);
        // 当前时间小于上一次生成id使用的时间,可能出现服务器时钟回拨问题
        //long timestamp = timeGen();
        if (currentTimeMillis < lastTimeMillis) {
            long offset = lastTimeMillis - currentTimeMillis;
            if (offset <= 5) {
                try {
                    wait(offset << 1);
                    currentTimeMillis = timeGen();
                    if (currentTimeMillis < lastTimeMillis) {
                        throw new RuntimeException("id生成失败");
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException("生成id时出现错误");
                }
            } else {
                throw new RuntimeException(
                        String.format("可能出现服务器时钟回拨问题,请检查服务器时间。当前服务器时间戳:%d,上一次使用时间戳:%d", currentTimeMillis,
                                lastTimeMillis));
            }
        }


        /*if (currentTimeMillis < lastTimeMillis) {
            throw new RuntimeException(
                    String.format("可能出现服务器时钟回拨问题,请检查服务器时间。当前服务器时间戳:%d,上一次使用时间戳:%d", currentTimeMillis,
                            lastTimeMillis));
        }*/
        if (currentTimeMillis == lastTimeMillis) {
            // 还是在同一毫秒内,则将序列号递增1,序列号最大值为4095
            // 序列号的最大值是4095,使用掩码(最低12位为1,高位都为0)进行位与运行后如果值为0,则自增后的序列号超过了4095
            // 那么就使用新的时间戳
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                currentTimeMillis = getNextMillis(lastTimeMillis);
            }
        } else { // 不在同一毫秒内,则序列号重新从0开始,序列号最大值为4095
            sequence = 0;
        }
        // 记录最后一次使用的毫秒时间戳
        lastTimeMillis = currentTimeMillis;
        // 核心算法,将不同部分的数值移动到指定的位置,然后进行或运行
        // <<:左移运算符, 1 << 2 即将二进制的 1 扩大 2^2 倍
        // |:位或运算符, 是把某两个数中, 只要其中一个的某一位为1, 则结果的该位就为1
        // 优先级:<< > |
        return
                // 时间戳部分
                ((currentTimeMillis - INIT_EPOCH) << TIMESTAMP_SHIFT)
                        // 数据中心部分
                        | (dataCenterId << DATA_CENTER_ID_SHIFT)
                        // 机器表示部分
                        | (workerId << WORK_ID_SHIFT)
                        // 序列号部分
                        | sequence;
    }

【分布式】分布式唯一 ID 的 8 种生成方案

SnowFlake 雪花算法详解与实现 - 掘金

雪花算法(SnowFlake)_文丑颜不良啊的博客-CSDN博客

分布式唯一Id(雪花算法),原理+对比+方案 - 简书

雪花算法snowflake分布式id生成原理详解,以及对解决时钟回拨问题几种方案讨论_51CTO博客_snowflake 分布式id

Leaf——美团点评分布式ID生成系统 - 美团技术团队

 https://github.com/Meituan-Dianping/Leaf/tree/master/leaf-core

Snowflake生产方案 时钟回拨问题解决思路_启动报错 snowflake 时钟回拨解决方法_都是底层的博客-CSDN博客

猜你喜欢

转载自blog.csdn.net/Alex_81D/article/details/131924058