之前在公司里维护了一个名字服务,这个名字服务日常管理了近4000台机器,有4000个左右的客户端连接上来获取机器信息,由于其基本是一个单点服务,所以某些模块接近瓶颈。后来倒是有重构计划,详细设计做了,代码都写了一部分,结果由于某些原因重构就被终止了。
JCM是我业余时间用Java重写的一个版本,功能上目前只实现了基础功能。由于它是个完全分布式的架构,所以理论上可以横向扩展,大大增强系统的服务能力。
名字服务
在分布式系统中,某个服务为了提升整体服务能力,通常部署了很多实例。这里我把这些提供相同服务的实例统称为集群(cluster
),每个实例称为一个节点(Node
)。一个应用可能会使用很多cluster,每次访问一个cluster时,就通过名字服务获取该cluster下一个可用的node。那么,名字服务至少需要包含的功能:
- 根据cluster名字获取可用的node
- 对管理的所有cluster下的所有node进行健康度的检测,以保证始终返回可用的node
有些名字服务仅对node管理,不参与应用与node间的通信,而有些则可能作为应用与node间的通信转发器。虽然名字服务功能简单,但是要做一个分布式的名字服务还是比较复杂的,因为数据一旦分布式了,就会存在同步、一致性问题的考虑等。
What’s JCM
JCM围绕前面说的名字服务基础功能实现。包含的功能:
- 管理cluster到node的映射
- 分布式架构,可水平扩展以实现管理10,000个node的能力,足以管理一般公司的后台服务集群
- 对每个node进行健康检查,健康检查可基于HTTP协议层的检测或TCP连接检测
- 持久化cluster/node数据,通过zookeeper保证数据一致性
- 提供JSON HTTP API管理cluster/node数据,后续可提供Web管理系统
- 以库的形式提供与server的交互,库本身提供各种负载均衡策略,保证对一个cluster下node的访问达到负载均衡
项目地址git jcm
JCM主要包含两部分:
- jcm.server,JCM名字服务,需要连接zookeeper以持久化数据
- jcm.subscriber,客户端库,负责与jcm.server交互,提供包装了负载均衡的API给应用使用
架构
基于JCM的系统整体架构如下:
cluster本身是不需要依赖JCM的,要通过JCM使用这些cluster,只需要通过JCM HTTP API注册这些cluster到jcm.server上。要通过jcm.server使用这些cluster,则是通过jcm.subscriber来完成。
使用
可参考git READMe.md
需要jre1.7+
- 启动zookeeper
- 下载jcm.server git jcm.server-0.1.0.jar
- 在
jcm.server-0.1.0.jar
目录下建立config/application.properties
文件进行配置,参考config/application.properties
-
启动jcm.server
java -jar jcm.server-0.1.0.jar
-
注册需要管理的集群,参考cluster描述:doc/cluster_sample.json,通过HTTP API注册:
curl -i -X POST http://10.181.97.106:8080/c -H "Content-Type:application/json" --data-binary @./doc/cluster_sample.json
部署好了jcm.server,并注册了cluster后,就可以通过jcm.subscriber使用:
// 传入需要使用的集群名hello9/hello,以及传入jcm.server地址,可以多个:127.0.0.1:8080
Subscriber subscriber = new Subscriber( Arrays.asList("127.0.0.1:8080"), Arrays.asList("hello9", "hello"));
// 使用轮询负载均衡策略
RRAllocator rr = new RRAllocator();
subscriber.addListener(rr);
subscriber.startup();
for (int i = 0; i < 2; ++i) {
// rr.alloc 根据cluster名字获取可用的node
System.out.println(rr.alloc("hello9", ProtoType.HTTP));
}
subscriber.shutdown();
JCM实现
JCM目前的实现比较简单,参考模块图:
- model,即cluster/node这些数据结构的描述,同时被jcm.server和jcm.subscriber依赖
- storage,持久化数据到zookeeper,同时包含jcm.server实例之间的数据同步
- health check,健康检查模块,对各个node进行健康检查
以上模块都不依赖Spring,基于以上模块又有:
- http api,使用spring-mvc,包装了一些JSON HTTP API
- Application,基于spring-boot,将各个基础模块组装起来,提供standalone的模式启动,不用部署到tomcat之类的servlet容器中
jcm.subscriber的实现更简单,主要是负责与jcm.server进行通信,以更新自己当前的model层数据,同时提供各种负载均衡策略接口:
- subscriber,与jcm.server通信,定期增量拉取数据
- node allocator,通过listener方式从subscriber中获取数据,同时实现各种负载均衡策略,对外统一提供
alloc node
的接口
接下来看看关键功能的实现
数据同步
既然jcm.server是分布式的,每一个jcm.server instance(实例)都是支持数据读和写的,那么当jcm.server管理着一堆cluster上万个node时,每一个instance是如何进行数据同步的?jcm.server中的数据主要有两类:
- cluster本身的数据,包括cluster/node的描述,例如cluster name、node IP、及其他附属数据
- node健康检查的数据
对于cluster数据,因为cluster对node的管理是一个两层的树状结构,而对cluster有增删node的操作,所以并不能在每一个instance上都提供真正的数据写入,这样会导致数据丢失。假设同一时刻在instance A和instance B上同时对cluster c1添加节点N1和N2,那么instance A写入c1(N1),而instance B还没等到数据同步就写入c1(N2),那么c1(N1)就被覆盖为c1(N2),从而导致添加的节点N1丢失。
所以,jcm.server instance是分为leader
和follower
的,真正的写入操作只有leader进行,follower收到写操作请求时转发给leader。leader写数据优先更新内存中的数据再写入zookeeper,内存中的数据更新当然是需要加锁互斥的,从而保证数据的正确性。
leader和follower是如何确定角色的?这个很简单,标准的利用zookeeper来进行主从选举的实现。
jcm.server instance数据间的同步是基于zookeeper watch机制的。这个可以算做是一个JCM的一个瓶颈,每一个instance都会作为一个watch,使得实际上jcm.server并不能无限水平扩展,扩展到一定程度后,watch的效率就可能不足以满足性能了,参考zookeeper节点数与watch的性能测试 (那个时候我就在考虑对我们系统的重构了) 。
jcm.server中对node健康检查的数据采用同样的同步机制,但node健康检查数据是每一个instance都会写入的,下面看看jcm.server是如何通过分布式架构来分担压力的。
健康检查
jcm.server的另一个主要功能的是对node的健康检查,jcm.server集群可以管理几万的node,既然已经是分布式了,那么显然是要把node均分到多个instance的。这里我是以cluster来分配的,方法就是简单的使用一致性哈希。通过一致性哈希,决定一个cluster是否属于某个instance负责。每个instance都有一个server spec,也就是该instance对外提供服务的地址(IP+port),这样在任何一个instance上,它看到的所有instance server spec都是相同的,从而保证在每一个instance上计算cluster的分配得到的结果都是一致的。
健康检查按cluster划分,可以简化数据的写冲突问题,在正常情况下,每个instance写入的健康检查结果都是不同的。
健康检查一般以1秒的频率进行,jcm.server做了优化,当检查结果和上一次一样时,并不写入zookeeper。写入的数据包含了node的完整key (IP+Port+Spec),这样可以简化很多地方的数据同步问题,但会增加写入数据的大小,写入数据的大小是会影响zookeeper的性能的,所以这里简单地对数据进行了压缩。
健康检查是可以支持多种检查实现的,目前只实现了HTTP协议层的检查。健康检查自身是单个线程,在该线程中基于异步HTTP库,发起异步请求,实际的请求在其他线程中发出。
jcm.subscriber通信
jcm.subscriber与jcm.server通信,主要是为了获取最新的cluster数据。subscriber初始化时会拿到一个jcm.server instance的地址列表,访问时使用轮询策略以平衡jcm.server在处理请求时的负载。subscriber每秒都会请求一次数据,请求中描述了本次请求想获取哪些cluster数据,同时携带一个cluster的version。每次cluster在server变更时,version就变更(时间戳)。server回复请求时,如果version已最新,只需要回复node的状态。
subscriber可以拿到所有状态的node,后面可以考虑只拿正常状态的node,进一步减少数据大小。
压力测试
目前只对健康检查部分做了压测,详细参考test/benchmark.md。在A7服务器上测试,发现写zookeeper及zookeeper的watch足以满足要求,jcm.server发起的HTTP请求是主要的性能热点,单jcm.server instance大概可以承载20000个node的健康监测。
网络带宽:
1
2
3
4
5
|
Time ---------------------traffic--------------------
Time bytin bytout pktin pktout pkterr pktdrp
01/07/15-21:30:48 3.2M 4.1M 33.5K 34.4K 0.00 0.00
01/07/15-21:30:50 3.3M 4.2M 33.7K 35.9K 0.00 0.00
01/07/15-21:30:52 2.8M 4.1M 32.6K 41.6K 0.00 0.00
|
CPU,通过jstack查看主要的CPU消耗在HTTP库实现层,以及健康检查线程:
1
2
3
4
|
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
13301 admin 20 0 13.1g 1.1g 12m R 76.6 2.3 2:40.74 java httpchecker
13300 admin 20 0 13.1g 1.1g 12m S 72.9 2.3 0:48.31 java
13275 admin 20 0 13.1g 1.1g 12m S 20.1 2.3 0:18.49 java
|
代码中增加了些状态监控:
1
|
checker HttpChecker stat count 20 avg check cost(ms) 542.05, avg flush cost(ms) 41.35
|
表示平均每次检查耗时542毫秒,写数据因为开启了cache没有参考价值。
虽然还可以从我自己的代码中做不少优化,但既然单机可以承载20000个节点的检测,一般的应用远远足够了。
总结
名字服务在分布式系统中虽然是基础服务,但往往承担了非常重要的角色,数据同步出现错误、节点状态出现瞬时的错误,都可能对整套系统造成较大影响,业务上出现较大故障。所以名字服务的健壮性、可用性非常重要。实现中需要考虑很多异常情况,包括网络不稳定、应用层的错误等。为了提高足够的可用性,一般还会加多层的数据cache,例如subscriber端的本地cache,server端的本地cache,以保证在任何情况下都不会影响应用层的服务。