前言
公司在19年开始推进同城双活架构,未来规划是在南汇机房出现故障时能把所有读流量切到宝山机房,这样至少保证读请求是没问题的;我们的微服务使用的zookeeper来做服务发现, zk由于它的强一致性模型不适合多机房部署, 由于zk的服务发现模型是基于会话机制创建的临时节点, 就算两个机房各部署一套zk, 再部署一个sync服务两边同步,也会因为跨机房网络不稳定导致连接断开, zk会因此一开始就没有考虑继续用zk作为下一代服务发现的注册中心.
zk基于ZAB协议实现了强一致性,在出现网络分区时有可能因集群无法选出Leader导致服务不可用, 这时候不可以新部署,重新启动,扩容或者缩容,这对于服务发现场景来讲是不能接受的, 在实践中,注册中心不能因为自身的任何原因破坏服务之间本身的可连通性, 数据的短期不一致对客户端来说就是多一个或者少一个节点,对于业务层面完全是可以接受的, 因此注册中心在设计时应偏向AP架构.
背景故事
我刚入职的时候便接手了一个任务:把zk注册中心替换成consul,因为网上的介绍都说consul是AP架构, 而且consul在生产环境已经完全落地, 运维已经用了consul来做物理机的发现和监控, 因此当时的做法就是研究consul的接入文档和API, 完成了基于consul的开发和验证,上线了一个注册中心同步的服务,用于把zk注册中心和consul双向同步, 上线后很多服务开始报no active server
的错误,最后排查下来发现我们线上的consul版本并不支持tag过滤的功能, 导致consul返回了在测试环境不会返回的服务实例信息, 最后导致反复触发服务上线下线事件, 进而引起了故障,最后把sync服务强制下线才解决; 这次事故还发现个问题是线上的prometheus会给所有thrift端口发送/metric http请求, 导致m2服务解析时直接内存溢出了, 需要重启服务才能解决.
这次事故我们吸取了深刻的教训, 由于没有深入了解Consul的原理, 导致解决问题花了较长时间,后面我们便把进度放慢, 先去熟悉Consul的技术架构, 深入Consul的源码才发现,Consul并不是AP架构, Consul的服务注册模型和KV模型都是基于Raft实现的强一致性,因此对于服务发现它是一个CP的架构, 而Consul内部Agent的发现和事件机制是基于Gossip流言协议实现的, 这个协议是最终一致性的,所以网上很多文章把Consul归类为AP架构, 这个是有问题的.
还有一个暴露出来的问题是运维用Consul来管理物理机,他们对可用性,一致性,性能这些指标并没有太多的要求, 因此业务和运维共用一套服务发现系统是有很大的风险的, 运维测试环境和生产环境安装的consul版本不一致就是如此, 因此基于一致性模型和数据共享的风险我们最终弃用了Consul.
注册中心选型
Eureka调研
基于我们在服务发现领域的积累, 最终一致性的架构比较适合注册中心的场景,因此我们调研了业界使用比较广泛的2款开源框架Eureka和Nacos.
Eureka是Netflix开源的一款提供服务注册和发现的产品,基于Java语言开发,在它的实现中,节点之间相互平等,部分注册中心的节点挂掉也不会对集群造成影响,即使集群只剩一个节点存活,也可以正常提供发现服务。哪怕是所有的服务注册节点都挂了,Eureka Clients上也会缓存服务调用的信息。这就保证了我们微服务之间的互相调用足够健壮.
看完前面的介绍可能会觉得Eureka的实现非常适合服务发现的场景,在SpringCloud体系下Eureka在很多公司都有成功的落地经验, 但深入Eureka源码层面进行分析时,我们发现了Eureka的一个巨大隐患, Eureka每个服务节点会保存所有的服务实例数据, 同时每个Eureka
会作为其他节点的Client, Eureka-Client也会保存一份全量的数据, 这就导致每个Eureka Server保存了2份全量的数据,因此当集群规模比较大时Eureka Server的压力会非常大,而且无法通过水平扩展来分担压力.
贴一张Eureka的官方架构图:
总结了一下Eureka存在的问题
订阅端拿到的是所有服务的全量地址,这个对于客户端的内存是一个比较大的消耗(不使用官方客户端可以解决),每个server节点会在内存里保存2份全量的注册信息
pull模式:客户端采用周期性pull方式存在实时性不足以及拉取性能消耗的问题(开发长轮询功能)
一致性协议:Eureka集群的多副本的一致性协议采用类似“异步多写”的AP协议,每一个 server都会把本地接收到的写请求(register/heartbeat/unregister/update)发送给组成集群的其他所有的机器,特别是hearbeat报文是周期性持续不断的在client->server->all other server之间传送
当读请求增多的时候集群需要扩容,但扩容又会导致每台server承担更多的写请求,扩容效果并不明显,而且每个server内存中保存2份全量的数据,内存会成为瓶颈
1.x版本更新频率很低,2.x版本闭源, 问题修复频率低
Nacos调研
除了Eureka之外,我们还调研了另一款采用AP架构的Nacos注册中心,Nacos由阿里开源,在国内很多公司有成功落地经验,由于Nacos包含注册中心和配置中心两部分功能,以下内容只涉及注册中心.
nacos总体思路和eureka差不多,但是解决了eureka存在的一些问题:
pull时效性问题:采用udpPush+ack的方式, 并且支持定时pull
心跳请求在server间来回转发的问题:采用分片方式每个server负责一部分service的状态检查,并定时向其他server同步
扩容效果不明显问题:采用hash分片机制,每个server处理一部分service, 虽然每个server存储的数据量是一样的,但扩容可以解决写入的瓶颈
除此之外Nacos的其他几个关键特性:
支持多个namespace,实现租户隔离
支持CP和AP两种一致性模式, CP模式也是采用的Raft协议
支持主动和被动的心跳检测方式, 主动检测支持TCP,HTTP和MySQL协议, 由Nacos Server主动向服务实例发送探测请求来更新节点的健康状态,主要用来监控那些持久的节点,比如数据库,Redis实例等, 被动检测是由节点主动通过HTTP接口向Nacos Server发送心跳请求
下面这个是Nacos Server的请求处理流转图
综合对比后,我们决定用Nacos来作为我们微服务的下一代注册中心.
Nacos开发篇
官方的Nacos还无法直接在我们的生产环境上使用, 因为Nacos采用的Udp Push的方式来更新服务实例, 而Udp Push需要每个服务实例多监听一个端口,我们线上基本都是一个物理机部署好几个服务, 采用监听端口的方式很容易就端口冲突了, 因此我们需要将Udp Push改造成HTTP 长轮询的方式;
Nacos Server验证和改造
HTTP长轮询改造
Http长轮询功能是基于Servlet 3.0 提供的AsyncContext的能力, nacos-client收到订阅请求后会和nacos-server建立HTTP长连接, nacos只有在服务实例有变更时才向客户端返回最新的数据, 延迟基本在ms级别.
MetaServer改造
Nacos Server之间互相发现是通过配置文件里配置服务列表实现的,如果要新增一个节点, 需要修改配置文件并重启所有节点; 因此为了后面方便快速扩容, 我们把Server列表接入了配置中心,可以动态更新server集群的地址;
Nacos-Client默认也是通过配置的方式获取获取server集群地址, 同时也支持通过vipServer的方式来动态刷新服务列表, 需要客户端配置一个http url, 由于我们线上物理机都安装了Consul Agent, 因此我们直接把Consul当做MetaServer, 当health server列表有变更时把变更后的地址写到Consul的KV里, Nacos-Client动态的从Consul KV获取healthy集群的地址.
压测和断网测试
Nacos官网给出的压测结果是非常优秀的, 我们压测下来也发现线上只需要部署三个节点就能满足要求,Nacos的代码质量也比较高, 服务GC一直都比较稳定, 最后我们线上是部署了5个节点.
由于Nacos采用的是AP架构, 我们需要重点关注出现网络分区或机器故障的场景下Nacos的实际表现, 因此针对各种可能出现的场景都做了验证,下面列出来的是发现问题的几个场景:
测试场景1:网络分区5分钟,网络恢复后2个server数据一直会不一致
最后发现是Nacos的同步任务有Bug,该Bug已提交PR并采纳,参考issue1665
测试场景2: 两个实例网络分区后服务实例的变化过程
由于采用的分片处理的模型, 当某一个节点和其他节点网络分区了并且15s内没有恢复, 这个节点上的实例会集体下线几秒钟,客户端会出现大量的no active server, 这个问题我在Github与Nacos团队有讨论过, 参考issue1873,他们给出的解决方案是Nacos Server之间剔除过期节点的时间间隔要小于 (服务实例超时时间-服务实例心跳间隔)
后面我们修改了Nacos Server之间的心跳配置,改为每秒发送一次心跳, 超过10s没有收到心跳就摘除该Server.
测试场景3: Nacos Server重启后服务实例的变化
Nacos Server启动后会从其他Server同步全量的数据, 但如果同步过来的部分服务原先是由自己负责处理心跳的, 而这些实例的心跳时间戳是比较早的(Nacos-Server之间并不会同步服务实例最新的heartbeat时间戳), 这些服务会被每5s执行的ClientBeatCheckTask删除掉,但很快会重新注册上.
如果不在业务高峰期重启一般问题不大, 但想了下还是加了个保护模式, 当Nacos-Server集群地址有变更时, 暂停主动的ClientBeatCheckTask 30s, 这样Nacos Server可以无损的发布和重启, 重启Nacos Server对业务完全无影响.
双注册中心架构
Nacos注册中心上线后, 我们需要考虑的是和现有zk注册中心的兼容问题, 由于我们线上还有很多使用服务还在使用老版本微服务sdk, 这些服务很多都找不到维护的人, 因此我们预计到未来很长一段时间内需要保留2个注册中心, 因此需要有个服务对2个注册中心的数据进行双向同步.
Nacos官方提供了Nacos-Sync来做注册中心数据的迁移, 但调研后发现这是一个单机版的实现,主要用于一次性迁移数据, 无法达到高可用, 因此我们需要自己开发一个Sync服务,用于zk和nacos双向同步数据.
Sync服务处理的原则:
无状态, 服务实例无论是注册在zk还是nacos,两个注册中心的数据必须是一致的, 而且允许中间状态的存在(在升级过程中一个服务可能部分实例用zk,部分用nacos)
一个业务服务在绝大多数情况下,一般只存在一个双向同步任务, 在sync服务上下线过程中可能reHash出现多个sync节点都存在同一个服务的sync任务,但结果必须是一致的
一个业务服务的同步方向,是根据业务服务实例元数据( Metadata )的标记 fromSync 来决定的,比如服务实例是注册在zk, 该实例同步到nacos后会加一个fromSync的
MetaData, 这样从Nacos同步到zk时会忽略fromSync=true的实例,避免来回同步
我们采用了一致性 Hash 方式来解决任务分配的问题,当一台或者几台同步服务器挂掉后,采用 Zookeeper 临时节点的 Watch 机制监听同步服务器挂掉情况,通知剩余同步服务器执行 reHash,挂掉服务的工作由剩余的同步服务器来承担,通过一致性 Hash 实现被同步的业务服务列表的平均分配,基于对业务服务名的二进制转换作为 Hash 的 Key 实现一致性 Hash 的算法.
Sync服务在给Nacos发送心跳时, 将心跳上报请求放入队列,以固定线程消费,当一个sync节点处理的服务实例数超过一定的阈值会造成业务服务实例的心跳发送不及时,从而造成业务服务实例的意外丢失。其实对于sync过去的服务来说, 心跳的间隔可以设置的长一些, 因为服务实例一旦在一个注册中心下线了, 会被sync服务监听到然后主动下线,这些实例并不需要nacos server频繁的主动剔除检查, 因此我们实际采用了一个15~45s的随机值作为心跳间隔, 极大的减少了sync服务发送心跳的压力,同一个服务的所有实例也不会出现集体下线的情况.
Sync服务平滑上下线
由于我们依赖zk的临时节点来做一致Hash, 当某个sync节点下线时,其他节点监听到该节点下线时有延迟的, 这样就会出现sync过去的实例会有1s左右的下线, 针对这个场景,我们实现了主动下线的逻辑, 在重启sync节点前发送主动下线命令, 该节点主动把自己从zk上摘除,并执行慢清理操作, 这样在该节点停止之前其他节点能把对应的syncTask接管过去, 实现无损下线.
最后我们的双注册中心架构如下: