雪花算法使用中的一些问题及解决方案

帮助理解雪花算法的知识

十进制二进制表示法位运算

什么是雪花算法

关于雪花算法是啥网上到处都是,为了节省大家的流量和时间,一句话概括下:

雪花算法是Twitter设计的根据时间戳、机器标识码和序列号生成的唯一长整型数

能解决什么问题

确保在大规模高并发环境中,可以生成持续递增的唯一长整型数

具有哪些特点

  1. 唯一性
  2. 持续递增
  3. 结果为长整型数字
  4. 不依赖其他系统,无需引入数据库、Redis等系统
  5. 吞吐量大,12位序列号情况下,每毫秒可生成 4095个ID

实现原理

一张非常经典的图
在这里插入图片描述

符号位:仅表示正、负数,0 - 正1 - 负,雪花算法结果都为,即符号位都为0

时间戳:系统 毫秒级 时间戳

工作机器ID:大规模集群中,标识唯一机器的机器码标识

序列号:当前时间戳内的递增编号

Java实现(关键代码)

代码过长,此处仅粘贴帮助理解的关键代码,详细代码,及使用具体使用方法,请移步

public class Snowflake {

    /**
     * 起始时间
     * 1412092800000 按 yyyy-MM-DD HH:mm:ss SSS 格式转换为 2013-10-01 00:00:00
     * 该时间设定值个人觉得应当分两种情况:
     * 1、只需要生成唯一递增ID,生成解决的位数,不影响系统正常使用
     * 2、需要生成定长(18或19位)唯一递增ID,定长19位需要将起始时间设为当前时间的8(7.5 ≈ 8)年前
     * <p>
     * 配置文件配置:snowflake.start-timestamp
     */
    private long startTimestamp = 1380556800000L;

    /**
     * 总得字符长度
     */
    private final static long TOTAL_BITS = 63L;

    /**
     * 标准的雪花算法各部分所占位数
     */
    private final static long STANDARD_TIMESTAMP_BITS = 41L;
    private final static long STANDARD_DATA_CENTER_ID_BITS = 5L;
    private final static long STANDARD_MACHINE_ID_BITS = 5L;
    private final static long STANDARD_SEQUENCE_BITS = 12L;

    /**
     * 时间戳所占的二进制位数
     * 41位 可表示范围为: 2^41 - 1 ≈ 69.73(年) * 365(天) * 24(时) * 3600 (秒)
     * <p>
     * 也就是大家所说的可用 69 年的出处
     * <p>
     * 自定义配置文件配置:snowflake.timestamp-bits
     */
    private long timestampBits = 41L;

    /**
     * 数据中心ID标识码
     * 5位 可表示范围为:2^5 - 1 = 31
     * <p>
     * 自定义配置文件配置:snowflake.data-center-id-bits
     */
    private long dataCenterIdBits = 5L;

    /**
     * 机器Id标识码
     * 5位 可表示范围为:2^5 - 1 = 31
     * <p>
     * 配合数据中心使用,可表示范围为:2^10 - 1 = 1023
     * <p>
     * 自定义配置文件配置:snowflake.machine-id-bits
     */
    private long machineIdBits = 5L;

    /**
     * 同一毫秒内序列号所占位数
     * 2^12 - 1 = 4095
     * <p>
     * 也就是说每毫秒内可生成 4095个序列号
     * <p>
     * 自定义配置文件配置:snowflake.sequence-bits
     */
    private long sequenceBits = 12L;


    /**
     * 可使用的最大数据中心ID
     * 设置该值是为了避免传入数据中心ID过大,超出可用位数限制
     * 数据中心Id可表示范围为 0 ~ 31,大于31就是超出了,程序进行取模处理
     */
    private long maxDataCenterId = this.calcMaxValueByBits(this.dataCenterIdBits);

    /**
     * 可使用的最大机器码ID
     * 设置该值是为了避免传入机器码过大,超出可用位数限制
     * 机器码Id可表示范围为 0 ~ 31,大于31就是超出了,程序进行取模处理
     */
    private long maxMachineId = this.calcMaxValueByBits(this.machineIdBits);

    /**
     * 每毫秒内可共生成的最大序列号
     * 序列号可表示范围为 0 ~ 4095,大于4095程序跳到下一毫秒进行生成
     */
    private long maxSequence = this.calcMaxValueByBits(this.sequenceBits);

    /**
     * 时间戳在64位结果中从右到左的起始位置
     * 结果为: 数据中心所展位数 + 机器表示码Id所占位数 + 序列号所占位数
     */
    private long timestampOffset = this.dataCenterIdBits + this.machineIdBits + this.sequenceBits;

    /**
     * 数据中心Id在64为结果中从右到左所占位数
     * 结果为:机器标识码Id所占位数 + 序列号所占位数
     */
    private long dataCenterIdOffset = this.machineIdBits + this.sequenceBits;

