分布式系列之ID生成器

背景

在分布式系统中,当数据库数据量达到一定量级后,需要进行数据拆分、分库分表操作,传统使用方式的数据库自有的自增特性产生的主键ID已不能满足拆分的需求,它只能保证在单个表中唯一,所以需要一个在分布式环境下都能使用的全局唯一ID。

应用场景

  • 用户ID、图片ID等各种业务场景
  • 分库分表情况下的订单号
  • 分布式链路追踪系统中的TraceId

需求分析:

  • 可靠性:全局唯一性,不能生成重复的ID,最基本的要求
  • 安全性:保证数据安全,防止恶意用户分析出ID生成规则,进而获取到系统业务信息
  • 递增性:下一个时间点产生的ID大于前一个时间点的ID
  • 时间有序:以时间为序,或ID里包含时间。表设计时可以不用考虑再添加一个时间字段,也方便冷热数据分离
  • 可读性:生成的ID应该有业务意义
  • 长度适中:不要太长,太长则数据表存储不便,可使用Long类型(String也行)
  • 分片支持:可以控制ShardingId。如某一个用户的文章要放在同一个分片内,这样查询效率高,修改也容易
  • 高可用:不能出现单点故障
  • 高性能:响应速度快,毫秒内生成的ID数量要满足海量用户请求
  • 扩展性:ID生成器服务集群发生节点宕机,加入新节点是否便捷
  • 可维护性:实现方案不能太复杂,方便后期维护

上面列举出11个需求点,已经足够多,当然也可以再增加。安全性和递增性之间存在一定的互斥,需要做取舍。递增性不强求绝对严格递增,即不需要满足+1递增。在做方案设计时,需要具体情况具体分析,前面几个是必须要满足。大体而言,首先确保满足前面几个需求点,即优先级更高,再考虑后面几个。能满足的需求点越多,则方案越复杂,需要加以权衡和取舍,不强求完全实现所有的需求点。

实现

实现方案有很多,不是每种方案都能完美实现上面提到的各个需求点:

  • 数据库
  • UUID
  • Snowflake
  • Redis
  • ZooKeeper
  • Snowflake-like

数据库

基于数据表auto increment规则来生成全局唯一递增ID。

优点:简单,可保证唯一性、递增性,步长固定

缺点:

  • 可用性:不高,数据库常见架构是一主多从+读写分离,生成自增ID是写请求,主库宕机,则服务不可用。
  • 扩展性:较差,性能有限,写入是单点,主库的写性能决定ID生成性能上限,且难以扩展
  • 兼容性:不同数据库语法和实现不同,数据库迁移时或多数据库版本支持时需要特殊处理

改进方法:冗余主库,避免写入单点;数据水平切分,保证各主库生成的ID不重复。

具体来说,比如可将1个写库变成N个写库,每个写库设置不同的auto increment初始值,和相同的步长,以保证每个数据库生成的ID是不同的。

改进后方案可提高可用性,但拓展性差的问题依旧存在。数据库写压力有所缓解,但写压力依旧存在;可考虑一次性从DB里取出多个ID放在Redis缓存里。

UUID

Universally Unique Identifier,标准型式包含32个16进制字符,以连字号分为五段,其形式为8-4-4-4-12,到目前为止业界一共有5种方式生成UUID,参考IETF发布的UUID规范

  • 版本1 - 根据时间和节点 ID(通常是MAC地址)生成;
  • 版本2 - 根据标识符(通常是组或用户ID)、时间和节点ID生成;
  • 版本3、版本5 - 确定性UUID,通过散列名字空间标识符和名称生成;版本5和3的区别在于使用不同的散列算法;
  • 版本4 - 使用随机性或伪随机性生成。

v1

UUID-v1是通过使用主机MAC地址和当前日期和时间的组合生成的。之外还引入另一个随机组件,以确保其唯一性。但是如果使用同一台机器、同时时间生成UUID,会有很小的几率重复。

