雪花算法重复事件复盘

引子

讲正题之前,我想先讲个故事,有天我在酒桌上敬酒,从大哥、二哥、三哥到小哥挨个儿打了一圈,无奈酒量不大,咱喝的有点上头,去厕所吐了,等我回来的时候有点懵逼,我问大哥:“去厕所前我敬到谁了”?大哥说:“你还没开始敬酒啊”。我 TM 总感觉哪里不对,但是又无法反驳,于是我又把这桌的人重新敬了一圈。。。。。。
无奈

背景

分布式系统架构下有一个必不可少的组件就是分布式 id 发号器,这个选择就比较多了,有的用 java uuid,有的用美团开源的 Leaf,有的用数据库自增序列,但是个人认为比较简单而且高效的方案就是推特开源的 snowflake (雪花算法),并且现在也有应用比较广泛的工具类 hutool 的支持,使用方法相当简单,可以参考官网说明:hutool-IdUtil

//参数1为终端ID
//参数2为数据中心ID
Snowflake snowflake = IdUtil.getSnowflake(1, 1);
long id = snowflake.nextId();

但不幸的是,当服务器时钟与 NTP 同步导致回退且回退时间小于两秒时,就可能造成 id 重复的问题。就这种小概率问题,我们线上系统碰到了两次。通过跟团队里的大神们讨论,找到了最终原因,并提出了有效的解决方案。先说下我们使用的 hutool 工具类版本,以便让各位看官自查(紧张)一下,看看自己的系统是否有这个问题:

            <dependency>
                <groupId>cn.hutool</groupId>
                <artifactId>hutool-all</artifactId>
                <version>5.4.4</version>
            </dependency>

分析过程

首先,我们排查了重复的 id 是同一台机器产生的还是不同机器产生的,如果是不同机器产生的,那这个问题就很自然的会聚焦到 workiId 和 centerId 是否在集群内重复这个点,但事实上这俩重复的 id 来自于同一台机器,且产生时间非常接近,在十几毫秒之内,当时碰到这个问题根本毫无头绪且无怀疑方向。于是,只能一头扎进源码,逐行分析。最后发现 hutool 工具类里的雪花算法其实跟推特原版雪花算法还是有点小区别的,总结如下:
1.推特原版雪花算法是不允许时钟回退的,如果时钟回退,则立即报错,而 hutool 工具类是支持两秒的时钟回退容忍时间,代码如下:

// 推特原版的雪花算法使用 Scala 语言实现的,我在逻辑不变的情况下用 Java 复刻了一版
// 出于文章篇幅的考虑,这里只贴出核心算法部分,若有需要完整版的请留言或私信我
			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) & SEQUENCE_MASK;
			
					// 毫秒内序列溢出
					if (sequence == 0) {
						// 阻塞到下一个毫秒,获得新的时间戳
						timestamp = tilNextMillis(lastTimestamp);
					}
				} else {
					sequence = 0L; // 时间戳改变,毫秒内序列重置
				}
			
				// 上次生成 ID 的时间戳
				lastTimestamp = timestamp;
			
				// 移位并通过或运算拼到一起组成 64 位的 ID
				return ((timestamp - START_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT) | (datacenterId << DATACENTER_ID_SHIFT)
						| (workerId << WORKER_ID_SHIFT) | sequence;
			}
// hutool 工具类的雪花算法
public synchronized long nextId() {
		long timestamp = genTime();
		if (timestamp < lastTimestamp) {
			if(lastTimestamp - timestamp < 2000){
				// 容忍2秒内的回拨,避免NTP校时造成的异常
				timestamp = lastTimestamp;
			} else{
				// 如果服务器时间有问题(时钟后退) 报错。
				throw new IllegalStateException(StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", lastTimestamp - timestamp));
			}
		}

		if (timestamp == lastTimestamp) {
			sequence = (sequence + 1) & sequenceMask;
			if (sequence == 0) {
				timestamp = tilNextMillis(lastTimestamp);
			}
		} else {
			sequence = 0L;
		}

		lastTimestamp = timestamp;

		return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << dataCenterIdShift) | (workerId << workerIdShift) | sequence;
	}

