微服务概览与治理
微服务概述
单体架构
Browser <-> Gateway <-> Web Server <-> Database / Storage
对于单体架构项目,其项目结构可设计为模块化,但是最终还是会打包并部署为单体式应用。随着业务发展:
应用变得复杂,以至于每位开发者都不能理解。
应用难以扩展、可靠性低。
无法实现敏捷开发和部署。

SOA 与微服务架构
后端服务对外提供统一的一组接口,但完成请求处理所需的数据,由后端多个服务联动完成。
1 | Web Interface |
可以把微服务理解成 SOA(面向服务的架构)的实践:
小即是美:小的服务代码少(bug 少),易测试、维护,容易不断迭代完善。单个服务可独立发版,提高网站可靠性(持续集成)。
单一职责:每个服务可专注做好一件事。
尽早创建原型:尽可能早地提供服务 API,建立服务契约,达成服务间沟通的一致性约定,后续再实现与完善。
可移植性:服务间轻量级交互协议在性能和可移植性二者间,首要考虑兼容性和移植性。
由于运维的职责是控制变更,开发的职责是迭代功能上线,两者本质上有一定冲突。
随着系统复杂度提高,系统生产效率必然下降。对于微服务架构而言,下降的速度和幅度都比单体架构更小。
微服务定义
服务围绕业务功能构建,关注单一业务,相互之间采用轻量级通信机制,可全自动独立部署,可使用不同的编程语言和数据存储技术。微服务架构通过业务拆分实现服务组件化,通过组件组合快速开发系统,业务单一的服务组件可以独立部署,使整个系统清晰灵活。
原子服务:针对单一应用场景、业务提供 API。
独立进程:拆分独立的 Web 服务、数据库,可各自扩缩容、提高吞吐量。
隔离部署:容器编排实现精细化的资源管理,且减少单台服务器故障带来的影响。
去中心化治理:
数据去中心化,服务独享数据库,缓存等设施(也有个别情况由多个服务共享数据库,如面向用户的管理后台和面向管理员的管理后台)。
治理去中心化,比如集中式负载均衡(统一的 LVS / Nginx) -> 去中心化治理(缓存、消息队列、RPC 负载均衡)。
技术去中心化,每个服务可使用合适的技术实施,但是注意技术栈过于发散对于企业或团队也有不利。
微服务缺点
分布式的复杂性:需要使用 RPC(同步)、消息队列(异步)来实现进程间通信;必须在代码层面处理 消息传递效率低、服务不可用 等局部失效问题。
分区的数据库架构:同时更新多个业务主体的事务很普遍,在微服务架构应用中更新不同服务所使用的不同的数据库涉及 分布式事务。
测试复杂:如集成测试、回归测试等,涉及多个服务多个分支。由于服务数量变多,构建稳定环境、确保每个服务正常交付难度加大。
服务间依赖复杂,需要引入链路追踪等工具分析调用状况;且服务间调用(尤其当循环调用时)流量容易被放大,可设计为更粗粒度的接口。
服务 模块间的依赖,应用的升级有可能会波及多个服务模块的修改。
基础设施 建设复杂度高,运维难度大(自动化运维、日志采集、监控告警、持续集成与部署、容器编排)。
组件服务化
传统应用通过库(library)实现组件,库和应用一起运行,库的局部变化意味着整个应用重新部署。
通过服务来实现组件,将应用拆散为运行在不同的进程中的一系列服务(即多个微服务组合成完整的用户场景),单一服务局部变化只需重新部署对应的服务进程。
比如用 Go 实现的微服务:
kit:微服务的基础库(框架)
service:业务代码 + kit 依赖 + 第三方依赖组成的业务微服务
RPC + message queue:轻量级通讯
按业务组织服务
服务提供的能力和业务功能对应,比如:订单服务和数据访问服务,前者反映了真实的订单相关业务,后者则是技术抽象服务,按微服务架构理念来划分服务时不应该存在数据访问服务。
传统应用设计架构的分层结构正反映了不同角色的沟通结构,若要按微服务的方式来构建应用,也需要调整对应团队的组织架构:每个服务背后的小团队组织是跨功能的,包含实现业务所需的全面的技能。
由开发团队对软件在生产环境运行担负全部责任,而不是由其他团队兜底:大前端(移动 / Web)-> 网关接入 -> 业务服务 -> 平台服务 -> 基础设施(PaaS / SaaS)
去中心化
去中心化包括数据、治理、技术几个层面,每个服务面临的业务场景不同,可针对性选择合适的技术解决方案。但也需要避免过度多样化,结合团队实际情况来选择取舍,要是每个服务都用不同的语言的技术栈来实现,维护成本就会很高。
区别于传统应用共享一套缓存和数据库,每个服务独享自身的数据存储设施(缓存,数据库等)有利于服务的独立性,隔离相关干扰。

