Fenix

服务架构演进史

1
2
3
4
# 架构并不是被发明出来的,而是持续演进的结果
架构
发明 ×
演进 √

原始分布式时代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# UNIX 的分布式设计哲学
保持接口与实现的简单性,比系统的任何其他属性,包括准确性、一致性和完整性,都来得更加重要。
—— Richard P. Gabriel,The Rise of 'Worse is Better',1991

# 单机有限制,想着搞多台分布式,突破硬件算力的限制。
OSF 与 计算机厂商 => 共同制定 => DCE
Open Software Foundation,OSF “开放软件基金会”
Distributed Computing Environment,DCE “分布式运算环境”

DCE 包含一套相对完整的分布式服务组件规范与参考实现,例如
=> RPC 远程服务调用规范(Remote Procedure Call)
=> DFS 分布式文件系统(Distributed File System)
=> UUID 通用唯一识别符(Universally Unique Identifier)

# 调用远程方法
+ 远程的服务在哪里(服务发现),
+ 有多少个(负载均衡),
+ 网络出现分区、超时或者服务出错了怎么办(熔断、隔离、降级),
+ 方法的参数与返回结果如何表示(序列化协议),
+ 信息如何传输(传输协议),
+ 服务权限如何管理(认证、授权),
+ 如何保证通信安全(网络安全层),
+ 如何令调用不同机器的服务返回相同的结果(分布式数据一致性)
+ ...等一系列问题,

# 代价 大于 利益 => 价值千金的教训
将一个系统拆分到不同的机器中运行,这样做带来的服务发现、跟踪、通信、容错、隔离、配置、传输、数据一致性和编码复杂度等方面的问题,所付出的代价远远超过了分布式所取得的收益。
亲身经历过那个年代的计算机科学家、IBM 院士 Kyle Brown 事后曾评价道:“这次尝试最大的收获就是对 RPC、DFS 等概念的开创,
以及得到了一个价值千金的教训:“某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自寻苦果。”

# 让步(两种出路)
提升单机
一条是尽快提升单机的处理能力,以避免分布式带来的种种问题;
找到更完美的解决方案
另一条路是找到更完美的解决如何构筑分布式系统的解决方案。

单体系统时代

单体架构(Monolithic)
“单体”只是表明系统中主要的过程调用都是进程内调用,不会发生进程间通信,仅此而已。

微服务后才知“单体”一词
单体架构 称作“巨石系统”(Monolithic Application)。 “单体架构”在整个软件架构演进的历史进程里,是出现时间最早、应用范围最广、使用人数最多、统治历史最长的一种架构风格,但“单体”这个名称,却是在微服务开始流行之后才“事后追认”所形成的概念。

小型系统,单体好
对于小型系统——即由单台机器就足以支撑其良好运行的系统,单体不仅易于开发、易于测试、易于部署,且由于系统中各个功能、模块、方法的调用过程都是进程内调用,不会发生进程间通信(Inter-Process Communication,IPC)。
广义上讲,可以认为 RPC 属于 IPC 的一种特例,但请注意这里两个“PC”不是同个单词的缩写。因此也是运行效率最高的一种架构风格,完全不应该被贴上“反派角色”的标签,反倒是那些爱赶技术潮流却不顾需求现状的微服务吹捧者更像是个反派。

单体的好与坏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 纵向
分层架构 更容易开发、部署、测试而获得一些便捷性上的好处。

# 横向
部分多个副本,多jar抗流量

# 好处
由于所有代码都运行在同一个进程空间之内,所有模块、方法的调用都无须考虑网络分区、对象复制这些麻烦的事和性能损失。

# 坏处
如果任何一部分代码出现了缺陷,过度消耗了进程空间内的资源,所造成的影响也是全局性的、难以隔离的。
例如内存泄漏、线程爆炸、阻塞、死循环等问题,都将会影响整个程序,而不仅仅是影响某一个功能、模块本身的正常运作。
如果消耗的是某些更高层次的公共资源,例如端口号或者数据库连接池泄漏,影响还将会波及整台机器,甚至是集群中其他单体副本的正常工作。

# 可维护性不好
因为不可能有“停掉半个进程,重启 1/4 个程序”这样不合逻辑的操作,所以从可维护性来说,单体系统也是不占优势的。
程序升级、修改缺陷往往需要制定专门的停机更新计划,做灰度发布、A/B 测试也相对更复杂。

# 技术难异构
java 和 C 混用?或 C++ 实现

# 单体系统很难兼容“Phoenix”的特性

单体案例

1
2
3
4
5
6
7
# 大超市、小卖铺
大超市,仓储部、采购部、安保部、库存管理部、巡检部、质量管理部、市场营销部、...划清职责,明确边界
小卖铺,你说搞好多部门,那纯粹是给自己找麻烦。

# 大公司、小公司
公司小,两个部门在一起节省资源
公司大,人多的时候,还挤在一起就不太好了

SOA 时代

1. 烟囱式架构(Information Silo Architecture)
》》》无交互
信息烟囱又名信息孤岛(Information Island)

2. 微内核架构(Microkernel Architecture)
》》》插件互不知,通信由内核
微内核架构也被称为插件式架构(Plug-in Architecture)
适合桌面应用程序
它假设系统中各个插件模块之间是互不认识,不可预知系统将安装哪些模块,因此这些插件可以访问内核中一些公共的资源,但不会直接交互。
在这里插入图片描述

3. 事件驱动架构(Event-Driven Architecture)
》》》管道接收,放入管道
这时,SOA来了
在这里插入图片描述

4. 面向服务的架构(Service Oriented Architecture,SOA)
服务之间的松散耦合、注册、发现、治理,隔离、编排,等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 更具体
有清晰软件设计的指导原则,例如服务的封装性、自治、松耦合、可重用、可组合、无状态,等等;
+ SOAP 作为远程调用的协议,依靠 SOAP 协议族(WSDL、UDDI 和一大票 WS-*协议)来完成服务的发布、发现和治理;
+ 企业服务总线(Enterprise Service Bus,ESB)的消息管道来实现各个子系统之间的通信交互,令各服务间在 ESB 调度下无须相互依赖却能相互通信,
既带来了服务松耦合的好处,也为以后可以进一步实施业务流程编排(Business Process Management,BPM)提供了基础;
+ 服务数据对象(Service Data Object,SDO)来访问和表示数据,
+ 服务组件架构(Service Component Architecture,SCA)来定义服务封装的形式和服务运行的容器,
+ ...等等。在这一整套成体系可以互相精密协作的技术组件支持下,SOA 可以算是成功地解决了分布式环境下出现的主要技术问题。

# 更系统
+ 如何挖掘需求、
+ 如何将需求分解为业务能力、
+ 如何编排已有服务、
+ 如何开发测试部署新的功能,
+ ...等等

微服务时代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 微服务
+ 微服务是一种软件开发技术,是一种 SOA 的变体形式。
+ 专注单一职责
+ 与语言无关的细粒度web服务

# 微服务
+ 多个小型服务组合 => 单个应用
+ 各个服务运行不同进程
不同语言:Java、Php、Go、...
不同数据存储:MySQL、PGSQL、Oracle、...
+ 轻量通信、自动化部署

# 九个核心
1. 围绕业务能力构建
+ 团队 -> 产品
有怎样结构、规模、能力的团队,就会产生出对应结构、规模、能力的产品
+ 跨团队 -> 成本
管理、沟通、工作安排上都有更高昂的成本
2. 分散治理
+ 异构 => 可 Java、Python、Go、...
通常会有统一的主流语言,乃至统一的技术栈或专有的技术平台
3. 服务自治
4. 产品化
软件研发视作一种持续改进、提升的过程。
开发者不仅应该知道软件如何开发,还应该知道它如何运作,用户如何反馈,乃至售后支持工作是怎样进行的。
5. 数据去中心化
中心化的存储天生就更容易避免一致性问题
服务独立性,数据一致性 => 两害相权取其轻(鱼与熊掌)
6. 强终端弱管道
额外通信能力 => Endpoint
微服务提倡 RESTful 风格的通信
7. 容错性设计
服务会出错
故障检测: 出错隔离,恢复重新联通。
可靠系统完全可能由会出错的服务组成,这是微服务最大的价值所在
8. 演进式设计
服务会被报废淘汰
9. 基础设施自动化
减少了构建、发布、运维工作的复杂性

# 微服务 与 SOA
微服务不是 SOA

SOA => 规范标准
微服务 => 实践标准

# 微服务的自由
自由是一把双刃剑。
构建服务不约束,不承担分布式责任。

好 => 无SOA复杂标准
需要解决什么问题,就引入什么工具
坏 => 架构者的决策平衡
对架构能力要求已提升到史无前例的程度
知识面不足
不清楚其中利弊
选择困难症

后微服务时代

Docker、Kubernetes、云原生(Cloud Native)

为什么软件,硬件不可?

1
2
3
4
5
6
7
8
9
10
11
如果不局限于采用软件的方式,这些问题几乎都有对应的硬件解决方案。
+ 伸缩扩容:通常会购买新的服务器,部署若干副本实例来分担压力;
+ 负载均衡:通常会布置负载均衡器,选择恰当的均衡算法来分流;
+ 传输安全:通常会布置 TLS 传输链路,配置好 CA 证书以保证通信不被窃听篡改;
+ 服务发现:通常会设置 DNS 服务器,让服务访问依赖稳定的记录名而不是易变的 IP 地址;
+ ...等等。
软做硬不做,硬件不灵活。
由硬件构成的基础设施,跟不上由软件构成的应用服务的灵活性的无奈之举。

早期的容器:快速启动的服务运行环境,目的是方便程序的分发部署。
单个应用进行封装的容器并未真正参与到分布式问题的解决之中。

K8S 的胜利

1
2
3
4
5
6
7
8
9
10
通过虚拟化基础设施去解决分布式架构问题的开端,应该要从 2017 年 Kubernetes 赢得容器战争的胜利开始算起。
2017 年是容器生态发展历史中具有里程碑意义的一年。
+ 在这一年,长期作为 Docker 竞争对手的RKT 容器一派的领导者 CoreOS 宣布放弃自己的容器管理系统 Fleet,未来将会把所有容器管理的功能移至 Kubernetes 之上去实现。
+ 在这一年,容器管理领域的独角兽 Rancher Labs 宣布放弃其内置了数年的容器管理系统 Cattle,提出了“All-in-Kubernetes”战略,把 1.x 版本时就能够支持多种容器编排系统的管理工具 Rancher,从 2.0 版本开始“反向升级”为完全绑定于 Kubernetes 这单一种系统。
看得我热血沸腾,所有都上 k8s --->--->--->--->--->--->--->--->--->--->--->--->--->--->--->↑↑↑↑↑↑↑↑↑
+ 在这一年,Kubernetes 的主要竞争者 Apache Mesos 在 9 月正式宣布了“Kubernetes on Mesos”集成计划,由竞争关系转为对 Kubernetes 提供支持,使其能够与 Mesos 的其他一级框架(如HDFS、Spark 和Chronos等)进行集群资源动态共享、分配与隔离。
+ 在这一年,Kubernetes 的最大竞争者 Docker Swarm 的母公司 Docker,终于在 10 月被迫宣布 Docker 要同时支持 Swarm 与 Kubernetes 两套容器管理系统,也即在事实上承认了 Kubernetes 的统治地位。

这场已经持续了三、四年时间,以 Docker Swarm、Apache Mesos 与 Kubernetes 为主要竞争者的“容器编排战争”终于有了明确的结果,Kubernetes 登基加冕是容器发展中一个时代的终章,也将是软件架构发展下一个纪元的开端。
Kubernetes 登基加冕

Kubernetes 与 Spring Cloud

1
2
3
4
5
6
7
8
9
10
11
12
            Kubernetes(硬)               Spring Cloud(软)
----------------------------------------------------------------
弹性伸缩 Autoscaling N/A
服务发现 KubeDNS / CoreDNS Spring Cloud Eureka
配置中心 ConfigMap / Secret Spring Cloud Config
服务网关 Ingress Controller Spring Cloud Zuul
负载均衡 Load Balancer Spring Cloud Ribbon
服务安全 RBAC API Spring Cloud Security
跟踪监控 Metrics API / Dashboard Spring Cloud Turbine
降级熔断 N/A Spring Cloud Hystrix

软、硬一体 => 云原生

K8S 的不完美

1
2
3
4
5
6
7
8
9
+ 单个服务难管控
基础设施是针对整个容器来管理的,粒度相对粗旷,只能到容器层面,对单个远程服务就很难有效管控。
+ 细化管理比较难
类似的情况不仅仅在断路器上出现,服务的监控、认证、授权、安全、负载均衡等都有可能面临细化管理的需求。
+ DNS流量控制
例如服务调用时的负载均衡,往往需要根据流量特征,调整负载均衡的层次、算法,等等,而 DNS 尽管能实现一定程度的负载均衡,但通常并不能满足这些额外的需求。

举个例子,微服务 A 调用了微服务 B 的两个服务,称为 B1和 B2,假设 B1表现正常但 B2出现了持续的 500 错,那在达到一定阈值之后就应该对 B2进行熔断,以避免产生雪崩效应。
如果仅在基础设施层面来处理,这会遇到一个两难问题,切断 A 到 B 的网络通路则会影响到 B1的正常调用,不切断的话则持续受 B2的错误影响。

在这里插入图片描述

虚拟化基础设施的第二次进化