UUID-v1存在的问题是:

  • 存在重复几率
  • 根据ID能推算出创建时的相对时间
  • 根据ID能推算出创建的机器唯一标识

v2

UUID-v2和v1很类似,是根据标识符(通常是组或用户ID)、时间和节点ID生成,区别在于v2将v1中的部分时间信息换成主机名, 存在隐私风险,未大规模使用。

v3

UUID-v3通过MD5散列算法基于命名空间标识符和名称生成UUID。和v1、v2不同,v3不依赖与机器信息和时间信息,但v3要求输入命名空间+名称,命名空间本身也是一个UUID,用来标识应用环境,名称通常是用户账号、用户名之类的内容,通过命名空间+名称+三列算法算出UUID。

UUID-v5和v3类似,区别在于使用sha1散列算法。

v4

基于随机数的算法。用SecureRandom生成16个随机的Byte,用2个long来存储。记得加-Djava.security.egd=file:/dev/./urandom

JDK里UUID.randomUUID静态方法使用加密强度高的伪随机数生成器生成v4伪随机UUID:

java 复制代码
public static UUID randomUUID() {
	SecureRandom ng = Holder.numberGenerator;
	byte[] randomBytes = new byte[16];
	ng.nextBytes(randomBytes);
	randomBytes[6]  &= 0x0f;  /* clear version        */
	randomBytes[6]  |= 0x40;  /* set to version 4     */
	randomBytes[8]  &= 0x3f;  /* clear variant        */
	randomBytes[8]  |= (byte) 0x80;  /* set to IETF variant  */
	return new UUID(randomBytes);
}

JDK提供UUID.randomUUID静态方法从字节数组生成基于名称的v3版本的UUID:

java 复制代码
public static UUID nameUUIDFromBytes(byte[] name) {
	MessageDigest md;
	try {
	    md = MessageDigest.getInstance("MD5");
	} catch (NoSuchAlgorithmException nsae) {
	    throw new InternalError("MD5 not supported", nsae);
	}
	byte[] md5Bytes = md.digest(name);
	md5Bytes[6]  &= 0x0f;  /* clear version        */
	md5Bytes[6]  |= 0x30;  /* set to version 3     */
	md5Bytes[8]  &= 0x3f;  /* clear variant        */
	md5Bytes[8]  |= (byte) 0x80;  /* set to IETF variant  */
	return new UUID(md5Bytes);
}

v1变种

Hibernate

基于hibernate-core-6.4.4.Final版本的CustomVersionOneStrategy源码如下:

java 复制代码
public class CustomVersionOneStrategy implements UUIDGenerationStrategy, UuidGenerator.ValueGenerator {
	private final long mostSignificantBits;
	
	public int getGeneratedVersion() {
		return 1;
	}
	
	public CustomVersionOneStrategy() {
		byte[] hiBits = new byte[8];
		System.arraycopy(Helper.getAddressBytes(), 0, hiBits, 0, 4);
		System.arraycopy(Helper.getJvmIdentifierBytes(), 0, hiBits, 4, 4);
		hiBits[6] = (byte)(hiBits[6] & 15);
		hiBits[6] = (byte)(hiBits[6] | 16);
		this.mostSignificantBits = BytesHelper.asLong(hiBits);
	}
	
	public UUID generateUuid(SharedSessionContractImplementor session) {
		long leastSignificantBits = generateLeastSignificantBits(System.currentTimeMillis());
		return new UUID(this.mostSignificantBits, leastSignificantBits);
	}
	
	public UUID generateUUID(SharedSessionContractImplementor session) {
		return this.generateUuid(session);
	}
	
	public long getMostSignificantBits() {
		return this.mostSignificantBits;
	}
	