2.推特原版雪花算法的时间戳取的是操作系统内核态时间,但是 hutool 工具类里时钟同步有两种实现,取决于构造函数里的 isUseSystemClock 变量,如果这个变量是 true,则在应用程序启动时先获取一个操作系统内核态的时间戳当成起始时间,然后通过 Java 的定时器来维护这个计时器,而不是去取操作系统内核态的时间,那么这样一来,时间戳在系统启动后就不会回退了;如果这个变量是 false,则时间戳从操作系统内核态获取,那么问题就来了,当同步 NTP 服务器时发生时间回退,就会造成 id 重复。具体重复过程我们可以推演一遍,看完整代码:

package cn.hutool.core.lang;

import cn.hutool.core.date.SystemClock;
import cn.hutool.core.util.StrUtil;

import java.io.Serializable;
import java.util.Date;

/**
 * Twitter的Snowflake 算法<br>
 * 分布式系统中,有一些需要使用全局唯一ID的场景,有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。
 *
 * <p>
 * snowflake的结构如下(每部分用-分开):<br>
 *
 * <pre>
 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
 * </pre>
 * <p>
 * 第一位为未使用(符号位表示正数),接下来的41位为毫秒级时间(41位的长度可以使用69年)<br>
 * 然后是5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)<br>
 * 最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
 * <p>
 * 并且可以通过生成的id反推出生成时间,datacenterId和workerId
 * <p>
 * 参考:http://www.cnblogs.com/relucent/p/4955340.html
 *
 * @author Looly
 * @since 3.0.1
 */
public class Snowflake implements Serializable {
	private static final long serialVersionUID = 1L;

	private final long twepoch;
	private final long workerIdBits = 5L;
	private final long dataCenterIdBits = 5L;
	 最大支持机器节点数0~31,一共32个
	// 最大支持数据中心节点数0~31,一共32个
	@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
	private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
	@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
	private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
	// 序列号12位
	private final long sequenceBits = 12L;
	// 机器节点左移12位
	private final long workerIdShift = sequenceBits;
	// 数据中心节点左移17位
	private final long dataCenterIdShift = sequenceBits + workerIdBits;
	// 时间毫秒数左移22位
	private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
	@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
	private final long sequenceMask = -1L ^ (-1L << sequenceBits);// 4095

	private final long workerId;
	private final long dataCenterId;
	private final boolean useSystemClock;
	private long sequence = 0L;
	private long lastTimestamp = -1L;

	/**
	 * 构造
	 *
	 * @param workerId     终端ID
	 * @param dataCenterId 数据中心ID
	 */
	public Snowflake(long workerId, long dataCenterId) {
		this(workerId, dataCenterId, false);
	}

	/**
	 * 构造
	 *
	 * @param workerId         终端ID
	 * @param dataCenterId     数据中心ID
	 * @param isUseSystemClock 是否使用{@link SystemClock} 获取当前时间戳
	 */
	public Snowflake(long workerId, long dataCenterId, boolean isUseSystemClock) {
		this(null, workerId, dataCenterId, isUseSystemClock);
	}