1
2
3
4
5
6
7
8
“服务网格”(Service Mesh)的“边车代理模式”(Sidecar Proxy)
所谓的“边车”是一种带垮斗的三轮摩托,我小时候还算常见,现在基本就只在影视剧中才会看到了。

这个场景里指的具体含义是由系统自动在服务容器(通常是指 Kubernetes 的 Pod)中注入一个通信代理服务器,
相当于那个挎斗,以类似网络安全里中间人攻击的方式进行流量劫持,在应用毫无感知的情况下,悄然接管应用所有对外通信。

这个代理除了实现正常的服务间通信外(称为数据平面通信),还接收来自控制器的指令(称为控制平面通信),
根据控制平面中的配置,对数据平面通信的内容进行分析处理,以实现熔断、认证、度量、监控、负载均衡等各种附加功能。

在这里插入图片描述

无服务时代

无服务 => 无服务器(云)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 云计算平台
2014年 => 亚马逊
商业化无服务应用,并在后续的几年里逐步得到开发者认可,发展成目前世界上最大的无服务的运行平台;
2018年 => 阿里云、腾讯云、...

# 论文:云计算概念刚提出的早期,UC Berkeley 大学
2009年 => 云计算 => 预言的云计算的价值、演进和普及在过去的十年里,得到验证。
2019年 => 无服务 => 再次预言未来 “无服务将会发展成为未来云计算的主要形式”

# 无服务的 两大卖点
后端设施
是指数据库、消息队列、日志、存储,等等这一类用于支撑业务逻辑运行,但本身无业务含义的技术组件,这些后端设施都运行在云中,
无服务中称其为“后端即服务”(Backend as a Service,BaaS)。
函数
是指业务逻辑代码,这里函数的概念与粒度,都已经很接近于程序编码角度的函数了,
其区别是无服务中的函数运行在云端,不必考虑算力问题,不必考虑容量规划(从技术角度可以不考虑,从计费的角度你的钱包够不够用还是要掂量一下的),
无服务中称其为“函数即服务”(Function as a Service,FaaS)。

# 无服务的 愿景
无服务的愿景是让开发者只需要纯粹地关注业务,不需要考虑技术组件,后端的技术组件是现成的,可以直接取用,没有采购、版权和选型的烦恼;
不需要考虑如何部署,部署过程完全是托管到云端的,工作由云端自动完成;不需要考虑算力,有整个数据中心支撑,算力可以认为是无限的;
也不需要操心运维,维护系统持续平稳运行是云计算服务商的责任而不再是开发者的责任。

在 UC Berkeley 的论文中,把无服务架构下开发者不再关心这些技术层面的细节,类比成当年软件开发从汇编语言踏进高级语言的发展过程,
开发者可以不去关注寄存器、信号、中断等与机器底层相关的细节,从而令生产力得到极大地解放。

# 无服务的 应用场景
符合
+ 不需要交互的离线大规模计算,
+ 多数 Web 资讯类网站、小程序、公共 API 服务、移动应用服务端等都契合于无服务架构所擅长的短链接、无状态、适合事件驱动的交互形式;
不符合
+ 信息管理系统、网络游戏等应用,
+ 所有具有业务逻辑复杂,依赖服务端状态,响应速度要求较高,需要长链接等这些特征的应用,至少目前是相对并不适合的。

# 无服务的 按量计费
这是因为无服务天生 “无限算力” 的假设决定了它必须要按使用量(函数运算的时间和占用的内存)计费以控制消耗算力的规模,
因而函数不会一直以活动状态常驻服务器,请求到了才会开始运行,这导致了函数不便依赖服务端状态,也导致了函数会有冷启动时间,
响应的性能不可能太好(目前无服务的冷启动过程大概是在数十到百毫秒级别,对于 Java 这类启动性能差的应用,甚至能到接近秒的级别)。

# 微服务 与 无服务
微服务架构是分布式系统这条路当前所能做到的极致,
那无服务架构,也许就是 “不分布式” 的云端系统这条路的起点。

虽然在顺序上笔者将 “无服务” 安排到了 “微服务” 和 “云原生” 时代之后,但它们两者并没有继承替代关系,
强调这点是为了避免有读者从两者的名称与安排的顺序中产生 “无服务就会比微服务更加先进” 的错误想法。

笔者相信软件开发的未来不会只存在某一种“最先进的”架构风格,多种具针对性的架构风格同时并存,是软件产业更有生命力的形态。

看似不远,实则路还很长。图灵

We can only see a short distance ahead, but we can see plenty there that needs to be done.
尽管目光所及之处,只是不远的前方,即使如此,依然可以看到那里有许多值得去完成的工作在等待我们。
—— Alan Turing,Computing Machinery and Intelligence,1950

访问远程服务

1
2
3
4
# 使用过,但是没有正确理解
远程服务将计算机程序的工作范围从单机扩展到网络,从本地延伸至远程,是构建分布式系统的首要基础。而远程服务又不仅仅是为了分布式系统服务的,
在网络时代,浏览器、移动设备、桌面应用和服务端的程序,普遍都有跟其他设备交互的需求,
所以今天已经很难找到没有开发和使用过远程服务的程序员了,但是没有正确理解远程服务的程序员却仍比比皆是。

远程服务调用

远程服务调用(Remote Procedure Call,RPC)

远程服务调用(Remote Procedure Call,RPC)在计算机科学中已经存在了超过四十年时间,但在今天仍然可以在各种论坛、技术网站上时常遇见“什么是 RPC?”、“如何评价某某 RPC 技术?”、“RPC 更好还是 REST 更好?”之类的问题,仍然“每天”都有新的不同形状的 RPC 轮子被发明制造出来,仍然有层出不穷的文章去比对 Google gRPC、Facebook Thrift 等各家的 RPC 组件库的优劣。

像计算机科学这种知识快速更迭的领域,一项四十岁高龄的技术能有如此关注度,可算是相当稀罕的现象,这一方面是由于微服务风潮带来的热度,另外一方面,也不得不承认,确实有不少开发者对 RPC 本身解决什么问题、如何解决这些问题、为什么要这样解决都或多或少存在认知模糊。

进程间通信

进程间通信(Inter-Process Communication,IPC)
【日期标记】2022-07-29 14:47:55 以上同步完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# RPC 最初目的
让计算机能够跟调用本地方法一样去调用远程方法。

# 调用本地方法 => 同一进程
// Caller : 调用者,代码里的main()
// Callee : 被调用者,代码里的println()
// Call Site : 调用点,即发生方法调用的指令流位置
// Parameter : 参数,由Caller传递给Callee的数据,即“hello world”
// Retval : 返回值,由Callee传递给Caller的数据。以下代码中如果方法能够正常结束,它是void,如果方法异常完成,它是对应的异常
public static void main(String[] args) {
System.out.println("hello world");
}

在完全不考虑编译器优化的前提下,程序运行至调用println()方法输出hello world这行时,计算机(物理机或者虚拟机)要完成以下几项工作。
1. 传递方法参数:将字符串 "hello world" 的引用地址压栈。
2. 确定方法版本:根据println()方法的签名,确定其执行版本。
这其实并不是一个简单的过程,不论是编译时静态解析也好,是运行时动态分派也好,
总之必须根据某些语言规范中明确定义原则,找到明确的Callee,“明确”是指唯一的一个Callee,或者有严格优先级的多个Callee,例如不同的重载版本。
3. 执行被调方法:从栈中弹出Parameter的值或引用,以此为输入,执行Callee内部的逻辑;这里我们只关心方法如何调用的,不关心方法内部具体是如何执行的。
4. 返回执行结果:将Callee的执行结果压栈,并将程序的指令流恢复到Call Site的下一条指令,继续向下执行。

# 所面临的问题 => 不同进程
我们再来考虑如果println()方法不在当前进程的内存地址空间中,会发生什么问题。
不难想到,此时至少面临两个直接的障碍:
1. 【方法入参、返回值】
首先,第一步和第四步所做的传递参数、传回结果都依赖于栈内存的帮助,
如果Caller与Callee分属不同的进程,就不会拥有相同的栈内存,将参数在Caller进程的内存中压栈,对于 Callee 进程的执行毫无意义。
2. 【方法版本定位】
其次,第二步的方法版本选择依赖于语言规则的定义,
如果Caller与Callee不是同一种语言实现的程序,方法版本选择就将是一项模糊的不可知行为。

# IPC 通信方案
先来解决 1. 两个进程之间如何交换数据的问题,这件事情在计算机科学中被称为“进程间通信”(Inter-Process Communication,IPC)。
可以考虑的办法有以下几种:
1. 管道(Pipe)或者具名管道(Named Pipe):管道类似于两个进程间的桥梁,可通过管道在进程间传递少量的字符流或字节流。
普通管道只用于有亲缘关系进程(由一个进程启动的另外一个进程)间的通信,具名管道摆脱了普通管道没有名字的限制,除具有管道所有的功能外,它还允许无亲缘关系进程间的通信。
管道典型的应用就是命令行中的|操作符,例如:
`ps -ef | grep java` ps与grep都有独立的进程,以上命令就通过管道操作符|将ps命令的标准输出连接到grep命令的标准输入上。

2. 信号(Signal):信号用于通知目标进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程自身。信号的典型应用是kill命令,例如:
`kill -9 pid` 以上就是由 Shell 进程向指定 PID 的进程发送 SIGKILL 信号。

3. 信号量(Semaphore):信号量用于两个进程之间同步协作手段,它相当于操作系统提供的一个特殊变量,程序可以在上面进行wait()和notify()操作。

4. (单机)消息队列(Message Queue):以上三种方式只适合传递传递少量信息,POSIX 标准中定义了消息队列用于进程间数据量较多的通信。
进程可以向队列添加消息,被赋予读权限的进程则可以从队列消费消息。
消息队列克服了信号承载信息量少,管道只能用于无格式字节流以及缓冲区大小受限等缺点,但实时性相对受限。

5. (单机)共享内存(Shared Memory):允许多个进程访问同一块公共的内存空间,这是效率最高的进程间通信形式。
原本每个进程的内存地址空间都是相互隔离的,但操作系统提供了让进程主动创建、映射、分离、控制某一块内存的程序接口。
当一块内存被多进程共享时,各个进程往往会与其它通信机制,例如信号量结合使用,来达到进程间同步及互斥的协调操作。

6. (单机、多机)套接字接口(Socket):消息队列和共享内存只适合单机多进程间的通信,套接字接口是更为普适的进程间通信机制,可用于不同机器之间的进程通信。
套接字(Socket)起初是由 UNIX 系统的 BSD 分支开发出来的,现在已经移植到所有主流的操作系统上。
出于效率考虑,当仅限于本机进程间通信时,套接字接口是被优化过的,不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,
只是简单地将应用层数据从一个进程拷贝到另一个进程,这种进程间通信方式有个专名的名称:UNIX Domain Socket,又叫做 IPC Socket。

通信的成本

注意:基于套接字接口的通信方式(IPC Socket),它不仅适用于本地相同机器的不同进程间通信,由于 Socket 是网络栈的统一接口,它也理所当然地能支持基于网络的跨机器的进程间通信。

此外,这样做有一个看起来无比诱人的好处,由于 Socket 是各个操作系统都有提供的标准接口,完全有可能把远程方法调用的通信细节隐藏在操作系统底层,从应用层面上看来可以做到远程调用与本地的进程间通信在编码上完全一致。
事实上,在原始分布式时代的早期确实是奔着这个目标去做的,但这种透明的调用形式却反而造成了程序员误以为通信是无成本的假象,因而被滥用以致于显著降低了分布式系统的性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# IPC ≠ RPC
1987 年,在“透明的 RPC 调用”一度成为主流范式的时候,
Andrew Tanenbaum 教授曾发表了论文《A Critique of The Remote Procedure Call Paradigm》,
对这种透明的 RPC 范式提出了一系列质问:

+ 两个进程通信,谁作为服务端,谁作为客户端?
+ 怎样进行异常处理?异常该如何让调用者获知?
+ 服务端出现多线程竞争之后怎么办?
+ 如何提高网络利用的效率,例如连接是否可被多个请求复用以减少开销?是否支持多播?
+ 参数、返回值如何表示?应该有怎样的字节序?
+ 如何保证网络的可靠性?例如调用期间某个链接忽然断开了怎么办?
+ 发送的请求服务端收不到回复该怎么办?
+ ……

# 分布式8宗罪
论文的中心观点是:本地调用与远程调用当做一样处理,这是犯了方向性的错误,把系统间的调用做成透明,反而会增加程序员工作的复杂度。
此后几年,关于 RPC 应该如何发展、如何实现的论文层出不穷,透明通信的支持者有之,反对者有之,冷静分析者有之,狂热唾骂者有之,但历史逐渐证明 Andrew Tanenbaum 的预言是正确的。
最终,到 1994 年至 1997 年间,由 ACM 和 Sun 院士Peter Deutsch、套接字接口发明者Bill Joy、Java 之父James Gosling等一众在 Sun Microsystems 工作的大佬们共同总结了
通过网络进行分布式运算的八宗罪(8 Fallacies of Distributed Computing):

1. The network is reliable —— 网络是可靠的。
2. Latency is zero —— 延迟是不存在的。
3. Bandwidth is infinite —— 带宽是无限的。
4. The network is secure —— 网络是安全的。
5. Topology doesn‘t change —— 拓扑结构是一成不变的。
6. There is one administrator —— 总会有一个管理员。
7. Transport cost is zero —— 不必考虑传输成本。
8. The network is homogeneous —— 网络是同质化的。


