雪花算法(snowflak)生成有序不重复ID的Java实现代码

 更新时间:2024年11月13日 08:24:31   作者:草青工作室  
雪花算法是一种分布式系统中生成唯一ID的方法,由41位时间戳、10位机器码和12位序列号组成,具有唯一性、有序性和高效率等优点,这篇文章主要介绍了雪花算法(snowflak)生成有序不重复ID的Java实现的相关资料,需要的朋友可以参考下

一、引言

雪花算法(Snowflake Algorithm)是一种在分布式系统中生成唯一ID的方法,最初由Twitter内部使用。它生成的是一个64位的长整型(long)数字,由以下几部分组成:

  • 最高位是符号位,通常为0,因为ID通常是正数。
  • 41位用于存储毫秒级的时间戳,这部分不是存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 开始时间戳),可以支持大约69年的时间。
  • 10位用于存储机器码,可以支持最多1024台机器。如果在同一毫秒内有多个请求到达同一台机器,机器码可以用于区分不同的请求。
  • 12位用于存储序列号,用于同一毫秒内的多个请求,每台机器每毫秒可以生成最多4096(0~4095)个ID。

雪花算法的优点包括:

  • 在高并发的分布式系统中,能够保证ID的唯一性。
  • 基于时间戳,ID基本上是有序递增的。
  • 不依赖于第三方库或中间件,减少了系统复杂性。
  • 生成ID的效率非常高。

二、雪花算法图解

使用64位long类型生成的ID,以下是一个long类型二进制的分解结构,如下:

|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|0000|
|-111|1111|1111|1111|1111|1111|1111|1111|1111|1111|11--|----|----|----|----|----|
|----|----|----|----|----|----|----|----|----|----|--11|1111|1111|----|----|----|
|----|----|----|----|----|----|----|----|----|----|----|----|----|1111|1111|1111|

便于区分各段代表的意思,把各段独立在不同行中表示:

  • 第一行:表示一个long类型,初始值是0L;
    • 因为ID通常是正数,java中最高位是符号位,0表示正数1表示负数,所以此处为0。
  • 第二行:41位用于存储毫秒级的时间戳;
    • 正常的时间戳不止41位,为了用固定位数表示更长时间,需要缩短时间戳长度,这里采用的是存储时间戳的差值(当前时间戳 - 开始时间戳);
    • 41位可以表示的最大数是2^41-1=2,199,023,255,552,一年的毫秒数为:3600x1000x24x365=31,536,000,000;
    • 用2,199,023,255,552/31,536,000,000=69.73,所以41毫秒级时间戳,最长可以表示69.73年;
    • 开始时间戳设置为系统上线时间,这个ID可以连续使用69.73年,能满足大多数业务系统要求;
  • 第三行:10位用于存储机器码;
    • 可以支持编号从0~1023的1024台机器。如果在同一毫秒内有多个请求到达同一台机器,机器码可以用于区分不同的请求。
  • 第四行:12位用于存储序列号;
    • 用于同一毫秒内的多个请求,每台机器每毫秒可以生成最多4096(编号从0~4095)个ID。

三、41位毫秒级时间戳的计算

算法中支持1.5秒以内的时间回拨,这里毫秒顺序号溢出时的逻辑,也就是getTimestamp()这个方法,在参考网上写的算法时,这个方法只写了个等待,没有返回值。等待结束后没有对当前的变量赋值,导致生成的ID有重复现象。~~~逻辑问题最不好排查了-_-!!!