自动化
包括测试和部署的自动化,单一进程的传统应用被拆分为一系列的多进程服务后,开发、调试、测试、监控和部署的复杂度都会相应增大,必须有合适的自动化基础设施来支持微服务架构模式,否则开发运维成本将大大增加。
CI / CD:GitLab + GitLab Hooks + Kubernetes
Testing:测试环境、单元测试、API 自动化测试
在线运行时:Kubernetes,以及一系列 Prometheus、ELK、 Control Panel

可用性 & 兼容性设计
面向失败设计,微服务架构采用粗粒度的进程间通信,引入了额外的复杂性,如网络延迟、消息格式、负载均衡和容错,因此需要考虑:隔离、超时、负载保护、限流、降级、重试等。
在服务需要变更时要特别小心,可能引发服务消费者的兼容性破坏,时刻谨记 保持服务契约(接口)的兼容性。
发送保守,接收开放。按照伯斯塔尔法则设计和实现服务时,发送保守即最小化地传送必要的信息,接收开放即要最大限度地容忍冗余数据,保证兼容性。
微服务设计
在 SOA 服务化演进过程中,按照垂直功能进行拆分,对外暴露一批微服务,缺乏统一出口将会面临以下困难:
客户端到微服务直接通信,强耦合(比如用户一直持有较旧的 Android、iOS 客户端版本,旧的 API 不能下线,为应用升级带来困难)。
客户端聚合数据 工作量巨大,需要多次请求,延迟高(一般前轻后重,如果由前端负责繁重的数据逻辑,将影响应用交付速度,无法快速迭代)。
不利于协议统一,各部门间有差异,需要端来兼容(统一工作难度大)。
面向端的 API 适配,耦合到了内部服务。
多终端兼容 逻辑复杂,每个服务都需要处理。
统一逻辑无法收敛,比如安全认证、限流(对外暴露 API 越少则越安全健壮)。
工作模型应该是内聚模式配合。
服务网关
通过新增了一个 app-interface 用于统一的协议出口,在服务内进行大量的 dataset join,按照业务场景来设计粗粒度的 API,为后续服务的演进带来很多优势:
轻量交互:协议精简、聚合(通常提供较粗、单一粒度的接口)。
差异服务:数据裁剪以及聚合、针对终端定制化 API(比如为弱网络环境提供的数据)。
动态升级:原有系统兼容升级,更新服务而非协议。
沟通效率提升,协作模式演进为移动业务 + 网关小组。
一个请求经过 BFF 会扇出(fan out)多个请求、并行访问下游多层 RPC 服务进行数据组装,其中需要考虑部分失败、异常处理等问题。

面向资源的接口设计:即 RESTful 接口,不进行过多的数据处理,返回原始数据。无法承担复杂的业务逻辑,不适用于面向外网、复杂、多终端的接口,相比之下提供针对某一业务场景而统一的粗粒度接口更高效(封装容错、统一协议等)。
BFF(Backend For Frontend,服务于前端的后端)是一种对后端微服务的适配服务(包括聚合裁剪和格式适配等逻辑),向无线端设备暴露友好和统一的 API,方便无线设备接入访问后端服务。
网关不必为所有前端提供无差别服务,而应针对不同前端聚合不同服务,提供不同接口和网络访问协议支持。
避免单点
如果所有微服务都接到同一个 app-interface 上,会有单点故障风险,严重代码缺陷或者流量洪峰可能引发集群宕机。而且单个模块也会导致后续业务集成复杂度高,根据康威法则,单块的无线 BFF 和多团队之间就出现不匹配问题,团队之间沟通协调成本高,交付效率低下,因此可拆解出多个 interface(如上图中的 BFF-View、BFF-Account、BFF-Space)。
关注分离
当跨横切面逻辑(Cross-Cutting Concerns 比如安全认证,日志监控,限流熔断等)代码变得越来越复杂,需要协调更新框架升级发版(路由、认证、限流、安全)。因此全部上升,引入 API Gateway 把业务集成度高的 BFF 层和通用功能服务层进行分层处理。
网关用于解耦拆分和后续升级迁移,在网关配合下单块 BFF 实现解耦拆分,各业务线团队独立开发和交付各自的微服务,大大提升研发效率。把跨横切面逻辑从 BFF 剥离到网关上,BFF 的开发人员可以更加专注业务逻辑交付,实现了架构上的关注分离(Separation of Concerns)。
业务流量实际为:移动端 -> API Gateway -> BFF -> Microservices。
在 FE Web业务中,BFF 可以是 Node.js 来做服务端渲染(SSR,Server-Side Rendering),此处忽略了上游的 CDN、ELB。
安全
对于外网请求,通常在 API Gateway 进行统一的认证拦截。认证成功则使用 JWT 方式通过 RPC metadata 传递的方式带到 BFF 层,BFF 校验 Token 完整性后把身份信息注入到应用的 Context 中。而 BFF 到其他下层的微服务建议直接在 RPC Request 中带入用户身份信息(userId)请求服务。
API Gateway -> BFF -> Service
Biz Auth -> JWT -> Request Args。
对于服务内部,一般要区分身份认证和授权。安全级别:
Full Trust:假定服务间安全。
Half Trust:服务间需要认证鉴权,但非所有接口都加密。
Zero Trust:所有请求通过身份认证鉴权后,还需要经过安全加密,防止被嗅探。
服务划分
可通过 业务职能(Business Capability)或是 DDD 界限上下文(Bounded Context)划分服务边界。
业务职能:基于公司内部不同部门提供的职能。例如客户服务部门提供客户服务的职能。
界限上下文:DDD 中用来划分不同业务边界的元素,业务边界即“解决不同业务问题”的问题域和对应的解决方案域,为了解决某种类型的业务问题,贴近领域知识(业务)。
划分时从性能、便捷性、业务领域去思考和抽象。
CQRS
将应用程序分为两部分:命令端(处理创建、更新和删除请求,并在数据更改时发出事件) 和 查询端(针对一或多个物化视图执行查询来处理查询,物化视图通过订阅数据更改时发出的事件流而保持最新)。
参考 Bilibili 案例:在稿件服务演进过程,围绕创作稿件、审核稿件、最终发布稿件有大量逻辑耦合,其中稿件本身的状态也有非常多种。但是最终前台用户只关注稿件能否查看,当审核条件变复杂,直接访问数据库查询结果时查询和变更容易相互影响。
针对审核稿件的数据状态与查询操作解耦:使用中间件 Canal(订阅稿件数据库 binlog)将审核结果发布到 Kafka 中,消费数据并组建稿件查阅结果数据库,对外提供独立查询服务来拆分复杂架构和业务。架构从 Polling publisher -> Transaction log tailing 实现了演进(Pull vs Push)。

