软件系统的可扩展

分享一篇我在公司做的presentation

一、What(什么是可扩展)

对于可扩展性,英文可以找到两个词汇来对应: Extensibility, Scalability,这两个词其实面对着完全不同的定义

  • Scalability

    • Scalability 是指问题规模和处理器数目之间的函数关系(Wiki),通俗的将,就是用户规模的增长可以通过增加计算资源的方式来解决系统性能问题。

    • 优秀的系统部署架构,可以保持 计算资源 与 用户规模 的线性甚至对数增长的关系,而相对较差的系统则只能通过成倍的增加计算资源来支撑支撑用户规模的增长,甚至无法通过增加计算资源来解决性能问题。

  • Extensibility

    • Extensibility 则是相对更加宏观的角度来衡量软件开发的复杂度。设计良好的代码允许更多的功能在必要时可以被插入到适当的位置中。这样做的目的的是为了应对未来可能需要进行的修改,而造成代码被过度工程化地开发。

    • Extensibility 是对软件系统复杂度的衡量。

本章可能更多的是去探讨 Extensibility 这个概念,也就是说,在软件系统演进的过程中,如何去管理系统复杂度。

并且 Extensibility 广义上来说,其实是包含着 Scalability.

二、Why(为什么需要可扩展)

我相信大家在职业生涯中,都遇到过一些老大难的系统,这些系统往往伴随着 “改不动”,“不敢改,改了怕影响另外的场景” 这样的印象。特别是针对B端系统,日趋复杂的业务逻辑让人头疼。

而对于业务架构的好坏,不像一个技术架构一样,有明确的衡量好坏的标准。比如评价一个数据库的技术架构好不好,就可以在限定的资源下去做benchmark进行评判,但是业务架构没有办法去做benchmark。

所以我们在讨论 “可扩展” 这个话题的时候,会提出很多抽象的principle,但是很难去证明这个principle是可以真正有效的指导“可扩展”,而共享是一个基于共识的原则。

既然没有办法找到一个直接指标去衡量 “可扩展” 的程度,或许可以去从侧面找到一个。

在软件系统日趋复杂、市场竞争日趋激烈的过程中,“需求交付时间” 往往是一个重要的衡量指标。

post image

在PM温柔而恒定节奏的提需求(假设每次提的需求一样大,复杂程度都差不多),那么,随着系统沉淀的代码越来越多,是否会影响到我们的交付时长?

如果平均交付时长的指标随着系统越来越大,变得越来越长,那可能我们的“扩展性”就出了一些问题。

对于一个优秀的好的扩展性系统,交付时长能坚挺的保持着线性的变化,即便代码已经非常多,但是模块与模块之间相互独立,每次只需要修改一小部分代码就能放心的将需求上线,那这个系统,可能就是“好的”。

那么回到我们的问题:为什么我么需要扩展性?

因为可以直接影响我们对需求的交付速度,研发层面能够快速交付想要的功能,并且不会因为体量越大而使得人力成本更高。

三、How(如何去做)

讲到这里,我认为,对于追求“扩展性”,其实我们是在追求 在每次需求迭代的时候,我们的系统能够很从容、相对低成本的去演进。

3.1 问题域

一个系统的设计,如果没有很好地去考虑扩展性和其中每个组件的演进和扩展,随着系统的臃肿,我们很可能成为 "code rot" 和 "design rot" 的受害者。

一个设计或者系统 到底出现了哪些迹象,就代表我们正在逐渐走向不好的方向呢?

Robert C. Martin 提出了四个迹象

  1. Rigidity (僵化)

    1. 很难对系统进行修改,因为一处修改意味着处处修改

  2. Fragility (脆弱)

    1. 对系统的修改,可能会导致和改动无关的地方出现问题

  3. Immobility (牢固)

    1. 很难实现复用,系统内部相互勾结,一个模块想要分离出来异常困难

  4. Viscosity (粘滞)

    1. 一个方法有多种实现方式,但是想要保持设计原则去做事情比破坏设计原则去做事情要难很多

    2. 做正确的事情 比 做错误的事情 难很多

既然明确 code rot,我们后面要做的事情,就是有哪些好的方法可以去避免这些 code rot 或者 design rot? 

在《软件设计哲学》当中提到,需要战略性编程而不是战术性编程,本质上是一样的,其实在做设计的时候,需要常把“扩展性”挂在嘴边,带着高扩展的目的去设计。