# 首次提出 RPC 的定义 => 施乐公司
远程服务调用是指位于互不重合的内存地址空间中的两个程序,在语言层面上,以同步的方式使用带宽有限的信道来传输程序控制信息。

三个基本问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 如何表示数据 => 入参、返回值(序列化、反序列化)
这里数据包括了传递给方法的参数,以及方法执行后的返回值。
无论是将参数传递给另外一个进程,还是从另外一个进程中取回执行结果,都涉及到它们应该如何表示。
进程内的方法调用,使用程序语言预置的和程序员自定义的数据类型,就很容易解决数据表示问题,远程方法调用则完全可能面临交互双方各自使用不同程序语言的情况;
即使只支持一种程序语言的 RPC 协议,在不同硬件指令集、不同操作系统下,同样的数据类型也完全可能有不一样表现细节,例如数据宽度、字节序的差异等等。
有效的做法是将交互双方所涉及的数据转换为某种事先约定好的中立数据流格式来进行传输,将数据流转换回不同语言中对应的数据类型来进行使用,这个过程就是序列化与反序列化。
每种 RPC 协议都应该要有对应的序列化协议,例如:
+ ONC RPC 的External Data Representation (XDR)
+ CORBA 的Common Data Representation(CDR)
+ Java RMI 的Java Object Serialization Stream Protocol
+ gRPC 的Protocol Buffers
+ Web Service 的XML Serialization
+ 众多轻量级 RPC 支持的JSON Serialization
+ ……

# 如何传递数据 => tcp、udp(HTTP)
准确地说,是指如何通过网络,在两个服务的 Endpoint 之间相互操作、交换数据。
这里“交换数据”通常指的是应用层协议,实际传输一般是基于标准的 TCP、UDP 等标准的传输层协议来完成的。
两个服务交互不是只扔个序列化数据流来表示参数和结果就行的,许多在此之外信息,例如异常、超时、安全、认证、授权、事务,等等,都可能产生双方需要交换信息的需求。
在计算机科学中,专门有一个名称“Wire Protocol”来用于表示这种两个 Endpoint 之间交换这类数据的行为,常见的 Wire Protocol 有:
+ Java RMI 的Java Remote Message Protocol(JRMP,也支持RMI-IIOP)
+ CORBA 的Internet Inter ORB Protocol(IIOP,是 GIOP 协议在 IP 协议上的实现版本)
+ DDS 的Real Time Publish Subscribe Protocol(RTPS)
+ Web Service 的Simple Object Access Protocol(SOAP)
+ 如果要求足够简单,双方都是 HTTP Endpoint,直接使用 HTTP 协议也是可以的(如 JSON-RPC)
+ ……

# 如何确定方法
这在本地方法调用中并不是太大的问题,编译器或者解释器会根据语言规范,将调用的方法签名转换为进程空间中子过程入口位置的指针。
不过一旦要考虑不同语言,事情又立刻麻烦起来,每门语言的方法签名都可能有所差别,所以“如何表示同一个方法”,“如何找到对应的方法”还是得弄个跨语言的统一的标准才行。
这个标准做起来可以非常简单,例如直接给程序的每个方法都规定一个唯一的、在任何机器上都绝不重复的编号,调用时压根不管它什么方法签名是如何定义的,直接传这个编号就能找到对应的方法。
这种听起既粗鲁又寒碜的办法,还真的就是 DCE/RPC 当初准备的解决方案。
虽然最终 DCE 还是弄出了一套语言无关的接口描述语言(Interface Description Language,IDL),成为此后许多 RPC 参考或依赖的基础(如 CORBA 的 OMG IDL),
但那个唯一的绝不重复的编码方案UUID(Universally Unique Identifier)却也被保留且广为流传开来,今天已广泛应用于程序开发的方方面面。
类似地,用于表示方法的协议还有:
+ Android 的Android Interface Definition Language(AIDL)
+ CORBA 的OMG Interface Definition Language(OMG IDL)
+ Web Service 的Web Service Description Language(WSDL)
+ JSON-RPC 的JSON Web Service Protocol(JSON-WSP)
+ ……

统一的 RPC

1
2
3
DCE/RPC、ONE RPC => C语言设计,不是面向对象
CORBA => 复杂
Web Service 性能差 => XML

分裂的 RPC

无完美 RPC

现在,已经相继出现过 RMI(Sun/Oracle)、Thrift(Facebook/Apache)、Dubbo(阿里巴巴/Apache)、gRPC(Google)、Motan1/2(新浪)、Finagle(Twitter)、brpc(百度/Apache)、.NET Remoting(微软)、Arvo(Hadoop)、JSON-RPC 2.0(公开规范,JSON-RPC 工作组)……等等难以穷举的协议和框架。

这些 RPC 功能、特点不尽相同,有的是某种语言私有,有的能支持跨越多门语言,有的运行在应用层 HTTP 协议之上,有的能直接运行于传输层 TCP/UDP 协议之上,但肯定不存在哪一款是“最完美的 RPC”。今时今日,任何一款具有生命力的 RPC 框架,都不再去追求大而全的“完美”,而是有自己的针对性特点作为主要的发展方向,举例分析如下。

1
2
3
4
5
6
7
8
9
10
11
12
# => 面向对象
不满足于 RPC 将面向过程的编码方式带到分布式,希望在分布式系统中也能够进行跨进程的面向对象编程,代表为 RMI、.NET Remoting,
之前的 CORBA 和 DCOM 也可以归入这类,这条线有一个别名叫做分布式对象(Distributed Object)。

# => 性能
代表为 gRPC 和 Thrift。决定 RPC 性能的主要就两个因素:序列化效率和信息密度。序列化效率很好理解,序列化输出结果的容量越小,速度越快,效率自然越高;
信息密度则取决于协议中有效荷载(Payload)所占总传输数据的比例大小,使用传输协议的层次越高,信息密度就越低,SOAP 使用 XML 拙劣的性能表现就是前车之鉴。
gRPC 和 Thrift 都有自己优秀的专有序列化器,而传输协议方面,gRPC 是基于 HTTP/2 的,支持多路复用和 Header 压缩,Thrift 则直接基于传输层的 TCP 协议来实现,省去了额外应用层协议的开销。

# => 简化
代表为 JSON-RPC,说要选功能最强、速度最快的 RPC 可能会很有争议,但选功能弱的、速度慢的,JSON-RPC 肯定会候选人中之一。
牺牲了功能和效率,换来的是协议的简单轻便,接口与格式都更为通用,尤其适合用于 Web 浏览器这类一般不会有额外协议支持、额外客户端支持的应用场合。

RPC 框架有明显的朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再追求独立地解决 RPC 的全部三个问题(表示数据、传递数据、表示方法),而是将一部分功能设计成扩展点,让用户自己去选择。框架聚焦于提供核心的、更高层次的能力,例如提供负载均衡、服务注册、可观察性等方面的支持。

这一类框架的代表有 Facebook 的 Thrift 与阿里的 Dubbo。

  • 尤其是断更多年后重启的 Dubbo 表现得更为明显,它默认有自己的传输协议(Dubbo 协议),同时也支持其他协议;
  • 默认采用 Hessian 2 作为序列化器,如果你有 JSON 的需求,可以替换为 Fastjson,如果你对性能有更高的追求,可以替换为Kryo、FST、Protocol Buffers 等效率更好的序列化器,
  • 如果你不想依赖其他组件库,直接使用 JDK 自带的序列化器也是可以的。

这种设计在一定程度上缓和了 RPC 框架必须取舍,难以完美的缺憾。

REST 设计风格

1
2
3
4
5
6
7
8
# REST 只能说是风格而不是规范、协议
REST
风格 √
规范、协议 ×

# REST 与 RPC
REST 面向资源
RPC 面向过程

理解 REST

REST 源于 Roy Thomas Fielding 在 2000 年发表的博士论文:《Architectural Styles and the Design of Network-based Software Architectures

REST,即“表征状态转移”的缩写。
“REST”(Representational State Transfer)实际上是“HTT”(Hypertext Transfer)的进一步抽象,两者就如同接口与实现类的关系一般。

HTTP 中使用的“超文本”(Hypertext)一词是美国社会学家 Theodor Holm Nelson 在 1967 年于《Brief Words on the Hypertext》一文里提出的,下面引用的是他本人在 1992 年修正后的定义:

Hypertext
By now the word “hypertext” has become generally accepted for branching and responding text, but the corresponding word “hypermedia”, meaning complexes of branching and responding graphics, movies and sound – as well as text – is much less used.
现在,”超文本 “一词已被普遍接受,它指的是能够进行分支判断和差异响应的文本,相应地, “超媒体 “一词指的是能够进行分支判断和差异响应的图形、电影和声音(也包括文本)的复合体。
—— Theodor Holm Nelson Literary Machines, 1992

  • 资源(Resource)
    例如你现在正在阅读一篇名为《REST 设计风格》的文章,这篇文章的内容本身(你可以将其理解为其蕴含的信息、数据)我们称之为“资源”。无论你是购买的书籍、是在浏览器看的网页、是打印出来看的文稿、是在电脑屏幕上阅读抑或是手机上浏览,尽管呈现的样子各不相同,但其中的信息是不变的,你所阅读的仍是同一份“资源”

  • 表征(Representation)
    当你通过电脑浏览器阅读此文章时,浏览器向服务端发出请求“我需要这个资源的 HTML 格式”,服务端向浏览器返回的这个 HTML 就被称之为“表征”,你可能通过其他方式拿到本文的 PDF、Markdown、RSS 等其他形式的版本,它们也同样是一个资源的多种表征。可见“表征”这个概念是指信息与用户交互时的表示形式,这与我们软件分层架构中常说的“表示层”(Presentation Layer)的语义其实是一致的。

  • 状态(State)
    当你读完了这篇文章,想看后面是什么内容时,你向服务器发出请求“给我下一篇文章”。但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”才能正确回应,这类在特定语境中才能产生的上下文信息即被称为“状态”。我们所说的有状态(Stateful)抑或是无状态(Stateless),都是只相对于服务端来说的,服务器要完成“取下一篇”的请求,要么自己记住用户的状态:这个用户现在阅读的是哪一篇文章,这称为有状态;要么客户端来记住状态,在请求的时候明确告诉服务器:我正在阅读某某文章,现在要读它的下一篇,这称为无状态。

  • 转移(Transfer)
    无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供,因为只有服务端拥有该资源及其表征形式。服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移”

  • 统一接口(Uniform Interface)
    上面说的服务器“通过某种方式”让表征状态发生转移,具体是什么方式?如果你真的是用浏览器阅读本文电子版的话,请把本文滚动到结尾处,右下角有下一篇文章的 URI 超链接地址,这是服务端渲染这篇文章时就预置好的,点击它让页面跳转到下一篇,就是所谓“某种方式”的其中一种方式。任何人都不会对点击超链接网页会出现跳转感到奇怪,但你细想一下,URI 的含义是统一资源标识符,是一个名词,如何能表达出“转移”动作的含义呢?答案是 HTTP 协议中已经提前约定好了一套“统一接口”,它包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS 七种基本操作,任何一个支持 HTTP 协议的服务器都会遵守这套规定,对特定的 URI 采取这些操作,服务器就会触发相应的表征状态转移。

  • 超文本驱动(Hypertext Driven)
    尽管表征状态转移是由浏览器主动向服务器发出请求所引发的,该请求导致了“在浏览器屏幕上显示出了下一篇文章的内容”这个结果的出现。但是,你我都清楚这不可能真的是浏览器的主动意图,浏览器是根据用户输入的 URI 地址来找到网站首页,服务器给予的首页超文本内容后,浏览器再通过超文本内部的链接来导航到了这篇文章,阅读结束时,也是通过超文本内部的链接来再导航到下一篇。浏览器作为所有网站的通用的客户端,任何网站的导航(状态转移)行为都不可能是预置于浏览器代码之中,而是由服务器发出的请求响应信息(超文本)来驱动的。这点与其他带有客户端的软件有十分本质的区别,在那些软件中,业务逻辑往往是预置于程序代码之中的,有专门的页面控制器(无论在服务端还是在客户端中)来驱动页面的状态转移。

  • 自描述消息(Self-Descriptive Messages)
    由于资源的表征可能存在多种不同形态,在消息中应当有明确的信息来告知客户端该消息的类型以及应如何处理这条消息。一种被广泛采用的自描述方法是在名为“Content-Type”的 HTTP Header 中标识出互联网媒体类型(MIME type),例如“Content-Type : application/json; charset=utf-8”,则说明该资源会以 JSON 的格式来返回,请使用 UTF-8 字符集进行处理。

RESTful 的系统

