53

微服务漫游指南(一)

 3 years ago
source link: https://studygolang.com/articles/15983?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

最近几年“微服务”这个词可谓是非常的火爆,大有席卷天下的态势。几乎所有公司都在按照自己的理解实施微服务,大公司也在逐步地把自己庞大的代码库通过一定的策略逐步拆分成微服务。不过如果你在Google上搜一下,你会发现“微服务”这个名词很难有一个明确的定义,不同的人,不同的业务,不同的架构,他们在不同的维度聊“微服务”。

不过总的来说,大家都比较认同的是:“微服务”的核心是把一个大的系统拆解成一系列功能单一的小系统,每个系统可以单独进行部署。这样的好处是显而易见的:

  • 由于单一职责,每个微服务的开发测试会更简单
  • 开发语言和技术方案不受限制,可以发挥不同团队的特长
  • 故障可以控制在单个系统之中
  • “服务化”使得复用更加便捷

如果要一一列举,还能列举很多很多的优点。总之,微服务看起来还是非常美好的。但是随着各个公司对微服务的不断实践,发现事实也不是那么美好,微服务的实施同时也引入了很多新的亟待解决的问题。这些问题并不代表微服务缺陷,而应该算是引入新技术的“代价”——任何技术升级都是有代价的。

我想通过本文,带你一起来讨论和学习这些代价,这对你更加深入理解微服务至关重要。每个section我都尽量细化,让你知道How&Why,避免空洞的概念罗列,同时也会给出具体的解决方案。

熵与服务治理

熵是物理学中的一个名词:

熵是系统的混乱程度的度量值,熵越大,意味着系统越混乱

当你把系统中的 模块 当成 子系统 拆分出来,必然会引入“混乱”。最简单的,以前调用一个功能就是import一个包,然后调用包的方法即可,仅仅是一个函数调用。编译器保证被调用的方法一定存在,同时保证参数的类型和个数一定匹配。调用是没有开销的,仅仅是把函数指针指到子模块的函数入口即可。但是一旦进行微服务拆分,子模块变成了一个独立部署的系统,调用方式将发生很大的变化,变得很复杂。

服务发现

首先,服务间通信基本都是依靠RPC,编译器无法帮你保证你调用的正确性了,函数签名、参数类型、返回类型等等,这些都需要你亲自和服务提供方进行口头沟通(wiki、文档等)。而且更重要的是,你需要提前知道对应服务的IP和端口号才能进行RPC。当然,你依然可以提前人肉沟通好你依赖的服务的IP和端口号,然后以配置文件的方式告之你的进程。但由于大部分微服务都以集群的方式来部署,一个集群里有多台服务器都在提供服务,因此你可能会得到一个IP+PORT的列表。你依然可以将这个列表写到配置文件里,但是问题也随之而来:

  • 如果依赖的服务器宕机了怎么办?
  • 怎么判断某台服务器是否正常?
  • 该服务器所在集群扩容了怎么办?

这几个问题都是在实际中会经常遇到的,某个服务会随着业务量的增长而承受更大的压力,于是会进行横向扩展(也就是加机器),这时该集群的服务器就从x台变成x+k台。如果你把集群的IP+PORT写到配置文件中,那么新增的IP+PORT你将无法获知,你的请求压力依然会落到之前的机器上。对调用方来说似乎无所谓,但是对于服务提供方来说便是巨大的隐患。因为这意味着它的扩容虽然增加了机器但实际上并没有生效(因为调用方还是call的原来的机器)。

解决这个问题的办法就是—— 服务发现 。我们需要一个单独的服务,这个服务就像DNS一样,使得我能通过别名获取到对应服务的IP+PORT列表。比如你可以发送 GET serviceA ,然后该服务返回给你serviceA集群的所有机器的IP+PORT。

当你拿到一系列IP之后,你又会面临另一个问题,到底使用哪个IP呢?这里就会出现另一个我们经常听到的名词—— 负载均衡 。通常情况下,我们希望请求能均匀的分散到所有机器上,这样不至于使得某台机器负载过大而另一台机器压力过小。我们就需要尽可能公平的使用这些IP,因此需要引入一些算法来帮助我们选择:

  • 轮询(加权轮询)
  • 随机(加权随机)