	public static long generateLeastSignificantBits(long seed) {
		byte[] loBits = new byte[8];
		short hiTime = (short)((int)(seed >>> 32));
		int loTime = (int)seed;
		System.arraycopy(BytesHelper.fromShort(hiTime), 0, loBits, 0, 2);
		System.arraycopy(BytesHelper.fromInt(loTime), 0, loBits, 2, 4);
		System.arraycopy(Helper.getCountBytes(), 0, loBits, 6, 2);
		loBits[0] = (byte)(loBits[0] & 63);
		loBits[0] = (byte)(loBits[0] | 128);
		return BytesHelper.asLong(loBits);
	}
}

解读:

MongoDB

MongoDB的bson-4.11.1版本下ObjectId的源码:

java 复制代码
public final class ObjectId implements Comparable<ObjectId>, Serializable {
    private static final AtomicInteger NEXT_COUNTER = new AtomicInteger((new SecureRandom()).nextInt());

	public ObjectId() {
		this(new Date());
	}
	
	public ObjectId(Date date) {
		this(dateToTimestampSeconds(date), NEXT_COUNTER.getAndIncrement() & 16777215, false);
	}
	
	private static int dateToTimestampSeconds(Date time) {
		return (int)(time.getTime() / 1000L);
	}
}

解读:

  • 16777215:即LOW_ORDER_THREE_BYTES,一个24位(3字节)的整数,其十六进制表示为0xFFFFFF。机器标识符是一个3字节的值,而16777215是3字节整数的最大值。这意味着机器标识符的范围是0到16777215,确保可以使用一个唯一的标识符来表示每台机器。
  • SecureRandom:和JDK UUID一样使用SecureRandom来生成强随机数。

ObjectId使用12字节的存储空间:

  • 前4个字节表示时间戳,秒级别
  • 随后3个字节是机器标识码
  • 随后2个字节由进程id组成,同一台机器上可能会运行多个mongod实例,因此也需要加入进程标识符PID
  • 最后3个字节是随机数

前9个字节保证同一秒钟不同机器不同进程产生的ObjectId的唯一性。后三个字节是一个自动增加的计数器(一个mongod进程需要一个全局的计数器),保证同一秒的ObjectId是唯一的。同一秒钟最多允许每个进程拥有(256^3=16777216)个不同的ObjectId。

ObjectId用于文档的主键_id字段,_id可在服务端、客户端生成,在客户端生成可以降低服务器端的压力。

总结

缺点:

  • 不易于存储:UUID太长,以36个字符串(加上4个连字符)表示;不适合作为数据表主键,不利于建索引,UUID的无序性可能会引起数据位置频繁变动,严重影响性能
  • 没有排序:无法保证趋势递增
  • 可读性不好
  • 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置

优点:性能非常高,本地生成,没有远程调用等网络消耗,时延低。

标准的UUID算法使用场景不多,改进版如MongoDB的ObjectId,可用于生产实践中。

Snowflake

参考GitHub。Twitter在把存储系统从MySQL迁移到Cassandra的过程中,由于Cassandra没有顺序ID生成机制,于是自己开发一套全局唯一ID生成服务。

共64位二进制:

  • 第1位固定为0,没有业务含义,符号位
  • 第2~42位,共41位,为时间戳位,用于存入精确到毫秒数的时间
  • 第43~52位,共10位,为机器ID位,其中高位5bit是数据中心ID(dataCenterId),低位5bit是工作节点ID(workerId)
  • 第53~64位,共12位,代表1ms内可以产生的序列号,取值区间为[0,4095],也就是说在数据中心ID和机器ID相同的情况下,1ms最多可以生成4096个序列号

如果序列号超过最大值,则会将程序阻塞到下一毫秒,然后序列号归零,继续生成ID。要想Snowflake生成全局唯一的ID,则ID生成器必须也是全局单例。

Snowflake对ZooKeeper的依赖性:集群节点启动时,从一个ZooKeeper集群获取,保证所有节点不会有重复的机器号

代码:

