程序员架构修炼之道:如何设计“易理解”的系统架构?

2022-09-19 16:02:58 浏览数 (1)

前言

尽管“可靠性”有时被视为“可用性”的同义词,但这一属性实际上意味着系统的所有关键设计的保证:可用性、持久性和安全不变量等。

我们构建易于理解的系统的主要指导思想是,使用清晰的、有约束的组件来构造系统。其中一些组件可能构成其可信计算的基础,因此可以集中解决安全风险。

为了对系统的安全态势及其实现服务等级目标的能力有信心,你需要管理系统的复杂性:必须让系统、组件及其之间的交互形成有意义的解释和理解。对于不同的属性,系统的易理解程度可能有很大的差异。设计出一个易理解的系统,并且随着时间推移仍能保持这种易理解性,是需要付出努力的。

本文主要讨论系统架构设计的易理解性,因为它与系统生命周期的每个阶段都有相关。 我们将讨论软件设计(特别是应用程序框架和 API 的使用)如何显著地影响你对安全性和可靠性属性的解读能力。

本文中对系统易理解性的定义是,有相关技术背景的人员能够准确、自信地解释以下两点: (1)系统运行时的行为; (2)系统的不变性约束条件,包括安全性和可用性。

为什么易理解性很重要?

设计出一个易理解的系统,并且随着时间推移仍能保持这种易理解性,是需要付出努力的。然而,这些努力是值得的。因为一个容易理解的系统有以下几点好处。

降低安全漏洞或弹性故障的可能性

无论什么时候更改系统或者软件组件(比如添加功能、修复缺陷或配置变更),都可能存在意外引入新的安全漏洞或者削弱系统弹性的风险。系统越不容易理解,工程师犯错误的可能性就越大。他可能会误解系统的现有行为,或者没有意识到变更会与隐藏的、内在的或是未记录在案的需求产生冲突。

促进有效的事件响应

在事件发生期间,响应人员能否快速准确地评估损失、控制事件、识别根本原因并解决问题,这是至关重要的。一个复杂、难以理解的系统就会严重阻碍这一工作。

增强对于系统安全态势的断言的信心

关于系统安全性的断言通常用不变量来表示。不变量指系统所有可能的行为必须具备的属性。其中包括系统与外部环境交互时出现意外的情况下是如何响应的,比如系统接收到格式有误或者恶意构造的输入的情况。换句话说,系统在对恶意输入做出响应时的行为不得违反所需的安全属性。

在一个难以理解的系统中,很难(甚至有时不可能)带着很强的信心确定这样的断言是否成立。测试通常不足以证明“针对所有可能的行为”的特性;测试一般只针对典型的、预期操作中相对应的一小部分行为来对系统进行测试 。你通常需要依赖对系统的抽象推理来建立这样的特性,如不变量。

系统的“心智模型”

高度复杂的系统让人类难以全盘推理。

在实践中,工程师和各个领域的专家经常构造“心智模型”,用以解释系统的相关行为,而忽略掉不相关的细节。

对于一个复杂的系统,你可以构建多个互为基础的心智模型。通过这种方式,考虑给定系统或者子系统的行为或不变量时,可以抽象出其周围组件和底层组件的细节,并将其替换为心智模型。

心智模型很有用,因为它简化了复杂系统的推理。出于同样的原因,心智模型也是有局限性的。

如果你根据系统在通常的运营条件下运行的经验构造出一个心智模型,那么这个模型可能无法预测系统在异常情况下的行为。

在很大程度上,安全性和可靠性工程,关注的是在那些异常的情况下分析系统,比如系统受到攻击、过载或者组件故障的场景下。

设计易理解的系统

接下来将讨论一些具体的措施,让系统更易于理解,并且在系统后续发展中仍保持系统的易理解性。我们从复杂性开始说起。

复杂性与易理解性

易理解性的主要对立面是不受管理的复杂性。

现代软件系统(尤其是分布式系统)的规模及其解决的问题导致了一定程度上的复杂性,这是不可避免的。

例如,Google 雇用了数以万计的工程师,在一个超过 10 亿行代码的代码仓库上工作。这些代码行共同实现了大量面向用户的服务,以及底层支撑的后端和数据通道。

即使是具有单一产品的较小组织,也可能在数十万行代码中,实现数百个功能和用户场景,而这些代码则由两位数或三位数的工程师共同编辑。

易理解性与系统、子系统的特定行为及属性有关。我们的目标必须是设计一个系统,让人对于这些特定的、相关的系统属性和行为进行精准的推断,来划分和组合这种固有的复杂性。换句话说,我们必须专门管理妨碍易理解性的复杂性。

分解复杂性

天下难事,必作于易。天下大事,必作于细。 老子《道德经》 译文:天下所有的难事都是由简单的小事发展而来的,天下所有的大事都是从细微的小事做起来的。由此可见,一个人要想成就一番事业,就得从简单的小事做起,从细节入手。

要理解复杂系统各方面的行为,需要内化和维护一个大型的心智模型。人类在这方面不是很在行。

由较小的组件组成系统,可以使系统更易于理解。你应该将每个组件分开来解析,并通过这样一种方式将它们组合起来,即可以从组件属性派生出整个系统的属性。通过这种方式,无须一次性考虑整个系统,即可建立起全局范围的不变量。

这种方法在实践中并不简单。建立子系统属性的能力,以及将子系统属性组合成系统范围属性的能力,取决于整个系统是如何结构化为组件的,以及这些组件之间接口和信任关系的性质。

如果每个独立的组件各自负责实现通用任务和检查逻辑,则很难确定系统是否真正满足需求。可以通过将通用功能的职责分给集中的组件(通常是库或者框架)来改进设计。

例如,RPC 服务框架可以确保为系统的每个 RPC 方法实现身份认证、授权和日志记录(根据整个服务集中定义的策略)。通过这样的设计,服务的各个方法不需要负责这些安全功能,应用程序的开发人员也不会忘记实现或者错误地实现它们。

虽然在应用程序框架或代码库中进行集中化实现时,构建和验证存在前期成本,但这一成本会在基于该框架构建的所有应用程序中摊薄。

系统架构:组件与边界

将系统分层和组件化,是管理复杂性的关键工具。 使用这种方法,可以对系统进行分块推理,而不必一次性了解整个系统的所有细节。

你需要仔细考虑:如何准确地将系统分层和拆解成组件。耦合度太高的组件,就像单片系统一样难以理解。

要让系统易于理解,你就必须像关注组件本身一样,关注组件之间的边界和接口。

有经验的软件开发人员通常会意识到,系统必须考虑来自外部环境的输入(以及交互的序列)是不可信任的,并且系统不能对这些输入做出有效的假设。相反,将内部较底层的 API 的调用层视为可信任的调用者,并依赖于这些调用者在使用 API 时遵守文档约束。

易于理解的接口规范

结构化的接口、一致的对象模型和幂等操作等,都有助于系统的易理解性。

如下所述,这些注意事项可以使得系统更加容易预测输出和接口交互行为。

优先选用解释空间更少的“窄接口”

服务可以使用许多不同的模型和框架来开放接口,举几个例子:

基于 RESTful HTTP 和 JSON 的 OpenAPI gRPC Thrift W3C Web Services(XML/WSDL/SOAP) CORBA DCOM

有些模型非常灵活,也有些模型较为结构化。例如,使用 gRPC 和 Thrift 的服务会定义它支持的每个 RPC 方法的名称,以及该方法的输入、输出的类型。相比之下,RESTful 服务可以接受任何 HTTP 请求,但应用程序代码会验证请求体是否为预期的 JSON 对象结构。

优先选用实施“通用对象模型”的接口

管理多种资源类型的系统可以从通用对象模型中受益,例如用于 Kubernetes 的模型。通过对象模型让工程师可以使用单一的心智模型来理解系统的绝大部分模块,而不是单独处理每种资源类型,举例如下。

可以保证系统中的每个对象满足于一组预定义的基本属性(不变量)。 系统可以提供标准方式来对所有类型的对象进行限定作用域、注释、引用和分级等操作。 运营中所有类型的对象具有一致的行为。 工程师可以创建自定义的对象类型来支持他们的用例,并可以使用与内置类型相同的心智模型来对这些对象类型进行推理。

注意幂等运算

幂等运算多次运行都会产生相同的结果。例如,有人在电梯中按下二楼的按键,电梯每次都会运行至二楼。再按一次按键,甚至多次,也不会改变结果。

在分布式系统中幂等很重要,因为操作可能是无序到达的,或者在服务器完成操作后的响应可能永远无法到达客户端。如果 API 方法是幂等的,则客户端可以发起重放操作,直到它收到成功的结果为止。

如果方法不是幂等的,系统可能需要使用辅助的方法,例如轮询服务器以查看新创建的对象是否已经存在。

幂等也会影响工程师的心智模型。API 的实际行为与其预期行为之间的不匹配可能会导致不可靠或不正确的结果。例如,客户端想要向数据库中添加一条记录。尽管请求成功,但由于连接被重置,响应无法传递。如果客户端代码的作者认为该操作是幂等的,则客户端可能会重试该请求。但如果操作实际上不是幂等的,系统将创建重复的记录。

