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

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

long polling
另外, nacos 服务发现部分的通知方式又作了升级, 不再 hold 客户端请求, 而是通过 udp 通知. 和 long polling 类似, 客户端每 10s 查询一次, 不再 hold, 保存客户端信息后直接返回. 在下一个 10 秒来临前, 如果 instance 有变更, 直接从缓存捞取所有相关客户端, 发送 udp 通知. 但需要客户端开启一个 udp 端口并且能被访问.
负载均衡一般有一致性 hash, 轮询, 加权轮询等, 比较成熟, 暂且不表. 路由比较重要, 灵活的路由能实现分流, 降级, 灰度发布, 金丝雀发布, 容灾等能力, 路由信息配置应该有个管理后台, 动态修改并实时生效. 有如下路由规则:
ip: 指定 ip 路由, 方便有时候测试和定位问题.
地域(Region), 区域(Zone): 多区域部署实现容灾, 同时优先 Zone 内调用降低网络延迟.
服务分组: 有可能需要部署多组服务分别不同消费方使用.
服务版本: 版本化, 支持灰度.
其他可选: 故障注入, 熔断, 流量镜像等.
有的服务发现通过注册中心的 proxy 代理实现负责均衡和路由, 这种中心化的设计并不好, 对 proxy 的性能要求极高, 从而成为瓶颈点, 一般都从客户端直接调用服务提供方.
有的服务发现为了实现多语言, 在客户端部署了一个 agent, 通过 agent 跟注册中心通讯, 对客户端调用透明, 但是增加了部署复杂度. 比如通过边车模式实现的 Netflix Prana https://github.com/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? https://github.com/Netflix/eureka/wiki/FAQ

cap 理论
总结, 目前实现服务发现的注册中心有三种方式:
1. 使用中心化一致性存储中间件, 如 zk(Paxos 算法),etcd(Raft 算法);
2. 使用传统 DNS + 新的一致性算法, 如 SkyDNS,Spotify;
3. 去中心化, 弱一致性实现如 Eureka.
上文提到的 nacos 采用的是 raft 算法保证集群数据一致. 下文主要介绍 Eureka, 注册信息维护在内存中, 不需选主, 集群间同步注册信息, 可能有短暂的数据不一致, 但保证可用性.
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 集群如何同步?
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 还做了哪些性能改善点?
eureka 本身有点像一个缓存架构的设计, 当然, 其中为了改善性能也使用了缓存如 guava cache, 还有 overriddenInstanceStatusMap,recentlyChangedQueue 等队列. 部分采用异步编程, gzip 压缩等.
eureka 的问题有哪些?
本身没有灰度功能(可以添加 metadata 信息自由扩展, 然后在客户端扩展路由规则)
服务粒度太粗, 客户端并不需要的信息也拉取, 如果有上千个服务, 难道客户端还维护那么多实例信息?
各个时间配置不好衡量(心跳时间, 服务拉取时间等), 需要改三四个配置以适应自己的服务.
没有权限控制; 如果我起了一个服务, 跟其他服务同名, 且不是把浏览偷偷分过来了.
服务提供方也没有权限控制, 可以任意调用 server 注册的服务.
控制台功能简陋, 只有简单的查看注册列表的功能.
总之, 如果要使用 eureka, 需要针对以上问题扩展很多细节. 之所以单独研究它, 也因为它问题多多, 再看其他框架设计时更能理解相比 Eureka 的优缺点.
Gossip
如果想了解 dns 加持的方式, kubernate 是一个很好的研究示例, 通过一个 dns 服务还有 iptables 的方式实现服务发现和路由. 这里再介绍一个新玩法 Serf.
Eureka 是去中心化, 弱一致性, 但还是有一个 AP 系统的注册中心集群. Serf 的玩法是, 完全去中心, 不再需要一个注册中心, 所有服务组成一个大集群, 大概如下:
每个服务都是网络中的一个节点, 每个节点都随机与其他节点通讯, 最终达成一致, 使得每一个节点都可能知道网络中的其他节点. Serf 就是 gossip https://en.wikipedia.org/wiki/Gossip 算法的实现. 看到这种分布式网络中通讯的容错问题, 马上想到区块链, P2P.
Serf 并不能解决服务发现所有需求, 虽然解决了大集群网络中的容错性, 但在几千个服务节点中, 节点信息传播效率我没作测试. 不过这是一个新的玩法, 且 gossip 协议在集群信息同步上用得越来越多, 比如 consul 不同数据中心的同步, Cassandra 集群信息的同步都是通过 gossip 实现.
Apache 还有一个正在孵化的项目 incubator-gossip http://gossip.incubator.apache.org/ .
总结
本文通过研究一些开源服务注册发现框架, 总结其设计要点.
[1]. Netflix GitHub https://github.com/Netflix
[2]. https://www.serf.io/
[3].
[4]. http://en.wikipedia.org/wiki/Gossip_protocol.
[5]. https://nacos.io/zh-cn/index.html
来源: https://juejin.im/post/5c3d708f5188252410607847