java 复制代码
public class SnowflakeIdWorker {
	/**
	 * 机器id所占的位数
	 */
	private final long workerIdBits = 5L;
	/**
	 * 数据标识id所占的位数
	 */
	private final long datacenterIdBits = 5L;
	/**
	 * 工作机器ID(0~31)
	 */
	private final long workerId;
	/**
	 * 数据中心ID(0~31)
	 */
	private final long datacenterId;
	/**
	 * 序列ID位数
	 */
	private static final long sequenceBits = 12L;
	/**
	 * 毫秒内序列(0~4095)
	 */
	private long sequence = 0L;
	
	/**
	 * 上次生成ID的时间截
	 */
	private long lastTimestamp = -1L;
	/**
	 * 构造函数
	 */
	public SnowflakeIdWorker(long workerId, long datacenterId) {
		// 支持的最大机器id,结果是31 (这个移位算法可以很快地计算出几位二进制数所能表示的最大十进制数)
		long maxWorkerId = ~(-1L << workerIdBits);
		if (workerId > maxWorkerId || workerId < 0) {
			throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
		}
		// 支持的最大数据标识id,结果是31
		long maxDatacenterId = ~(-1L << datacenterIdBits);
		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;
	}
	
	/**
	 * 获得下一个ID(线程安全)
	 */
	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) {
			// 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
			long sequenceMask = ~(-1L << sequenceBits);
			sequence = (sequence + 1) & sequenceMask;
			// 毫秒内序列溢出
			if (sequence == 0) {
				// 阻塞到下一个毫秒,获得新的时间戳
				timestamp = tilNextMillis(lastTimestamp);
			}
		} else {
			// 时间戳改变,毫秒内序列重置
			sequence = 0L;
		}
		// 上次生成ID的时间截
		lastTimestamp = timestamp;
		// 移位并通过或运算拼到一起组成64位的ID
		// 开始时间截(2024-01-01)
		long startEpoch = 1704038400000L;
		// 数据标识id向左移17位(12+5)
		long datacenterIdShift = sequenceBits + workerIdBits;
		// 时间截向左移22位(5+5+12)
		long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
		return ((timestamp - startEpoch) << timestampLeftShift)
	            | (datacenterId << datacenterIdShift)
	            | (workerId << sequenceBits)
	            | sequence;
	}
	
	/**
	 * 阻塞到下一个毫秒,直到获得新的时间戳
	 *
	 * @param lastTimestamp 上次生成ID的时间截
	 * @return 当前时间戳
	 */
	protected long tilNextMillis(long lastTimestamp) {
		long timestamp = timeGen();
		while (timestamp <= lastTimestamp) {
			timestamp = timeGen();
		}
		return timestamp;
	}
	
	/**
	 * 返回以毫秒为单位的当前时间
	 */
	protected long timeGen() {
		return System.currentTimeMillis();
	}
}

如何分配datacenterId和workerId呢?

  • 写死 : 单机部署,然后写死两个值,不可取
  • 读配置文件 : 将值放在配置中心,应用启动时读取
  • 动态分配 :

存在的问题:

  • 时间戳只存在41位二进制,只能使用69年,69年后就可能产生重复ID
  • 如果机器性能足够好,每秒可以产生超过400万个ID,但是对于大部分企业来说,只需要每秒满足数万个ID即可。这种高性能浪费的主要是序号的二进制位,实际上,二进制位达到9位,就可以产生512个序号,如果机器性能足够,就可以每秒产生超过50万的ID,能满足绝大部分企业的需要
  • 从机器位来说,因为去中心化是分布式和微服务的趋势,所以在实现时,并未考虑受理机器编号,这样就会造成机器位数有10位二进制,可以表达区间[0, 1023]的整数。如果数据中心预估总共只有几十台机器,显然也会造成二进制位的浪费。

时钟回拨问题

