本文是我发表在InfoQ下属公众号“聊聊架构”上的文章,地址 重新理解微服务
微服务是个说的挺长时间的概念,也是比较成熟的技术体系。像 Spring Cloud,甚至提供了微服务所需要的全套框架,包括注册中心 (Eureka)、配置中心 (Config)、断路器 (Hytrix)、API 网关 (Zuul) 等组件。微服务体系庞杂,每个组件都能独自成章。本文作者仅从个人的经验和实践出发,谈了谈自己对微服务及其部分内涵的思考和理解。
微服务与 SOA/ESB 的异同
微服务与更早就起来的 SOA 是什么关系? 个人觉得如果从概念上来说,微服务和 SOA 都是一回事,强调把整个系统,按照多个服务的方式去组合及通信,而不是揉合在一起,但它们的内涵有很大的区别。
SOA 诞生在早期企业级的应用,其业务复杂、技术体系多样,SOA 强调的是各个服务之间,尤其是异构系统、遗留系统之间,建立起一套统一的协议和通信 (SOAP),以及寻址服务 (UDDI),它的侧重点在集成和兼容;与 SOA 同期的另一种概念 ESB(企业总线),强调通过一根总线服务,把所有服务串联起来,由 ESB 总线来屏蔽各种不同业务系统自身业务 / 语言 / 协议的特殊性,各服务以一种统一的方式,与总线相连,从而降低接入成本。
这两种概念,我感觉在国内没有太发展起来。一是国内的软件起步相对较晚,系统的整体复杂度——多厂商、多语言 / 技术栈、历史遗留系统的问题,还不算突出。而对于公司内部的产品系,又没有必要使用 SOA、UDDI 来做复杂的集成。随着互联网的兴起和用户量的迅速爆发,企业自身的产品的微服务化的需求,快速发展起来,而与此同时 SOA 这种以 XML 为基础的 SOAP 协议、以寻址为主要作用的 UDDI,不能使用互联网产品的发展——SOAP 的 XML 协议内容太多,造成性能明显下降;HTTP 协议的效率不如 RPC;UDDI 只有寻址,缺少服务治理等功能。
在此种大背景下,以服务切分 + 服务注册 + 服务治理 + 限流降级 +RPC+ 监控等为主要内涵的微服务,就快速发展起来的。国内的阿里巴巴走在前列,以 Dubbo 为代表在国内互联网企业中得到广泛应用;后来 Spring 官方发布 Spring Cloud,揉合了一系列自研或其他企业捐赠的开源项目,发布微服务领域的 Spring Cloud 产品。各自都有各自的优势和劣势,而随着这些年来,微服务的继续下沉 (sidecar 和 service mesh) 到基础设施层,给微服务的治理带来了新的方向。
微服务的关键特性
服务粒度
服务的粒度,切分到多大算合适? 太粗的话,这服务就涵盖过多的业务逻辑,从而难维护、易出错;太细了,就会搞出很多的工程,造成很大的工程维护和通信成本。
主流说法是依据康威定律——团队的交流机制应该和组织机构相匹配。应用到软件领域来看,如果某个应用,需要多个组织之间一起交流和修改,那么它的交流机制就大于组织机构了,出现了不匹配的情况,那么这个应用很可能就太粗而需要拆分。
这里有个不太好懂的地方——既然系统架构和团队组织机构想匹配,那我们是先定系统架构呢,还是先定团队组织机构呢? 这有点类似先有鸡还是先有蛋。我觉得可以这么来理解:无论是团队怎么定、还是架构怎么定,这都是跟着业务的发展而发展的,可以说都是业务的衍生发展而来。所以系统架构设计,首要做的还是业务理解和切分——业务切分决定了服务切分、业务切分也决定了团队组织。
业务切分有两种简单办法:
- 参照业内同类公司的划分:比如电商,业内比较成熟的:支付、库存、订单、搜索、用户等;
- 将自身业务的主要信息流画出来,先找出其中的名词或动名词,它就可能是个服务
eg:在我们的线上贷款业务中,典型的 user case 是这样:
- 用户导入几项金融资料数据
- 系统根据信息清洗出部分衍生变量
- 系统跑欺诈规则
- 系统计算授信,给出额度
- 用户试算得到月费率和利息
- 系统人工信审
- 系统放款
- 到还款日时用户还款或者我们系统主动扣款
治理范围
从服务的角度来看,对外公开的是契约——即我们系统提供哪些特性,而内部算法 / 数据都应该隐藏起来,而在不同服务间“是共享数据库还是独享数据库”上,实践中的冲突和困惑,体现地比较明显。
我们假想个流程,ServiceA 的李雷需要更新 User 表的某个字段,如果大家数据库表都共享的,李雷只要写个 SQL 就解决了。但一旦把 User 表服务化后,归到 UserCenter 这个服务自治之后,问题就麻烦了:
- 李雷要去找 UserCenter 团队——假设是韩梅梅接了这个需求,好在是个女生,男女搭配干活不累——讲清楚他的需求或提供需求文档;
- 韩梅梅理解了需求,设计接口、提供文档、评审并准备开发;
- 韩梅梅可能手里有其他事,所以这个需求大概要等几天才能开发;
- 终于韩梅梅开发完了,她要自测、部署;上游李雷开始联调,如果有问题,需要双方再沟通解决;
- 联调完毕上线,韩梅梅的 UserCenter 先上,李雷的业务系统再接着上;
从这可以看到,一旦一个人、一个系统做的事,变成了 2 个人、两个系统来做,那要多出多少麻烦了。所以我完全理解,在公司早期,所以业务系统共享一套数据库表,是多么地务实。我们功夫贷在创业之初也是这么做的,在创业 2 年后,它的弊端开始密集体现,而服务化改造过程中,我们也是付出了相当大的代价。
随着用户量和数据量的上升,这种共享数据库表的最明显的弊端就是慢查询越来越多——因为谁都可以操作任何一张表、而开发过程中或者是对业务理解不够、或者是 SQL 能力不足,很容易写出慢 SQL 来,其结果就是导致 DB 的 CPU 飙升到 100%、或者是 IOPS 被打满,从而全 APP 被拖慢甚至无法提供服务。这种危害是相当巨大的。
所以,从运行时的慢 SQL 带来的巨大杀伤力来说,数据库应该是隐藏在服务内部,该服务由熟悉该业务的固定团队维护、也会做很多优化。虽然开发阶段慢了,但是运行时稳定了、系统的可用性得到了保障。只是这件事,不应该在创业初期就做,那样会比较严重地放缓系统迭代速度、更应该在系统规模相对较大的时候来改造。
当然,我们说改造是要付出代价的。不仅之前的一个库中的表,要分成不同的库,各服务的程序要做不小的改造,其中最困难的是,同一张表的字段,可能会属于各个不同的应用。看下面这个 User 表。开始的时候,User 表只包含了完全业务无关的属性,但随着系统的发展,一些和业务相关的字段 (上图红色部分) 逐渐地被加进来——这也不完全是决策时犯的错误,而是本身这属性是否和业务有关,也不是很容易界定。所以逐渐会发现,很多系统都会依赖这张表,从而交织难以拆分。各个服务可能都需要有这张表,而各自维护自己所关心的那部分字段及功能。
在我们的实践中,服务化的过程以及数据迁移,大约是这样的步骤 (以“用户中心”应用为例):
- 创建新应用 UserCenter,梳理清楚其的业务边界和所涉及的数据表;
- 收集和分析其他系统对这些数据表的需求,并在 UserCenter 中开发接口,以备上游系统调用;
- 逐渐改造上游系统,使其由原先的读取数据库,修改为调用 UserCenter 接口。由于有多个上游系统和功能需要改在,因此这个阶段会比较长,上游系统在这个时间周期内,也会“访问接口服务”和“直接访问数据库”这两种形态并存;
- 检查并确认上游系统都改造完毕上线,此时理论上应该没有上游系统直接读取 UserCenter 的表了,都通过接口了,此时准备迁移 UserCenter 的表数据;
- 建立 New UserCenter DB,并通过 DB 同步机制,实时地将 UserCenter 的表数据由老库同步到新库。在新库同步完成之前,UserCenter 的应用仍然使用的是老库里的表;
- 新库同步完毕,UserCenter 应用切换到新库,此时所有的新数据都会进新库,而老库理论上是不用了;
- 断开新老库的同步链,同时 rename 老库的表 (先不删,同时在 rename 前一定要断开同步链,否则新库也会被同步 rename 掉了)。如果此时万一有某个系统的功能,在之前的系统改造 / 测试中遗漏了没被发现,仍然是直接读取的数据库表,那么这时候就会报错 (因为表名被 rename 掉了,找不到了)。此时就是个恢复窗口,赶紧把表 rename 回来,减少损失,然后再继续处理。这也是前面千万不能直接把老表删除的原因;
- 运行几天没问题之后,再把老库的表删除,整个服务化过程结束;
服务组合
在微服务之后,各个系统只对某一块业务负责,那么就有可能需要对服务做一些聚合。下面是常见的两种模式:这是聚合服务的模式,由 web 应用去负责聚合后端服务或做个性化处理,这是它的好处——可以根据自身的业务做任何组合和处理,而它的坏处也很明显——对于不需要特殊处理的,也得过它一道。
这是后台服务自包含的模式。某个后台应用,依赖于其他服务,于是就将其他服务的相关调用都处理完了,或者这么理解——后台服务也有多个层次:库存服务、支付服务、发票服务是最底层的,交易服务是更上层一些的共享服务,从而达到封装细粒度服务的目的,与此同时,它的个性化也就丧失了。假如有个交易,是不需要发票服务的,那么这种模式就不是太灵活。 从我个人的经验来看,我是倾向于聚合服务这种模式。每个前端应用,还都是应该有个自己的后台服务,去完成很多小的功能 (比如更新 APP 版本、展示首页广告、记录埋点等 APP 特有 feature)、以及聚合。而对于不需要 App-Server 处理、直接使用后台服务的,应该能够通过 gateway 直接调用,而不需要 App-Server 来做代理转发。
容错
容错的目的就是在出现问题的时候,仍然能够正常提供服务,其具体表现形式有这么几种:
- 当调用下游服务 B 出错的时候,可以在安全的情况下考虑重试;
- 当调用下游服务 B 出错的时候,可以调用替代服务 B';
- 当调用下游服务 B 出错的时候,是否可以返回某个默认值、或者返回最近一次的值?
- 当调用下游服务 B 超时的时候,如果超时请求达到一定数量,则需要熔断,以保证自身其他服务能正常提供服务,而不会被拖垮;
- 当调用下游服务 B 出错的时候,能否以异步 + 定时任务补偿的方式代替?
上面这些特性,有些是通过 RPC 框架来实现 (重试)、有些是应用控制 (调用替代服务、异步 + 定时补偿)、有些可以通过 Hytrix 这样的断路保护框架来实现。容错也比较简单,但为了容错确实也需要增加不少开发工作量,它就像买保险,有的人看重风险、愿意付出一些代价来买一份适合的保险;有的人比较乐观,不相信灾难会降临到自己身上,所以这就看一个公司对自己的要求了。从我个人的观点来看,公司到达千万用户级以上,就需要比较严肃地考虑这个了,因为一次全局事故,带来的损失就会是不小。
限流
限流主要有两种算法:令牌桶算法和漏桶算法。
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
从互联网实践的角度看,我觉得这两种方法都不是很理想。主要原因是看我们怎么理解流量控制这个事情。在互联网领域,系统的最大处理能力,是一个比较核心的指标。假设系统 (或某接口服务) 只能同时支撑 10000 个请求同时处理 (这也是非常容易模拟测试的),那么它所关心的,就是在任何一个时间点的执行中任务,是否超过了 10000,而这是令牌算法和漏桶算法都提供不了的。
令牌算法:单位时间内生产的令牌是固定的,而令牌桶就相当于一个蓄水池。如果令牌不被马上用完,令牌桶可以存储一部分。它是从请求数 (开始处理的) 的角度,随着蓄水池中的令牌多少,而相应请求多或者少。它不能衡量当前有多少请求正在进行中。且蓄水池的大小,并不是它自己可以说的算,虽然它有令牌,也还需要系统能处理才可以。
漏桶算法:单位时间内可以处理的请求是固定的,持续恒定,没有令牌桶来做蓄水池。它同样是从请求的角度出来,无法衡量“当前的并行处理任务”。
所以这两种算法,我认为它都不是从精确的系统承载量角度出发,更像是一些预估或外界因素所引发的流控——比如该系统 1 分钟只允许处理 100 个请求,以一种比较粗略的方式、来保护系统不过载;该系统依赖的第三方不能超过每天 XX 次的请求量;
从请求出发的角度,还有令牌算法的优化版——滑窗模式。以 X 个滑窗作为一个周期,比如 1 秒作为 1 个窗口,3 个窗口作为一个周期,在这个周期内令牌蓄水池。3 秒到了则在排队等待令牌的请求都置拒绝。这样防止在流量阻塞的时候,随着时间推移,很多用户已经等不及离开了,而他们的请求还在这里排队,导致最新用户的请求无法获得令牌。
而如果从系统承载力的角度,既能最大发挥系统能力,又不会过载,个人认为最好的方法“响应模式——在访问开始前,计数器 +1;访问结束,计数器 -1;保证计数器不超过阀值,也就是当前系统正在处理的任务,不超过阀值”。
从另一个角度看,令牌算法和漏桶算法被很多框架完好支持,比如 Nginx,这样对于大部分接口,尤其是处于安全角度考虑的限流,就是个很好的策略。在 Nginx 中配下就可以了,不需要业务去衡量自身负载、再开发相应代码。所以这也是种取舍——如果要快速覆盖、尤其在产品初期,尽量地保护自己的系统,尤其是安全原因,那么令牌或漏桶算法是很好的选择;如果针对一些核心接口,希望能在保护自己系统的同时、尽量多地发挥系统潜力,那么就开发“响应模式”是更好的选择。
一致性
CAP(Consistence、Available、Partition) 理论是很熟悉的分布式理论——在同一时间 CAP 不能全部满足、只能满足其中两个。而由于分布式系统的特点,Partition 是必须要满足的,所以只能要么 CP、要么 AP,即要么系统可用,但数据可能不一致;要么数据一致,但系统不可用。
这里对“为什么 Partition 必须要满足”解释一下。CAP 主要是针对有状态、即有数据的,典型形态就是存储类产品。因为数据才有一致性、分区同步这样的场景。在存储类产品中,为了避免单点故障,都是需要主从结构、或者集群结构,这就势必有相互之间的数据复制——主从的话,是主向从复制;集群的话,是多副本复制。这就必然涉及到网络通信,而网络我们说不是非常稳定的,“满足 Partition”就是在网络不稳定的时候,比如主和从网络短时不通了,这时候产品还能够正常提供服务。这就是“Partition 必须要满足”的原因,否则就有较大的单点风险。
既然 P 必须要满足,则只能选 AP 或者 CP 了。就互联网企业来说,保证服务可用性更为重要,所以 AP 往往是主流选择。在我的经验中,金融财务相关领域可能会用到 AP 这样的强一致,这往往是通过有 ACID 特性的 RDMS(Oracle、MySQL) 来实现的。
前面我们提到,分布式的服务治理,数据被隐藏到服务内部了,那么对数据的修改就由原先的直接操作变成了接口调用,原先可能可以通过 Transaction 来实现多表更新的 ACID,现在实现不了了,那在保证 AP 的同时,Consistence 怎么办呢? 此时 BASE 理论也就应运而生。
BASE(Base Available、Soft State、Eventually Consistence)——基本可用、暂时不一致、最终一致。短时间内数据不一致,可能会造成一定的脏读,但最终会达成一致,而达成一致的速度窗口,也就是个比较重要的指标。Paxos 和 Raft 算法是两个主流的最终一致性的算法。从 BASE 的定义来看,对于准确性高度敏感的金融财务领域,可能就不合适。
在存储类产品中,使用 Paxos 或 Gossip 算法,主要是用于协调各个节点的状态和版本,以完成同步。而在微服务领域中,我们面对的是各个 RPC 或 http 通信的不同类的应用服务 (可能使用这些算法也可以,复杂度应该是比较高,反正我是没试过),那么又怎么做到最终一致? 主要策略有两个:撤销、补偿。前者是努力恢复到操作前的一致状态,后者是努力保证成功、达到操作后的一致状态。看下图,Server 的某个业务操作中,要分别调用 Service-X、Service-Y、Service-Z 三个服务,才能完成。此时如果调用 Service-Z 的过程中出现错误了,怎么保证最终一致性?
按照上面的撤销或者补偿,就有两个策略:
撤销:Service-X 和 Service-Y 提供反向的撤销接口。如果调用 Service-Z 失败,则调用 Service-X 和 Service-Y 的反向撤销接口,以恢复到操作前的状态。如果撤销的过程中失败? 呵呵,那又要补偿了。
补偿:Service-X 和 Service-Y 都执行成功了,那么 Service-Z 调用失败,在“确保 Service-Z 只要恢复正常、必然能执行成功的前提下 (无论是系统自动还是人工)”,通过定时任务重试或者 MQ 的机制,补偿重试,再不行人工处理,直到 Service-Z 成功,以达到都成功的状态。这里“确保 Service-Z 必然能执行成功”非常重要。以账户转账举例,如 Service-Z 的操作是从账户上扣 100 元,但他的余额只有 10 元,那无论怎么重试、人工,都是不可能成功的,也就不可能达到最终都操作成功的一致性状态,此时要么提前校验、锁定,要么就采用前面的撤销的思路。
在实际的实践中,除了类似 Service-Z 的环节失败,还有入库失败、网络通信失败、发送 MQ 失败等各种可能失败的环节,我在后面一篇《功夫贷的支付服务,是怎么实现最终一致性的》这篇文章里,拿一个具体的 case 详细地介绍了它的实现,供参考。要实现一个严谨的最终一致性,还是比较复杂的,所幸在全系统中,真正要保证绝对最终一致性的功能点,还是比较少的。
容量评估 / 测试
容量评估后续会专门拿个 case 来介绍实践,我们主要是强调拿线上环节通过路由、监控等策略,在线测试评估容量。搭建性能测试环境,在现在已经是相对落后的手段。
支撑系统
对于如今的互联网系统来说,越来越复杂,支撑系统必不可少,每一章都可以单独列文章来分析其原理,这里只列出些目录。
- 业务监控系统:如订单量、转化率、各类报表等,以及相应的预警子模块。这主要是从业务视角来看的监控,很直接有效;
- 日志系统:开源的选择之一就是 ELK 套装,在数据量大的情况下,如果要把这三个搞稳定运行,工作量也是不小的;
- 分布式调用链系统:跟踪请求在全系统中的去向、以及快速定位出问题的地方;
- 服务设施监控系统:传统的 CPU、Memory、Disk 这个很多产品都提供,但还不够,应用内的情况,我们也要知道,主要包括:线程池使用情况、GC 的频率和时间、线程栈
- 技术指标监控系统:Error 率、Latency、Exception 统计等
- 运维支撑系统:资源管理、容器化、部署、灰度、可用性指标等
- 测试平台:接口测试、MQ 测试、集成测试、性能测试、测试环境构建、持续集成
作者张轲目前任职于杭州大树网络技术有限公司,担任首席架构师,负责系统整体业务架构以及基础架构,熟悉微服务、分布式设计、中间件领域,对运维、测试、敏捷开发等相关领域也有所涉猎。下方是我的微信公众号,欢迎关注。