美团分布式ID生成服务LeafCode

分布式ID作用

复杂系统中往往需要唯一标识做一些事情,比如全局唯一code码,比如分库分表用系统递增id不能满足需求,比如做IM消息id需要有个生成器。

业务上对于全局唯一ID有什么要求呢?

  1. 全局唯一:不能出现重复的ID;

  2. 趋势递增:MySql InnoDB引擎使用的是聚集索引,关系数据库使用b-tree数据结构组织存储数据,所以作为数据库表主键尽量保障其有序性以提高写性能;

  3. 单调递增:需要保障某个时间点后面生成的ID一定大于前面生成的ID,这样我们可以在业务上做一些事情,比如事务版本、增量消息、排序等要求;

  4. 数据安全:如果ID是连续递增了,其实是可以反解出对应的数据量增长的,比如订单id如果递增,竞对可以知道每日订单量是多少了,而且一旦数据id有规则,就容易被穷举,存在安全风险;

  5. 可靠性高:全局唯一ID生成服务往往是很多业务架构的底层服务,需要保障较高的可靠性,如果全局唯一ID服务不可用,上层业务系统可能会瘫痪;

总结下来一个支撑全局唯一ID生成的服务需要具备以下几点:

  1.  TP999尽可能要低,不能因此提高整个上层业务时延;

  2. 可用性要求高,需要保证5个9;

  3. 高QPS支持,上层很多业务同一个高峰期间调用ID生成服务;

业界有以下几种常见唯一ID生成方式。

UUID方式

UUID是32个16进制数字,有基于“-”的连字号分成5端,共36字符。

8-4-4-4-12

优点:

  1. 性能高,本地生成,没有网络损耗

缺点:

  1. 不易存储:UUID太长,16字节128位,通常以36长度字符串表示,很多场景不适用;

  2. 信息安全不高:因为UUID生成需要基于MAC地址,可能造成MAC地址泄露,可能被黑客利用;

  3. 对于DB主键场景非常不适用:MySql官方建议主键尽量越短越好,36字符长度的UUID显然不符合,同时这种无序生成的ID对mysql索引非常不利,在InnoDB引擎下,UUID的主键会引起数据位置频繁变动,严重影响性能,如果存储介质不是MySql可以忽略;

Snowflake方式

之前的文章介绍过手写一个Snowflake方案,大致的思路是类似于UUID的划分命名空间的方法来生成唯一ID,把64-bit分成多段,用来区分机器标识、时间、自增序列等,具体如图所示:

41-bit时间可以表示(1L<<41)/(1000L*3600*24*365)=69年,10-bit可以表示1024台机器。如果对于IDC有要求,可以将10-bit分5个给到IDC,另外5个给工作机器。这样可以标识32个IDC,每个IDC可以有32台机器,可以根据自身需求定义。

12个自增序列号可以标识2^12个ID,理论上snowflake的qps可以支持409.6w/s,这种分配方式可以保证在任何一个IDC任何一台机器在毫秒内生成ID是不同的。

优点:

  1. 毫秒数在高位,自增序列在低位,整个ID是趋势递增的;

  2. 不依赖数据库等三方系统,可以独立部署服务,稳定性高,生成ID的性能也非常好;

  3. 可以根据业务特点分配bit位,非常灵活;

缺点:

  1.  强制依赖机器时钟,如果机器上分配的时钟回拨,会导致发号重复或服务不可用;

数据库生成方式

就是利用Mysql的自增方式生成id,给字段设置auto_increment_increment和auto_increment_offset来保证ID自增,每次业务使用下面SQL读写MySql得到ID:

begin;

REPLACE INTO Tickets64 (stub) VALUES ('a');

SELECT LAST_INSERT_ID();

commit;

优点:

  1. 非常简单,利用现有数据库实现,成本小;

  2. ID号单调自增,可以实现一些对ID有特殊要求的业务场景;

 缺点:

  1. 强依赖DB,当DB整体不可用时会导致服务不可用,配置主从复制可以增加DB可用性,但是数据一致性在主从延迟等情况下难以保证,主从切换时不一致可能导致重复发号;

  2. ID发号器的瓶颈在DB本身;