获取到的当前Timestamp比前一个已生成ID的Timestamp还要小。做法是while循环,继续获取当前机器的时间,直到获取到更大的Timestamp才能继续工作,在这个等待过程中不能分配出新的ID。

解决方法

使用NTP(Network Time Protocol)确保系统时钟是准确的。最好把NTP配置成不会向后调整的模式。即NTP纠正时间时,不会向后回拨机器时钟。

Redis

基于Redis的原子操作INCR和INCRBY来实现,与数据库改进版类似,Redis采用集群化部署方案,每个节点设置一个不同的初始值,步长保持一致。且可以预生成一批ID,提高性能。

ZooKeeper

Snowflake改进

业界最常用的解决方案是基于Snowflake的改进版。

Boundary flake

GitHub

Flake: A decentralized, k-ordered id generation service in Erlang

几点变化:

  • ID长度扩展到128位
  • 最高64位时间戳
  • 48位的Worker ID,和Mac地址一样长,启动时无需和ZooKeeper通讯获取Worker ID,做到完全去中心化
  • 16位的Seq Number
  • 基于Erlang

目的是用更多的位实现更小的冲突概率,且能支持更多的Worker同时工作,每毫秒能分配出更多的ID。

Instagram

Instagram的分布式存储方案:

  • 先把每个Table划分为多个逻辑分片(Logic Shard,简称LS)
  • 制定规则,每个LS被存储到哪个数据库实例上,数据库实例不需要很多。例如有2个PostgreSQL实例的系统,可将奇数逻辑分片存放到第一个数据库实例,偶数放到第二个
  • 每个Table指定一个字段作为分片字段,如用户表可指定uid作为分片字段
  • 插入一个新的数据时,先根据分片字段的值,决定数据被分配到哪个逻辑分片
  • 然后再根据逻辑分片和PostgreSQL实例的对应关系,确定这条数据应该被存放到哪台PostgreSQL实例上

Instagram unique ID组成:

  • 41位: 精确到毫秒的Timestamp,和Snowflake类似
  • 13位: 每个Logic Shard的代号,最大支持 2 13 2^{13} 213个LS
  • 10位: Sequence Number,简称SN,每个Shard每毫秒最多可以生成1024个ID

SN利用PostgreSQL表的自增序列(sequence)来生成:如果当前表上已经有5000条记录,则这个表的下一个自增序列就是5001(直接调用PG提供的方法可以获取到),然后把这个5001对1024取模就得到10位的SN。

优势在于:

  • 利用LS号来替换Snowflake使用的Worker号,就不需要到中心节点获取Worker号,做到完全去中心化
  • 通过ID可直接知道这条记录被存放在哪个LS上;数据迁移时,也是按LS为单位做数据迁移

开源

Leaf

美团-点评开源的Leaf

UidGenerator

百度开源的UidGenerator

Flyway

Flyway是一款开源的数据库版本管理工具,其源码里有对Snowflake的支持:

有待进一步研究。

其他

https://github.com/zhuzhong/idleaf

参考

相关推荐
躺不平的理查德6 小时前
General Spark Operations(Spark 基础操作)
大数据·分布式·spark
talle20216 小时前
Zeppelin在spark环境导出dataframe
大数据·分布式·spark
渣渣盟6 小时前
大数据开发环境的安装,配置(Hadoop)
大数据·hadoop·分布式
Angindem7 小时前
SpringClound 微服务分布式Nacos学习笔记
分布式·学习·微服务
龙仔72515 小时前
离线安装rabbitmq全流程
分布式·rabbitmq·ruby
〆、风神18 小时前
Spring Boot 整合 Lock4j + Redisson 实现分布式锁实战
spring boot·分布式·后端
胡萝卜糊了Ohh19 小时前
kafka
分布式·kafka
桑榆080621 小时前
Spark-Streaming核心编程
大数据·分布式·spark
nbsaas-boot1 天前
分布式微服务架构,数据库连接池设计策略
分布式·微服务·架构