RPC 与服务发现
gRPC
gRPC 是一种高性能的开源统一 RPC 框架:
基于 Proto 的请求响应,支持多种语言。
轻量级、高性能:序列化支持 Protocol Buffer 和 JSON。
可插拔:支持多种插件扩展。
IDL:基于文件定义服务,通过 proto3 生成指定语言的数据结构、服务端接口以及客户端 Stub。
移动端基于标准 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 多路复用、服务端推送等特性,使得 gRPC 在移动端设备上更加省电和网络流量(传输层透明,便于升级到 HTTP/3、QUIC)。
1 | syntax = "proto3"; |
1 | protoc --go_out=.--go_opt=paths=source_relative \ |
设计原则:
服务而非对象,消息而非引用:促进微服务系统间粗粒度消息交互设计理念。
负载无关:不同服务使用不同的消息类型和编码,例如 protocol buffers、JSON、XML、Thrift。
流:Streaming API。
阻塞/非阻塞:支持异步和同步处理在客户端和服务端间交互消息序列。
元数据交换:常见的横切关注点,如认证或跟踪,依赖数据交换。
标准化状态码:客户端以有限方式响应 API 调用返回的错误(优先使用标准的 HTTP 状态码)。
设计时不要过早关注性能问题,先实现标准化。
Health Check
gRPC 有标准的 健康检测协议,在所有语言实现中基本都提供了 生成代码 和 设置运行状态 功能。
主动健康检查 可在服务不稳定时被消费者所感知,临时从负载均衡中摘除,减少错误请求。当服务提供者重新稳定、health check 成功后再重新加入到消费者的负载均衡、恢复请求。
health check 同样也被用于外挂方式的容器健康检测,或者流量检测(k8s liveness & readiness)。
Kubernetes 结合 health check 实现平滑发布:
Kubernetes 向 discovery(注册中心)发起注销请求。
Kubernetes 向 APP 发送 SIGTER 信号,进入优雅退出过程(待 Inflight 请求处理结束)。
其他客户端在最多 2 个心跳周期内(一般是实时)退出。
Kubernetes 退出超时(一般 10-60s),强制退出 SIGKILL。
1 | [Discovery] |
服务发现
客户端发现
服务实例被启动时,网络地址会被写到注册表上,当服务实例终止时再从注册表中删除。
服务注册表通过心跳机制动态刷新,客户端使用负载均衡算法选择可用的服务实例响应请求(Smart Client)。
由于客户端直连,比服务端服务发现少一次网络跳转,Consumer 需要内置特定的服务发现客户端。
由于微服务的核心是去中心化,更建议使用客户端发现模式。
服务端发现
客户端通过负载均衡器向服务发送请求,由负载均衡器查询服务注册表,将请求路由到可用的服务实例上。服务实例在服务注册表上被注册和注销,比如 Consul Template + Nginx,Kubernetes + etcd。
Consumer 无需关注服务发现具体细节,只需知道 DNS 域名。支持异构语言开发,需要基础设施支撑,由于多一次网络跳转可能有性能损失。

服务网格(Service Mesh):介于两者之间的模型,服务网格部署在 pod 中、在同一台物理机上(同组的两个容器),两个容器通过本地进程间通信(IPC)。流量先发送给 Proxy,再由 Proxy 转发给直连的下游服务提供者。相当于把服务发现、负载均衡等功能转移到 Proxy 上,避免 Client 过重,同时由于 Proxy 非集中式部署,也实现去中心化(避免热点请求)。然而除非服务体量、规模足够大,或者服务涉及多种语言(框架层面不统一),使用 Service Mesh 带来的收益很有限。
服务发现演进
早期常用 ZooKeeper 实现,但在海量服务场景下可妥协为弱一致性,使用 ZK 反而带来以下问题:
ZK 可提供非必要的强一致性,但也因此牺牲了可用性。
当出现网络抖动或分区(Java FGC 等),Master 节点失联会导致重新选举,当超过半数不可用会导致整体瘫痪。
节点数量多时大量服务长连接导致性能瓶颈。
AP 式的发现服务(注册、注销的事件延迟)。基本思路:
通过 Family(appid)和 Addr(IP:Port)定位实例(使用三段式命名,business.service.xxx。还能附加权重、染色标签、集群等元数据)。
Provider 注册后定期(30s)发送心跳,注册、下线都需要同步,数据通过长轮询推送(新启动时 load cache、JVM 预热,故障时 Provider 不建议重启和发布)。
Consumer 启动时拉取实例,发起长轮询(30s ,故障时需要 client 侧 cache 节点信息)
Server 定期(60s)检测并剔除失效(90s)的实例。短时间里丢失了大量的心跳连接(15分钟内心跳低于期望值 * 85%)则开启自我保护,保留过期服务不删除。