为什么会有加权轮询、加权随机?这很可能是因为我们实际的物理机配置不一样,虽然都在一个集群,有些是8核CPU有些是4核,内存也有差异,加权算法使得我们可以人为配置哪些机器接受请求多一些哪些少一些。

还有一些特殊场景,我们希望相同特征的请求尽量落到同一台服务器,比如同一个用户的请求我们可能希望它落到固定的某台机器(虽然这么做不太合理,这里仅举例),我们也可以在负载均衡算法上做文章,使得我们的目的达成。

另一个问题是,依赖的服务可能会宕机,如果我们的负载均衡算法刚好选中了该IP,那么很显然我们这次请求将会失败。因此我们的服务发现需要尽量保证它存储的是 最新的健康的 服务的IP+PORT。怎么来完成这个工作呢?—— 服务注册健康检查

服务注册是说,每当新启动一个服务进程时,它会主动告诉“服务中心”:“Hi,我是serviceX集群的一个实例,我的IP是a.b.c.d我的端口是xxxx。”这样,当客户端去服务中心查找serviceX的ip地址时,就能查到最新实例的IP了。换句话说,我们的服务发现自动支持集群的扩容了!

不过任何集群都可能会出现各式各样的故障,比如说停电,机器死机,甚至是系统资源被恶意程序耗尽导致正常进程被kill等等。这时,我们希望服务中心能及时地把这些故障机器的IP从集群中移除,这样客户端就不会使用到这些有问题的服务器了。这便是健康检查。由于服务挂掉都是因为各种各样的突然因素,因此不可能由服务本身在进程异常时主动上报,只能有服务中心来进行定期的检测。一般来说,health check有两种方法:

  • ping
  • HeartBeat

对于第一种方法,如果能ping通该台机器,我们就认为服务是健康的。当然,这是一种很不准确的检测方法,它只能保证机器不宕机,但是并不知道该台机器上实际进程的运行情况,有可能进程已经被kill掉。因此ping只是一种比较简便但不够准确的检测方式:

  • ping不通,一定不健康
  • ping通,可能不健康

另一种方式是服务中心定期去curl某个服务的指定接口,根据接口返回值来确认服务的状态。这种方式更合理,它能够真正检测到某台服务器上进程的状态,包括进程死锁导致服务无响应等。这种方式如果curl失败,那就一定可以说明服务不健康。对于不健康的服务,服务中心可以根据一定的策略把它的IP摘除,这样使得客户端能够最大可能拿到可用的服务IP。

为什么上面说“根据一定策略”摘除,而不是直接摘除呢?因为curl是网络请求,curl不通有可能是网络抖动,也有可能是对端服务器由于某些原因使得CPU占用率突然飙高,导致响应变慢或超时,但是可能很快就恢复了。因此对于摘除,也需要有一定的重试策略。

但是截至目前,我们忽略了一个非常严重的问题,那便是“服务中心”也是一个服务,挂了怎么办?谁又来告诉我们服务中心的IP?这么一想似乎又回到了解放前…其实不然。

这里先要说一说,服务发现其实有两种方案。我们上面说的是客户端服务发现,也就是每次客户端发送请求前先去服务中心获取IP并在本地通过负载均衡算法选取其一。其实还有另一种方案,是服务端服务发现。

服务端服务发现是这样的:客户端调用serviceA时使用固定的一个IP,比如10.123.123.10/proxy/serviceA/real_uri。而在服务端会有专门的服务来代理这个请求(比如Nginx)。根据URI它可以识别出你要调用的服务是serviceA,然后它找到serviceA的可用IP,通过预设的负载均衡算法直接把rewrite后的请求IP:Port/real_uri反向代理到对应机器上。

这两种方案各有优劣,很多时候是共存的,这样可以取长补短。客户端服务发现的缺点是,所有语言都需要一个服务发现的SDK,既然是SDK那发版之后再想升级就难了…服务端服务发现的缺陷是,它是个单点,一旦挂了对整个公司都是灾难性的。

这里你又会问了,客户端服务发现也需要向“服务中心”去取IP列表,那个服务中心不也可能成为单点吗?确实如此!因此一般需要客户端缓存服务中心的结果到本地文件,然后每次去本地文件读取 service->[ip:port,] 的映射关系,然后定期轮询服务中心看映射关系是否发生变化,再更新本地文件。这样,即使服务中心挂掉,也不至于造成灾难性的后果。还有一种方式,干脆服务中心只做推送,服务中心把 service -> [ip:port] 的映射作为配置文件推送到所有服务器上,客户端直接去读本地文件即可,不再需要轮询了。如果有新机器加入或者被摘除,服务中心重新进行推送即可。

