无论一个业务实体的数据被分散到多少个数据库中,每条数据的唯一 ID都 是全局的,这个全局唯一 ID就是分布式唯一 ID。
占用8字节(64位)的long类型整数适合用作唯一 ID,因为:一是long类型虽然占 用的空间较小,但是可表示的ID范围却非常大;二是long类型整数很容易实现递增的效 果。至此,本章的议题已经明确:设计一个可以生成递增的long类型唯一 ID的生成器。
String uuid = UUID.randomUUID().toString();
UUID的标准格式由32个十六进制数字组成,并通过连字符“ ”分隔成“8.4 4.4.12” 共 36 个字符的形式。例如,6a0d3e6f-allc-4b7d-bb35-c4c530a456b0. 123e4567-e89b-12d3- a456-426655440000这种唯一 ID的生成方式足够简单,利用本地计算即可生成全球唯一 ID。
缺点:
UUID字符串需要占用36字节的存储空间,如果每条数据都携带UUID,那么 在海量数据场景下存储空间消耗较大。
此外,UUID是无数据规律的长字符串,如果将其用作数据库主键,则会导致数据在磁盘中的位置频繁变动,严重影响数据库的写操作性能。
UUID仅适合数据量不大的场景,比如一个存储集群使用UUID标识每个数据分区。 真正可用于海量数据场景的唯一 ID生成器,除保证ID不可重复外,还应该具有如下特点。
Redis提供的INCRBY命令可以为键(Key)的数字值加上指定的增量(increment)。 如果键不存在,则其数字值被初始化为0,然后执行增量操作。使用INCRBY命令限制的 值类型为64位有符号整数,此命令的特性与单调递增的唯一 ID的诉求非常契合。
INCRBY命令实现的唯一 ID生成器性能表现很好,不过还有优化的空间。唯一 ID生成器 服务可以每次从Redis中批量获取ID并存储到本地内存中,当业务服务请求到来时,直 接从本地内存返回最小可用的ID。如果本地内存中没有可用的ID,则再次从Redis中批 量获取。
无论是否采用批量获取ID的思路,单调递增的唯一 ID生成器都始终无法支持高并发 访问,这是单调递增的唯一 ID不被广泛使用的最主要原因。如果不批量获取ID,则意味 着每个业务请求都会写数据库,数据库难以承受高并发写入操作,性能表现不佳,甚至可 能会被击垮;而如果批量获取ID,虽然数据库访问量级降低 了,但是ID生成器服务 只能有一个工作实例,单实例所能承载的并发量级非常有限,服务失去了可扩展性。相比 之下,唯一 ID生成器能被广泛接受的实现方式是生成趋势递增的唯一 ID。
时间戳是指计算机维护的从1970年1月1日开始到当前时间经过的秒数,并且随着 时间的流逝而逐步递增。几乎所有的编程语言都仅需要一行代码,就可以轻而易举地得到 当前时间戳,并支持毫秒精度,甚至是纳秒精度。时间戳自增的属性非常适合生成趋势递 增的唯一 IDO。
在高并发场景下,同一时间有很多业务请求到达唯一 ID生成器,如果用时间戳表示唯一 ID,就会生成重复的ID。
假设我们 设计的唯一 ID生成器在2023年1月1日上线,那么根本不需要关心在上线时间之前时间 戳的数值。所以,我们可以将时间戳的起始时间改为上线时间,仅记录唯一 ID生成器从 上线时间到当前时间经过的秒数就行。对于毫秒精度也是同样的道理。
假设我们期望唯一 ID生成器可以正常提供服务50年(已经很久了),总计 1,576,800,000s,那么**实际上使用31位整数即可表示时间戳。即使采用毫秒精度的时间戳, 41位整数也已经足够使用。**所以,我们只记录唯一 ID生成器从上线时间到当前时间的相对时间戳即可。
另外,需要强调的是,一个整数的大小优先由数字的高位决定。所以,时间戳应该被 设置到唯一 ID的高位,这样才能保证随着时间的推移唯一 ID趋势递增。
Snowflake算法的原理是将分布式环境下的各变量按数位组合成64位的long类型数字生成唯一 ID。
最终效果是,Snowflake算法给出的唯一 ID生成器是一个支持多机房共1024个服务 实例规模、单个服务实例每秒可生成410万个long类型唯一 ID的分布式系统,且此系统可以正常工作69年。
Snowflake算法是想告诉我们:需要将所考虑的高并发与分布式环境下的变量都体现 在唯一 ID上,不是必须使用41位表示毫秒级时间戳、5位表示机房、5位表示服务实例、 12位表示同一毫秒内的并发请求,而是应该按照实际的业务情况进行灵活调整。
假设某互联网公司将唯一 ID生成器立为项目,该公司目前的条件如下。
◎采用3个机房(北京、上海、深圳)的多活架构,均匀承接用户请求。
◎按照当前的日活跃用户数量做乐观估计,将来每秒有近10亿次获取唯一 ID的需求。
◎当前的硬件和服务器框架可支持单个服务实例每秒最多处理100万个请求。
◎期望唯一 ID生成器可以工作30年。
根据如上条件,采用Snowflake算法设计的唯一 ID按位从高到低分段如下。
◎ 1位:依然是符号位,固定值为0,以保证生成正整数。
◎ 40位:系统运行的总毫秒数,30年约为9461亿毫秒,可以用40位二进制数表示。
◎ 2位:用于区分3个机房,并满足将来增加一个新机房的需求。
◎接下来的10位:单个服务实例每秒处理100万个请求,即每毫秒处理1000个请 求。使用10位二进制数来区分同一毫秒内的并发请求。
◎最后的11位:可全部用于区分单个机房内唯一 ID生成器服务的不同实例,即可 支持部署最多2048个服务实例。
最终的唯一 ID生成器服务在全部机房每秒可承接的用户请求量为3 * 2048 * 100万 约等于 61亿个,远超公司预期的请求量,并可实际运行34年有余。
Snowflake算法要求人工指定系统初始时间、机房ID和服务实例ID。系统初始时间, 可以设置为唯一 ID生成器服务上线的时间;机房ID,很容易人为指定,如指定北京机房 ID为1,深圳机房ID为2,上海机房ID为3;服务实例ID,由于唯一 ID生成器服务本 身涉及功能迭代、扩容、缩容,服务实例的集合相对动态,所以直接人工指定服务实例ID 不太现实,我们需要找到一种自动化的方式,为目前在线的唯一 ID生成器服务的不同实 例分配服务实例ID。
为了保证生成的ID的唯一性,应该为ID生成器服务的不同实例分配不同的服务实例 ID ( worker ID )。
数据库自增主键方案实现
设计数据表worker_id,其中ip_address字段用于保存服务实例的IP地址。
当一个ID生成器服务实例启动时,将携带本地IP地址查询worker_id表,如果找到 对应的数据,则使用该数据行的主键作为其worker ID;否则,服务实例向worker_id表中 插入IP地址,并将插入数据后得到的主键作为worker ID。如果某服务实例获得的worker ID数值超过所允许的范围,则启动失败。
etcd p177
计算机时间有误差
时间戳的数据通过计算机的时钟获得,而计算机的时钟用专门的硬件来模拟:计算机 主板上有一个石英晶体振荡器和一个纽扣电池,其中石英晶体振荡器的频率为32,768Hzo 在通电状态下,石英晶体振荡器每振动32,768次,电路就会传出一次信息,表示Is到了, 计算机就是通过这种方式来记录时间的。不过,用石英晶体模拟时钟会有误差,在正常情 况下,每天的计时误差在土 Is内,而在极端条件下(如低温)误差会变大。这种现象被称 为“时钟漂移”。
由于时钟漂移的存在,一组工作的计算机之间可能会存在时间戳误差。
1985年,David L. Mills设计了网络时间协议(Network Time Protocol, NTP )来同步计算机之间的时钟, 它可以将所有计算机之间的时钟误差调整到几毫秒以内,一个局域网内的计算机时钟误差 甚至可以在1ms内。NTP有效地解决了时钟漂移的问题。
但是NTP时钟同步又带来了另一个问题:时钟回拨。如果某计算机的时钟时间远快 于NTP标准时间,那么经过NTP时间校准后,此计算机的时钟时间需要回退到NTP标准 时间,即发生了时钟回拨,这个问题会导致基于Snowflake算法的唯一 ID生成器服务生成重复的ID。例如:唯一 ID生成器服务的某个实例的时间为2023年1月1日 11时05分05秒,经过NTP时间校准后,此服务实例的时间变为2023年1月1日11时 05分00秒,此时此服务实例生成的唯一 ID与5s前生成的ID产生了重复。
当唯一 ID生成器服务的某个实例收到请求时,首先计算出此时的毫秒时间戳millis, 然后与上一次请求的毫秒时间戳svr.millisPassed进行比较,如果 millis小于 svr.millisPasseds,则说明发生了时钟回拨。当发生时钟回拨时,可以采取如下措施防止生 成重复的ID。
另外,还有一种建议的做法是对唯一 ID生成器服务的全部实例直接关闭NTP时钟同 步功能,防止发生时钟回拨。关闭NTP时钟同步功能,虽然可能会导致一些服务实例发 生大幅度的时钟漂移,但是我们可以选择从服务集群中摘除这些服务实例。
基于Snowflake算法的唯一 ID生成器服务的最终架构如下所述:
数据库一般都支持设置自增主键的初始值和自增步长
就可以比如三个表初始值为123,每个自增步长为4;
每个服务实例都从数据库中批量获取ID并缓存到本地;同时,为了保证 数据库主从切换不会生成重复的ID,数据库主从节点采用半同步复制或MGR方式同步最 新数据。
Leaf根据不同业务的需求分别实现了 Leaf-segment和Leaf-snowflake两种方案,前者基于数据库的自增主键,后者基于Snowflake算法。
Leaf-segment方案与批量缓存架构方案类似,只不过它没有依赖数据 库的自增主键,而是在数据库中为每个业务场景都记录目前可用的唯一 ID号段。
服务实例在本地缓存一批可用的唯一 ID号段供业务请求使用,当某业务请求发现唯一 ID号段用完时,再从数据库中批量获取新的唯一 ID号段。
具体的数据表设计如下所示。
不同业务方的唯一 ID需求用biz_tag字段区分,每个biz_tag的ID相互隔离。当某业 务请求携带biz_tag访问Leaf服务时,数据库会通过执行如下语句生成唯一 ID:
BEGIN
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx;
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx;
COMMIT
比如在数据表中外卖业务方的biz tag为waimai_ordertag,此时max_id为10000, step 为2000,那么外卖业务方下次得到的唯一 ID号段是10001〜12000, max_id的值被更新 为12000。通过修改step字段值,可以方便地控制一个业务访问数据库的频率:如果step 为1,则说明每次生成唯一 ID时业务方都要访问数据库;如果step为1000,则说明每用完1000个唯一 ID时,业务方才再次访问数据库。
服务实例在本地缓存一批可用的唯一 ID号段供业务请求使用,当某业务请求发 现唯一 ID号段用完时,再从数据库中批量获取新的唯一 ID号段。如果此时数据库发生网 络抖动或慢查询,则会导致访问数据库的业务请求被阻塞,整个服务的响应变慢。
Leaf-segment方案针对这个问题做了优化:当使用可用的唯一 ID号段到达某个检查 点时,Leaf服务实例就异步地从数据库中获取下一个可用的唯一 ID号段,而不需要等到 唯一 ID号段用完才访问数据库,这样可以防止唯一 ID号段用完时阻塞业务请求。
具体来说,Leaf服务实例内部有两个唯一 ID号段缓存区,其中第一个缓存区用于对 外提供服务,业务请求从这里获取唯一 ID;第二个缓存区用于提前向数据库加载下一个 可用的唯一 id号段。当第一个缓存区已经下发10%可用的唯一 ID时,Leaf服务实例将启 动一个线程异步访问数据库,并将获取到的下一个可用的唯一 ID号段保存到第二个缓存 区。这样一来,当某业务请求发现第一个缓存区中已无可用的唯一 ID时,Leaf服务实例 就直接切换到第二个缓存区继续下发可用的唯一 ID,如此循环往复,业务请求不会被阻 塞在访问数据库的过程中。
使用Leaf-segment方案可以生成趋势递增的唯一 ID,但是ID值会反映实际的数据量, 并不适用于订单ID生成的场景。如果将此方案应用在订单ID生成的场景中,则很容易被 竞品公司计算出订单的总量,这等于把业务的数据表现直接实时暴露给其他公司。为了解 决这个问题,美团点评公司提供了 Leaf-snowflake方案,这个方案和4.3节介绍的基于时 间戳的方案类似。
Leaf-snowflake方案在唯一 ID的设计上完全沿用Snowflake算法,即使用“1+41+10+ 12”的方式组装ID;至于worker ID的分配问题,Leaf snowflake方案借助了 ZooKeeper 持久顺序节点的特性,每个Leaf服务实例都会在ZooKeeper的leaf_forever节点下注册一 个持久顺序节点,将对应的顺序数字作为worker ID。
每个服务实例都携带IP地址和端口号在leatforever节点下注册持久顺序节点(格式 为“IP:port”),然后ZooKeeper会自动生成一个自增序号作为每个顺序节点的后缀,这个序号就可被分配作为实例的worker ID。
具体过程:
(1 ) Leaf服务实例启动时,连接ZooKeeper。
(2)服务实例查询leaf^forever节点是否存在。如果不存在,则跳至第4步,否则继续。
(3 )服务实例读取leaf^forever节点下的子节点列表,然后根据自身的IP地址和端口号遍历子节点列表,查询自己是否注册过子节点。
(4) 如果未找到子节点,则实例在leaf_forever节点下创建子节点,将所得到的节点 后缀序号作为worker IDO
(5) 如果找到子节点,则将此子节点的后缀序号取出作为worker ID。
(6) 获取到worker ID后,Leaf服务实例就启动成功了;否则,启动失败。
Leaf服务实例在获取到worker ID后会将其保存到本地文件中,这样可以做到对 ZooKeeper的弱依赖。将来,如果ZooKeeper出现故障,而此时Leaf服务实例恰好重启, 那么就可以从本地文件中得到worker ID,避免了无法正常启动的问题。
Leaf-snowflake方案通过检查服务实例上报的自身系统时间和其他Leaf服务实例的平均时间来解决时钟回拨问题,按照美团点评公司技术博客中的说法,这个策略有效地避免了时钟回拨对业务造成的影响。另外,此方案也建议关闭NTP时钟同步功能。
每个Leaf服务实例都会每隔3s将自身的系统时间上报到其在leaf_forever节点下注册 的子节点,并且还会在另一个ZooKeeper节点leaf_temporary下创建一个临时节点, leaf_temporary下的临时节点列表代表了此时正在运行的Leaf服务实例集合。也就是说, Leaf服务实际上与两个ZooKeeper父节点交互:leaf_forever节点与leaf_temporary节点,如图所示。
Leaf-snowflake方案使用这两个节点来解决时钟回拨问题,具体的工作流程如下。
(1 )如果Leaf服务实例在leaf forever节点下未注册持久顺序节点,那么在注册节点时将顺便写入自身的系统时间。
(2 )如果Leaf服务实例已在leaf_forever节点下注册持久顺序节点,则对比持久顺序节点记录的时间与自身的系统时间。如果自身的系统时间更小,则认为发生了时钟回拨, 服务实例启动失败。
(3)否则,获取leaf_temporary节点下的所有临时节点信息,然后向这些临时节点代 表的Leaf服务实例发送RPC请求查询它们的系统时间,并计算出平均时间,用于表示Leaf服务集群的系统时间。
(4 )如果平均时间与Leaf服务实例自身的系统时间的差值小于某个阈值,则认为本 服务实例的系统时间是准确的,服务实例可以正常启动。
(5) 否则,说明本服务实例的系统时间相较于Leaf集群中的其他服务实例发生了大 幅度的时钟漂移,服务实例启动失败。
(6)启动成功的Leaf服务实例每隔3s将自身的系统时间上报到在leaf_forever节点下注册的持久顺序节点。