	/**
	 * @param epochDate        初始化时间起点(null表示默认起始日期),后期修改会导致id重复,如果要修改连workerId dataCenterId,慎用
	 * @param workerId         工作机器节点id
	 * @param dataCenterId     数据中心id
	 * @param isUseSystemClock 是否使用{@link SystemClock} 获取当前时间戳
	 * @since 5.1.3
	 */
	public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock) {
		if (null != epochDate) {
			this.twepoch = epochDate.getTime();
		} else{
			// Thu, 04 Nov 2010 01:42:54 GMT
			this.twepoch = 1288834974657L;
		}
		if (workerId > maxWorkerId || workerId < 0) {
			throw new IllegalArgumentException(StrUtil.format("worker Id can't be greater than {} or less than 0", maxWorkerId));
		}
		if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
			throw new IllegalArgumentException(StrUtil.format("datacenter Id can't be greater than {} or less than 0", maxDataCenterId));
		}
		this.workerId = workerId;
		this.dataCenterId = dataCenterId;
		this.useSystemClock = isUseSystemClock;
	}

	/**
	 * 根据Snowflake的ID,获取机器id
	 *
	 * @param id snowflake算法生成的id
	 * @return 所属机器的id
	 */
	public long getWorkerId(long id) {
		return id >> workerIdShift & ~(-1L << workerIdBits);
	}

	/**
	 * 根据Snowflake的ID,获取数据中心id
	 *
	 * @param id snowflake算法生成的id
	 * @return 所属数据中心
	 */
	public long getDataCenterId(long id) {
		return id >> dataCenterIdShift & ~(-1L << dataCenterIdBits);
	}

	/**
	 * 根据Snowflake的ID,获取生成时间
	 *
	 * @param id snowflake算法生成的id
	 * @return 生成的时间
	 */
	public long getGenerateDateTime(long id) {
		return (id >> timestampLeftShift & ~(-1L << 41L)) + twepoch;
	}

	/**
	 * 下一个ID
	 *
	 * @return ID
	 */
	public synchronized long nextId() {
		long timestamp = genTime();
		if (timestamp < lastTimestamp) {
			if(lastTimestamp - timestamp < 2000){
				// 容忍2秒内的回拨,避免NTP校时造成的异常
				timestamp = lastTimestamp;
			} else{
				// 如果服务器时间有问题(时钟后退) 报错。
				throw new IllegalStateException(StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", lastTimestamp - timestamp));
			}
		}

		if (timestamp == lastTimestamp) {
			sequence = (sequence + 1) & sequenceMask;
			if (sequence == 0) {
				timestamp = tilNextMillis(lastTimestamp);
			}
		} else {
			sequence = 0L;
		}

		lastTimestamp = timestamp;

		return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << dataCenterIdShift) | (workerId << workerIdShift) | sequence;
	}

	/**
	 * 下一个ID(字符串形式)
	 *
	 * @return ID 字符串形式
	 */
	public String nextIdStr() {
		return Long.toString(nextId());
	}

	// ------------------------------------------------------------------------------------------------------------------------------------ Private method start

	/**
	 * 循环等待下一个时间
	 *
	 * @param lastTimestamp 上次记录的时间
	 * @return 下一个时间
	 */
	private long tilNextMillis(long lastTimestamp) {
		long timestamp = genTime();
		// 循环直到操作系统时间戳变化
		while (timestamp == lastTimestamp) {
			timestamp = genTime();
		}
		if (timestamp < lastTimestamp) {
			// 如果发现新的时间戳比上次记录的时间戳数值小,说明操作系统时间发生了倒退,报错
			throw new IllegalStateException(
					StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", lastTimestamp - timestamp));
		}
		return timestamp;
	}

	/**
	 * 生成时间戳
	 *
	 * @return 时间戳
	 */
	private long genTime() {
		return this.useSystemClock ? SystemClock.now() : System.currentTimeMillis();
	}
	// ------------------------------------------------------------------------------------------------------------------------------------ Private method end
}

id 重复复现过程如下,我会提示代码行,请各位看官对比上面的源码行数进行跟踪。
假设:
构造雪花算法器时使用的是系统内核时间,即 useSystemClock = false;
上次时间戳 lastTimestamp = 123;
最后 12 个 bit 代表的毫秒内自增序列 sequence = 4095;
这时候来了一个请求,调用 nextId(),在 142 行代码发生了系统时间回退1毫秒,那么这时候 timestamp = 122;

			long timestamp = genTime();// 由于时间回退一毫秒,则 timestamp = 122

进入 146 行代码,将上次时间戳赋值给了这次时间戳,即:

			// 容忍2秒内的回拨,避免NTP校时造成的异常
			timestamp = lastTimestamp;

然后进入下面这个关键的分支代码:

			if (timestamp == lastTimestamp) {
				sequence = (sequence + 1) & sequenceMask;
				if (sequence == 0) {
					timestamp = tilNextMillis(lastTimestamp);
				}
			} else {
				sequence = 0L;
			}

由于时间戳被上一次的覆盖了,所以会走 if 条件语句块,然后,进行这个序列自增运算,我这里把它单独提出来计算给各位看看:

			public class MainTester {
			    static long sequenceBits = 12L;
			    static long sequence = 4095L;
			    static long sequenceMask = -1L ^ (-1L << sequenceBits);// 4095
			    public static void main(String[] args) {
			        /**
			         * 以下这些数都是 Java 长整型 long,这玩意儿占 8 个字节,也就是 64 个 bit 位
			         * sequence       ->  0000000000000000000000000000000000000000000000000000111111111111
			         * sequenceMask   ->  0000000000000000000000000000000000000000000000000000111111111111
			         * ------------------------------------------------------------------------------------
			         * sequence + 1   ->  0000000000000000000000000000000000000000000000000001000000000000
			         * ------------------------------------------------------------------------------------
			         * (sequence + 1) & sequenceMask: 全变成 0 了
			         *                ->  0000000000000000000000000000000000000000000000000000000000000000
			         * ------------------------------------------------------------------------------------
			         */
			        sequence = (sequence + 1) & sequenceMask;
			        System.out.println(sequence == 0); // true
			    }
			}

