从0到1搭建技术中台之ID生成服务实践

作者 伴鱼技术团队

自去年开始,中台话题的热度不减,很多公司都投入到中台的建设中,从战略制定、组织架构调整、协作方式变动到技术落地实践,每个环节都可能出现各种各样的问题。技术中台最坏的状况是技术能力太差,不能支撑业务的发展,其次是技术脱离业务,不能服务业务的发展。前者是能力问题,后者是意识问题。在本专题中,伴鱼技术团队分享了从0到1搭建技术中台的过程及心得。

前言

ID生成器在前后端系统内都比较常见,应用场景广泛,如:订单ID、账户ID 、流水号、消息ID等等。常见的ID类型如下:

• UUID和GUID:GUID和UUID本质类似,GUID来源于微软。一个UUID是一个16字节 (128 bit) 的数字。UUID由网卡MAC地址、时间戳、名字空间 ( Namespace )、随机或伪随机数、时序等元素进行生成。优点:在特定范围内可以保证全局唯一;生成方便,单机管理即可。缺点:所占空间比较大;无序,在插入数据库时可能会引起大规模数据位置变动,性能不友好。

• 数据库自增ID:主要基于关系数据库如MySQL的auto increment自增键,在业务量不是很大时使用比较方便。基于数据库自增字段也有一些变种,如下面会介绍到的号段模式。优点:实现成本低,直接基于DB实现,不需要引入额外组件;能够实现单调自增,递增场景友好。缺点:需要考虑高可用、横向扩展问题。

• snowflake :雪花算法由毫秒时间戳(41位) + 机器ID( workerId 10位) + 自增序列 (12位),理论上最多支持1024台机器每秒生产400w个ID。雪花算法综合考虑了性能、全局唯一、趋势自增、可用性等,是一种非常理想的ID生成算法,也是伴鱼内部使用最为广泛的ID生成算法。

伴鱼内部也有很多ID生成的需求,像是我们的订单、支付单、一对一课程、绘本、IM聊天消息、账号等等。ID类型上也基本脱离不了上面几种,但是使用质量上参差不齐。

背景

第一阶段:各自封装

伴鱼早期业务量比较少,各系统基本都是有自己的ID生成模块,有基于TiDB自增ID的,有基于UUID的,也有基于雪花算法的,其中雪花算法也被称为snowflake,使用最为广泛。各自封装模块比较简单,但是实现分散、各系统模块的质量也很难统一保证。

第二阶段:集成框架

为了解决上述分散实现的问题,我们统一实现了一个综合各类ID生成功能的基础库,供业务方统一调用。统一基础库解决了分散调用问题,但是对于snowflake这种带有workerId的算法,需要业务系统关注workerId分配的逻辑。于是,我们把snowflake的逻辑封装到了服务治理框架内,服务启动时,由框架来负责workerId的分配和服务内的唯一性。

第三阶段:idgen服务

封装到框架后,同一服务的不同实例之间可以很好的处理workerId的分配问题。但是,workerId的逻辑也使得服务内多个实例成为了有状态实例,K8s部署也只能使用StatefulSet。最近两年,伴鱼业务量突飞猛进,系统数量暴增,业务对系统的稳定性、弹性提出了更高的要求,我们需要ID生成逻辑非常稳定、高效,我们需要服务实例都是无状态实例Deployment,使服务具备快速滚动升级、弹性伸缩的能力。基于这样的背景,我们决定提供一个单独的ID生成服务,需求如下:

• 支持DB号段和snowflake两种模式

• ID生成器自身的可用性、稳定性非常高,具备时钟校准能力

• TP99必须非常低

• 兼容现有逻辑,业务迁移要非常方便

• 服务使用Deployment部署

伴鱼的ID生成器能基本经历了以上三个阶段,可能有人会有疑问:开源ID生成服务也不少,为什么不直接使用开源项目呢?这里有三点考虑:

• 开源ID生成服务基本以Java实现为主,比如Leaf、tinyid,我司的技术栈以Go为主。

• 历史原因,我司之前都是使用slowId(后面有介绍,防止js中精度丢失的简单处理)的方式,即使直接使用开源项目也一定要进行二次开发。

• ID生成本身并不复杂,以上开源项目也缺少必要的时钟回退、多节点时钟校验等优化。综合考虑下来,我们还是决定自己开发,后续也有开源的计划,期望能为Go社区做些贡献。

基于以上背景和需求,我们打造了伴鱼第一代ID生成服务:idgen。

系统设计

DB号段模式

号段模式可以理解为对DB自增ID方案的优化,本质是利用批量获取的方式,定期获取一个号段,缓存在本地供外部使用,减轻DB的压力,提升对外服务性能。交互形式如下:

snowflake模式

snowflake是Twitter于2010年首次对外公开,其值为64位整数,可以做到全局唯一。构造如下:

系统优化

号段模式优化

双buffer提升性能、减少毛刺

DB号段模式的原理比较简单,但是上面的实现方案也有一定的潜在风险。首先,任一节点的号段耗尽时都需要从DB中取出下一个号段再返回ID,这个延迟会造成一定的请求毛刺。其次,如果请求DB的时候出现网络错误、慢查询,对于可用性方面也带来了一定的挑战。

针对毛刺问题,我们可以同时分配两个buffer,当其中一个buffer消耗到一定阈值时,异步更新下一个buffer,这个阈值是可调整的。双buffer交互方式如下:

动态步长

一个号段的使用时间是由消费速度和buffer长度决定的。为了尽最大可能提升可用性,buffer自然是越长越好,这样在DB出问题时,我们还能抗一段时间。但是,buffer太长有坏处,如果程序异常退出、正常重启,buffer太长很容易造成巨大的ID空洞。所以我们根据ID消耗速度和规划时间,动态调整buffer的长度,尽量在提升可用性的同时避免ID空洞。

snowflake优化

snowflake workerId分配机制

workerId在snowflake内必须保证全局不重复,范围在0-1023之间(如果调整各个段落的位数,会发生变化)。可以通过对实例打标记的方式,分配workerId,但是打标记会给实例带来一定的状态,我们还是期望实例是无状态的(idgen服务通过K8s Deployment模式部署)。etcd可以充当全局coordinator的角色,通过etcd原子分配的方式,我们可以比较容易获取到全局唯一的workerId。

snowflake容错机制

snowflake本身的容错有两点,一是防止自身节点时钟回拨,另一点是防止节点自身时钟不正确。

• 时钟回拨

对于时钟回拨,我们会在etcd内记录节点上次的时间,节点启动时,根据节点ID从etcd取回之前的时间。如果判定回拨非常少,我们可以等待回拨时间过后,正常启动。如果回拨过大,节点直接启动失败并报错,报错后人为介入处理。这里还有一个细节,节点是定时上报的,假设每interval秒上报一次当前时间,如果节点失败后被快速拉起,新节点和旧节点之间可能存在时间冲突的风险。对于这种情况,我们采取上报时间为now+interval秒的方式,这样新节点需要超过这个时间戳,问题自然解决(或者新节点启动时等待timestamp+interval秒以上,启动不是太顺滑,不推荐)。

• 多节点时钟校验

对于时钟错误,机房都会有NTP调整时钟,一般机器都不会有问题。为了进一步降低时钟错误风险,每个节点会定期上报自己的节点信息(IP/Port)到etcd,同时每个节点都有一个rpc方法,可供外界获取本节点的时间戳。一个新节点启动时,会通过etcd注册的其他节点信息,并发调用rpc方法获取其他节点的时间戳,然后一一对比,如果差异过大,则代表本节点时间戳可能有问题,直接报错,人为介入处理。

这里大家可能会有两个个疑问:

一是为什么不采用把各个节点上报时间戳到etcd,新启动节点直接取etcd内的时间戳进行逐个判断呢?

这里主要考虑时间校准的准确性,如果各节点定期上报时间戳,各节点时间戳差异会比较大,这会导致我们判断时间偏差的幅度比较大,准确性会下降。

二是如果第一个节点时间戳是错误的,后续正常节点启动怎么办?

首先,这种情况发生的几率非常低并且此时我们启动正常节点时肯定会报错,人为介入。报错时,直接停掉异常节点,然后逐个启动正常的新节点,第一个新节点启动时,etcd内也没有其他节点信息,无需校验。

接口优化

批量获取

有一些业务会有这样的顾虑,虽然idgen服务进一步提升了服务稳定性和可用性,但是多了一次rpc调用,貌似也不是很划算。首先,这种rpc调用其实在整个业务逻辑里耗时占比微乎及微,所以一般都不会成为问题。但是,有的服务就是特别在意,比如它内部可能是个循环调用,每次rpc请求,循环100次调用idgen服务,针对这种情况,我们提供了一个批量获取ID的rpc方法。批量的个数和上限都是按照接口超时时间和每秒生产数做一个折中。这里其实还有其他方法可以进一步降低整体耗时,比如我们提供具备pipeline功能的sdk,业务系统看起来还是一个一个获取,其实sdk层面都是缓存+批量获取的方式,这样获取ID的性能也会比较好。

伴鱼特色

slowId

由于历史原因(和前端js交互,使用float类型,js没有int64类型,如果直接使用原生snowflake会出现精度损失),我司不少服务使用了snowflake的变种:由41 bit毫秒级时间戳,10 bit的workId,以及1位的自增序列组成的ID返回给前端,虽然没有精度损失,但是这种ID获取方式性能比较差(每毫秒最多两个ID )。所以,如果大家有跟前端ID交互,优先选择字符串类型,目前我司不少服务也已经逐渐迁移为标准snowflake。

业务迁移

idgen服务上线后,开始推动业务系统进行接入。

• 迁移准备

我们首先通过代码扫描的方式,整理出一份当前使用到id生成库的服务列表。然后,逐个业务负责人沟通迁移,同时,我们也提供了rpc服务的简单封装函数,业务改动非常小,对接成本非常低。

• 切换时冲突

在对接的过程中我们发现,由于以前的服务使用了0-X (实例部署个数)这段workerId,如果idgen也使用这段workerId,在切换的过程中,有一定的概率造成ID重复。所以,我们在idgen服务增加了手工指定workerId offset的功能,优先将idgen的workerId调到一个比较大的起始区间,迁移冲突的问题就解决了(后续迁移完成之后,我们可以在调回到0-X区间)。

总结

目前idgen服务已经对接了大几十个服务,高峰期TP99在3ms左右,一直稳定运行。下一步,除了对接更多的服务,我们会进一步提升idgen的稳定性和性能,包括提供定制化的客户端、一定的ID缓存机制等等。另外,目前内部正在进行框架剥离,剥离后我们会把idgen开源出去,希望能为Go社区也提供一个企业级的ID生成项目。