架构

架构的分类

按照不同的角度,架构可以有很多分类,但一般来说,主要分为业务架构、应用架构和技术架构。

所以做架构设计时,一般是先考虑业务架构,再应用架构,最后是技术架构。

什么是好的架构?

从上面的内容,我们不难看出,一个好的架构必须满足两方面挑战:业务复杂性和技术复杂性。

1、业务复杂性系统首先要满足当前的业务需求,在此基础上,还要满足将来的业务需求,因此系统要能不断地扩展变化,包括调整现有功能,以及增加新功能。

而且,系统的功能变化不能影响现有业务,不要一修改,就牵一发动全身,到处出问题。因此,在架构设计上,要做到系统的柔性可扩展,能够根据业务变化做灵活的调整。

此外,市场不等人,上新业务要快,之前花了半年上了个业务,这回再上个类似的新业务,需要短时间就能落地。因此,架构设计上,还要做到系统功能的可重用,这样才能通过快速复用,实现业务敏捷和创新。

2、技术复杂性要保证一个业务能正常运行,除了满足业务功能之外,还要保证这个系统稳定可用。

一个复杂系统是由很多部分组成的,如应用程序、服务器、数据库、网络、中间件等,都可能会出问题。那怎么在出问题时,能够快速恢复系统或者让备用系统顶上去呢?

还有流量问题,平时流量不大,少量机器就可以处理,但在大促的时候,大量流量进来,系统是不是能够通过简单地加机器方式就能支持呢?

此外还有低成本的问题,系统能否做到,使用廉价设备而不是高大上的 IOE 设备,使用免费的开源组件而不是昂贵的商业套件,使用虚拟化技术而不是物理机,并且在流量低谷和高峰的不同时期,让系统能够弹性缩容和扩容呢?

这些都属于技术性的挑战,解决的是系统的非业务性功能,也都是架构设计要支持的。

因此,一个好的架构设计既要满足业务的可扩展、可复用;也要满足系统的高可用、高性能和可伸缩,并尽量采用低成本的方式落地。所以,对架构设计来说,技术和业务两手都要抓,两手都要硬。

什么是好的架构师?

一个优秀的架构师,应具备很强的综合能力,要内外兼修,“下得厨房,上得厅堂”,下面我来通过典型的架构方式,来介绍一名优秀架构师应该具备的能力:

一个驾校教练,必定开车技术好;一个游泳教练,必定游泳水平好,因为这些都是实践性很强的工作。架构师也是一样,TA 必定是一个出色的程序员,写的一手好代码。

在此基础上,架构师要有技术的广度(多领域知识)和深度(技术前瞻)。对主流公司的系统设计非常了解,知道优劣长短,碰到实际问题,很快就能提供多种方案供评估。

此外,架构师还需要有思维的高度,具备抽象思维能力。抽象思维是架构师最重要的能力,架构师要善于把实物概念化并归类。比如,面对一个大型的 B2C 网站,能够迅速抽象为采购 -> 运营 -> 前台搜索 -> 下单 -> 履单这几大模块,对系统分而治之。

架构师还需要有思维的深度,能够透过问题看本质。透过问题看本质是由事物的表象到实质,往深层次挖掘。比如,看到一段 Java 代码,知道它在 JVM(Java Virtual Machine,Java 虚拟机)中如何执行;一个跨网络调用,知道数据是如何通过各种介质(比如网卡端口)到达目标位置。透过问题看本质,可以使架构师能够敏锐地发现底层的真实情况,以端到端闭环的方式去思考问题,能够识别系统的短板并解决它。

还有很重要的一点,能落地的架构才是好架构,所以架构师还需要具备良好的沟通能力(感性),能确保各方对架构达成共识,愿意采取一致的行动;而良好的平衡取舍能力(理性),可以确保架构在现有资源约束下是最合理的,能让理想最终照进现实。

一个项目能否成功落地,首先需要的是把业务分析做到位,至于选用什么技术来实现,这是我们第二位才去考虑的因素。从架构角度看,业务架构是源头,然后才是技术架构。

业务架构

作为开发人员,我们平常讨论比较多的是技术层面的东西,比如 Spring 框架、Redis 缓存、MySQL 数据库等等,我们喜欢讨论这些,是因为纯技术的东西比较通用,和业务相关性不大,沟通起来比较方便。

但你要知道,一个项目能否成功落地,首先需要的是把业务分析做到位,至于选用什么技术来实现,这是我们第二位才去考虑的因素。从架构角度看,业务架构是源头,然后才是技术架构。所以,作为专栏的第二讲,今天我们就从业务架构开始说起。

在软件开发的过程中,你肯定知道需求分析是怎么回事,但不一定知道业务架构设计是怎么回事;你也肯定清楚需要产品经理这个角色,但不一定清楚有时还需要业务架构师这个角色。关于需求分析和业务架构设计,相信你经常会有以下几个疑问:

业务架构师和产品经理有什么区别?

需求分析和业务架构设计有什么区别,业务架构到底有什么用?

我们知道,项目的开发都是从收集业务需求开始的,原始的需求一般来自于最终用户。但是,每个用户其实只清楚自己所负责的那部分,因此这些原始需求往往是零散和碎片化的,特别是当一个业务流程跨多个部门的时候,更没有一个人能够说清楚这个业务的全貌。

产品经理的职责

简单来说,产品经理的职责就是:告诉用户,系统长什么样子;告诉开发,他要实现什么功能。

产品经理首先会收集用户的原始需求,然后,将它们梳理成一个个业务流程,每个业务流程由多个业务步骤组成。一个业务步骤包含三部分的内容:输入、输出和业务功能。

比方说,一个典型的交易流程,它包含商品浏览、商品加购物车、下单、支付等步骤。其中,下单步骤的输入,就是订单的各种信息,下单的功能,就是整合这些信息,创建一个具体的订单,而下单的输出结果,就是新创建的订单。

需求梳理好后,产品经理会把每个步骤具体化为页面原型。在原型中,会以直观的方式给出各个步骤的输入或输出,以及用户的操作过程,最后再把这些页面串起来,形成一个业务流程。

经过产品经理的工作,大量零散的原始需求经过梳理和关联,变成一系列有序的业务流程,以及流程里面的业务步骤(业务步骤也称之为业务节点),然后产品经理把这一系列的业务流程和业务节点以用户界面的方式定义出来,总的来说,产品经理定义了系统的外表。

业务架构师的职责

在这之前,我们不妨先思考下,如果是按照产品的输出,直接以业务流程的角度来构建系统,会是什么样子呢?

如果按照这个思路,我们将为每个业务流程搭建一个对应的系统模块,然后业务流程中的每个业务步骤,将对应系统模块中的一个接口,包括它的功能、输入和输出。

就拿前面的购物流程来说,我们设计一个购物流程模块,里面包含商品查询、添加购物车、下单和支付接口,来分别对应流程里的 4 个业务步骤。

以这样的方式构建系统,表面上看起来,业务和系统的映射好像非常简单,但在实际中,落地的难度非常很大。因为只是这样一个小小的购物流程模块,就要同时涉及商品、购物车、下单和支付四个业务,模块的开发者要同时非常清楚这四部分的数据模型和业务逻辑。

同样的道理,系统里的其他模块也是包含多个业务领域的内容,如果一个业务领域的需求发生了变化,比如说,订单要增加一个新的状态,那么所有涉及该订单的模块都要知道这个变化,并要做出相应的调整。这就要求,每个开发者都是全知全能的,对所有业务都了如指掌,我们知道,这是不可能的。

每个业务都有其本身的专业性,比如订单业务、商品业务、支付业务,它们的数据模型和业务逻辑都相当复杂,构成了一个个相对独立的业务领域。如果我们是按照业务流程来划分系统模块,结果是把不同业务混在了一个模块里,所以,这种模块划分的方式并没有降低总的业务复杂度。

我们可以换一种做法,先把所有的业务流程拆散,这样得到了一堆业务节点;然后把业务节点进行归类,相关的业务节点放在同一个系统模块里。判断节点是否相关,主要看它们是否属于同一个业务领域,比如一个订单查询的节点,和订单创建的节点,它们都属于订单域,那么这些节点都可以归属到同一个订单模块里。

下图就清楚地表示出了系统模块按业务流程拆分,和按业务域拆分的不同。

如果按照业务流程来拆分系统模块,那么,有多少业务流程,就有多少个系统模块,这个对应关系比较直接,但实现起来很困难。

如果按照业务域来拆分,有多少业务领域,就有多个系统模块,流程中的业务节点按照业务域的不同,可以划分到不同的系统模块。

在实际业务场景中,一个业务节点可能会涉及不同业务领域的功能。比如说,一个下单节点,会涉及到获取商品信息、获取用户信息、扣库存、下订单等多个业务功能,那么你就可以进一步分解这个节点的功能,把不同的功能分到对应的业务域和系统模块。

基于业务域,构建了系统模块后,我们就可以按照这样的方式还原整个业务流程,比如上面的购物流程例子,我们就可以这样还原它:

购物流程 = 商品模块. 商品搜索 + 购物车模块. 添加商品 + 订单模块. 创建订单 + 支付模块. 支付

如果你把这个定义画成序列图,就很直观和容易理解,也比较符合开发人员思维,系统实现起来非常容易。通过这种系统模块之间的不同功能组合,我们很容易给出各个业务流程的定义。

所以,对业务架构师来说,TA 的工作,就是把业务流程和节点打散,按照业务域的维度来划分系统模块,并定义这些模块之间的关系,最终形成一个高度结构化的模块体系。这样,开发人员既容易理解业务,又方便落地系统。

现在,我们就可以回答文章开头的问题了,产品经理和业务架构师的工作既有区别又有联系,简单地说,产品经理定义了系统的外观,满足了用户;业务架构师在此基础上,进一步定义了系统的内部模块结构,满足了开发人员。

当然,满足现有的业务需求,保证系统顺利落地,这只是业务架构的最基本目标,业务架构的意义远不止于此,它有一系列更高的目标。

架构目标之一:业务的可扩展

第一个目标是业务的可扩展,我们都知道,业务需求是不断变化的,不断创新是业务的内在要求。而对于系统来说,它的要求却是相对稳定,尽量避免大的调整。

那么,我们如何才能实现业务的快速变化和系统的相对稳定呢?

这也是业务架构要重点解决的问题,具体地讲,业务架构设计要能支持打造一个柔性系统,通过提供良好的业务扩展性,允许业务不断调整和快速生长。

可以看到下图中,左边部分就比较形象地展示了业务和系统的不同特点:业务的主题是变化和创新,系统的主题是稳定和可靠。

在右边图中,我们通过巧妙的业务架构设计,很好地解决了业务和系统之间的矛盾。

这里,我们把业务平台和业务线剥离开,让业务平台封装基础通用的功能,这样,它就变得相当地稳定;让各个业务线包含自己的个性化需求,业务线只依赖业务平台,业务线彼此之间互相独立,可以自由变化。这样的业务架构设计,就同时保证了系统的相对稳定和业务的快速创新。

为了帮助你更好地理解业务架构的扩展性,这里,我给出了支付宝的业务架构变化过程。

在支付宝一代的业务架构中,前台的业务和后台的业务直接耦合,形成了多对多的网状结构,如果修改一个后台业务线,就会影响到很多前台业务线;如果增加一条新的前台业务线,需要同时和很多后台业务线对接,这样的架构无疑是对业务的扩展非常不利的。

而在支付宝二代业务架构中,你会发现,他们在前后台业务线之间,构建了独立的支付清算平台,从而实现了前台业务和后台业务的解耦。

在这里,不管前台业务,还是后台业务,都只需要对接中间的支付清算平台,把系统的变化收敛到一个点,而业务线之间相互不影响,这样的方式,自然可以很好地支持业务扩展。

好了,这里我们说完了业务架构的可扩展目标,接着再说说业务架构的另一个目标:可复用。

架构目标之二:业务的可复用

你肯定会有这样的体验:一个项目过来,你和伙伴们一起加班加点、紧赶慢赶,总算把它成功落地了。结果这时候又有另一个类似的项目过来,你们又要按照同样的方式,重新吃一遍苦,结果就是开发不满意,项目经理不满意,老板也不满意。

对于类似的业务需求,如果原来做的工作可以大量复用的话,这是非常理想的结果,无论对于开发效率和开发质量的提升都很有帮助。

当然,能不能复用,能在多大程度上复用,这和业务架构设计很有关系,也是业务架构设计的重要目标之一。

那么,业务架构设计如何实现业务的可复用呢?

你可以试想一下,在业务架构设计中,如果只是简单地基于业务流程来定义系统模块,这个系统模块就要和业务流程严格对应。我们知道,业务流程对应业务场景,而业务场景是经常变化或是定制的,这就导致系统模块也是经常变化和定制的,那么,这样的系统模块就很难在不同业务场景中复用。

如果我们按照业务域来划分业务,把业务流程中的节点拆分到各个业务域,按照业务域构造系统模块,这样的复用效果会如何呢?

我们都知道,业务域是相对固定的,它有明确的数据模型和业务规则,这样一来,系统模块也就比较固定和通用,也就具备比较好的复用基础。

但要想实现高复用,业务架构对系统模块的定义,还有更多的要求。

首先,模块的职责定位要非常清晰。对于模块来说,在定位范围内的职责要全部涵盖到,而不在这个范围的职责全部不要。

其次,模块的数据模型和接口设计要保证通用。架构师需要归纳业务场景,通过抽象提炼,形成通用化的设计,以此来满足多个类似场景的需求。

小提示:清晰的模块定位和通用化设计,是模块能够复用的内在要求

最后,实现模块的高复用,还需要做好业务的层次划分。我们知道,越是底层的业务,它就相对更固定。举个例子,同样是订单业务域,对于底层订单的增删改查功能,不同类型的订单都是一样的,但对于上层的订单生命周期管理,外卖订单和堂食订单可能就不一样。

所以,在做高复用设计时,我们可以尝试把一个业务域按照层次拆分得更细,比如,把订单模块拆分为多个上层订单模块和一个基础订单模块,这样,基础订单模块对于所有类型的订单,都能够提供复用。

就拿当前非常流行的微服务架构来说,很多公司在微服务的基础上,通过服务分层,进一步落地了共享服务体系和中台架构,这些都是业务架构复用能力的体现。

下面是一个三方支付平台的业务架构图,你可以看下,在一个实际的业务架构中,模块是怎么划分的,架构的可扩展和高复用是如何体现的。

可扩展架构

在实际工作中,业务需求总在不断变化,因此,你经常会面临以下这些问题:

如何快速地上线新业务?老板很可能明天就想看到效果。