很多团队和服务发现解决方案甚至使用上了强一致性的etcd来做存储,我个人认为这并不妥当。所有分布式系统当然都希望一致性越强越好,但是一定能够分辨业务对一致性的要求,是必须强一致否则系统无法运行,还是最终一致即可但是期望越快越好。我认为服务发现并不是一个要求强一致性的场景,引入etcd只是徒增复杂性并且收效甚微。

你看,对于实施微服务来说,单纯地想调用别的服务的方法,就有这么多需要解决的问题,而且每个问题深入下去都还有很多可优化的点,因此技术升级确实代价不小。但是开源软件帮助了我们,不是吗?由于服务发现的普遍性,开源界已经有很多成熟的解决方案了,比如JAVA的Eureka,比如Go的Consul等等,它们都是功能强大的”服务中心“,你通过简单地学习就能快速使用到生产环境中了。

服务发现就完了吗?当然不是了,上面说的仅仅是技术层面的东西,实际上还有很多细节内容,这些细节设计才决定着服务发现系统的扩展性和易用性。比如,如果有多机房,服务名怎么统一?换句话说,对于订单服务,广州机房的client希望拿到广州机房的订单服务集群的IP而不是巴西机房的,毕竟跨机房访问的延时是很高的。除了多机房问题,另一个问题是多环境问题。大多数公司都会有这么三个相互隔离的环境:生产环境、预览环境、开发测试环境。预览环境和生产环境一样,就是为了模拟真实的线上环境,唯一的不同是预览环境不接入外部流量而已。对于多机房、多环境,其实有个简便的方法,就是把服务名都设计成形如serviceX.envY,比如order.envGZ、order.envTest、order.envPre…客户端在启动时需要根据自身所在环境提前实例化服务发现组件,后续请求都自动附加上实例化参数做为后缀。

陡增流量

我们的系统一定会有个承压阈值,QPS高于这个阈值后,平均响应时间和请求数就成正比关系,也就是说请求越多平均响应时间越长。如果遇到公司做活动,或者业务本身就是波峰波谷周期性特别明显的场景,就会面临流量陡增的情况。当流量发生陡增时,服务的整体响应时间将会变长;而与此同时,用户越是感觉响应慢越急于反复重试,从而造成流量的暴涨,使得本身就已经很长的响应时间变得更长,使得服务502。

这是一个可怕的恶性循环,响应越慢,流量越大,流量越大,响应更慢,直至崩溃。如果你的服务是整个系统的核心服务,并不是可以被降级的服务(我们后面会聊降级),比如鉴权系统、订单系统、调度系统等等,如果对陡增的流量没有一个应对方式,那么很容易就会崩溃并且蔓延至整个系统,从而导致整个系统不可用。

应对方式其实也很简单,就是 限流 。如果某个服务经过压力测试后得出:当QPS达到X时响应的成功率为99.98%,那我们可以把X看做是我们的流量上限。我们在服务中会有一个专门的限流模块作为处理请求的第一道阀门。当流量超过X时,限流模块可以pending该请求或者直接返回HTTP CODE 503,表示服务器过载。也就是说,限流模块最核心的功能就是保证同一时刻应用正在处理的请求数不超过预设的流量上限,从而保证服务能够有比较稳定的响应时间。

那么限流模块应该怎么实现呢?最简单的就是计数器限流算法。不是要保证QPS(Query Per Second)不大于X吗,那我是不是只需要有一个每隔一秒就会被清零的计数器,在一秒钟内,每来一个请求计数器就加一,如果计数器值大于X就表明QPS>X,后续的请求就直接拒绝,直到计数器被清零。这个算法很容易实现,但是也是有弊端的。我们实际上是希望服务一直以一个稳定的速率来处理请求,但是通过计数器我们把服务的处理能力按照秒来分片,这样的弊端是,很可能处理X个请求只需要花费400ms,这样剩下600ms系统无事可干但一直拒绝服务。这种现象被称为突刺现象。然而你可以说,这个算法是没问题的,因为这个阈值X是开发人员自己配置的,他设置得不合理。不过作为算法提供方,当然需要考虑这些问题,不给用户犯错的机会岂不是更好?事实上,把服务按照秒来划分时间片本身也不是很合理,为什么计数器的清零周期不是100ms呢,如果设置为Query Per Millisecond是不是更合理?Microsecond是不是更精确?当然,以上问题只是在极端情况下会遇到,绝大多数时候使用计数器限流算法都没有问题。