非幂等运行可能是必要的,但幂等运行通常会带来更简单的心智模型。当操作是幂等操作时,工程师(包括开发人员和事件响应人员)不需要跟踪操作开始的时间。他们只需要不断尝试操作,直到他们知道操作成功就可以了。

有些操作自然是幂等的,可以通过重组这些操作,使得其他操作变成幂等的。在前面的示例中,数据库可以要求客户端在每个变化的 RPC 请求中包括唯一标识符(例如 UUID)。如果服务器接收到具有唯一标识符的第二个请求,它就知道该操作是重复的,并且可以做出相应的响应。

易于理解的身份、认证和访问控制

任何系统都应该能够识别谁有权限访问哪些资源,特别是在资源为高敏感级别的情况下。

身份:身份是与实体相关联的一组属性或标识符。凭证可以用来确定特定实体的身份。凭证可以采用不同的形式,例如简单的密码、X.509 证书或 OAuth2 令牌。凭证通常使用定义好的 身份认证协议来发送,而访问控制系统使用这个协议来标识访问资源的实体的身份。识别实体以及选择识别所用的模型可能会比较复杂。虽然推断系统如何识别人类实体(用户和管理员)会相对比较容易,但大型系统需要能够识别所有实体,不仅限于人类。

大型系统通常由可以互相调用的一组微服务构成,无论是否有人类的参与。比如,数据库服务可能希望定期生成快照,放到更底层的磁盘服务中。该磁盘服务可能需要调用配额服务,以确保数据库服务有足够的磁盘配额用于存放快照数据。或者,用户正在向订餐系统的前端服务申请身份认证。前端服务会调用后台服务,后台服务又调用数据库来检索用户的食物偏好。通常,活动实体是由系统中相互交互的人、软件组件和硬件组件组成的集合。

访问控制:使用框架对传入的服务请求进行编码和实施访问控制策略,对于全局系统的易理解性来说有很大的好处。框架加强了通用能力,并提供了描述策略的统一方式,因此是工程师的工具箱中重要的组成部分。

框架规定了指定和使用声明式访问控制策略的一致性。这种声明和统一性允许工程师开发工具来评估基础设施内的服务和用户数据的安全暴露风险。如果访问控制逻辑是在应用程序代码级别以特殊的方式实现的,那么开发该工具基本是不可能的。

应用框架

框架可以提供一些可复用的功能。特定的系统可以有认证框架、授权框架、RPC 框架、编排框架、监控框架、软件发布框架等。这些框架可以提供很大的灵活性,但也可能过于灵活。所有框架都可能进行组合,其配置方式可能会使得工程师(应用程序和服务开发人员、服务所有者、网站可靠性工程师和 DevOps 工程师)应接不暇。

一般来说,应用程序框架必须给应用程序开发人员和服务负责人提供一种自己的方式,使其在所需的所有功能上实施启用和配置等操作,包括但不限于以下功能:

请求分发、请求转发和截止时间传递; 用户输入清理和区域设置检测; 身份认证、授权和数据访问审计; 日志记录和错误上报; 健康状况管理、监控和诊断; 配额强制执行; 负载均衡和流量管理; 二进制和配置部署; 集成、预发布和负载测试; 仪表板和告警; 容量规划和资源调配; 处理计划内的基础设施停机。

应用程序框架解决了可靠性相关的问题,如监控、告警、负载均衡和容量规划。因此,应用程序框架可以让多个部门的工程师使用相同的语言来表述,从而提升团队之间的理解力和同理心。

数据类型与数据流

如何理解复杂的数据流?

在数据流经大型、复杂的系统时,如果将参数值设定为特定的数据类型,并且该类型规定了所需的属性,那么分析起来会更加容易。

使用类型有助于易理解性,因为它可以极大地减少必须读取和验证的代码量。

小结

本文讨论了在这些组件内部和外部实施所需属性的策略,如安全不变量、弹性架构和数据持久性。易理解性系统中体现的可靠性和安全性优势,是深刻且紧密相连的。如何设计一个易理解的系统,包括以下策略。

窄、一致、类型化的接口。 一致且精心实现的身份认证、授权和账号管理策略。 为活动实体分配明确的身份标识,无论是软件组件还是人工管理员。 封装安全不变量的应用程序框架库和数据类型,确保组件始终遵循最佳实践。

当最关键的系统行为出现故障时,系统的易理解性可以决定这究竟是短暂的事件还是持久的灾难。网站可靠性工程师必须了解系统的安全不变量才能完成他们的工作。在极端情况下,他们不得不在安全事件期间让服务离线,为安全性而牺牲可用性。

参考资料

[美]希瑟·阿德金斯 [美]贝齐·拜尔. Google系统架构解密 构建安全可靠的系统

0 人点赞