主键策略之雪花算法详解

前言

雪花算法大家多多少少肯定都有听说过,并且大家肯定都知道它能提供一个全局的唯一ID,但是更详细的内容大家肯定也没有了解太多,下面就由我带着大家来了解了解雪花算法出现的来由和雪花算法的一些特性以及如何使用等等。

一、什么是分布式ID?

数据库主键ID大家都知道吧,每张表中主键ID是不可以重复的,在我们传统的小项目或者单机项目中我们可以通过多种方式去实现库表的主键ID。随着时代的进步,项目结构也由曾经的单一架构变成了如今的微服务集群模式,在当前的环境下曾经的主键策略已经跟不上步伐了,不能满足现在的情况,在分布式集群环境下的主键策略也就是我们说的分布式ID。

  • 单机部署:同一个服务只有一个节点(常用于小项目或者并发量不是很高的项目)
  • 集群部署:同一个服务在生产环境存在多个节点,每个节点可能部署在不同的服务器上,因此在集群模式下提供一个全局唯一的ID是一个必不可少的需求。

二、ID生成规则部分硬性要求

1.全局唯一性

不可以出现重复的ID号,全局的唯一标识,高并发情况下同一服务在同一时刻不同的节点上生成的ID数据不能重复

2.趋势递增

生成的Id要有顺序可言,不可以是无规则,无顺序数据,因为生成的Id数据会作为数据库的主键内容,以MySQL数据库为例,MySQL数据库的默认存储引擎中主键采用B+树的结构,B+树的非叶子节和叶子节点会对主键进行从小到大的排序,以便于进行数据快速查询。因此如果采用无序的主键的话那么对库表的更新、插入操作而言会大大的影响数据库的性能。

3.单调递增

保证下一个ID一定大于上一个ID,比如事务版本号、IM增量消息、排序等特殊需求

4.信息安全

防止别人根据规则计算出后边的ID,然后恶意的进行数据攻击

5.包含时间戳

便于在开发中快速了解整个分布式Id的生成时间

三、ID生成系统的可用性要求

高可用

系统发送生成Id请求时,服务器要保证99.999%的情况下生成一个分布式ID

低延迟

可以极快的生成一个分布式ID,不能发送请求后3秒之后才生成一个

高QPS

能同时创建多个ID,因为高并发场景下请求很多,必须满足所有的请求都可以生成一个分布式ID

四、分布式ID通用方案

方式一:采用UUID

可以保证全局唯一性,但是生成的ID是无序的且ID长度过长,生产环境禁止使用

方式二:采用数据库自增

数据库的自增ID机制主要原理:数据库自增ID和MySQL数据库的replace into实现的 replace into的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。 因此在单机版适用,但是在分布式集群环境下不适用 。

方式三:采用Redis集群

因为Redis是单线程可以满足原子性,所以可以通过Redis的INCR和INCRBY命令来实现分布式ID,但是如果仅仅是为了获取一个分布式ID就采用Redis集群方案,明显有些大材小用了,因此生产环境中也不是太实用

方式四:采用SnowFlake算法(雪花算法)

SnowFlake算法是Twitter公司推出的专门针对分布式ID的解决方案,可以完美解决这个问题

五、雪花算法特点

  • 产生的ID根据时间有序生产
  • 算法生成的ID的结果是一个64bit大小的整数,转换成十进制则为Long型(最多19为数字)
  • 分布是系统内不会产生ID碰撞(由datacenter和workerId作区分)并且生成效率很高

六、雪花算法结构

一、构成图

在这里插入图片描述
从上图可以看出来雪花算法是由符号位+时间戳+工作进程位+序列号位构成,每部分分别占用不同的位数,我们都知道Java语法中Long类型是占用8个字节,一个字节码在计算机中占8bit位,因此Long类型在计算集中是占64bit位,因此雪花算法生成的ID正好可以用Java中的Long类型去进行存储。

二、各部分解析
符号位

这个应该不用多解释,在计算机语言中第一位一般都是符号位,其中0代表正数,1代表负数。

时间戳部分

由于雪花算法是根据时间戳进行生成,因此雪花算法也会有时间戳的弊端。其最大值也就是111…1(41个1)转换成十进制即为2199023255551,再将这个时间戳转换成年月日会发现时间正好是2039-09-07 23:47:35,故此我们可以得出结论,雪花算法最多可以用到2039年,减去时间戳的初始年份会得到69,故此才会有雪花算法只能使用69年的说法,验证代码如下所示:

	SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    System.out.println(sdf.format(new Date(2199023255551L)));  // 2039-09-07 23:47:35
    System.out.println(2039-1970);  // 雪花算法可以使用时间   69年 
10bit工作位

10bit工作位是用来记录机器的id(包括5位datacenterId和5位workId),雪花算法是如何保证不重复的呢,除了时间戳相关以外其实跟这10数字有很大的关联,这10位数字最大值也就是全为1因此转换成十进制则为2^10 -1 =1023(下标从0开始),代表雪花算法在生产环境最多可同时支持1024个节点生成ID,但现实中往往不会有这么多的节点。其中5位的datacenterId和workId的范围是:0到2^5 -1 = 31,生产环境中一般都是通过设置不同的datacenterId和workId来确保生成的ID不会重复

12bit工作位

用来记录同毫秒内产生的不同id,也就是同一机器同一毫秒内可产生的最大ID数量,也即为2^12 = 4096个ID

七、雪花算法在工作中的使用方式

方式一:偷懒,使用hutool工具包
1、引入jar包
    	<dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-captcha</artifactId>
            <version>5.6.4</version>
        </dependency>
