起点
首先,我们得有一个“服务”。根据定义,我们可以把每个服务实例都视作一个黑盒。这个盒子有着明确的输入点和输出点,并且(理想情况下)仅通过这些输入和输出点和外界产生关联。每个服务实例会拥有专属的网络地址、独立的计算资源,并且独立部署。客户端通过访问服务实例的地址来调用服务 API。不同服务也可以相互调用。
配置管理器:统一管理配置
在微服务体系中,每个服务都独立部署和运行,团队可以根据需要自行选择增加和减少计算资源。一个服务可能会跑多个实例,每个服务实例都会需要做配置。为了方便统一调整配置,我们可以把配置中心化,每个服务实例都去找配置管理器(Configuration Manager)拿配置。当配置更新的时候,我们也可以让服务实例再去拿新的配置。
服务名册:解耦主机地址
这也引出了一个问题:网络地址(比如 IP)很容易因为扩容、维护而变动,调用者难以实时获知可用的地址。
鉴于此,我们可以把网络地址抽象成不容易变动的概念,比如给每个服务一个固定的名字。互联网使用 DNS 来解决这个问题,对应到微服务基建里面就是服务名册(Service Registry)。
每个服务实例在运行期间,都会以心跳的形式向服务名册发送注册信息,包括服务的 ID 、访问地址以及健康状况。这样,需要访问服务的时候,客户端就可以先问服务名册拿可用的实例地址,然后再访问实例来调用服务。除了更好地定位实例地址,服务名册还可以在某些实例下线、维护或升级的时候把其临时从名册中去掉,让服务不断线。
服务之间的调用也是如此,先找名册拿网络地址,再进行调用。
API 网关:入口和路由
找名册要地址,然后调用服务 API,这些是每个客户端都会去做的琐事,我们完全可以把这些事情抽象、集中,把服务的 API 整合到一个大的中心点,然后把要地址和调用服务 API 这样的细节封装起来,所有客户端都只跟这个中心点对话,不再直接访问单个服务。
从结构上看,这个中心点把整个架构划分成了内外两部分,内部是所有的服务,客户端则在外部,中心点站在中间。它作为内外的唯一通道,被顺理成章地命名作“API 网关”(API Gateway),有时候也被称做“边缘服务”(Edge Service)。
API 网关作为唯一出入口,又占据了最前沿的有利位置,所以有时还会承载别的公共功能,比如我们马上会提到的鉴权。
鉴权服务:身份和权限问题
顺着这个架构继续开发,我们会遇到新的问题:不方便的鉴权。
鉴权(Auth)包括了两个部分:身份认证(Authentication)和权限验证(Authorization)。身份认证关心的是“你是谁”,权限验证关心的是“你能不能做某件事”。
身份和权限都是高度中心化的概念。
对于一个系统来说,用户的身份必须是统一的。不能说这个用户在做这个事情的时候是张三,做那个事情的时候是李四。此外,用户的认证状态也应该是统一的。不能说用户访问这个服务的时候是已登录认证,访问另一个服务时又是未登录状态。所以,只能有一个身份认证方。
权限稍微复杂一点。和身份不同,权限通常分成两种类别:功能权限和数据权限。这样的划分对应了现实世界中常见的权限模式:你的角色决定了你的职能,而职能范围通常由附加条件来限制。比如,你是一个法官,对案件有裁决权,但是你是 A 区的法官,只能判 A 区的案子。再比如,某个快餐门店的经理有权看员工的详细资料,但是只能看自己门店的员工资料。
两种权限都由全局的规则来确定,而不掌握在执行部门。比如,谁来判案,取决于法律,而不取决于法院。谁能查看谁的资料,也不由资料保管部门决定,而由规章制度决定。
在现实的情况中,组织可能会有专门的审核部门来验证权限,但对那些不是特别敏感的权限,企业会让各个部门自行验证。不过不管谁来执行验证,都必须拿着同一份规章制度,不能各说各话。这份制度必须由中心机构来统一制定、维护。也就是说,权限的管理也应该中心化。
明确鉴权中心化之后,我们就可以开发一个公用的鉴权服务,执行身份认证和权限验证。下一个问题是:谁来发起鉴权?
所有服务的调用都要求调用者明确自己的身份,所以自然身份认证越靠前越好。作为出入口的 API 网关自然是发起身份认证的不二之选。权限验证则稍微复杂,完全值得另起一文详述。此处我们暂时假定权限验证也由 API 网关来发起。
消息中介:异步和通知
开发继续进行,一切风平浪静,技术上暂时没有什么问题。不过,业务上有一个问题需要解决。
比如,我们做一个在线商城,要求在订单成功创建的一刻,仓库就要启动备货和发货的流程。问题是,订单和仓储是两个服务,不同团队在负责,而且从关注点来说,订单服务并不关心仓储相关的问题,所以订单服务不可能在创建订单的时候去主动通知仓储服务。仓储服务只能定时轮询订单服务,看看有没有新的订单。这不仅麻烦,而且实时性不够。
仔细想想,我们会发现这种需求很常见,信息的产生者并不知道(也不关心)谁会对信息产生兴趣。比如我们可能会有一个监控服务需要实时展示产品销量,有一个 BI 服务需要获取客户购买产品的信息来做分析,等等。既然这是一个常见需求,我们不妨把它模式化,形成一个机制:信息产生者把通知发出来,收到通知的人再确定是否需要采取行动。
这就意味着我们需要再引入一个中心化的公共服务:消息中介(Message Broker)。当某个事件发生的时候(比如用户激活成功、订单创建成功),服务可以朝消息队列发一条消息。而其他服务可以订阅这些消息,并针对这些消息做出反应。
比如,仓储服务可以订阅订单创建成功的消息。这样,订单成功创建后,订单服务将这个消息发到消息中介,消息中介通知仓储服务,仓储服务一看,就问订单服务要新的订单信息,最后,启动出库流程。
消息中介除了能广播事件之外,还能做异步调用。把同步的调用转化成异步的回调。针对调用时间长和不要求实时结果的调用,可以增加性能,提升体验。
前置后端:优化前端开发
走到这里,其实体系已经比较完备。现在的问题是,如何让微服务基建结构和研发团队常见的结构更好地对应起来。这要求我们从康威定律的角度来看待整个基建的设计。
在围绕用户和价值的软件研发流程中,我们常用用户历程和用户故事来捕捉和跟踪价值的实现。一个用户故事通常会包含一个有明确边界、明确验收标准和明确价值的业务步骤。
问题在于,支撑一个故事有前后两端的研发工作,二者是不同步的。前端由业务流程和设计来驱动,希望按顺序产出;后端则由业务资源和建模来驱动,希望按模块来产出。
比如说,前端常常会因为设计的原因调整自己需要的字段,而后端从建模的角度并没有这个需要,也没有动力频繁地去跟随前端的调整,使得前端不得不在不稳定的网络条件下传输多余的信息,占用了宝贵的网络带宽。
此外,前端呈现某个业务步骤的时候,有两种信息不属于当前必备信息,但常常需要和必要信息一起展示。一种是状态信息,比如当前的登录状态和用户名,短消息的数量等等。一种是垂直相关的信息,比如在展示文章的时候顺便展示一下相关的文章。
这就要求前端在调用主服务的同时还要再调用多个不同的服务。且不说这些服务有可能会有调用超时、出错的可能,仅仅是多出来一堆异步请求,就已经足够让前端效率降低一大截了。
在微服务体系下,这些问题更加严重,因为现在不仅仅是前后端的差别,不同服务还由不同团队负责。这些团队的诉求和日程不一,很难做到前端所需要的快速响应。
这些问题和麻烦可能会催生一个“缓冲带”,比如后端出专人来负责对接前端的需要,或者前端派驻一个人到后端来谈需求。按康威定律,这种沟通体系,久而久之,很容易以软件的形式沉淀下来,形成一个专属的中间层。
要调和前后端的不同步是不可能的,而这种中间层是自然催生的解决方案,可以保留。新的问题是,它的职责是什么?应该把它放在哪?应该由谁来维护?
分析下来,其责任有二。第一是解耦前后端的工作,降低相互的影响。前端需要的东西可以写在中间层里,让它频繁变化也没有关系。后端如果还没有准备好,前端也可以在这一层模拟假的数据,不至于被阻塞。第二则是提升前端的运行效率。前端可以把所需要的多个服务的东西统一汇总,一次拿完,免得发多个请求。
放置的位置则在 API 网关之内,让它可以享有 API 网关所带来的好处和保护。
最后是维护问题。按照“谁主张,谁举证”的原则,既然有了这个中间层,好处让前端得了,那么,理论上应该由前端来维护。
这样,一个主要为前端服务的中间层就定义好了。不同类型的前端(桌面、移动)可能会有不同的需要,为了避免中间层的碎片化,我们可以让各个中间层都特定的前端类型紧密耦合,比如桌面专用、移动专用。如此,每个中间层都像是某类型前端的专享后端,所以“前置后端”(Backend-for-Frontend,简称 BFF)也因此得名。
回路熔断器:提高容错度
现在,调试也方便了,我们又继续开发。一开始没有什么问题,但部署到预生产环境的时候,又一个问题出现了:整个体系的容错度很低。一个小错误容易被层层传递和放大,导致整个体系的崩溃。
我们都知道,编程最麻烦的就是远程调用。本地调用大部分时候结果是“成功”或“失败”,但远程调用则很可能是“无响应”。“无响应”有可能是正常的,对方可能稍后会给你结果,也可能是因为对方已经死了,没法给你响应。最坏的结果,就是门口挤满了人,大家都在等你给结果,而你也在等别人给结果,资源全部占用来等,什么也做不了。
不过,远程调用是无法避免的。在微服务体系中,这个问题被进一步放大。这是因为微服务的模块化以服务为单位,而每个服务独立部署和运维,使得服务之间的调用成了家常便饭。
在这种严峻的情况下,我们必须从架构上尽量提高整个服务体系的容错度,让个别服务的问题不至于影响到全局。
具体的做法,则是给远程调用加一个熔断阈值检查,当调用超时次数超过阈值时,就不再调用,直接返回错误。过一段时间之后,再把阈值恢复,尝试继续调用,重复前面的过程。这个机制就是回路熔断,而这个工具则是回路熔断器(Circuit Breaker)。
除了隔离已经出错的服务实例,熔断器还有一个重要的功能是提供备用方案。虽然我们把所有业务都拆成了服务,但服务有高低贵贱之分。有一些服务属于关键服务,一旦出问题,则整个流程无法继续,有一些则属于分支服务,即便错了,也不会影响大局。
比如说,购买商品的时候,常常会根据用户的习惯和当前正在购买的东西做一些推荐。负责推荐的服务出问题的话,大不了就不推荐了,不应该影响用户正常的购买流程。同理,如果是在线点餐的地址定位服务出问题了,我们也应该允许用户手动选择餐厅进行点餐——体验虽然不佳,但至少正常的流程仍然可以走完。基于这个考虑,熔断器应该为非必要的服务调用提供备用方案,尽量保证核心流程的顺畅。
有了回路熔断器,远程调用出错的问题就从一定程度上缓解了。结合回路熔断器和对熔断阈值变化的监控,开发者可以更容易地发现问题,并及时采取行动。
负载均衡器:提升服务弹性
要正式上线,我们还必须做好负载均衡(Load Balancing,下简称 LB),提升整个服务的弹性。要做负载均衡,从理论上有两种方式:
客户端负载均衡(Client-Side LB):由客户端来决定如何分散请求。 中间方负载均衡(Mid-Tier LB):由 DNS、网关等中间方来决定如何分散请求。
现在,服务名册中已经有了服务及其对应的实例地址列表,所以客户端的负载均衡最简便的方式就是把地址拉下来,然后依次或者随机选择可用的地址。中间方的负载均衡则选择面较多,从最外层的 DNS 到网关都可以不同程度地去按需要去做。
扩展基建
现在,微服务基建基本完成了。如果有需要,我们可以对这个基建进行扩展。在做扩展时,架构师应该注意区分哪些东西应该中心化,哪些东西应该由服务自行决定。 比如说,在本文提到的基建之中,(几乎是)强制完全中心化的模块有:
配置管理
服务名册
消息队列
其中,配置管理和服务名册是所有服务都需要的基础设施,必然需要统一。消息队列和日志收集都是为了跨服务的操作和追踪,也必须中心化。
半中心化的模块则有:
路由
鉴权
路由和鉴权都必须统一,我们前面讨论过。不过,微服务可能会向外界暴露“自用”和“客用”等多套公共 API(比如快递公司内部使用的物流 API 和开放给第三方使用的物流 API),所以可能会有两个 API 网关,对应会有两套 API 目录和两套鉴权体系,所以,它们是“半中心化”。
这些都是中心化、半中心化的选择范例。每一次中心化的选择都可能会让整个架构变得死板,失去灵活性,所以,我们在设计和扩展基建的时候应该特别注意这个问题。
除了中心化的选择之外,架构发展的另一个关注点,是让业务保持“黑盒”。
我们把每个服务之间的关联抽取了出来,也把权限的定义和验证抽取了出来,每个服务变得简单而纯粹,成了“纯业务式服务”,等同于一个仅包含了业务规则的黑盒。这样,不管服务和模块再多,也没有影响。业务的重用性也很高。
总而言之,搭建好了微服务的必要设施之后,剩下的就要根据实际情况和项目经验来继续调整了。比如,我们可能会选择把很多功能合并到一层,以避免过度分层所带来的不必要的性能损失,或者对整个基建进行一些细节微调。只要把控好“中心-自理”和“业务-非业务”之间的关系,这个基础设施就能健康地发展。
微服务基建总结
总结此文,微服务的基建应该包括如下一些组件(按请求流中的出场顺序):
配置管理:配置集中管理。
API 网关:对外的 API 总目录;API 依赖关系;发起鉴权。
服务名册:服务的注册和发现。
鉴权服务:提供鉴权服务:认证身份,验证功能权限。
前置后端:按前端的需求拆解请求、调用服务,并汇总、转换结果。
消息中介:全局通知机制;异步调用机制。
回路熔断:隔离出问题的服务并等待其恢复;提供备用方案。
负载均衡:避免服务过载。
需要说明的是,这些组件的组合形式,具体拆分形式,是否需要,都需要结合实际项目和团队的情况来调整。本文权作抛砖引玉,请读者知悉。在这里顺便给大家推荐一个架构交流群:617434785,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源。相信对于已经工作和遇到技术瓶颈的码友,在这个群里会有你需要的内容。