    /**
     * 机器标识码Id在64为结果中从右到左所占位数
     * 结果为:序列号所占位数
     */
    private long machineIdOffset = this.sequenceBits;

    /**
     * 记录上一次生成雪花算法Id时的时间戳,记录该值主要用于检测时钟回拨:
     * <p>
     * 如果 已记录上一次生成的时间戳 > 当前时间戳 ,即发生了时钟回拨情况
     */
    private long lastTimestamp = 0L;

    /**
     * 记录上一次生成雪花算法Id时的序列号
     * <p>
     * 记录上一次的生成序列号是非常有必要的,雪花算法能够保持持续递增,取决于三个组成部分:
     * 1、保持持续增长的时间戳部分
     * 2、保持不变的机器标识码部分
     * 3、保持统一毫秒内持续增长的序列号部分
     */
    private long lastSequence = 0L;

    /**
     * 运行该程序的机器所处数据中心Id
     * <p>
     * 自定义配置文件配置:snowflake.data-center-id
     */
    private long dataCenterId = 1L;

    /**
     * 运行改程序的机器在该数据中心的唯一Id
     * <p>
     * 自定义配置文件配置:snowflake.machine-id
     */
    private long machineId = 1L;

    /**
     * 允许始终回拨的最大毫秒数
     * <p>
     * 自定义配置文件配置:snowflake.allow-time-backward-m-s
     */
    private long allowTimeBackwardMS = 5L;

    public Snowflake() {
        if(this.dataCenterId == 0L) {
            this.dataCenterId = this.getDataCenterId(this.maxDataCenterId);
        }

        if(this.machineId == 0L){
            this.machineId = this.getMachineId(this.maxMachineId);
        }
    }

    /**
     * 根据时间戳、机器标识码和序列号生成持续递增的 针对该机器标识码唯一 的雪花算法Id
     *
     * @return snowflakeId
     */
    public synchronized long nextId() {
        /**
         * 如果各部分所占位数进行了自定义:
         *     1、重新计算各部分从左到右起始位数
         *     2、重新计算各部分允许的最大值
         */
        if (this.hasCustomBits()) {
            this.reCalcBitsMax();   // 重新计算各部分从左到右起始位数
            this.reCalcBitsOffset(); // 重新计算各部分允许的最大值
        }

        long currentTimestamp = this.getCurrentTimestamp();

        /**
         * 如果当前时间小于上次生成的时间,可能发生的时钟回拨情况
         *
         * 1、如果回拨时间范围 小于 允许的最大回拨范围,程序等待
         * 2、如果回拨时间范围 大于 允许的最大回拨范围,抛出异常
         */
        if (currentTimestamp < this.lastTimestamp) {
            long timestampOffset = this.lastTimestamp - currentTimestamp;
            if (timestampOffset > this.allowTimeBackwardMS) {   // 如果时钟回拨范围大于允许的最大回拨范围,抛出异常
                throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", timestampOffset));
            }

            try {
                wait(timestampOffset);  // 如果回拨时间范围 小于 允许的最大回拨范围,程序等待
            } catch (Exception e) {
                throw new RuntimeException(e);
            }

            /**
             * 等待结束,即认定为可以继续生成Id
             * 重新获取用于生成最终结果的 时间戳
             */
            currentTimestamp = this.getCurrentTimestamp();
        }

        this.lastSequence = (this.lastSequence + 1) & maxSequence;

        /**
         * this.lastSequence 为 0,代表当前时间戳已经达到 序列号最大值(maxSequence),分两种情况处理:
         *  1、如果 当前时间戳 与 所记录的上次生成时间戳 相同,则当前时间戳跳到下一毫秒,并将序列号重新置为起始状态
         *  2、如果 当前时间戳 大于 所记录的上次生成时间戳,则直接将当前时间戳跳到下一毫秒,并将序列号重新置为起始状态
         *  3、当前时间戳 小于 所记录的上次生成时间戳 的情况即为 时钟回拨的情况,按时钟回拨已进行处理,不在考虑
         */
        if (this.lastSequence == 0L) {
            if (currentTimestamp == this.lastTimestamp) { // 相同时间戳改为下一个时间戳
                currentTimestamp = this.getNextTimestamp(this.lastTimestamp);
            }

            this.lastSequence = ThreadLocalRandom.current().nextLong(0, 3);
        }

        this.lastTimestamp = currentTimestamp; // 记录当前所生成的时间戳,用于下一次生成进行比对

        return ((currentTimestamp - this.startTimestamp) << this.timestampOffset)
                | (this.dataCenterId << this.dataCenterIdOffset)
                | (this.machineId << this.machineIdOffset)
                | this.lastSequence;
    }