我们已经明确了问题域,知道了需要避免什么,那么我们做设计的时候,我们需要追求什么?到底什么高扩展的系统有哪些特质?

业界有非常多的指导原则,设计思想,但是在搬出它们之前,我们需要明确我们到底在追求什么。

这里总结了三个点:

  • Modifiability 可修改

    • 程序架构和程序编码方式 决定着 当我们要做出一个修改时,需要改动的代码量 和 需要改动的元素数量 的多少。需要改动的越少,可修改性就越强

    • 需要 高内聚、低耦合

  • maintainability 可维护

    • 在新增功能的时候,是否会容易引入 系统问题,标志着可维护性的高低。

    • 同样需要 高内聚、低耦合

  • scalability 可伸缩

    • 在某个维度下,可以在不改变其架构的基础上,去做扩展

3.2 如何做?

在明确了我们追求什么之后,接下来就是如何去做。

3.2.1 设计原则

谈设计是离不开设计原则

业界流传着很多的设计原则,设计原则是基于经验的高度抽象总结,其实很难言传身教,只有带着原则去实践,强化理解。(子目录放一些强化理解的case)

从各种设计原则当中,抽出来几个我觉得对“扩展性”有很大帮助的指导原则,展开来讲讲。

需要强调:设计原则从来不是单一行动的,往往是相辅相成,多管齐下的。

3.2.1.1 里式替换原则(Liskov Substitution Principle, LSP)

  • A program that uses an interface must not be confused by an implementation of that interface.

  • 所有基类用到的地方,都可以替换成子类而不受影响

里式替换是OOP的一个基石,是对抽象和封装的一个描述。

经常会犯的一个误区,是认为这个原则是在指 父子继承,其实不然,这个原则是在讲子类(或者接口与实现)的设计,这个原则是关于需要去保持抽象的简洁明确的定义

举个例子:

我们所有的父类(或者接口定义),可以无条件的替换为子类(或者接口实现),就像我们可以使用 Map 接口,可以直接使用 HashMap, ConcurrentHashMap, TreeMap,都是可以完成 key-Value 的功能,并且在大多数场景下是可以无条件替换的。

当然我们在写代码的时候,其实是不提倡继承关系,因为继承关系是 父类对子类的一个侵入

而里式替换,也可以同样用于描述 接口与实现 的关系。

如果遵守里式替换原则,那相当于对 抽象继承、接口实现 的一层束缚,保证 子类、实现类 的功能,是和父类、接口类 的定义保持一致,能够很好的增加封装性,从而提高系统总体的可修改和可维护性。

3.2.1.2 开闭原则(Open-Closed Principle, OCP)

  • A Module should be open for extension but closed for modification.

  • 实体,应该对扩展开放,对修改关闭

修改容易带来问题,而新增往往不会。

保持开闭原则,新增功能而尽量不去动已有的代码,那我们在扩展一个模块的时候,可以不用去担心影响到已有的功能。那总体的可维护性就能有很大的提升。

当然,一直新增代码肯定是会让系统越来越大,所以开闭原则只是其中一个原则,还需要配合其他的原则一起,在扩展系统的情况下,也可以 保持系统不臃肿。

3.2.1.3 模块化原则(Modularity Principle)

  • Systems should be built from cohesive, loosely coupled components (modules).

  • 系统应该以更加内聚、耦合性低的模块来组成

这个原则有一些抽象和大,听起来就是我们一直在追求的 “高内聚、低耦合”。

  • 高内聚

    • 我理解为深模块,接口简单,功能复杂。对外暴露最低限度的接口,而内部的实现可以屏蔽复杂度。

    • 最好的例子就是 unix 的 各种pipline 接口,使用都非常简单,但是内部逻辑非常复杂。

  • 低耦合

    • 模块与模块之间保持最低限度的耦合(哪怕是一个枚举类的引用都要严肃对待)

模块化的使用不仅带来了可扩展性,还带来了可维护性和可修改性。如果系统的设计允许模块的交换,那么修改和维护就会变得更加容易。

post image