2、直接使用工具包封装好的方法,或者自己重新封装一遍
	// 传入机器id和数据中心id,数据范围为:0~31
    Snowflake snowflake = IdUtil.createSnowflake(1, 1);
    System.out.println(snowflake.nextId());  

如上即可用雪花算法获取到一个Long类型的分布式ID

方式二:下载雪花算法源码类,然后自行封装方法使用

这里提供一个之前项目中的类,提供大家参考:
雪花算法源码类

package com.demo;

/**
 ** 问题场景:分布式场景下各节点时钟不可能完全同步
 ** Twitter_Snowflake
 ** SnowFlake的结构如下(每部分用-分开):
 ** 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
 ** 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0
 ** 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)得到的值,这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69
 ** 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId
 ** 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号
 ** 加起来刚好64位,为一个Long型
 ** SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高
 */
public class SnowflakeIdWorker {

	// 开始时间截 (2018-12-03)
	private final long twepoch = 1543766400000L;

	// 机器id所占的位数
	private final long workerIdBits = 5L;

	// 数据标识id所占的位数
	private final long datacenterIdBits = 5L;

	// 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
	private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

	// 支持的最大数据标识id,结果是31
	private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

	// 序列在id中占的位数
	private final long sequenceBits = 12L;

	// 机器ID向左移12位
	private final long workerIdShift = sequenceBits;

	// 数据标识id向左移17位(12+5)
	private final long datacenterIdShift = sequenceBits + workerIdBits;

	// 时间截向左移22位(5+5+12)
	private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

	// 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
	private final long sequenceMask = -1L ^ (-1L << sequenceBits);

	// 工作机器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;
	}

	public SnowflakeIdWorker() {}

	/**
	 * * 获得下一个ID (该方法是线程安全的)
	 * @return SnowflakeId
	 */
	public synchronized long nextId() {
		long timestamp = timeGen();

		// 如果当前时间小于上一次生成ID的时间戳,说明系统时钟回退过这个时候应当抛出异常
		if (timestamp < lastTimestamp) {
			throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
		}

		// 如果是同一时间生成的,则进行毫秒内序列
		if (lastTimestamp == timestamp) {
			sequence = (sequence + 1) & sequenceMask;
			// 毫秒内序列溢出
			if (sequence == 0) {
				// 阻塞到下一个毫秒,获得新的时间戳
				timestamp = tilNextMillis(lastTimestamp);
			}
		}
		// 时间戳改变,毫秒内序列重置
		else {
			sequence = 0L;
		}

		// 上次生成的时间截
		lastTimestamp = timestamp;

		// 移位并通过或运算拼到一起组成64位的ID
		return ((timestamp - twepoch) << timestampLeftShift)
				| (datacenterId << datacenterIdShift)
				| (workerId << workerIdShift)
				| sequence;
	}

	/**
	 * * 阻塞到下一个毫秒,直到获得新的时间戳
	 * @param lastTimestamp 上次生成ID的时间截
	 * @return 当前时间戳
	 */
	private long tilNextMillis(long lastTimestamp) {
		long timestamp = timeGen();
		while (timestamp <= lastTimestamp) {
			timestamp = timeGen();
		}
		return timestamp;
	}

	/**
	 * * 返回以毫秒为单位的当前时间
	 * @return 当前时间(毫秒)
	 */
	private long timeGen() {
		return System.currentTimeMillis();
	}
}

雪花算法工具类:

package com.demo;

//import com.xyebank.hzx.core.util.PropertiesUtil;

import java.util.concurrent.locks.ReentrantLock;

public class IdUtil {
	
	private static volatile SnowflakeIdWorker snowflakeIdWorker;
	
	private static final String PRIMARYKEY_WORKERID = "primarykey.workerid";
	
	private static final ReentrantLock LOCK = new ReentrantLock();
	
	private static SnowflakeIdWorker getSnowflakeIdWorker() {
		if(snowflakeIdWorker == null) {
		    LOCK.lock();
			try {
				if(snowflakeIdWorker == null) {
					// 此处是把机器ID存放到配置文件中进行读取
					// 数据中心ID则是通过Zookeeper去进行管理获取
//					snowflakeIdWorker = new SnowflakeIdWorker(Long.valueOf(PropertiesUtil.getProperty(PRIMARYKEY_WORKERID)),DataCenterConfig.getDataCenterID());
					snowflakeIdWorker = new SnowflakeIdWorker(1L,2L);
				}
			} finally {
			    LOCK.unlock();
			}
		}
		return snowflakeIdWorker;
	}
	
	public static long getPrimaryKey() {
		return getSnowflakeIdWorker().nextId();
	}
}

使用方法:

    System.out.println(IdUtil.getPrimaryKey());

八、雪花算法的优缺点

优点
  • 毫秒数在高位.自增序列在低位.整个ID都是趋势递增的.
  • 不依赖数据库等第二方系统,以服务的方式部署,稳定性更高.生成ID的性能也是非常高的。
  • 可以根据自身业务特性分配bit位,非常灵活。
缺点
  • 依赖机器时钟.如果机器时钟回拨,会导致重复ID生成
  • 在单机上是递增的但由于设计到分布式环境.每台机器上的时钟不可能完个同步。有时候会出现不是全局递增的情况(此缺点可以认为无所谓,一般分布式旧只要求趋势递增,并不会严格要求递增,90 %的击求都只要求趋势递增)

九、小结

雪花算法是目前解决分布式唯一ID的一种很好的解决方案,也是目前市面上使用较多的一种方案,大家可以去深入学习并了解这个算法,本文主要是对雪花算法的结构以及使用方式进行了讲解,至于雪花算法本身为何如此写和设计,本人功力不够,没有到达那一层。告辞!

  • 4
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值