/** 业务系统上线的时间 2024-10-01 0:0:0,41位最多可以表示约69.7年 */
private static final long twepoch = 1727712000000L;
/**
* 生成下一个唯一的ID
*
* @return 下一个唯一的ID
* @throws RuntimeException 如果系统时钟回退,则抛出RuntimeException异常
*/
public synchronized long nextId() {
    long now = getTimestamp(); // 获取时间戳
    // 时钟回退处理:如果当前时间小于上一次ID生成的时间戳
    if (now < lastTimestamp) {
        //最多支持1.5秒以内的回拨(1500毫秒),否则抛出异常
        long offset = lastTimestamp - now;
        if(offset<=1500) {
            try {
                offset = offset<<2;//等待2两倍的时间
                Thread.sleep(offset);
                now = getTimestamp();
                //还是小,抛异常
                if (now < lastTimestamp) {
                    throw new RuntimeException(String.format("时钟回拨,无法生成ID %d milliseconds", lastTimestamp - now));
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
    // 如果是同一时间生成的,则进行毫秒内序列
    if (lastTimestamp == now) {
        //毫秒级顺序号,使用掩码4095取低12位的数,限制自增取值在1~4095之间,(掩码4095表示二进制12位均为1的值,即:1111 1111 1111)
        sequence = (sequence + 1) & 4095;
        //溢出
        if (sequence == 0) {
            //毫秒内序列溢出,等待到下一毫秒再继续
            now = getNextMillis(now);
        }
    } else {
        //置0之前,序列号在同一时间并发后自增到这里说明时间不同了,版本号所以置0
        sequence = 0;
    }
    lastTimestamp = now;
    /*
    * 长度64位,其中:
    * 1位符号位,0正数,1负数
    * 41位毫秒级时间戳,41111111111111111111111111111
    * 10位机器ID,11 1111 1111
    * 12位序列号,1111 1111 1111
    * */
    long id = ((now - twepoch) << 22) | (workerId << 12) | sequence;
    return id;
}

四、10机器码的生成

真对中间这10位机器码,有些算法中分成了2段,前5位为数据中心ID,后5位为机器码,最多只能表示31*31=961台机器。
如果用10位都标识机器码,可以最多从0~1023表示1024个机器,能够表示更多的机器,还能减少逻辑的复杂度,所以我采用了10位机器码的形式。
而且有些高并发的业务场景,在保证异地多活下部署模式下,一个机房31台机器也真心不够用。

真对机器码生成有一个思路:

  • 利用ZooKeeper数据模型中的顺序节点作为ID编码;
  • 使用Redis对ID编码;
  • 基于数据库表对ID编码;
  • 本地基于IP地址位ID编码,下面实例采用的是这个方法;
/**
 * workId使用IP生成
 * @return workId
 */
private int getWorkId() {
    try {
        String hostAddress = SystemInfo.getHostAddress();
        int[] ints = StringUtils.toCodePoints(hostAddress);
        int sums = 0;
        for (int b : ints) {
            sums = sums + b;
        }
        return (sums % 1024);
    } catch (UnknownHostException ex) {
        ex.printStackTrace();
        // 失败就随机生成
        return RandomUtils.nextInt(0, 1024);
    }
}

五、12位序列号的生成

生成12位序号用的主要是这段算法,可以代表0~4095共4096个数,也可以代表毫秒级最大4096个并发。

使用4095做为掩码,对顺序号做与操作,可以得到低12位的数值。

因为qequence上来就+1,所以如果数值为0就代表值溢出了。

溢出后就需要等待下一个毫秒,重新从0开始编号。

long now = getTimestamp(); // 获取时间戳
// 如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == now) {
    //毫秒级顺序号,使用掩码4095取低12位的数,限制自增取值在1~4095之间,(掩码4095表示二进制12位均为1的值,即:1111 1111 1111)
    sequence = (sequence + 1) & 4095;
    //溢出
    if (sequence == 0) {
        //毫秒内序列溢出,等待到下一毫秒再继续
        now = getNextMillis(now);
    }
} else {
    //置0之前,序列号在同一时间并发后自增到这里说明时间不同了,版本号所以置0
    sequence = 0;
}
lastTimestamp = now;

六、雪花算法ID最后组装

使用了按位左移操作,最终将时间戳差值、机器码、顺序号,三个值合并到一个long中。

这个算法有个好处是,可以把ID解码,得到时间、机器码和顺序号。

/*
* 长度64位,其中:
* 1位符号位,0正数,1负数
* 41位毫秒级时间戳,41111111111111111111111111111
* 10位机器ID,11 1111 1111
* 12位序列号,1111 1111 1111
* */
long id = ((now - twepoch) << 22) | (workerId << 12) | sequence;

七、雪花算法ID解码

使用了按位右移操作,将时间戳差值、机器码、顺序号,三个值从long中,拆分出来。

输出的结果是:id:7778251575992320 -> time:1854479688 req:0 wid:584 2024-10-22 11:07:59.688

/** 业务系统上线的时间 2024-10-01 0:0:0,41位最多可以表示约69.7年 */
private static final long twepoch = 1727712000000L;
/**
 * 将长整型ID解码为字符串格式
 *
 * @param id 需要解码的长整型ID
 * @return 解码后的字符串,格式为"时间戳\t序列号\t工作机ID\t中心ID"
 */
public static String idDecode(long id) {
    long sequence = id & 4095; //取低12位的数
    long workerId = (id >> 10) & 1023;//左移后取低10位的数
    long time = (id >> 22); //左移后取低41位的数
    return MessageFormat.format("time:{0,number,#}\treq:{1}\twid:{2}\t{3}"
            , time
            , sequence
            , workerId
            , getDataTime(time));
}
private static String getDataTime(long timeInterval) {
    var timestamp = twepoch+timeInterval;
    var date = new Date(timestamp);
    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    var dtStr = format.format(date);
    return dtStr;
}

八、完整的ID生成类

import org.apache.commons.lang3.RandomUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.UnknownHostException;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.atomic.AtomicLong;

public class SnowflakeIdUtil {
    private static Logger logger = LoggerFactory.getLogger(SnowflakeIdUtil.class.getName());
    /** 业务系统上线的时间 2024-10-01 0:0:0,41位最多可以表示约69.7年 */
    private static final long twepoch = 1727712000000L;
    /** 毫秒内序列 */
    private long sequence = 0L;
    /** 机器ID */
    private int workerId;
    /** 上次生成ID的时间戳 */
    private long lastTimestamp = -1L;
    private volatile static SnowflakeIdUtil instance = null;

    public void setWorkerId(int workerId) {
        if (workerId > 1023 || workerId < 0)
            throw new IllegalArgumentException("workerId must be between 0 and 1023");
        this.workerId = workerId;
    }


    /**
     * SnowflakeIdUtil 类的构造函数
     *
     * @throws IllegalArgumentException 如果传入的 workerId 或 datacenterId 不在 0 到 31 的范围内,则抛出此异常
     */
    private SnowflakeIdUtil() {
        workerId = getWorkId();
    }


    /**
     * 获取 SnowflakeIdUtil 的单例对象。
     * 此方法首先获取工作机器ID和数据中心ID,然后使用这两个ID调用另一个 getInstance 方法来获取 SnowflakeIdUtil 的单例对象。
     * @return 返回 SnowflakeIdUtil 的单例对象。
     */
    public static SnowflakeIdUtil getInstance() {
        if (instance == null) {
            synchronized (SnowflakeIdUtil.class) {
                if (instance == null) {
                    instance = new SnowflakeIdUtil();
                }
            }
        }
        return instance;
    }

    /**
     * workId使用IP生成
     * @return workId
     */
    private int getWorkId() {
        try {
            String hostAddress = SystemInfo.getHostAddress();
            int[] ints = StringUtils.toCodePoints(hostAddress);
            int sums = 0;
            for (int b : ints) {
                sums = sums + b;
            }
            return (sums % 1024);
        } catch (UnknownHostException ex) {
            ex.printStackTrace();
            // 失败就随机生成
            return RandomUtils.nextInt(0, 1024);
        }
    }

    /**
     * 生成下一个唯一的ID
     *
     * @return 下一个唯一的ID
     * @throws RuntimeException 如果系统时钟回退,则抛出RuntimeException异常
     */
    public synchronized long nextId() {
        long now = getTimestamp(); // 获取时间戳
        // 时钟回退处理:如果当前时间小于上一次ID生成的时间戳
        if (now < lastTimestamp) {
            //最多支持1.5秒以内的回拨(1500毫秒),否则抛出异常
            long offset = lastTimestamp - now;
            if(offset<=1500) {
                try {
                    offset = offset<<2;//等待2两倍的时间
                    Thread.sleep(offset);
                    now = getTimestamp();
                    //还是小,抛异常
                    if (now < lastTimestamp) {
                        throw new RuntimeException(String.format("时钟回拨,无法生成ID %d milliseconds", lastTimestamp - now));
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        // 如果是同一时间生成的,则进行毫秒内序列
        if (lastTimestamp == now) {
            //毫秒级顺序号,使用掩码4095取低12位的数,限制自增取值在1~4095之间,(掩码4095表示二进制12位均为1的值,即:1111 1111 1111)
            sequence = (sequence + 1) & 4095;
            //溢出
            if (sequence == 0) {
                //毫秒内序列溢出,等待到下一毫秒再继续
                now = getNextMillis(now);
            }
        } else {
            //置0之前,序列号在同一时间并发后自增到这里说明时间不同了,版本号所以置0
            sequence = 0;
        }
        lastTimestamp = now;
        /*
        * 长度64位,其中:
        * 1位符号位,0正数,1负数
        * 41位毫秒级时间戳,41111111111111111111111111111
        * 10位机器ID,11 1111 1111
        * 12位序列号,1111 1111 1111
        * */
        long id = ((now - twepoch) << 22) | (workerId << 12) | sequence;
        return id;
    }

    /**
     * 将长整型ID解码为字符串格式
     *
     * @param id 需要解码的长整型ID
     * @return 解码后的字符串,格式为"时间戳\t序列号\t工作机ID\t中心ID"
     */
    public static String idDecode(long id) {
        long sequence = id & 4095; //取低12位的数
        long workerId = (id >> 10) & 1023;//左移后取低10位的数
        long time = (id >> 22); //左移后取低41位的数
        return MessageFormat.format("time:{0,number,#}\treq:{1}\twid:{2}\t{3}"
                , time
                , sequence
                , workerId
                , getDataTime(time));
    }

    private static String getDataTime(long timeInterval) {
        var timestamp = twepoch+timeInterval;
        var date = new Date(timestamp);
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        var dtStr = format.format(date);
        return dtStr;
    }


    protected long getTimestamp() {
        return System.currentTimeMillis();
    }

    // 等待下一个毫秒,直到获得新的时间戳
    protected long getNextMillis(long lastTimestamp) {
        //logger.info("wait until next millis : "+lastTimestamp);
        long timestamp = getTimestamp();
        while (timestamp <= lastTimestamp) {
            timestamp = getTimestamp();
        }
        return timestamp;
    }
}

九、多线程测试用例

import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.Test;
import org.openjdk.jmh.runner.RunnerException;
import org.springframework.util.Assert;

import java.text.MessageFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class IdUtilTest {
    /**
     * 测试SnowflakeId生成器的并发性能
     *
     * @throws InterruptedException 如果线程在等待时被中断,则抛出InterruptedException异常
     */
    @Test
    public void snowflakTest() throws InterruptedException {
        var trehadCount = 30;
        var loopCount = 100000;
        var debug = true;
        var unique = new ConcurrentHashMap<Long,String>();
        var duplicates  = new TreeMap<Long,String>();
        System.out.println("线程:"+trehadCount+"\t每个线程循环次数:"+loopCount+"");
        Runnable runnable = () -> {
            var start = System.currentTimeMillis();
            for(int i = 0; i < loopCount; i++) {
                var id = SnowflakeIdUtil.getInstance().nextId();
                if(debug) {
                    if (unique.containsKey(id)) {
                        duplicates.put(id, Thread.currentThread().getName());
                    } else {
                        unique.put(id, Thread.currentThread().getName());
                    }
                }
            }
            var timecost = System.currentTimeMillis() - start;
            System.out.println(timecost+"\t"+Thread.currentThread().getName());
        };
        List<Thread> threads = new ArrayList<>();
        for(int i = 0; i < trehadCount; i++) {
            Thread thread = new Thread(runnable);
            threads.add(thread);
        }
        for(Thread thread : threads) {
            thread.start();
            thread.join();
        }
        System.out.println("---------------------------- 统计结果");
        System.out.println("计划生成个数:"+trehadCount*loopCount);
        System.out.println("不重复ID个数:"+unique.size());
        System.out.println("重复ID个数:"+duplicates.size());
        System.out.println("---------------------------- 重复ID");
        for(var id : duplicates.keySet()) {
            System.out.println(MessageFormat.format("id:{0}\t->\t| DECODE:{1}\t| thread:{2}\t{3}"
                    ,id
                    ,SnowflakeIdUtil.idDecode(id)
                    ,unique.get(id)
                    ,duplicates.get(id)));
        }
        Assert.isTrue(duplicates.size() == 0, "重复ID个数不为0");
    }
    @Test
    public void snowflakIdDecodTest(){
        for(var i=0;i<100;i++){
            var id = SnowflakeIdUtil.getInstance().nextId();
            var idDecode = SnowflakeIdUtil.idDecode(id);
            System.out.println("id:" + id+"\t->\t"+idDecode);
        }
    }
}

十、看下测试结果

30个并发生成300万个ID,耗时1356毫秒,性能优于300个UUID的生成。

线程:30	每个线程循环次数:100000
185	Thread-0
63	Thread-1
26	Thread-2
57	Thread-3
25	Thread-4
26	Thread-5
24	Thread-6
103	Thread-7
55	Thread-8
26	Thread-9
35	Thread-10
25	Thread-11
25	Thread-12
25	Thread-13
26	Thread-14
135	Thread-15
25	Thread-16
25	Thread-17
42	Thread-18
27	Thread-19
25	Thread-20
26	Thread-21
25	Thread-22
40	Thread-23
49	Thread-24
50	Thread-25
27	Thread-26
75	Thread-27
32	Thread-28
27	Thread-29
---------------------------- 统计结果
计划生成个数:3000000
不重复ID个数:3000000
重复ID个数:0
---------------------------- 重复ID

总结

在后端系统中,使用64位long类型的ID通常不会遇到问题。但是,考虑到当前大多数服务都是Web应用,与JavaScript的交互变得极为普遍。JavaScript在处理整数时存在一个重要的限制:它能够精确表示的最大整型数值为53位。当数值超出这个范围时,JavaScript会出现精度丢失的问题。

因此,在设计系统时,我们必须确保ID长度不超过53位,以便JavaScript能够直接且无误地处理这些数值。如果ID长度超过了53位,我们必须将这些数值转换为字符串格式,这样才能在JavaScript中正确处理。这种转换无疑会增加API接口的复杂度,因此在系统设计和开发时,我们需要对此进行周密的考虑。

为了在不转换的情况下将Long类型ID传递到前端,我们可以采用53位的雪花算法。这种算法将ID分为三个部分:32位的秒级时间戳、16位的自增值和5位的机器标识。这样的组合可以支持32台机器每秒生成65535个序列号,从而满足大多数系统的需求。

如果仍然需要使用63位的ID,我们可以在数据库中将ID保存为varchar(64)类型的字符串,或者在实体对象中添加一个字符串类型的ID字段。在将数据返回给前端之前,我们可以直接提供这个字符串ID值,从而避免JavaScript处理整数时的精度问题。这样的设计既保证了数据的完整性,又简化了前端处理的复杂性。

到此这篇关于雪花算法(snowflak)生成有序不重复ID的Java实现的文章就介绍到这了,更多相关Java生成有序不重复ID内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:

相关文章

  • Java SpringMVC的@RequestMapping注解使用及说明

    Java SpringMVC的@RequestMapping注解使用及说明

    这篇文章主要介绍了Java SpringMVC的@RequestMapping注解使用及说明,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-01-01
  • 面试官:java ThreadLocal真的会造成内存泄露吗

    面试官:java ThreadLocal真的会造成内存泄露吗

    ThreadLocal,java面试过程中的“钉子户”,在网上也充斥着各种有关ThreadLocal内存泄露的问题,本文换个角度,先思考ThreadLocal体系中的ThreadLocalMap为什么要设计成弱引用
    2021-08-08
  • SpringMVC统一异常处理实例代码

    SpringMVC统一异常处理实例代码

    这篇文章主要介绍了SpringMVC统一异常处理实例代码,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2019-11-11
  • 关于spring项目中无法加载resources下文件问题及解决方法

    关于spring项目中无法加载resources下文件问题及解决方法

    在学习Spring过程中,TestContext框架试图检测一个默认的XML资源位置,再resources下创建了一个com.example的文件夹,执行时,报错,本文给大家介绍spring项目中无法加载resources下文件,感兴趣的朋友跟随小编一起看看吧
    2023-10-10
  • java文件上传至ftp服务器的方法

    java文件上传至ftp服务器的方法

    这篇文章主要为大家详细介绍了java文件上传至ftp服务器的方法,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2018-01-01
  • AsyncHttpClient KeepAliveStrategy源码流程解读

    AsyncHttpClient KeepAliveStrategy源码流程解读

    这篇文章主要为大家介绍了AsyncHttpClient KeepAliveStrategy源码流程解读,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-12-12
  • java实现LRU缓存淘汰算法的方法

    java实现LRU缓存淘汰算法的方法

    LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。下面看下java实现LRU缓存淘汰算法的方法,一起看看吧
    2021-11-11
  • 详解Java设计模式之桥接模式

    详解Java设计模式之桥接模式

    桥接,顾名思义,就是用来连接两个部分,使得两个部分可以互相通讯。桥接模式将系统的抽象部分与实现部分分离解耦,使他们可以独立的变化。本文通过示例详细介绍了桥接模式的原理与使用,需要的可以参考一下
    2022-10-10
  • Mybatis映射文件之常用标签及特殊字符的处理方法

    Mybatis映射文件之常用标签及特殊字符的处理方法

    这篇文章主要介绍了Mybatis映射文件常用标签及特殊字符的处理,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2023-05-05
  • Java欧拉函数的计算代码详解

    Java欧拉函数的计算代码详解

    这篇文章主要介绍了Java实现欧拉函数的计算,从欧拉函数引伸出来在环论方面的事实和拉格朗日定理构成了欧拉定理的证明,本文通过实例代码给大家介绍的很详细,需要的朋友可以参考下
    2021-05-05

最新评论