zoukankan      html  css  js  c++  java
  • SnowFlake雪花算法源码分析&灵活改造,常见分布式ID生成解决方案

    带着几个关注点去研读源码

    1. 算法设计的整体逻辑是什么,核心点是什么?
    2. 算法是如何达到高并发的?
    3. 算法的高并发能力极限?
    4. 既然是生成ID,那么生成的可用量有多大,可用的时间为多少,ID的存储方式?
    5. 算法是否有缺陷,如何避免或者改进?
    6. 算法是否可自由拓展或改造,以契合当前项目需求?

    SnowFlake源码:

    /**
     * Twitter_Snowflake
     * SnowFlake的结构(每部分用-分开):
     * 0-00000000000000000000000000000000000000000-00000-00000-000000000000
     * 第一部分:
     * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,ID要是正数,最高位是0
     * 第二部分:
     * 41位毫秒数,不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 自定义起始时间截),
     * 自定义起始时间戳即人为指定的算法起始时间,当前时间即生成ID时的时间戳
     * 41位的时间截,可以使用约69年, (1L << 41) / (365 * 24 * 3600 * 1000)≈ 69
     * 第三、四部分:
     * 10位的数据机器位,可以部署在1024(1L<<10)个节点,包括5位datacenterId(机房)和5位workerId(机器号)
     * 第五部分:
     * 12位序列,每毫秒可生成序列号数,共4096(1L<<12)个ID序号
     * 以上5部分总64bit,即需要一个Long整型来记录
     * SnowFlake的优点:
     * - 整体按时间自增排序
     * - Long整型ID,存储高效,检索高效
     * - 分布式系统内无ID碰撞(各分区由datacenterId和workerId来区分)
     * - 生成效率高,占用系统资源少,理论每秒可生成1000 * 4096 = 4096000个
     * SnowFlake的缺点:
     * - 时钟回拨问题,尤其在高并发中,时钟回拨可能会生产出重复的ID
     */
    public class SnowFlakeIdWorker {
    
        /**
         * 指定起始时间戳 (2021-05-21 00:00:00)
         */
        private final long twepoch = 1621440000000L;
    
        /**
         * 数据中心/机房标识所占bit位数
         */
        private final long datacenterIdBits = 5L;
    
        /**
         * 机器标识所占bit位数
         */
        private final long workerIdBits = 5L;
    
        /**
         * 每毫秒下的序列号所占bit位数
         */
        private final long sequenceBits = 12L;
    
        /**
         * 数据中心掩码,即最大支持32个机房
         */
        private final long maxDatacenterId = ~(-1L << datacenterIdBits);
    
        /**
         * 机器掩码,即最大支持32个机器
         */
        private final long maxWorkerId = ~(-1L << workerIdBits);
    
        /**
         * 每毫秒序列号的掩码
         */
        private final long sequenceMask = ~(-1L << sequenceBits);
    
        /**
         * 机器ID表示的bit在long中位置,需要左移的位数(12)
         */
        private final long workerIdShift = sequenceBits;
    
        /**
         * 数据中心ID表示的bit在long中的位置,需要左移的位数(12+5)
         */
        private final long datacenterIdShift = sequenceBits + workerIdBits;
    
        /**
         * 时间截部分需要左移的位数(5+5+12)
         */
        private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
        /**
         * 机器ID(0~31)
         */
        private long workerId;
    
        /**
         * 数据中心ID(0~31)
         */
        private long datacenterId;
    
        /**
         * 每毫秒内序列(0~4095)
         */
        private long sequence = 0L;
    
        /**
         * 最后一次生成ID时的时间截
         */
        private long lastTimestamp = -1L;
    
        /**
         * 构造函数
         *
         * @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;
        }
    
        /**
         * 获得下一个ID,synchronized同步的,此处必须同步
         *
         * @return SnowflakeId
         */
        public synchronized long nextId() {
            long timestamp = timeGen();
            // 若当前时间戳小于最后一次生成ID时的时间戳,说明系统时钟回退过,此时无法保证ID的唯一性,算法抛异常退出
            if (timestamp < lastTimestamp) {
                throw new RuntimeException(
                        String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
            }
            // 若当前时间戳等于最后一次生成ID时的时间戳(同一毫秒内),则进行序列号累加
            if (lastTimestamp == timestamp) {
                // 此操作可获得的最大值是4095,最小值是0,在溢出时为0
                sequence = (sequence + 1) & sequenceMask;
                // 毫秒内序列溢出
                if (sequence == 0) {
                    // 阻塞到下一个毫秒,获得新的时间戳
                    timestamp = tilNextMillis(lastTimestamp);
                }
            } else {
                // 若当前时间戳大于最后一次生成ID时的时间戳,则序列号需要重置到0
                sequence = 0L;
            }
            // 更新记录本次时间戳
            lastTimestamp = timestamp;
            // 位运算,获得最终的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();
        }
    
        /**
         * 测试
         */
        public static void main(String[] args) {
            SnowFlakeIdWorker idWorker = new SnowFlakeIdWorker(0, 0);
            for (int i = 0; i < 10; i++) {
                long id = idWorker.nextId();
                System.out.println(id);
            }
        }
    }
    

    源码中的注释分析已经很详尽,再贴一张直观的图看看:

    • 1bit保留,最高位有符号标识,Long为正数
    • 41bit为时间戳差值,可用时长约69年
    • 5bit用于标识机房,取值:0~31
    • 5bit用于标识机器,取值:0~31
    • 12bit用于区分同一毫秒内请求的序列号,取值:0~4095

    在研读源码后对一开始几个问题的回答:

    1. 算法以时间戳为生成源作为生成ID核心,使用一个Long整型来记录ID,并对Long的64位进行分割设计为:保留位+时间戳+机房+机器+序列号,其中时间戳为核心,序列号进一步提高了并发量;
    2. 算法的高并发主要是毫秒级的时间戳和每毫秒的序列化计数,调整这两个值即可调整并发量;
    3. 单台机器并发理论值为:4096 * 1000 = 4096000ID/秒;
    4. 时间维度上,算法的可用时间由41个bit时间戳锁定了,约69年,极限总量约为:69 * 365 * 24 * 3600 * 4096000 * 1024
    5. 机器的时钟回拨可能导致生成重复ID,需要额外手段保持时钟同步;
    6. 算法的5个部分中时间戳是核心,时间戳bit可增减,其他部分可合并或重新分割为更多或更少的部分,且不一定非要用一个Long整型来生成ID,在保留时间戳这个核心点之外的部分都可以自由设计,以满足项目的需求;

    对于获取毫秒时间的效率问题:

    // 直接调用原生Java api
    System.currentTimeMillis();
    

    查阅资料,一堆的复制粘贴文章说直接这样调用api在高并发时有性能问题,搜索关键词:“System.currentTimeMillis()的性能问题”,主要的两个链接:

    简要概括下Orcale官方的观点:此问题级别太低,且超出了jvm范围,慢是os或cpu本身的问题,不在jvm生态内,所以不是bug,问题定级为低,不予处理。

    既然官方这样回复,说明直接调用api的效率在Java本身生态系统内是没有问题的,那么直接调用即可。

    本人在实际项目中的改造案例

    项目中的原有的id生成逻辑是这样的:

    • 记录一个起始long值,作为计数值,每次获取的时候+1,该值每次记录到文件,系统启动时读取上次记录的值;
    • 把long处理为byte数组,并进行位操作乱序(类似hashcode操作);
    • 获取当前日期,年月日每个部分为一个byte;
    • 把两个byte数组前后拼接并转为16进制的字符串,总32长度;

    旧有逻辑也不知道谁写的… 反正记录到文件这个就挺不靠谱,还要多个文件管理,文件不见了又是个问题,现在需求要求不要依赖文件,重新写一个生成ID效率高且仍然是16进制的32长度的字符串;

    基于SnowFlake改造后的ID生成方案:

    • 保留时间自增的核心;
    • 因为最终要生成16进制的字符串,所以不再局限Long,实际设计为32长度16进制共128bit;
    • 考虑一个4位IP地址需要32bit记录;
    • 整个ID设计为:32bit记录k8s的主机IP + 32bit记录pod的IP + 52bit时间戳 + 12bit序列号

    简单讲就是,保留原有的生成时间戳和序列号的部分,共占64bit,剩余64bit用来记录两个IP地址(暂未从项目找到其他固定可用标识了),总计128bit。
    即:

    • 128bit = 32bit + 32bit + 52bit + 12bit = 8hex + 8hex + 13hex + 3hex = 32hex

    改造后的源码:

    /**
     * @author zhoujie
     * @date 2021/5/17 下午5:10
     * @description: 基于SnowFlake改造的32长度hex进制的ID生成方案
     * 总128bit长度,32hex长度:
     * <p>
     * 32bit记录k8s的主机IP + 32bit记录pod的IP + 52bit时间戳 + 12bit序列号
     * <p>
     * 128bit = 32bit + 32bit + 52bit +12bit = 8hex + 8hex + 13hex + 3hex = 32hex
     */
    public class SnowFlakeIdUtil {
    
        /**
         * 指定起始时间戳 (2021-05-21 00:00:00)
         */
        private static final long twepoch = 1420041600000L;
    
        /**
         * 每毫秒下的序列号所占bit位数
         */
        private static final long sequenceBits = 12L;
    
        /**
         * 每毫秒序列号的掩码
         */
        private static final long sequenceMask = ~(-1L << sequenceBits);
    
        /**
         * 每毫秒内序列(0~4095)
         */
        private static long sequence = 0L;
    
        /**
         * 最后一次生成ID时的时间截
         */
        private static long lastTimestamp = -1L;
    
        /**
         * HOST_IP
         */
        private static final String HOST_IP = "222.222.222.222";
    
        /**
         * POD_IP
         */
        private static final String POD_IP = "111.111.111.111";
    
        /**
         * ip的处理后16进制表示的部分
         */
        private static String IP_HEX_PART = null;
    
        /**
         * 静态工具类,构造器私有化
         */
        private SnowFlakeIdUtil() {
        }
    
        /**
         * 获得下一个ID,synchronized同步的,此处必须同步
         *
         * @return SnowflakeId
         */
        public static synchronized long nextId() {
            long timestamp = timeGen();
            // 若当前时间戳小于最后一次生成ID时的时间戳,说明系统时钟回退过,此时无法保证ID的唯一性,算法抛异常退出
            if (timestamp < lastTimestamp) {
                throw new RuntimeException(
                        String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
            }
            // 若当前时间戳等于最后一次生成ID时的时间戳(同一毫秒内),则进行序列号累加
            if (lastTimestamp == timestamp) {
                // 此操作可获得的最大值是4095,最小值是0,在溢出时为0
                sequence = (sequence + 1) & sequenceMask;
                // 毫秒内序列溢出
                if (sequence == 0) {
                    // 阻塞到下一个毫秒,获得新的时间戳
                    timestamp = tilNextMillis(lastTimestamp);
                }
            } else {
                // 若当前时间戳大于最后一次生成ID时的时间戳,则序列号需要重置到0
                sequence = 0L;
            }
            // 更新记录本次时间戳
            lastTimestamp = timestamp;
            // 位运算,此处拼接时间戳和序列号一并返回是为了效率,后面处理时还是需要拆开各自处理
            return (timestamp - twepoch) << sequenceBits | sequence;
        }
    
        /**
         * @author zhoujie
         * @date 2021/5/17 下午5:15
         * @description: 改造后的生成ID方案,生成32长度16进制的ID:
         * <p>
         * host_ip + pod_ip + 时间戳 + 序列号
         * <p>
         * 8hex + 8hex + 13hex +3hex
         */
        private static String getMsgId() {
            long nextId = nextId();
            long seq = nextId & sequenceMask;
            long unixTime = nextId >> sequenceBits;
    
            StringBuilder msgIdBuffer = new StringBuilder(32);
            // 末3位hex为序列号
            msgIdBuffer.append(Long.toHexString(seq));
            while (msgIdBuffer.length() < 3) {
                msgIdBuffer.insert(0, "0");
            }
            // 中间13位hex为时间戳
            msgIdBuffer.insert(0, Long.toHexString(unixTime));
            while (msgIdBuffer.length() < 16) {
                msgIdBuffer.insert(0, "0");
            }
            // IP为环境信息,只需要初始化一次
            if (IP_HEX_PART == null) {
                IP_HEX_PART = ipToHexString(HOST_IP) + ipToHexString(POD_IP);
            }
            // 前16位为环境相关的两个IP地址
            return msgIdBuffer.insert(0, IP_HEX_PART).toString().toUpperCase();
        }
    
        /**
         * @return java.lang.String
         * @author zhoujie
         * @date 2021/5/17 下午5:25
         * @param: ip
         * @description: 把ip转为16进制字符串表示
         */
        private static String ipToHexString(String ip) {
            if (ip == null) {
                return "00000000";
            }
            String[] ipPort = ip.split("\.");
            if (ipPort.length < 4) {
                return "00000000";
            }
            StringBuilder ipBuffer = new StringBuilder(8);
            for (String s : ipPort) {
                String s1 = Integer.toHexString(Integer.parseInt(s));
                ipBuffer.append(s1.length() > 1 ? s1 : "0" + s1);
            }
            return ipBuffer.toString();
        }
    
        /**
         * @return java.lang.String
         * @author zhoujie
         * @date 2021/5/17 下午5:36
         * @param: msgId
         * @description: 反解析msg获取信息
         */
        private static Map<String, String> parseMsgIdInfo(String msgId) {
            if (msgId == null) {
                return null;
            }
            int len = msgId.length();
            if (len != 32) {
                return null;
            }
            // 项目用的json对象存储并返回json字符串对象,简化import,此处使用map,意会即可
            HashMap<String, String> msgIdInfo = new HashMap<>();
            msgIdInfo.put("msgId", msgId);
            msgIdInfo.put("host_ip", hexStrToIp(msgId.substring(0, 8)));
            msgIdInfo.put("pod_ip", hexStrToIp(msgId.substring(8, 16)));
            msgIdInfo.put("sequence", new BigInteger(msgId.substring(30), 16).toString());
            long timestamp = Long.parseLong(msgId.substring(16, 29), 16) + twepoch;
            msgIdInfo.put("dateTime", LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).toString());
            return msgIdInfo;
        }
    
        /**
         * @return java.lang.String
         * @author zhoujie
         * @date 2021/5/18 下午3:19
         * @param: hexIpStr
         * @description: 把16进制的字符串ip转为常规显示
         */
        private static String hexStrToIp(String hexIpStr) {
            int step = 2;
            StringBuilder ipBuffer = new StringBuilder(17);
            for (int i = 0; i < hexIpStr.length(); i += step) {
                String ipPart = hexIpStr.substring(i, i + step);
                ipBuffer.append(new BigInteger(ipPart, 16).toString()).append(".");
            }
            ipBuffer.setLength(ipBuffer.length() - 1);
            return ipBuffer.toString();
        }
    
        /**
         * 阻塞到下一个毫秒,直到获得新的时间戳
         *
         * @param lastTimestamp 上次生成ID的时间截
         * @return 当前时间戳
         */
        protected static long tilNextMillis(long lastTimestamp) {
            long timestamp = timeGen();
            while (timestamp <= lastTimestamp) {
                timestamp = timeGen();
            }
            return timestamp;
        }
    
        /**
         * 返回以毫秒为单位的当前时间戳
         *
         * @return 当前时间(毫秒)
         */
        protected static long timeGen() {
            return System.currentTimeMillis();
        }
    
        /**
         * 测试
         */
        public static void main(String[] args) {
            for (int i = 0; i < 10; i++) {
                System.out.println(SnowFlakeIdUtil.getMsgId());
            }
            String msgId = SnowFlakeIdUtil.getMsgId();
            Map<String, String> stringStringMap = parseMsgIdInfo(msgId);
            for (Map.Entry<String, String> entry : stringStringMap.entrySet()) {
                System.out.println(entry.getKey() + "-" + entry.getValue());
            }
        }
    }
    

    改造后的问题:

    • 原有生成是Long类型,现在是String类型,对数据库索引不友好,但当前项目用作记录多,暂不涉及搜索;
    • 原有仅生成Long并返回,效率很高,现在在原有基础上增加了一些字符串的处理,对效率略有影响;

    改造后生成的是数字字母字符串,所以UUID也能完成这个需求,但是UUID生成的是完全无意义字符,当前对SnowFlake的改造能携带有用信息,且在以后可以按需求再改造扩展。

    其他分布式ID的解决方案及优劣分析

    生成分布式ID的一些要求:

    • 全局唯一:最基本要求;
    • 高性能:低延迟,生成速度快,不能成为业务瓶颈;
    • 高可用:99.99…%可用,9越多越好;
    • 易接入:最好是插件式使用,与项目低耦合,易于后续替换/拓展;
    • ID友好:一般最好是数字型ID且趋势递增,能更好贴合业务属性,利于项目开发;

    常见分布式ID生成解决方案及优缺点:

    UUID

    优点:

    • 简单,一行代码搞定,且能保证唯一性,效率很高;

    缺点:

    • 生成的字符无序无意义;
    • 长度偏长,存储查询性能不好;

    数据库ID

    优点:

    • 实现简单,ID单调递增,生成及查询速度快;
    • 可设计为号段模式,降低压力;

    缺点:

    • 数据库本身性能将成为生成ID的性能上限;
    • 数据库本身的可用性成为生成ID的可用性;
    • 单机数据库能力有限,集群模式可一定程度提高能力,但缺点仍存在;

    Redis生成ID

    优点:

    • Redis天然的单线程能保证线程安全,生产的ID全局唯一;
    • Redis为内存数据库,效率能保证;
    • 依赖于Redis的INCR命令,能高效实现自增ID;
    • ID为数字型,数据库存储查询友好效率高;

    缺点:

    • 需要搭建维护额外的Redis生成ID系统,增加系统复杂度;
    • 增加了Redis网络请求,占用网络资源,性能比本地生成要慢;
    • Redis可能宕机,可用性也低于本地生成;

    SnowFlake雪花算法

    优点:

    • 代码少且简单,无需额外依赖,本地生成;
    • 生成效率高;
    • Long型ID,趋势递增,对项目友好;
    • 对Long的64bit分段设计,且可根据项目需求重新设计,可塑性高;

    缺点:

    • 强依赖时间的准确性,时钟回拨可能生成重复ID;

    百度uid-generator,基于SonwFlake的二次开发

    优点:

    • 使用了Atom原子类计数替换了获取时间戳,解决了时钟回拨问题(正常回拨误差在毫秒级);
    • 序列号增加1bit到13bit,共8092个序列号,算是弥补毫秒到秒级的缺失;
    • 使用RingBuffer缓存提前生成的ID,提高并发时抗压能力;

    缺点:

    • 原有时间戳被替换为秒级且使用的缓存,很早生成的ID可能很久之后才用到,其实问题不大;

    美团Leaf,基于SnowFlake的二次开发

    优点:

    • 早期Leaf的号段+双buffer模式已经解决了大部分问题;
    • 使用zk持久模式顺序生成workId,且本地缓存,启动时先从本地缓存恢复,没有时请求zk获取新的workId并缓存;
    • 解决时钟回拨问题:在判断时间落后时等待2倍的落后差时间重新获取时间戳,以等待并重新尝试的方式解决正常NTP(Network Time Protocol)时间同步时的误差问题,若时间差太多或等待后时间差仍存在则抛异常终止,此时时钟落后问题需要人工介入解决;
    • 初始序列号每次不是从0开始,而是nextInt(100),避免0的情况过多,利于DB的分库分表;

    缺点:

    • 号段模式提供的ID可能是不连续的,buffer的存在使得系统的重启后存在ID的浪费;

    滴滴TinyId

    优点:

    • 与美团的Leaf类似,采用号段的模式生成ID;
    • 提供http和rest两种接入方式;

    缺点:

    • 号段模式提供的ID可能是不连续的,buffer的存在使得系统的重启后存在ID的浪费;

    总结

    综合以上的分析学习研究,当前分布式ID还是以SnowFlake雪花算法为模板,结合项目实际需求进行设计的方案为最优,大厂都是采用了号段模式或自我改造雪花算法的方案,号段模式可以说是对ID生成本身的拆分--分布式思想;雪花算法因其本身的效率已经足够高,而其唯一缺陷是时钟回拨问题,使用前需要关注解决。

    Talk is cheap. Show me the code.
  • 相关阅读:
    在ServiceImpl层加载Spring配置文件进行测试
    MyBatis:逆向工程,实现实体类中文注释(Eclipse + MySQL)
    Linux(CentOS):开机自动启动Tomcat脚本(判断MySQL是否启动后再启)
    Linux(CentOS):设置FTP开机自动启动
    转载 PowerDesigner导出mysql数据结构
    SVN分支/主干Merge操作小记
    Quartz.NET+TopSelf 实现定时服务
    关于redis,学会这8点就够了(转)
    kafka 基础知识梳理(转载)
    Centos7 忘记密码的情况下,修改root或其他用户密码
  • 原文地址:https://www.cnblogs.com/izhoujie/p/14781048.html
Copyright © 2011-2022 走看看