软件系统设计#

程序设计并不等同与软件设计,程序写完了不等于软件就写完了 1王建兴:程序设计不等同于软件开发 http://www.ithome.com.tw/voice/89695

一个好的软件应该是可扩展、健壮、高可用、安全、模块化的 2Software design https://en.wikipedia.org/wiki/Software_design

软件系统设计四步走 3donnemartin/system-design-primer

  1. 明确使用场景和约束;

  2. 使用所有重要的组件描绘出一个高层级的设计;

  3. 对每一个核心组件进行详细深入的分析;

  4. 扩展设计,确认和处理瓶颈以及一些限制。

明确使用场景和约束#

在这个阶段,尝试回答以下问题:

  • 谁会使用它?

  • 他们会怎样使用它?

  • 有多少用户?

  • 系统的作用是什么?

  • 系统的输入输出分别是什么?

  • 我们希望处理多少数据?

  • 我们希望每秒钟处理多少请求?

  • 我们希望的读写比率?

高层级设计#

在这一阶段,我们尝试:

  • 画出主要的组件和连接

  • 并证明你的想法的可行性

设计核心组件#

什么叫核心组件?某个功能模块可以叫做核心组件,比如 url 缩写服务。设计细节包括:

  • 生成并存储一个完成的 url 的 hash

  • 将一个 hashed url 翻译成完整的 url

  • API 和面向对象设计

  • 域名系统(DNS)

  • 内容分发网络(CDN)

那么每个细节展开来讲,又会有比较多的考虑。如下:

生成并存储一个完成的 url 的 hash 时,用 MD5 还是用 Base62 生成散列函数?如何处理 hash 碰撞?SQL 还是 NoSQL,数据库模型选哪个?

提示

NoSQL 是一个统称,它包括键-值存储、文档存储、列型存储以及图数据库。

将一个 hashed url 翻译成完整的 url 时,如何确定数据库查找算法?

选择域名系统时,记录类型?缓存位置?路由方式?DNS 失效如何解决?

设计内容分发网络时,CDN 的推送和拉取怎么实现?如何确认 CDN 缓存已经失效?

可扩展性设计#

扩展设计指的是当用户量增加时,系统还能否满足要求。在这个阶段,我们通常会考虑:

  • 负载均衡器(优点、缺点、路由方式)

  • 反向代理(优点、缺点、与负载均衡器对比)

  • 垂直和水平扩展

  • 缓存(缓存到哪里、什么级别的缓存、什么时候更新缓存)

  • 数据库设计(数据库复制、联合、分片、SQL 还是 NoSQL、调优技巧)

  • 异步(消息队列、任务队列、背压)

  • 微服务

  • 可用性与一致性(分布式计算系统、CAP 理论)

  • 通信(HTTP、TCP、UDP、RPC、REST)

  • 安全(加密传输、防止 SQL 注入、最小权限原则)

凤凰架构笔记#

../_images/icyfenix-overview.svg

图 14 编辑原图:https://kdocs.cn/l/cadS3e8BGKby#

软件架构的发展大致分为以下几个阶段:

  • 原始分布式架构

  • 单体架构

  • SOA 架构

  • 微服务架构

  • 服务网格架构

  • 无服务架构

对上述的软件架构进行设计时,通常都需要考虑以下几个问题:

  • 服务之间的调用规则选择 RPC 还是 REST 风格?

  • 如何进行事务处理?细分为本地事务、全局事务、共享事务、分布式事务。

  • 如何进行多级分流?客户端和服务端的缓存策略、域名解析、传输链路、内容分发网络、负载均衡。

  • 如何保证架构安全性?认证、授权、凭证、保密、传输、验证。

对于不同架构使用的技术规范,在不同的级别上,它们的区别如下:

  • 整体上提供统一的解决方案?

  • 还是由应用系统自行解决?

  • 还是在基础设施层面将这些问题隔离掉?

备注

技术规范指的是编写程序时应该遵循的一种标准。

不可变基础设施意在隐藏分布式架构的复杂性,让分布式架构成为一种可普遍推广的普适架构风格,不可变基础设施包括:

  • 虚拟化容器

  • 容器间网络

  • 持久化存储

  • 资源与调度

  • 服务网格

更多整理内容:

服务架构的演进#

服务架构演进大致经历了原始分布式架构、单体架构、SOA 架构、微服务架构、服务网格架构和无服务架构。

由于一台计算机的资源十分有限(16 位寻址、5 MHz 时钟频率、128 KB 内存地址空间),人们开始想象用多台计算机共同完成任务,这是早期分布式的尝试

在早期尝试中的标志性事件是国际开放标准组织(OSF)和业界主流计算机厂商共同制定了 “分布式运算环境(DCE)” 的分布式技术体系。

OSF 的愿景是使分布式环境中的服务调用、资源访问、数据存储等操作尽可能透明化、简单化,从而使开发人员不必过于关注他们访问的方法或其他资源是位于本地还是远程

因此,DCE 制定了包含一套相对完整的分布式服务组件规范和参考实现:

  • 源自 NCA 的远程服务调用规范(DCE/RPC);

  • 源自 AFS 的分布式文件系统(DCE/DFS);

  • 源自 Kerberos 的服务认证规范;

  • 通用唯一标识符(UUID) 也是在 DCE 中发明的。

但是这种复杂的设计限制了它的发展,而且调用远程方法相比于调用本地方法有更多的局限性

  • 远程方法不能依靠以内联为代表的传统编译优化来提升速度

  • 网络环境下:

    • 远程服务在哪里(服务发现)

    • 有多少个(负载均衡)

    • 网络出现分区、超时或者服务出错了怎么办(熔断、隔离、降级)

    • 方法的参数与返回值如何表示(序列化协议)

    • 信息如何传输(传输协议)

    • 服务权限如何管理(认证、授权)

    • 如何保证通信安全(网络安全层)

    • 如何调用不同机器的服务返回相同的结果(分布式数据一致性)

某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自食苦果。

后随着单机处理能力的稳步上升,一些在分布式中遇到的问题,在单体架构中通常不会出现。

单体是一个完全不可分割的整体,这种看法是不恰当的。

从纵向来看,不论是单体架构(也叫巨石系统)还是微服务,抑或是其他架构风格,分层是普遍的。 收到的外部请求在各层之间以不同形式的数据结构进行流转传递。

从横向来看,单体架构也支持按照技术、功能、职责等维度将代码拆分为各个模块。 在扩展能力上,也可以用负载均衡器之后部署若干个相同的副本,来达到分摊流量的效果。

单体架构的缺陷,不在于如何拆分,而在于拆分之后的自治与隔离能力上(代码是否能够运行在不同的进程中)。

如果任何一部分代码出现缺陷,过度消耗了进程空间的资源,造成的影响可能也是全局性的,难以隔离的, 比如内存泄漏、线程爆炸、阻塞、死循环等。

更高层的公共资源发生问题,比如端口号或数据库连接池泄漏,将会影响整台机器,甚至集群中的其他单体副本。

从可维护性上讲,单体系统的升级和修改需要制定专门的停机更新计划,做灰度发布,A/B 测试。

单体架构还面对技术异构的困难,每个模块的代码通常需要使用一样的程序语言。

单体架构只能尽可能低地让意外发生,以维持系统的稳定性。而出错是必然,也才出现了后面的架构风格。

面向服务架构(SOA):

  • 烟囱式架构(又名信息烟囱,信息孤岛):假设模块之间没有任何交集,不现实,已被淘汰

  • 微内核架构(又称插件式架构):公共资源(也叫主数据,如人员、组织、权限等)放在内核中,具体业务已插件模块的形式存在。 它很适合桌面应用程序和 Web 应用程序,因为对于平台型应用,我们希望将新特性或新功能及时加入系统。 局限性在于假设各个插件之间不能直接交互,不适用于企业信息系统或互联网应用;

  • 事件驱动架构:解决了为内核架构的局限性,既能拆分成独立的子系统,又能让子系统之间顺畅地通信。 它在子系统之间建立一套事件队列管道,来自系统外部的消息以事件的形式发送至管道中。 各个子系统可以从管道中获取自己感兴趣、能够处理的事件消息,也可以新增或修改其中的附加信息。 如此,每一条消息都是独立的、高度解耦的但又能与其他处理者通过事件管道进行交互。

后面因为 SOA 变得越来越复杂,已经不能简单视为一种架构风格,它对软件设计提出了一系列方法论:

  • 服务的封装性、自治、松耦合、可重用、可组合、无状态;

  • 采用 SOAP 作为远程服务调用协议;

  • 依靠 SOAP 协议族(WSDL、UDDI、WS-*)完成服务的发布、发现、治理

  • 利用企业服务总线(ESB)的消息管道实现各子系统之间的交互;

  • 令各服务在 ESB 的调度下,无需相互依赖就能相互通信,进而实现业务流程编排(BPM);

  • 使用服务数据对象(SDO)来访问和表示数据;

  • 使用服务组件架构(SCA)来定义服务封装的形式和服务运行的容器;

  • ……

过于严格的规范定义带来过度的复杂性,而构建在 SOAP 之上的 ESB、BPM、SCA、SDO 等诸多上层建筑进一步加剧了复杂性, 最终导致 SOAP 逐渐被边缘化。取而代之的是草根框架:Spring、Hibernate

微服务是一种通过多个小型服务组合来构建单个应用的架构风格,这些服务围绕业务能力而非特定的技术标准来构建。 各个服务可以采用不同的编程语言、不同的存储技术、运行在不同的进程中。

服务采用轻量级的通信机制和自动化的部署机制实现通信和运维。

微服务提倡以 “实践标准” 代替 “规范标准”。因此,针对分布式中的每个问题,都有许多针对性的解决方案,比如:

  • 远程服务调用:RMI(Sun/Oracle)、Thrift(Facebook)、Dubbo(阿里巴巴)、gRPC(Google)、Motan2(新浪)、 Finagle(Twitter)、brpc(百度)、Arvo(Hadoop)、JSON-RPC、REST 等;

  • 服务发现:Eureka(Netflix)、Consul(HashiCorp)、Nacos(阿里巴巴)、ZooKeeper(Apache)、 etcd(CoreOS)、CoreDNS(CNCF)等;

  • ……

因此,”解决什么问题,就引入什么工具,熟悉什么技术,用使用什么框架” 是微服务中的一大特征。 但是,微服务对架构能力要求较高,需要了解各个工具的利弊,做出权衡。

分布式架构中遇到的诸如注册发现、跟踪治理、负载均衡、传输通信等,既有硬件解决方案,也有软件解决方案。硬件方案比如:

  • 系统伸缩扩容:购买新的服务器,部署副本;

  • 负载均衡:布置负载均衡器,选择均衡算法;

  • 解决传输安全问题:布置 TLS 传输链路,配置好 CA 证书;

  • 服务发现:设置 DNS 服务器;

  • ……

相比于硬件,软件解决方案更加灵活。上面这些硬件,可以使用虚拟化技术或容器化技术来实现。 早期的容器和虚拟化技术用来:软件定义网络(SDN)、软件定义存储(SDS)等。

因此,我们可以采用虚拟化的基础设施解决在分布式系统中遇到的问题。 可供选择的工具,比如 Kubernetes、Spring Cloud 等。

至此,软件和硬件之间的界限变得越来越模糊。

一旦虚拟化的基础设施能够跟上软件的灵活性,那些与业务无关的技术性问题便有可能从软件层面剥离,悄无声息地在硬件基础设施中解决, 让软件只关注业务,真正围绕业务能力构建团队和产品。 因此,DCE 设想的 “透明式的分布式应用” 成为可能。这种软硬一体解决架构问题的方式被称为云原生

Kubernetes 局限性在于难以对处于应用系统和基础设施边缘的问题精细化管理。 比如下图中微服务 A 调用了微服务 B 的两个服务,是否要熔断?

../_images/fenix-001.png

图 15 是否要熔断对服务 B 的访问?#

这在 K8s 中很难解决,但是 Spring Cloud 就相对比较容易解决,因为 Spring Cloud 自定义程度更高。

需要注意的是,基础设施是针对整个容器来管理的,粒度相对粗犷,只能到容器层面,对单个远程服务则难以有效管控。 因此,针对更加精细化的管理,出现了服务网格的边车代理模式

虚拟化场景中的边车指的是由系统自动在服务容器(通常指 Kubernetes 中的 Pod。 一个 Pod 由一个或多个容器组成 4https://zhuanlan.zhihu.com/p/32618563)中注入的一个通信代理服务器,相当于那个挎斗。 在应用毫无感知的情况下,接管应用所有的对外通信(包含数据平面通信和控制平面通信)。 通过边车代理模式,实现了精细化管理。

2014 年亚马逊发布了 Lambda 无服务计算平台。它只涉及后端设施和函数两个概念:

  • 后端设施:数据库、消息队列、日志、存储等技术组件;

  • 函数:业务逻辑代码。

无服务计算平台比较适合短连接、无状态、事件驱动的程序:Web 资讯类网站、小程序、公共 API 服务。 不适合长连接、依赖服务状态、、响应速度较高的程序:游戏。而且,程序的冷启动时间也是耗时的。

访问远程服务#

远程服务调用#

分析访问远程服务的规则时,一般经常和调用本地服务作对比。

因为本地服务调用无法解决跨越两个内存地址空间进行方法调用。 因此出现了进程间通信(IPC),常用的解决方法有:(具名)管道、信号、信号量、消息队列、共享内存、本地套接字接口。

构建分布式服务时,需要注意以下认知误区:

  • 网络是可靠的

  • 延迟是不存在的

  • 带宽是无限的

  • 网络是安全的

  • 拓扑结构是一成不变的

  • 总会有一个管理员

  • 不必考虑传输成本

  • 网络都是同质化的

构建分布式服务时,需要考虑三个基本问题:

  • 如何表示数据(就是我们常说的序列化和反序列化)

  • 如何传递数据(网络各分层之间如何交换数据,需要考虑异常、超时、安全、认证、授权、事务等)

  • 如何表示方法(最简单的方式是给每个方法一个唯一的 UUID)

没有一个完美的协议或框架能够解决所有分布式中碰到的问题:

  • 考虑分布式中面向对象编程:RMI、.NET Remoting

  • 优先考虑性能:gRPC、Thrift

  • 优先考虑简单易用:JSON-PRC

因此在做选择时,需要权衡利弊。

REST 设计风格#

REST 风格和远程服务调用有些相似,但是本质上不同。两者最大的不同是抽象目标不同:

  • 远程服务调用:面向过程的思想

  • REST 风格:面向资源的思想

REST 不是一种远程服务调用协议,甚至,它就不是一个协议,因此也就没有过多约束。

REST 和 RPC 是两种主流的远程调用方式,其侧重范围略有不同:

  • RPC:分布式对象、提升性能、简化调用复杂性

  • REST:浏览器端(也可以用于移动端、桌面端,只要支持 HTTP 就行,但是性能不高)

因为 REST 风格基于 HTTP 协议,所以,使用起来也是比较方便,它只有七种操作: GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS。每一次操作都会触发表征状态转移。

一个理想的、完全满足 REST 风格的系统应该满足一下六大原则:

  • 客户端与服务端分离(例如,前端代码驱动服务端渲染的 SSR)

  • 无状态(服务端不保存用户当前状态)

  • 可缓存(客户端或中间服务器缓存部分服务器应答)

  • 分层系统(客户端可经过多个中间服务器连接到最终服务器,典型应用为 CDN)

  • 统一接口(使用 HTTP 提供的操作,建议使用资源 ID 访问资源)

  • 按需代码(可选原则,可执行的代码由服务端发往客户端,并在客户端执行和销毁)

REST 风格具有学习成本低、按资源分层方便、基于 HTTP 协议等优点。 但是,过于抽象不太符合人类思维习惯是它的一个缺点。

以上是理论,下面讲实践。

衡量一个 “服务有多么 REST” 的成熟度模型规定:

  • 第 0 级:完全不 REST;

  • 第 1 级:开始引入资源的概念;

  • 第 2 级:引入统一接口,映射到 HTTP 协议的方法上;

  • 第 3 级:超文本驱动。

具体案例参考 RMM 成熟度。 在这个案例中,我们需要思考:应该如何设计一个资源的上下级关系,让服务端的 API 和客户端完全解耦,以应对不断变化的需求。

REST 风格也有一些局限性,值得我们注意:

  • 面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑;

  • REST 与 HTTP 完全绑定,不适合应用于要求高性能传输的场景中;

  • REST 不利于事务支持;

  • REST 没有传输可靠性支持;

  • REST 缺乏对资源进行 “部分” 和 “批量” 的处理能力。

最后,考虑使用哪种方式来编程取决于我们的场景:

  • 面向过程编程时,为什么要以算法和处理过程为中心?输入数据、输出结果?(计算机世界的交互方式)

  • 面向对象编程时,为什么要将数据和行为统一起来,封装成对象?(现实世界的交互方式)

  • 面向资源编程时,为什么要将资源作为抽象的主体,把行为看做统一的接口?(网络世界的交互方式)

事务处理#

事务的概念起源于数据库系统,但是现在已经不再局限于数据库本身了。

原子性、隔离性、持久性是手段,一致性是目的。事务一致性可细分为:

  • 内部一致性:一个服务使用一个数据源;

  • 外部一致性:一个服务使用多个数据源。

事务的几个阶段可分为:开启、终止、提交、回滚、嵌套、设置隔离级别。

数据源:指提供数据的逻辑设备,不必与物理设备一一对应。

本地事务#

适用场景:一个服务使用一个数据源。

实现原子性#

实现原子性的最大障碍是:写入磁盘操作不是原子的。因为有 “正在写” 的中间状态。

  • 未提交事务,写入后崩溃:撤销磁盘操作;

  • 已提交事务,写入前崩溃:重新写入磁盘。

为了能够崩溃恢复,主流方式是使用 提交日志(Commit Logging) 的方法,步骤如下:

  • 将用户操作按照顺序追加的方式,写到日志文件中;

  • 数据库看到了提交记录(Commit Record);

  • 根据日志修改数据,并在日志中添加结束记录(End Record)。

另一种方法是 影子分页(Shadow Paging) 应用案例如 SQLite Version 3。 基本思路是先复制一份副本,保留原数据,修改副本,修改完成后修改指针。 影子分页比提交日志简单,但是涉及隔离性与并发锁时,性能较低,因此在高性能数据库中较少使用。

提交日志的方法的局限性:所有对数据的真实修改都必须发生在事务提交后,即使磁盘 I/O 有空闲。

改进方法: 提前写入日志(Write-Ahead Logging)。它分两种情况:

  • FORCE:当事务提交后,要求变动数据必须同时完成写入。相应地,不要求同时变动数据为 NO-FORCE。

  • STEAL:在提交事务前,允许变动数据提前写入。相应地,不允许变动提前写入为 NO-STEAL。

提交日志方法允许 NO-FORCE 但不允许 STEAL。提前写入日志允许 NO-FORCE 也允许 STEAL, 它的解决方法是引入回滚日志(Undo Log)的日志类型,当变动写入磁盘前,需要先记录回滚日志。 此前记录的日志为重做日志(Redo Log)。 这两个日志有什么不一样?

  • 回滚日志:用于擦除提前写入的变动;

  • 重做日志:用于重演数据变动。

提前写入日志方法在崩溃恢复时经历以下三个阶段:

  • 分析阶段:从最后一次检查点(Checkpoint)扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合。 这个集合至少包含事务表(Transaction Table)和脏页表(Dirty Page Table);

  • 重做阶段:将待恢复的事务集合中包含 Commit Record 的事务重新写入磁盘。完成后写入 End Record;

  • 回滚阶段:处理待恢复的事务集合中的剩余事务,将提前写入的变动改回原样。

实现隔离性#

隔离性保证各个事务的读、写互相独立,不会彼此影响。实现隔离性的方法是加锁同步:

  • 写锁(Write Lock,X-Lock):也叫排它锁。 只能有一个事务持有数据的写锁,其他事务不能再对该数据加读锁。

  • 读锁(Read Lock,S-Lock):也叫共享锁。 多个事务可以对同一个数据加多个读锁。如果只有一个事务持有读锁,可以将读锁升级为写锁。

  • 范围锁(Range Lock):选中一个范围的数据,施加排它锁。 对该范围内的数据不能新增或删除,因此它不等价于一组排它锁。

隔离性分为四个级别,隔离程度越高,并发访问的吞吐量就越低:

  • 可串行化:对事务涉及的数据加读锁、写锁、范围锁。其中加锁和解锁两个阶段称为两阶段锁。

  • 可重复读:对事务涉及的数据加读锁、写锁,且一直持续到事务结束,但不加范围锁。 有幻读问题:对某个范围的两次查询结果不一样。此为 MySQL/InnoDB 的默认隔离级别。

  • 读已提交:对事务涉及的数据加读锁、写锁,写锁一直持续到事务结束,读锁在查询操作完成后马上释放。 有不可重复读问题:对同一行数据的两次查询结果不同。

  • 读未提交:只对事务涉及的数据加写锁,且一直持续到事务结束。 有脏读问题:在事务执行过程中,一个事务读取到了另一个事务没提交的数据。 注意,写锁禁止其他事务施加读锁,而不是禁止事务读取数据。

以上幻读、不可重复读、脏读等问题都是由于一个事务在读数据时,受另一个写数据的事务影响而破坏了隔离性。

针对这种 “读 + 写” 的隔离问题,有一个称为 “多版本并发控制(MVCC)” 的无锁优化方案被主流商业数据库厂商使用。 MVCC 是一种读取优化策略,它的 “无锁” 是指读取时不需要加锁。 基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存。 实现方法如下:

  • 创建两个隐藏字段:CREATE_VERSIONDELETE_VERSION,两个字段记录的都是事务 ID;

  • 插入数据时,CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空;

  • 删除数据时,CREATE_VERSION 为空,DELETE_VERSION 记录删除数据的事务 ID;

  • 修改数据时,先将原有的数据复制一份,删除原有数据,插入复制数据。

然后,根据隔离级别选择应该读取哪个版本的数据:

  • 隔离级别是可重复读:总是读取 CREATE_VERSION ≤ 当前事务 ID 的记录;

  • 隔离级别是读已提交:总是读取最新版本,即最近被提交的版本的数据记录;

  • 隔离级别是读未提交:不用 MVCC,直接修改原始数据即可;

  • 隔离级别是可串行化:不用 MVCC,可串行化会阻塞其他事务,与 MVCC 相悖。

由于 MVCC 只是针对 “读 + 写” 场景的优化,如果是 “写 + 写” 的场景,加锁几乎是唯一的方法。 可细分为:

  • 乐观加锁:认为数据存在竞争是偶然现象,不应该一开始就加锁。数据竞争激烈时效率较低;

  • 悲观加锁:前面所述都是悲观加锁方案。

因此,对于隔离性而言,也没有一个十全十美的方案。

全局事务#

适用场景:一个服务使用多个数据源。

理论上没有单个服务的约束,本来就是 DTP(分布式事务处理)模型中的概念。

为了解决分布式事务的一致性问题,X/Open 组织提出了 X/Open XA 处理事务架构。 其核心内容是定义了全局的事务管理器和局部的资源管理器之间的通信接口。

XA 接口是双向的,能在一个事务管理器和多个资源管理器之间形成通信桥梁, 通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚。

XA 将事务提交拆分为两个阶段:准备阶段和提交阶段。

sequenceDiagram 协调者 ->>+ 参与者: 要求所有参与者进入准备阶段 参与者 -->>- 协调者: 已进入准备阶段 协调者 ->>+ 参与者: 要求所有参与者进入提交阶段 参与者 -->>- 协调者: 已进入提交阶段 opt 失败或超时 协调者 ->>+ 参与者: 要求所有参与者回滚事务 参与者 -->>- 协调者: 已回滚事务 end

以上被称为 “两段式提交(2PC)” 协议,而它能够成功保持一致性需要满足以下前提:

  • 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。

  • 必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。

两段式提交原理简单,但是有几个显著缺点:

  • 单点问题:主要是由于协调者的中心决策;

  • 性能问题:两次远程服务调用、三次数据持久化、木桶效应;

  • 一致性风险:网络故障、服务器宕机。

改进方法:三段式提交(3PC)协议,将准备阶段分为 CanCommit 和 PreCommit,提交阶段改称 DoCommit。 因为准备阶段如果发生失败,回滚的代价是昂贵的。但是在正常提交的场景下,两者的性能都很差,3PC 更差。

sequenceDiagram 协调者 ->>+ 参与者: 询问阶段:是否有把握完成事务 参与者 -->>- 协调者: 是 协调者 ->>+ 参与者: 准备阶段:写入日志,锁定资源 参与者 -->>- 协调者: 确认(Ack) 协调者 ->>+ 参与者: 提交阶段:提交事务 参与者 -->>- 协调者: 已提交 opt 失败 协调者 ->>+ 参与者: 要求回滚 参与者 -->>- 协调者: 已回滚 end opt 超时 参与者 ->> 参与者: 提交事务 end

3PC 对 2PC 的单点问题和回滚时的性能问题有所改善,但是对一致性风险问题没有任何改进,甚至略有增加。 因为,超时后参与者仍会提交事务,这时有可能会产生多个参与者之间的数据不一致。

共享事务#

适用场景:多个服务共享一个数据源。

理论可行的方案:直接让各个服务共享数据库连接。但是实际不可行,因为数据库连接的基础是网络连接, 它是与 IP 地址和端口号绑定的,字面意义上的 “不同服务节点上共享数据库连接” 很难做到。 因此,为了实现共享事务,必须新增 “中间服务器”,将它作为各个服务的远程数据库连接池来看待。 然后由中间服务器与数据库连接,这就相当于一个本地事务了。

graph LR User("用户账户") --> Proxy("交易服务器") Business("商家账户") --> Proxy Warehouse("商品仓库") --> Proxy Proxy --> Database("数据库 ")

这种方式在现实中是不可用的。 因为实际生产系统中,我们会为多个数据库实例使用负载均衡,但通常不会对多个服务做负载均衡。 这种方法在实际应用中并不值得提倡,鲜有采用这种方式的成功案例。

也就是说,共享事务一般不会再现实中出现。

分布式事务#

使用场景:多个服务使用多个数据源。

本节所说的分布式有别于 DTP 模型中的分布式。DTP 模型中的分布式是针对多个数据源来说的,不涉及服务。 本节说的分布式针对的是多个服务。

CAP 理论:

  • 一致性(C):在任何时刻、任何分布式节点中所看到的都是符合预期的;

  • 可用性(A):系统不间断地提供服务的能力;

  • 分区容忍性(P):节点之间形成 “网络分区” 时,系统仍能正确地提供服务的能力。

三个特性只能同时满足两个:

  • CA without P:放弃分区容忍性。通过共享存储来保证没有网络分区,如 Oracle RAC。

  • CP without A:放弃可用性。以时间为代价,保证一致性。如 HBase 集群。

  • AP without C:放弃一致性。通过本地缓存保证可用性。分布式系统的主流选择。如 NoSQL 和 Redis 集群。

凤凰架构的服务拓扑结构如下图所示:

graph TB User("最终用户")-->Store("Fenix's Bookstore") Store-->Warehouse("仓库服务集群") Store-->Business("商家服务集群") Store-->Account("账号服务集群") subgraph o1 Warehouse-.->Warehouse1("仓库节点1") Warehouse-.->Warehouse2("仓库节点2") Warehouse-->WarehouseN("仓库节点N") end subgraph o2 Business-.->Business1("商家节点1") Business-->Business2("商家节点2") Business-.->BusinessN("商家节点N") end subgraph o3 Account-->Account1("账号节点1") Account-.->Account2("账号节点2") Account-.->AccountN("账号节点N") end

每个节点都有自己的数据库,一次交易需要由多个节点联合响应。 比如,当用户购买一件价值 100 元的商品后:

  • 账号节点需要扣款 100 元;

  • 商家节点收款 100 元;

  • 仓库节点发货。

可靠事件队列#

事件队列通过轮询(不断重试,也叫最大努力交付)的方式保证事务的准确性。

工具有 RocketMQ。

sequenceDiagram Fenix's Bookstore ->>+ 账号服务: 启动事务 账号服务 ->> 账号服务: 扣减货款 账号服务 ->>- 消息队列: 提交本地事务,发出消息 loop 循环直至成功 消息队列 ->> 仓库服务: 扣减库存 alt 扣减成功 仓库服务 -->> 消息队列: 成功 else 业务或网络异常 仓库服务 -->> 消息队列: 失败 end end 消息队列 -->> 账号服务: 更新消息表,仓库服务完成 loop 循环直至成功 消息队列 ->> 商家服务: 货款收款 alt 收款成功 商家服务 -->> 消息队列: 成功 else 业务或网络异常 商家服务 -->> 消息队列: 失败 end end 消息队列 -->> 账号服务: 更新消息表,商家服务完成

可靠消息队列保证了最终结果的一致性,但是却没有任何隔离性可言。

TCC 事务#

对于需要隔离性的事务,需要使用 TCC 事务,它包含 Try、Confirm、Cancel 三个阶段。 比如下图所示的 “超售” 案例,需要可重复读的隔离级别。

sequenceDiagram Fenix's Bookstore ->> 账号服务: 业务检查,冻结货款 alt 成功 账号服务 -->> Fenix's Bookstore: 记录进入Confirm阶段 else 业务或网络异常 账号服务 -->> Fenix's Bookstore: 记录进入Cancel阶段 end Fenix's Bookstore ->> 仓库服务: 业务检查,冻结商品 alt 成功 仓库服务 -->> Fenix's Bookstore: 记录进入Confirm阶段 else 业务或网络异常 仓库服务 -->> Fenix's Bookstore: 记录进入Cancel阶段 end Fenix's Bookstore ->> 商家服务: 业务检查 alt 成功 商家服务 -->> Fenix's Bookstore: 记录进入Confirm阶段 else 业务或网络异常 商家服务 -->> Fenix's Bookstore: 记录进入Cancel阶段 end opt 全部记录均返回Confirm阶段 loop 循环直至全部成功 Fenix's Bookstore->>账号服务: 完成业务,扣减冻结的货款 Fenix's Bookstore->>仓库服务: 完成业务,扣减冻结的货物 Fenix's Bookstore->>商家服务: 完成业务,货款收款 end end opt 任意服务超时或返回Cancel阶段 loop 循环直至全部成功 Fenix's Bookstore->>账号服务:取消业务,解冻货款 Fenix's Bookstore->>仓库服务:取消业务, 解冻货物 Fenix's Bookstore->>商家服务:取消业务 end end

缺点:TCC 事务的业务侵入性较强,要求业务处理过程必须拆分为 “预留业务资源” 和 “确认/释放消费资源” 两个子过程。

但是 TCC 事务在柔性事务中,性能还是比较高的(相对 SAGA 事务来讲)。

通常我们不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(如阿里开源的 Seata)去完成,尽量减轻一些编码工作量。

SAGA 事务#

跨越两个系统的事务无法在 TCC 事务中实现。比如 “使用第三方支付”,我们无权对银行的数据库进行干涉。

SAGA 事务的主要思路如下:

  • 将大事务拆分若干个小事务,将整个分布式事务 \(T\) 分解为 \(n\) 个子事务,命名为 \(T_1, T_2, ..., T_i, ..., T_n\)。 每个子事务都应该是或者能被视为是原子行为。 如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 \(T_i\) 等价。

  • 为每一个子事务设计对应的补偿动作,命名为 \(C_1, C_2, ..., C_i, ..., C_n\)\(T_i\)\(C_i\) 必须满足以下条件:

    • \(T_i\)\(C_i\) 都具备幂等性。

    • \(T_i\)\(C_i\) 满足交换律(Commutative),即先执行 \(T_i\) 还是先执行 \(C_i\),其效果都是一样的。

    • \(C_i\) 必须能成功提交,即不考虑 \(C_i\) 本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。

如果 \(T_1\)\(T_n\) 均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):如果 \(T_i\) 事务提交失败,则一直对 \(T_i\) 进行重试,直至成功为止(最大努力交付)。 这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。

    • 正向恢复的执行模式为:\(T_1, T_2, ..., T_i(失败), T_i(重试)..., T_{i+1}, ..., T_n\)

  • 反向恢复(Backward Recovery):如果 \(T_i\) 事务提交失败,则一直执行 \(C_i\)\(T_i\) 进行补偿,直至成功为止(最大努力交付)。 这里要求 \(C_i\) 必须(在持续重试后)执行成功。

    • 反向恢复的执行模式为:\(T_1, T_2, ..., T_i(失败), C_i(补偿)..., C_{i-1}, ..., C_2, C_1\)

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多

SAGA 系统本身也有可能会崩溃,所以它必须设计与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况。 譬如执行至哪一步或者补偿至哪一步了。

SAGA 事务实现起来也不太容易,通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,Seata 同样支持 SAGA 事务模式。