对某个功能进行修改,如何不影响到系统其它的功能?

对于新的需求变化,我们一方面要快快搞定,另一方面要稳稳接住。但问题是软件虽然姓“软”,但也不是想变就能变,如果事先没有经过良好的设计,调整起来,往往牵一发动全身,导致系统到处出问题。

系统的构成:模块 + 关系

我们天天和系统打交道,但你有没想过系统到底是什么?在我看来,系统内部是有明确结构的,它可以简化表达为:系统 = 模块 + 关系。

在这里,模块是系统的基本组成部分,它泛指子系统、应用、服务或功能模块。关系指模块之间的依赖关系,简单地讲,就是模块之间有调用,我们知道,调用区分发起方和服务方,因此,依赖关系是有方向性的。

这个模型虽然简单,但它给我们提供了一个深入分析系统的工具。接下来,我们就从业务扩展性出发,讨论什么样的模块是容易修改的,什么样的依赖关系是容易调整的。

模块

我们先看模块,模块定义系统都有哪些基本的“玩家”,分别承担什么职责。从业务的角度看,每个模块都代表了某个业务概念,或者说业务领域。

模块内部由数据和业务逻辑组成,其中数据是核心,业务逻辑围绕着数据,对数据做进一步加工,方便外部使用。

从扩展性的角度出发,首先,我们对模块的要求是:定位明确,概念完整。

每个模块要有明确的定位,模块有了定位,说明我们已经想清楚了它的核心职责是什么,这样,每个人对它的期望和理解就会一致。在实践中,我们经常会争论一个功能应该放到 A 模块还是 B 模块,表面上看,各有各的道理,谁也说不服谁,但如果对照模块的定位,回到模块设计的初心,我们往往很快就能有答案。

定位比较抽象,在具体划分模块职责的时候,要保证模块业务概念的完整性。数据上,模块需要覆盖对应业务领域的全部数据,比如一个订单模块,它要覆盖所有渠道的订单,包括三方平台的订单、自有商城的订单、线下门店的订单等,这些不同类型订单的数据模型和实际数据,都由订单模块负责。

功能上,模块要包含业务领域的全部功能,比如订单模块包含所有订单相关的功能,包括订单数据的增删改查、订单业务规则校验、订单的状态和生命周期管理等。

其次,模块还要:自成体系,粒度适中。模块的业务逻辑尽量围绕自身内部数据进行处理,对外部依赖越小,模块的封装性越好,稳定性也越强,不会随着外部模块的调整而调整。

模块的粒度要保持适中,不能为了追求定位清晰,把粒度划分得很小,导致系统的碎片化。比如系统早期的时候,一般我们把积分功能放到用户模块里面,不单独构建积分模块,如果后续积分的概念越来越突出,承载的业务越来越复杂,到时候可以把积分功能分离出来,单独成模块。

这里,为帮助你更好的理解,我举一个模块划分的反面例子。在实际工作中,很多老系统都有体量很大的模块,我们称之为“肿瘤”,它的特点就是定位模糊,职责泛滥,功能无所不包,这样,模块的可维护性很差,没人敢轻易对它动刀子。

依赖关系

依赖关系定义了模块如何协作,一起完成业务流程,依赖关系实质上体现的是模块的组织结构。

如果不对模块的依赖关系做针对性设计的话,依赖关系就是一个多对多的网状结构,一个有 N 个模块的系统,理论上有 N×N 个依赖关系,如果考虑依赖具有方向性,这个数字还要加倍。

所以,要简化模块的依赖关系,我们就要同时简化依赖的方向和减少依赖的数量。

首先,我们希望模块之间的依赖是单向的,尽量避免相互调用,为什么单向更好呢?我们知道业务流程是有顺序的,如果模块依赖关系越直观地体现业务流程的顺序,越能帮助人理解,否则,我们会被双向的依赖箭头绕的晕头转向,很难通过模块之间的依赖关系还原实际业务的处理过程。

接下来,我们看下模块的组织结构。我们知道,网状结构是一种松散的结构,节点之间的依赖关系比较复杂,一般用于表示非正式的关系,比如人群的社交关系;而层次结构是一种更有序的结构,一般用于表示正式的关系,比如公司内部的人员关系。

在模块的组织结构设计上也是如此,我们要尽量把网状结构转化为层次结构,模块结构层次化是简化模块依赖关系的有力手段。

具体做法就是,我们按照模块定位的不同,把模块划分为不同层次,比如划分为上面的应用层和下面的资源层。这样,一个层通过把多个模块组织在一起,就形成了概念上更大粒度的模块。有了层以后,我们理解业务时,因为模块定位相同,往往关注这个更大粒度的层就可以,依赖关系只要指向这个层,而不是层里面的各个模块。这样,从人理解业务的角度,依赖的数量大幅度地减少了。

另外,我们知道,层与层之间的依赖关系都是层与层之间自上而下的依赖,相对于多对多的网状依赖,层次依赖的方向更清晰,特别符合人的理解习惯。

举个具体例子,作为开发,我们都比较了解 MVC 架构,系统模块按照定位,分为表示层、应用层、聚合服务层、基础服务层。

表示层,对应前端的模块,如 App、小程序、公众号等,属于 View 层。

应用层,对应和前端表示层直接关联的服务端,属于 Control 层。

聚合服务层,如果系统业务比较复杂,经常需要单独的聚合服务层负责业务流程的编排组合,这个属于 Model 层的加强。

基础服务层,代表最基础的业务模块管理,如订单、商品、用户等,属于实际的 Model 层。

我在这里贴了一张 MVC 分层结构图,你可以看到,模块总体上是非常清晰的层次结构。

现在,我们清楚了一个可扩展系统对模块和依赖关系的要求,接下来,我们再回到系统扩展性目标,做个深入总结。

扩展性的本质

这只是表象,深层的原因是,一个新的需求进来,系统不只是为它增加一个新功能这么简单,系统的调整会引起一系列的连锁反应,从而大面积地影响系统的现有功能。架构设计时,如果模块划分的不好,一个 N 个模块的系统,它的复杂度就是 N×N(这个在上一讲介绍的支付宝一代架构中,体现得很明显)。如果再加一个新的模块,复杂度就变成 (N+1)×(N+1),系统的复杂度随着功能的数量指数级地上升,这样一来,当系统的规模到一定程度,复杂度就会失控,导致系统彻底无序。

所以,要支持系统的扩展,架构设计上必须能够控制系统的复杂度,面对新需求,要让系统复杂度做加法而不是乘法,从而保证系统的调整是局部化和最小化的,所以,业务架构扩展性的本质是:通过构建合理的模块体系,有效地控制系统复杂度,最小化业务变化引起的系统调整。

那如何打造一个合理的模块体系呢?具体的架构手段就是按照业务对系统进行拆分和整合:通过拆分,实现模块划分;通过整合,优化模块依赖关系。

接下来,我们以一个在线出行公司为例,它有出租车、快车和顺风车 3 条业务线,来具体看下如何为它打造合理的模块体系。

打造可扩展的模块体系:模块拆分

我们先对系统进行模块化拆分,拆分有两种方式:水平拆分和垂直拆分。

水平方向拆分

水平拆分是指从上到下把系统分为多层,按照系统处理的先后顺序,把业务拆分为几个步骤。

比如,整个叫车过程,我们可以分为 UI 展现、地图搜索、运力调度和订单支付等几个环节,这是根据系统的处理过程进行划分的。

这样一来,我们就把一个复杂流程,分解为几个相对独立的环节,分别进行处理,这么做带来了很多好处。

首先,UI 展现部分独立成为一个模块,实现了前后端的分离。我们知道,前端的用户体验和界面样式会经常变化,而后端的数据和业务逻辑相对稳定,通过水平拆分,我们实现了稳定部分和不稳定部分的分开,避免相互影响。

这里的后端包含三个模块,其中地图搜索负责路径规划,运力调度负责人车匹配,订单支付负责交易管理。

可以看到,通过水平拆分,可以使每一块职责都比较明确,功能内聚,每个模块管理自己内部的复杂性。同时,模块之间相互松耦合,一个模块的修改不影响另一个模块,比如地图搜索模块中改变了优先路径的推荐,不会影响运力调度模块中的人车匹配算法。

水平分层可以很好地满足现有业务做深度扩展,当业务有变化时,系统在特定层做调整,对其他层影响有限,这样把变化局限在一个小范围。

垂直方向拆分

垂直拆分指的是按照不同的业务线拆分,比如,将整个出行业务分为出租车业务、快车业务和顺风车业务,按照不同的业务场景,自上而下进行竖切,让每个业务都自成体系,形成自己的业务闭环。

通过垂直拆分,一个复杂的出行场景就拆分为几个具体的场景,我们可以根据各个业务线的特点去设计系统,从而降低了整个系统的复杂性。

垂直拆分可以很好地满足业务广度上的扩展,比如说增加一条新的业务线,可以按照这个思路落地系统。

一般做业务架构时,我们先考虑垂直拆分,从大方向上,把不同业务给区分清楚,然后再针对具体业务,按照业务处理流程进行水平拆分。

如果同时进行垂直拆分和水平拆分,一个大系统被拆分为了一个二维的模块矩阵,每个模块既属于某个业务线,也属于业务流程的某个环节。这样一来,每个模块的职责都很清晰,当业务变化了,我们可以清楚地知道,这个变化涉及哪些模块,然后,对这些模块进行相应的调整就可以。

为了帮你更好地理解这两种拆分方式的好处,我这里举个搭积木的例子。经过拆分,每个业务模块都成为一个积木,然后,我们以搭积木的方式来构造系统。当业务发生变化,我们就调整对应的积木,如果系统拆分得合理,拆分后的模块就具有良好的封装性,也就意味着我们主要是调整积木的内部,而它的外观基本不变。这样一来,相邻的积木不会受到影响,系统整体也不需要大的调整。结果是,系统的变化是局部和可控的,保证了灵活的应对变化能力。

打造可扩展的模块体系:模块整合

系统拆完后,接下来就是模块整合的工作,整合也有两种好的手段:通用化和平台化。

通用化整合

通用化指的是通过抽象设计,让一个模块具备通用的能力,能够替代多个类似功能的模块。

回到刚才的出行平台,我们发现 3 条业务线都有地图搜索、运力调度、订单支付这些模块,不同的业务线之间,这些同名的模块逻辑高度类似,只是细节方面有差别。

那么,我们能不能对这些类似的模块进行抽象化处理,整合成一个通用的模块呢?答案是可以的,我们可以在模块接口中,通过输入参数标识调用来自哪个业务,是出租车、快车还是顺风车,然后在模块内部,针对不同业务线的差异化部分做针对性处理。结果可能是这个通用模块增加 5% 的逻辑,但避免了 95% 的重复逻辑,这样,经过通用化整合,新的模块以很低的代价,就为多个业务线提供了复用。而且,当新的业务线进来,很可能这个通用化的模块,就已经提供了现成的支持。

通过模块通用化,模块的数量减少了,模块的定位更清晰,概念更完整,职责更聚焦。在实践中,当不同业务线对某个功能需求比较类似时,我们经常会使用这个手段。

平台化整合

平台化是把定位相同的模块组织在一起,以组团的方式对外提供服务。对于外部系统来说,我们可以把这些模块看成是一个整体,一起对业务场景提供全面的支撑。

如下图所示,我们可以看到,地图搜索、运力调度、订单支付,都是各个业务线都需要的基础和通用的业务能力,当我们增加新的业务线时,还是离不开这些基础能力。

所以,我们可以把这些基础模块放在同一层,构成一个基础业务平台。之前,它们是一个个离散的服务,独立地输出能力,现在变成一个大的业务平台,可以提供整体的能力输出。

通过打造业务平台,一方面,我们对多个业务模块进行包装,形成更大粒度的抽象,相当于减少了模块的数量;另一方面,作为平台,它的定位更明确,系统依赖关系也更清晰;而且,如果新的业务线进来,它可以基于业务平台快速落地。

业务平台化是模块依赖关系层次化的一个特例,只是它偏向于基础能力,在实践中,当业务线很多,业务规则很复杂时,我们经常把底层业务能力抽取出来,进行平台化处理。

可复用架构

作为开发人员,你对复用这个概念一定不陌生。在开发过程中,我们把系统中通用的代码逻辑抽取出来,变成公共方法或公共类,然后在多个地方调用,这就是最简单的技术上的复用。

但一开始,我们不会过多地考虑复用,当一个新项目过来,我们会选择最直接的方式来实现,结果往往是欲速而不达,比如说:

  • 好不容易搞定了一个项目,接着又有新的类似项目过来,我们又要从头再来;
  • 项目的代码是定制的,项目结束后,系统维护的噩梦刚刚开始。

如果项目缺乏沉淀,每个项目都是全新的开始,出现这些情况,一点都不意外。而要想解决这个问题,我们一开始就要考虑系统的复用性。

用,它可以让我们站在巨人的肩膀上,基于现有的成果,快速落地一个新系统。

复用的分类

复用有多种形式,它可以分为技术复用业务复用两大类。

  • 技术复用包括代码复用技术组件复用
  • 业务复用包括业务实体复用业务流程复用产品复用

从复用的程度来看,从高到低,我们可以依次划分为产品复用 > 业务流程复用 > 业务实体复用 > 组件复用 > 代码复用。

技术复用

​ 首先是代码级复用,这部分应该是你最熟悉的了。这里包括你自己打包的类库,第三方提供的 SDK,还有各种算法封装等。我们的代码可以直接调用它们,物理上也和我们的应用打包在一起,运行在同一个进程里。代码级复用是最低层次的复用,你可以把它当作你自己源代码的一部分。

​ 再往上,是技术组件复用。这些组件有我们自己封装的,更多的是大量开源的中间件,比如 Redis、MQ、Dubbo 等;组件也包括各种开发框架,比如 Spring Cloud。这些基础组件技术复杂度很高,它们的存在,极大地简化了我们的开发工作。

​ 值得注意的是,代码级复用和技术组件复用都属于工具层面,它们的好处是在很多地方都可以用,但和业务场景隔得有点远,不直接对应业务功能,因此复用的价值相对比较低。

业务复用

业务实体复用针对细分的业务领域,比如订单、商品、用户等领域。它对各个业务领域的数据和业务规则进行封装,将它变成上层应用系统可以直接使用的业务组件。

业务流程的复用针对的是业务场景,它可以把多个业务实体串起来,完成一个端到端的任务。比如说,下单流程需要访问会员、商品、订单、库存等多个业务,如果我们把这些调用逻辑封装为一个下单流程服务,那下单页面就可以调用这个流程服务来完成下单,而不需要去深入了解下单的具体过程。相比单个的业务实体复用,业务流程的复用程度更高,业务价值也更大。

最高层次的复用是对整个系统的复用,比如说一个 SaaS 系统(Software-as-a-Service),它在内部做了各种通用化设计,允许我们通过各种参数配置,得到我们想要的功能;或者说一个 PaaS(Platform-as-a-Service)平台,它会提供可编程的插件化支持,允许我们“嵌入”外部代码,实现想要的功能。

这种产品级的复用,它的复用程度无疑是最高的。这样的系统,在落地的时候,它无需核心的开发团队进行开发,只由外围的实施团队负责就可以了,这样,一个项目的上线就能简化为一次快速的实施,不但上线周期短,系统也更稳定。

当然,实现这样的复用,难度也是很大的,你既要对所在行业的业务有很全面的理解,又要有很强的抽象设计能力。这类系统中,比较典型的有 Salesforce 的 CRM 系统和 SAP 的 ERP 系统。

现在,我们先对复用做个总结。**从技术复用到业务复用,越往上,复用程度越高,复用产生的价值也越大,但实现起来也越复杂,它能复用的场景就越有限。**在实际工作中,技术层面上的复用相对比较简单,我们对这部分的认知也最多,而且由于开源的普及,现在有丰富的中间件让我们选择,我们可以基于它们,逐步构建适合自己的技术体系。

但如果我们能进一步打造业务中间件,并在这个基础上,形成业务平台,这样,我们就能实现更高的业务级复用,可以更高效地支持系统的快速落地。

而在实现业务组件化和平台化的过程中,首要的问题就是基础服务边界的划分。边界划分决定了服务的粒度和职责,在实际工作中,也是非常困扰我们和有争议的地方。

基础服务边界划分

服务边界划分要解决“我是谁”的问题,它实现了服务和周边环境的清晰切割。

我们都知道,服务包含了业务数据和业务规则,并提供接口给外部访问,其中,接口是服务的对外视图,它封装了服务的业务数据和规则。

所以从边界划分的角度来看,我们就是要确定哪些数据属于这个服务,哪些接口功能由这个服务提供。这里,我总结了 3 个基础服务边界划分的原则,供你设计时做参考。

首先,是服务的完整性原则

你在划分服务的边界时,需要确保服务内部数据的完整性。

举个例子,一个商品服务的数据模型,不仅要有商品基本信息,比如商品名称、价格、分类、图片、描述等;还需要包含商品的扩展信息,如商品的各种属性、商品标签等;最后还要包含各种复杂商品类型的定义,比如组合商品、套餐商品、多规格商品等。

另外,你还要保证服务功能的完整性。对于服务使用者来说,他们是以业务的角度看服务,而不是纯粹的数据角度。比如一个套餐商品,在服务内部,它是多个单品的复杂组合,但从服务调用者的角度来看,它就是一个商品。

那现在问题来了,对于套餐的价格,商品服务是给出一个最终价格呢?还是给出各个单品的价格,然后让调用方自己算最终价格呢?我们知道,套餐的价格不是各个单品价格累加的结果,它包含了一定的优惠,如果它的价格由服务调用方来算,这会导致商品的部分业务规则游离于服务外面,破坏了商品服务的功能完整性。

在实践中,有些服务只是存储基础数据,然后提供简单的增删改查功能,这样一来,服务只是一个简单的 DAO,变成了数据访问通道。这样的服务,它的价值就很有限,也容易被服务调用方质疑。因此,我们要尽可能在服务内部封装完整的业务规则,对外提供完整的业务语义,最大程度地简化服务的使用。

所以,当你在划分服务边界时,要保证服务数据完整、功能全面,这样才能支撑一个完整的业务领域。

其次,是服务的一致性原则

也就是说,服务的数据和职责要一致,谁拥有信息,谁就负责提供相应的功能。

服务内部的业务逻辑要尽量依赖内部数据,而不是接口输入的数据,否则会造成数据和业务规则的脱节(一个在外面,一个在里面),如果服务对外部的依赖性很强,就无法提供稳定的能力了。

很多时候,我们对一个功能到底划分到哪个服务,有很大的争议。这时,我们可以结合这个功能所依赖的数据来判断,如果功能所需要的大部分数据都存储在 A 服务里,那当然由 A 服务来提供接口比较合适,这样接口输入的数据比较少,不但简化了服务对外部的依赖,同时也降低了接口调用的成本。

给你举个例子,在订单小票上,我们经常能看到一些优惠信息,比如说商品原价是多少,其中因为满减优惠了多少,因为商品特价减免了多少。这个优惠计算的结果是订单的一部分,毫无疑问,它需要保存在订单服务里。

​ **但这个订单的优惠计算过程,却不是由订单服务来负责,而是由独立的促销服务负责的。**因为优惠计算所需要的优惠规则是在促销服务里定义的,促销服务可以在内部拿到所有的优惠规则,然后完成整个优惠计算。

否则,如果是由订单服务负责优惠计算,订单服务的调用者就需要在接口中提供完整的促销规则,不但调用成本高,而且外部促销规则的改变会影响订单服务的内部实现。

​ 所以在这里,促销服务负责促销规则的维护,以及对应的优惠计算功能;订单服务负责优惠结果数据落地,以及后续的查询功能。这样,每个服务存储的数据和对外提供的功能是一致的

最后一个,是正交原则

既然是基础服务,它们就处于调用链的底层,服务之间不会有任何的调用关系,也就是说基础服务相互之间是正交的。比如说会员服务和商品服务,它们代表不同维度的基础业务域,彼此之间不会有调用关系。

正交还有另外一种情况:服务之间有数据的依赖关系,但没有接口的调用关系。

比如说,订单明细里包含商品 ID 信息,但订单服务内部不会调用商品服务来获取商品详情。如果页面需要展示订单的商品详情,针对这个具体的业务场景,我们可以在上层的聚合服务里,通过聚合订单服务和商品服务来实现。

总结

可复用是架构设计的一个重要目标,今天我们对复用进行了梳理,包括复用有哪些形式,以及它们有哪些价值,相信你现在对复用已经有了一个整体的认识。业务上的复用比纯粹的技术复用有更高的价值,我们要尽量往这个方向上靠。

在实践中,落地基础服务是实现业务复用的有效方式,而基础服务边界的划分,它有科学的成分,但更多的是一种艺术,这里我提供了几个实用的划分原则,你可以在工作中结合实际情况,灵活地运用它们。

上层应用调各个基础服务,基础服务之间不会互相调。

落地一个微服务其实并不困难,但要实现一个能够高度复用的共享服务并不容易,在落地过程中,经常会有一系列的问题困扰着我们。

  • 我们事先对服务的边界没有进行很好的划分,结果在落地的过程中,大家反复争论具体功能的归属。
  • 由于对业务的了解不够深入,我们要么设计不足,导致同一个服务有很多版本;要么服务过度设计,实现了一堆永远用不上的功能。

对于落地一个共享服务来说,服务边界的划分和功能的抽象设计是核心。服务边界确定了这个服务应该“做什么”,抽象设计确定了这个服务应该“怎么做”。

接下来,我就以一个实际的订单服务例子,为你详细讲解一下要如何重点解决这两个问题。这样你可以通过具体的案例,去深入地理解如何落地共享服务,实现业务能力的复用。

订单业务架构

不同企业的订单业务是不一样的,所以这里我先介绍下这个订单的业务场景。这是个 O2O(Online To Offline,线上到线下)的交易业务,订单的来源有两个,一个是自有小程序或 App 过来的订单,还有一个是外卖平台过来的订单,然后这些线上的订单会同步到门店的收银系统进行接单和进一步处理。这里我放了一张订单的业务架构图,你可以到文稿中看下:

在这里,订单服务是和 4 个应用直接打交道的:

  • 小程序服务端调用订单服务落地自有线上订单;
  • 外卖同步程序接收三方外卖平台的订单,然后调用订单服务落地订单;
  • POS 同步程序通过订单服务拉取订单,并推送给商户内部的收银系统;
  • 最后还有一个订单管理后台,通过订单服务查询和修改订单。

OK,接下来,我们就具体看下,如何从头开始落地这个订单服务。

订单服务边界划分

首先,我们要确定这个服务的边界,这是进行服务内部设计的前提。划分边界时,你需要对相关的业务场景有充分了解,并且在一定程度上,能够预测潜在的需求。在上一讲,我也和你分享了划分边界一些比较实用的原则和做法,你可以对照学习一下。

根据业务场景的分析,这个订单服务需要负责三个方面的功能。

基本信息管理

首先是订单基本信息管理,主要提供订单基础信息的增删改查功能,包括下单用户、下单商品、收货人、收货地址、收货时间、堂食或外卖、订单状态、取餐码等。

另外,你需要注意的是,这里有多个下单渠道,除了通用的订单信息,每个渠道还有特定的渠道相关信息,比如堂食的订单要有取餐码、外卖的订单要有收货人和收货地址等等,这个都需要在我们的数据模型里给出定义。

订单优惠管理

然后是订单优惠管理功能,这对应的是订单的小票信息,从最开始的商品金额,到最后需要用户实际支付的金额,中间会有一系列的折扣和减免,这些都是属于订单信息的一部分。这些信息我们需要展示给用户看,如果后续要进行订单成本的分摊,也需要用到它。

订单生命周期管理

最后是订单的生命周期管理功能,主要负责管理订单的状态变化。我们知道,从不同下单渠道过来的订单,它的状态变化过程是不一样的;不同行业的订单,它的状态变化过程也是不同的,所以订单服务的状态要做到通用,能够支持各种可能的状态定义和状态转换过程。这个也是订单服务设计的难点,我在后面会重点介绍。

好了,现在我们已经给出了订单服务的功能。为了更好地定义边界,在实践中,你还需要澄清哪些功能不属于服务,这样可以避免后续的很多争论。所以在这里,我会进一步给出订单服务不包括的功能,你在划分自己的服务边界时最好也能够明确给出。

第一,作为基础服务,订单服务不主动调用其他服务。

比如说,你想了解订单的用户详情、商品详情等等,这应该由上层应用通过调用相应的服务来实现,然后和订单信息组装在一起,而不是在订单服务内部直接调用其他服务,否则会导致基础服务之间相互依赖,职责模糊。

如果说这个信息整合的场景非常通用,我们可以创建一个在基础服务之上的聚合服务来实现,把订单信息、用户信息、商品信息整合在一起。

第二,订单服务不负责和第三方系统的集成。

在这里,订单需要在我们的订单服务和三方外卖平台,以及收银系统之间进行同步,这些同步功能都是针对第三方系统定制的,不具有通用性。而我们的订单服务作为基础服务,需要具备通用性,因此这些和外部系统对接的功能不会在订单服务的内部实现,而是由额外的同步程序实现。

小提示:这些同步程序可以主动调用订单服务,然后再和第三方对接,如果想实时获取订单信息的变化,同步程序可以订阅订单服务的消息通知,第一时间了解订单变化。

第三,订单服务不提供优惠计算或成本分摊逻辑。

订单服务不负责具体的优惠计算,只提供优惠结果的存储和查询,用于还原订单的费用组成。优惠的具体计算过程一般由专门的促销系统负责,成本的分摊一般由后续的财务系统负责。这个我们在上一讲中已经说过,这里就不详细解释了。

最后,该服务不提供履单详情,不负责详细物流信息的存储。

比如说,订单已经发送至上海、订单已经到达某某快递站等等这些信息,订单服务不负责提供这些详细信息,这些都是属于后续履单系统的职责。订单服务可以存储一些外部系统的单据号码,比如配送单号,这样能方便上层应用通过订单记录和配送系统进行关联,获取配送的详细信息。但订单服务只负责存储,不负责数据的进一步解释。

到这里,你可以看到,通过从正反两个方面说明订单服务的职责,我们就得到了一个边界很清晰、职责很聚焦的订单服务边界,所有人对它的职责认识是一致的,尽可能地避免了后续的争论。

订单服务内部设计

好,确定了这个订单服务要做什么之后,接下来,我们要解决的就是服务内部怎么做的问题了。

作为共享服务,我们要保证订单服务功能上的通用性,就需要同时对内部数据模型和外部接口进行良好的抽象设计。

订单状态通用化

对于数据模型来说,订单要存储哪些信息,已经比较明确了,具体你可以看下这个图。

但对于如何管理订单的状态,情况就比较复杂了。

我们知道,如果针对一个具体的项目,无论它的订单状态有多么的复杂,我们都可以事先精确地定义出来。但不同的行业甚至不同的企业,他们对于订单状态管理都是不一样的,订单服务作为一个共享服务,它必须要满足不同项目的订单状态管理。所以对于如何解决这个问题,这里我有两个思路供你参考。

一个是开放订单状态定义。

在这里,订单服务事先不限定订单有哪些状态,每个项目都可以自己定义有哪些订单状态。服务的调用方可以在接口里传递任意的状态值;订单服务只负责保存状态数据,不负责解释具体的状态,也不负责任何的规则校验,它允许订单从一个状态转换为其他任意的状态。

这样的设计,在理论上可以满足各种状态的定义,满足各种状态之间的变化,但这样做其实有很大的问题。在这里,订单状态是完全由外部负责管理的,上层应用的负担会很重,不但要负责定义有哪些状态,而且还要维护状态的转换规则,一不小心,订单可能从状态 A 非法地变成状态 B,导致业务出问题。

另外一个是应用和服务共同管理状态。

对于订单状态管理,应用和服务各自承担一部分职责,我们看下具体如何实现。

我们知道,无论订单的状态变化是如何的复杂,我们总是可以定义一个订单有哪些基本的状态,包括这些基本状态之间是如何变化的。比如,订单一开始都是用户下单后待支付,支付完成后变成一个有效的订单,然后由商家进行接单,制作完成后进行发货配送等等,订单最终的状态要么是完成,要么是取消。

**这些订单的基本状态,我们称之为“主状态”,它们由订单服务负责定义,**包括这些主状态之间的转换规则,比如已完成的订单不能变为已取消的订单。主状态的数量是比较有限的,状态之间的变化关系也是比较明确的。

这个主状态,我们对大量现有的业务场景进行总结和抽象,是完全可以定义出来的。在这个订单服务例子里,我们定义了如下图所示的订单状态机,包括有哪些主状态,以及它们的转化关系。

订单除了“主状态”,还有“子状态”。

比如,一个订单处于配送中,实际情况可能是“仓库已发货”,“货已到配送站”,或者是“快递员正在送货中”等等,那么在这些情况中,订单的主状态都是“配送中”,它的子状态就是细化的这几种情况。子状态有哪些具体的取值,不同的项目是不一样的,这个就开放给各个应用来定义。