    /**
     * 抄自:
     * 项目:mybatis-plus
     * 源代码包名:om.baomidou.mybatisplus.core.toolkit.Sequence;
     * 地址:https://github.com/baomidou/mybatis-plus
     * <p>
     * 如果没有手动设定 数据中心Id,程序为保证正常运行,自动计算一个数据中心Id,
     * 计算出的结果是根据最大数据中心Id取模后的,不会超出数据中心Id的标识范围
     *
     * @param maxDataCenterId
     * @return
     */
    protected long getDataCenterId(long maxDataCenterId) {
        long id = 0L;
        try {
            InetAddress ip = InetAddress.getLocalHost();
            NetworkInterface network = NetworkInterface.getByInetAddress(ip);

            if (network == null) {
                id = 1L;
            } else {
                byte[] mac = network.getHardwareAddress();
                if (null != mac) {
                    id = ((0x000000FF & (long) mac[mac.length - 2]) | (0x0000FF00 & (((long) mac[mac.length - 1]) << 8))) >> 6;
                    id = id % (maxDataCenterId + 1);
                }
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        return id;
    }

    /**
     * 抄自:
     * 项目:mybatis-plus
     * 源代码包名:com.baomidou.mybatisplus.core.toolkit.Sequence;
     * 地址:https://github.com/baomidou/mybatis-plus
     */
    protected long getMachineId(long maxMachineId) {
        StringBuilder mPid = new StringBuilder();
        mPid.append(dataCenterId);
        String name = ManagementFactory.getRuntimeMXBean().getName();
        if (this.isBlank(name)) {
            /*
             * GET jvmPid
             */
            mPid.append(name.split("@")[0]);
        }
        /*
         * MAC + PID 的 hashcode 获取16个低位
         */
        return (mPid.toString().hashCode() & 0xffff) % (maxMachineId + 1);
    }

    /**
     * 获取比当前毫秒时间戳大的下一个毫秒时间戳
     *
     * @param currentTimestamp
     * @return
     */
    private long getNextTimestamp(long currentTimestamp) {
        long nextTimestamp = getCurrentTimestamp();

        while (nextTimestamp <= currentTimestamp) {
            nextTimestamp = getCurrentTimestamp();
        }

        return nextTimestamp;
    }

    /**
     * 获取当前毫秒级时间戳
     *
     * @return
     */
    private long getCurrentTimestamp() {
        return System.currentTimeMillis();
    }

}

使用中可能存在的问题

  1. 机器时钟回拨可能会导致ID重复,解决办法:

参照

定义一定的回拨允许范围,比如: 5ms,程序等待5*2ms,后重新获取时间戳,进行ID生成

  1. 在集群环境中可能会受到工作机器ID大小的影响,生成的ID并非绝对递增

栗子:在1ms时,dataCenterId = 10;machineId=20的机器持续递增生成了id1;在2ms时,dataCenterId = 1;machineId=2的机器持续递增生成了id2,这时虽然id2在时间上先生成,但是并不能保证id2 > id1

  1. 相同的代码生成的id长度不一致,比较常见的有18位19位

    1. 原因:代码中的时间戳虽然分配了41位,并且进行了左移,但是时间戳的十进制大小区别比较大,比如:

      十进制1000 ,41位二进制表示为:00000000000000000000000000000001111101000

      十进制1100000000000,41位二进制表示为:10000000000011101000110111111100000000000

      由此可以看出,时间戳比较临近时,时间戳差值比较小。二进制即时都是41位,表示十进制的大小也是不一样的,雪花算法的64位二进制同理。

    2. 如何解决:

      大佬的思路,请移步

      在求证雪花算法最多生成的Id位数时,个人做的计算是:找生成19位Id的临界值。个人也就当玩一玩,极不推荐

      雪花算法最大表示63位二进制为:111111111111111111111111111111111111111111111111111111111111111,十进制为9223372036854776000,不超过19位,可作为上边界

      计算下边界,先确定最小19位十进制数为1000000000000000000,转换成二进制00011011110000010110110101100111010011101 1001000000000000000000,但是后22位(机器标识码和序列号)处有1存在,万一机器标识码改变有可能导致十进制表示数变成18位。将后22位(机器标识码和序列号)全部置000011011110000010110110101100111010011101 1001000000000000000000,十进制表示为:999999999997640700为18位,但是将从右至左第24位置1,二进制表示为:00011011110000010110110101100111010011111 0000000000000000000000,即可表示19位十进制数1000000000006029300,并将此作为下边界。41位时间戳部分为:00011011110000010110110101100111010011111,转换为十进制为238418579103,也就是238418579103 ms ≈ 7.57年。与大佬算的基本一致。

    3. 结论,由此得出,设置的起始时间只要在当前时间7.57年前,生成的Id,永远都是19位。

  2. 定制更短雪花算法生成的ID长度,方法:

    建议先考虑缩短机器标识码序列号位数。毕竟有些项目,机器少、交易量小。

    如果改变后,要确保生成Id为固定长度,可以参照大佬的思路,请移步

如有问题请帮忙指出,感谢!


版权声明:本文为for_happy123原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。