限流的另一种常用算法是令牌桶算法。想象一个大桶,里面有X个令牌,当且仅当某个请求拿到令牌才能被继续处理,否则就需要排队等待令牌或者直接503拒绝掉。同时,这个桶中会以一定的速率K新增令牌,但始终保证桶中令牌最多不超过X。这样可以保证在下一次桶中新增令牌前,同时最多只有X个请求正在被处理。然而突刺现象可能依然存在,比如短时间内耗光了所有令牌,在下一次新增令牌之前的剩下时间里,只能拒绝服务。不过好在新增令牌的间隔时间很短,因此突刺现象并不会很突出。并且突刺现象本身就很少见,因此令牌桶算法是相比于计数器更好也更常见的算法。不过你也可以看到,不同的算法来进行限流,本质上都是尽量去模拟“一直以一个稳定的速率处理请求”,不过只要这个模拟间隔是离散的,它始终都不会完美。

对于限流来说,业界其实也有比较多的成熟方案可选,比如JAVA的Hystrix,它不仅有限流的功能,还有很多其它的功能集成在里面。对于Golang来说有golang.org/x/time里的限流库,相当于是准标准库。

我们到目前为止聊的应对陡增流量都是从服务提供方的角度来说的,目的是保证服务本身的稳定性。但是同时我们也可以从服务调用方的角度来聊聊这个问题,我们叫它—— 熔断 。当然熔断并不是单纯针对陡增流量,某些流量波谷时我们也可能需要 熔断

当作为服务调用方去调用某个服务时,很可能会调用失败。而调用失败的原因有很多,比如网络抖动,比如参数错误,比如被限流,或者是服务无响应(超时)。除了参数错误以外,调用方很难知道到底为什么调用失败。这时我们考虑一个问题,假设调用失败是因为被依赖的服务限流了,我们该如何应对?重试吗?

显然这个问题的答案不能一概而论,得具体看我们依赖的服务是哪种类型的服务,同时还要看我们自身是哪种服务。

我们先来看一种特殊的场景,即我们(调用方)是一个核心服务,而依赖是一个非核心服务。比如展示商品详情的接口,这个接口不仅需要返回商品详情信息,同时需要请求下游服务返回用户的评价。假如评价系统频繁返回失败,我们可以认为评价系统负载过高,或者遇到了其它麻烦。而评价信息对于商品详情来说并不是必须的,因此为了减少评价系统的压力,我们之后可以不再去请求评价系统,而是直接返回空。

我们不再请求评价系统这个行为,称之为 熔断 ,这是调用方主动的行为,主要是为了加快自己的响应时间(即使继续请求评价系统,大概率依然会超时,什么返回都没有,还白白浪费了时间,不如跳过这一步),不过同时也能减少对下游的请求使下游的压力减小。

当我们进行熔断之后,原本应该返回用户的评价列表,现在直接返回一个空数组,这个行为我们称之为 降级 。因为我们 熔断 了一个数据链路,那么之后的行为就会和预期的不一致,这个不一致就是降级。当然,降级也有很多策略,不一定是返回空,这个需要根据业务场景制定相应的降级策略。

另一个典型的场景是,非核心服务调用核心服务,比如一个内部的工单系统,它可能也需要展示每个工单关联的订单详情。如果发现订单系统连续报错或者超时,此时应该怎么办?最好的办法就是主动进行熔断!因为订单系统是非常核心的系统,在线业务都依赖于它,没有它公司就没法赚钱了!而工单系统是内部系统,晚一些处理也没关系,于是可以进行熔断。虽然这可能导致整个工单系统不可用,但是它不会增加订单系统的压力,期望它尽可能保持平稳,也就是那句话:“我只能帮你到这里了”。不过实际上到底能不能进行自我毁灭式的熔断依然要根据业务场景来定,不是想熔断就熔断的,有些业务场景可能也无法接受熔断带来的后果,那么就需要你和相关人员制定降级策略plan B。

总之,熔断和降级就是调用方用来保护依赖服务的一种方式,很多人都会忽略它。但这正如你家里的电路没有跳闸一样,平时感觉不到有啥,一旦出事儿了后果就不堪设想!

那么,我们到底什么时候需要进行熔断?一般来说,我们需要一个专门的模块来完成这个工作,它的核心是统计RPC调用的成功率。如果调用某个服务时,最近10s内有50%的请求都失败了,这可以作为开启熔断的指标。当然,由于依赖的服务不会一直出问题(毕竟它也有稳定性指标),因此熔断开启需要有一个时间段,在一段时间内开启熔断。当一段时候过后,我们可以关闭熔断,重新对下游发起请求,如果下游服务恢复了最好,如果依然大量失败,再进入下一个熔断状态,如此往复…

前面提到的JAVA用于限流的模块Hystrix,它也集成了熔断的功能,而且它还多了一个叫 半熔断 的状态。当失败率达到可以熔断的阈值时,Hystrix不是直接进入熔断状态,而是进入半熔断状态。在半熔断状态,有一部分请求会熔断,而另一部分请求依然会请求下游。然后经过二次统计,如果这部分请求正常返回,可以认为下游服务已经恢复,不需要再熔断了,于是就切换回正常状态;如果依然失败率居高不下,说明故障还在持续,这时才会进入真正的熔断状态,此时所有对该下游的调用都会被熔断。

Hystrix的半熔断状态可以有效应对下游的瞬时故障,使得被熔断的请求尽可能少,从熔断状态回复到正常状态尽可能快,这也意味着服务的可用性更高——一旦进入熔断状态就回不了头了,必须等熔断期过了才行。

实现熔断功能并不像实现限流一样简单,它复杂得多:

  • 熔断需要介入(劫持)每个RPC请求,才能完成成功率的统计
  • 需要提供方便的接口供用户表达fallback逻辑(降级)
  • 最好能够做到无感知,避免用户在每个RPC请求之前手动调用熔断处理函数

由于熔断和降级的功能对用于来说更像是一种函数的钩子,它不仅要求功能完备,更需要简单易用,甚至是不侵入代码。也就是说,熔断模块不仅在实现上有一定技术难度,在易用性设计上也很有讲究。一个很容易想到的并且能够将易用性提升的方法就是wrap你的http库,比如提供特殊的http.Post、http.Get方法,它们的签名和标准库一致,不过在内部集成了熔断的逻辑。当然,像Hystrix一样使用一个对象来代理执行网络请求,也是一种不错的思路。

在熔断和降级方面,业界主要的比较成熟的方案就是Netflix的Hystrix,其它语言也很多借鉴Hystrix做了很多类似的库,比如Go语言的Hystrix-go。可以肯定的是,服务限流和熔断等工作,真正落地实施时还有很多困难和可以优化的点,这里只是带你简单游览一番。

我们讲了服务发现和注册,服务限流和熔断降级,这些概念伴随着微服务而出现,因此我们需要解决它。但是仔细想一下,为什么实施了微服务,就会遇到这些问题?实际上最根本的原因是,微服务松散的特性使得它缺少一个全局的 编译器 。单体应用中添加和使用一个模块,直接编写代码即可,编译器可以来帮你做剩下的事情,帮你保证正确性。而微服务架构中,各个服务间都是隔离的,彼此不知道对方的存在,但又需要用到对方提供的方法,因此只能通过 约定 ,通过一个中心来互相告知自己的存在。同时在单体应用中,我们可以很容易地通过压测来测试出系统的瓶颈然后来进行优化。但是在微服务架构中,由于大多数时候不同服务是由不同部门不同组来开发,把它们集成起来是一件很费劲的事情。你只能通过全链路压测才能找到一个系统的瓶颈,然而实施全链路压测是非常困难的,尤其是在已有架构体系上支持全链路压测,需要非常深地侵入业务代码,各种trick的影子表方案…全链路压测是另一个非常庞大的话题,跟我们的话题不太相关,因此我不打算在这里长篇大论,但是很明确的一点是:由于无法实施全链路压测,所以微服务中我们只能进行 防御性编程 ,我们必须假设任何依赖都是脆弱的,我们需要应对这些问题从而当真正出现问题时不至于让故障蔓延到整个系统。因此我们需要限流,需要熔断,需要降级。

所以你可以看到,很多技术并不是凭空出现的,当你解决某个问题时,可能会引入新的问题。这是一定的,所有技术的变革都有代价。不过要注意,这和你边改Bug边引入新Bug并不一样:P。

服务间通信

我们上面一起聊了微服务之间如何相互发现(相当于实现了编译器的符号表),也聊了当出错时怎么保护下游和自我保护。但是微服务的核心是服务间的通信!正是服务间通信把小的服务组合成一个特定功能的系统,我们才能对外提供服务。接下来我们来聊一聊服务间通信。

由于不同的服务都是独立的进程,大多数都在不同的机器,服务间通信基本都是靠网络(同一台机器的IPC就不考虑了)。网络通信大家都知道,要么是基于面向有连接的TCP,要么是面向无连接的UDP。绝大多数时候,我们都会使用TCP来进行网络通信,因此下面的讨论我们都默认使用TCP协议。

一说到通信协议,很多人脑海中可能就会跳出一个名词: RESTful 。然而RESTful并不是一个协议,而是基于HTTP协议的一种API设计方式。使用RESTful意味着我们使用HTTP协议进行通信,同时我们需要把我们的业务按照 资源 进行建模,API通过 POST DELETE PUT GET 四种方法来对资源进行增删改查。由于绝大多数企业的用户都是通过浏览器或者手机APP来使用服务的,因此我们可以认为:

对用户直接提供服务时,通信协议一定要使用HTTP

既然一定需要用HTTP(1.1)那就用吧,似乎没有讨论通信协议的必要了?不,当然有必要了!

首先我们需要了解的一个事实是,绝大部分直接和用户打交道的接口都是 聚合型接口 ,它们的工作大多是收集用户请求,然后再去各下游系统获取数据,把这些数据组合成一个格式返回给用户。后面的章节我们会详细讨论这种API接口,我们称之为 API Gateway ,这里先不深入。不过从中你可以发现,仅仅是API Gateway和客户端直接通信被限制使用HTTP协议,API Gateway和它后面的各个微服务并没有限制使用哪种通信协议。

不过让我们先抛开不同协议的优劣,先来看一下发起一次RPC需要经历的步骤:

  1. 客户端根据接口文档,填好必要的数据到某个对象中
  2. 客户端把改对象按照协议要求进行序列化
  3. 发送请求
  4. 服务端根据协议反序列化
  5. 服务端把反序列化的数据填充到某个对象中
  6. 服务端进行处理,把结果按照通信协议序列化并发送
  7. 客户端按照通信协议反序列化数据到某个对象中

可以看到,RPC需要根据协议进行大量的序列化和反序列化。但是通信协议是给机器看的,只有接口文档才是给程序员看的。每次调用一个下游服务都需要对照文档组装数据,服务方也必须提供文档否则没有人知道该如何调用。换句话说

在RPC中,接口文档是必须存在的

既然接口文档存在,实际上问题就简化了,因为我们可以写一个很简单的代码生成器根据文档生成调用接口的代码。既然程序员只关心接口文档的参数,剩下的代码都可以自动生成,那么通信协议使用什么就无所谓了,只要调用方和服务提供方使用一样的协议即可。既然用什么通信协议无所谓了,而且不论协议多复杂反正代码也能自动生成,那为什么不使用性能更好的传输协议呢?

所以你可以看到,具体使用什么通信协议其实是一个自然选择的过程,反正都是面向接口文档利用生成器编程,选择性能更好的协议属于免费的午餐,那当然选性能好的协议了。不过这并不代表你值得花精力去开发一个拥有极致卓越性能的协议,因为:

  • 耗时大部分都是网络传输和IO,协议多些字节解码多费点时间只是小意思
  • 生态,小众的协议很难利用现有的基础设施

总之,在API Gateway背后的微服务之间,选用高性能的传输协议基本是免费的午餐,因此我们应该一开始就使用某种协议。业界有很多开源的高性能通信协议,比如Google的ProtoBuf(简称PB)和Facebook贡献给Apache的Thrift,这两个协议都是被广泛使用于生产环境的。

不过很多人不知道gRPC和PB的区别。gRPC其实是个服务框架,可以理解为一个代码生成器。它接收一个接口文档,这个文档用PB的语法编写(也称为IDL),输出对应的server端和client端的代码,这些代码使用PB协议来对数据进行序列化。而对于Thrift,我们通常没有这种混淆,因为thrift序列化方法一直是和与其配套的代码生成器同时使用的。