Fielding 认为,一套理想的、完全满足 REST 风格的系统应该满足以下六大原则。

  1. 服务端与客户端分离(Client-Server) => 前后端分离
    将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性,这一点正越来越受到广大开发者所认可,以前完全基于服务端控制和渲染(如 JSF 这类)框架实际用户已甚少,而在服务端进行界面控制(Controller),通过服务端或者客户端的模版渲染引擎来进行界面渲染的框架(如 Struts、SpringMVC 这类)也受到了颇大的冲击。这一点主要推动力量与 REST 可能关系并不大,前端技术(从 ES 规范,到语言实现,到前端框架等)的近年来的高速发展,使得前端表达能力大幅度加强才是真正的幕后推手。由于前端的日渐强势,现在还流行起由前端代码反过来驱动服务端进行渲染的 SSR(Server-Side Rendering)技术,在 Serverless、SEO 等场景中已经占领了一块领地。

  2. 无状态(Stateless)
    无状态是 REST 的一条核心原则,部分开发者在做服务接口规划时,觉得 REST 风格的服务怎么设计都感觉别扭,很有可能的一种原因是在服务端持有着比较重的状态。REST 希望服务器不要去负责维护状态,每一次从客户端发送的请求中,应包括所有的必要的上下文信息,会话信息也由客户端负责保存维护,服务端依据客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁。客户端承担状态维护职责以后,会产生一些新的问题,例如身份认证、授权等可信问题,它们都应有针对性的解决方案(这部分内容可参见“安全架构”的内容)。
    、、、但必须承认的现状是,目前大多数的系统都达不到这个要求,往往越复杂、越大型的系统越是如此。服务端无状态可以在分布式计算中获得非常高价值的好处,但大型系统的上下文状态数量完全可能膨胀到让客户端在每次请求时提供变得不切实际的程度,在服务端的内存、会话、数据库或者缓存等地方持有一定的状态成为一种是事实上存在,并将长期存在、被广泛使用的主流的方案(Kafka => ZK)

  3. 可缓存(Cacheability)
    无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。“降低网络性”的通俗解释是某个功能如果使用有状态的设计只需要一次(或少量)请求就能完成,使用无状态的设计则可能会需要多次请求,或者在请求中带有额外冗余的信息。为了缓解这个矛盾,REST 希望软件系统能够如同万维网一样,允许客户端和中间的通讯传递者(例如代理)将部分服务端的应答缓存起来。当然,为了缓存能够正确地运作,服务端的应答中必须明确地或者间接地表明本身是否可以进行缓存、可以缓存多长时间,以避免客户端在将来进行请求的时候得到过时的数据。运作良好的缓存机制可以减少客户端、服务器之间的交互,甚至有些场景中可以完全避免交互,这就进一步提高了性能。

  4. 分层系统(Layered System) => CDN
    这里所指的并不是表示层、服务层、持久层这种意义上的分层。而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。该原则的典型的应用是内容分发网络(Content Distribution Network,CDN)。如果你是通过网站浏览到这篇文章的话,你所发出的请求一般(假设你在中国国境内的话)并不是直接访问位于 GitHub Pages 的源服务器,而是访问了位于国内的 CDN 服务器,但作为用户,你完全不需要感知到这一点。我们将在“透明多级分流系统”中讨论如何构建自动的、可缓存的分层系统。

  5. 统一接口(Uniform Interface) => GET、POST、…
    、、、这是 REST 的另一条核心原则,REST 希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。这条原则你可以类比计算机中对文件管理的操作来理解,管理文件可能会进行创建、修改、删除、移动等操作,这些操作数量是可数的,而且对所有文件都是固定的、统一的。如果面向资源来设计系统,同样会具有类似的操作特征,由于 REST 并没有设计新的协议,所以这些操作都借用了 HTTP 协议中固有的操作命令来完成。
    、、、统一接口也是 REST 最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更合适,这事情哪怕是很长时间里都不会有个定论,也许永远都没有。但是,已经有一个基本清晰的结论是:面向资源编程的抽象程度通常更高。抽象程度高意味着坏处是往往距离人类的思维方式更远,而好处是往往通用程度会更好。用这样的语言去诠释 REST,大概本身就挺抽象的,笔者还是举个例子来说明:例如,几乎每个系统都有的登录和注销功能,如果你理解成登录对应于 login()服务,注销对应于 logout()服务这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是 PUT Session,注销是 DELETE Session,这样你只需要设计一种“Session 资源”即可满足需求,甚至以后对 Session 的其他需求,如查询登陆用户的信息,就是 GET Session 而已,其他操作如修改用户信息等都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处。
    、、、想要在架构设计中合理恰当地利用统一接口,Fielding 建议系统应能做到每次请求中都包含资源的 ID,所有操作均通过资源 ID 来进行;建议每个资源都应该是自描述的消息;建议通过超文本来驱动应用状态的转移。

  6. 按需代码(Code-On-Demand) => Server -> 代码 -> Client(执行并销毁)
    按需代码被 Fielding 列为一条可选原则。它是指任何按照客户端(例如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术,按需代码赋予了客户端无需事先知道所有来自服务端的信息应该如何处理、如何运行的宽容度。举个具体例子,以前的Java Applet技术,今天的WebAssembly等都属于典型的按需代码,蕴含着具体执行逻辑的代码是存放在服务端,只有当客户端请求了某个 Java Applet 之后,代码才会被传输并在客户端机器中运行,结束后通常也会随即在客户端中被销毁掉。将按需代码列为可选原则的原因并非是它特别难以达到,而更多是出于必要性和性价比的实际考虑。

RMM 成熟度

不足与争议

  • 面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑
    面向资源的编程思想与另外两种主流编程思想只是抽象问题时所处的立场不同,只有选择问题,没有高下之分:

    1
    2
    3
    4
    5
    6
    7
    8
    # 面向过程编程
    为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。

    # 面向对象编程
    为什么要将数据和行为统一起来、封装成对象?当然是为了符合现实世界的主流的交互方式。

    # 面向资源编程
    为什么要将数据(资源)作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络世界的主流的交互方式。
  • REST 与 HTTP 完全绑定,不适合应用于要求高性能传输的场景中
    笔者个人很大程度上赞同此观点,但并不认为这是 REST 的缺陷,锤子不能当扳手用并不是锤子的质量有问题。面向资源编程与协议无关,但是 REST(特指 Fielding 论文中所定义的 REST,而不是泛指面向资源的思想)的确依赖着 HTTP 协议的标准方法、状态码、协议头等各个方面。HTTP 并不是传输层协议,它是应用层协议,如果仅将 HTTP 当作传输是不恰当的(SOAP:再次感觉有被冒犯到)。对于需要直接控制传输,如二进制细节、编码形式、报文格式、连接方式等细节的场景中,REST 确实不合适,这些场景往往存在于服务集群的内部节点之间,这也是之前曾提及的,REST 和 RPC 尽管应用场景的确有所重合,但重合的范围有多大就是见仁见智的事情。

  • REST 不利于事务支持 => 看事务设计
    这个问题首先要看你怎么看待“事务(Transaction)”这个概念。如果“事务”指的是数据库那种的狭义的刚性 ACID 事务,那除非完全不持有状态,否则分布式系统本身与此就是有矛盾的(CAP 不可兼得),这是分布式的问题而不是 REST 的问题。如果“事务”是指通过服务协议或架构,在分布式服务中,获得对多个数据同时提交的统一协调能力(2PC/3PC),例如WS-AtomicTransaction、WS-Coordination这样的功能性协议,这 REST 确实不支持,假如你已经理解了这样做的代价,仍决定要这样做的话,Web Service 是比较好的选择。如果“事务”只是指希望保障数据的最终一致性,说明你已经放弃刚性事务了,这才是分布式系统中的正常交互方式,使用 REST 肯定不会有什么阻碍,谈不上“不利于”。当然,对此 REST 也并没有什么帮助,这完全取决于你系统的事务设计,我们会在事务处理中再详细讨论。

  • REST 没有传输可靠性支持 => 幂等性
    是的,并没有。在 HTTP 中你发送出去一个请求,通常会收到一个与之相对的响应,例如 HTTP/1.1 200 OK 或者 HTTP/1.1 404 Not Found 诸如此类的。但如果你没有收到任何响应,那就无法确定消息到底是没有发送出去,抑或是没有从服务端返回回来,这其中的关键差别是服务端到底是否被触发了某些处理?应对传输可靠性最简单粗暴的做法是把消息再重发一遍。这种简单处理能够成立的前提是服务应具有幂等性(Idempotency),即服务被重复执行多次的效果与执行一次是相等的。HTTP 协议要求 GET、PUT 和 DELETE 应具有幂等性,我们把 REST 服务映射到这些方法时,也应当保证幂等性。对于 POST 方法,曾经有过一些专门的提案(如POE,POST Once Exactly),但并未得到 IETF 的通过。对于 POST 的重复提交,浏览器会出现相应警告,如 Chrome 中“确认重新提交表单”的提示,对于服务端,就应该做预校验,如果发现可能重复,返回 HTTP/1.1 425 Too Early。另,Web Service 中有WS-ReliableMessaging功能协议用于支持消息可靠投递。类似的,由于 REST 没有采用额外的 Wire Protocol,所以除了事务、可靠传输这些功能以外,一定还可以在 WS-*协议中找到很多 REST 不支持的特性。

  • REST 缺乏对资源进行“部分”和“批量”的处理能力 => 面向资源的不完美
    这个观点笔者是认同的,这很可能是未来面向资源的思想和 API 设计风格的发展方向。REST 开创了面向资源的服务风格,却肯定仍并不完美。以 HTTP 协议为基础给 REST 带来了极大的便捷(不需要额外协议,不需要重复解决一堆基础网络问题,等等),但也是 HTTP 本身成了束缚 REST 的无形牢笼。这里仍通过具体例子来解释 REST 这方面的局限性:例如你仅仅想获得某个用户的姓名,RPC 风格中可以设计一个“getUsernameById”的服务,返回一个字符串,尽管这种服务的通用性实在称不上“设计”二字,但确实可以工作;而 REST 风格中你将向服务端请求整个用户对象,然后丢弃掉返回的结果中该用户除用户名外的其他属性,这便是一种“过度获取”(Overfetching)。REST 的应对手段是通过位于中间节点或客户端的缓存来缓解这种问题,但此缺陷的本质是由于 HTTP 协议完全没有对请求资源的结构化描述能力(但有非结构化的部分内容获取能力,即今天多用于断点续传的Range Header),所以返回资源的哪些内容、以什么数据类型返回等等,都不可能得到协议层面的支持,要做你就只能自己在 GET 方法的 Endpoint 上设计各种参数来实现。而另外一方面,与此相对的缺陷是对资源的批量操作的支持,有时候我们不得不为此而专门设计一些抽象的资源才能应对。例如你准备把某个用户的名字增加一个“VIP”前缀,提交一个 PUT 请求修改这个用户的名称即可,而你要给 1000 个用户加 VIP 时,如果真的去调用 1000 次 PUT,浏览器会回应你 HTTP/1.1 429 Too Many Requests,老板则会揍你一顿。此时,你就不得不先创建一个(如名为“VIP-Modify-Task”)任务资源,把 1000 个用户的 ID 交给这个任务,最后驱动任务进入执行状态。又例如你去网店买东西,下单、冻结库存、支付、加积分、扣减库存这一系列步骤会涉及到多个资源的变化,你可能面临不得不创建一种“事务”的抽象资源,或者用某种具体的资源(例如“结算单”)贯穿这个过程的始终,每次操作其他资源时都带着事务或者结算单的 ID(多个资源 => 事务控制)。HTTP 协议由于本身的无状态性,会相对不适应(并非不能够)处理这类业务场景。
    、、、目前,一种理论上较优秀的可以解决以上这几类问题的方案是GraphQL,这是由 Facebook 提出并开源的一种面向资源 API 的数据查询语言,如同 SQL 一样,挂了个“查询语言”的名字,但其实 CRUD 都有涉猎。比起依赖 HTTP 无协议的 REST,GraphQL 可以说是另一种“有协议”的、更彻底地面向资源的服务方式。然而凡事都有两面,离开了 HTTP,它又面临着几乎所有 RPC 框架所遇到的那个如何推广交互接口的问题。

事务处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 事务存在的意义
是为了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。

# A、I、D 是手段,C 是目的
# 前者是因,后者是果
按照数据库的经典理论,要达成【一致性】这个目标,需要【三方面】共同努力来保障。
+ 原子性(Atomic):在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
+ 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
+ 持久性(Durability):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

# 内部一致性、外部一致性
一个服务一个数据源,顺序执行,内部一致性
一个服务多个数据源,无序执行,外部一致性

# 编程问题 => 架构问题
外部一致性问题通常很难再使用 A、I、D 来解决,而外部一致性又是分布式系统中必然会遇到且必须要解决的问题。

本地事务

本地事务是最基础的一种事务解决方案,只适用于单服务单数据源场景。

1
2
3
4
5
# 本地事务
+ 有标准化的包装(JDBC 接口)。
+ 事务控制依赖底层数据源。如:开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与应用代码贴近的事务传播方式
# 分布式事务
主要靠应用代码实现。如 XA、TCC、SAGA 等

ARIES理论(Algorithms for Recovery and Isolation Exploiting Semantics,ARIES)“基于语义的恢复与隔离算法”。

ARIES 是现代数据库的基础理论,就算不能称所有的数据库都实现了 ARIES,至少也可以称现代的主流关系型数据库(Oracle、MS SQLServer、MySQL/InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响。

在 20 世纪 90 年代,IBM Almaden 研究院总结了研发原型数据库系统“IBM System R”的经验,发表了 ARIES 理论中最主要的三篇论文,

原子性和持久性

1
2
3
4
5
6
7
8
9
10
11
12
# 原子性
保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;

# 持久性
保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

# 崩溃
数据落盘才持久,内存意外就丢失。
=> 应用程序忽然崩溃,或者数据库、操作系统崩溃,机器断电等情况,我们将这些意外情况都统称为“崩溃”(Crash)。

# 落盘三状态:未写入undo、正在写doing、已写入done
实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子的,不仅有“写入”与“未写入”状态,还客观地存在着“正在写”的中间状态。

举个栗子
网购一本书需要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态。
由于写入存在中间状态,所以可能发生以下情形。

  1. 未提交事务,写入后崩溃
    程序还没修改完三个数据,但数据库已经将其中一个或两个数据的变动写入磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性

  2. 已提交事务,写入前崩溃
    程序已经修改完三个数据,但数据库还未将全部三个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性


1
2
3
4
5
6
# 想保证原子、持久 => 要解决 doing、crash
正因为 写入中间状态doing 与 崩溃crash 都不可能消除,所以要做额外保障措施,将内存中的数据写入磁盘,来保证原子性与持久性。

# 想解决 doing、crash => 采取保障措施(崩溃恢复)
由于 写入中间状态doing 与 崩溃crash 都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救措施,
这种数据恢复操作被称为“崩溃恢复”(Crash Recovery,也有资料称作 Failure Recovery 或 Transaction Recovery)。

“Commit Logging”提交日志 <=== 崩溃恢复方案
为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”(提交日志)。

1
2
Commit Record 事务记录已提交(未落盘)
End Record 持久化完成(真正的全部落盘)

额外补充: Shadow Paging
、、、通过日志实现事务的原子性和持久性是当今的主流方案,但并不是唯一的选择。除日志外,还有另外一种称为“Shadow Paging”(有中文资料翻译为“影子分页”)的事务实现机制,常用的轻量级数据库 SQLite Version 3 采用的事务机制就是 Shadow Paging。
、、、Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。Shadow Paging 实现事务要比 Commit Logging 更加简单,但涉及隔离性与并发锁时,Shadow Paging 实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多。

“Commit Logging” 是怎么保证原子、持久的 ???
、、、如果日志没有成功写入 Commit Record 就发生崩溃,那整个事务就是失败的,系统重启后会看到一部分没有 Commit Record 的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没好有发生过一样,这保证了原子性
、、、如果日志一旦成功写入 Commit Record,那整个事务就是成功的,即使真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性

Commit Logging 缺陷
、、、所有对数据的真实修改都必须发生在事务提交以后,即日志写入了 Commit Record 之后(必须写入 Commit Record 才能落盘,落盘完成再写入 End Record)。在此之前,即使磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是 Commit Logging 成立的前提,却对提升数据库的性能十分不利。
、、、为了解决这个问题,前面提到的 ARIES 理论终于可以登场。ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# FORCE:事务提交后,落盘完成;
# STEAL:事务提交前,可落盘;
Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,划分为 FORCE 和 STEAL 两类情况。
+ FORCE(刷盘):当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE。
(现实中绝大多数数据库采用的都是 NO-FORCE 策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。)
+ STEAL(写日志):在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。
(从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。)

# Commit Logging 允许 NO-FORCE,但不允许 STEAL。
(很好理解,因为 STEAL是指写入Commit Record 前就可落盘,这与 Commit Logging 是矛盾的,Commit Logging是写入Commit Record 后才可落盘。)
因为假如事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。

# Write-Ahead Logging 允许 NO-FORCE,也允许 STEAL。
它给出的解决办法是增加了另一种被称为 Undo Log 的日志类型,当变动数据写入磁盘前,必须先记录 Undo Log,注明修改了哪个位置的数据、从什么值改成什么值,等等。
以便在事务回滚或者崩溃恢复时根据 Undo Log 对提前写入的数据变动进行擦除。
Undo Log 现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名为 Redo Log,一般翻译为“重做日志”。

# WAL => 崩溃恢复三阶段
由于 Undo Log 的加入,Write-Ahead Logging 在崩溃恢复时会执行以下三个阶段的操作。

分析阶段(Analysis):找出未落盘的事务(未落盘的事务分两种,1:事务已提交 2:事务未提交)。
该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,
找出所有没有 End Record 的事务,组成待恢复的事务集合,这个集合至少会包括 Transaction Table 和 Dirty Page Table 两个组成部分。

重做阶段(Redo):已提交 -> 落盘(移除集合)。
该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作为:
找出所有包含 Commit Record 的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条 End Record,然后移除出待恢复事务集合。

回滚阶段(Undo):未提交 -> Undo Log 回滚。
该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为 Loser,
根据 Undo Log 中的信息,将已经提前写入磁盘的信息重新改写回去,以达到回滚这些 Loser 事务的目的。

FORCE、STEAL => 四种组合(如下图)
允许 NO-FORCE 要重做(Redo Log):已提交,后面慢慢落盘
允许 STEAL 要回滚(Undo Log):未提交,日志先行(可回滚)
在这里插入图片描述

实现隔离性

解决事务并发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 事务的隔离性
隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。

# 三种锁
只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。
但现实情况不可能没有并发,要在并发下实现串行的数据访问该怎样做?几乎所有程序员都会回答:加锁同步呀!正确,现代数据库均提供了以下三种锁。

(孤儿锁)写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为 X-Lock)
如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。

(读读共存)读锁(Read Lock,也叫作共享锁,Shared Lock,简写为 S-Lock)
多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。
对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。

(不可增删改)范围锁(Range Lock)
对于某个范围直接加排他锁,在这个范围内的数据不能被写入。
典型的加范围锁的例子: SELECT * FROM books WHERE price < 100 FOR UPDATE;
注意:“范围不能被写入”与“一批数据不能被写入”的差别,即不要把范围锁理解成一组排他锁的集合。
加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。

可串行化(Serializable) => 读、写持续,有范围

串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。

可串行化完全符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化
(“即可”是简化理解,实际还是很复杂的,要分成 Expanding 和 Shrinking 两阶段去处理读锁、写锁与数据间的关系,称为Two-Phase Lock,2PL)

但数据库不考虑性能肯定是不行的,并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。

现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。


可重复读(Repeatable Read) => 读、写持续,无范围

可串行化的下一个隔离级别是可重复读(Repeatable Read),可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁

可重复读比可串行化弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。

例如现在准备统计一下 Fenix’s Bookstore 中售价小于 100 元的书有多少本,会执行以下第一条 SQL 语句:

1
2
3
SELECT count(1) FROM books WHERE price < 100                    /* 时间顺序:1,事务: T1 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90) /* 时间顺序:2,事务: T2 */
SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */

(两次查询不一致 => 可重复读没有范围锁)
根据前面对范围锁、读锁和写锁的定义可知,假如这条 SQL 语句在同一个事务中重复执行了两次,且这两次执行之间恰好有另外一个事务在数据库插入了一本小于 100 元的书籍,这是会被允许的,那这两次相同的查询就会得到不一样的结果,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。

(只读事务 与 读写事务的区别)
提醒注意一点,这里的介绍是以 ARIES 理论为讨论目标的,具体的数据库并不一定要完全遵照着理论去实现。一个例子是 MySQL/InnoDB 的默认隔离级别为可重复读,但它在只读事务中可以完全避免幻读问题,例如上面例子中事务 T1 只有查询语句,是一个只读事务,所以例子中的问题在 MySQL 中并不会出现。但在读写事务中,MySQL 仍然会出现幻读问题,例如例子中事务 T1 如果在其他事务插入新书后,不是重新查询一次数量,而是要将所有小于 100 元的书改名,那就依然会受到新插入书籍的影响。


读已提交(Read Committed) => 写持续,读短暂,无范围

可重复读的下一个隔离级别是读已提交(Read Committed),读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放

读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。

例如笔者想要获取 Fenix’s Bookstore 中《深入理解 Java 虚拟机》这本书的售价,同样执行了两条 SQL 语句,在此两条语句执行之间,恰好另外一个事务修改了这本书的价格,将书的价格从 90 元调整到了 110 元,如下 SQL 所示:

1
2
3
SELECT * FROM books WHERE id = 1;                                /* 时间顺序:1,事务: T1 */
UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */
SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */

如果隔离级别是读已提交,这两次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务 T2 中的更新语句可以马上提交成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。

假如隔离级别是可重复读的话,由于数据已被事务 T1 施加了读锁且读取后不会马上释放,所以事务 T2 无法获取到写锁,更新就会被阻塞,直至事务 T1 被提交或回滚后才能提交。


读未提交(Read Uncommitted) => 写持续,无读,无范围

读已提交的下一个级别是读未提交(Read Uncommitted),读未提交对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁

读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。

例如笔者觉得《深入理解 Java 虚拟机》从 90 元涨价到 110 元是损害消费者利益的行为,又执行了一条更新语句把价格改回了 90 元,在提交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的,按 90 元卖要亏本,于是笔者随即回滚了事务,场景如下 SQL 所示:

1
2
3
4
5
6
SELECT * FROM books WHERE id = 1;                              /* 时间顺序:1,事务: T1 */
/* 注意没有COMMIT */
UPDATE books SET price = 90 WHERE id = 1; /* 时间顺序:2,事务: T2 */
/* 这条SELECT模拟购书的操作的逻辑 */
SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */
ROLLBACK; /* 时间顺序:4,事务: T2 */

不过,在之前修改价格后,事务 T1 已经按 90 元的价格卖出了几本。

原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,即上述事务 T1 中两条查询语句得到的结果并不相同。

如果你不能理解这句话中的“反而”二字,请再重读一次写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据

如果事务 T1 读取数据并不需要去加读锁的话,就会导致事务 T2 未提交的数据也马上就能被事务 T1 所读到。这同样是一个事务受到其他事务影响,隔离性被破坏的表现。

假如隔离级别是读已提交的话,由于事务 T2 持有数据的写锁,所以事务 T1 的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此 T1 中的第二次查询就会被阻塞,直至事务 T2 被提交或者回滚后才能得到结果。


完全不隔离
理论上还存在更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务的没提交之前的修改可以被另外一个事务的修改覆盖掉,脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性都无法实现,所以一般谈论隔离级别时不会将它纳入讨论范围内,而将读未提交视为是最低级的隔离级别。

表面现象、根本原因 => 不要迷恋于表象
以上四种隔离级别属于数据库理论的基础知识,多数大学的计算机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的某种固有属性或设定来讲解,这导致很多同学只能对这些现象死记硬背。

其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象
各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因


多版本并发控制(Multi-Version Concurrency Control,MVCC)

除了都以锁来实现外,以上四种隔离级别还有另一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。

针对这种“一个事务读+另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

MVCC 是一种读取优化策略,它的“无锁”是特指读取时不需要加锁

MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的

在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID,事务 ID 是一个全局严格递增的数值,然后根据以下规则写入数据。

1
2
3
4
5
6
7
8
9
10
# 插入数据时
CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。

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

# 修改数据时 => 删旧插新
将修改数据视为“删除旧数据,插入新数据”的组合,即先将原有数据复制一份。
原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。
复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。

MVCC 下的四种隔离级别

1
2
3
4
5
6
7
8
9
10
11
12
13
此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。
# 可重复读 => 小于当前版本中最大的版本
总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。

# 读已提交 => 总是最新
总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

另外两个隔离级别都没有必要用到 MVCC。
# 读未提交
读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。

# 可串行化
可串行化本来的语义就是要阻塞其他事务的读取操作,而 MVCC 是做读取时无锁优化的,自然就不会放到一起用。

悲观锁、乐观锁
MVCC 是只针对“读+写”场景的优化。
如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案。

稍微有点讨论余地的是加锁的策略是“乐观加锁”(Optimistic Locking)还是“悲观加锁”(Pessimistic Locking)。

1
2
3
4
5
6
7
8
# 悲观锁 => 先加锁,再访问
前面笔者介绍的加锁都属于悲观加锁策略,即认为如果不先做加锁再访问数据,就肯定会出现问题。

# 乐观锁 => 先不加锁,竞争时再找补救措施
相对地,乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。
这种思路被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),囿于篇幅与主题的原因,就不再展开了。
不过笔者提醒一句,没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而更慢。
_____________________________________________________