序列 sequence 到 4095 最大值后归零了,于是继续走到了 155 行代码 if (sequence == 0) 这个分支,好的,问题的关键来了,这里是想获取下一个毫秒时间戳,然后将其赋值给 lastTimestamp,但是,就在这里出问题了,来看看 156 行代码 tilNextMillis 这个方法里面究竟干了啥:

			private long tilNextMillis(long lastTimestamp) {
				long timestamp = genTime();
				// 循环直到操作系统时间戳变化
				while (timestamp == lastTimestamp) {
					timestamp = genTime();
				}
				if (timestamp < lastTimestamp) {
					// 如果发现新的时间戳比上次记录的时间戳数值小,说明操作系统时间发生了倒退,报错
					throw new IllegalStateException(
							StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", lastTimestamp - timestamp));
				}
				return timestamp;
			}

看到没,这个方法又去获取时间戳,然后当时钟再次回退的时候,就抛错了,那么这时候,156 行以上的代码都没回滚,也就是说刚进来这个 nextId 方法的时候, lastTimestamp = 123,sequence = 4095,而这个时候因为时间回退,导致 lastTimestamp 还停留在 123,但是 sequence 却等于 0 了,当下一次在同一个毫秒时间戳(123)内再进这个 nextId 方法的时候,这个算法又会从 0 开始自增,那必然会跟之前生成的 id 重复了。
我们架构组同学向 hutool 作者提了一个 issue 说明了一下这个问题,热心的作者也及时回复了这确实是个 bug,并表示会在后期版本修复。
悬疑事件到此水落石出,接下来且听我分析解决方案。

解决方案

1.关掉这台服务器的时钟同步
这种方式适用于分布式 id 发号器独立成一个微服务单独部署在一个集群里。我们没有采取这种方式,原因很简单,改造成本比较高,但是如果有条件独立部署一个分布式 id 发号器服务的话是可以这么操作的。
2.将时钟回退状态返回给上层逻辑去处理,毕竟时钟回退不是常态,而是偶发性的,所以可以选择在上层优雅的处理。但是我们也没有这么选择,原因很简单,业务层已经写了很多代码了,不想因为这种突发情况来改业务层代码。
3.在构造 Snowflake 时传入 isUseSystemClock = true,使用程序内部控制的计时器,嗯,这个改造量是最小的,我们团队一致认为这是最好的解决方案,最后全票通过,采取了这种方案,下面把改造前和改造后的代码贴出来。

// 改造前
			Snowflake snowflake = IdUtil.getSnowflake(1, 1);
			long id = snowflake.nextId();
			// 改造后
			Snowflake snowflake = Singleton.get(Snowflake.class, 1, 1, true);
			long id = snowflake.nextId();

其实就是使用了三个参数的构造:

			/**
			 * 构造
			 *
			 * @param workerId         终端ID
			 * @param dataCenterId     数据中心ID
			 * @param isUseSystemClock 是否使用{@link SystemClock} 获取当前时间戳
			 */
			public Snowflake(long workerId, long dataCenterId, boolean isUseSystemClock) {
				this(null, workerId, dataCenterId, isUseSystemClock);
			}

当然,这个方案在非常极端的场景下还是会重复,比如:当系统重启的时候,时钟回退到了上一次停机前的时间戳,那么这个时候相当于 sequence 又要从上次的时间戳开始从 0 重新打一圈,但是这种情况应该是非常非常少见的。并且我们数据库有主键唯一索引的保障,所以可以忍受这种极端场景出现重复的可能性。其实最稳妥的做法还是不要忍受时间回退或者将回退状态交由上层统一处理,如果有条件的话也可以将序列号服务单独部署一个集群,然后关闭时间同步,虽然这些方案都很麻烦,但是稳呐!!!
各位看完觉得很爽的可以点个赞,有建议的也可以评论下帮老铁涨涨人气。
点赞

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