在我们选定协议之后,服务间通信就告一段落了吗?当然不是!可以说微服务相关的技术栈都是围绕服务间,后面还有很多需要解决的问题。

比如在单体应用中,加入我们发现一个漏洞,修复的方法是让获取订单详情的函数增加验证用户的token。此时我们需要改动获取订单详情的函数签名以及它的内部实现,同时在各个调用处都加传token参数,然后通过编译即可。但是在微服务中,由于系统间是隔离的,单个服务的改动别的服务无法感知,上线也不是同步的。这意味着如果我修改了接口签名并重新上线后,所有依赖于我的服务将会立刻失败!因为根据之前的接口定义生成的client对数据的序列化,此时新的server端无法成功反序列化出来。

当然,这个问题gRPC和Thrift也早已经考虑到。它们的IDL让你在定义接口时,不仅要给出参数名和类型,同时还需要编号。这个编号就用来保证序列化的兼容性。也就是说,只要你更新接口定义是通过在结构体后面增加参数而不是删除或者修改原参数类型,那么序列化和反序列化是兼容的。所以解决上面问题的方法也很简单,只需要在原来定义的结构体后面增加一个Token字段即可,服务端做兼容。传了Token的就验Token,没传Token的依然可以按照老逻辑运行,只是你需要统计哪些上游还没有更新,然后去逐个通知他们。

到这里你也能发现微服务架构面临的一个比较严峻的问题,想要全量升级某个服务是非常困难的,想要整个系统同时升级某个服务是几乎不可能的。

gRPC和Thrift都是非常常用的RPC框架,它们的优劣其实并不太明显,如果一个比另一个在各方面都强的话,就不需要拿来比了…Thrift由于时间更长,支持的语言更多功能更齐全;而gRPC更年轻,支持的语言更少,但是gRPC集成了Google出品的一贯作风,配套设施和文档、教程非常齐全。当然它们还有很多性能上的差异,但是这些差异大多是由对应语言的geneator造成的,并不是协议本身。所以实际上你可以随意选择一个,只要整个公司统一就行,我个人更建议gRPC。

我们上面的讨论也讲了,我们在升级服务接口时需要 统计 哪些上游还在用过时的协议,方便我们推动对方升级。由于不同接口定义都不一样,差异化很大,以现有的架构几乎无法实现旁路追踪,只能在服务端进行埋点,在反序列化之后服务端自己来判断,从而统计出需要的信息。有没有更好的办法呢?我们后面再聊。

Tracing

我们上面说了很多和微服务息息相关的点,比如限流,比如熔断,比如服务发现,比如RPC通信。但如果仅仅是这些,你会觉得整个系统还是很模糊,很零散,你不知道一个请求通过API Gateway之后都调用了哪些服务——因为你缺少一个全局的视图。

对于单体应用来说,最简单的全局视图就是backtrace调用栈。通过在某个函数中输出调用栈,可以在运行时打印出从程序入口运行到此的层层调用关系。哪个模块被谁调用,哪个模块调用了谁,都一目了然(其实backtrace的输出一般也不太好看…)。更强大一点的,比如说JAVA编写的程序,通过在eclipse中安装插件CallGraph,就能静态分析出各个对象和方法的调用关系,并以图像来展示,非常直观。但是对于微服务来说,下游服务无法打印出它上游服务的backtrace,也没有任何编译器能把所有服务的代码合并起来做静态分析。因此对于微服务来说,要得到调用关系的视图并不容易。

Google在一篇名为Dapper的论文中,提出了一种方法用于在微服务系统中“绘制”调用关系视图。不过抛开具体的论文,我们自己其实也能很容易地把tracing划分出三个比较独立的部门:

  • 业务埋点
  • 埋点日志存储
  • Search+可视化UI