对于提高Mysql性能上可以采用这种方案:

在分布式系统中可以部署多台机器,每台机器提供不同的初始值,且步长相等。

比如两台机器,step为2,TicketServer1初始值为1,取值过程为:1,3,5,7,9,11...。TicketServer2初始值为2,取值过程为:2,4,6,8,10...。

这是Flicker在2010年介绍的一种主键生成策略,上述方案对应参数如下:

TicketServer1:(TicketServer)

auto-increment-increment = 2

auto-increment-offset = 1

TicketServer2:

auto-increment-increment = 2

auto-increment-offset = 2

如果我们部署N台机器,步长需要设置为N,每台初始值依次为0,1,2...,n-1:

这种架构看起来可以满足性能需求,但也存在几个缺点:

  1.  系统水平扩展起来比较麻烦,比如定义好步长和机器台数之后,后续添加机器应该怎么操作?可以这样搞,比如之前有一台机器步长为1,发号逻辑为1,2,3,4,5。当想要扩容另一台机器时,考虑在扩容期间发号不会到14,则可以将第二台机器设置为从14开始发号,步长为2。然后摘除第一台,保留为奇数,步长设置为2。这样操作之后两台机器是可以产生符合预期的数字的。但是如果发号服务整体有100台机器,这个扩容操作就是噩梦了,复杂的难以实现。

  2. 两台机器整体对外提供的id是趋势递增的,但是对于大部分业务不是很重要,可以容忍。

  3. 数据库压力还是很大的,每次获取id都需要读一次数据库,只能靠堆机器提高性能。

那么LeafCode是如何实现的呢?

Leaf是基于上面第二种和第三种方案优点组合一起实现的。

基于数据库的Segment方案

之前基于数据库方案,每次获取ID都得读写一次数据库,对数据库造成非常大的压力。改为proxy方式后,可以批量获取,每次获取一个segment号段的值。用完之后再去数据库获取新的号段,可以大大降低数据库的压力。

不同业务通过biz_tag字段区分,每个biz_tag的ID获取相互隔离,互不影响。后续如果有性能需求可以对数据库进行扩容,不需要之前数据库方案复杂的扩容操作,只需要对biz_tag分库分表就行。

库表结构:

说明:

  1.  biz_tag用来区分业务;

  2. max_id表示该biz_tag目前所被分配的id号段最大值;

  3. step表示每次分配号段长度;

之前每次获取id都需要读写数据库,现在只需要把step设置足够大,比如1000,那么只有当1000个号被消耗完之后才重新读写一次数据库。读写频率从之前的1减少到了1/step。

比如test_tag在第一台leaf机器上是1~1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,比如现在有三个leaf服务,每个1000,如果考虑另外两个服务的id没有变更的情况下,新加载的1000号会到第一台机器,号段变成了3001~4000。同时数据库对应的biz_tag数据的max_id会从3000变更为4000,对应sql:

优点:

  1.  Leaf服务可以线性扩展,性能完全支撑多数业务场景;

  2. ID号是趋势递增的8byte的64位数字,满足数据库主键要求;

  3. 容灾性高,leaf服务内部有号段缓存,即使DB宕机,短时间内leaf仍然可以对外提供服务;

  4. 可以自定义max_id大小,便于业务从原有id方式迁移过来;

缺点:

  1.  ID号码不够随机,可以基于发号数量推演一些信息;

  2. TP999数据波动大,当号段使用完之后,会去db更新数据,这个过程可能会hang在这个IO上,tp999数据会出现偶尔尖刺;

  3. DB宕机,整个服务可能不可用;

解决IO尖刺问题

IO尖刺主要出现在Leaf服务内号段消耗完的时候,去db获取下一阶段临界点数据,可能会导致IO线程阻塞,如果DB查询网络抖动或是产生慢查询,整个系统会响应比较慢。