【日期标记】2022-08-01 19:42:27 以上同步完成

全局事务

全局事务(Global Transaction),有一些资料中也将其称为外部事务(External Transaction)。

适用于单服务多数据源场景的事务解决方案。

请注意,理论上真正的全局事务并没有“单个服务”的约束,为了避免与后续介绍的放弃了 ACID 的弱一致性事务处理方式相互混淆,所以这里的全局事务所指范围有所缩减,后续涉及多服务多数据源的事务,笔者将称其为“分布式事务”。


X/Open XA(eXtended **Architecture)
1991 年,为了解决分布式事务的一致性问题,X/Open组织(后来并入了The Open Group)提出了一套名为X/Open XA(XA 是 eXtended Architecture 的缩写)的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口

XA 接口是双向的,能在一个事务管理器和多个资源管理器(Resource Manager)之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚,现在我们在 Java 代码中还偶尔能看见的 XADataSource、XAResource 这些名字都源于此。

JTA(Java Transaction API)
不过,XA 并不是 Java 的技术规范(XA 提出那时还没有 Java),而是一套语言无关的通用规范,所以 Java 中专门定义了JSR 907 Java Transaction API,基于 XA 模式在 Java 语言中的实现了全局事务处理的标准,这也就是我们现在所熟知的 JTA。JTA 最主要的两个接口是:

  • 事务管理器的接口:javax.transaction.TransactionManager。这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的,还提供了另外一套javax.transaction.UserTransaction接口,用于通过程序代码手动开启、提交和回滚事务。
  • 满足 XA 规范的资源定义接口:javax.transaction.xa.XAResource,任何资源(JDBC、JMS 等等)如果想要支持 JTA,只要实现 XAResource 接口中的方法即可。

