首选我们需要明确的结论:雪花算法生成分布式ID是互联网公司常用的算法,所以可以大胆放心的使用。但是雪花算法也会存在问题,比如时间回拨会造成生成重复的ID,但是这种情况毕竟是小概率事件,不必特殊考虑,或者如果真的发生了,我们要有对应的解决方案。还是那句话,没有完美的解决方案,我们不能因噎废食。
1、首先我们来了解一下雪花算法生成的分布式id结构,了解结构之后我们对于代码才能有更深的理解。
雪花算法生成分布式ID组成图
1.1 1bit:最高位关于整个数字是正还是负。 1 -代表数字位负数 0-代表正数。
1.2 41bit: 存储毫秒级时间戳(现在时间戳-1970年得出得时间戳),2^41/(1000*60*60*24*365)=69,大概可以使用 69 年。那么从1970开始算的话,那么大约可以使用到2039年左右,之后可能会出现重复的情况。
1.3 10bit: 存储机器码,包括 5 位 datacenterId(数量没有那么大的话可以直接根据业务来区分) 和 5位workerId。最多可以部署 2^10=1024 台机器。
1.4 12bit: 存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。
2、那么通过上面结构的介绍,我们可以用代码实现,主要看getNextId方法,一定要先看上面的思路,搞懂整个分布式ID串的结构,然后再看代码,这一样更容易理解。
public class SnowFlakeUtil {
// 初始时间戳(纪年),可用雪花算法服务上线时间戳的值
// 1650789964886:2022-04-24 16:45:59
private static final long INIT_EPOCH = 1650789964886L;
// 记录最后使用的毫秒时间戳,主要用于判断是否同一毫秒,以及用于服务器时钟回拨判断
private long lastTimeMillis = -1L;
// 最后12位,代表每毫秒内可产生最大序列号,即 2^12 - 1 = 4095
private static final long SEQUENCE_BITS = 12L;
// 掩码(最低12位为1,高位都为0),主要用于与自增后的序列号进行位与,如果值为0,则代表自增后的序列号超过了4095
// 0000000000000000000000000000000000000000000000000000111111111111
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
// 同一毫秒内的最新序号,最大值可为 2^12 - 1 = 4095
private long sequence;
/**
* 通过该方法生成分布式ID
* 整个算法的流程:
* 1.先获取当前时间戳到毫秒。
* 2.当前时间戳-INIT_EPOCH时间(防止41位空间浪费)
* 3.查看当前日期是否同一毫秒。是统一毫秒直接相加。不是同一毫秒的话直接新增。
* 4.如果当前时间戳内生成的ID超过了4095,那么可以循环阻塞等待下一1毫秒在生成。
* 5.如果服务器时间出现问题,回退了,这抛出异常。(时间回拨的问题)
* @param dataCenterId 机器中心ID
* @param workerId 服务器ID
* @return
*/
public synchronized long getNextId(long dataCenterId, long workerId) {
// 获取当前毫秒
long currentTimeMillis = System.currentTimeMillis();
// 5.如果服务器时间出现问题,回退了,这抛出异常。(时间回拨的问题)
if (currentTimeMillis < lastTimeMillis) {
throw new RuntimeException(
String.format("可能出现服务器时钟回拨问题,请检查服务器时间。当前服务器时间戳:%d,上一次使用时间戳:%d", currentTimeMillis,
lastTimeMillis));
}
if (currentTimeMillis == lastTimeMillis) {
//4095+1 和 SEQUENCE_MASK & 得出的结果是0。
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
//4.如果当前时间戳内生成的ID超过了4095,那么可以循环阻塞等待下一1毫秒在生成。
currentTimeMillis = getNextMillis(lastTimeMillis);
}
} else { // 不在同一毫秒内,则序列号重新从0开始,序列号最大值为4095
sequence = 0;
}
// 记录最后一次使用的毫秒时间戳
lastTimeMillis = currentTimeMillis;
// 时间戳部分,currentTimeMillis - INIT_EPOCH 和 因为什么要左移,具体原因可以看我的上一篇Redis生成ID中有详细的解释
return ((currentTimeMillis - INIT_EPOCH) << 22)
// 数据中心部分
| (dataCenterId << 17)
// 机器表示部分
| (workerId << 12)
// 序列号部分
| sequence;
}
/**
* 获取指定时间戳的接下来的时间戳,也可以说是下一毫秒
* @param lastTimeMillis 指定毫秒时间戳
* @return 时间戳
*/
private long getNextMillis(long lastTimeMillis) {
long currentTimeMillis = System.currentTimeMillis();
while (currentTimeMillis <= lastTimeMillis) {
currentTimeMillis = System.currentTimeMillis();
}
return currentTimeMillis;
}
}
3、雪花算法的优缺点
优点:不依赖第三方库或者中间件,直接java代码生成;每秒可以生成百万个不重复的ID(一般数据量没有那么大);可以保证生成的ID自增;在内存中生成,效率高。
缺点: 依赖服务器时间,服务器时钟回拨时可能会生成重复 id。
4、雪花算法在实际项目中真正的用法
4.1 可以把雪花算法部署在和服务相同的机器上,这样对应的传入参数dataCenterId(我们可以根据业务来区分)和workerId(对应的每个机器编码)。
4.2 可以把雪花算法单独部署微服务和真正的服务器分开,如下图。
但是这里会出现问题:
4.3.1 不同的业务使用相同的序列号,这样就会造成分布式ID重复,并且还可能超过每毫秒4095的极限。
举例如下: 订单 001 支付服务 002 ,最终会造成 001 01, 002 02,00103,000204。这样的序列。这样会造成每个业务的id不连续。随着业务的逐渐增加,可能会造成每毫秒4095的极限。
解决方案:我们可以给代码中增加每个业务类型 lastTimeMillis。增加map结构key:业务类型 value:lastTimeMillis来解决。
4.3.2 4.2将雪花算法单独增加服务器,这样我们的确实实现了高可用,高并发。但是还是有问题,我们每个服务中的dataCenterId和workerId必须自己单独设置和真正的服务无关。不然还可能出现重复的情况。针对这种情况,1)我们可以将每台的hostname和ip地址作为dataCenterId和workerId。2)引入第三方,比如增加zookeeper来启动的时候自动分配dataCenterId和workerId。
5、时间回拨的问题
在实际生产中,服务器事件回拨的问题属于小概率事件。一般情况下我们不需要考虑这样的问题。所以到这时候,你就可以结束了。代码写完直接部署运行就行,不用考虑那么多。
但是有的时候:官方的话,为了系统的稳定性,我们也需要设置对应得解决方案。这样才能保证系统得稳定性,私下里,其实是有些人专牛角尖,或者面试的时候,面试官非要你说出个456来。那么记得了解一下这样的解决方案。
5.1 那么如果时间非常短。比如回拨时间在<5毫秒,我们可以利用代码中得方案。直接循环阻塞直到经过5毫秒。然后继续生成。
5.2 如果大于1秒小于10毫秒了,那么我们可以把最最近的10毫秒,存储在内存的map存最近10毫秒的最大值,然后这个上面直接增加。
5.3 如果直接大约10毫秒了,或者很大,那怎么办,按照我的意思是直接抛出异常,直接报警发送邮件,然后运维手动处理服务器时间,然后重新启动服务就行了。
5.4 如果服务器不是高可用的咋办,一停的话直接真个服务都挂了,咋办,那么我们可以直接保存这样的数据到数据库或者mq,然后通过调用批量或者mq直接处理。