3.2.1.4 DRY原则(Don't Repeat Yourself)

  • Every piece of knowledge must have a single, unambiguous, authoritative representation within a system

  • 保持源头唯一,single source of truth

造成系统复杂度攀升的一个因素,就是**修改扩散,**每当你要改一个地方的时候,发现还有很多地方要跟着一起改,那就非常容易出错。

一个典型的违反此原则的反例:假设一个枚举类比较通用(比如 BusinessLine ),每个系统都会用到这个枚举类的定义,而每个系统都维护一个自己的枚举类。那当某一天,我们要扩展一个业务线的时候,那几十上百个系统都要跟着一起改,成本非常高,容易出问题。就是扩展性不好的一个例子。

尽量的保持 single source of truth 是提升扩展性的一个重要原则。

3.2.1.5 依赖倒置原则(Dependency Inversion Principle, DIP)

  • Depend in the direction of abstraction. High level modules should not depend upon low level details.

  • 高层模块不应该依赖底层模块,并且不应该依赖底层细节

高层往往是指业务逻辑层,底层则是提供基础能力的模块(比如数据库模块)

依赖应该是反转过来的,高层业务逻辑定义好我们需要什么样的底层能力,再由底层模块去实现这些功能。

这样做的好处是,底层能力是可以轻易替换的,只要保证对外实现的接口表现一致就可以了。

保持依赖倒置的原则,可以很好的提升封装性,从而提升扩展性。

3.2.1.6 接口隔离原则(Interface Segregation Principle, ISP)

  • Keep interfaces small so that users don’t end up depending on things they don’t need.

  • 不暴露过多的信息给依赖自己的用户

这个原则往往是和 单一职责 一起聊的。保持接口独立。

我对这个原则的理解是:接口独立,逻辑复用。

比如我有一个页面,那给这个页面提供的接口就是专用的,而复用的点则是在我们自己程序的内部。

因为“跨团队”的成本是相对高的,内部消化则是更高效的。

3.2.1.7 迪米特法则(Law of Demeter)

  • talk only to your immediate friends

  • 最少知识原则

对另一个系统的概念的依赖,应该保持最低限度的了解。

比如一个系统依赖一个 订单类Order,我只依赖其中的 status 字段,那我尽可能的 把Order 封装为 OrderBO,其中只有两个字段,id 和 status,订单上大把的多余字段,就不应该出现在我自己的系统内部。因为这些东西是对封装性的破坏。

遵守了迪米特法则,设计将与数据流紧密相连,从而更容易确定系统中哪些部分应该负责哪些逻辑。这也使得在实施过程中更容易发现设计中的缺陷。能做到设计有问题,提前暴露,而不是越积越深、病急乱投医。

3.2.2 设计习惯

3.2.2.1 分层与抽象

软件系统由不同的层次组成,层次之间通过接口来交互。在严格分层的系统里,内部的层只对相邻的层次可见,这样就可以将一个复杂问题分解成增量步骤序列。由于每一层最多影响两层,也给维护带来了很大的便利。

分层系统最有名的实例是TCP/IP网络模型。

在分层系统里,每一层应该具有不同的抽象。TCP/IP模型中,应用层的抽象是用户接口和交互;传输层的抽象是端口和应用之间的数据传输;网络层的抽象是基于IP的寻址和数据传输;链路层的抽象是适配和虚拟硬件设备。如果不同的层具有相同的抽象,可能存在层次边界不清晰的问题。

3.2.2.2 复杂性下沉

不应该让用户直面系统的复杂性,即便有额外的工作量,开发人员也应当尽量让用户使用更简单。如果一定要在某个层次处理复杂性,这个层次越低越好。

复杂性下沉,并不是说把所有功能下移到一个层次,过犹不及。如果复杂性跟下层的功能相关,或者下移后,能大大下降其他层次或整体的复杂性,则下移。

3.2.2.3 习惯战略编程,拒绝战术编程

战术编程致力于完成任务,新增加特性或者修改Bug时,能解决问题就好。这种方式,会逐渐增加系统的复杂性。如果系统复杂到难以维护时,再去重构会花费大量的时间,很可能会影响新功能的迭代。

战略编程,是指重视设计并愿意投入时间,短时间内可能会降低工作效率,但是长期看,会增加系统的可维护性和迭代效率。

post image

3.3 讲点实际的

上面提到的这些方法和原则实际能帮到我们吗?确实有一定的指导意义。但是它们还是太笼统了,不够具体。不够具体造成的问题

  • 不同的人理解差距太大,不能达成共识

  • 不能真正的实施,不能通过工具以数据的方式体现

  • 极难进行知识的传达

这就是理论与实践之间的距离。

下面讲一点我自己的思考,到底什么样的设计才是“可扩展、易扩展” 的呢。

先看几个现实生活中的例子:

post image

我希望理想的业务系统可以像乐高一样,每一类乐高组件是一个子系统(业务能力),可以利用不同类的组件基础能力组合成复杂的业务能力(系统)。

网络七层模型

post image

这两个例子,我觉得是现实生活 和 互联网工程 当中,很好地体现“扩展性” 的两个例子。

我觉得有一个词可以去形容他们共同的特点:Autonomy(模块自治)

3.3.1 Autonomy(模块自治)

依赖是系统整体复杂度的一个重要来源,而自治,就是 自己干自己的,互相不影响。

无论是微服务拆分、代码分层、模块化,想要达成的目标,就是去提升自治的程度。自治了之后,功能内聚,耦合松散,不会因为别人的需求而改动自己的代码,也不会因为别人依赖你的服务而导致你改不动、不敢改。

比如在OSI的七层模型当中,层与层之间其实是互相不知道的,网络层的IP协议是不知道也不关心传输层到底是采用TCP协议还是UDP协议,网络层只管将一个节点在整个网络中的地址告诉上一层即可。假设我们认为现有的传输层协议不好用,那直接基于网络层和会话层实现可用于替换的协议即可,上下两层都是可以不感知或者最低限度感知传输层的变化。

而实现自治的基础是:层与层、模块与模块 之间的接口是否稳定。

接口是一个广义的概念,指模块与模块之间达成的一个协议,只要协议是稳定的,基于协议的语义,内部的实现逻辑可以轻松的变化。

模块也是一个广义的概念,往大了说,可以是业务团队与业务团队之间的划分,往小了说,可以是自己一个代码仓库内部类与类之间,而接口协议,则是两个模块之间的对接窗口。

为了实现不同粒度的模块的内部自治,需要时刻警醒,你设计的接口是否满足设计原则,是否是足够自治的。

3.3.2

Complexity has to livesomewhere. If you are lucky, it lives in well-defined places.

复杂度是无法消除的,只能转移到合适的位置

摘自 https://zhuanlan.zhihu.com/p/138145081

业务逻辑的复杂度,是天然存在的,无法消除的。不可能因为某种牛逼的设计原则,就让一个复杂的订单流转逻辑变得简单。我们能做的,只是把复杂度放在合适的位置,来达到总体成本的最低。

那能让成本降低的点在哪里呢?

我们为什么要强调自治,因为我们希望自己内部就解决问题,而不是两拨人解决同一个问题。

模块与模块之间(团队与团队之间)是存在天然的沟通和协调的成本,而消除这些成本最好的方式,就是不必要的沟通和协调。

一旦保证了尽可能的模块自治,那我在开发一个功能的时候

  1. 不需要耗费大量的时间去和另一个模块沟通需求的细节

  2. 一个模块提供的接口想要变更(无论是重构、演进还是干脆下线),都不需要牵一发而动全身

3.3.3

对一个系统的建立,一般会有三个不同层次的设计过程

  • 领域建模

  • 微服务设计

  • 编码落地

上面所聊到的一系列原则和思想,其实在不同层次都是通用的,只是视角与关注点不同。

post image

在领域建模,我们关注概念、关注统一语言、关注业务模型和边界

在服务领域,我们关注逻辑架构、开发架构、部署架构

实现层面,我们关注代码结构、接口、设计模式和演进过程中的复杂度管理

本质上其实并无太大区别,只是面对的问题不同,确有相对一致的解决思路和方法。

因为篇幅原因,先聚焦在服务层 和 领域层,实现层可以本文档下摘抄的一个写代码的例子

3.3.4

简化一下问题,重点讨论

  • Logical view: 逻辑架构,业务模块的设计与划分

  • Process view:物理运行时部署架构,运行时进程组的设计与划分

逻辑架构 可以 通过部署行为 映射到 部署架构上

接下来,跟着四个不同例子,一起看下如何追求逻辑架构的 Autonomy(模块自治)

3.3.5 一些例子

3.3.5.1 Feed流的例子

post image

3.3.5.2 订单系统的例子

一个电商的订单系统的要素非常多而复杂。

post image

某电商的结算页,当用户点击“提交订单”的时候,订单相关的各类信息被保存下来(收货信息、物流信息、支付信息、商品信息、发票信息、优惠券等)然后开始进入履约的环节。

这样一个页面,比较直观的做法是,将各类信息都存储在订单系统当中,然后各方履约的时候从订单系统里获取(getOrderByOrderId),这样的设计最容易被理解。如果部分履约系统发生了发生了业务变化是怎样的?例如买了免费商品不需要付费了怎么办?新增了某种支付方式或者开发票的信息怎么办?都需要通知订单系统进行修改,进行接口的变动,跨团队的沟通。是不够自治,十分影响开发效率的。

post image

那如何让每个系统保持自治?

可以换一个思路来看,极端一些,假设订单系统就只是一个单一的发号器,各个履约方存储自己关心的信息,并且与履约方关联,这样各个履约方如果发生业务迭代的时候只需要改动自己的系统和存储就可以,与其他系统业务相对隔离,保持足够的自治。

post image

那么在这个简单的案例当中,orderId 就充当着 模块之间的接口(协议) ,这个协议是一旦定下来了之后,就不会发生变更的,是稳定的。

3.3.5.3

还是上面那张京东的图

订单价格理论上是由多方信息汇总计算出来的。比如商品的原始价格、优惠券、支付方式、配送距离等

那报价系统需要将所有信息汇聚、存储在一起,并把相关计算逻辑聚集在一个模块里面,专门负责算价格

这种情况下,报价系统可能就会成为非常繁忙的中心点,需要关注报价的计算逻辑、计算顺序、综合判断情况,而任意一个业务发生了变化,都可能需要去改报价系统的逻辑

有两种处理方式

  1. 类似于 **责任链 **将价格在不同的系统当中流转,流转完毕得到最终价格,报价系统依次去调用即可

  2. 需要综合判断,比如必须有会员才能使用本优惠券,并且通过特定的支付方式 这种跨多个领域的计算规则存在

业务的复杂情况是无法避免的,这里的建议是,报价的接口由报价系统去定义,其他系统去实现。因为报价系统自己定义的接口是相对稳定的,不同的业务方即便发生迭代,只需要去保证实现报价系统定义的接口兼容性即可,这也是依赖倒置在业务架构当中的重要体现。

3.3.5.4 业务数仓的例子

在很多团队,业务数仓和与业务数仓合作的研发团队,总是会遇到以下问题:

  • 业务MySQL Schema的变化,导致数仓同学很多数据加工逻辑需要大改,甚至没有通知到数仓的同学,知道导致报表数据的故障

  • 业务研发把MySQL换成其他存储比如Redis,但是数仓不同意,因为无法采集binlog进行数据提取

  • 数仓的数据错了,debug成本非常高

造成这个问题的原因是什么呢?

我觉得是 MySQL的schema在业务研发和业务数仓之间,充当着 “接口” 的角色,而这个接口却不稳定,需要经常改。

做业务的不会去为数仓的数据负责,而做数据的也不想关注你业务的各类变化。

接口不稳定,就无法实现自治。

如果业务团队像提供 “RPC” 一样提供稳定接口给数仓,并且由业务研发为接口的稳定负责,保持领域业务的处理逻辑收敛在业务方。

这也是最近比较火的 Date Mesh 的思路

小结

想要去追求自治,其实是在追求模块与模块之间的相对稳定。

上面的四个例子,模块与模块之间的关系依次是

  1. 无任何共享,模块无依赖(可以从编译角度保证,不引入依赖,满足迪米特法则)

  2. 只共享不变数据(订单发号器,一次发放用不变更)

  3. 共享可变数据,但是追求接口 少而不易变

  4. 接口也易变,但是通过合理的交互,追求 变化次数的减少

总结

业务系统的可扩展本质上还是对演进系统过程中的复杂度管理。一旦复杂了,就扩展不动了。

而繁多的设计原则、哲学、模式、法则 都是高度抽象的经验之谈,有指导意义,但是不易传播。

在使用篇通过去追求“自治”,来找到一些相对可以量化的方式。

再回过头来看一开始的例子

post image

对于乐高来说,会不会拆的太细了呢?和去拆成一个个只有两行代码的超简单函数的核心区别是什么呢?

接口稳定不易变

业务的复杂度可以通过业务思维来简化,去掉业务上不必要的分支。这也是唯一能简化复杂度的方式。

对于无法精简的逻辑,就需要通过合理的规划,将复杂度转移到能更好地把控它的位置上。