本文来自作者:
李艳鹏
在 GitChat 上的分享
点击文末「
阅读原文
」这场 Chat 看看大家与作者交流了哪些问题
前言
在之前
《支付平台架构师谈大规模高并发服务化系统设计经验》
一文中提出了保证系统最终一致性的定期校对模式,在定期校对模式中最常使用的方法是在每个系统间传递和保存一个统一的唯一流水号(或称为traceid),通过系统间两两核对或者第三方统一核对唯一流水号来保证各个系统之间步伐一致、没有掉队的行为,也就是系统间状态一致。
在互联网的世界里,产生唯一流水号的服务系统俗称发号器。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 性能压测实践报告
(http://cloudate.net/?p=1632)
。
1.3 需求分析和整理
既然数据库自增ID和 UUID 有诸多的限制,我们需要整理一下发号器的需求。
1.3.1 全局唯一
有些业务系统可以使用相对小范围的唯一性,例如,如果用户是唯一的,那么同一用户的订单采用自增序列在用户范围内也是唯一的,但是如果这样设计,订单系统就会在逻辑上依赖用户系统,因此,不如我们保证ID在系统范围内的全局唯一性更实用。
分布式系统保证全局唯一的一个悲观策略是使用锁或者分布式锁,但是,只要使用了锁,就会大大的降低性能。
因此,我们决定利用时间的有序性,并且在时间的某个单元下采用自增序列,达到全局的唯一性。
1.3.2 粗略有序
上面讨论了 UUID 的最大问题就是无序的,任何业务都希望生成的 ID 是有序的,但是,分布式系统中要做到完全有序,就涉及到数据的汇聚,当然要用到锁或者布式锁,考虑到效率,只能采用折中的方案,粗略有序,到底有多粗略。
目前有两种主流的方案,一种是秒级有序,一种是毫秒级有序,这里又有一个权衡和取舍,我们决定支持两种方式,通过配置来决定服务使用其中的一种方式。
1.3.3 可反解
一个ID生成之后,ID本身带有很多信息量,线上排查的时候,我们通常首先看到的是ID,如果根据ID就能知道什么时候产生的,从哪里来的,这样一个可反解的ID可以帮上很多忙。
如果ID里有了时间而且能反解,在存储层面就会省下很多传统的 timestamp 一类的字段所占用的空间了,这也是一举两得的设计。
1.3.4 可制造
一个系统即使再高可用也不会保证永远不出问题,出了问题怎么办,手工处理,数据被污染怎么办,洗数据,可是手工处理或者洗数据的时候,假如使用数据库自增字段,ID已经被后来的业务覆盖了,怎么恢复到系统出问题的时间窗口呢?
所以,我们使用的发号器一定要可复制,可恢复 ,可制造。
1.3.5 高性能
不管哪个业务,订单也好,商品也好,如果有新记录插入,那一定是业务的核心功能,对性能的要求非常高,ID生成取决于网络IO和CPU的性能,CPU 一般不是瓶颈,根据经验,单台机器 TPS 应该达到 10000/s。
1.3.6 高可用
首先,发号器必须是一个对等的集群,一台机器挂掉,请求必须能够转发到其他机器,另外,重试机制也是必不可少的。最后,如果远程服务宕机,我们需要有本地的容错方案,本地库的依赖方式可以作为高可用的最后一道屏障。
1.3.7 可伸缩
作为一个分布式系统,永远都不能忽略的就是业务在不断地增长,业务的绝对容量不是衡量一个系统的唯一标准,要知道业务是永远增长的,所以,系统设计不但要考虑能承受的绝对容量,还必须考虑业务增长的速度,系统的水平伸缩是否能满足业务的增长速度是衡量一个系统的另一个重要标准。
1.4 设计与实现
1.4.1 发布模式
根据最终的客户使用方式,可分为嵌入发布模式,中心服务器发布模式和 REST 发布模式。
-
嵌入发布模式:只适用于 Java 客户端,提供一个本地的 Jar 包,Jar 包是嵌入式的原生服务,需要提前配置本地机器ID(或者服务启动时候 Zookeeper 动态分配唯一的 ID ,在第二版中实现),但是不依赖于中心服务器。
-
中心服务器发布模式:只适用于 Java 客户端,提供一个服务的客户端 Jar 包,Java 程序像调用本地 API 一样来调用,但是依赖于中心的ID产生服务器。
-
REST 发布模式:中心服务器通过 Restful API 导出服务,供非 Java 语言客户端使用。
发布模式最后会记录在生成的ID中。也参考下面数据结构段的发布模式相关细节。
1.4.2 ID类型
根据时间的位数和序列号的位数,可分为最大峰值型和最小粒度型。
1)最大峰值型:采用秒级有序,秒级时间占用30位,序列号占用20位
2)最小粒度型:采用毫秒级有序,毫秒级时间占用40位,序列号占用10位
最大峰值型能够承受更大的峰值压力,但是粗略有序的粒度有点大,最小粒度型有较细致的粒度,但是每个毫秒能承受的理论峰值有限,为1k,同一个毫秒如果有更多的请求产生,必须等到下一个毫秒再响应。
ID类型在配置时指定,需要重启服务才能互相切换。
1.4.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年。
1.4.4 并发
对于中心服务器和REST发布方式,ID生成的过程涉及到网络IO和CPU操作,ID的生成基本都是内存到高速缓存的操作,没有IO操作,网络IO是系统的瓶颈。
相对于 CPU 计算速度来说网络IO是瓶颈,因此,ID产生的服务使用多线程的方式,对于ID生成过程中的竞争点 time 和 sequence,我们使用 concurrent 包的 ReentrantLock 进行互斥。
1.4.5 机器ID的分配
我们将机器ID分为两个区段,一个区段服务于中心服务器发布模式和 REST 发布模式,另外一个区段服务于嵌入发布模式。
0-923:嵌入发布模式,预先配置,(或者由 Zookeeper 产生,第二版中实现),最多支持924台内嵌服务器
924 – 1023:中心服务器发布模式和REST发布模式,最多支持300台,最大支持300*1万=300万/s的TPS
如果嵌入式发布模式和中心服务器发布模式以及 REST 发布模式的使用量不符合这个比例,我们可以动态调整两个区间的值来适应。
另外,各个垂直业务之间具有天生的隔离性,每个业务都可以使用最多1024台服务器。
1.4.6 与Zookeeper集成
对于嵌入发布模式,服务启动需要连接 Zookeeper 集群,Zookeeper 分配一个0-923区间的一个ID,如果0-923区间的ID被用光,Zookeeper 会分配一个大于923的ID,这种情况,拒绝启动服务。
如果不想使用 Zookeeper 产生的唯一的机器ID,我们提供缺省的预配的机器ID解决方案,每个使用统一发号器的服务需要预先配置一个默认的机器ID。
注:此功能在第二版中实现。
1.4.7 时间同步
使用 Linux 的定时任务 crontab,定时通过授时服务器虚拟集群(全球有3000多台服务器)来核准服务器的时间。
ntpdate -u pool.ntp.orgpool.ntp.org
时间相关的影响以及思考:
1). 未重启机器调慢时间,Vesta 抛出异常,拒绝产生ID。重启机器调快时间,调整后正常产生ID,调整时段内没有ID产生。
2). 重启机器调慢时间,Vesta 将可能产生重复的时间,系统管理员需要保证不会发生这种情况。重启机器调快时间,调整后正常产生ID,调整时段内没有ID产生。
1). 原子时钟和电子时钟每四年误差为1秒,也就是说电子时钟每4年会比原子时钟慢1秒,所以,每隔四年,网络时钟都会同步一次时间,但是本地机器 Windows,Linux 等不会自动同步时间,需要手工同步,或者使用 ntpupdate 向网络时钟同步。
2). 由于时钟是调快1秒,调整后不影响ID产生,调整的1s内没有ID产生。
1.4.8 设计验证
-
我们根据不同的信息分段构建一个ID,使ID具有全局唯一,可反解和可制造。
-
我们使用秒级别时间或者毫秒级别时间以及时间单元内部序列递增的方法保证ID粗略有序。
-
对于中心服务器发布模式和 REST 发布模式,我们使用多线程处理,为了减少多线程间竞争,我们对竞争点 time 和 sequence 使用 ReentrantLock 来进行互斥,由于 ReentrantLock 内部使用 CAS,这比 JVM 的 Synchronized 关键字性能更好,在千兆网卡的前提下,至少可达到1万/s以上的TPS。
-
由于我们支持中心服务器发布模式,嵌入式发布模式和 REST 发布模式,如果某种模式不可用,可以回退到其他发布模式,如果 Zookeeper 不可用,可以会退到使用本地预配的机器ID。从而达到服务的最大可用。
-
由于ID的设计,我们最大支持1024台服务器,我们将服务器机器号分为两个区段,一个从0开始向上,一个从128开始向下,并且能够动态调整分界线,满足了可伸缩性。
2、如何保证性能需求
一款软件的发布必须保证满足性能需求,这通常需要在项目初期提出性能需求,在项目进行中做性能测试来验证,请参考本文末尾的源码连接下载源代码,查看性能测试用例,本章节只讨论性能需求和测试结果,以及改进点。
2.1 性能需求
最终的性能验证要保证每台服务器的 TPS 达到1万/s以上。
2.2 测试环境
笔记本,客户端服务器跑在同一台机器
双核2.4G I3 CPU, 4G内存
2.3 嵌入发布模式压测结果
设置:
并发数:100
测试结果:
2.4 中心服务器发布模式压测结果
设置:
并发数:100
测试结果:
2.5 REST发布模式(Netty实现)压测结果
设置:
并发数:100
Boss线程数:1
Workder线程数:4
测试结果:
2.6 REST发布模式(Spring Boot + Tomcat)压测结果
设置:
并发数:100
Boss 线程数:1
Workder 线程数:2
Exececutor 线程数:最小25最大200
测试结果:
2.7 性能测试总结
根据测试,Netty 服务可到达11000的QPS,而 Tomcat 只能答道5000左右的 QPS。
嵌入发布模式,也就是 JVM 内部调用最快,没秒可答道40万以上。可见线上服务的瓶颈在网络IO以及网络IO的处理上。