全量复制:当数据每次都全部复制到所有 Discover 节点(比如 Eureka),随着规模变大,一旦涉及扩容操作,同样大规模的数据量被广播,写放大严重(可以使用读写分离、一致性哈希来优化,减少广播压力)。
Poll 模型的无效请求太多,更建议用 Push 实现,节点状态变化时推送到 Consumer 端,传播速度更快。
多集群,多租户
多集群
对于重要级别较高的服务(L0),如果只有一套大集群,一旦故障影响范围巨大。需要考虑:
对于单一集群,多个节点保证可用性,通常使用 N+2 的方式来冗余节点。
从单一集群故障带来的影响面角度考虑冗余多套集群。
单个机房内的机房故障导致的问题。
利用 PaaS 平台给某个 appid 服务建立多套集群(物理上相当于两套资源,逻辑上维护 cluster 的概念)。在不同集群服务启动后从环境变量获取当下服务 cluster,在服务发现注册时带入这些元信息,不同集群可隔离使用不同缓存资源等。
多套冗余的集群对应多套独占缓存,带来更好的性能和冗余能力。
尽量避免业务隔离使用或者 sharding 带来的 cache hit 影响(按照业务划分集群资源)。
1 | svcA |
业务隔离集群可造成缓存命中率下降,不同业务形态数据正交,可统一为一套逻辑集群(物理上多套资源池),即 gRPC 客户端默认忽略服务发现 cluster 信息,按照全部节点全部连接(利用负载均衡为多套集群预热,确保缓存都生效),存在问题:
长连接导致的内存和 CPU 开销,health check 频率太高。
短连接极大的资源成本和延迟。
优化:从全集群中选取一批节点,划分子集限制连接池大小。
挑选部分后端建立连接(通常 20-100 个,减少连接、health check 开销。部分场景需要大子集,比如大批量读写)。
后端平均分给客户端。
客户端重启并保持重新均衡,同时对后端重启保持透明、连接的变动最小。
Provider 和 Consumer 是动态变化的,算法如果不能感知其变化并再均衡,则会导致资源消耗不均匀;但再均衡操作也不能过于敏感,会导致节点频繁重连加大开销。
1 | def subset(backend, client_id, subset_size): |
多租户
多租户(multi-tenancy):建立多套共存的系统,便于实现微服务稳定性和模块化。租户可针对测试、金丝雀发布、影子系统(shadow systems)、服务层或者产品线来建立,可保证代码隔离性并基于流量租户做路由决策。
对于 传输中的数据(data-in-flight),例如消息队列中请求或者消息、静态数据(data-at-rest)、存储或者持久化缓存,租户都能够保证隔离性和公平性,以及基于租户的路由机会。
如果对服务 B 做出改变,需要确保它仍然能够和服务 A,C,D 正常交互。要实现测试和该系统中其他服务的交互,有两种基本的方式:并行测试和生产环境测试。
并行测试
需要和生产环境一样的过渡(staging)环境,只用于处理测试流量。当完成生产服务的变动,将代码部署到测试栈。可在不影响生产环境的情况下让开发者稳定地测试服务,同时在发布前更容易识别和控制 bug。存在以下问题:
混用环境导致的不可靠测试。
多套环境带来的硬件成本。
难以做负载测试,仿真线上真实流量情况。
生产测试
在生产环境测试(称为染色发布),待测试的服务 B’ 在隔离的、可访问集成环境(UAT)C 和 D 的沙盒环境中启动。把测试流量路由到服务 B’,保持生产流量正常流入到集成服务,此时服务 B’ 仅处理测试流量、不处理生产流量。同时要确保集成流量不要被测试流量影响。

两个基本要求作为多租户体系结构的基础:
流量路由:基于流入栈中的流量类型做路由(请求数据打上 tag)。
隔离性:可靠地隔离测试和生产中的资源,保证对于关键业务微服务没有副作用。
灰度测试成本代价很大,影响 1/N 的用户。其中 N 为节点数量。
比如为入站请求绑定上下文(如 HTTP Header),in-process 使用 context 传递,跨服务使用 metadata 传递(如 opentracing baggage item)。在架构中每个基础组件都能够理解租户信息,并基于租户路由隔离流量。
在平台中允许对运行不同的微服务有更多的控制(指标和日志),典型的基础组件是日志、指标、存储、消息队列、缓存以及配置,基于租户信息隔离数据需要分别处理基础组件。
多租户架构本质上描述为:跨服务传递请求携带上下文(context),数据隔离的流量路由方案。利用服务发现注册租户信息,注册成特定的租户。
