http://www.tuicool.com/articles/VfQbauB
远程服务依赖
依赖分为两种,本地的lib依赖,远程的服务依赖。
本地的依赖其实是很复杂的问题。从操作系统的apt-get,到各种语言的pip, npm。包管理是无穷无尽的问题。但是所有的本地依赖已经被docker终结了。无论是依赖了什么,全部给你打包起来,从操作系统开始。除了你依赖的cpu指令集没法给你打包成镜像了,其他都给打包了。
docker之后,依赖问题就只剩远程服务依赖的问题。这个问题就是服务注册发现与调度需要解决的问题。从软件工程的角度来说,所有的解耦问题都可以通过抽取lib的方式解决。lib也可以实现独立的发布周期,良好定义的IDL接口。所以如果非必要,请不要把lib依赖升级成网络服务依赖的角度。除非是从非功能性需求的角度,比如独立的扩缩容,支持scale out这些。很多时候微服务是因为基于lib的工具链支持不全,使得大家义无反顾地走上了拆分网络服务的不归路。
名字服务
服务名又称之为Service Qualifier,是一个人类可理解的英文标识。所谓的服务注册和发现就是在一个Service Qualifier下注册一堆Endpoint。一个Endpoint就是一个ip+端口的网络服务。就是一个非常类似DNS的名字服务,其实DNS本身就可以做服务的注册和发现,用SRV类型记录。
名字服务的存在意义是简化服务的使用方,也就是主调方。过去在使用方的代码里需要填入一堆ip加端口的配置,现在有了名字服务就可以只填一个服务名,实际在运行时用服务名找到那一堆endpoint。
从名字服务的角度来讲并不比DNS要强多少。可能也就是通过“服务发现的lib”帮你把ip和端口都获得了。而DNS默认lib(也就是libc的getHostByName)只支持host获取,并不能获得port。当然既然你都外挂了一个服务发现的lib了,和libc做对比也就优势公平了。
lib提供的接口类似
$endpoints = listServiceEnpoints('redis'); echo($endpoints[0]['ip]);
甚至可以直接提供拼接url的接口
$url = getServiceUrl('order', '/newOrder'); # http://xxx:yyy/newOrder
比DNS更快的广播速度
传统DNS的服务发现机制是缓存加上TTL过期时间,新的endpoint要传播到使用方需要各级缓存的刷新。而且即便endpoint没有更新,因为TTL到期了也要去上游刷新。为了减少网络间定时刷新endpoint的流量,一般TTL都设得比较长。
而另外一个极端是gossip协议。所有人连接到所有人。一个服务的endpoint注册了,可以通过gossip协议很快广播到全部的节点上去。但是gossip的缺点是不基于订阅的。无论我是不是使用这个服务,我都会被动地被gossip这个服务的endpoint。这样就造成了无谓的网络间带宽的开销。
比较理想的更新方式是基于订阅的。如果业务对某个服务进行了发现,那么缓存服务器就保持一个订阅关系获得最新的endpoint。这样可以比定时刷新更及时,也消耗更小。这个方面要黑一下etcd 2.0,它的基于http连接的watch方案要求每个watch独占一个tcp连接,严重限制了watch的数量。而etcd 3.0基于gRPC的实现就修复了这个问题。而consul的msgpack rpc从一开始就是复用tcp连接的。
图中的observer是类似的zookeeper的observer角色,是为了帮权威服务器分担watch压力的存在。也就是说服务发现的核心其实是一个基于订阅的层级消息网络。服务注册和发现并不承诺任何的一致性,它只是尽力地进行分发,并不保证所有的节点对一个服务的endpoint是哪些有一致的view,因为这并没有价值。因为一个qualifier下的多个endpoint by design 就是等价的,只要有足够的endpint能够承担负载,对于abc三个endpoint具体是让ab可见,还是bc可见,并无任何影响。
服务发现agent的高可用
DNS的方案是在每台机器上装一个dnsmasq做为缓存服务器。服务发现也是类似的,在每台机器上有一个agent进程。如果dnsmasq挂了,dns域名就会解析失败,这样的可用性是不够的。服务发现的agent会把服务的配置和endpoint dump一份成本机的文件,服务发现的lib在无法访问agent的时候会降级去读取本机的文件,从而保证足够的可用性。当然你要愿意搞什么共享内存,也没人阻拦。
无法实现对dns服务器的降级。因为哪怕是降级到 /etc/hosts 的实现,其一个巨大的缺陷是 /etc/hosts 对于一个域名只能填一个ip,无法满足扩展性。而如果这一个ip填的是代理服务器的话,则失去了做服务发现的意义,都有代理了那就让代理去发现服务好了。
更进一步,很多基于zk的方案是把服务发现的agent和业务进程做到一个进程里去了。所以就不需要担心外挂的进程是否还存活的问题了。
软负载均衡
这点上和DNS是类似的。理论来说ttl设置为0的DNS服务器也可以起到负载均衡的作用。通过把权重分发到服务发现的agent上,可以让业务“每次发现”的endpoint都不一样,从而达到均衡负载的作用。权重的实现通过简单的随机算法就可以实现。
通过软负载均衡理论上可以实现小流量,灰度地让一个新的endpoint加入集群。也可以实现某一些endpoint承担更大的调用量,以达到在线压测的目的。
不要小瞧了这么一点调权的功能。能够中央调度,智能调度流量,是非常有用的。
故障检测(减endpoint)
故障检测其实是好做的。无非就是一个qualifier下挂了很多个endpoint,根据某种探活机制摘掉其中已经无法提供正常服务的endpoint。摘除最好是软摘除,这样不会出现一个闪失把所有endpoint全摘掉的问题。比如zookeeper的临时节点就是硬摘除,不可取。
本地探活
在业务拿到endpoint之后,做完了rpc可以知道这个endpoint是否可用。这个时候对endpoint的健康状态本地做一个投票累积。如果endpoint连续不可用则标记为故障,被临时摘除。过一段时间之后再重新放出小黑屋,进行探活。这个过程和nginx对upstream的被动探活是非常类似的。
被动探活的好处是非常敏感而且真实可信(不可用就是我不能调你,就是不可用),本地投票完了立即就可以判定故障。缺陷是每个主调方都需要独立去进行重复的判定。对于故障的endpoint,为了探活其是否存活需要以latency做为代价。
被动探活不会和具体的rpc机制绑定。无论是http还是thrift,无论是redis还是mysql,只要是网络调用都可以通过rpc后投票的方式实现被动探活。
主动探活
主动探活比较难做,而且效果也未必好:
所有的主动探活的问题都在于需要指定如何去探测。不是tcp连接得上就算是能提供服务的。
主动探活受到网络路由的影响,a可以访问b,并不带表c也可以访问b
主动探测带来额外的网络开销,探测不能过于频繁
主动探测的发起者过少则容易对发起者产生很大的探活压力,需要很高的性能
本地主动探活
consul 的本机主动探活是一个很有意思的组合。避免了主动探活的一些缺点,可以是被动探活的一些补充。
心跳探活
无论是zookeeper那样一来tcp连接的心跳(tcp连接的保持其实也是定时ttl发ip包保持的)。还是etcd,consul支持的基于ttl的心跳。都是类似的。
gossip探活
改进版本的心跳。减少整体的网络间通信量。
服务注册(加endpoint)
服务endpoint注册比endpoint摘除要难得多。
无状态服务注册
无状态服务的注册没有任何约束。不管是中央管理服务注册表,用web界面注册。还是和部署系统联动,在进程启动时自动注册都可以做。
有状态服务的注册
有状态服务,比如redis的某个分片的master。其有两个约束:
除非是在数据层协议上做ack(paxos,raft)或者协议本身支持冲突解决(crdt),否则基于服务注册来实现的分布式要么牺牲一致性,要么牺牲可用性。
有状态服务的注册需求,和普通的注册发现需求是本质不同的。有状态服务需要的是一个一致性决策机制,在consistency和availability之间取平衡。这个机制可以是外挂一个zookeeper,也可以是集群的数据节点自身做一个gossip的投票机制。
而普通的注册和发现就是要给广播渠道,提供visibility。尽可能地让endpoint曝光到其使用方那。不同的问题需要的解决方案是不同的。对于有状态服务的注册表需要非常可靠的故障检测机制,不能随意摘除master。而用于广播的服务注册表则很随意,故障检测机制也可以做到尽可能错杀三千不放过一个。广播的机制需要解决的问题是大集群,怎么让服务可见。而数据节点的选主要解决的是相对小的集群,怎么保持一致地情况下尽量可用。拿zookeeper的临时节点这样的机制放在大集群背景下,去做无状态节点探活就是技术用错了地方。
比如kafka,其有状态服务部分的注册和发现是用zookeeper实现的。而无状态服务的注册与发现是用data node自身提供集群的metadata来实现的。也就是消费者和生产者是不需要从zookeeper里去集群分片信息的(也就是服务注册表),而是从data node拿。这个时候data node其是充当了一个服务发现的agent的作用。如果不用data node干这个活,我们把data node的内容放到DNS里去,其实也是可以work的。只是这些存储的给业务使用的客户端lib已经把这些逻辑写好了,没有人会去修改这个默认行为了。
但是广播用途的服务注册和发现,比如DNS不是只提供visibility而不能保证任何consistency吗?那我读到分片信息是旧的,把slave当master用了怎么办呢?所有做得好的存储分片选主方案,在data node上自己是知道自己的角色的。如果你使用错了,像redis cluster会回一个move指令,相当于http 302让你去别的地方做这个操作。kafka也是类似的。
接入方式
libc只支持getHostByName,任何更高级的服务发现都需要挖空心思想怎么简化接入。反正操作系统和语言自身的工具链上是没有标准的支持的。每个公司都有一套自己的玩法。行业严重缺乏标准。
无论哪种方式都是要修改业务代码的。即便是用proxy方式接入,业务代码里也得写死固定的proxy ip才行。从可读性的角度来说,固定proxy ip的可读性是最差的,而用服务名或者域名是可读性最好的。
给每种语言写lib
最笨拙的方法,也是最保险的。业务代码直接写服务名,获得endpoint。
探活也就是硬改各种rpc的lib,在调用后面加上投票的代码。
复用libc的getHostByName
因为所有的语言基本上都支持DNS域名解析。利用这一层的接口,用钩子换掉lib的实际实现。业务代码里写域名,端口固定。
socket的钩子要难做得多,而且仅仅tcp4层探活也是不够的(http 500了往往也要认为对方是挂了的)。
实际上考虑golang这种没有libc的,java这种自己缓存域名结果的,钩子的方案其实没有想得那么美好。
本地 proxy
proxy其实是一种简化服务发现接入方式的手段。业务可以不用知道服务名,而是使用固定的ip和端口访问。由proxy去做服务发现,把请求转给对方。
http的proxy也很成熟,在proxy里对rpc结果进行跳票也有现成的工具(比如nginx)。很多公司都是这种本地proxy的架构,比如airbnb,yelp,eleme,uber。当用lib方式接业务接不动的时候,大家都会往这条路上转的。
远程 proxy
远程proxy的缺陷是固定ip导致了路由是固定的。这条路由上的所有路由器和交换机都是故障点。无法做到多条网络路由冗余容错。而且需要用lvs做虚ip,也引入了运维成本。
而且远程proxy无法支持分区部署多套环境。除非引入bgp anycast这样妖孽的实现。让同一个ip在不同的idc里路由到不同的服务器。
分区部署
国内大部分的网游都是分区分服的。这种架构就是一种简化的存储层数据分片。存储层的数据分片一般都做得非常完善,可以做到key级别的搬迁(当你访问key的时候告诉你我可以响应,还是告诉你搬迁到哪里去了),可以做到访问错了shard告诉你正确的shard在哪里。而分区部署往往是没有这么完善的。
所以为了支持分区部署。往往是给不同分区的服务区不同的服务名。比如模块叫 chat,那么给hb_set(华北大区)的chat模块就命名为hb_set.chat,给hn_set(华南大区)的chat模块就命名为hn_set.chat。当时如果我们是gamesvr模块,需要访问chat模块,代码都是同一份,我怎么知道应该访问hn_set.chat还是hb_set.chat呢?这个就需要让gamesvr先知道自己所在的set,然后去访问同set下的其他模块。
again,这种分法也就是因为分区部署做为一个大的组合系统没法像一个孤立地存储做得那么好。像kafka的broker,哪怕你访问的不是它的本地分片,它可以帮你去做proxy连接到正确的分片上。而我们没法要求一个组合出来的业务系统也做到这么完备地程度。所以凑合着用吧。
但是这种分法也有问题。有一些模块如果不是分区的,是全局的怎么办?这个时候服务发现就得起一个路由表的作用,把不同分区的服务通过路由串起来。