在互联网的世界里,产生唯一流水号的服务系统俗称发号器。Twitter的Snowflake是一个流行的开源的发号器的实现。Slowfake是由Scala语言实现的,并且文档简单、发布模式单一、缺少支持和维护,很难在现实的项目中直接使用。
为了能让Java领域的小伙伴们在不同的环境下快速使用发号器服务,本文向大家推荐一款自主研发的多场景分布式发号器Vesta,这是由Java语言编写的,可以通过Jar包的形式嵌入到任何Java开发的项目中,也可以通过服务化或者REST服务发布,发布样式灵活多样,使用简单、方便、高效。
Vesta是一款通用的唯一流水号产生器,它具有全局唯一、粗略有序、可反解和可制造等特性,它支持三种发布模式:嵌入发布模式、中心服务器发布模式、REST发布模式,根据业务的性能需求,它可以产生最大峰值型和最小粒度型两种类型的ID,它的实现架构使其具有高性能,高可用和可伸缩等互联网产品需要的质量属性,是一款通用的高性能的发号器产品。
本文聚焦在笔者原创的多场景分布式发号器Vesta的设计、实现、性能评估等方面,同时介绍Vesta的发布模式以及使用方式,并在最后给读者介绍如何在你的项目中使用Vesta。
1 如何思考和设计
1.1 当前遇到的问题
当前业务系统的ID使用数据库的自增字段,自增字段完全依赖于数据库,这在数据库移植,扩容,洗数据,分库分表等操作时带来了很多麻烦。
在数据库分库分表时,有一种办法是通过调整自增字段或者数据库sequence的步长来达到跨数据库的ID的唯一性,但仍然是一种强依赖数据库的解决方案,有诸多的限制,并且强依赖数据库类型,我们并不推荐这种方法。
1.2 为什么不用UUID
UUID虽然能够保证ID的唯一性,但是,它无法满足业务系统需要的很多其他特性,例如:时间粗略有序性,可反解和可制造型。另外,UUID产生的时候使用完全的时间数据,性能比较差,并且UUID比较长,占用空间大,间接导致数据库性能下降,更重要的是,UUID并不具有有序性,这导致B+树索引在写的时候会有过多的随机写操作(连续的ID会产生部分顺序写),另外写的时候由于不能产生顺序的append操作,需要进行insert操作,这会读取整个B+树节点到内存,然后插入这条记录后写整个节点回磁盘,这种操作在记录占用空间比较大的情况下,性能下降比较大,具体压测报告请参考:Mysql性能压测实践报告
1.3 需求分析和整理
既然数据库自增ID和UUID有诸多的限制,我们需要整理一下发号器的需求。
1. 全局唯一
有些业务系统可以使用相对小范围的唯一性,例如,如果用户是唯一的,那么同一用户的订单采用自增序列在用户范围内也是唯一的,但是如果这样设计,订单系统就会在逻辑上依赖用户系统,因此,不如我们保证ID在系统范围内的全局唯一性更实用。
分布式系统保证全局唯一的一个悲观策略是使用锁或者分布式锁,但是,只要使用了锁,就会大大的降低性能。
因此,我们决定利用时间的有序性,并且在时间的某个单元下采用自增序列,达到全局的唯一性。
2. 粗略有序
上面讨论了UUID的最大问题就是无序的,任何业务都希望生成的ID是有序的,但是,分布式系统中要做到完全有序,就涉及到数据的汇聚,当然要用到锁或者布式锁,考虑到效率,只能采用折中的方案,粗略有序,到底有多粗略,目前有两种主流的方案,一种是秒级有序,一种是毫秒级有序,这里又有一个权衡和取舍,我们决定支持两种方式,通过配置来决定服务使用其中的一种方式。
3. 可反解
一个 ID 生成之后,ID本身带有很多信息量,线上排查的时候,我们通常首先看到的是ID,如果根据ID就能知道什么时候产生的,从哪里来的,这样一个可反解的 ID 可以帮上很多忙。
如果ID 里有了时间而且能反解,在存储层面就会省下很多传统的timestamp 一类的字段所占用的空间了,这也是一举两得的设计。
4. 可制造
一个系统即使再高可用也不会保证永远不出问题,出了问题怎么办,手工处理,数据被污染怎么办,洗数据,可是手工处理或者洗数据的时候,假如使用数据库自增字段,ID已经被后来的业务覆盖了,怎么恢复到系统出问题的时间窗口呢?
所以,我们使用的发号器一定要可复制,可恢复 ,可制造。
5. 高性能
不管哪个业务,订单也好,商品也好,如果有新记录插入,那一定是业务的核心功能,对性能的要求非常高,ID生成取决于网络IO和CPU的性能,CPU一般不是瓶颈,根据经验,单台机器TPS应该达到10000/s。
6. 高可用
首先,发号器必须是一个对等的集群,一台机器挂掉,请求必须能够转发到其他机器,另外,重试机制也是必不可少的。最后,如果远程服务宕机,我们需要有本地的容错方案,本地库的依赖方式可以作为高可用的最后一道屏障。
7. 可伸缩
作为一个分布式系统,永远都不能忽略的就是业务在不断地增长,业务的绝对容量不是衡量一个系统的唯一标准,要知道业务是永远增长的,所以,系统设计不但要考虑能承受的绝对容量,还必须考虑业务增长的速度,系统的水平伸缩是否能满足业务的增长速度是衡量一个系统的另一个重要标准。
1.4 设计与实现
1. 发布模式
根据最终的客户使用方式,可分为嵌入发布模式,中心服务器发布模式和REST发布模式。
- 嵌入发布模式:只适用于Java客户端,提供一个本地的Jar包,Jar包是嵌入式的原生服务,需要提前配置本地机器ID(或者服务启动时候Zookeeper动态分配唯一的ID,在第二版中实现),但是不依赖于中心服务器。
- 中心服务器发布模式:只适用于Java客户端,提供一个服务的客户端Jar包,Java程序像调用本地API一样来调用,但是依赖于中心的ID产生服务器。
- REST发布模式:中心服务器通过Restful API导出服务,供非Java语言客户端使用。
发布模式最后会记录在生成的ID中。也参考下面数据结构段的发布模式相关细节。
2. ID类型
根据时间的位数和序列号的位数,可分为最大峰值型和最小粒度型。
1). 最大峰值型:采用秒级有序,秒级时间占用30位,序列号占用20位
|
|
|
|
|
|
|
位数 | 63 | 62 | 60-61 | 40-59 | 10-39 | 0-9 |
2). 最小粒度型:采用毫秒级有序,毫秒级时间占用40位,序列号占用10位
|
|
|
|
|
|
|
位数 | 63 | 62 | 60-61 | 20-59 | 10-19 | 0-9 |
最大峰值型能够承受更大的峰值压力,但是粗略有序的粒度有点大,最小粒度型有较细致的粒度,但是每个毫秒能承受的理论峰值有限,为1k,同一个毫秒如果有更多的请求产生,必须等到下一个毫秒再响应。
ID类型在配置时指定,需要重启服务才能互相切换。
3. 数据结构
1). 机器ID
10位, 2^10=1024, 也就是最多支持1000+个服务器。中心发布模式和REST发布模式一般不会有太多数量的机器,按照设计每台机器TPS 1万/s,10台服务器就可以有10万/s的TPS,基本可以满足大部分的业务需求。
但是考虑到我们在业务服务可以使用内嵌发布方式,对机器ID的需求量变得更大,这里最多支持1024个服务器。
2). 序列号
最大峰值型
20位,理论上每秒内平均可产生2^20= 1048576个ID,百万级别,如果系统的网络IO和CPU足够强大,可承受的峰值达到每毫秒百万级别。
最小粒度型
10位,每毫秒内序列号总计2^10=1024个, 也就是每个毫秒最多产生1000+个ID,理论上承受的峰值完全不如我们最大峰值方案。
3). 秒级时间/毫秒级时间
最大峰值型
30位,表示秒级时间,2^30/60/60/24/365=34,也就是可使用30+年。
最小粒度型
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年。
4. 并发
对于中心服务器和REST发布方式,ID生成的过程涉及到网络IO和CPU操作,ID的生成基本都是内存到高速缓存的操作,没有IO操作,网络IO是系统的瓶颈。
相对于CPU计算速度来说网络IO是瓶颈,因此,ID产生的服务使用多线程的方式,对于ID生成过程中的竞争点time和sequence,我们使用concurrent包的ReentrantLock进行互斥。
5. 机器ID的分配
我们将机器ID分为两个区段,一个区段服务于中心服务器发布模式和REST发布模式,另外一个区段服务于嵌入发布模式。
0-923:嵌入发布模式,预先配置,(或者由Zookeeper产生,第二版中实现),最多支持924台内嵌服务器
924 – 1023:中心服务器发布模式和REST发布模式,最多支持300台,最大支持300*1万=300万/s的TPS
如果嵌入式发布模式和中心服务器发布模式以及REST发布模式的使用量不符合这个比例,我们可以动态调整两个区间的值来适应。
另外,各个垂直业务之间具有天生的隔离性,每个业务都可以使用最多1024台服务器。
6. 与Zookeeper集成
对于嵌入发布模式,服务启动需要连接Zookeeper集群,Zookeeper分配一个0-923区间的一个ID,如果0-923区间的ID被用光,Zookeeper会分配一个大于923的ID,这种情况,拒绝启动服务。
如果不想使用Zookeeper产生的唯一的机器ID,我们提供缺省的预配的机器ID解决方案,每个使用统一发号器的服务需要预先配置一个默认的机器ID。
注:此功能在第二版中实现。
7. 时间同步
使用Linux的定时任务crontab,定时通过授时服务器虚拟集群(全球有3000多台服务器)来核准服务器的时间。
ntpdate -u pool.ntp.orgpool.ntp.org
时间相关的影响以及思考:
- 调整时间是否会影响ID产生功能?
1). 未重启机器调慢时间,Vesta抛出异常,拒绝产生ID。重启机器调快时间,调整后正常产生ID,调整时段内没有ID产生。
2). 重启机器调慢时间,Vesta将可能产生重复的时间,系统管理员需要保证不会发生这种情况。重启机器调快时间,调整后正常产生ID,调整时段内没有ID产生。
- 每4年一次同步润秒会不会影响ID产生功能?
1). 原子时钟和电子时钟每四年误差为1秒,也就是说电子时钟每4年会比原子时钟慢1秒,所以,每隔四年,网络时钟都会同步一次时间,但是本地机器Windows,Linux等不会自动同步时间,需要手工同步,或者使用ntpupdate向网络时钟同步。
2). 由于时钟是调快1秒,调整后不影响ID产生,调整的1s内没有ID产生。
8. 设计验证
- 我们根据不同的信息分段构建一个ID,使ID具有全局唯一,可反解和可制造。
- 我们使用秒级别时间或者毫秒级别时间以及时间单元内部序列递增的方法保证ID粗略有序。
- 对于中心服务器发布模式和REST发布模式,我们使用多线程处理,为了减少多线程间竞争,我们对竞争点time和sequence使用ReentrantLock来进行互斥,由于ReentrantLock内部使用CAS,这比JVM的Synchronized关键字性能更好,在千兆网卡的前提下,至少可达到1万/s以上的TPS。
- 由于我们支持中心服务器发布模式,嵌入式发布模式和REST发布模式,如果某种模式不可用,可以回退到其他发布模式,如果Zookeeper不可用,可以会退到使用本地预配的机器ID。从而达到服务的最大可用。
- 由于ID的设计,我们最大支持1024台服务器,我们将服务器机器号分为两个区段,一个从0开始向上,一个从128开始向下,并且能够动态调整分界线,满足了可伸缩性。