我们希望到DB取号的过程中可以做到无阻塞,可以在号段消费到某个时间点之后,异步的将下一号段加载到内存里面,这样就不需要等到用尽号之后才去更新号段,这样可以很大程度降低系统的TP999了。

可以采用双buffer方式,leaf内存服务有两个号段缓冲区(segment)。当号段已下发10%时,如果下一个号段未更新,则另起一个更新线程去更新号段。如果下个号段准备好了,则切换到下个号段为当前segment接着下发,循环往复:

  1. 每个biz-tag都有消费速度监控,通常推荐segment长度设置为高峰期发号qps的600倍(10分钟),这样即使宕机,leaf仍然可以持续发号1-~20分钟;

  2. 每次请求来临时,都会(多次check)判断下个号段状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新;

高可用容灾

对于DB可用性问题,可以采用一主两从方式,同时分机房部署,Master和Slave之间采用半同步方式同步数据。引入高可用中间件做故障转移与主从切换。当然极端情况下,这些还会退化为异步模式,导致数据不一致,但概率很小。

所以如果系统要保证100%的数据强一致,可以选择类似于Paxos算法,做到数据主从的强一致MySql方案。

同时Leaf服务分IDC部署,内部服务基于RPC调用,服务调用时根据负载均衡算法优先调用同机房的leaf服务。在该IDC内leaf服务不可用时才选择其他机房Leaf服务。同时基于治理平台提供针对于服务过载保护、一键截流、动态流量分配等对服务进行保护。

Snowflake的方案

Leaf的另一种方案是基于Snowflake的方案,产生趋势递增的id,由于ID是可以计算的,不适用于订单id生成场景。

leaf-Snowflake完全沿用了Snowflake方案的bit设计,1+41+10+12方式生成id。

针对于workerId的分配,当服务集群数量较少时,完全可以手动配置。leaf服务规模较大时,动手配置成本太高。可以适用zk持久顺序节点特性自动对Snowflake节点配置workerid。启动步骤如下:

  1.  启动leaf-Snowflake服务,连接zk,在leaf-forever节点下检查自己是否已经注册过,判断依据是该顺序子节点;

  2. 如果有注册,则直接返回自己的workerId(zk顺序节点生成int类型id号),启动服务;

  3. 如果没有注册过,就在该父节点下创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerid,启动服务;

弱化zk依赖

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

解决时钟问题

因为Snowflake方案对于时间强依赖,如果机器的时钟发生了回拨,那么可能会产生重复id,需要解决时钟回退问题。

参照整个启动流程图,服务启动时先检查自己是否写过zk的leaf_forever节点:

  1.  写过,则用自身系统时间与leaf_forever/${self}节点记录的时间比较,如果小于leaf_forever/${self}节点的时间,则认为机器时间发生了大步长回拨,服务启动失败并告警;

  2. 未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self},并写入自身系统时间,接下来综合对比其余leaf节点的系统时间判断自身系统时间是否准确,具体做法是取leaf_temporary下所有临时节点的服务ip:port,然后通过rpc请求得到对应节点的系统时间,计算sum(time)/nodeSize;

  3. 如果 (系统时间-sum(time)/nodeSize)<阈值,则认为当前系统时间正确(小于平均值),正常启动服务,同时写入临时节点leaf_temporary/${self}维持租约;

  4. 否则认为本机系统时间发生了大步长的便宜,启动失败告警;

  5. 每隔一段时间,比如3s上报自身服务时间,写入leaf_forever/${self};

因为Snowflake强依赖时钟,对时间比较敏感,在机器工作时NTP同步也会造成秒级回退,建议可以直接关闭NTP同步。或者在机器时钟回拨时,直接不对外提供服务,返回ERROR_CODE,等时钟追上之后再提供服务。

或做一层重试,等上报告警系统,或发现有时钟回拨之后自动摘除本身节点并告警:

监控

Leaf提供了web监控,映射的是内存数据情况,可以看到实时的发号状态,比如每个号段双buffer使用情况,当前id下发到哪个位置等等。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值