分布式唯一全局 ID 解决方案之二

大纲

1、UidGenerator 分布式 ID 生成器

1.1、概述

UidGenerator 是 Java 实现的,基于 Snowflake 算法的唯一 ID 生成器。UidGenerator 以组件形式工作在应用项目中, 支持自定义 workerId 位数和初始化策略, 从而适用于 Docker 等虚拟化环境下实例自动重启、漂移等场景。在实现上, UidGenerator 通过借用未来时间来解决 sequence 天然存在的并发限制;采用 RingBuffer 来缓存已生成的 UID, 并行化 UID 的生产和消费, 同时对 CacheLine 补齐,避免了由 RingBuffer 带来的硬件级「伪共享」问题。 最终单机 QPS 可达 600 万。依赖 Java8 及以上版本, MySQL (内置 WorkerID 分配器, 启动阶段通过数据库进行分配;如自定义实现,则数据库非必选依赖)。

1.2、结构

Snowflake 算法描述:指定机器 & 同一时刻 & 某一并发序列,是唯一的。据此可生成一个 64 bit 的唯一 ID(Long 型),默认采用下图字节分配方式:

uid-generate-1

  • sign(1bit):符号位,固定是 0,表示全部 ID 都是正整数
  • delta seconds (28 bits):当前时间,相对于时间基点 2016-05-20 的增量值,单位为秒,最多可支持约 8.7 年
  • worker id (22 bits):机器 ID,最多可支持约 420w 次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略
  • sequence (13 bits):每秒下的并发序列,13 bits 可支持每秒 8192 个并发

2、Leaf 分布式 ID 生成系统

Leaf 提供了两种方案,分别是 Leaf-segment 和 Leaf-snowflake 方案,前者依赖 MySQL,后者依赖 ZooKeeper。

2.1、Leaf-segment 方案

2.1.1、概述

