本文欢迎转载,转载请注明原文链接,并附作者个人信息李艳鹏。
身份和身份证在《分布式服务化系统一致性的“最佳实干”》一文中提出了保证系统最终一致性的定期校对模式,在定期校对模式中最常使用的方法是在每个系统间传递和保存一个统一的唯一流水号(或称为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性能压测实践报告
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位
字段 |
版本 |
类型 |
生成方式 |
秒级时间 |
序列号 |
机器ID |
---|---|---|---|---|---|---|
位数 | 63 | 62 | 60-61 | 40-59 | 10-39 | 0-9 |
2). 最小粒度型:采用毫秒级有序,毫秒级时间占用40位,序列号占用10位
字段 |
版本 |
类型 |
生成方式 |
毫秒级时间 |
序列号 |
机器ID |
---|---|---|---|---|---|---|
位数 | 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开始向下,并且能够动态调整分界线,满足了可伸缩性。
2 如何保证性能需求
一款软件的发布必须保证满足性能需求,这通常需要在项目初期提出性能需求,在项目进行中做性能测试来验证,请参考本文末尾的源码连接下载源代码,查看性能测试用例,本章节只讨论性能需求和测试结果,以及改进点。
2.1 性能需求
最终的性能验证要保证每台服务器的TPS达到1万/s以上。
2.2 测试环境
笔记本,客户端服务器跑在同一台机器
双核2.4G I3 CPU, 4G内存
2.3 嵌入发布模式压测结果
设置:
并发数:100
测试结果:
测试 | 测试1 | 测试2 | 测试3 | 测试4 | 测试5 | 平均值/最大值 |
---|---|---|---|---|---|---|
QPS | 431000 | 445000 | 442000 | 434000 | 434000 | 437200 |
平均时间(us) | 161 | 160 | 168 | 143 | 157 | 157 |
最大响应时间(ms) | 339 | 304 | 378 | 303 | 299 | 378 |
2.4 中心服务器发布模式压测结果
设置:
并发数:100
测试结果:
测试 | 测试1 | 测试2 | 测试3 | 测试4 | 测试5 | 平均值/最大值 |
---|---|---|---|---|---|---|
QPS | 1737 | 1410 | 1474 | 1372 | 1474 | 1493 |
平均时间(us) | 55 | 67 | 66 | 68 | 65 | 64 |
最大响应时间(ms) | 785 | 952 | 532 | 1129 | 1036 | 1129 |
2.5 REST发布模式(Netty实现)压测结果
设置:
并发数:100
Boss线程数:1
Workder线程数:4
测试结果:
测试 | 测试1 | 测试2 | 测试3 | 测试4 | 测试5 | 平均值/最大值 |
---|---|---|---|---|---|---|
QPS | 11001 | 10611 | 9788 | 11251 | 10301 | 10590 |
平均时间(ms) | 11 | 11 | 11 | 10 | 10 | 11 |
最大响应时间(ms) | 25 | 21 | 23 | 21 | 21 | 25 |
2.6 REST发布模式(Spring Boot + Tomcat)压测结果
设置:
并发数:100
Boss线程数:1
Workder线程数:2
Exececutor线程数:最小25最大200
测试结果:
测试 | 测试1 | 测试2 | 测试3 | 测试4 | 测试5 | 平均值/最大值 |
---|---|---|---|---|---|---|
QPS | 4994 | 5104 | 5223 | 5108 | 5100 | 5105 |
平均时间(ms) | 20 | 19 | 19 | 19 | 19 | 19 |
最大响应时间(ms) | 75 | 61 | 61 | 61 | 67 | 75 |
2.7 性能测试总结
- 根据测试,Netty服务可到达11000的QPS,而Tomcat只能答道5000左右的QPS。
- 嵌入发布模式,也就是JVM内部调用最快,没秒可答道40万以上。可见线上服务的瓶颈在网络IO以及网络IO的处理上。
- 使用Dubbo导入导出的中心服务器发布模式的QPS只有不到2000, 这比Tomcat提供的HTTP服务的QPS还要小,这个不符合常理,一方面需要查看是否Dubbo RPC需要优化,包括线程池策略,序列化协议,通信协议等,另外一方面REST使用apache ab测试,嵌入式发布模式使用自己写的客户端测试,是否测试工具存在一定的差异。
- 测试过程中发现loopback虚拟网卡达到30+M的流量,没有到达千兆网卡的极限,双核心CPU占用率已经接近200%,也就是CPU已经到达瓶颈。
参考上面总结第三条,中心服务器的性能问题需要在后期版本跟进和优化。
3 如何快速使用
Vesta多场景分布式发号器支持嵌入发布模式、中心服务器发布模式、REST发布模式,每种发布 模式的API文档以及使用向导可参项目主页的文档连接。
3.1 安装与启动
1. 下载最新版本的REST发布模式的发布包
-
点击下载:
-
如果你通过源代码方式安装Vesta的发布包到你的Maven私服,你可以直接从你的Maven私服下载此安装包:
2. 解压发布包到任意目录
-
解压:
tar xzvf vesta-rest-netty-0.0.1-bin.tar.gz
3. 解压后更改属性文件
-
属性文件:
vesta-rest-netty-0.0.1/conf/vesta-rest-netty.properties
-
文件内容:
vesta.machine=1022
vesta.genMethod=2
vesta.type=0注意:
- 机器ID为1022, 如果你有多台机器,递减机器ID,同一服务中机器ID不能重复。
- genMethod为2表示使用嵌入发布模式
- type为0, 表示最大峰值型,如果想要使用最小粒度型,则设置为1
4. REST发布模式的默认端口为8088,你可以通过更改启动文件来更改端口号,这里以10010为例
-
启动文件:
vesta-rest-netty/target/vesta-rest-netty-0.0.1/bin/server.sh
-
文件内容:
port=10010
5. 修改启动脚本,并且赋予执行权限
-
进入目录:
cd vesta-rest-netty-0.0.1/bin
-
执行命令:
chmod 755 *
6. 启动服务
-
进入目录:
cd vesta-rest-netty-0.0.1/bin
-
执行命令:
./start.sh
7. 如果看到如下消息,服务启动成功
-
输出:
apppath: /home/robert/vesta/vesta-rest-netty-0.0.1
Vesta Rest Netty Server is started.
3.2 测试Rest服务
1. 通过URL访问产生一个ID
-
命令:
-
结果:
1138729511026688
2. 把产生的ID进行反解
-
命令:
-
结果:
{"genMethod":0,"machine":1,"seq":0,"time":12235264,"type":0,"version":0}
JSON字符串显示的是反解的ID的各个组成部分的数值。
3. 对产生的日期进行反解
-
命令:
-
结果:
Fri May 22 14:41:04 CST 2015
4. 使用反解的数据伪造ID
-
命令:
-
结果:
1138729511026688
4 总结思考
发号器作为分布式服务化系统不可或缺的基础设施之一,它在保证系统正确运行和高可用上发挥着不可替代的作用。而本文介绍了一款原创开源的多场景分布式发号器Vesta,并介绍了Vesta的设计、实现、以及使用方式,读者在现实项目中可以直接使用它的任何发布模式,既装既用,读者也可以借鉴其中的设计思路和思想,开发自己的分布式发号器,除了发号器本身,本文按照一款开源项目的生命周期构思文章结果,从设计、实现、验证到使用向导,以及论述遗留的问题等,并提供了参考的开源实现,帮助读者学习如何创建一款平台类软件的过程的思路,帮助读者在技术的道路上发展越来越好。
在《分布式服务化系统一致性的“最佳实干”》一文中提到全局的唯一流水ID可以把一个请求在分布式系统中流转的路径聚合,而调用链中的spanid可以把聚合的请求路径通过树形结构进行展示,让技术支持人员轻松的发现系统出现的问题,能够快速定位出现问题的服务节点,提高应急效率,下一篇《如何设计一款分布式服务化调用链追踪》
5 了解更多
《分布式服务架构:原理、设计与实战》是一本不可多得的理论与实践相结合的架构秘籍,京东购买请点这里或者扫描下方二维码。
《分布式服务架构:原理、设计与实战》京东主页加入【云时代架构】技术社区,做互联网时代最适合的架构,回归架构的简洁之美。
作者简书博客云时代架构
网友评论
vesta.cloudate.net/genid
字段 版本 类型 生成方式 毫秒级时间 序列号 机器ID
位数 63 62 60-61 20-59 10-19 0-9
我想,是不是把 毫秒级时间 放在从63开始的位上更合适呢,就是说把时间放在最前面,后面放版本,类型等等,这样就天然具备时间先后顺序了,当然这可能是具体业务的需求
欢迎订阅《精益编程》https://toutiao.io/subject/38960