雪花算法(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内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!