JTA 原本是 Java EE 中的技术,一般情况下应该由 JBoss、WebSphere、WebLogic 这些 Java EE 容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫 Arjuna)都以 JAR 包的形式实现了 JTA 的接口,称为 JOTM(Java Open Transaction Manager),使得我们能够在 Tomcat、Jetty 这样的 Java SE 环境下也能使用 JTA。


举个栗子
假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件仍与之前相同,那情况会发生什么变化呢?
假如你平时以声明式事务来编码,那它与本地事务看起来可能没什么区别,都是标个@Transactional注解而已,但如果以编程式事务来实现的话,就能在写法上看出差异,伪代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    public void buyBook(PaymentBill bill) {
userTransaction.begin();
warehouseTransaction.begin();
businessTransaction.begin();
try {
userAccountService.pay(bill.getMoney());
warehouseService.deliver(bill.getItems());
businessAccountService.receipt(bill.getMoney());
userTransaction.commit();
warehouseTransaction.commit();
businessTransaction.commit();
} catch (Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}

// 从代码上可看出,程序的目的是要做三次事务提交,但实际上代码并不能这样写。
//
// 试想一下:
// 如果在businessTransaction.commit()中出现错误,代码转到catch块中执行,此时userTransaction和warehouseTransaction已经完成提交,
// 再去调用rollback()方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证了。

2PC(2 Phase Commit) <=== XA 解决方案
为了解决这个问题,XA 将事务提交拆分成为两阶段过程:

  • 准备阶段(已记录Undo Log、Redo Log,未记录Commit Record,一直持有锁
    又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。这里所说的准备操作跟人类语言中通常理解的准备并不相同,对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record 而已,这意味着在做完数据持久化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。
  • 提交阶段(记录Commit Record 或 回滚,释放锁
    又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条 Commit Record 而已,通常能够快速完成,只有收到 Abort 指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。

2PC 两个假设
以上这两个过程被称为“两阶段提交”(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件。

  1. 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通信在全过程都不会出现误差,即可以丢失消息,但不会传递错误的消息,XA 的设计目标并不是解决诸如拜占庭将军一类的问题。两阶段提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这也是为了尽量控制网络风险的考虑。
  2. 必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,并向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。

2PC 时序图
上面所说的协调者、参与者都是可以由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生的,而应用程序相对于数据库来说只扮演客户端的角色。两阶段提交的交互时序如图所示:
在这里插入图片描述

2PC 缺点

  • 单点问题(协调者单点
    协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。

  • 性能问题(短板效应
    两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两阶段提交的性能通常都较差。

  • 一致性风险(非一致性) ===> 协调者(已提交) => 断网 => 参与者(未提交)
    前面已经提到,两阶段提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985 年 Fischer、Lynch、Paterson 提出了“FLP 不可能原理”,证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与“CAP 不可兼得原理“齐名的理论。而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然被断开,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交,也没有办法回滚,产生了数据不一致的问题。


3PC(3 Phase Commit) <=== 2PC 优化方案
为了缓解两阶段提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了“三阶段提交”(3 Phase Commit,3PC)协议。

三阶段提交把原本的两阶段提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改称为 DoCommit 阶段。

为什么一分为二??? => 避免2PC短板效应,2PC第一步因为一台不行,第二步大家再回滚(执行,再回滚)
其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小
因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。

3PC 的好 => 等不到do,就提交(避免了协调者单点问题)
同样也是由于事务失败回滚概率变小的原因,在三阶段提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险

3PC 的坏 => 不一致的问题
从下图可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。
例如,进入 PreCommit 阶段之后,协调者发出的指令不是 Ack 而是 Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的 Abort 指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题

3PC 时序图
三阶段提交的操作时序如图所示。
在这里插入图片描述

2PC 与 3PC 对比

1
2
3
4
5
6
7
8
2PC
1. 执行(已记录Undo Log、Redo Log,未记录Commit Record,一直持有锁)
2. 提交(记录Commit Record 或 回滚,释放锁)

3PC
1.1 询问 => 你能不能行。不能行,就提前得知,代价小(此时还未进行任何事务操作)
1.2 执行(同 2PC.1)
2 提交(同 2PC.2)

共享事务

共享事务(Share Transaction)是指多服务单数据源(把共享事务列为四种事务类型之一只是为了叙述逻辑的完备)。

可以视为是一个独立于各个服务的远程数据库连接池,或 数据库代理来看待。

如图所示:
在这里插入图片描述

分布式事务

分布式事务(Distributed Transaction)特指多服务多数据源的事务处理机制。

CAP 与 ACID

CAP 的诞生
CAP 定理(Consistency、Availability、Partition Tolerance Theorem),也称为 Brewer 定理。

起源于在 2000 年 7 月,是加州大学伯克利分校的 Eric Brewer 教授于“ACM 分布式计算原理研讨会(PODC)”上提出的一个猜想。
在这里插入图片描述

两年之后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 以严谨的数学推理证明了 CAP 猜想。自此,CAP 正式从猜想变为分布式计算领域所公认的著名定理。

CAP 的三特性
这个定理里描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:

  • (符合预期)一致性(Consistency)
    代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,以后讨论分布式共识算法时,我们还会再提到一致性,那种面向副本复制的一致性与这里面向数据库状态的一致性严格来说并不完全等同,具体差别我们将在后续分布式共识算法中再作探讨。

  • (不鸡掰)可用性(Availability)
    代表系统不间断地提供服务的能力,理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,例如 99.9999%可用,即代表平均年故障修复时间为 32 秒。

  • (孤立他人,一群孤立一个)分区容忍性(Partition Tolerance)
    代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力


举个栗子

假设 Fenix’s Bookstore 的服务拓扑如图所示,一个来自用户的交易请求,将交由账号、商家和仓库服务集群中某一个节点来完成响应:
在这里插入图片描述

假设每一个单独的服务节点都有自己的数据库(这里是为了便于说明问题的假设,在实际生产系统中,一般应避免将用户余额这样的数据设计成存储在多个可写的数据库中)。

假设某次交易请求分别由“账号节点 1”、“商家节点 2”、“仓库节点 N”联合进行响应。

当用户购买一件价值 100 元的商品后,账号节点 1 首先应给该用户账号扣减 100 元货款,它在自己数据库扣减 100 元很容易,但它还要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况。

  • 如果该变动信息没有及时同步给其他账号节点,将导致有可能发生用户购买另一商品时,被分配给到另一个节点处理,由于看到账号上有不正确的余额而错误地发生了原本无法进行的交易,此为一致性问题
  • 如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统暂时无法提供服务而被拒绝交易,此为可用性问题
  • 如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的,整个集群能否承受由于部分节点之间的连接中断而仍然能够正确地提供服务,此为分区容忍性

以上还仅仅涉及了账号服务集群自身的 CAP 问题,对于整个 Fenix’s Bookstore 站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的 CAP 问题,例如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售
又例如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了的可用性问题,等等。


由于 CAP 定理已有严格的证明,本节不去探讨为何 CAP 不可兼得,而是直接分析如果舍弃 C、A、P 时所带来的不同影响。

  • 如果放弃分区容忍性(CA without P) => 分布式
    意味着我们将假设节点之间通信永远是可靠的。永远可靠的通信在分布式系统中必定不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就会始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以 Oracle 的 RAC 集群为例,它的每一个节点均有自己独立的 SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据的,通过共享磁盘的方式来避免出现网络分区。因而 Oracle RAC 虽然也是由多个实例组成的数据库,但它并不能称作是分布式数据库

  • 如果放弃可用性(CP without A) => 数据(暂停离线)
    意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题相当于退化到前面“全局事务”中讨论的单服务多数据源的场景之中,我们可以通过 2PC/3PC 等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的 CP 系统情况一般用于对数据质量要求很高的场合中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统,以 HBase 集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预先估计的

  • 如果放弃一致性(AP without C) => 可用(节点增加)
    意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数 NoSQL 库和支持分布式的缓存框架都是 AP 系统,以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。


强一致性、弱一致性
读到这里,不知道你是否对“选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题“事务”原本的目的就是获得“一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。但无论如何,系统终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability,通常是在讨论共识算法的场景中),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求“弱一致性”。

最终一致性 => 比弱一致性强一点
在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”(Eventual Consistency),它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。

强一致性(降低追求) => 最终一致性
在本节讨论的主题“分布式事务”中,目标同样也不得不从之前三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人们把使用 ACID 的事务称为“刚性事务”,而把笔者下面将要介绍几种分布式事务的常见做法统称为“柔性事务”。

可靠事件队列

幂等重试(无隔离)

最终一致性的概念是 eBay 的系统架构师 Dan Pritchett 在 2008 年在 ACM 发表的论文《Base: An Acid Alternative》中提出的,该论文总结了一种独立于 ACID 获得的强一致性之外的、使用 BASE 来达成一致性目的的途径。

BASE 分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)的缩写。

我们继续以本章的场景事例来解释 Dan Pritchett 提出的“可靠事件队列”的具体做法,目标仍然是交易过程中正确修改账号、仓库和商家服务中的数据,如图所示:
在这里插入图片描述

1)、最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。

2)、Fenix’s Bookstore 首先应对用户账号扣款、商家账号收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序,这种评估一般直接体现在程序代码中,有一些大型系统也可能会实现动态排序。例如,根据统计,最有可能的出现的交易异常是用户购买了商品,但是不同意扣款,或者账号余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收款,如果到了商家收款环节,一般就不会出什么意外了。那顺序就应该安排成最容易出错的最先进行,即:账号扣款 → 仓库出库 → 商家收款。

3)、账号服务进行扣款业务,如扣款成功,则在自己的数据库建立一张消息表,里面存入一条消息:“事务 ID:某 UUID,扣款:100 元(状态:已完成),仓库出库《深入理解 Java 虚拟机》:1 本(状态:进行中),某商家收款:100 元(状态:进行中)”,注意,这个步骤中“扣款业务”和“写入消息”是使用同一个本地事务写入账号服务自己的数据库的。

4)、在系统中建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去(也可以串行地发,即一个成功后再发送另一个,但在我们讨论的场景中没必要)。这时候可能产生以下几种情况。

  1. 正常)商家和仓库服务都成功完成了收款和出库工作,向用户账号服务器返回执行结果,用户账号服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。
  2. 未收到。重发消息 => 支持幂等)商家或仓库服务中至少一个因网络原因,未能收到来自用户账号服务的消息。此时,由于用户账号服务器中存储的消息状态一直处于“进行中”,所以消息服务器将在每次轮询的时候持续地向未响应的服务重复发送消息。这个步骤的可重复性决定了所有被消息服务器发送的消息都必须具备幂等性,通常的设计是让消息带上一个唯一的事务 ID,以保证一个事务中的出库、收款动作会且只会被处理一次。
  3. 自动重发,直至成功 => 可人工介入)商家或仓库服务有某个或全部无法完成工作,例如仓库发现《深入理解 Java 虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(例如补充了新库存),或者被人工介入为止。由此可见,可靠事件队列只要第一步业务完成了,后续就没有失败回滚的概念,只许成功,不许失败。
  4. 未回复。重发消息 => 支持幂等)商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失,此时,用户账号服务仍会重新发出下一条消息,但因操作具备幂等性,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息,此过程重复直至双方网络通信恢复正常。
  5. 消息框架支持)也有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候上述情况 2、4 也可以交由消息框架来保障。

以上这种靠着持续重试来保证可靠性的解决方案谈不上是 Dan Pritchett 的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字叫作“最大努力交付”(Best-Effort Delivery),例如 TCP 协议中未收到 ACK 应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的就是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。

TCC 事务

预留资源(隔离性) => 技术不可控(银联不可预留资源)

TCC 是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写。


可靠消息队列:坏处(无隔离性)
前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠的,过程也足够简单(相对于 TCC 来说),但整个过程完全没有任何隔离性可言,有一些业务中隔离性是无关紧要的,但有一些业务中缺乏隔离性就会带来许多麻烦。例如在本章的场景事例中,缺乏隔离性会带来的一个显而易见的问题便是“超售”:完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,例如,以上场景就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部分属于数据库本地事务方面的知识,可以参考前面的讲解。

TCC:好处(隔离性)
如果业务需要隔离,那架构师通常就应该重点考虑 TCC 方案,该方案天生适合用于需要强隔离性的分布式事务中。

在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。

1
2
3
4
5
6
7
8
9
# 1. Try:尝试执行阶段 => 预留资源
完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。

# 2. Confirm:确认执行阶段 => 幂等
不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。
Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。

# 3. Cancel:取消执行阶段 => 幂等
释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。

举个栗子

在这里插入图片描述

1)、(发起交易)最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。

2)、(创建事务,try预留资源)创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:

  • 用户服务:检查业务可行性,可行的话,将该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
  • 仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
  • 商家服务:检查业务可行性,不需要冻结资源。

3)、(confirm 执行)如果第 2 步所有业务均反馈业务可行,将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:

  • 用户服务:完成业务操作(扣减那被冻结的 100 元)。
  • 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。
  • 商家服务:完成业务操作(收款 100 元)。

4)、(confirm业务异常,网络异常 => 幂等重试)第 3 步如果全部完成,事务宣告正常结束,如果第 3 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,即进行最大努力交付。

5)、(任何一个 try 失败,全部 cancel)如果第 2 步有任意一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:

  • 用户服务:取消业务操作(释放被冻结的 100 元)。
  • 仓库服务:取消业务操作(释放被冻结的 1 本书)。
  • 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。

6)、(cancel业务异常,网络异常 => 幂等重试)第 5 步如果全部完成,事务宣告以失败回滚结束,如果第 5 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,即进行最大努力交付。


