服务注册与发现是微服务的核心,否则新发布一个服务只能去调用方配置地址,不能接受的事。不管是rpc还是spring cloud这种Http调用,注册中心都不可少。

这是dubbo的基本结构,但几乎所有服务发现的注册中心都这样。服务提供方注册到注册中心,消费方订阅或者拉取提供者信息,发起调用。
客户端比较简单:
1. 从注册中心拉取服务信息
2. 维持服务信息缓存
3. 负载均衡和路由
不过说简单也不简单,根据注册中心使用的技术不同,实现方式不同。比如使用zk,consule等中间件自带通知功能,集成中间件客户端,做好订阅即可。eureka采用定时Http拉取的方式,可以自己开发多语言客户端,按照注册中心提供的接口实现。
不过,只是拉取(poll)并不是很好,频率低无法及时获取服务更新信息,频率太高增加注册中心的负载,而且大部分拉取都是没变化的。一直保持长连接接收推送也不是很好的方案,可以考虑长轮询的方式(实现参考nacos配置监听代码 LongPollingService )。

long polling
另外,nacos服务发现部分的通知方式又作了升级,不再hold客户端请求,而是通过udp通知。和long polling类似,客户端每10s查询一次,不再hold,保存客户端信息后直接返回。在下一个10秒来临前,如果instance有变更,直接从缓存捞取所有相关客户端,发送udp通知。但需要客户端开启一个udp端口并且能被访问。
负载均衡一般有一致性hash、轮询、加权轮询等,比较成熟,暂且不表。路由比较重要,灵活的路由能实现分流、降级、灰度发布、金丝雀发布、容灾等能力,路由信息配置应该有个管理后台,动态修改并实时生效。有如下路由规则:
有的服务发现通过注册中心的proxy代理实现负责均衡和路由,这种中心化的设计并不好,对proxy的性能要求极高,从而成为瓶颈点,一般都从客户端直接调用服务提供方。
有的服务发现为了实现多语言,在客户端部署了一个agent,通过agent跟注册中心通讯,对客户端调用透明,但是增加了部署复杂度。比如通过边车模式实现的 Netflix Prana 。不过service mesh的到来反而做到解耦业务和网络配置,方便升级、支持多语言等,在k8s生态下部署也不是问题。
服务端:
1. 注册
2. 续租(心跳、定时上报)
3. 下线
启动完成后,调用注册中心注册服务信息,然后定时上报,除了告知自己还活着外,还可以上报健康状态。因为活着不代表健康,比如某个中间件连不上等,spring boot可以检查 /health
。
注意,注册是服务正常启动完成后才开始,如果无法做到启动成功才上报,可以延时注册,否则客户端发起调用时,服务提供方其实还没准备好。
好的方式有个服务状态机:UP,DOWN,STRATING等,启动时注册服务时,status为STARTING,后续的心跳更新为UP。Eureka就是这样的方式。
容易出现的问题是服务下线时,注册中心没有及时下线,导致请求还是被路由到已经关闭(或者关闭中)的提供者,一般客户端会写一个shutdown hook通知注册中心下线。不过客户端可能直接被kill,或者消费者本地缓存没更新,仍然存在问题。以其纠结半天,不如弯道解决问题,通过应用启停脚本,先主动下线,隔几秒再stop提供者。
市面上开源的可以用作注册中心的中间件主要是zk,console,etcd等。double用的zk、nacos,docker swarm使用consul,老版本的kubernate dns使用etcd。
这些中间件不是为服务发现而生,大部分保证了CAP定律的一致性、分区容错性,但不能保证每次请求都可用。对服务注册发现来说,我们更希望是AP,可以容忍短暂不一致,但必须可用。具体可参考: Why not use Curator/Zookeeper as a service registry?

cap理论
总结,目前实现服务发现的注册中心有三种方式:
1. 使用中心化一致性存储中间件,如zk(Paxos算法),etcd(Raft算法);
2. 使用传统DNS+新的一致性算法,如SkyDNS、Spotify;
3. 去中心化,弱一致性实现如Eureka。
上文提到的nacos采用的是raft算法保证集群数据一致。下文主要介绍Eureka,注册信息维护在内存中,不需选主,集群间同步注册信息,可能有短暂的数据不一致,但保证可用性。
Eureka高可用架构图:


eureka server主要通过一个嵌套ConcurrentHashMap维护注册信息:
ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>(); 复制代码
外层map的key为服务名(要求服务名唯一、好像没有实现namespace隔离),内层map的key为实例ID,用以区分相同服务的不同实例。Lease对象持有InstanceInfo,还有一些属性如 lastUpdatetime
。InstanceInfo类就是服务信息,比如ip,port,host等。下面是数据结构示例:
{ "name":"my-app-name", "instance":[ { "instanceId":"192.18.99.100:my-app-name:60000", "hostName":"192.18.99.100", "app":"my-app-name", "ipAddr":"192.18.99.100", "status":"UP", "overriddenstatus":"UNKNOWN", "port":{ "$":8080, "@enabled":"true" }, "securePort":{ "$":443, "@enabled":"false" }, "countryId":1, "dataCenterInfo":{ "name":"MyOwn" }, "leaseInfo":{ "renewalIntervalInSecs":30, "durationInSecs":90, "registrationTimestamp":1525342799736, "lastRenewalTimestamp":1525364448834, "evictionTimestamp":0, "serviceUpTimestamp":1525242780899 }, "metadata":{ }, "homePageUrl":"http://192.18.99.100:8080/", "statusPageUrl":"http://192.168.99.100:8080/info", "isCoordinatingDiscoveryServer":"false", "lastUpdatedTimestamp":"1525342799736", "lastDirtyTimestamp":"1525342797179", "actionType":"ADDED" } ] } 复制代码
不考虑数据同步间隔,一般移出不健康的服务用4种方式:
1. 停服务时,通过shutdown hook调用server,主动下线服务;
2. eureka server有一个定时任务检查一段时间没有心跳的服务,把它从列表剔除;
3. 服务健康检查不通过时,通过心跳上报给server,服务不被剔除,但被标记为down,客户端也不会访问。
4. 直接调用eureka api,把服务标为offline,同down类似。
eureka集群去中心化,客户端和不同的server通讯,eureka间同步有3个问题(eureka 特指eureka server):
1. 如图:如果某服务S,先向eurekaA注册,再向eurekaB注册,然后eurekaA向eurekaB同步。此时,同步信息会不会覆盖?
2. 服务S只向eurekaA注册,如果eurekaA向eurekaB同步失败,访问B的客户端是不是一直无法获取服务S的信息?
3. 服务多、eureka集群大时,eureka集群同步压力太大?

针对第一个问题,eureka通过时间戳和status判断新旧,始终以新版本为主;
第二个问题通过心跳解决,如果A->B失败,心跳再次达到A,还是会向B发送心跳,往B心跳结果404,则A重新把S注册到B。
第三个问题确实是个瓶颈,不过eureka通过“启动一次拉取,之后走批量、增量同步”的方式改善性能。
总结同步流程如下:
1. server启动时去其他server全量拉去
2. 启动后,其他客户端会自动增量同步(走批量接口)
3. 如果同步失败,通过心跳实现补偿逻辑。
eureka本身有点像一个缓存架构的设计,当然,其中为了改善性能也使用了缓存如guava cache,还有overriddenInstanceStatusMap,recentlyChangedQueue等队列。部分采用异步编程,gzip压缩等。
总之,如果要使用eureka,需要针对以上问题扩展很多细节。之所以单独研究它,也因为它问题多多,再看其他框架设计时更能理解相比Eureka的优缺点。
如果想了解dns加持的方式,kubernate是一个很好的研究示例,通过一个dns服务还有iptables的方式实现服务发现和路由。这里再介绍一个新玩法Serf。
Eureka是去中心化,弱一致性,但还是有一个AP系统的注册中心集群。Serf的玩法是,完全去中心,不再需要一个注册中心,所有服务组成一个大集群,大概如下:
每个服务都是网络中的一个节点,每个节点都随机与其他节点通讯,最终达成一致,使得每一个节点都可能知道网络中的其他节点。Serf就是gossip算法的实现。看到这种分布式网络中通讯的容错问题,马上想到区块链,P2P。
Serf并不能解决服务发现所有需求,虽然解决了大集群网络中的容错性,但在几千个服务节点中,节点信息传播效率我没作测试。不过这是一个新的玩法,且gossip协议在集群信息同步上用得越来越多,比如consul不同数据中心的同步,Cassandra集群信息的同步都是通过gossip实现。
Apache还有一个正在孵化的项目incubator-gossip。
本文通过研究一些开源服务注册发现框架,总结其设计要点。
[1]. Netflix github
[2].serf
[3]. service-discovery-in-the-cloud
[4].Gossip_protocol
[5].nacos