之前在公司里维护了一个名字服务,这个名字服务日常管理了近4000台机器,有4000个左右的客户端连接上来获取机器信息,由于其基本是一个单点服务,所以某些模块接近瓶颈。后来倒是有重构计划,详细设计做了,代码都写了一部分,结果由于某些原因重构就被终止了。
JCM 是我业余时间用Java重写的一个版本,功能上目前只实现了基础功能。由于它是个完全分布式的架构,所以理论上可以横向扩展,大大增强系统的服务能力。
在分布式系统中,某个服务为了提升整体服务能力,通常部署了很多实例。这里我把这些提供相同服务的实例统称为集群( cluster
),每个实例称为一个节点( Node
)。一个应用可能会使用很多cluster,每次访问一个cluster时,就通过名字服务获取该cluster下一个可用的node。那么,名字服务至少需要包含的功能:
有些名字服务仅对node管理,不参与应用与node间的通信,而有些则可能作为应用与node间的通信转发器。虽然名字服务功能简单,但是要做一个分布式的名字服务还是比较复杂的,因为数据一旦分布式了,就会存在同步、一致性问题的考虑等。
JCM围绕前面说的名字服务基础功能实现。包含的功能:
项目地址 git jcm
JCM主要包含两部分:
基于JCM的系统整体架构如下:
cluster本身是不需要依赖JCM的,要通过JCM使用这些cluster,只需要通过JCM HTTP API注册这些cluster到jcm.server上。要通过jcm.server使用这些cluster,则是通过jcm.subscriber来完成。
可参考 git READMe.md
需要jre1.7+
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目前的实现比较简单,参考模块图:
以上模块都不依赖Spring,基于以上模块又有:
jcm.subscriber的实现更简单,主要是负责与jcm.server进行通信,以更新自己当前的model层数据,同时提供各种负载均衡策略接口:
alloc node
的接口 接下来看看关键功能的实现
既然jcm.server是分布式的,每一个jcm.server instance(实例)都是支持数据读和写的,那么当jcm.server管理着一堆cluster上万个node时,每一个instance是如何进行数据同步的?jcm.server中的数据主要有两类:
对于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.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的健康监测。
网络带宽:
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库实现层,以及健康检查线程:
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
代码中增加了些状态监控:
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,以保证在任何情况下都不会影响应用层的服务。