但是事实上调用链路追踪是个很复杂的 系统 ,而不单单是某个微服务中的一个模块,它是重量级的。不像之前说的限流、熔断等可以通过引入一个开源库就能实现,它的复杂性体现在:

  • 业务埋点是个艺术活,怎么样才能是埋点负担最小同时埋点足够准确。另一方面,就像之前提到服务升级的话题,在微服务中一旦代码上线后,想再全量升级是非常困难的。埋点收集的数据要足够丰富,但是太丰富又会给业务带来负担,必须提前规划好哪些是必要的,这很难
  • 一旦系统规模做大,RPC调用是非常多的,埋点收集数据将非常多,需要一个稳定的存储服务。这个存储不仅要能承载海量数据,同时需要支持快速检索(一般来说就是ES)
  • 需要单独的界面能够让用户根据某些条件检索调用链路,并进行非常直观的图形化展示

Dapper最重要的其实就是它提出了一种日志规范,如果每个业务埋点都按此标准来打日志,那么就可以以一种统一的方式通过分析日志还原出调用关系。一般来说,Tracing有以下几个核心概念:

  • Trace: 用户触发一个请求,直到这个请求处理结束,整个链路中所有的RPC调用都属于同一个Trace
  • Span: 可以认为一个RPC请求就是一个Span,Span中需要附带一些上下文信息支持后续的聚合分析
  • Tag: Tag是Span附带的信息,用于后续的检索。它一般用来把Span分类,比如db.type="sql"表示这个RPC是一个sql请求。后续检索时就可以很容易把进行过sql查询的请求给筛出来

这里只是简单列举了Tracing系统最重要的三个概念。如果一条日志包含了 traceID spanID Tag,相信你也能很容易地利用它们绘制出请求调用链路图。当然,这其实也不用你自己来实现,业界已经有比较成熟的开源方案了,比如twitter开源的zipkin和Uber开源Go的jaeger(jaeger已经进入CNCF进行孵化了,进入CNCF意味着它通常可以作为分布式、云计算等领域的首选方案)。但是它们和之前所说的各种限流或者熔断组件不一样,它们并不是一个库,而是一个整体的解决方案,需要你部署存储和Dashbord,也提供给你SDK进行埋点。但是由于Docker的存在,实际上部署也非常简单(Docker我们后面会细聊)。

然而,jaeger和zipkin也各有各的不足,比如它们薄弱的UI。因此还有很多类似的项目正在被开发。考虑到通用性,所以业界一开始就先出了一个OpenTracing项目(也进入了CNCF),它可其实是一个interface定义。它致力于统一业务埋点收集数据的API和数据格式,这样使得大家可以把中心放到展示层等其他方面。由于有了一致的数据,用户也能随意切换到别的系统。jaeger和zipkin都实现了OpenTracing规范。

不过总的来说,服务链路跟踪是一个很庞大的工作,有很多需要优化和订制的地方。如何快速响应用户的查询,这依赖于高性能的存储引擎。随着数据量的增加,存储的容量也会成问题。当然,展示是否直观,是否能从Tag或者Log里挖出更多信息,也是非常重要的。一般来说,这都需要一个团队深入去做。Tracing实际上是一个比较深的领域,要做好不容易,这里也就不深入下去了,感兴趣可以从Dapper开始看起。

这篇文章已经很长了,但实际上微服务中还有非常多的topic没讲。即使我们讲过的topic,大多也是泛泛而谈,比如服务发现系统其实就是一个非常复杂的系统。每一个点都值得我们程序员去学习钻研。

在后续的文章中我会接着讲监控、日志等在微服务中应用。微服务体系有这么多需要解决的问题,但实际上更重要的问题是,如何交付系统,这涉及到持续集成和持续部署相关话题。在现有的架构体系中,持续集成和部署并不是一件容易的事情,很多时候它们可能会让运维同学疲于奔命,因此我们会讲到Docker到底是如何解决这些问题,以及简单聊一聊Docker的原理。Docker的出现给微服务架构插上了翅膀,使得微服务以更快的速度普及。但是所有团队都会面临微服务带来的新问题,而这些问题实际上并没有被系统的解决。Docker使得一个个的微服务就像一个函数一样简单,但是正如单体应用是由一系列函数按一定逻辑组合而成,我们的系统也是由一系列微服务构建而成。这种组合函数的工作并不会消失,只是从单体应用中的Controller迁移到了 容器编排 ,我们会看到Swarm和Kubernates是如何解决这些问题的。Kubernates是一个革命性的软件,它的抽象使得我们前面聊的Topic可以有更先进更纯粹的解决方案,比如服务网格ServiceMesh……还有好多好多,我会在下一章细细道来


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK