如何设计一款永不重复的高性能分布式发号器(第一篇)
前言
在互联网世界里,产生唯一流水号的服务系统俗称发号器,这里将围绕一款专业的开源项目Vesta 讲解发号器的架构设计思想和使用方式。
除了发号器本身,将按照一款开源项目的声明周期来构思,从设计、实现、验证到使用向导,以及论述遣留的问题等,帮助读者学习如何创建一款平台类软件及其思路,并帮助读者在技术的道路上发展得越来越好。
可选方案及技术选型
为什么不用UUID
UUID虽然能够保证ID的唯一性, 但是无法满足业务系统需要的很多其他特性,例如:时间粗略有序性、可反解和可制造性。另外,UUID 产生时使用完全的时间数据,性能比较差,并且UUID比较长、占用空间大,会间接导致数据库性能下降;更重要的是, UUID并不具有有序性,会导致B+树索引在写的时候有过多的随机写操作(连续的ID会产生部分顺序写);还有,由于在写的时候不能产生有顺序的append操作,而需要进行insert 操作,将读取整个B+树节点到内存,在插入这条记录后会将整个节点写回磁盘,这种操作在记录占用空间比较大的情况下,性能下降明显。
基于数据库的实现方案
若当前业务系统的ID使用数据库的自增字段,而自增字段完全依赖于数据库,则在进行数据库移植、扩容、洗数据、分库分表等操作时会带来很多麻烦。
在数据库分库分表时,有一种方案是通过调整自增字段或者数据库sequence的步长来确保跨数据库的ID的唯一性但这仍然是一种强依赖数据库的解决方案,有诸多限制,并且强依赖数据库类型,我们并不推荐采用这种方案。
随着业务的发展,请求的量级在不断增加,导致数据库的性能瓶颈可能会出现。在这种情况下,有些方案会通过设置数据库sequence或者表自增字段的步长进行水平伸缩,如下图。
在上图所示的方案中有8个服务节点,每个服务节点使用一个sequence功能来产生ID,每个sequence的起始ID是不同的,而且是依次增加的,但步长都是8。在用于防止产生的ID重复时,这种方案实现起来简单,也能达到性能目标,还能水平扩展,但也存在如下问题。
- 服务节点固定,sequence 的步长也固定,将来如果增***务节点,则难以再进行水平扩展。
- 仍然依赖于数据库,对数据库会造成压力,因为ID的产生在一些场景下也是高频访问的服务。
- 由于多个 sequence是疏散管理的,所以增加了人员维护的成本。
Snowflake开源项目
Twitter的Snowflake是一个流行的开源的发号器实现,在互联网公司里得到了广泛应用。然而,Slowflake是通过Scala语言实现的,文档简单,发布模式单一,缺少支持和维护,很难在现实项目中直接使用。
Vesta
由于上面提到的三种方案都有各自的缺陷,所以我们在后续内容中力图实现一个通用、原创的唯一流水号产生器, 基于流行的互联网编程语言Java实现,这也是这里发号器项目的示例实现,被命名为Vesta。 Vesta 具有全局唯一、 粗略有序、可反解和可制造等特性,支持三种发布模式:嵌入发布模式、中心服务器发布模式、REST 发布模式,可以通过Jar包的形式嵌入到Java开发的任何项目中,也可以通过服务化或者REST服务发布,发布样式灵活多样,使用简单、方便、高效。Vesta 还可以根据业务的性能需求,产生最大峰值型和最小粒度型这两种类型的ID,它的实现架构使其具有高性能、高可用和可伸缩等互联网产品需要的质量属性,是一款通用的高性能的发号器产品。
分布式系统对发号器的基本需求
在分布式系统中,整体的业务被拆分成多个自治的微服务,每个微服务之间需要通过网络进行通信和交互,由于网络的不确定性,会给系统带来各种各样的不一致问题。 为了避免和解决不一致问题,最重要的模式就是做系统之间的实时核对和事后核对,核对的基础就是领域对象及系统间的请求要有唯一ID 来标识,这样在核对时才能有据可依。
需求是所有设计的起点,一切偏离需求的设计都是“耍流氓”。
1.全局唯一
有些业务系统可以使用相对小范围的唯一性, 例如,如果用户是唯一 的, 那么同一用户的订单采用的自增序列在用户范围内也是唯一的, 但是如果这样设计,订单系统就会在逻辑上依赖用户系统,因此,不如保证ID在系统范围内的全局唯一更实用。
分布式系统保证全局唯一的一个悲观策略是使用锁或者分布式锁,但是,只要使用了锁,就会大大地降低性能。
因此,我们决定利用时间的有序性,并且在时间的某个单元下采用自增序列,来达到全局唯一。
2.粗略有序
在前面讨论了UUID的最大问题是无序,任何业务都希望生成的ID是有序的,但是在分布式系统中要做到完全有序,就涉及数据的汇聚,当然要用到锁或者分布式锁。考虑到效率,我们只能采用折中的方案:粗略有序。目前有两种主流的方案,一种是秒级有序, 另一种是亳秒级有序。这里又有一个权衡和取舍,我们决定支持两种方式,通过配置来决定服务使用其中的某种方式。.
3.可反解
一个ID在生成之后,其本身带有很多信息量。在线上排查的时候,我们通常首先看到的是ID,如果根据ID就能知道它是什么时候产生的及是从哪里来的,则这个可反解的ID能帮我们很多忙。
如果在ID里有了时间且能反解,在存储层面就会省下很多传统的timestamp类的字段所占用的空间了,这也是一举两得的设计。
4.可制造
一个系统即使再高可用也不会保证永远不出问题,那么出了问题怎么办?手工处理。数据被污染了怎么办?洗数据。可是在手工处理或者洗数据时,假如使用了数据库的自增字段,ID已经被后来的业务覆盖了,那么怎么恢复到系统出问题的时间窗口呢?所以,我们使用的发号器一定要可复制、可恢复、可制造。
5.高性能
不管哪种业务,订单也好,商品也好,如果有新记录插入,那么一定是业务的核心功能,对性能的要求非常高。ID的生成取决于网络I/0和CPU的性能,网络1I0一般不是瓶颈, 根据经验,单台机器的TPS应该能达到10000/s。
6.高可用
首先,发号器必须是一个对等的集群,在一台机器挂掉时,请求必须能够转发到其他机器上,重试机制也是必不可少的。然后,如果远程服务宕机,我们还需要有本地的容错方案,本地库的依赖方式可以作为高可用的最后一道屏障。
7.可伸缩
在分布式系统中,我们永远都不能忽略的是业务量在不断增长,业务的绝对容量不是衡量系统性能的唯一标准, 要知道业务是永远增长的,所以,对系统的设计不但要考虑能承受的绝对容量,还必须考虑业务量增长的速度。系统的水平伸缩能否满足业务的增长速度,是衡量系统性能的另一个重要标准。
架构设计与核心要点
发布模式
根据最终的用户使用方式,发布模式可分为嵌入发布模式、中心服务器发布模式和REST发布模式。
- 嵌入发布模式: 只适用于Java客户端,提供了一个本地的Jar 包,Jar 包是嵌入式的原生服务,需要提前配置本地的机器ID,但是不依赖于中心服务器。
- 中心服务器发布模式:只适用于Java客户端,提供-一个服务的客户端Jar包,Java程序像调用本地API一样来调用,但是依赖于中心的ID产生服务器
- REST发布模式:中心服务器通过Restful API导出服务,供非Java语言客户端使用。发布模式最后会被记录在生成的ID中。也可参考下面数据结构段的发布模式的相关细节。
ID类型
根据时间的位数和序列号的位数,ID类型可以分为最大峰值型和最小粒度型。
(1)最大峰值型:采用秒级有序,秒级时间占用30位,序列号占用20位
(2)最小粒度型:采用毫秒级有序,亳秒级时间占用40位,序列号占用10位
最大峰值型能够承受更大的峰值压力,但是粗略有序的粒度有点大:最小粒度型有较细致的粒度,但是每个毫秒能承受的理论峰值有限,为1024, 如果在同一个毫秒有更多的请求产生,则必须等到下一-个毫秒再响应。
ID类型在配置时指定,需要重启服务才能互相切换。
数据结构
1.机器ID
10位,2^10=1024, 也就是说最多支持1000多个服务器。中心发布模式和REST发布模式一般不会有太多数量的机器,按照设计每台机器TPS为1万/s计算,10台服务器就可以有10万/s的TPS,基本可以满足大部分的业务需求。
但是考虑到我们在业务服务中可以使用内嵌发布方式,对机器ID的需求量变得更大,所以这里最多支持1024个服务器。
2.序列号
(1)最大峰值型:20位,理论上每秒内可平均产生2^20=1 048 576个ID,为百万级别。.如果系统的网络I/O和CPU足够强大,则可承受的峰值将达到每亳秒百万级别。
(2)最小粒度型:10位,每毫秒内的序列号总计2^10=1024个,也就是说每毫秒最多产生1000多个ID,理论上承受的峰值完全不如最大峰值方案。
3.秒级时间/毫秒级时间
(1)最大峰值型:30位,表示秒级时间,2^30/60/60/24/365=34, 也就是说可以使用30多年。
(2)最小粒度型:40位,表示毫秒级时间,2^40/1000/60/60/24/365=34, 同样可以使用30多年。
4.生成方式
2位,用来区分三种发布模式:嵌入发布模式、中心服务器发布模式、REST 发布模式。
- 00:嵌入发布模式。
- 01:中心服务器发布模式。
- 02:REST发布模式。
- 03:保留未用。
5.ID 类型
1位,用来区分两种ID类型:最大峰值型和最小粒度型。
- 0:最大峰值型。
- 1:最小粒度型。
6.版本
1位,用来做扩展位或者扩容时的临时方案。
0:默认值。
1:表示扩展或者扩容中。用于30年后扩展使用,或者在30年后ID将近用光之时,扩展为秒级时间或者毫秒级时间,来获得系统的移植时间窗口。其实只要扩展一位,就完全可以再用30年。
并发
对于中心服务器和REST发布方式,ID生成的过程涉及网络I0和CPU操作。ID 的生成基本上是内存到高速缓存的操作,没有磁盘I/O 操作,网络I/O是系统的瓶颈。
相对于网络1/O来说,CPU 计算速度是瓶颈,因此,ID 产生的服务使用多线程的方式,对于ID生成过程中的竞争点time和sequence,这里使用了多种实现方式。
(1)使用concurrent包的ReentrantLock进行互斥,这是默认的实现方式,也是追求性能和稳定这两个目标的妥协方案。
(2)使用传统的synchronized进行互斥,这种方式的性能稍微逊色一些,通过传入JVM参数-Dvesta. sync.lock.impl.key=true来开启。
(3)使用concurrent包的原子变量进行互斥,这种实现方式的性能非常高,但是在高并发
机器ID的分配
我们将机器ID分为两个区段,一个区段服务于中心服务器发布模式和REST发布模式,另一个区段服务于嵌入发布模式。
- 0-923:嵌入发布模式,预先配置机器ID,最多支持924台内嵌服务器。
- 924-1023:中心服务器发布模式和REST发布模式,最多支持100台,最大支持100x1万/s即100万/s的TPS。
如果嵌入式发布模式、中心服务器发布模式及REST发布模式的使用量不符合这个比例,则我们可以动态调整西个区间的值来适应。
另外,各个垂直业务之间具有天生的隔离性,每个业务都可以使用最多1024台服务器。我们实现了3种机器ID的分配方式。
- 通过共享数据库的方式为发号器服务池中的每个节点生成唯一的机器 ID,这适合服务池中节点比较多的情况。
- 通过配置发号器服务池中每个节点的IP的方式确定每个节点的机器ID,这适合服务池中节点比较少的情况。
- 在Spring配置文件中直接配置每个节点的机器ID,这适合测试时使用。
如果有兴趣,则可以自已实现以ZooKeeper为基础的机器ID的生成器,这也是一种比较合理的实现方式。
时间同步
运行发号器的服务器需要保证时间的正确性,这里使用Linux的定时任务crontab,周期性地通过时间服务器虚拟集群(全球有3000多台服务器)来核准服务器的时间:
ntpdate -u pool ntp.orgpool.ntp.org
其中,时间的变动对发号器的影响如下。
(1)调整时间是否会影响ID的产生?
- 未重启机器调慢时间,Vesta 抛出异常,拒绝产生ID。重启机器调快时间,调整后正常产生ID,在调整时段内没有ID产生。
- 重启机器调慢时间,Vesta将可能产生重复的ID,系统管理员需要保证不会发生这种情况。重启机器并调快时间,调整后正常产生ID,在调整时段内没有ID产生。
(2)每4年一次同步润秒会不会影响ID的产生?
- 原子时钟和电子时钟每 4年的误差为1秒,也就是说电子时钟每4年会比原子时钟慢1秒,所以,每隔4年,网络时钟都会同步一次时间,但是本地机器Windows、Linux 等不会自动同步时间,需要手工同步,或者使用ntpdate向网络时钟同步。
- 由于时钟是调快1秒的,调整后不影响ID的产生,所以在调整的1秒内没有ID产生。
设计验证
(1)根据不同的信息分段构建一个ID,使ID具有全局唯一、 可反解和可制造性等特性。
(2)使用秒级别时间或者毫秒级别时间及时间单元内部序列递增的方法保证ID粗略有序。
(3)对于中心服务器发布模式和REST发布模式,我们使用多线程处理。为了减少多线程间的竞争,我们对竞争点time和sequence使用ReentrantLock来进行互斥,由于ReentrantLock内部使用了CAS,比JVM的synchronized关键字性能更好,所以在千兆网卡的前提下,至少可达到1万/s的TPS。
(4)由于我们支持中心服务器发布模式、嵌入式发布模式和REST发布模式,所以如果某种模式不可用,就可以回退到其他发布模式;对于生成机器ID,如果基于数据库的方式不可用,就可以回退到使用本地预配的机器ID,从而达到服务的最大可用。
(5)由于ID的设计,我们最大支持1024台服务器,将服务器的机器号分为两个区段,一个从0开始向上,一个从1024开始向下,并且能够动态调整分界线,满足了可伸缩性。
由于平台篇幅限制,同时为了大家更好的阅读,内容不便过多,有感兴趣的可以持续关注博主,以便更新后第一时间看到