Leaf-segment 方案,在使用 MySQL 自增 ID 的方案上,做了如下改变:

  • 原方案每次获取 ID 都得读写一次数据库,造成数据库压力大。改为利用 Proxy Server 批量获取,每次获取一个 segment(step 决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力
  • 各个业务不同的 ID 生成需求用 biz_tag 字段来区分,每个 biz-tag 的 ID 获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述复杂的扩容操作,只需要对 biz_tag 分库分表就行
1
2
3
4
5
6
7
8
9
+-------------+--------------+------+-----+-------------------+-----------------------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+-------------------+-----------------------------+
| biz_tag | varchar(128) | NO | PRI | | |
| max_id | bigint(20) | NO | | 1 | |
| step | int(11) | NO | | NULL | |
| desc | varchar(256) | YES | | NULL | |
| update_time | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
+-------------+--------------+------+-----+-------------------+-----------------------------+

重要字段说明:

  • biz_tag:用来区分业务
  • max_id:表示该 biz_tag 目前所被分配的 ID 段的最大值
  • step:表示每次分配的号段长度。原来获取 ID 每次都需要写数据库,现在只需要把 step 设置得足够大,比如 1000。那么只有当 1000 个号被消耗完了之后才会去重新读写一次数据库,读写数据库的频率从 1 减小到了 1 / step

2.1.2、架构

leaf-segment

test_tag 在第一台 Leaf 机器上是 11000 的号段,当这个号段用完时,会去加载另一个长度为 step=1000 的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是 30014000。同时数据库对应的 biz_tag 这条数据的 max_id 会从 3000 被更新成 4000,更新号段的 SQL 语句如下:

1
2
3
4
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

2.1.3、优缺点

优点:

  • Leaf 服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景
  • ID 是趋势递增的 8 byte 的 64 位数字,满足上述数据库存储的主键要求
  • 可以自定义 max_id 的大小,非常方便业务从原有的 ID 方式上迁移过来
  • 容灾性高,Leaf 服务内部有号段缓存,即使数据库宕机,短时间内 Leaf 仍能正常对外提供服务

缺点:

  • 数据库宕机会造成整个系统不可用
  • ID 不够随机,能够泄露发号数量的信息,不太安全
  • TP999 数据波动大,当号段使用完之后,ID 生成的性能瓶颈还是会在更新数据库的 I/O 上,TP999 数据会出现偶尔的尖刺

2.1.4、高可用容灾

针对第一个缺点数据库可用性问题,目前采用一主两从的方式,同时分机房部署,Master 和 Slave 之间采用半同步方式同步数据。同时使用 Atlas 数据库中间件(已开源,改名为 DBProxy)做主从切换。当然这种方案在一些情况会退化成异步模式,甚至在非常极端情况下仍然会造成数据不一致的情况,但是出现的概率非常小。如果系统要保证 100% 的数据强一致,可以选择使用 类 Paxos 算法 实现的强一致 MySQL 方案,如 MySQL 5.7 GA 的 MySQL Group Replication,但是运维成本和精力都会相应的增加,根据实际情况选型即可。在美团点评内部,Leaf 服务分 IDC 部署,内部的服务化框架是 MTthrift RPC。服务调用的时候,根据负载均衡算法会优先调用同机房的 Leaf 服务。在该 IDC 内 Leaf 服务不可用的时候才会选择其他机房的 Leaf 服务。同时服务治理平台 OCTO 还提供了针对服务的过载保护、一键截流、动态流量分配等对服务的保护措施。

leaf-segment-hight-available

2.1.5、双 Buffer 优化

针对上述第三个缺点,Leaf-segment 做了一些优化,简单的说就是:Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的 ID 下发时间取决于下一次从数据库取回号段的时间,并且在这期间进来的请求也会因为数据库号段没有取回来,导致线程阻塞。如果请求数据库的网络和数据库的性能稳定,这种情况对系统的影响是不大的,但是假如取数据库的时候网络发生抖动,或者数据库发生慢查询就会导致整个系统的响应时间变慢。为此,希望数据库取号段的过程能够做到无阻塞,不需要在数据库取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的 TP999 指标。详细实现如下图所示:

leaf-segment-double-buffer

  • 采用双 Buffer 的方式,Leaf 服务内部有两个号段缓存区 segment。当前号段已下发 10% 时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前 segment 接着下发,循环往复
  • 每个 biz-tag 都有消费速度监控,通常推荐 segment 长度设置为服务高峰期发号 QPS 的 600 倍(10 分钟),这样即使数据库宕机,Leaf 仍能持续发号 10-20 分钟不受影响
  • 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新

2.2、Leaf-snowflake 方案

2.2.1、概述

Leaf-segment 方案可以生成趋势递增的 ID,同时 ID 是可计算的,不适用于订单 ID 生成场景,比如竞对在两天中午 12 点分别下单,通过订单 ID 相减就能大致计算出公司一天的订单量,这个是不能忍受的。面对这一问题,美团点评提供了 Leaf-snowflake 方案。

2.2.2、架构

Leaf-snowflake 方案完全沿用 SnowFlake 方案的 bit 位设计,即是 1+41+10+12 的方式组装 ID。对于 workerId 的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf 服务规模较大,动手配置成本太高。所以使用 ZooKeeper 持久顺序节点的特性自动对 SnowFlake 节点配置 wokerId。Leaf-snowflake 是按照下面几个步骤启动的:

  • 启动 Leaf-snowflake 服务,连接 ZooKeeper,在 leaf_forever 父节点下检查自己是否已经注册过(是否有该顺序子节点)
  • 如果有注册过直接取回自己的 workerId(ZooKeeper 顺序节点生成的 int 类型 ID),启动服务
  • 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的 workerId 号,启动服务

leaf-snowflake-1

2.2.3、弱依赖 ZooKeeper

除了每次会去 ZooKeeper 拿数据以外,也会在本机文件系统上缓存一个 workerId 文件。当 ZooKeeper 出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖,一定程度上提高了 SLA。

2.2.4、解决时钟回拨问题

因为 Leaf-snowflake 方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的 ID,因此需要解决时钟回退的问题。Leaf-snowflake 整个启动流程图如下:

leaf-snowflake-2

  • 1)服务启动时首先检查自己是否写过 ZooKeeper 的 leaf_forever 节点
  • 2)若写过,则用自身系统时间与 leaf_forever/${self} 节点记录时间做比较,若小于 leaf_forever/${self} 时间则认为机器时间发生了大步长回拨,服务启动失败并报警
  • 3)若未写过,证明是新服务节点,直接创建持久节点 leaf_forever/${self} 并写入自身系统时间,接下来综合对比其余 Leaf 节点的系统时间来判断自身系统时间是否准确,具体做法是取 leaf_temporary 下的所有临时节点(所有运行中的 Leaf-snowflake 节点)的服务 IP:Port,然后通过 RPC 请求得到所有节点的系统时间,计算 sum(time) / nodeSize
  • 4)若 abs (系统时间 - sum (time) /nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点 leaf_temporary/${self} 维持租约
  • 5)否则认为本机系统时间发生大步长偏移,启动失败并报警
  • 6)每隔一段时间(3s)上报自身系统时间写入 leaf_forever/${self}

由于强依赖时钟,对时间的要求比较敏感,在机器工作时 NTP 同步也会造成秒级别的回退,建议可以直接关闭 NTP 同步。要么在时钟回拨的时候直接不提供服务直接返回 ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//发生了回拨,此刻时间小于上次发号时间
if (timestamp < lastTimestamp) {

long offset = lastTimestamp - timestamp;
if (offset <= 5) {
try {
//时间偏差大小小于5ms,则等待两倍时间
wait(offset << 1);//wait
timestamp = timeGen();
if (timestamp < lastTimestamp) {
//还是小于,抛异常并上报
throwClockBackwardsEx(timestamp);
}
} catch (InterruptedException e) {
throw e;
}
} else {
//throw
throwClockBackwardsEx(timestamp);
}
}
//分配ID

3、参考资料