TCC 灵活性,锁粒度
由上述操作过程可见,TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。

TCC 性能高
TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。

TCC 开发成本和业务侵入性
但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们不会手撸一套 TCC,可以使用 阿里开源的Seata来支持 TCC,尽量减轻一些编码工作量。

SAGA 事务

补偿代替回滚

SAGA:解决TCC问题
TCC 事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。

TCC 的最主要限制是它的业务侵入性很强,这里并不是它开发代码的工作量,而是指它第一阶段 Try 的约束

例如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,允许直接在购物时通过 U 盾或扫码支付,在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲的地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以 TCC 中的第一步 Try 阶段往往无法施行。

我们只能考虑采用另外一种柔性事务方案:SAGA 事务。SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。

SAGA 由来
SAGA 事务模式的历史十分悠久,还早于分布式事务概念的提出。

它源于 1987 年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)。

文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合

原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式

SAGA 两部分

1
2
3
4
5
6
7
8
9
10
11
# 1. 大 -> 小
大事务拆分若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。
每个子事务都应该是或者能被视为是原子行为。
如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 Ti等价。

# 2. 补偿、幂等、可交换
为每一个子事务设计对应的补偿动作,命名为 C1,C2,…,Ci,…,Cn。
Ti与 Ci必须满足以下条件:
+ Ti与 Ci都具备幂等性。
+ Ti与 Ci满足交换律(Commutative),即先执行 Ti还是先执行 Ci,其效果都是一样的。
+ Ci必须能成功提交,即不考虑 Ci本身提交失败被回滚的情形(如出现就必须持续重试直至成功,或者要人工介入)。

SAGA 两种恢复策略
如果 T1到 Tn均成功提交,那事务顺利完成。否则,要采取以下两种恢复策略之一:

1
2
3
4
5
6
7
8
9
# 重试 => 正向恢复(Forward Recovery)
如果 Ti事务提交失败,则一直对 Ti进行重试,直至成功为止(最大努力交付)。
这种恢复方式不需要补偿,适用于事务最终都要成功的场景,例如在别人的银行账号中扣了款,就一定要给别人发货。
正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。

# 补偿 => 反向恢复(Backward Recovery)
如果 Ti事务提交失败,则一直执行 Ci对 Ti进行补偿,直至成功为止(最大努力交付)。
这里要求 Ci必须(在持续重试后)执行成功。
反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

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

例如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到 Fenix’s Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是由 Fenix’s Bookstore 系统将货款转回到用户账上作为补偿措施却是完全可行的

SAGA Log:崩溃恢复
SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,例如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,例如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式

AT 事务

也是一种“补偿代替回滚”的方案

例如阿里的 GTS(Global Transaction Service,Seata 由 GTS 开源而来)所提出的“AT 事务模式”就是这样的一种应用。

AT 事务:解决 XA准备阶段短板效应
从整体上看是 AT 事务是参照了 XA 两段提交协议实现的,但针对 XA 2PC 的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),设计了针对性的解决方案。

AT 事务:工作流程

1
2
3
4
5
6
7
8
9
GTS(Global Transaction Service) => AT事务

业务提交 => 拦截SQL(数据修改前后的结果分别保存快照,生成行锁)【通过本地事务一起提交到操作的数据源中】
分布式事务提交成功 => 清理日志
分布式事务需要回滚 => 根据日志逆向生成 SQL,进行补偿(异步,提交完释放锁和资源)
脏写问题 => 补偿之前又被其他操作修改过,即出现了脏写(Dirty Write)
避免脏写:全局锁(默认:读未提交 => 可能脏读)
本地事务拿到全局锁:提交
本地事务未拿到全局锁:一直等待

大致的做法是在业务数据提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL”。基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交的模式,相比起 2PC 极大地提升了系统的吞吐量水平,而代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不一定是总能成功的。例如,当本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Write),这时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。

通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,人工其实也很难进行有效处理。所以 GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待,这种设计以牺牲一定性能为代价,避免了有两个分布式事务中包含的本地事务修改了同一个数据,从而避免脏写。在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。

由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法(分布式事务:没有银弹)。

【日期标记】2022-08-02 17:23:43 以上同步完成

透明多级分流系统

奥卡姆剃刀原理
Entities should not be multiplied without necessity
如无必要,勿增实体
—— Occam’s Razor,William of Ockham

分流是必要的
现代的企业级或互联网系统,“分流”是必须要考虑的设计,分流所使用手段数量之多、涉及场景之广,可能连它的开发者本身都未必能全部意识到。这听起来似乎并不合理,但笔者认为这恰好是优秀架构设计的一种体现,“分布广阔”源于“多级”,“意识不到”谓之“透明”,也即本章我们要讨论的主题“透明多级分流系统”(Transparent Multilevel Diversion System, “透明多级分流系统”这个词是笔者自己创造的,业内通常只提“Transparent Multilevel Cache”,但我们这里谈的并不仅仅涉及到缓存)的来由。

在用户使用信息系统的过程中,请求从浏览器出发,在域名服务器的指引下找到系统的入口,经过网关、负载均衡器、缓存、服务集群等一系列设施,最后触及到末端存储于数据库服务器中的信息,然后逐级返回到用户的浏览器之中。这其中要经过很多技术部件,它们各有不同的价值。

1
2
3
4
5
6
7
8
9
10
11
12
+ 缓存
有一些部件位于客户端或网络的边缘,能够迅速响应用户的请求,避免给后方的 I/O 与 CPU 带来压力,典型如本地缓存、内容分发网络、反向代理等。
+ 集群化:并发
有一些部件的处理能力能够线性拓展,易于伸缩,可以使用较小的代价堆叠机器来获得与用户数量相匹配的并发性能,应尽量作为业务逻辑的主要载体。
典型如集群中能够自动扩缩的服务节点。
+ 高可用:容错
有一些部件稳定服务对系统运行有全局性的影响,要时刻保持着容错备份,维护着高可用性,典型如服务注册中心、配置中心。
+ 单点
有一些设施是天生的单点部件,只能依靠升级机器本身的网络、存储和运算性能来提升处理能力。
有一些设施是天生的单点部件,只能依靠升级机器本身的网络、存储和运算性能来提升处理能力。
如位于系统入口的路由、网关或者负载均衡器、位于请求调用链末端的传统关系数据库等,都是典型的容易形成单点部件。
(它们都可以做集群,但一次网络请求中无可避免至少有一个是单点的部件)

对系统进行流量规划时,我们应该充分理解这些部件的价值差异,有两条简单、普适的原则能指导我们进行设计:

  • (分流)第一条原则是尽可能减少单点部件,如果某些单点是无可避免的,则应尽最大限度减少到达单点部件的流量。在系统中往往会有多个部件能够处理、响应用户请求,例如要获取一张存储在数据库的用户头像图片,浏览器缓存、内容分发网络、反向代理、Web 服务器、文件服务器、数据库都可能提供这张图片。恰如其分地引导请求分流至最合适的组件中,避免绝大多数流量汇集到单点部件(如数据库),同时依然能够在绝大多数时候保证处理结果的准确性,使单点系统在出现故障时自动而迅速地实施补救措施,这便是系统架构中多级分流的意义。
  • (奥卡姆剃刀)另一条更关键的原则是奥卡姆剃刀原则。作为一名架构设计者,你应对多级分流的手段有全面的理解与充分的准备,同时清晰地意识到这些设施并不是越多越好。在实际构建系统时,你应当在有明确需求、真正必要的时候再去考虑部署它们。不是每一个系统都要追求高并发、高可用的,根据系统的用户量、峰值流量和团队本身的技术与运维能力来考虑如何部署这些设施才是合理的做法,在能满足需求的前提下,最简单的系统就是最好的系统

本章,笔者将会根据流量从客户端发出到服务端处理这个过程里,所流经的与功能无关的技术部件为线索,解析这里面每个部件的透明工作原理与起到的分流作用。这节所讲述的客户端缓存、域名服务器、传输链路、内容分发网络、负载均衡器、服务端缓存,都是为了达成“透明分流”这个目标所采用的工具与手段,高可用架构、高并发则是通过“透明分流”所获得的价值。

客户端缓存

客户端缓存(Client Cache)
HTTP 协议的无状态性决定了它必须依靠客户端缓存来解决网络传输效率上的缺陷。

1
2
3
4
5
6
7
8
9
10
11
12
13
# HTTP 无状态 => 每次请求独立
浏览器的缓存机制几乎是在万维网刚刚诞生时就已经存在,在 HTTP 协议设计之初,便确定了服务端与客户端之间“无状态”(Stateless)的交互原则。
即要求每次请求是独立的,每次请求无法感知也不能依赖另一个请求的存在,这既简化了 HTTP 服务器的设计,也为其水平扩展能力留下了广袤的空间。

# HTTP 无状态:坏处
但无状态并不只有好的一面,由于每次请求都是独立的,服务端不保存此前请求的状态和资源,所以也不可避免地导致其携带有重复的数据,造成网络性能降低。

# 解决:HTTP无状态坏处 => 状态缓存
HTTP 协议对此问题的解决方案便是客户端缓存,在 HTTP 从 1.0 到 1.1,再到 2.0 版本的每次演进中,逐步形成了现在被称为“状态缓存”、“强制缓存”(许多资料中简称为“强缓存”)和“协商缓存”的 HTTP 缓存机制。

# 状态缓存:强制缓存、协商缓存
HTTP 缓存中,状态缓存是指不经过服务器,客户端直接根据缓存信息对目标网站的状态判断,以前只有 301/Moved Permanently(永久重定向)这一种;
后来在RFC6797中增加了HSTS(HTTP Strict Transport Security)机制,用于避免依赖 301/302 跳转 HTTPS 时可能产生的降级中间人劫持(详细可见安全架构中的“传输”),这也属于另一种状态缓存。

强制缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 强制缓存:白话翻译
例如收到响应后的 10 分钟内,资源的内容和状态一定不会被改变,因此客户端可以无须经过任何请求,在该时点前一直持有和使用该资源的本地缓存副本

# 约定
在浏览器的地址输入、页面链接跳转、新开窗口、前进和后退中均可生效。
但在用户主动刷新页面时应当自动失效。

###########################################################
###### HTTP Header 实现强制缓存:Expires、Cache-Control ###
###########################################################

# Expires => HTTP/1.0 Header
Expires 是 HTTP/1.0 协议中开始提供的 Header,后面跟随一个截至时间参数。
当服务器返回某个资源时带有该 Header 的话,意味着服务器承诺截止时间之前资源不会发生变动,浏览器可直接缓存该数据,不再重新发请求。
示例:
HTTP/1.1 200 OK
Expires: Wed, 8 Apr 2020 07:28:00 GMT

# Expires 缺点 => HTTP缓存的第一版本:简单,但不全面
+ 受限于客户端的本地时间。
例如,在收到响应后,客户端修改了本地时间,将时间前后调整几分钟,就可能会造成缓存提前失效或超期持有。
+ 无法处理涉及到用户身份的私有资源。
例如,某些资源被登录用户缓存在自己的浏览器上是合理的,但如果被代理服务器或者内容分发网络缓存起来,则可能被其他未认证的用户所获取。
+ 无法描述“不缓存”的语义。
例如,浏览器为了提高性能,往往会自动在当次会话中缓存某些 MIME 类型的资源,在 HTTP/1.0 的服务器中就缺乏手段强制浏览器不允许缓存某个资源。
以前为了实现这类功能,通常不得不使用脚本,或者手工在资源后面增加时间戳(例如如“xx.js?t=1586359920”、“xx.jpg?t=1586359350”)来保证每次资源都会重新获取。

# Cache-Control => HTTP/1.1 Header(解决 Expires 缺点)
Cache-Control 是 HTTP/1.1 协议中定义的强制缓存 Header,它是 Expires 增强版。
如果 Cache-Control、Expires 同时存在,并且语义冲突,规定必须以 Cache-Control 为准。(例如 Expires 与 max-age / s-maxage 冲突)
示例:
HTTP/1.1 200 OK
Cache-Control: max-age=600

# Cache-Control 一系列参数
Cache-Control 在客户端的请求 Header 或服务器的响应 Header 中都可以存在,有一系列的参数,且允许自行扩展(即不在标准 RFC 协议中,由浏览器自行支持的参数)。
其标准的参数主要包括有:
+ max-age和s-maxage:max-age 后面跟随一个以秒为单位的数字,表明相对于请求时间(在 Date Header 中会注明请求时间)多少秒以内缓存是有效的,资源不需要重新从服务器中获取。相对时间避免了 Expires 中采用的绝对时间可能受客户端时钟影响的问题。s-maxage 中的“s”是“Share”的缩写,意味“共享缓存”的有效时间,即允许被 CDN、代理等持有的缓存有效时间,用于提示 CDN 这类服务器应在何时让缓存失效。
+ public和private:指明是否涉及到用户身份的私有资源,如果是 public,则可以被代理、CDN 等缓存,如果是 private,则只能由用户的客户端进行私有缓存。
+ no-cache和no-store:no-cache 指明该资源不应该被缓存,哪怕是同一个会话中对同一个 URL 地址的请求,也必须从服务端获取,令强制缓存完全失效,但此时下一节中的协商缓存机制依然是生效的;no-store 不强制会话中相同 URL 资源的重复获取,但禁止浏览器、CDN 等以任何形式保存该资源。
+ no-transform:禁止资源被任何形式地修改。例如,某些 CDN、透明代理支持自动 GZip 压缩图片或文本,以提升网络性能,而 no-transform 就禁止了这样的行为,它要求 Content-Encoding、Content-Range、Content-Type 均不允许进行任何形式的修改。
+ min-fresh和only-if-cached:这两个参数是仅用于客户端的请求 Header。min-fresh 后续跟随一个以秒为单位的数字,用于建议服务器能返回一个不少于该时间的缓存资源(即包含 max-age 且不少于 min-fresh 的数字)。only-if-cached 表示客户端要求不必给它发送资源的具体内容,此时客户端就仅能使用事先缓存的资源来进行响应,若缓存不能命中,就直接返回 503/Service Unavailable 错误。
+ must-revalidate和proxy-revalidate:must-revalidate 表示在资源过期后,一定需要从服务器中进行获取,即超过了 max-age 的时间后,就等同于 no-cache 的行为,proxy-revalidate 用于提示代理、CDN 等设备资源过期后的缓存行为,除对象不同外,语义与 must-revalidate 完全一致。