所以,订单服务数据模型里有两个字段,其中的主状态由订单服务负责管理,包括主状态之间的变化规则;而子状态由上层应用来定义,管理子状态的变化规则,比如一个配送中的订单,它的子状态可以由“仓库已发货”,变为“快递员正在送货中”。

现在,我们就可以总结下这两种订单状态的设计思路。

第一种方案,我们不对订单状态进行管理,而是把订单的状态作为一个简单的属性存储,只支持订单状态简单的增删改查功能。我们知道,订单状态是订单业务规则的核心体现,这样的订单服务是没有灵魂的,也失去了大部分业务复用的价值。

第二种方案,应用和服务共同管理订单的状态,订单服务抓大放小,通过主状态管理把控住了订单的核心业务规则,同时把子状态开放给应用进行管理,为具体的业务场景提供了灵活性。通过主状态和子状态的结合,订单服务就满足了不同行业、不同企业的订单状态管理需求。

订单服务接口定义

说完了订单的状态管理,接下来,我们从调用方怎么使用服务的角度,来看下订单服务外部接口是如何设计的。

外部系统和服务的交互有两种方式,包括同步的服务接口调用异步的消息通知

首先是同步的服务接口调用。

为了方便外部调用方,我们在服务接口命名时,一定要规范和统一,接口名字要能够望文生义,方便调用者快速找到所需要的接口。并且,我们还要提供接口具体的请求和响应样例帮助说明。

具体的接口设计规范,我就不具体展开了,每个公司都要有明确的规范要求,这里我就说下常见的查询接口是如何设计的。

一个订单有很多字段,每次调用方要查询的信息可能都不相同,不同字段之间的组合方式有很多,我们不可能一一支持。

那么,我们怎么设计查询接口,来满足各种场景需求呢” />如何对现有系统做微服务化改造。

​ 很多早期的互联网公司都有巨大的单体应用,底层的数据表集中放在一个数据库里,这些表加起来可能有几百张。对于这样的应用系统和数据库,我们往往需要对它们进行拆分,通过微服务化改造,保证系统能够不断地扩展和复用。

相比从头开始落地服务,对现有系统做微服务化改造,这会面临更多的挑战。

首先,应用和数据表紧密耦合在一起,代码模块和表是多对多的依赖关系。一个模块会访问多张表,多个模块也会对同一张表进行访问,而且由于表都在一个数据库里,开发人员往往会随意对表做关联,有时候甚至 Join 5~6 张表以上。这样,代码模块和表之间的关系是剪不断,理还乱,我们很难清晰地划分代码和数据表的边界,也就很难把它们封装成独立的微服务。

还有,系统现在已经在运行了,我们的改造不能影响业务的稳定性。那微服务落地后,现有的系统要怎么对接微服务,数据要怎么迁移,才能保证系统的平滑过渡呢?

所以,要想应对这些挑战,一方面,我们要保证比较合理的服务设计,才能达到优化系统架构的目的;另一方面,我们要做到整个过程对现有系统的影响比较小,才能达到系统改造顺利落地的目的。

接下来,我就以 1 号店库存服务化改造为例,让你深入理解,我们是如何把库存相关的功能和数据表,从现有系统里剥离出来,最终构建独立的库存服务,并实现和业务系统平滑对接的。

改造背景和目标

们先来看下这次架构改造的背景和目标。

1 号店作为一个网上超市,售卖的商品种类有数十万个,包括 1 号店自营和第三方商家的商品。由于历史原因,所有商品相关的表都存在产品库里面,这里面有产品的表(产品、分类、品牌、组合关系、属性等)、商品 SKU 的表、商家和供应商的表、库存和价格的表等等,这些表加起来,数量超过了上百张。

我们知道,商品是电商业务的核心,几乎所有的前后台系统都需要访问这个产品库,而这些系统的开发人员,早期的时候,只关心如何实现业务功能,对这些表的访问是怎么方便怎么来,有些 SQL 语句会对大量的表做 Join 关联。所以说,虽然系统是类似分布式的,但数据库是集中式的,如下图所示:

这样的方式,就给系统的维护带来了一系列的问题。

  • 应用方面来说,各个系统功能重复建设,比如很多系统都会直接访问库存相关的表,类似的库存逻辑散布在很多地方;另外,如果修改了库存表的某个字段,这些系统同时会受影响,正所谓牵一发而动全身。
  • 数据库方面来说,数据库的可用性是比较差的,如果某个系统有慢查询,它就很可能拖垮整个产品数据库,导致它不可用;还有,这么多系统同时访问产品库,数据库的连接数也经常不够用。

所以,我们这次架构改造的目标,首先是对这个大数据库按照业务维度进行垂直拆分,比如分成产品数据库、库存数据库、价格数据库等等;然后基于这些拆分后的库,构建微服务,以接口的方式来支持数据库表的访问;最后将各个业务系统统一接入微服务,最终完成整个商品体系的微服务化改造。

微服务改造过程

你可以看到,这里涉及了多个微服务,如果同时进行服务化改造的话,牵扯太大,很难落地。于是,我们选择从库存微服务开始。一方面,库存的业务很重要,库存的规则也比较复杂,如果我们能够对库存逻辑进行优化,这会带来明显的业务价值;另一方面,电商的库存概念相对独立,涉及的表也比较少,我们可以相对容易地把它从现有体系中剥离出来。

整个改造过程,从确定库存相关的表开始,到最后把库存表从产品库迁移出来,落到单独的库存数据库为止,一共分为两个阶段,每个阶段包含了 3 个步骤,具体如下图所示:

  • 准备阶段:这个阶段为微服务改造做好前期的准备工作,具体步骤包括了圈表、收集 SQL 和 SQL 拆分。
  • 实施阶段:这个阶段实际落地微服务,具体步骤包括微服务开发、服务接入和数据库独立。

通过这些良好定义的步骤,我们就很好地保证了整个库存微服务改造的有序和可控。接下来,我就具体说明下改造的各个步骤,包括哪些人负责哪些事情、具体的挑战在什么地方,这样,你可以深入地理解整个改造过程。

准备阶段

准备阶段的第一步,就是圈表。产品数据库有 100 多张表,圈表就是用来确定库存微服务具体包含哪些表,也就是确定服务的数据模型。在确定了表以后,库存微服务就负责这些表的访问,当然,库存微服务也不会访问其它的表,而业务系统后续将通过库存微服务的接口,实现对这些表的访问。

圈表是微服务改造中比较有挑战性的地方,它实际上对应了服务的边界划分。只是针对老系统做服务化改造的时候,我们更多的是从数据库表的角度来考虑划分,这样更好落地。

针对库存微服务来说,我们要求圈定的表,一方面要满足所有的库存访问需求,这些表之间关系紧密,和其它的表关联不大;另一方面,这些表的数量不能太多,一般不超过十几张。这样,我们既容易拆分数据库,又能控制服务的粒度,保证功能聚焦。

在这个例子中,由于库存的概念比较独立,圈表相对比较容易,一共有 15 张表和库存直接相关,包括自营库存表 (这里有分表,实际是 12 张)、商家虚拟库存表、活动库存表和库存共享表,这些库存表之间是紧密相关的,它们一起决定了前台用户能看到的可用库存数量。

这些库存相关的表都有商品 ID 字段,和商品基本信息表关联,我们知道,库存数量的计算不依赖于商品的具体信息。所以,这些库存表和其它表的关系比较弱,这样我们就可以比较清晰地实现库存表和其它表的切分,简化了库存服务的落地。

在微服务改造中,确定哪些表属于这个服务,会直接影响后续的所有改造工作,这需要有经验的业务架构师和数据架构师参与进来,通过深入地分析现有的业务场景和表的关系,才能对库表进行合理的划分。

所以,你可以发现,对现有系统的改造,服务的边界划分主要是从圈表入手的,而不是从一个服务应该有哪些功能入手的,这一点和新服务设计是有所不同的。这有两方面原因:

  • 一方面,如果确定了服务包含哪些表,也就大致确定了服务有哪些功能,而表是现成的,它比业务功能要直观很多,所以从表入手比较高效;
  • 另一方面,如果从表入手,构造的服务和表是对应的,服务包含的是完整的表,不会产生一个表的一部分字段属于库存服务,而另一部分字段属于别的服务的情况,避免表字段的拆分带来额外的复杂性。

值得注意的是,因为这是对现有系统的改造,为了避免一下子引入太多变化,我们先不对库存的表结构进行调整,表结构的优化可以放在服务的升级版里做,这样对业务系统的影响也最小。

第二步是收集 SQL。在确定了哪些表属于库存服务后,我们会收集所有业务系统访问这些表的 SQL 语句,包括它的业务场景说明、访问频率等等。库存微服务后续就针对这些 SQL 进行封装,提供相应的接口给业务系统使用。

这里,服务开发团队负责提供 SQL 收集的 Excel 模板,各业务系统开发团队负责收集具体的 SQL。

第三步是拆分 SQL。对于收集过来的 SQL 语句,有些 SQL 不仅仅访问圈定的这几张库存表,还会和产品库中的其他表进行关联。

比如说,商品详情页需要展示商品详情,它会发起 SQL 查询商品基本信息表和库存表,一次性获取商品的基本信息和库存数量。针对这种情况,我们就需要把查询语句拆分为两条 SQL,先查询商品表获取商品基本信息,再查询库存表获取库存数量。

对于这样的 SQL 语句,我们就要求各个业务团队先进行拆分,保证最后提供给服务开发团队的 SQL,只包含访问库存的相关表。通过 SQL 拆分,我们切断了库存表和其他表的直接联系,等后面微服务落地后,业务系统就可以通过接入微服务,完成现有 SQL 的替换。

SQL 拆分,会涉及一定的业务系统改造,这部分工作主要由各个研发团队负责,一般情况下,性能可能会受些影响,但问题不是很大。

实施阶段

完成了圈表、SQL 收集和拆分以后,接下来,我们就进入了服务实际落地的阶段。

第四步是构建库存微服务。这里面包括了接口设计、代码开发、功能测试等步骤,服务开发团队会对业务方提供的 SQL 进行梳理,然后对接口做一定的通用化设计,避免为每个 SQL 定制一个单独的接口,以此保证服务的复用能力。

这部分工作由微服务开发团队负责,第一版的服务主要是做好接口设计,聚焦业务功能,以保证服务能够落地,业务系统能够顺利对接为目标。将来,服务可以持续迭代内部做各种技术性优化,只要服务的接口保持不变,就不会影响业务系统。

**第五步是接入库存微服务。**库存服务经过功能和性能验证以后,会由各个业务开发团队逐步接入,替换原来的 SQL 语句。这部分工作主要由业务研发团队负责,难度不大,但需要耗费比较多的时间。

**最后一步是数据库独立。**当服务接入完成,所有的 SQL 语句都被替换后,业务系统已经不会直接访问这些库存的表。这时,我们就可以把库存相关的表,从原来的产品库中迁移出来,部署成为一个物理上独立的数据库。业务系统是通过服务来访问数据库的,因此,这个数据迁移对于业务系统来说是透明的,业务团队甚至都不用关心这些表的新位置。

通过库存表独立成库,我们可以从物理层面,切断业务团队对这些表的依赖,同时,也可以大幅度降低产品库的压力,特别是大促的时候,库存读写压力是非常大的,数据库独立也为库存服务后续的技术优化打下了基础。

这部分工作主要由微服务开发团队和 DBA 一起配合完成,主要是要避免业务系统还有遗漏的 SQL 语句,避免它们还在直接访问库存的表。我们可以在迁库前,通过代码扫描做好相应的检查工作。

改造完成后的库存微服务架构如下图所示,库存微服务一共包含了 15 张表,对外有 30 多个接口,几十个业务系统接入库存服务。平时,库存服务会部署 50 个实例,大促时会部署更多,我们很容易通过加机器的方式,实现库存服务的水平扩展。

微服务改造小结

到这里,我们的库存微服务就改造完成了,整个改造大概持续了 3 个月,主要是对接的工作比较耗时。

从前面的步骤中,你可以看到,除了做好库存服务本身的设计开发工作,相关团队之间的配合也是非常重要的。

在整个改造过程中,有很多团队之间沟通和确认的环节。比如说,服务开发团队圈定表以后,需要和业务开发团队一起确认,保证圈表的合理性;在业务团队拆分 SQL 的过程中,服务开发团队需要介入进去,帮助解决拆分时带来的性能和一致性问题;在服务接口设计和接入过程中,服务的接口可能需要重新调整,也可能有新的 SQL 进来,双方需要及时沟通,相互配合。

这些都是纯技术层面的问题,值得一提的是,系统改造不会产生直接的业务价值,对于业务开发团队来说,他们往往还需要承担大量新需求的开发工作。所以,从项目推进的角度来看,这种核心服务的改造,很多时候都是技术一把手工程。在库存微服务改造过程中,我们也是老板高度重视,大家事先定好时间计划,每周 Review 进度,协调各个团队工作的优先级,确保改造的顺利落地。

以上就是库存微服务改造的例子。1 号店的系统从 08 年就开始建设了,由于历史原因,形成了几个典型的大库,比如产品库、用户库等等,我们通过类似的微服务改造,逐步把这些大库拆分开,构建了一系列的基础服务,如订单服务、用户服务、产品服务、库存服务、价格服务等等。而且通过这些微服务化改造,我们同时提升了业务的复用性和系统的稳定性。

最后,我在这里放了一张 1 号店的总体系统架构图,你可以深入看下,一个历史包袱很重的系统,它是如何经过服务化改造,最终变成一个能够高度复用和扩展的平台的。

总结

好了,下面我总结一下今天所讲的内容。基于现有系统进行改造和全新的服务设计是有所不同的,我们不能追求理想化和一步到位,而是要考虑到系统的平滑过渡,先实现微服务的顺利落地,后续再考虑各种优化。

中台是如何炼成的?

如何在实际的业务场景中,通过一步步的架构升级,最后落地一个中台,实现企业级能力的复用。

通过前面的介绍,我们已经很清楚了共享服务和中台的价值,但在实践中,要不要对系统做这样的升级,我们还需要结合业务来判断,比如说:

  • 业务上有什么重大变化,导致当前系统的弊端已经很明显,不能适应业务发展了呢?
  • 架构改造时,如何在业务、系统、资源三者之间做好平衡,对系统进行分步式的改造呢?

我们知道,架构没有最好,只有最合适的。随着业务的发展,系统需要不断地升级,这是一个螺旋式上升的过程,如何结合当前的业务发展阶段,适时地推进架构改造,并能比较接地气地落地,是我们要追求的目标。

接下来,我以实际的订单系统改造为例,结合订单业务的发展和系统的痛点,为你介绍,如何推进架构从单体到共享服务、再到中台的改造过程,保证系统能够不断适配业务的升级。

先说下项目背景。公司作为供应商,为大型餐饮连锁企业打造 O2O 交易平台,包括三方聚合外卖、自有小程序、App 点餐,这些线上用户的订单最终会落到门店的收银系统,由门店进行履单。

公司的业务发展有一个变化过程,一开始只提供聚合外卖服务,后来进一步提供小程序 /App 下单服务。你可以发现,整个订单处理的架构也是随着业务的变化而不断演变的,下面我就为你一一介绍。

聚合外卖订单架构

一开始,我们提供的是聚合外卖服务,相应地,系统整体架构如下图所示:

这里一共有三个系统,分别是三方外卖平台、门店收银系统以及外卖系统。其中,外卖系统是我们开发的,其他两个都是我们要对接的外部系统,接下来,我说下系统具体的交互过程。

首先,用户在三方外卖平台(如美团、饿了么)下单;然后,我们的外卖系统通过外卖平台的 API 拉取用户的订单,把订单落到本地数据库;最后,门店的收银系统访问外卖系统提供的接口获取订单,在门店内部完成履单。当然,门店履单后,收银系统会反过来同步订单状态给外卖系统,外卖系统再同步订单状态到第三方外卖平台。

你可以看到,这里的外卖系统是一个单体应用,内部包含外卖同步接口和 POS 接口两个模块。其中,外卖同步接口负责和第三方外卖平台对接,它主要是针对不同的外卖平台做接口适配;而 POS 接口负责和门店的收银系统对接。这两个模块都是使用同一个外卖订单数据库。

数据模型上看,系统的订单模型也是完全按照外卖订单的需求设计的,订单状态管理也相对比较简单,因为这些订单都是用户在第三方外卖平台已经完成支付的。所以,我们的外卖系统,主要是负责管理门店履单过程中带来的订单状态变化。

系统架构上看,外卖系统从外卖平台接单,然后把订单推送给后面的收银系统,只需要一个应用、一个数据库、两套接口就可以支持,使用单体架构就能很好地满足外卖的接单需求。

小程序下单架构

接下来,随着公司业务的升级,除了提供聚合外卖服务之外,公司还提供自有小程序的下单服务。这样,消费者既可以在三方外卖平台下单,也可以在品牌自有的小程序里下单。

不同于三方外卖订单,小程序下单平台是一个完整的业务,它包括小程序用户注册、商品和菜单浏览、商品加购物车、在线支付等等。相应地,这里会有多个基础服务对应具体业务的处理。比如,商品服务提供前台的商品浏览功能,支付服务提供用户的支付功能,这些基础服务都是由独立的小程序服务端负责整合,然后提供接口供小程序前端访问。

当用户在小程序提交订单后,小程序前端会调用服务端的下单接口,然后服务端调用订单服务,在小程序的订单库里落地订单。现在我们已经完成了前台用户的下单,但后台的订单履行怎么处理呢?这里有两种选择:

  • 小程序订单和外卖订单的处理类似,收银系统除了对接外卖系统,同时也对接小程序的订单服务。但这样一来,收银系统需要同时对接两套订单接口,它需要做大的改造。由于这是第三方的系统,我们在实践中很难落地。
  • 我们把小程序订单当作一个特殊的外卖渠道,把小程序订单推送到外卖订单库里,最终还是由外卖系统来对接收银系统,也就是相当于小程序订单直接借用了外卖订单的履单通道。

当时由于项目上线的时间比较紧急,同时从系统稳定性的角度出发,避免对收银系统做大的改造,我们采用了第二种方式,小程序的订单处理就嫁接在已有的外卖系统上,整个系统架构如下图所示:

你可以看到,小程序下单平台和外卖系统相对独立,同时为了更好地解耦,小程序订单服务和外卖系统之间是通过消息系统同步订单数据的。

这个方案是一个比较务实的选择,通过复用外卖订单的履单通路,我们也实现了小程序订单的闭环处理。表面上看,我们节省了重新搭建系统的成本,也快速落地了小程序交易这条新业务线。

但这样的架构实际上是一种妥协,在后续的系统运行过程中,给我们带来了很多问题:

  • 这里有两套订单系统,一套针对小程序订单,一套针对外卖订单。我们知道,两者的字段属性和订单状态定义都有不同的地方,我们把小程序的订单硬生生地套在了外卖订单的模型里,这样限制了小程序订单能力的扩展。
  • 小程序订单处理链路过长,从小程序服务端 -> 订单服务 -> 小程序订单数据库 -> 消息系统 -> 外卖同步接口 -> 外卖订单数据库 -> POS 接口 -> 收银系统,一共包含了 8 个处理环节,系统整体的性能和可用性都存在很大问题。比如,取餐码已经从收银系统同步给了外卖系统,但由于消息队列堵塞,外卖系统不能及时同步给小程序的订单服务,这样导致了小程序用户不能及时地看到取餐码。
  • 为了使两套订单系统解耦,我们使用了消息队列在两个库之间同步订单数据,这降低了系统整体的稳定性。实践中,也发生过多起消息队列故障导致的线上事故。

你可以发现,出现这些问题的根源是我们把小程序订单硬塞给外卖系统,一方面订单数据模型不匹配,另一方面由于这是两个系统的简单拼接,导致系统调用链路很长,影响了业务的扩展和系统的稳定性。

那有没有更好的办法,**能够把这两个系统有机地结合起来呢?**接下来,我们就来看下,如何通过一个统一的订单服务对两个系统进行深度的融合,从而灵活地支持多种订单业务。

统一订单服务架构

这里,我们把小程序订单服务提升为统一共享的订单服务,由它来落地所有类型的订单。对于这个统一的订单服务来说,外卖订单、小程序订单,或者是其他的新订单,都是它的下单来源,所有订单汇总在订单服务里,然后统一提供给收银系统进行履单。具体架构如下图所示:

你可以看到,系统架构经过调整,有两个大的变化:

  • 原来外卖和小程序各自有一个订单库,现在合并为了一个订单库,由这个订单服务统一对外提供订单数据的访问和状态管理。
  • 原来外卖系统的两个模块“外卖同步接口”和“POS 接口”,升级为了两个独立的应用。外卖同步接口变成外卖同步服务,对接外卖平台;POS 接口变成 POS 服务,对接门店的收银系统。它们都是通过统一订单服务存取订单数据。

经过升级,新的架构具备了明显的层次结构,自上而下分为三层:

  • 首先是各个渠道端,包括三方外卖平台、小程序前端和 POS 收银系统;
  • 然后,每个端都有相应的服务端来对接,比如外卖同步服务对接外卖平台、小程序服务端对接小程序、POS 服务对接收银系统;
  • 最后,这些服务端都统一调用底层的订单服务。

在这个架构里,如果我们要增加新的下单渠道,就非常方便,比如要支持 App 下单,我们提供 App 服务端即可;要新增加后台履单方式也非常方便,比如对于新的电子卡券类订单,它不需要经过收银系统,可以直接由企业的 OMS 系统(Order Management System,订单管理系统)处理,要实现这样的业务,我们只需新增加一个和 OMS 系统的适配应用就可以了。所以,这里就不仅仅是一个外卖订单和小程序订单的处理平台,而是升级成了一个完整的全渠道交易平台。

同时,订单处理的链路大大缩短,从小程序服务端 -> 订单服务 -> 订单数据库 -> POS 服务 -> 收银系统,只有 5 个节点,相比之前减少了 3 个,系统的可用性和端到端的性能得到了大幅度的提升。

最后,统一订单服务实现了统一的订单属性定义、统一的订单状态管理,以及订单数据的集中存储,这对后续的 BI 分析和数据中台建设非常有帮助它们处理数据时,只需要从一个订单库拉取数据,解析一个订单数据模型就可以了。

中台架构

上面的统一订单服务整合了外卖和小程序的订单,并且为新的下单渠道预留扩展。按照同样的思路,我们可以构建统一的商品服务,同时满足外卖和小程序上商品的管理;可以构建统一的促销服务,同时支持线上和线下的促销活动;也可以构建统一的库存服务,实现线上和线下库存的同步和共享等等。

通过构建这样一系列的共享服务,我们就实现了各个渠道业务规则和业务数据的统一管理,最终我们落地了一个强大的业务中台,可以很方便地扩展各个业务,实现企业整体业务能力的复用。

最后,实际项目的中台架构如下图所示:

在这个架构中,前端有 3 个业务场景,分别是小程序点单、App 商城下单、外卖平台下单,每个业务场景都有相应的服务端负责对接。在各个服务端下面,还有一些辅助的应用,如购物车、秒杀、拼团等等。同时这里还有一个订单控制服务(Order Control Service,OCS),负责订单逻辑的编排以及前后台之间的状态同步,你可以把它看作是基础服务之上的聚合服务。

再底下就是核心的业务中台,它由 9 大服务中心组成,这些中心和商户内部系统进行对接。其中,商品中心和库存中心对接 ERP 系统,会员中心对接 CRM 系统,订单中心对接 POS 收银系统,这里的对接分别由对应的适配插件负责。

通过这个订单业务改造落地后的中台架构,你可以看到,中台由各个通用的基础服务构成,它是相对标准的;而插件是定制的,具体和每个企业的后台系统有关。这样,通过共享服务和中台,我们就把企业内部基础设施和线上业务场景有效地打通了,从系统架构的层面,为企业的全面数字化转型打下了良好的基础。

总结

今天,我从一个企业的订单业务变化出发,为你介绍了为什么要落地一个统一的订单服务,以及如何落地,并通过打造一系列类似的共享服务,逐步升级系统到中台架构。

相信通过这个实际案例,你进一步理解了如何通过共享服务和中台,实现业务能力的复用,并能根据公司的业务发展阶段,选择合适的时机、合适的架构,以接地气的方式对系统进行逐步改造。

技术架构

面对一个复杂的系统,我想你可能经常会有以下困扰:

  • 不清楚系统整体的处理过程,当系统出问题时,不知道如何有针对性地去排查问题。
  • 系统设计时,经常忽视非业务性功能的需求,也不清楚如何实现这些目标,经常是付出惨痛的教训后,才去亡羊补牢。
系统的物理模型

对于大部分开发人员来说,我们主要的工作是写业务相关的代码,保证业务逻辑正确、业务数据准确,然后,这些业务代码经过打包部署后,变成实际可运行的应用。但我们写的代码只是系统的冰山一角,为了保证应用能正常运行,我们需要从端到端系统的角度进行分析。

我们先看下一个系统的具体组成,这里我为你提供了一个简化的系统物理模型,你可以了解一个系统大致包含哪些部分。

从用户请求的处理过程来看,系统主要包括五大部分。

首先是接入系统,它负责接收用户的请求,然后把用户的请求分发到某个 Web 服务器进行处理,接入系统主要包括 DNS 域名解析、负载均衡、Web 服务器这些组件。

接下来,Web 服务器会把请求交给应用系统进行处理。一般来说,我们是基于某个开发框架来开发应用的,比如 Java 应用一般是基于 Spring MVC 框架。

这个时候,开发框架首先会介入请求的处理,比如对 HTTP 协议进行解析,然后根据请求的 URL 和业务参数,转给我们写的业务方法。接下来,我们的应用代码,会调用开发语言提供的库和各种第三方的库,比如 JDK 和 Log4j,一起完成业务逻辑处理。在这里,我们会把开发框架、应用代码,还有这些库打包在一起,组成一个应用系统,作为独立的进程在 Web 服务器中进行部署和运行。

到这里,整个系统要做的事情就完了吗?

还没有呢,在我们的应用系统底下,还有基础平台,它由好几个部分组成:首先是各个语言的运行时,比如说 JVM;然后是容器或虚拟机;下面还有操作系统;最底下就是硬件和网络。

接入系统、应用系统、基础平台就构成一个最简单的系统。

在大多数情况下,应用系统还要借助大量外部的中间件来实现功能和落地数据,比如数据库、缓存、消息队列,以及 RPC 通讯框架等等。这里,我统称它们为核心组件,它们也是系统不可缺少的一部分。

除此之外,还有大量周边的支撑系统在支持应用的正常运行,包括日志系统、配置系统,还有大量的运维系统,它们提供监控、安全、资源调度等功能,它们和核心组件的区别是,这些系统一般不参与实际的用户请求处理,但它们在背后默默保障系统的正常运行。

到这里,你可以发现,一个端到端的系统是非常复杂的,它包含了大量的软硬件。为了保障我们的应用代码能够正常运行,我们就需要保证这里的每个组件不出问题,否则一旦组件出问题,很可能就导致系统整体的不可用。

技术架构的挑战

应用代码怎么组织(比如模块划分和服务分层),那主要是业务架构的事,这部分在前面我们已经讨论过很多了;而技术架构的职责,首先是负责系统所有组件的技术选型,然后确保这些组件可以正常运行。

我们知道,系统是由硬件和软件组成的。接下来,我们就分别从软硬件的角度来看下,技术架构都会面临什么挑战,我们需要如何应对。

硬件的问题

硬件是一个系统最基础的部分,负责真正干活的,但它有两方面的问题。

首先是硬件的处理能力有限。 对于服务器来说,它的 CPU 频率、内存容量、磁盘速度等等都是有限的。虽然说按照摩尔定律,随着制造工艺的发展,大概每隔 18 个月,硬件的性能可以提升一倍,但还是赶不上快速增长的系统处理能力的要求,特别是目前许多互联网平台,面向的都是海量的 C 端用户,对系统处理能力的要求可以说是没有上限的。

从技术架构的角度,提升硬件的处理能力一般有两种方式。

  • Scale Up

也就是垂直扩展,简单地说就是通过升级硬件来提升处理能力。CPU 不够快,升级内核数量;内存不够多,升级容量;网络带宽不够,升级带宽。所以说,Scale Up 实际上是提升硬件的质量。

  • Scale Out

也就是水平扩展,通过增加机器数量来提升处理能力。一台机器不够,就增加到 2 台、4 台,以及更多,通过大量廉价设备的叠加,增强系统整体的处理能力。所以说,Scale Out 是提升硬件的数量。

垂直扩展是最简单的方式,对系统来说,它看到的是一个性能更强的组件,技术架构上不需要任何改造。如果碰到性能有问题,垂直扩展是我们的首选,但它有物理上的瓶颈或成本的问题。受硬件的物理限制,机器的性能是有天花板的;或者有时候,硬件超出了主流的配置,它的成本会指数级增长,导致我们无法承受。