协商缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# 强制缓存 => 时效性
# 协商缓存 => 基于变化的检测机制
强制缓存是基于时效性的,但无论是人还是服务器,其实多数情况下都并没有什么把握去承诺某项资源多久不会发生变化。
另外一种基于变化检测的缓存机制,在一致性上会有比强制缓存更好的表现,但需要一次变化检测的交互开销,性能上就会略差一些,这种基于检测的缓存机制,通常被称为“协商缓存”。

# 强制、协商 => 可并行(先强制,再协商)
另外,应注意在 HTTP 中协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的。
例如:
当强制缓存存在时,直接从强制缓存中返回资源,无须进行变动检查;
而当强制缓存超过时效,或者被禁止(no-cache / must-revalidate),协商缓存仍可以正常地工作。

# 约定
协商缓存不仅在浏览器的地址输入、页面链接跳转、新开窗口、前进、后退中生效,而且在用户主动刷新页面(F5)时也同样是生效的。
只有用户强制刷新(Ctrl+F5)或者明确禁用缓存时(譬如在 DevTools 中设定)才会失效,此时客户端向服务端发出的请求会自动带有“Cache-Control: no-cache”。 => 如下图

###########################################################
###### HTTP Header 实现协商缓存:Last-Modified、Etag #####
###########################################################
协商缓存有两种变动检查机制,它们都是靠一组成对出现的请求、响应 Header 来实现的。
+ 根据资源的修改时间进行检查
+ 根据资源唯一标识是否发生变化来进行检查

# Last-Modified 和 If-Modified-Since
Last-Modified 是服务器的响应 Header,用于告诉客户端这个资源的最后修改时间。
对于带有这个 Header 的资源,当客户端需要再次请求时,会通过 If-Modified-Since 把之前收到的资源最后修改时间发送回服务端。
# 最后修改时间不变 => 304
如果此时服务端发现资源在该时间后没有被修改过,就只要返回一个 304/Not Modified 的响应即可,无须附带消息体,达到节省流量的目的。
示例:
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=600
Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT

# 最后修改时间改变 => 200
如果此时服务端发现资源在该时间之后有变动,就会返回 200/OK 的完整响应,在消息体中包含最新的资源。
示例:
HTTP/1.1 200 OK
Cache-Control: public, max-age=600
Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT

Content

# Etag 和 If-None-Match
Etag 是服务器的响应 Header,用于告诉客户端这个资源的唯一标识。
HTTP 服务器可以自己来生成这个标识。例如:Apache 服务器的 Etag 值默认是对文件的索引节点(INode),大小和最后修改时间进行哈希计算后得到的。
对于带有这个 Header 的资源,当客户端需要再次请求时,会通过 If-None-Match 把之前收到的资源唯一标识发送回服务端。
# tag不变 => 304
如果此时服务端计算后发现资源的唯一标识与上传回来的一致,说明资源没有被修改过,就只要返回一个 304/Not Modified 的响应即可,无须附带消息体,达到节省流量的目的。
示例:
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=600
ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"

# tag改变 => 200
如果此时服务端发现资源的唯一标识有变动,就会返回 200/OK 的完整响应,在消息体中包含最新的资源。
示例:
HTTP/1.1 200 OK
Cache-Control: public, max-age=600
ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"

Content

# Etag的好 => 精确
Etag 是 HTTP 中一致性最强的缓存机制。
譬如,Last-Modified 标注的最后修改只能精确到秒级,如果某些文件在 1 秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间;
又或者如果某些文件会被定期生成,可能内容并没有任何变化,但 Last-Modified 却改变了,导致文件无法有效使用缓存,这些情况 Last-Modified 都有可能产生资源一致性问题,只能使用 Etag 解决。

# Etag的坏 => 性能开销
Etag 却又是 HTTP 中性能最差的缓存机制。
体现在每次请求时,服务端都必须对资源进行哈希计算,这比起简单获取一下修改时间,开销要大了很多。
Etag 和 Last-Modified 是允许一起使用的,服务器会优先验证 Etag,在 Etag 一致的情况下,再去对比 Last-Modified,这是为了防止有一些 HTTP 服务器未将文件修改日期纳入哈希范围内。

# Accept* 与 Content-*
到这里为止,HTTP 的协商缓存机制已经能很好地处理通过 URL 获取单个资源的场景,为什么要强调“单个资源”呢?
在 HTTP 协议的设计中,一个 URL 地址是有可能能够提供多份不同版本的资源。
譬如,一段文字的不同语言版本,一个文件的不同编码格式版本,一份数据的不同压缩方式版本,等等。

因此针对请求的缓存机制,也必须能够提供对应的支持。
为此,HTTP 协议设计了以 Accept*(Accept、Accept-Language、Accept-Charset、Accept-Encoding)开头的一套请求 Header 和对应的以 Content-*(Content-Language、Content-Type、Content-Encoding)开头的响应 Header,这些 Headers 被称为 HTTP 的内容协商机制。
与之对应的,对于一个 URL 能够获取多个资源的场景中,缓存也同样也需要有明确的标识来获知根据什么内容来对同一个 URL 返回给用户正确的资源。
这个就是 Vary Header 的作用,Vary 后面应该跟随一组其他 Header 的名字。
示例:
HTTP/1.1 200 OK
Vary: Accept, User-Agent
以上响应的含义是应该根据 MIME 类型和浏览器类型来缓存资源,获取资源时也需要根据请求 Header 中对应的字段来筛选出适合的资源版本。

在这里插入图片描述
在这里插入图片描述

之前你打开 F12 调试 Network,查看请求的时候都会看到上面几个 Header,却不知明确含义, 读到这里是否深有感触。

【日期标记】2022-08-05 09:22:02 以上同步完成

技术方法论

No Silver Bullet 没有银弹

  • “软件研发中任何一项技术、方法、架构都不可能是银弹”
  • 微服务不是银弹

目的:微服务的驱动力

  • 微服务的目的是有效的拆分应用,实现敏捷开发和部署。

  • 有人说:为了先进架构 > 废话
    有人会说迈向微服务的目的是为了追求更先进的架构形式。这话对,但没有什么信息量可言,任何一次架构演进的目的都是为了更加先进,应该没谁是为“追求落后”而重构系统的。

  • 有人说:性能不行 > 单体也可以扩缩容
    有人会说微服务是信息系统发展的必然阶段,为了应对日益庞大的压力,获得更好的性能,自然会演进至能够扩缩自如的微服务架构,这个观点看似合理、具体、正确,实则争议颇大。笔者个人的态度是旗帜鲜明地反对以“获得更好的性能”为主要目的,将系统重构为微服务架构的,性能有可能会作为辅助性的理由,但仅仅为了性能而选择分布式的话,那应该是 40 年前“原始分布式时代”所追求的目标。现代的单体系统同样会采用可扩缩的设计,同样能够集群部署,更重要的是云计算数据中心的处理能力几乎可以认为是无限的,那能够通过扩展硬件的手段解决问题就尽量别使用复杂的软件方法,

  • 硬件成本平稳下降,而软件不行
    其中原因在前面引用的《没有银弹》中已经解释过:“硬件的成本能够持续稳定地下降,而软件开发的成本则不可能”。而且,性能也不会因为采用了微服务架构而凭空产生。

  • 微服务不如单体
    把系统拆成多个微服务,一旦在某个关键地方依然卡住了业务流程,其整体的结果往往还不如单体,没有清晰的职责划分,导致扩展性失效,多加机器往往还不如单机。


  • 当意识到没有什么技术能够包打天下。

    1
    2
    3
    4
    # 异构
    AI => Python
    Redis => C
    etcd => Go

    举个具体例子,某个系统选用了处于Tiobe 排行榜榜首多年的 Java 语言来开发,也会遇到很多想做但 Java 却不擅长的事情。例如想去做人工智能,进行深度学习训练,发现大量的库和开源代码都离不开 Python;想要引入分布式协调工具时,发现近几年 ZooKeeper 已经有被后起之秀 Golang 的 Etcd 蚕食替代的趋势;想要做集中式缓存,发现无可争议的首选是 ANSI C 编写的 Redis,等等。很多时候为异构能力进行的分布式部署,并不是你想或者不想的问题,而是没有选择、无可避免的。

  • 当个人能力因素成为系统发展的明显制约。

    1
    2
    # 部分 => 全局
    => 局部:容错、快速迭代

    对于北上广深的信息技术企业这个问题可能不会成为主要矛盾,在其他地区,不少软件公司即使有钱也很难招到大量的靠谱的高端开发者。此时,无论是引入外包团队,抑或是让少量技术专家带着大量普通水平的开发者去共同完成一个大型系统,微服务都是一个更有潜力的选择。在单体架构下,没有什么有效阻断错误传播的手段,系统中“整体”与“部分”的关系没有物理的划分,系统质量只能靠研发与项目管理措施来尽可能地保障,少量的技术专家很难阻止大量螺丝钉式的程序员或者不熟悉原有技术架构的外包人员在某个不起眼的地方犯错并产生全局性的影响,不容易做出整体可靠的大型系统。这时微服务可以作为专家掌控架构约束力的技术手段,由高水平的开发、运维人员去保证关键的技术和业务服务靠谱,其他大量外围的功能即使不靠谱,甚至默认它们必定不靠谱,也能保证系统整体的稳定和局部的容错、自愈与快速迭代。

  • 当遇到来自外部商业层面对内部技术层面提出的要求。

    1
    2
    # 甲方要求
    (招标规范)

    对于那些以“自产自销”为主的互联网公司来说这一点体验不明显,但对于很多为企业提供信息服务的软件公司来说,甲方爸爸的要求往往才是具决定性的推动力。技术、需求上困难也许能变通克服,但当微服务架构变成大型系统先进性的背书时,甲方的招投标文件技术规范明文要求系统必须支持微服务架构、支持分布式部署,那就没有多少讨价还价的余地。


在系统和研发团队内部,也会有一些因素促使其向微服务靠拢:

  • 变化发展特别快的创新业务系统往往会自主地向微服务架构靠近。

    1
    2
    # 上线活不过3天
    可观测,可自愈

    需求喊着“要试错!要创新!要拥抱变化!”,开发喊着“资源永远不够!活干不完!”,运维喊着“你见过凌晨四点的洛杉矶吗!”,对于那种“一个功能上线平均活不过三天”的系统,如果团队本身能力能够支撑在合理的代价下让功能有快速迭代的可能,让代码能避免在类库层面的直接依赖而导致纠缠不清,让系统有更好的可观测性和回弹性(自愈能力),需求、开发、运维肯定都是很乐意接受微服务的,毕竟此时大家的利益一致,微服务的实施也会水到渠成。

  • 大规模的、业务复杂的、历史包袱沉重的系统也可能主动向微服务架构靠近。
    这类系统最后的结局不外乎三种:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # 爷爷辈
    第一种是日渐臃肿,客户忍了,系统持续维持着,直到谁也替代不了却又谁也维护不了。
    笔者曾听说过国外有公司招聘 60、70 岁的爷爷辈程序员去维护上个世纪的 COBOL 编写的系统,没有求证过这到底是网络段子还是确有其事。

    # 新老并行
    第二种是日渐臃肿,客户忍不了了,痛下决心,宁愿付出一段时间内业务双轨运行,
    忍受在新、旧系统上重复操作,期间业务发生震荡甚至短暂停顿的代价,也要将整套旧系统彻底淘汰掉,第二种情况笔者亲眼看见过不少。

    # 部分CRUD
    第三种是日渐臃肿,客户忍不了,系统也很难淘汰。
    此时迫于外部压力,微服务会作为一种能够将系统部分地拆除、修改、更新、替换的技术方案被严肃地论证,
    若在重构阶段有足够靠谱的技术人员参与,该大型系统的应用代码和数据库都逐渐分离独立,直至孵化出一个个可替换可重生的微服务,
    微服务的先驱 Netflix 曾在多次演讲中介绍说自己公司属于第三种的成功案例。

以上列举的这些内外部原因只是举例,肯定不是全部,促使你的产品最终选择微服务的具体理由可能是多种多样,相信你做出向微服务迈进的决策时,一定经过恰当的权衡,认为收益大于成本。

1
2
3
4
5
# 微服务最主要的目的
是对系统进行有效的拆分,实现物理层面的隔离

# 微服务的核心价值
就是拆分之后的系统能够让局部的单个服务有可能实现敏捷地卸载、部署、开发、升级,局部的持续更迭,是系统整体具备 Phoenix 特性的必要条件。

前提:微服务需要的条件

系统的架构趋同于组织的沟通结构。 - 康威定律