水平扩展通过硬件数量弥补性能问题,理论上可以应对所有服务器处理能力不足的情况,并实现系统处理能力和硬件成本保持一个线性增长的关系。

但水平扩展对于系统来说,它看到的是多个组件,比如说多台 Web 服务器。如何有效地管理大量的机器,一方面,使得性能上可以实现类似 1+1=2 的效果;另一方面,要让系统各个部分能够有效地衔接起来,稳定地运行,这不是一件容易的事情。我们需要通过很复杂的技术架构设计来保障,比如说,通过额外的负载均衡,来支持多台 Web 服务器并行工作。

硬件的第二个问题是,硬件不是 100% 的可靠,它本身也会出问题。

比如说,服务器断电了,网络电缆被挖断了,甚至是各种自然灾害导致机房整体不可用。尤其是一个大型系统,服务器规模很大,网络很复杂,一旦某个节点出问题,整个系统都可能受影响,所以,机器数量变多,也放大了系统出故障的概率,导致系统整体的可用性变差。我们在做技术架构设计时,就要充分考虑各种硬件故障的可能性,做好应对方案。比如说针对自然灾害,系统做异地多机房部署。

软件的问题

接下来我们说下软件的问题,这里的软件,主要说的是各种中间件和系统级软件,它们配合我们的应用代码一起工作。

软件是硬件的延伸,它主要是解决硬件的各种问题,软件通过进一步封装,给系统带来了两大好处。

  • **首先是弥补了硬件的缺陷。**比如 Redis 集群,通过数据分片,解决了单台服务器内存和带宽的瓶颈问题,实现服务器处理能力的水平扩展;通过数据多副本和故障节点转移,解决了单台服务器故障导致的可用性问题。
  • **其次,封装让我们可以更高效地访问系统资源。**比如说,数据库是对文件系统的加强,使数据的存取更高效;缓存是对数据库的加强,使热点数据的访问更高效。

但软件在填硬件的各种坑的同时,也给系统挖了新的坑。举个例子,Redis 集群的多节点,它解决了单节点处理能力问题,但同时也带来了新的问题,比如节点内部的网络有问题(即网络分区现象),集群的可用性就有问题;Redis 数据的多副本,它解决了单台服务器故障带来的可用性问题,但同时也带来了数据的一致性问题。

我们知道,分布式系统有个典型的 CAP 理论,C 代表系统内部的数据一致性,A 代码系统的可用性,P 代表节点之间的网络是否允许出问题,我们在这三者里面只能选择两个。对于一个分布式系统来说,网络出问题是比较常见的,所以我们首先要选择 P,这意味着我们在剩下的 C 和 A 之间只能选择一个。

CAP 理论只是针对一个小的数据型的分布式系统,如果放大到整个业务系统,C 和 A 的选择就更加复杂了。

比如有时候,我们直接对订单进行写库,这是倾向于保证数据一致性 C,但如果数据库故障或者流量太大,写入不成功,导致当前的业务功能失败,也就是系统的可用性 A 产生了问题。如果我们不直接落库,先发订单数据到消息系统,再由消费者接收消息进行落库,这样即使单量很大或数据库有问题,最终订单还是可以落地,不影响当前的下单功能,保证了系统的可用性,但可能不同地方(比如缓存和数据库)的订单数据就有一致性的问题。

鱼和熊掌不能兼得,系统无法同时满足 CAP 的要求,我们就需要结合具体的业务场景,识别最突出的挑战,然后选择合适的组件,并以合理的方式去使用它们,最终保障系统的稳定运行,不产生大的业务问题。

技术架构的目标

好,现在你已经了解了系统的复杂性和软硬件的问题,那技术架构就要选择和组合各种软硬件,再结合我们开发的应用代码,来解决系统非功能性需求。

**什么是系统非功能性需求呢?**这是相对于业务需求来说的,所谓的业务需求就是保证业务逻辑正确,数据准确。比如一个订单,我们要保证订单各项数据是准确的,订单优惠和金额计算逻辑是正确的。而一个订单页面打开需要多少时间,页面是不是每次都能打开,这些就和具体的业务逻辑没有关系,属于系统非功能性需求的范畴。产品经理在一般情况下,也不会明确提这些需求。非功能性需求,有时候我们也称之为系统级功能,和业务功能相区分。

那对于一个系统来说,技术架构都要解决哪些非功能性需求呢?

系统的高可用

可用性的衡量标准是,系统正常工作的时间除以总体时间,通常用几个 9 来表示,比如 3 个 9 表示系统在 99.9% 的时间内可用,4 个 9 表示 99.99% 的时间内可用,这里的正常工作表示系统可以在相对合理的时间内返回预计的结果。

导致系统可用性出问题,一般是两种情况:

  • 一种是软硬件本身有故障,比如机器断电,网络不通。这要求我们要么及时解决当前节点的故障问题,要么做故障转移,让备份系统快速顶上。
  • 还有一种是高并发引起的系统处理能力的不足,软硬件系统经常在处理能力不足时,直接瘫痪掉,比如 CPU 100% 的时候,整个系统完全不工作。这要求我们要么提升处理能力,比如采取水平扩展、缓存等措施;要么把流量控制在系统能处理的水平,比如采取限流、降级等措施。

系统的高性能

我们这里说的高性能,并不是指系统的绝对性能要多高,而是系统要提供合理的性能。比如说,我们要保证前端页面可以在 3s 内打开,这样用户体验比较好。

保证合理的性能分两种情况:

  • 一种是常规的流量进来,但系统内部处理比较复杂,我们就需要运用技术手段进行优化。比如针对海量商品的检索,我们就需要构建复杂的搜索系统来支持。
  • 第二种是高并发的流量进来,系统仍旧需要在合理的时间内提供响应,这就更强调我们做架构设计时,要保证系统的处理能力能够整体上做水平扩展,而不仅仅是对某个节点做绝对的性能优化,因为流量的提升是很难准确预计的。

系统的可伸缩和低成本

系统的业务量在不同的时间点,有高峰有低谷,比如餐饮行业有午高峰和晚高峰,还有电商的大促场景。我们的架构设计要保证系统在业务高峰时,要能快速地增加资源来提升系统处理能力;反之,当业务低谷时,可以快速地减少系统资源,保证系统的低成本。

高可用、高性能、可伸缩和低成本,这些技术架构的目标不是孤立的,相互之间有关联,比如说有大流量请求进来,如果系统有很好的伸缩能力,它就能通过水平扩展的方式,保证系统有高性能,同时也实现了系统的高可用。

如果系统的处理能力无法快速提升,无法保证高性能,那我们还是可以通过限流、降级等措施,保证核心系统的高可用。我在前面也提到,这些目标很多时候会冲突,或者只能部分实现,我们在做技术架构设计时,不能不顾一切地要求达到所有目标,而是要根据业务特点,选择最关键的目标予以实现。

比如说,一个新闻阅读系统,它和订单、钱没有关系,即使短时间不可用,对用户影响也不大。但在出现热点新闻时,系统要能支持高并发的用户请求。因此,这里的设计,主要是考虑满足高性能,而不用太过于追求 4 个 9 或 5 个 9 的可用性。

总结

系统比我们想象的要复杂得多,这里,我和你分享了系统的物理模型,相信你不再局限于我们自己写的代码,而是对系统的整体结构有了更清晰的认识。

因为业务架构解决的是系统功能性问题,我们更多的是从人出发,去更好地理解系统;而技术架构解决的是系统非功能性问题,我们在识别出业务上的性能、可用性等挑战后,更多的是从软硬件节点的处理能力出发,通过合理的技术选型和搭配,最终实现系统的高可用、高性能和可伸缩等目标。

高可用架构

系统有哪些故障点?

那么一个系统,它在运行的过程中,都可能会出现哪些故障呢?我们来看一个简化的系统处理过程。

首先,客户端在远程发起请求,经过接入系统处理后,请求被转发给应用系统;应用系统调用服务完成具体的功能;在这个过程中,应用和服务还会访问各种资源,比如数据库和缓存。这里,我用红色部分,标识出了整个处理过程中可能出现的故障点,如下图所示:

这些故障点可以归纳为三类:

  • 资源不可用,包括网络和服务器出故障,网络出故障表明节点连接不上,服务器出故障表明该节点本身不能正常工作。
  • 资源不足,常规的流量进来,节点能正常工作,但在高并发的情况下,节点无法正常工作,对外表现为响应超时。
  • 节点的功能有问题,这个主要体现在我们开发的代码上,比如它的内部业务逻辑有问题,或者是接口不兼容导致客户端调用出了问题;另外有些不够成熟的中间件,有时也会有功能性问题。
高可用策略和架构原则

系统可能出问题的地方有很多,解决的方式也不一样,在讨论具体的解决手段之前,我想先说下高可用的总体解决思路,这样你就能更好地理解具体的实现方式。

要想让系统能够稳定可用,我们首先要考虑如何避免问题的发生。比如说,我们可以通过 UPS(Uninterruptible Power System,不间断电源)来避免服务器断电,可以通过事先增加机器来解决硬件资源不足的问题。

然后,如果问题真的发生了,我们就要考虑怎么转移故障(Failover)。比如说,我们可以通过冗余部署,当一个节点发生故障时,用其它正常的节点来代替问题节点。

如果故障无法以正面的方式解决,我们就要努力降低故障带来的影响。比如说流量太大,我们可以通过限流,来保证部分用户可以正常使用,或者通过业务降级的手段,关闭一些次要功能,保证核心功能仍旧可用。

最后是要快速恢复系统。我们要尽快找到问题的原因,然后修复故障节点,使系统恢复到正常状态。

这里我要强调的是,处理线上事故的首要原则是先尽快恢复业务,而不是先定位系统的问题,再通过解决问题来恢复系统。因为这样做往往比较耗时,这里给出的处理顺序也体现了这个原则。

那么结合前面介绍的系统故障点和高可用的解决思路,我们在做架构设计时,就可以从 正面保障减少损失 两个角度来考虑具体的应对手段。下面,我就来和你分享一下高可用的设计原则。

正面保障

第一个设计原则是冗余无单点

首先,我们要保证系统的各个节点在部署时是冗余的,没有单点。比如在接入层中,我们可以实现负载均衡的双节点部署,这样在一个节点出现问题时,另一个节点可以快速接管,继续提供服务。

还有远程网络通信,它会涉及到很多节点,也很容易会出现问题,我们就可以提供多条通信线路,比如移动 + 电信线路,当一条线路出现问题时,系统就可以迅速切换到另一条线路。

甚至,我们可以做到机房层面的冗余,通过系统的异地多 IDC 部署,解决自然灾害(如地震、火灾)导致的系统不可用问题。

第二个设计原则是水平扩展

很多时候,系统的不可用都是因为流量引起的:在高并发的情况下,系统往往会整体瘫痪,完全不可用。

在前面的故障点介绍中,你可以看到,在应用层、服务层、资源层,它们的处理压力都是随着流量的增加而增加。上一讲中,我也提到过,由于硬件在物理上存在瓶颈,通过硬件升级(垂直扩展)一般不可行,我们需要通过增加机器数量,水平扩展这些节点的处理能力。

对于无状态的计算节点,比如应用层和服务层来说,水平扩展相对容易,我们直接增加机器就可以了;而对于有状态的节点,比如数据库,我们可以通过水平分库做水平扩展,不过这个需要应用一起配合,做比较大的改造。

减少损失

第三个原则是柔性事务。

我们知道,系统的可用性经常会和数据的一致性相互矛盾。在 CAP 理论中,系统的可用性、一致性和网络容错性,三个最多只能保证两个,在分布式系统的情况下,我们只能在 C 和 A 中选一个。

在很多业务场景中,系统的可用性比数据的实时一致性更重要,所以在实践中,我们更多地使用 BASE 理论来指导系统设计。在这里,我们努力实现系统的基本可用和数据的最终一致。

我们平时对单个数据库事务的 ACID 特性非常熟悉,因为这里不存在 P,所以 C 和 A 都能得到很好地保证,这是一种刚性事务。但在复杂的分布式场景下,基于 BASE 理论,我们通常只能实现部分的 C(软状态和最终一致)和部分的 A(基本可用),这是一种柔性事务

柔性事务具体的实现方式有很多,比如说,通过异步消息在节点间同步数据。当然,不同的方式,对 C 和 A 的支持程度是不一样的,我们在设计系统时,要根据业务的特点来决定具体的方式。

第四个原则是系统可降级

当系统问题无法在短时间内解决时,我们就要考虑尽快止损,为故障支付尽可能小的代价。具体的解决手段主要有以下这几种。

  • 限流:让部分用户流量进入系统处理,其它流量直接抛弃。
  • 降级:系统抛弃部分不重要的功能,比如不发送短信通知,以此确保核心功能不受影响。
  • 熔断:我们不去调用出问题的服务,让系统绕开故障点,就像电路的保险丝一样,自己熔断,切断通路,避免系统资源大量被占用。比如,用户下单时,如果积分服务出现问题,我们就先不送积分,后续再补偿。
  • 功能禁用:针对具体的功能,我们设置好功能开关,让代码根据开关设置,灵活决定是否执行这部分逻辑。比如商品搜索,在系统繁忙时,我们可以选择不进行复杂的深度搜索。
做好监控

最后一个设计原则,是系统可监控。

在实践中,系统的故障防不胜防,问题的定位和解决也非常的困难,所以,要想全面保障系统的可用性,最重要的手段就是监控。

当我们在做功能开发的时候,经常会强调功能的可测试性,我们通过测试来验证这个功能是否符合预期,而系统可监控,就像业务功能可测试一样重要。通过监控,我们可以实时地了解系统的当前状态,这样很多时候,业务还没出问题,我们就可以提前干预,避免事故;而当系统出现问题时,我们也可以借助监控信息,快速地定位和解决问题。

好,为了帮助你更好地理解,我对这些架构原则做个小结。

  • 无单点和水平扩展是从正面的角度,直接保障系统的可用性。无单点设计针对的是节点本身的故障,水平扩展针对的是节点处理能力的不足。
  • 柔性事务和可降级是通过提供有损服务的方式来保证系统的可用性。柔性事务保证功能的基本可用和数据的最终一致,可降级通过损失非核心功能来保证核心功能的可用。
  • 最后,无论我们采取了多么强大的高可用措施,我们还是不能充分相信系统,还需要借助额外的监控来及时发现系统的问题并加以解决。监控是我们的第二条保命措施。
高可用手段

好了,通过前面的介绍,你应该已经了解了系统的故障点,以及高可用的设计原则。下面我们就一起来看下,在实践中都有哪些手段来保障系统的高可用。这里,我会按照系统的处理顺序来给你做详细介绍。

客户端 -> 接入层

客户端到服务端通常是远程访问,所以我们首先要解决网络的可用性问题。

针对网络的高可用,我们可以拉多条线路,比如在企业私有的 IDC 机房和公有云之间,同时拉移动和电信的线路,让其中一条线路作为备份,当主线路有问题时就切换到备份线路上。

在接入层,也有很多成熟的 HA 方案,比如说,你可以选择 Nginx、HAProxy、LVS 等负载均衡软件,它们都能很好地支持双节点 +Keepalived 部署。这样当一个节点出了问题,另一个节点就可以自动顶上去,而且两个节点对外是共享一个虚拟 IP,所以节点的切换对外部是透明的。

这里,我们通过冗余和自动切换避免了单点的故障。

接入层 ->Web 应用

Web 应用通常是无状态的,我们可以部署多个实例,很方便地通过水平扩展的方式,提升系统的处理能力;接入层的负载均衡设备,可以通过各种算法进行多个 Web 实例的路由,并且对它们进行健康检测,如果某个实例有问题,请求可以转发到另一个实例进行处理,从而实现故障的自动转移。

通常情况下,我们还可以在接入层做限流,比如,在 Nginx 中设置每秒多少个并发的限制,超过这个并发数,Nginx 就直接返回错误。

这里,我们同时支持了 Web 节点的水平扩展、自动故障转移以及系统的可降级(限流)。

Web 应用 -> 内部服务

服务通常也是无状态的,我们也可以通过部署多个实例进行水平扩展。

有多种方式可以支持服务实例的发现和负载均衡,比如说,我们可以使用传统的代理服务器方式,进行请求分发;另外,很多的微服务框架本身就支持服务的直接路由,比如在 Spring Cloud 中,我们就可以通过 Eureka 进行服务的自动注册和路由。

应用通常会访问多个服务,我们在这里可以做服务的隔离和熔断,避免服务之间相互影响。

比如在 Spring Cloud 的 Hystrix 组件(开源熔断框架)中,我们可以为不同服务配置不同的线程池,实现资源隔离,避免因为一个服务响应慢,而占用所有的线程资源;如果某个服务调用失败,我们可以对它进行熔断操作,避免无谓的超时等待,影响调用方的整体性能。

在应用和服务的内部,针对具体的功能,我们还可以做一些功能开关。开关实际上是一个标志变量,它的值可以是 on/off, 我们在代码中可以根据它的值,来确定某一段逻辑是否要执行。开关的值可以在数据库或配置系统里定义,这样我们就能够通过外部的开关值,控制应用内部的行为,这个在 eBay 有大量的落地。

这里,我们同时支持了服务节点的水平扩展、自动故障转移以及系统的可降级(熔断和业务开关)。

访问基础资源

常见的资源包括关系数据库、缓存和消息系统,我就以它们为例来介绍一下。

关系数据库属于有状态服务,它的水平扩展没有那么容易,但还是有很多手段能够保障数据库的可用性和处理能力。

首先,我们可以做数据库的主从部署,一方面通过读写分离,提升数据库读的性能,减轻主库压力;另一方面,数据库有成熟的 MHA 方案,支持主库故障时,能够自动实现主从切换,应用可以通过 VIP 访问数据库,因此这个切换过程对应用也是透明的。

另外,我们也可以通过物理的水平分库方式,对数据进行分片,这样就有多个主库支持写入。水平分库会涉及较多的应用改造,后面会有一篇文章专门介绍 1 号店的订单水平分库项目,到时我们再详细讨论。

再说下缓存。在数据读写比很高的情况下,我们可以利用缓存优化数据库的访问性能,包括进程内部缓存和分布式缓存,缓存是应对高并发的有效武器。

很多缓存方案,比如 Redis 本身就支持集群方式,它可以通过多节点支持处理能力的水平扩展,通过数据的多副本来支持故障转移。

最后说下消息系统。消息系统有很多成熟的 MQ 组件,比如说 Kafka,它可以通过多节点部署来支持处理能力的水平扩展,也能通过数据的多分区,实现故障的自动切换,保证系统的可用性。

最后我想说的是,明天和意外你永远不知道哪个先到来,即使有了这些高可用措施,还是会有各种各样的意外等待着我们。所以,系统的监控非常重要,只有准确地了解系统当前的状况,我们在面对问题时,才能快速响应,处理到点子上。

总结

今天,我和你介绍了保障系统高可用都有哪些策略和设计原则,相信你现在对高可用的整体处理思路有了清楚的认识。

另外,我还针对典型的系统处理过程,和你介绍了各个环节都有哪些具体的高可用手段,希望你可以在工作中,结合系统的实际情况去落地它们。

架构案例

高可用架构案例(一):如何实现O2O平台日订单500万?

项目背景介绍

先说下项目的背景。这是一个小程序点餐平台,用户在小程序上点餐并支付完成后,订单会先落到订单库,然后进一步推送到门店的收银系统;收银系统接单后,推送给后厨系统进行生产;同时返回小程序取餐码,用户可以凭取餐码去门店取餐或收取外卖。

这个项目服务于一家大型的餐饮公司,公司在全国有大量的门店,他们准备搞一个长期的大型线上促销活动,促销的力度很大:用户可以在小程序上先领取优惠券,然后凭券再支付 1 元,就可以购买价值数十元的套餐。

结合以往的经验,以及这次的促销力度,我们预计在高峰时,前端小程序请求将会达到每秒 10 万 QPS,并且预计首日的订单数量会超过 500 万。在这种高并发的情况下,我们为了保证用户的体验,系统整体的可用性要达到 99.99%。

你可以先了解一下这个点餐平台的具体架构:

这里呢,我具体说下系统主要的调用过程,以便于你更好地理解它:

  • 小程序前端通过 Nginx 网关,访问小程序服务端;
  • 小程序服务端会调用一系列的基础服务,完成相应的请求处理,包括门店服务、会员服务、商品服务、订单服务、支付服务等,每个服务都有自己独立的数据库和 Redis 缓存;
  • 订单服务接收到新订单后,先在本地数据库落地订单,然后通过 MQ 同步订单给 OMS 履单中心;
  • 门店的收银系统通过 HTTP 远程访问云端的 OMS 履单中心,拉取新订单,并返回取餐码给 OMS,OMS 再调用小程序订单服务同步取餐码;
  • 小程序前端刷新页面,访问服务端获得取餐码,然后用户可以根据取餐码到门店取餐或等待外卖。
高可用系统改造措施

我在前面也介绍了,这次活动的促销力度很大,高峰期流量将达到平时的数十倍,这就要求系统能够在高并发的场景下,保证高可用性。

所以,基于访问量、日订单量和可用性的指标,我们对原有系统进行了一系列改造,最终顺利地实现了首日 500 万订单,以及在大促期间,系统 4 个 9 的可用性目标。这个 500 万的订单量,也创造了中国单商户线上交易的历史记录。

在下面的系统架构图中,我标出了具体的改造点,主要有 10 处,接下来我就给你分别具体介绍一下,你可以通过这些具体的改造措施,来真正理解高可用系统的设计手段。

前端接入改造

这里的前端有两个,C 端的小程序和 B 端的门店收银系统。前端部分主要是对三个点进行改造,包括小程序端的 CDN 优化、Nginx 负载均衡,以及收银端的通信线路备份。

  • 小程序端的 CDN 优化

用户点餐前,需要先浏览商品和菜单,这个用户请求的频率很高,数据流量大,会对服务端造成很大的压力。所以,针对这一点,我们通过 CDN 供应商,在全国各地构建了多个 CDN 中心,储存静态的商品数据,特别是图片,这样小程序前端可以就近访问 CDN,流量无需通过小程序服务端,缓解了服务端的压力。

  • Nginx 负载均衡

这个小程序点餐平台,之前是直接利用云服务商提供的 LB,它只有简单的负载均衡能力。为了能应对这次的高并发流量,现在我们独立搭建了数十台的 Nginx 集群,集群除了负载均衡,还提供限流支持,如果 QPS 总数超过了 10 万,前端的访问请求将会被丢弃掉。

另外,Nginx 在这里还有一个好处,就是可以实时提供每个接口的访问频率和网络带宽占用情况,能够起到很好的接入层监控功能。

补充说明:一台 Nginx 一般可以支持数万的并发,本来这里无需这么多台 Nginx,这是因为云服务商对单个 LB 的接入有网络带宽的限制,所以我们要通过提升 Nginx 的数量,来保证接入有足够的带宽。

  • 收银端的通信线路备份

门店的收银系统会通过前置代理服务器,来访问云端的 OMS 系统,这个代理服务器部署在商户自己的 IDC 机房,原来只通过电信线路和云端机房打通。在这次改造中,我们增加了移动线路,这样当电信主线路出问题时,系统就可以快速地切换到移动线路。

应用和服务的水平扩展

首先,针对小程序服务端的部署,我们把实例数从十几台提升到了 100 台,水平扩展它的处理能力。在上面的架构图中,你可以看到,小程序服务端依赖了 7 个基础服务,每个基础服务也做了相应的水平扩展,由于应用和基础服务都是无状态的,因此我们很容易扩充。

这里的基础服务是 Java 开发的,原来是用虚拟机方式部署的,现在我们把基础服务全部迁移到了容器环境,这样在提升资源利用率的同时,也更好地支持了基础服务的弹性扩容。

订单水平分库

在大促情况下,下单高峰期,订单主库的写访问频率很高,一个订单会对应 6~7 次的写操作,包括了创建新订单和订单状态变更;订单的读操作,我们之前通过一主多从部署和读写分离,已经得到了支持。

但负责写入的主库只有一个实例,所以这次我们通过订单的水平分库,扩充了订单主库的实例数,改造后,我们有 4 个主库来负责订单数据写入。数据库的配置,也从原来的 8 核 16G 提升到了 16 核 32G,这样我们通过硬件的垂直扩展,进一步提升了数据库的处理能力。

这里的订单水平分库在实现上比较简单,我们是**通过订单 ID 取模进行分库,基于进程内的 Sharding-JDBC 技术,实现了数据库的自动路由。**后面的课程中,我会专门介绍电商平台的订单水平分库,它会更加复杂,到时你可以做个比较,如果有需要的话,也可以在实际项目参考落地。

异步化处理

你可以看到,在前台订单中心和后台 OMS 之间,我们需要同步订单数据,所以这两者是紧密耦合的。不过这里,我们通过消息系统对它们进行了解耦。 一方面,前台下单要求比较快,后台 OMS 的订单处理能力比较弱(OMS 库没有进行水平分库),通过消息的异步化处理,我们实现了对订单流量的削峰;另一方面,如果 OMS 有问题,以异步的方式进行数据同步,也不会影响前台用户下单。

还有在小程序服务端,在用户支付完成或者后台生成取餐码后,我们会以微信消息的方式通知用户,这个在代码中,也是通过异步方式实现的,如果微信消息发送不成功,用户还是可以在小程序上看到相关信息,不影响用户取餐。

主动通知,避免轮询

在原来的架构中,前台小程序是通过轮询服务端的方式,来获取取餐码;同样,商户的收银系统也是通过轮询 OMS 系统拉取新订单,这样的收银系统有上万个,每隔 10s 就会拉取一次。这种盲目轮询的方式,不但效率低,而且会对服务端造成很大的压力。

经过改造后,我们落地了消息推送中心,收银系统通过 Socket 方式,和推送中心保持长连接。当 OMS 系统接收到前台的新订单后,会发送消息到消息推送中心;然后,收银系统就可以实时地获取新订单的消息,再访问 OMS 系统拉取新订单。为了避免因消息推送中心出问题(比如消息中心挂掉了),导致收银系统拿不到新订单,收银系统还保持对 OMS 系统的轮询,但频率降低到了 1 分钟一次。

同理,小程序前端会通过 Web Socket 方式,和消息推送中心保持长连接。当 OMS 系统在接收到收银系统的取餐码后,会发送消息到消息推送中心。这样,小程序前端可以及时地获取取餐码信息。

缓存的使用

我们知道,缓存是提升性能十分有效的工具。这里的改造,就有两个地方使用了缓存。

  • 当收银系统向 OMS 拉取新订单时,OMS 不是到数据库里查询新订单,而是把新订单先保存在 Redis 队列里,OMS 通过直接查询 Redis,把新订单列表返回给收银系统。
  • 在商品服务中,菜单和商品数据也是放在了 Redis 中,每天凌晨,我们通过定时任务,模仿前端小程序,遍历访问每个商品数据,实现对缓存的预刷新,进一步保证缓存数据的一致性,也避免了缓存数据的同时失效,导致缓存雪崩。
一体化监控

在前面各个节点可用性优化的基础上,我们也在系统的监控方面做了很多强化。除了常规的 Zabbix 做系统监控、CAT 做应用监控、拉订单曲线做业务监控以外,我们还对系统实现了一体化的监控。

在这里,所有的节点都在一个页面里显示,包括 Web 应用、Redis、MQ 和数据库,页面也会体现节点之间的上下游关系。**我们通过采集节点的状态数据,实时监测每个节点的健康程度,并且用红黄绿三种颜色,表示每个节点的健康状况。**这样,我们就可以非常直观地识别出,当前的哪些节点有问题。

监控的效果如下图所示,在下一讲中,我就会为你具体地介绍这个监控系统。

在实践中,这套监控系统也确实发挥了巨大的作用。很多时候,在系统问题还没有变得严重之前,我们就能够识别出来,并能进行主动干预。

比如说,小程序服务端的部分节点有时候会假死,这在 Zabbix 监控里往往看不出来,但在我们的监控页面中,这些节点就会飘红,我们就可以通过重启节点来快速恢复。还有好几次,系统有大面积的节点出问题了,我们通过节点的上下游关系,很容易地定位出了真正出现问题的地方,避免所有人一窝蜂地扑上去排查问题。

除了这里我介绍的优化措施以外,我们也为系统可能出问题的地方做了各种预案。比如说,我们保留了部分虚拟机上部署的基础服务实例,这样如果容器出现了问题,基础服务可以快速切回到虚拟机上的实例。

系统改造小结

到这里为止,系统主要的优化措施就介绍完了,但我们是如何知道要配置多少个节点,有没有达到预定的效果呢?

对于这个问题,我们的做法是,按照 10 万 QPS 和 99.99% 的可用指标要求,通过大量的压测来确定的。

  • 首先,我们对每个节点进行接口压测,做各种性能优化,确定好需要的机器数量;
  • 然后,我们利用 JMeter,模拟小程序前端发起混合场景的调用,以此检验系统的抗压能力,以及在压力下,系统的可用性是否达到了预定的要求;
  • 最后,我们在生产环境中根据压测环境,按照服务器 1:1 的数量进行部署,保证性能不打折,最终这个小程序下单平台总的机器规模,也达到了数百台的量级。

从正面保障的角度来看,我们首先在各个环节都避免了单点,包括远程通信线路,这样能保证任意一个节点出了问题,都有其他实例可以顶上去;其次,我们通过节点的垂直扩展和水平扩展,大幅度提升了系统的处理能力,包括应用、服务和数据库的扩展;我们也有效地利用了 Redis 缓存,对高频的订单和菜单数据的读取进行了优化。

柔性处理方面,我们通过异步处理,来优化系统的性能和避免大流量的直接冲击,包括使用消息系统解耦前台下单系统和后台 OMS 系统,以及通过及时的消息推送,避免前端盲目轮询服务端。

同时,我们在系统接入层,通过 Nginx 进行限流,为系统的可用性进行兜底,这样在流量超过预估时,能够有效地避免后端系统被冲垮。

最后,我们通过强有力的监控手段,可以实时全面地了解系统运行状况,随时为异常情况做好准备。

高可用架构案例(二):如何第一时间知道系统哪里有问题?

要想保证系统的高可用,我们还需要对系统进行全面有效的监控。

监控是系统的眼睛,无监控,不运维。今天我们就从监控的角度来聊聊如何保证系统的高可用。

在开发软件时,我们经常强调一个业务功能的可测性,甚至有一种说法是测试驱动开发。在开发之前,我们会先设计测试用例,再去考虑如何实现功能。同样,当我们对系统作了很多加固,也是希望能保证它的稳定可用。

**但我们怎么判断系统的各个节点当前是否正常呢?**这个就对应了节点的可监控性,如果你事先想好了系统应该如何监控,如何判断每个节点是否正常,那你就会更清楚应该采取什么样的措施。很多时候,我们可以从监控的角度来倒推系统的可用性设计。

监控的分类

在第 11 讲中,我和你介绍了系统的组成,它包括接入层、应用系统、中间件、基础设施这几个部分,那我们的监控也是针对这些部分来实施的。一般来说,监控可以分为 5 个层次,如下图所示:

从上到下,分别为用户体验监控、业务监控、应用监控、中间件监控、基础平台监控。

  • 用户体验监控:指的是从前端用户的访问速度出发,来监测系统的可用性,包括页面能否打开、关键接口的响应时间等等,用户体验监控一般结合前端的埋点来实现。
  • 业务监控:它是从业务结果的角度来看,比如说订单数、交易金额等等,业务监控也是最直观的,我们知道,如果业务数据没问题,系统整体也就没有问题。对于业务监控,我们一般是从数据库里定时拉取业务数据,然后以曲线的方式展示业务指标随着时间的变化过程。除了当前的曲线,一般还有同比和环比曲线。同比是和前一天的数据进行比较,环比是和一周前的数据进行比较,两方面结合起来,我们就能知道当前的业务指标有没有问题。
  • 应用监控:指的是对自己开发的代码进行监控,比如接口在一段时间内的调用次数、响应时间、出错次数等等。更深入一点的应用监控还包含了调用链监控,我们知道,一个外部请求的处理过程包含了很多环节,比如说网关、应用、服务、缓存和数据库,我们可以通过调用链监控把这些环节串起来,当系统有问题时,我们可以一步步地排查。有很多 APM 工具可以实现调用链监控,如 CAT、SkyWalking 等等。
  • 中间件监控:指的是对标准中间件进行监控,它是第三方开发的代码,比如数据库、缓存、Tomcat 等等,这些组件对应的是系统的 PaaS 层。这些中间件往往带有配套的监控系统,比如,RabbitMQ 就有自带的监控后台。
  • 基础平台监控:指的是对系统底层资源进行监控,如操作系统、硬件设备等等,这个层次的监控对应的是系统的 IaaS 层。Zabbix 就是典型的基础设施监控工具,它可以监控 CPU、内存和磁盘的使用情况。
监控的痛点

我们知道,一个大型的互联网平台,背后对应的是大规模的分布式系统,有大量的软硬件节点一起协作,这里的任何节点都有可能出问题,所以我们需要通过监控,及时发现和解决问题,提升系统的可用性。

但想要实现高效的监控,这不是一件容易的事情。下面,我给你举一个线上事故处理的例子,你就能理解监控面临的挑战。

首先,Monitor 发现订单曲线突然跌停,当前的订单数量变为 0,于是,Monitor 快速拉起电话会议,或者在微信群里 @所有人进行排查。这时候,一大堆相关的或不相关的人,都开始排查自己负责的那部分系统,比如说,运维在 Zabbix 里检查网络和机器,开发在 ELK 系统**(Elasticsearch+Logstash+Kibana)**里检查错误日志,DBA 检查数据库。

过了一会儿,负责 App 服务端的开发人员,在 ELK 里发现有大量的调用下单服务超时,于是他去询问下单服务的开发人员这是怎么回事。下单服务的开发人员就去检索错误日志,结果发现调用会员服务有大量的超时情况,然后他就去问会员服务的开发人员这是怎么回事。会员服务的开发人员通过错误日志,发现会员数据库连接不上,于是他把问题反映给 DBA。DBA 先拉上负责网络的同事一起看,发现网络没啥问题,然后他再去检查会员数据库本身,这时,他发现有慢查询把 DB 给挂住了。

这样,通过一系列的接力式排查,问题终于找到了,最后 DBA 把慢查询杀掉,所有人都去检查自己的系统,发现没有新的错误情况,系统恢复了正常。而这个时候,距离问题的发生已经过去了很长时间,在这个期间,技术被老板催,老板被商户催,而商户也已经被用户投诉了 N 次。

以上的事故处理过程还算比较顺利的,毕竟我们通过顺藤摸瓜,最后找到并解决了问题。更多的时候,我们面对事故,就像是热锅上的蚂蚁,众说纷纭,谁也不能肯定问题出在哪里。结果呢,我们病急乱投医,胡乱干预系统,不但没能解决问题,而且往往引发了二次事故。

你可以发现,在这个例子中,虽然我们有应用日志监控,有 Zabbix 系统监控,有网络和数据库监控,但对于一个大规模的分布式系统来说,这种分散的监控方式在实践中有一系列的弊端。

  • 首先,不同的节点,它的监控的方式是不一样的,相应地,监控的结果也在不同的系统里输出。
  • 同时,系统不同部分的监控都是由不同的人负责的,比如说,运维负责的是基础平台监控,开发负责的是应用系统监控。而监控信息往往专门的人才能解读,比如应用监控,它需要对应的开发人员才能判断当前的接口访问是否有问题。
  • 最后,系统作为一个整体,需要上下游各个环节的人一起协作,进行大量的沟通,才能最终找到问题。

你可以看到,这种监控方式是碎片化的,对于处理线上紧急事故,它无疑是低效的,这里有很多问题。

发现问题慢:业务监控的曲线一般 1 分钟更新一次,有时候因为正常的业务抖动,Monitor 还需要把这种情况排除掉。因此,他会倾向于多观察几分钟,这样就导致问题的确认有很大的滞后性。

定位问题慢:系统节点多,大量的人需要介入排查,而且由于节点依赖复杂,需要反复沟通才能把信息串起来,因此很多时候,这种排查方式是串行或者说无序的。一方面,无关的人会卷入进来,造成人员的浪费;另一方面排查效率低,定位问题的时间长。

解决问题慢:当定位到问题,对系统进行调整后,验证问题是否已经得到解决,也不是一件很直观的事情,需要各个研发到相应的监控系统里去进行观察,通过滞后的业务曲线观察业务是否恢复。

那么,我们怎么解决监控面临的这些困境,以高效的方式解决线上事故,保障系统的高可用呢?

解决思路

你可以看到,前面这种监控方式,它是碎片化和人工化的,它由不同的工具负责监控系统的不同部分,并且需要大量专业的人介入,并通过反复的沟通,才能把相关的信息拼接起来,最后定位到问题。

那我们能不能把系统所有的监控信息自动关联起来,并且以一种直观的方式展示,让所有人一看就明白是哪里出了问题,以及出问题的原因是什么呢?

从这个思路出发,对系统的监控,我们需要做到两点:

  • 系统能够自动地判断每个节点是否正常,并直观地给出结果,不需要经过专业人员的分析。
  • 系统能够自动把各个节点的监控信息有机地串起来,从整体的角度对系统进行监控,不需要很多人反复地进行沟通。

这里,我们可以借鉴一下道路交通监控的例子。

我们经常可以在市内的高架上看到交通拥堵示意图。在下面的这张交通信息图上,你可以看到,每条道路都通过上下左右不同的方位,有机地关联在一起,形成一个整体的交通网络;同时,在交通图上,通过红黄绿三种状态,实时地反映了每条道路的拥堵情况。这样,司机就可以非常直观地了解道路是否畅通,从而提前避开拥堵路段。

这里有几个关键词:实时、直观、整体。下面,我们就来对照下软件系统的监控,来看看要想实现类似的监控效果,我们应该怎么做。

首先要实时,我们需要第一时间知道系统当前是否有问题。

然后要直观,节点是否有问题,我们需要很直观地就能判断出来,就像交通图上的红黄绿颜色标识一样。我们知道,在发生紧急事故时,人脑很可能会处于错乱状态,这个时候,我们一定不能指望专业的头脑或者严密的分析来判断问题,这样不但慢,而且很容易出错。所以,系统哪些部分有问题,问题是否严重,以及出问题的大致原因是什么,这些信息,监控系统都必须能够直观地给出来。

最后是整体,我们需要针对系统做整体监控,就像交通图一样,它是针对周边整体的道路情况进行展示,我们也需要把系统的各个节点放在一起,清晰地给出节点依赖关系。系统真正出问题的地方往往只有一个,其他地方都是连带的,如果监控系统能够给出节点的上下游依赖关系,对于定位真正的问题源是非常有用的。

所以,对照道路交通监控的思路,我们可以采取这样的监控方式:

  • 首先,系统中的每个节点对应交通图的一条道路;

  • 然后,节点的健康状况对应道路的拥堵情况,节点同样也有红黄绿三种不同的颜色,来展示该节点是否正常;

  • 最后,节点之间的调用关系对应道路的方位关系。

这样我们就能构建一个实时的、直观的、一体化的监控系统,类似交通图一样,可以一眼就看出系统的问题所在。

好,回到刚才事故处理的例子,如果我们的监控系统按照这种方式来设计,它的监控效果会是什么样的呢?

首先所有的节点,包括服务端应用、下单服务、会员服务还有其他服务,以及它们各自用到的缓存、消息队列和数据库,这些节点的健康状态我们在一个页面里就可以看到,包括它们的依赖关系。

如果会员数据库出了问题,我们根据依赖关系倒推,会员数据库 -> 会员服务 -> 下单服务 -> 服务端应用这 4 个节点都会爆红,而其他节点不受影响,保持绿色。服务端应用和下单服务节点会有错误消息提示接口调用超时,而会员服务和会员数据库节点的错误消息提示的是数据库连接超时。

这样其他绿色的节点,我们就不用排查了,然后我们观察爆红的节点,通过上下游依赖关系,就知道最终的问题很可能出在会员数据库上,DBA 重点检查会员数据库就可以了。当数据库问题解决以后,我们可以看到所有爆红的节点马上变绿,立即就能确认系统恢复了正常。

架构方案和效果

根据前面的思路,我们设计了监控系统的整体架构,如下图所示:

每个被监控的节点,均有对应的 Agent 负责采集健康数据,不同的节点类型,数据采集的方式也不一样:Web 节点通过 HTTP 接口调用,Redis 通过 Jredis,MQ 也通过对应的 API 接口,DB 则采用 JDBC。

Agent 每隔 3s 采集节点数据,然后上报数据给 Monitor Service;Monitor Service 负责确定节点当前的状态并保存到数据库,这样就完成了节点健康状态的检测;最后,前端 Dashboard 每隔 3s,拉取所有节点的状态,以红黄绿三种颜色在同一页面展示,同时还会显示具体的出错信息。

那我们是根据什么规则来判断节点的健康状态呢?

这里,我以 DB 为例简单说明一下。Agent 每隔 3 秒会去尝试连接数据库,并进行简单的表读写操作,如果连接和读写都能够成功,那就说明该 DB 当前的运行是正常的,相应的,在 Dashboard 里面,这个 DB 节点会显示为绿色。

Redis 和 MQ 类似,我们主要也是检测组件的可用性;Web 应用的健康规则会相对复杂一些,我们会结合 Web 应用接口的功能和性能来做综合判断。这个监控系统的设计,我还会在下一讲里具体介绍,你到时候可以深入理解其中的细节。

我们最后来看下监控的效果。

下图是某个业务系统的实际监控效果图,左边是系统的部署架构,最上面是两个 Web 应用,这两个应用分别有自己的 Web 服务器、MQ 和 Redis 节点。

提示:这里,我对细节做了模糊化处理,不过没关系,我主要的目的是让你能了解监控的效果,尽管图片模糊,但它不会影响你理解内容。

以左上角的应用为例,它的 Web 应用部署在 Docker 里面,所以这里只显示一个节点(虚拟机部署可以看到每个实例的 IP,但 Docker 容器无法看到,对外表现为一个地址);对于 Redis,我们是购买公有云的服务,所以也是一个实例;但 MQ 是集群的方式,它有三个实例。

然后,这两个 Web 应用同时依赖后端的 3 个基础服务,这 3 个服务是并列的关系,每个服务又分别有自己的应用、MQ 和 Redis。所以,你可以看到,在这个监控页面里,节点的部署情况和依赖关系都是一目了然的。

在这个例子中,有一个节点显示为黄色,黄色说明它有问题,但并不严重。你可以在右边的异常消息列表里看到具体的原因(在最近 3s 内,这个 Web 应用的接口响应时间超过了正常值的 5 倍),每条异常消息包括了出错的节点、具体出错的接口、该接口的正常响应时间,以及当前的响应时间。这样,你就可以很方便地把左边的出错节点和右边的异常消息对应起来,知道哪些节点有错误,还有出错的原因是什么。

另外,如果你在左边的图里点击某个节点,会弹出新页面,显示该节点的历史出错信息,并且新页面里有链接可以直接跳到 Zabbix、CAT 和 ELK 系统,这样你可以在这些专门的系统里做进一步的排查。

所以说,这里的监控系统提供的是整体的监控信息,可以帮助你快速定位问题的根源,在很多情况下,我们通过这里给出的错误信息,就可以知道出错的原因。当然,如果碰到特别复杂的情况,你还是可以在这里快速关联到各个专业的监控系统去收集更深入的信息。