凤凰架构:构建可靠的大型分布式系统-1:架构

购买链接
在线阅读

服务架构演进史

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),就是允许在事务提交之前可落盘的意思。


FORCE、STEAL => 四种组合(如下图)

1
2
3
4
5
*STEAL   写日志,落盘 => 同时进行【Undo Log 可回滚】
NO-STEAL 日志写完再落盘 => 顺序进行

FORCE End释放锁(已落盘完成:同步)【Redo Log 可重做】
*NO-FORCE Commit释放锁(未落盘完成:后面异步落盘)

在这里插入图片描述

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
# STEAL:事务提交前,可落盘;
# FORCE:事务提交后,落盘完成;
Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,划分为 FORCE 和 STEAL 两类情况。
+ STEAL(写日志):在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。
(从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。)
+ FORCE(刷盘):当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE。
(现实中绝大多数数据库采用的都是 NO-FORCE 策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘 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 事务的目的。

实现隔离性

解决事务并发

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,尽量减轻一些编码工作量。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
2022-09-21 10:30:18 补充三个问题在这里

# TCC 幂等
在上面已经讲过了,cancel、confirm 要支持幂等,支持重试 => 最大努力通知

# TCC 空回滚
try开始 -----------------------------> try结束
丨=> try超时,cancel
try超时,此时 cancel取消了,cancel应该是一个空回滚(如果是正常回滚,就出错了)。
在cancel的时候,要加判断,if(try结束) => 正常回滚 else => 空回滚

# TCC 悬挂
接着上面的例子,cancel之后,try再执行的时候,即使预留了资源,后续也没有 confirm和 cancel了。
基于这个问题,try执行的时候,如果已经执行 confirm或cancel,就不能再继续执行 try了。

在这里插入图片描述

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 以上同步完成

域名解析

域名缓存(DNS Lookup)
DNS 也许是全世界最大、使用最频繁的信息查询系统,如果没有适当的分流机制,DNS 将会成为整个网络的瓶颈。

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
# DNS 的作用
将便于人类理解的域名地址转换为便于计算机处理的 IP 地址。
世界根域名服务器的 ZONE 文件只有 2MB 大小,甚至可以打印出来物理备份的时候,DNS 系统的设计是非常惊叹。

# DNS => 分流系统
域名解析对于大多数信息系统,尤其是对于基于互联网的系统来说是必不可少的组件,却属于没有太高存在感,通常都不会受重点关注的设施。
不过 DNS 本身的工作过程,以及它对系统流量能够施加的影响,却还是有许多程序员不太了解;
而且 DNS 本身就堪称是示范性的透明多级分流系统,非常符合本章的主题,值得我们去借鉴。

# DNS 工作流程
无论是使用浏览器抑或是在程序代码中访问某个网址域名,例如以www.taopanfeng.com为例,如果没有缓存的话,都会先经过 DNS 服务器的解析翻译,
找到域名对应的 IP 地址才能开始通信,这项操作是操作系统自动完成的,一般不需要用户程序的介入。
不过,DNS 服务器并不是一次性地将“www.taopanfeng.com”直接解析成 IP 地址,需要经历一个递归的过程。
===> 首先 DNS 会将域名还原为“www.taopanfeng.com.”,注意最后多了一个点“.”,它是“.root”的含义。
早期的域名必须带有这个点才能被 DNS 正确解析,如今几乎所有的操作系统、DNS 服务器都可以自动补上结尾的点号,然后开始如下解析步骤:

1. 检查本地缓存
客户端先检查本地的 DNS 缓存,查看是否存在并且是存活着的该域名的地址记录。
DNS 是以存活时间(Time to Live,TTL)来衡量缓存的有效情况的,所以,如果某个域名改变了 IP 地址,
DNS 服务器并没有任何机制去通知缓存了该地址的机器去更新或者失效掉缓存,只能依靠 TTL 超期后的重新获取来保证一致性。
后续每一级 DNS 查询的过程都会有类似的缓存查询操作,再遇到时就不重复叙述了。
2. 地址 => 发送 => 本地 DNS
客户端将地址发送给本机操作系统中配置的本地 DNS(Local DNS),这个本地 DNS 服务器可以由用户手工设置,也可以在 DHCP 分配时或者在拨号时从 PPP 服务器中自动获取到。
3. 本地DNS => 依次查询
本地 DNS 收到查询请求后,会按照“是否有www.taopanfeng.com的权威服务器” → “是否有taopanfeng.com的权威服务器” → “是否有com的权威服务器”的顺序。
依次查询自己的地址记录,如果都没有查询到,就会一直找到最后 . 点代表的根域名服务器为止。这个步骤里涉及了两个重要名词:
权威域名服务器(Authoritative DNS)
是指负责翻译特定域名的 DNS 服务器,“权威”意味着这个域名应该翻译出怎样的结果是由它来决定的。
DNS 翻译域名时无需像查电话本一样刻板地一对一翻译,根据来访机器、网络链路、服务内容等各种信息,
可以玩出很多花样,权威 DNS 的灵活应用,在后面的内容分发网络、服务发现等章节都还会有所涉及。
根域名服务器(Root DNS)
是指固定的、无需查询的顶级域名(Top-Level Domain)服务器,可以默认为它们已内置在操作系统代码之中。
全世界一共有 13 组根域名服务器(注意并不是 13 台,每一组根域名都通过任播的方式建立了一大群镜像,根据维基百科的数据,迄今已经超过 1000 台根域名服务器的镜像了)。
13 这个数字是由于 DNS 主要采用 UDP 传输协议(在需要稳定性保证的时候也可以采用 TCP)来进行数据交换,
未分片的 UDP 数据包在 IPv4 下最大有效值为 512 字节,最多可以存放 13 组地址记录,由此而来的限制。
4. 现在假设本地 DNS 是全新的,上面不存在任何域名的权威服务器记录,所以当 DNS 查询请求按步骤 3 的顺序一直查到根域名服务器之后,
它将会得到“com的权威服务器”的地址记录,然后通过“com的权威服务器”,得到“taopanfeng.com的权威服务器”的地址记录,以此类推,最后找到能够解释www.taopanfeng.com的权威服务器地址。
5. 通过“www.taopanfeng.com的权威服务器”,查询www.taopanfeng.com的地址记录,地址记录并不一定就是指 IP 地址,在 RFC 规范中有定义的地址记录类型已经多达数十种。
例如 IPv4 下的 IP 地址为 A 记录,IPv6 下的 AAAA 记录、主机别名 CNAME 记录,等等。

# 根据地区来选择 DNS
前面提到过,每种记录类型中还可以包括多条记录,以一个域名下配置多条不同的 A 记录为例,此时权威服务器可以根据自己的策略来进行选择。
典型的应用是智能线路:根据访问者所处的不同地区(例如华北、华南、东北)、不同服务商(例如电信、联通、移动)等因素来确定返回最合适的 A 记录,将访问者路由到最合适的数据中心,达到智能加速的目的。

# DNS 拖慢访问
DNS 系统多级分流的设计使得 DNS 系统能够经受住全球网络流量不间断的冲击,但也并非全无缺点。
典型的问题是响应速度,当极端情况(各级服务器均无缓存)下的域名解析可能导致每个域名都必须递归多次才能查询到结果,显著影响传输的响应速度,譬下面图示高达 310 毫秒的 DNS 查询。

# DNS预取 => 加速访问
专门有一种被称为“DNS 预取”(DNS Prefetching)的前端优化手段用来避免这类问题:如果网站后续要使用来自于其他域的资源,那就在网页加载时生成一个 link 请求,促使浏览器提前对该域名进行预解释
例如:
<link rel="dns-prefetch" href="//domain.not-icyfenx.cn">

# DNS 缺陷 => 中间人攻击
而另一种可能更严重的缺陷是 DNS 的分级查询意味着每一级都有可能受到中间人攻击的威胁,产生被劫持的风险。
要攻陷位于递归链条顶层的(例如根域名服务器,cn 权威服务器)服务器和链路是非常困难的,它们都有很专业的安全防护措施。
但很多位于递归链底层或者来自本地运营商的 Local DNS 服务器的安全防护则相对松懈,甚至不少地区的运营商自己就会主动进行劫持,
专门返回一个错的 IP,通过在这个 IP 上代理用户请求,以便给特定类型的资源(主要是 HTML)注入广告,以此牟利。

# HTTPDNS => 防止中间人攻击
为此,最近几年出现了另一种新的 DNS 工作模式:HTTPDNS(也称为 DNS over HTTPS,DoH)。
它将原本的 DNS 解析服务开放为一个基于 HTTPS 协议的查询服务,替代基于 UDP 传输协议的 DNS 域名解析,通过程序代替操作系统直接从权威 DNS 或者可靠的 Local DNS 获取解析数据,从而绕过传统 Local DNS。
这种做法的好处是完全免去了“中间商赚差价”的环节,不再惧怕底层的域名劫持,能够有效避免 Local DNS 不可靠导致的域名生效缓慢、来源 IP 不准确、产生的智能线路切换错误等问题。

在这里插入图片描述

【我真的是不想坚持写下去了,啊啊啊…】
【2022-08-09 17:23:19 再来。。。继续整理,挑选重点来】

传输链路

0. 优化传输链路

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
# 传输链路
Client -> Server
1. 客户端缓存
2. DNS 解析
3. 传输链路 √(本章节讲述)

# 前端网页的优化技巧
可能不少人的第一直觉会认为传输链路是开发者完全不可控的因素,网络路由跳点的数量、运营商铺设线路的质量决定了线路带宽的大小、速率的高低。
然而事实并非如此,程序发出的请求能否与应用层、传输层协议提倡的方式相匹配,对传输的效率也会有极大影响。
最容易体现这点的是那些前端网页的优化技巧,只要简单搜索一下,就能找到很多以优化链路传输为目的的前端设计原则。

例如经典的雅虎 YSlow-23 条规则中与传输相关的内容如下。
1. (减少请求数量)Minimize HTTP Requests。
请求每次都需要建立通信链路进行数据传输,这些开销很昂贵,减少请求的数量可有效的提高访问性能,对于前端开发者,可能用来减少请求数量的手段包括:
+ 雪碧图(CSS Sprites)
+ CSS、JS 文件合并/内联(Concatenation / Inline)
+ 分段文档(Multipart Document)
+ 媒体(图片、音频)内联(Data Base64 URI)
+ 合并 Ajax 请求(Batch Ajax Request)
+ ……
2. (扩大并发请求数)Split Components Across Domains。
现代浏览器(Chrome、Firefox)一般对每个域名支持 6 个(IE 为 8-13 个)并发请求,如果希望更快地加载大量图片或其他资源,需要进行域名分片(Domain Sharding),将图片同步到不同主机或者同一个主机的不同域名上。
3. (启用压缩传输)GZip Components。
启用压缩能够大幅度减少需要在网络上传输内容的大小,节省网络流量。
4. (避免页面重定向)Avoid Redirects。
当页面发生了重定向,就会延迟整个文档的传输。在 HTML 文档到达之前,页面中不会呈现任何东西,降低了用户体验。
5. (按重要性调节资源优先级)Put Stylesheets at the Top,Put Scripts at the Bottom。
将重要的、马上就要使用的、对客户端展示影响大的资源,放在 HTML 的头部,以便优先下载。
…………


# HTTP 在变化 1.0 => 1.1 => 2 => 3
这些原则在今天暂时仍算得上有一定价值,但在若干年后再回头看它们,大概率其中多数已经成了奇技淫巧,有些甚至成了反模式。
导致这种变化的原因是 HTTP 协议还在持续发展,从 20 世纪 90 年代的 HTTP/1.0 和 HTTP/1.1,到 2015 年发布的 HTTP/2,再到 2019 年的 HTTP/3。
由于 HTTP 协议本身的变化,使得“适合 HTTP 传输的请求”的特征也在不断变化。

1. 优化连接数 => 减少 TCP,连接有成本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1. HTTP => TCP 传输协议
我们知道 HTTP(特指 HTTP/3 以前)是以 TCP 为传输层的应用层协议,但 HTTP over TCP 这种搭配只能说是 TCP 在当今网络中统治性地位所造就的结果,而不能说它们两者配合工作就是合适的。

# 2. HTTP传输对象特性 => 数量多、时间短、资源小、切换快。
回想一下你上网平均每个页面停留的时间,以及每个页面中包含的资源(HTML、JS、CSS、图片等)数量,可以总结出 HTTP 传输对象的主要特征是数量多、时间短、资源小、切换快。

# 3. TCP 三次握手慢,并且传输速度开始慢后面稳定
另一方面,TCP 协议要求必须在三次握手完成之后才能开始数据传输,这是一个可能高达“百毫秒”为计时尺度的事件;
另外,TCP 还有慢启动的特性,使得刚刚建立连接时传输速度是最低的,后面再逐步加速直至稳定。

# 4. TCP 长时间有好处 => 长时间、大数据传输
由于 TCP 协议本身是面向于长时间、大数据传输来设计的,在长时间尺度下,它连接建立的高昂成本才不至于成为瓶颈,它的稳定性和可靠性的优势才能展现出来。

# 5. HTTP over TCP 矛盾点 => 上面 2、4矛盾
因此,可以说 HTTP over TCP 这种搭配在目标特征上确实是有矛盾的,以至于 HTTP/1.x 时代,大量短而小的 TCP 连接导致了网络性能的瓶颈。

# 6. 因为矛盾,所以有上面的优化:减少请求数量、扩大并发请求数
为了缓解 HTTP 与 TCP 之间的矛盾,聪明的程序员们一面致力于减少发出的请求数量,另外一方面也致力于增加客户端到服务端的连接数量,这就是上面 Yslow 规则中“Minimize HTTP Requests 减少请求数量”与“Split Components Across Domains 扩大并发请求数”两条优化措施的根本依据所在。

# 7. 数据统计 => TCP 连接减少
通过前端开发者的各种 Tricks,的确能够减少消耗 TCP 连接数量,这是有数据统计作为支撑的。
下面展示了 HTTP Archive对最近五年来数百万个 URL 地址采样得出的结论:页面平均请求没有改变的情况下(桌面端下降 3.8%,移动端上升 1.4%),TCP 连接正在持续且幅度较大地下降(桌面端下降 36.4%,移动端下降 28.6%)。

在这里插入图片描述

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
# 减少TCP连接的坏处 => 两害相权取其轻(鱼与熊掌)
但是,通过开发人员的 Tricks 来节省 TCP 连接,这样的优化措施并非只有好处,它们同时也带来了诸多不良的副作用:
+ (多张图片合并)如果你用 CSS Sprites 将多张图片合并,意味着任何场景下哪怕只用到其中一张小图,也必须完整加载整个大图片;任何场景下哪怕一张小图要进行修改,都会导致整个缓存失效,类似地,样式、脚本等其他文件的合并也会造成同样的问题。
+ (媒体内嵌)如果你使用了媒体内嵌,除了要承受 Base64 编码导致传输容量膨胀 1/3 的代价外(Base64 以 8 bit 表示 6 bit 数据),也将无法有效利用缓存。
+ (合并异步请求)如果你合并了异步请求,这就会导致所有请求返回时间都受最慢的那个请求的拖累,整体响应速度下降.
+ (图片放不同子域)如果你把图片放到不同子域下面,将会导致更大的 DNS 解析负担,而且浏览器对两个不同子域下的同一图片必须持有两份缓存,也使得缓存效率的下降。
+ ……
由此可见,一旦在技术根基上出现问题,依赖使用者通过各种 Tricks 去解决,无论如何都难以摆脱“两害相权取其轻”的权衡困境,否则这就不是 Tricks 而是会成为一种标准的设计模式了。

# 连接复用 => 用完不断开(FIFO队列)
在另一方面,HTTP 的设计者们并不是没有尝试过在协议层面去解决连接成本过高的问题,即使是 HTTP 协议的最初版本(指 HTTP/1.0,忽略非正式的 HTTP/0.9 版本)就已经支持了连接复用技术(连接复用技术在 HTTP/1.0 中并不是默认开启的,是在 HTTP/1.1 中变为默认开启),即今天大家所熟知的持久连接(Persistent Connection),也称为连接Keep-Alive 机制。
持久连接的原理是让客户端对同一个域名长期持有一个或多个不会用完即断的 TCP 连接。
典型做法是在客户端维护一个 FIFO 队列,每次取完数据(如何在不断开连接下判断取完数据将会放到稍后传输压缩部分去讨论)之后一段时间内不自动断开连接,以便获取下一个资源时直接复用,避免创建 TCP 连接的成本。

# 连接复用的坏处 => 阻塞问题(1TCP传多资源,不乱序 => 队首阻塞问题)
但是,连接复用技术依然是不完美的,最明显的副作用是“队首阻塞”(Head-of-Line Blocking)问题。
请设想以下场景:浏览器有 10 个资源需要从服务器中获取,此时它将 10 个资源放入队列,入列顺序只能按照浏览器遇见这些资源的先后顺序来决定的。
但如果这 10 个资源中的第 1 个就让服务器陷入长时间运算状态会怎样呢?当它的请求被发送到服务端之后,服务端开始计算,而运算结果出来之前 TCP 连接中并没有任何数据返回,此时后面 9 个资源都必须阻塞等待。
因为服务端虽然可以并行处理另外 9 个请求(例如第 1 个是复杂运算请求,消耗 CPU 资源,第 2 个是数据库访问,消耗数据库资源,第 3 个是访问某张图片,消耗磁盘 I/O 资源,这就很适合并行),
但问题是处理结果无法及时返回客户端,服务端不能哪个请求先完成就返回哪个,更不可能将所有要返回的资源混杂到一起交叉传输,
原因是只使用一个 TCP 连接来传输多个资源的话,如果顺序乱了,客户端就很难区分哪个数据包归属哪个资源了。

# 管道 => 不采用 ×(只是优化,还是无法避免阻塞问题)
2014 年,IETF 发布的RFC 7230中提出了名为“HTTP 管道”(HTTP Pipelining)复用技术,试图在 HTTP 服务器中也建立类似客户端的 FIFO 队列,让客户端一次将所有要请求的资源名单全部发给服务端,由服务端来安排返回顺序,管理传输队列。
无论队列维护在服务端还是客户端,其实都无法完全避免队首阻塞的问题,但由于服务端能够较为准确地评估资源消耗情况,进而能够更紧凑地安排资源传输,保证队列中两项工作之间尽量减少空隙,甚至做到并行化传输,从而提升链路传输的效率。
可是,由于 HTTP 管道需要多方共同支持,协调起来相当复杂,推广得并不算成功。

# HTTP/2 多路复用技术 => 解决阻塞
# 帧(流ID) => 最小单位,客户端根据流ID重组信息
队首阻塞问题一直持续到第二代的 HTTP 协议,即 HTTP/2 发布后才算是被比较完美地解决。
在 HTTP/1.x 中,HTTP 请求就是传输过程中最小粒度的信息单位了,所以如果将多个请求切碎,再混杂在一块传输,客户端势必难以分辨重组出有效信息。
而在 HTTP/2 中,帧(Frame)才是最小粒度的信息单位,它可以用来描述各种数据,例如请求的 Headers、Body,或者用来做控制标识,例如打开流、关闭流。
这里说的流(Stream)是一个逻辑上的数据通道概念,每个帧都附带一个流 ID 以标识这个帧属于哪个流。
这样,在同一个 TCP 连接中传输的多个数据帧就可以根据流 ID 轻易区分出开来,在客户端毫不费力地将不同流中的数据重组出不同 HTTP 请求和响应报文来。
这项设计是 HTTP/2 的最重要的技术特征一,被称为 HTTP/2 多路复用(HTTP/2 Multiplexing)技术,如图 4-4 所示。

# HTTP/2 一个域名一个TCP => 回答为什么前面说反模式
有了多路复用的支持,HTTP/2 就可以对每个域名只维持一个 TCP 连接(One Connection Per Origin)来以任意顺序传输任意数量的资源,既减轻了服务器的连接压力,开发者也不用去考虑域名分片这种事情来突破浏览器对每个域名最多 6 个连接数限制了。
而更重要的是,没有了 TCP 连接数的压力,就无须刻意压缩 HTTP 请求了,所有通过合并、内联文件(无论是图片、样式、脚本)以减少请求数的需求都不再成立,甚至反而是徒增副作用的反模式。

# HTTP/2 不适合合并资源 => 多传 Header也无所谓
说这是反模式,也许还有一些前端开发者会不同意,认为 HTTP 请求少一些总是好的,减少请求数量,最起码还减少了传输中耗费的 Headers。
必须先承认一个事实,在 HTTP 传输中 Headers 占传输成本的比重是相当的大,对于许多小资源,甚至可能出现 Headers 的容量比 Body 的还要大,以至于在 HTTP/2 中必须专门考虑如何进行 Header 压缩的问题。
但是,以下几个因素决定了通过合并资源文件减少请求数,对节省 Headers 成本也并没有太大帮助:
+ Header成本大 => 多用于 Ajax
Header 的传输成本在 Ajax(尤其是只返回少量数据的请求)请求中可能是比重很大的开销,但在图片、样式、脚本这些静态资源的请求中,通常并不占主要。
+ 不利 Header压缩
在 HTTP/2 中 Header 压缩的原理是基于字典编码的信息复用,简而言之是同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以 HTTP/2 是单域名单连接的机制,合并资源和域名分片反而对性能提升不利。
+ HTTP/2 不适合:多小合一大(1TCP包错,所有流等待,这个错包重传 => HTTP/3 解决木桶效应)
与 HTTP/1.x 相反,HTTP/2 本身反而变得更适合传输小资源了,例如传输 1000 张 10K 的小图,HTTP/2 要比 HTTP/1.x 快,但传输 10 张 1000K 的大图,则应该 HTTP/1.x 会更快。这一方面是 TCP 连接数量(相当于多点下载)的影响,更多的是由于 TCP 协议可靠传输机制导致的,一个错误的 TCP 包会导致所有的流都必须等待这个包重传成功,这个问题就是 HTTP/3 要解决的目标了。因此,把小文件合并成大文件,在 HTTP/2 下是毫无好处的。

2. 传输压缩

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
# 压缩
为了解决上面遗留的问题,如何不以断开 TCP 连接为标志来判断资源已传输完毕。

# MIME类型 => 自动判断压缩
HTTP 很早就支持了GZip压缩,由于 HTTP 传输的主要内容,例如 HTML、CSS、Script 等,主要是文本数据,对于文本数据启用压缩的收益是非常高的,传输数据量一般会降至原有的 20%左右。
而对于那些不适合压缩的资源,Web 服务器则能根据 MIME 类型来自动判断是否对响应进行压缩,这样,已经采用过压缩算法存储的资源,如 JPEG、PNG 图片,便不会被二次压缩,空耗性能。

# 早期 => 静态预压缩
# 现代 => 即时压缩(Server给不出 Content-Length 这个响应 Header,Server不知压缩后大小)
不过,大概就没有多少人想过压缩与之前提到的用于节约 TCP 的持久连接机制是存在冲突的。
在网络时代的早期,服务器处理能力还很薄弱,为了启用压缩,会是把静态资源先预先压缩为.gz 文件的形式存放起来,当客户端可以接受压缩版本的资源时(请求的 Header 中包含 Accept-Encoding: gzip)就返回压缩后的版本(响应的 Header 中包含 Content-Encoding: gzip),否则就返回未压缩的原版,这种方式被称为“静态预压缩”(Static Precompression)。
而现代的 Web 服务器处理能力有了大幅提升,已经没有人再采用麻烦的预压缩方式了,都是由服务器对符合条件的请求将在输出时进行“即时压缩”(On-The-Fly Compression),整个压缩过程全部在内存的数据流中完成,不必等资源压缩完成再返回响应,这样可以显著提高“首字节时间”(Time To First Byte,TTFB),改善 Web 性能体验。
而这个过程中唯一不好的地方就是服务器再也没有办法给出 Content-Length 这个响应 Header 了,因为输出 Header 时服务器还不知道压缩后资源的确切大小。

# 持久链接:如何结束?
# Content-Length => 资源结束
# 即时压缩坏处 => 不知资源结束(无 Content-Length)
到这里,大家想明白即时压缩与持久链接的冲突在哪了吗?持久链接机制不再依靠 TCP 连接是否关闭来判断资源请求是否结束,它会重用同一个连接以便向同一个域名请求多个资源,
这样,客户端就必须要有除了关闭连接之外的其他机制来判断一个资源什么时候算传递完毕,这个机制最初(在 HTTP/1.0 时)就只有 Content-Length,即靠着请求 Header 中明确给出资源的长度,传输到达该长度即宣告一个资源的传输已结束。
由于启用即时压缩后就无法给出 Content-Length 了,如果是 HTTP/1.0 的话,持久链接和即时压缩只能二选其一,事实上在 HTTP/1.0 中两者都支持,却默认都是不启用的。
依靠 Content-Length 来判断传输结束的缺陷,不仅仅在于即时压缩这一种场景,例如对于动态内容(Ajax、PHP、JSP 等输出),服务器也同样无法事先得知 Content-Length。

# HTTP/1.1 分块传输编码 => 0结束(解决:即时压缩无 Content-Length)
HTTP/1.1 版本中修复了这个缺陷,增加了另一种“分块传输编码”(Chunked Transfer Encoding)的资源结束判断机制,彻底解决了 Content-Length 与持久链接的冲突问题。
分块编码原理相当简单:在响应 Header 中加入“Transfer-Encoding: chunked”之后,就代表这个响应报文将采用分块编码。此时,报文中的 Body 需要改为用一系列“分块”来传输。
每个分块包含十六进制的长度值和对应长度的数据内容,长度值独占一行,数据从下一行开始。最后以一个长度值为 0 的分块来表示资源结束。
举个具体例子(例子来自于维基百科,为便于观察,只分块,未压缩):
--------------
HTTP/1.1 200 OK
Date: Sat, 11 Apr 2020 04:44:00 GMT
Transfer-Encoding: chunked
Connection: keep-alive

25
This is the data in the first chunk

1C
and this is the second one

3
con

8
sequence

0
--------------
根据分块长度可知,前两个分块包含显式的回车换行符(CRLF,即\r\n 字符)
"This is the data in the first chunk\r\n" (37 字符 => 十六进制: 0x25)
"and this is the second one\r\n" (28 字符 => 十六进制: 0x1C)
"con" (3 字符 => 十六进制: 0x03)
"sequence" (8 字符 => 十六进制: 0x08)
所以解码后的内容为:
This is the data in the first chunk
and this is the second one
consequence

一般来说,Web 服务器给出的数据分块大小应该(但并不强制)是一致的,而不是如例子中那样随意。
HTTP/1.1 通过分块传输解决了即时压缩与持久连接并存的问题,到了 HTTP/2,由于多路复用和单域名单连接的设计,已经无须再刻意去提持久链接机制了,但数据压缩仍然有节约传输带宽的重要价值。

3. 快速 UDP 网络连接

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
# 替换 TCP 传输协议
HTTP 是应用层协议而不是传输层协议,它的设计原本并不应该过多地考虑底层的传输细节,从职责上讲,持久连接、多路复用、分块编码这些能力,已经或多或少超过了应用层的范畴。
要从根本上改进 HTTP,必须直接替换掉 HTTP over TCP 的根基,即 TCP 传输协议,这便最新一代 HTTP/3 协议的设计重点。

# Google 推动 => 替换 TCP 传输协议
推动替换 TCP 协议的先驱者并不是 IETF,而是 Google 公司。
目前,世界上只有 Google 公司具有这样的能力,这并不是因为 Google 的技术实力雄厚,而是由于它同时持有着占浏览器市场 70%份额的 Chrome 浏览器与占移动领域半壁江山的 Android 操作系统。

# HTTP/3 的由来 => Google
2013 年,Google 在它的服务器(如 Google.com、YouTube.com 等)及 Chrome 浏览器上同时启用了名为“快速 UDP 网络连接”(Quick UDP Internet Connections,QUIC)的全新传输协议。
在 2015 年,Google 将 QUIC 提交给 IETF,并在 IETF 的推动下对 QUIC 进行重新规范化(为以示区别,业界习惯将此前的版本称为 gQUIC,规范化后的版本称为 iQUIC),使其不仅能满足 HTTP 传输协议,日后还能支持 SMTP、DNS、SSH、Telnet、NTP 等多种其他上层协议。
2018 年末,IETF 正式批准了 HTTP over QUIC 使用 HTTP/3 的版本号,将其确立为最新一代的互联网标准。

从名字上就能看出 QUIC 会以 UDP 协议为基础,而 UDP 协议没有丢包自动重传的特性,因此 QUIC 的可靠传输能力并不是由底层协议提供,而是完全由自己来实现。
由 QUIC 自己实现的好处是能对每个流能做单独的控制,如果在一个流中发生错误,协议栈仍然可以独立地继续为其他流提供服务。
这对提高易出错链路的性能非常有用,因为在大多数情况下,TCP 协议接到数据包丢失或损坏通知之前,可能已经收到了大量的正确数据,但是在纠正错误之前,其他的正常请求都会等待甚至被重发,这也是在连接数优化一节中,提到 HTTP/2 未能解决传输大文件慢的根本原因。

QUIC 的另一个设计目标是面向移动设备的专门支持,由于以前 TCP、UDP 传输协议在设计时根本不可能设想到今天移动设备盛行的场景,因此肯定不会有任何专门的支持。
QUIC 在移动设备上的优势体现在网络切换时的响应速度上,例如当移动设备在不同 WiFi 热点之间切换,或者从 WiFi 切换到移动网络时,如果使用 TCP 协议,现存的所有连接都必定会超时、中断,然后根据需要重新创建。
这个过程会带来很高的延迟,因为超时和重新握手都需要大量时间。
为此,QUIC 提出了连接标识符的概念,该标识符可以唯一地标识客户端与服务器之间的连接,而无须依靠 IP 地址。这样,切换网络后,只需向服务端发送一个包含此标识符的数据包即可重用既有的连接,因为即使用户的 IP 地址发生变化,原始连接连接标识符依然是有效的。

# 同时兼容 => 无感知扩大
无论是 TCP 协议还是 HTTP 协议,都已经存在了数十年时间。
它们积累了大量用户的同时,也承载了很重的技术惯性,要使 HTTP 从 TCP 迁移走,即使由 Google 和 IETF 来推动依然不是一件容易的事情。
一个最显著的问题是互联网基础设施中的许多中间设备,都只面向 TCP 协议去建造,仅对 UDP 提供很基础的支持,有的甚至完全阻止 UDP 的流量。
因此,Google 在 Chromium 的网络协议栈中同时启用了 QUIC 和传统 TCP 连接,并在 QUIC 连接失败时以零延迟回退到 TCP 连接,尽可能让用户无感知地逐步地扩大 QUIC 的使用面。

# 新旧交替
根据W3Techs的数据,截至 2020 年 10 月,全球已有 48.9%的网站支持了 HTTP/2 协议,按照维基百科中的记录,这个数字在 2019 年 6 月时还只是 36.5%。在 HTTP/3 方面,今天也已经得到了 7.2%网站的支持。
可以肯定地说,目前网络链路传输领域正处于新旧交替的时代,许多既有的设备、程序、知识都会在未来几年时间里出现重大更新。
=> 2022-08-10 11:12:44 此时我看了一下我的博客,全部都是 HTTP/1.1

在这里插入图片描述

内容分发网络

内容分发网络(Content Distribution Network)
CDN 是一种十分古老而又十分透明,没什么存在感的分流系统,许多人都说听过它,但真正了解过它的人却很少。

0. 内容分发网络:讲解

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
# 内容分发网络
Client -> Server
1. 客户端缓存
2. DNS 解析
3. 传输链路
4. 内容分发网络 √(本章节讲述)
=> Content Distribution Network,CDN,也有写作 Content Delivery Network

# 内容分发网络 => 是什么?
内容分发网络是一种十分古老的应用,相信大部分读者都或多或少对其有一定了解,至少听过它的名字。
如果把某个互联网系统比喻为一家企业,那内容分发网络就是它遍布世界各地的分支销售机构。
现在有客户要买一块 CPU,那么订机票飞到美国加州 Intel 总部肯定是不合适的,到本地电脑城找个装机铺才是通常的做法,在此场景中,内容分发网络就相当于电脑城里的本地经销商。

# 内容分发网络 => 十分透明
内容分发网络又是一种十分透明的应用,可能绝大多数读者对于它为互联网站点分流的工作原理并没有什么系统性的概念,至少没有自己亲自使用过。

# 速度四因素
如果抛却其他影响服务质量的因素,仅从网络传输的角度看,一个互联网系统的速度取决于以下四点因素:
1. 网站服务器接入网络运营商的链路所能提供的出口带宽。
2. 用户客户端接入网络运营商的链路所能提供的入口带宽。
3. 从网站到用户之间经过的不同运营商之间互联节点的带宽,一般来说两个运营商之间只有固定的若干个点是互通的,所有跨运营商之间的交互都要经过这些点。
4. 从网站到用户之间的物理链路传输时延。爱打游戏的同学应该都清楚,延迟(Ping 值)比带宽更重要。
以上四个网络问题,除了第二个只能通过换一个更好的宽带才能解决之外,其余三个都能通过内容分发网络来显著改善。

# 内容分发网络 => 好处
一个运作良好的内容分发网络,能为互联网系统解决跨运营商、跨地域物理距离所导致的时延问题,能为网站流量带宽起到分流、减负的作用。
举个例子,如果不是有遍布全国乃至全世界的阿里云 CDN 网络支持,哪怕把整个杭州所有市民上网的权力都剥夺了,把带宽全部让给淘宝的机房,恐怕也撑不住全国乃至全球用户在双十一期间的疯狂“围殴”。

# 内容分发网络 => 工作过程
内容分发网络的工作过程如下,负载均衡的内容后面会专门讨论,我们先逐一来了解 CDN 其余三个方面。
1. 路由解析
2. 内容分发
3. 负载均衡(内容较多,下面“负载均衡”单独讲解)
4. 所能支持的 CDN 应用内容

【日期标记】2022-08-10 11:42:09 以上同步完成


1. 路由解析

1
2
3
4
# DNS 域名解析 => 无 CDN
介绍 DNS 域名解析时,提到翻译域名无须像查电话本一样刻板地一对一翻译,根据来访机器、网络链路、服务内容等各种信息,可以玩出很多花样。
内容分发网络将用户请求路由到它的资源服务器上就是依靠 DNS 服务器来实现的。
根据我们对 DNS 域名解析的了解,一次没有内容分发网络参与的用户访问,其解析过程如图 4-5 所示。

在这里插入图片描述

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
# DNS 域名解析 => 有 CDN
有内容分发网络介入会发生什么变化呢?
我们不妨先来看一段对网站icyfenix.cn进行 DNS 查询的真实应答记录,这个网站就是通过国内的内容分发网络对位于GitHub Pages上的静态页面进行加速的。
通过 dig 或者 host 命令,就能很方便地得到 DNS 服务器的返回结果(结果中头 4 个 IP 的城市地址是手工加入的,后面的其他记录就不一个一个查了)。
例如:
--------------
$ dig icyfenix.cn # DNS应答

; <<>> DiG 9.11.3-1ubuntu1.8-Ubuntu <<>> icyfenix.cn
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 60630
;; flags: qr rd ra; QUERY: 1, ANSWER: 17, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;icyfenix.cn. IN A

;; ANSWER SECTION:
icyfenix.cn. 600 IN CNAME icyfenix.cn.cdn.dnsv1.com.
icyfenix.cn.cdn.dnsv1.com. 599 IN CNAME 4yi4q4z6.dispatch.spcdntip.com.
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 101.71.72.192 #浙江宁波市
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 113.200.16.234 #陕西省榆林市
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 116.95.25.196 #内蒙古自治区呼和浩特市
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 116.178.66.65 #新疆维吾尔自治区乌鲁木齐市
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 118.212.234.144
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 211.91.160.228
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 211.97.73.224
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 218.11.8.232
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 221.204.166.70
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 14.204.74.140
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 43.242.166.88
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.80.39.110
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.83.204.12
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.83.204.14
4yi4q4z6.dispatch.spcdntip.com. 60 IN A 59.83.218.235

;; Query time: 74 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
;; WHEN: Sat Apr 11 22:33:56 CST 2020
;; MSG SIZE rcvd: 152
--------------
根据以上解析信息,DNS 服务为icyfenix.cn的查询结果先返回了一个CNAME 记录(icyfenix.cn.cdn.dnsv1.com.),递归查询该 CNAME 时候,返回了另一个看起来更奇怪的 CNAME(4yi4q4z6.dispatch.spcdntip.com.)。
继续查询后,这个 CNAME 返回了十几个位于全国不同地区的 A 记录,很明显,那些 A 记录就是分布在全国各地、存有本站缓存的 CDN 节点。

# CDN 解析流程
CDN 路由解析的具体工作过程是(时序图如下,请与上面给出的没有 CDN 参与的图进行对比):
1. (得到CNAME)架设好“icyfenix.cn”的服务器后,将服务器的 IP 地址在你的 CDN 服务商上注册为“源站”,注册后你会得到一个 CNAME,即本例中的“icyfenix.cn.cdn.dnsv1.com.”。
2. (注册CNAME)将得到的 CNAME 在你购买域名的 DNS 服务商上注册为一条 CNAME 记录。
3. (解析CNAME)当第一位用户来访你的站点时,将首先发生一次未命中缓存的 DNS 查询,域名服务商解析出 CNAME 后,返回给本地 DNS,至此之后链路解析的主导权就开始由内容分发网络的调度服务接管了。
4. (查询CNAME:获取CDN IP)本地 DNS 查询 CNAME 时,由于能解析该 CNAME 的权威服务器只有 CDN 服务商所架设的权威 DNS,这个 DNS 服务将根据一定的均衡策略和参数,如拓扑结构、容量、时延等,在全国各地能提供服务的 CDN 缓存节点中挑选一个最适合的,将它的 IP 代替源站的 IP 地址,返回给本地 DNS。
5. (请求 CDN IP:代理访问)浏览器从本地 DNS 拿到 IP 地址,将该 IP 当作源站服务器来进行访问,此时该 IP 的 CDN 节点上可能有,也可能没有缓存过源站的资源,这点将在下面“内容分发”讨论。
6. (CDN IP:可代理)经过内容分发后的 CDN 节点,就有能力代替源站向用户提供所请求的资源。

在这里插入图片描述


2. 内容分发

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
# 完全透明性 => 两个子问题:获取、更新
在 DNS 服务器的协助下,无论是对用户还是服务器,内容分发网络都可以是完全透明的,在两者都不知情的情况下,由 CDN 的缓存节点接管了用户向服务器发出的资源请求。
后面随之而来的问题是缓存节点中必须有用户想要请求的资源副本,才可能代替源站来响应用户请求。
这里面又包括了两个子问题:“如何获取源站资源”和“如何管理(更新)资源”。

# CDN 获取资源 => 两种方式
CDN 获取源站资源的过程被称为“内容分发”,“内容分发网络”的名字正是由此而来,可见这是 CDN 的核心价值。
目前主要有以下两种主流的内容分发方式:

1. 推(Push):主动分发
=> (单向透明)源 -> CDN
分发由源站主动发起,将内容从源站或者其他资源库推送到用户边缘的各个 CDN 缓存节点上。
这个推送的操作没有什么业界标准可循,可以采用任何传输方式(HTTP、FTP、P2P,等等)、任何推送策略(满足特定条件、定时、人工,等等)、任何推送时间,只要与后面说的更新策略相匹配即可。
由于主动分发通常需要源站、CDN 服务双方提供程序 API 接口层面的配合,所以它对源站并不是透明的,只对用户一侧单向透明。
主动分发一般用于网站要预载大量资源的场景。
例如双十一之前一段时间内,淘宝、京东等各个网络商城就会开始把未来活动中所需用到的资源推送到 CDN 缓存节点中,特别常用的资源甚至会直接缓存到你的手机 APP 的存储空间或者浏览器的localStorage上。

2. 拉(Pull):被动回源
=> 双向透明
被动回源由用户访问所触发全自动、双向透明的资源缓存过程。
当某个资源首次被用户请求的时候,CDN 缓存节点发现自己没有该资源,就会实时从源站中获取,这时资源的响应时间可粗略认为是资源从源站到 CDN 缓存节点的时间,再加上资源从 CDN 发送到用户的时间之和。
因此,被动回源的首次访问通常是比较慢的(但由于 CDN 的网络条件一般远高于普通用户,并不一定就会比用户直接访问源站更慢),不适合应用于数据量较大的资源。
被动回源的优点是可以做到完全的双向透明,不需要源站在程序上做任何的配合,使用起来非常方便。
这种分发方式是小型站点使用 CDN 服务的主流选择,如果不是自建 CDN,而是购买阿里云、腾讯云的 CDN 服务的站点,多数采用的就是这种方式。

# CDN 更新资源
# => 自动:缓存超时
# => 手动:call接口
对于“CDN 如何管理(更新)资源”这个问题,同样没有统一的标准可言,尽管在 HTTP 协议中,关于缓存的 Header 定义中确实是有对 CDN 这类共享缓存的一些指引性参数,例如Cache-Control的 s-maxage,但是否要遵循,完全取决于 CDN 本身的实现策略。更令人感到无奈的是,由于大多数网站的开发和运维人员并不十分了解 HTTP 缓存机制,所以导致如果 CDN 完全照着 HTTP Headers 来控制缓存失效和更新,效果反而会相当的差,还可能引发其他问题。因此,CDN 缓存的管理就不存在通用的准则。

# 常见:被动主动相结合
现在,最常见的做法是超时被动失效与手工主动失效相结合。
超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。
而手工失效是指 CDN 服务商一般会提供给程序调用来失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新。
例如“icyfenix.cn”就是依靠Travis-CI的持续集成服务来触发 CDN 失效和重新预热的。

3. CDN 应用

1
2
3
4
5
6
7
8
9
10
11
12
内容分发网络最初是为了快速分发静态资源而设计的。
但今天的 CDN 所能做的事情已经远远超越了开始建设时的目标,这部分应用太多,无法展开逐一细说,只能对现在 CDN 可以做的事情简要列举,以便读者有个总体认知。

+ 加速静态资源:这是 CDN 本职工作。
+ 安全防御:CDN 在广义上可以视作网站的堡垒机,源站只对 CDN 提供服务,由 CDN 来对外界其他用户服务,这样恶意攻击者就不容易直接威胁源站。CDN 对某些攻击手段的防御,如对DDoS 攻击的防御尤其有效。但需注意,将安全都寄托在 CDN 上本身是不安全的,一旦源站真实 IP 被泄漏,就会面临很高的风险。
+ 协议升级:不少 CDN 提供商都同时对接(代售 CA 的)SSL 证书服务,可以实现源站是 HTTP 协议的,而对外开放的网站是基于 HTTPS 的。同理,可以实现源站到 CDN 是 HTTP/1.x 协议,CDN 提供的外部服务是 HTTP/2 或 HTTP/3 协议、实现源站是基于 IPv4 网络的,CDN 提供的外部服务支持 IPv6 网络,等等。
+ 状态缓存:前面讲的客户端缓存时简要提到了状态缓存(强制缓存、协商缓存),CDN 不仅可以缓存源站的资源,还可以缓存源站的状态,例如源站的 301/302 转向就可以缓存起来让客户端直接跳转、还可以通过 CDN 开启HSTS、可以通过 CDN 进行OCSP 装订加速 SSL 证书访问,等等。有一些情况下甚至可以配置 CDN 对任意状态码(例如 404)进行一定时间的缓存,以减轻源站压力,但这个操作应当慎重,在网站状态发生改变时去及时刷新缓存。
+ 修改资源:CDN 可以在返回资源给用户的时候修改它的任何内容,以实现不同的目的。例如,可以对源站未压缩的资源自动压缩并修改 Content-Encoding,以节省用户的网络带宽消耗、可以对源站未启用客户端缓存的内容加上缓存 Header,自动启用客户端缓存,可以修改CORS的相关 Header,将源站不支持跨域的资源提供跨域能力,等等。
+ 访问控制:CDN 可以实现 IP 黑/白名单功能,根据不同的来访 IP 提供不同的响应结果,根据 IP 的访问流量来实现 QoS 控制、根据 HTTP 的 Referer 来实现防盗链,等等。
+ 注入功能:CDN 可以在不修改源站代码的前提下,为源站注入各种功能。国际 CDN 巨头 CloudFlare 提供的 Google Analytics、PACE、Hardenize 等第三方应用,在 CDN 下均能做到无须修改源站任何代码即可使用。
+ 绕过某些“不存在的”网络措施,这也是在国内申请 CDN 也必须实名备案的原因,就不细说了(原来如此:我当时想跟我的博客加上 CDN,还在想为什么上面提示要必须网站备案)。
+ ...

【日期标记】2022-08-11 09:07:51 以上同步完成

负载均衡

负载均衡(Load Balancing)
调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”。

0. 负载均衡:OSI 七层模型

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
# 还记得吗?
上面“内容分发网络”我们说的,负载均衡单独讲解吗,这不来了吗(负载均衡属于内容分发网络中的一步,下面再粘贴一下)。
--------------
内容分发网络的工作过程如下:
1. 路由解析
2. 内容分发
3. 负载均衡 √(本章节讲述)
4. 所能支持的 CDN 应用内容
--------------

# 为什么负载均衡? => 用户透明,看似一台,实则多台。
在互联网时代的早期,网站流量还相对较小,并且业务也比较简单,单台服务器便有可能满足访问需要,但时至今日,互联网应用也好,企业级应用也好,一般实际用于生产的系统,几乎都离不开集群部署了。
信息系统不论是采用单体架构多副本部署还是微服务架构,不论是为了实现高可用还是为了获得高性能,都需要利用到多台机器来扩展服务能力,希望用户的请求不管连接到哪台机器上,都能得到相同的处理。
另一方面,如何构建和调度服务集群这事情,又必须对用户一侧保持足够的透明,即使请求背后是由一千台、一万台机器来共同响应的,也绝非用户所关心的事情,用户需记住的只有一个域名地址而已。
调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”(Load Balancing)。

# 负载均衡是多级的
# DNS 负载均衡 => 前面已经讲解
# 进入数据中心后的负载均衡 => √(本章节讲述)
真正大型系统的负载均衡过程往往是多级的。
例如:在各地建有多个机房,或机房有不同网络链路入口的大型互联网站,会从 DNS 解析开始,通过“域名” → “CNAME” → “负载调度服务” → “就近的数据中心入口”的路径,先将来访地用户根据 IP 地址(或者其他条件)分配到一个合适的数据中心中,然后才到下面将要讨论的各式负载均衡。
在 DNS 层面的负载均衡与前面介绍的 DNS 智能线路、内容分发网络等,在工作原理上是类似的,其差别只是数据中心能提供的不仅有缓存,而是全方位的服务能力。
由于 DNS 层面的负载均衡此前已经详细讲解过,后续我们所讨论的“负载均衡”就只聚焦于网络请求进入数据中心入口之后的其他级次的负载均衡。

# 四层负载均衡 => 性能高
# 七层负载均衡 => 功能强
无论在网关内部建立了多少级的负载均衡,从形式上来说都可以分为两种:四层负载均衡和七层负载均衡。
在详细介绍它们是什么以及如何工作之前,我们先来建立两个总体的、概念性的印象。
+ 四层负载均衡的优势是性能高,七层负载均衡的优势是功能强。
+ 做多级混合负载均衡,通常应是低层的负载均衡在前,高层的负载均衡在后(想一想为什么?先提升性能,再增强功能)。
我们所说的“四层”、“七层”,指的是经典的 OSI 七层模型中第四层传输层和第七层应用层。
下面是来自于维基百科上对 OSI 七层模型的介绍(做了简单的中文翻译),这部分属于网络基础知识,这里就不多解释了(后面我们会多次使用到这张表)。
=> OSI:Open System Interconnect 开放式系统互联

在这里插入图片描述

1
2
3
4
5
# 四层负载均衡:同一个 TCP 连接(作用二层、三层,并非四层)
现在所说的“四层负载均衡”其实是多种均衡器工作模式的统称,“四层”的意思是说这些工作模式的共同特点是维持着同一个 TCP 连接,而不是说它只工作在第四层。
事实上,这些模式主要都是工作在二层(数据链路层,改写 MAC 地址)和三层(网络层,改写 IP 地址)上,单纯只处理第四层(传输层,可以改写 TCP、UDP 等协议的内容和端口)的数据无法做到负载均衡的转发,因为 OSI 的下三层是媒体层(Media Layers),上四层是主机层(Host Layers),既然流量都已经到达目标主机上了,也就谈不上什么流量转发,最多只能做代理了。
但出于习惯和方便,现在几乎所有的资料都把它们统称为四层负载均衡,后面也同样称呼它为四层负载均衡,如果读者在某些资料上看见“二层负载均衡”、“三层负载均衡”的表述,应该了解这是在描述它们工作的层次,与这里说的“四层负载均衡”并不是同一类意思。
下面来介绍几种常见的四层负载均衡的工作模式。

1. 负载均衡(数据链路层) => 修改:目标 MAC

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
参考上面 OSI 模型的表格,数据链路层传输的内容是数据帧(Frame),例如常见的以太网帧、ADSL 宽带的 PPP 帧等。
我们讨论的具体上下文里,目标必定就是以太网帧了,按照IEEE 802.3标准,最典型的 1500 Bytes MTU 的以太网帧结构,如下所示。
数据项 取值
---------------------------------------
前导码 10101010 7 Bytes
帧开始符 10101011 1 Byte
MAC 目标地址 ☆修改 6 Bytes
MAC 源地址 ☆ 6 Bytes
802.1Q标签(可选) 4 Bytes
以太类型 2 Bytes
有效负载 1500 Bytes
冗余校验 4 Bytes
帧间距 12 Bytes

帧结构中其他数据项的含义在本节中可以暂时不去理会,只需注意到“MAC 目标地址”和“MAC 源地址”两项即可(我标记了小星星)。
我们知道每一块网卡都有独立的 MAC 地址,以太帧上这两个地址告诉了交换机,此帧应该是从连接在交换机上的哪个端口的网卡发出,送至哪块网卡的。

# 工作原理 => 二层:修改目标 MAC
数据链路层负载均衡所做的工作,是修改请求的数据帧中的 MAC 目标地址,让用户原本是发送给负载均衡器的请求的数据帧,被二层交换机根据新的 MAC 目标地址转发到服务器集群中对应的服务器(后文称为“真实服务器”,Real Server)的网卡上,这样真实服务器就获得了一个原本目标并不是发送给它的数据帧。

# 工作流程
由于二层负载均衡器在转发请求过程中只修改了帧的 MAC 目标地址,不涉及更上层协议(没有修改 Payload 的数据),所以在更上层(第三层)看来,所有数据都是未曾被改变过的。
由于第三层的数据包,即 IP 数据包中包含了源(客户端)和目标(均衡器)的 IP 地址,只有真实服务器保证自己的 IP 地址与数据包中的目标 IP 地址一致,这个数据包才能被正确处理。
因此,使用这种负载均衡模式时,需要把真实物理服务器集群所有机器的虚拟 IP 地址(Virtual IP Address,VIP)配置成与负载均衡器的虚拟 IP 一样,这样经均衡器转发后的数据包就能在真实服务器中顺利地使用。
也正是因为实际处理请求的真实物理服务器 IP 和数据请求中的目的 IP 是一致的,所以响应结果就不再需要通过负载均衡服务器进行地址交换,可将响应结果的数据包直接从真实服务器返回给用户的客户端,避免负载均衡器网卡带宽成为瓶颈,因此数据链路层的负载均衡效率是相当高的。
整个请求到响应如下所示:

在这里插入图片描述

1
2
3
4
5
6
7
# 多名称 => 三角传输、单臂路由、直接路由
上述只有请求经过负载均衡器,而服务的响应无须从负载均衡器原路返回的工作模式,整个请求、转发、响应的链路形成一个“三角关系”,所以这种负载均衡模式也常被很形象地称为“三角传输模式”(Direct Server Return,DSR),也有叫“单臂模式”(Single Legged Mode)或者“直接路由”(Direct Routing)。

# 好处、坏处
虽然数据链路层负载均衡效率很高,但它并不能适用于所有的场合,除了那些需要感知应用层协议信息的负载均衡场景它无法胜任外(所有的四层负载均衡器都无法胜任,将在后续介绍七层均衡器时一并解释),它在网络一侧受到的约束也很大。
二层负载均衡器直接改写目标 MAC 地址的工作原理决定了它与真实的服务器的通信必须是二层可达的,通俗地说就是必须位于同一个子网当中,无法跨 VLAN。
优势(效率高)和劣势(不能跨子网)共同决定了数据链路层负载均衡最适合用来做数据中心的第一级均衡设备,用来连接其他的下级负载均衡器。

2. 负载均衡(网络层) => 修改:源IP、目标IP

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
根据 OSI 七层模型,在第三层网络层传输的单位是分组数据包(Packets),这是一种在分组交换网络(Packet Switching Network,PSN)中传输的结构化数据单位。
以 IP 协议为例,一个 IP 数据包由 Headers 和 Payload 两部分组成, Headers 长度最大为 60 Bytes,其中包括了 20 Bytes 的固定数据和最长不超过 40 Bytes 的可选的额外设置组成。
按照 IPv4 标准,一个典型的分组数据包的 Headers 部分结构如表所示。
长度 存储信息
---------------------------------------
0-4 Bytes 版本号(4 Bits)、首部长度(4 Bits)、分区类型(8 Bits)、总长度(16 Bits)
5-8 Bytes 报文计数标识(16 Bits)、标志位(4 Bits)、片偏移(12 Bits)
9-12 Bytes TTL 生存时间(8 Bits)、上层协议代号(8 Bits)、首部校验和(16 Bits)
13-16 Bytes 源地址(32 Bits)☆ 修改
17-20 Bytes 目标地址(32 Bits)☆ 修改
20-60 Bytes 可选字段和空白填充
在本节中,无须过多关注表格中的其他信息,只要知道在 IP 分组数据包的 Headers 带有源和目标的 IP 地址即可(我标记了小星星)。
源和目标 IP 地址代表了数据是从分组交换网络中哪台机器发送到哪台机器的,我们可以沿用与二层改写 MAC 地址相似的思路,通过改变这里面的 IP 地址来实现数据包的转发。
具体有两种常见的修改方式。

# 方式一
# => 包一层,拆包
第一种是保持原来的数据包不变,新创建一个数据包,把原来数据包的 Headers 和 Payload 整体作为另一个新的数据包的 Payload,在这个新数据包的 Headers 中写入真实服务器的 IP 作为目标地址,然后把它发送出去。
经过三层交换机的转发,真实服务器收到数据包后,必须在接收入口处设计一个针对性的拆包机制,把由负载均衡器自动添加的那层 Headers 扔掉,还原出原来的数据包来进行使用。
这样,真实服务器就同样拿到了一个原本不是发给它(目标 IP 不是它)的数据包,达到了流量转发的目的。
那时候还没有流行起“禁止套娃”的梗,所以设计者给这种“套娃式”的传输起名叫做“IP 隧道”(IP Tunnel)传输,也还是相当的形象。

# 封新包导致性能下降了点,仍三角特性,可跨 VLAN
尽管因为要封装新的数据包,IP 隧道的转发模式比起直接路由模式(数据链路层修改MAC)效率会有所下降,但由于并没有修改原有数据包中的任何信息,
所以 IP 隧道的转发模式仍然具备三角传输的特性,即负载均衡器转发来的请求,可以由真实服务器去直接应答,无须在经过均衡器原路返回。
而且由于 IP 隧道工作在网络层,所以可以跨越 VLAN,因此摆脱了直接路由模式中网络侧的约束。
此模式从请求到响应的过程如下所示:

# 缺点
而这种转发方式也有缺点。
+ 第一个缺点是它要求真实服务器必须支持“IP 隧道协议”(IP Encapsulation),就是它得学会自己拆包扔掉一层 Headers,这个其实并不是什么大问题,现在几乎所有的 Linux 系统都支持 IP 隧道协议。
+ 另外一个缺点是这种模式仍必须通过专门的配置,必须保证所有的真实服务器与均衡器有着相同的虚拟 IP 地址,因为响应时,要使用这个虚拟 IP 作为响应数据包的源地址,这样客户端收到这个数据包时才能正确解析。这个限制就相对麻烦一些,它与“透明”的原则冲突,需由系统管理员介入。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 方式二
# => 请求:改变目标IP=Server IP,响应:改变源IP=负载均衡器 IP
而且,对服务器进行虚拟 IP 的配置并不是在任何情况下都可行的,尤其是当有好几个服务共用一台物理服务器的时候,此时就必须考虑第二种修改方式——改变目标数据包:
=> 直接把数据包 Headers 中的目标地址改掉,修改后原本由用户发给均衡器的数据包,也会被三层交换机转发送到真实服务器的网卡上,而且因为没有经过 IP 隧道的额外包装,也就无须再拆包了。
但问题是这种模式是通过修改目标 IP 地址才到达真实服务器的,如果真实服务器直接将应答包返回客户端的话,这个应答数据包的源 IP 是真实服务器的 IP,也即均衡器修改以后的 IP 地址,客户端不可能认识该 IP,自然就无法再正常处理这个应答了。
因此,只能让应答流量继续回到负载均衡,由负载均衡把应答包的源 IP 改回自己的 IP,再发给客户端,这样才能保证客户端与真实服务器之间的正常通信。

# NAT模式
如果你对网络知识有些了解的话,肯定会觉得这种处理似曾相识,这不就是在家里、公司、学校上网时,由一台路由器带着一群内网机器上网的“网络地址转换”(Network Address Translation,NAT)操作吗?
这种负载均衡的模式的确被称为 NAT 模式,此时,负载均衡器就是充当了家里、公司、学校的上网路由器的作用。
NAT 模式的负载均衡器运维起来十分简单,只要机器将自己的网关地址设置为均衡器地址,就无须再进行任何额外设置了。
此模式从请求到响应的过程如图所示:

# 缺点:压力过大
在流量压力比较大的时候,NAT 模式的负载均衡会带来较大的性能损失,比起直接路由和 IP 隧道模式,甚至会出现数量级上的下降。
这点是显而易见的,由负载均衡器代表整个服务集群来进行应答,各个服务器的响应数据都会互相挣抢均衡器的出口带宽,这就好比在家里用 NAT 上网的话,如果有人在下载,你打游戏可能就会觉得卡顿是一个道理,此时整个系统的瓶颈很容易就出现在负载均衡器上。

# SNAT => 源IP、目标IP 都修改
# => 优点:透明
# => 缺点:客户端 IP拿不到
还有一种更加彻底的 NAT 模式:即均衡器在转发时,不仅修改目标 IP 地址,连源 IP 地址也一起改了,源地址就改成均衡器自己的 IP,称作 Source NAT(SNAT)。
这样做的好处是真实服务器无须配置网关就能够让应答流量经过正常的三层路由回到负载均衡器上,做到了彻底的透明。
但是缺点是由于做了 SNAT,真实服务器处理请求时就无法拿到客户端的 IP 地址了,从真实服务器的视角看来,所有的流量都来自于负载均衡器,这样有一些需要根据客户端 IP 进行控制的业务逻辑就无法进行。

在这里插入图片描述


3. 负载均衡(应用层) => 代理

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
# 四层:转发(1条TCP)
# 七层:代理(2条TCP)
前面介绍的四层负载均衡工作模式都属于“转发”,即直接将承载着 TCP 报文的底层数据格式(IP 数据包或以太网帧)转发到真实服务器上,此时客户端到响应请求的真实服务器维持着同一条 TCP 通道。
但工作在四层之后的负载均衡模式就无法再进行转发了,只能进行代理,此时真实服务器、负载均衡器、客户端三者之间由两条独立的 TCP 通道来维持通信,转发与代理的区别如下图所示:

# 代理分三种:正向、反向、透明
“代理”这个词,根据“哪一方能感知到”的原则,可以分为“正向代理”、“反向代理”和“透明代理”三类。
+ (仅客户端可见)正向代理就是我们通常简称的代理,指在客户端设置的、代表客户端与服务器通信的代理服务,它是客户端可知,而对服务器透明的。例如:VPN
+ (仅服务端可见)反向代理是指在设置在服务器这一侧,代表真实服务器来与客户端通信的代理服务,此时它对客户端来说是透明的。例如:Nginx
+ (双方都不可见)透明代理是指对双方都透明的,代理服务配置在客户端与服务器之间的网络设备上,例如:在路由器上的翻墙代理。

# 七层:反向代理
# 七层性能不高,但可玩花活。
根据以上定义,很显然,七层负载均衡器它就属于反向代理中的一种,如果只论网络性能,七层均衡器肯定是无论如何比不过四层均衡器的,它比四层均衡器至少多一轮 TCP 握手,有着跟 NAT 转发模式一样的带宽问题,而且通常要耗费更多的 CPU,因为可用的解析规则远比四层丰富。
所以如果用七层均衡器去做下载站、视频站这种流量应用是不合适的,起码不能作为第一级均衡器。
但是,如果网站的性能瓶颈并不在于网络性能,要论整个服务集群对外所体现出来的服务性能,七层均衡器就有它的用武之地了。
这里面七层均衡器的底气就是来源于它工作在应用层,可以感知应用层通信的具体内容,往往能够做出更明智的决策,玩出更多的花样来。

# 举个栗子
四层均衡器就像银行的自助排号机,转发效率高且不知疲倦,每一个达到银行的客户根据排号机的顺序,选择对应的窗口接受服务;
七层均衡器就像银行大堂经理,他会先确认客户需要办理的业务,再安排排号;
这样办理理财、存取款等业务的客户,会根据银行内部资源得到统一协调处理,加快客户业务办理流程,有一些业务,由大堂经理直接就可以解决了,无需转发到柜台处理。
例如:反向代理的就能够实现静态资源缓存,对于静态资源的请求就可以在反向代理上直接返回,无须转发到真实服务器。

# 七层的花活
七层均衡器 VS CDN
CDN 可以的,七层均衡器都可以,例如静态资源缓存、协议升级、安全防护、访问控制,等等。
=> 但有一个不行:CDN 优化链路 => 根据物理位置就近返回
七层均衡器 智能化路由
=> 例如:
+ 根据 Session 路由,以实现亲和性的集群;
+ 根据 URL 路由,实现专职化服务(此时就相当于网关的职责);
+ 甚至根据用户身份路由,实现对部分用户的特殊服务(如某些站点的贵宾服务器);
+ ...等等
七层均衡器 抵御安全攻击
例如一种常见的 DDoS 手段是 SYN Flood 攻击,即攻击者控制众多客户端,使用虚假 IP 地址对同一目标大量发送 SYN 报文。
=> 从技术原理上看:由于四层均衡器无法感知上层协议的内容,这些 SYN 攻击都会被转发到后端的真实服务器上;
而七层均衡器下这些 SYN 攻击自然在负载均衡设备上就被过滤掉,不会影响到后面服务器的正常运行。
七层均衡器 多种策略
=> 例如:过滤特定报文,以防御如 SQL 注入等应用层面的特定攻击手段。
七层均衡器 链路治理措施
=> 很多微服务架构的系统中,链路治理措施都需要在七层中进行,例如服务降级、熔断、异常注入,等等。
=> 例如:一台服务器只有出现物理层面或者系统层面的故障,导致无法应答 TCP 请求才能被四层均衡器所感知,进而剔除出服务集群,
如果一台服务器能够应答,只是一直在报 500 错,那四层均衡器对此是完全无能为力的,只能由七层均衡器来解决。

在这里插入图片描述


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
# 两大职责
负载均衡的两大职责
1. “选择谁来处理用户请求”(负载均衡策略)
2. “将用户请求转发过去”(请求的转发或代理过程)

# 负载均衡策略
+ 轮循均衡(Round Robin):每一次来自网络的请求轮流分配给内部中的服务器,从 1 至 N 然后重新开始。此种均衡算法适合于集群中的所有服务器都有相同的软硬件配置并且平均服务请求相对均衡的情况。
+ 权重轮循均衡(Weighted Round Robin):根据服务器的不同处理能力,给每个服务器分配不同的权值,使其能够接受相应权值数的服务请求。例如:服务器 A 的权值被设计成 1,B 的权值是 3,C 的权值是 6,则服务器 A、B、C 将分别接收到 10%、30%、60%的服务请求。此种均衡算法能确保高性能的服务器得到更多的使用率,避免低性能的服务器负载过重。
+ 随机均衡(Random):把来自客户端的请求随机分配给内部中的多个服务器,在数据足够大的场景下能达到相对均衡的分布。
+ 权重随机均衡(Weighted Random):此种均衡算法类似于权重轮循算法,不过在分配处理请求时是个随机选择的过程。
+ 一致性哈希均衡(Consistency Hash):根据请求中某一些数据(可以是 MAC、IP 地址,也可以是更上层协议中的某些参数信息)作为特征值来计算需要落在的节点上,算法一般会保证同一个特征值每次都一定落在相同的服务器上。一致性的意思是保证当服务集群某个真实服务器出现故障,只影响该服务器的哈希,而不会导致整个服务集群的哈希键值重新分布。
+ 响应速度均衡(Response Time):负载均衡设备对内部各服务器发出一个探测请求(例如 Ping),然后根据内部中各服务器对探测请求的最快响应时间来决定哪一台服务器来响应客户端的服务请求。此种均衡算法能较好的反映服务器的当前运行状态,但这最快响应时间仅仅指的是负载均衡设备与服务器间的最快响应时间,而不是客户端与服务器间的最快响应时间。
+ 最少连接数均衡(Least Connection):客户端的每一次请求服务在服务器停留的时间可能会有较大的差异,随着工作时间加长,如果采用简单的轮循或随机均衡算法,每一台服务器上的连接进程可能会产生极大的不平衡,并没有达到真正的负载均衡。最少连接数均衡算法对内部中需负载的每一台服务器都有一个数据记录,记录当前该服务器正在处理的连接数量,当有新的服务连接请求时,将把当前请求分配给连接数最少的服务器,使均衡更加符合实际情况,负载更加均衡。此种均衡策略适合长时处理的请求服务,如 FTP 传输。
+ …………

# 负载均衡器的实现
+ 软件
+ 操作系统内核 => 性能会更好,因为无须在内核空间和应用空间中来回复制数据包
=> LVS(Linux Virtual Server)
+ 应用程序 => 选择广泛,使用方便,功能不受限于内核版本
=> Nginx、HAProxy、KeepAlived 等
+ 硬件
+ 应用专用集成电路(Application Specific Integrated Circuit,ASIC),有专用处理芯片的支持,避免操作系统层面的损耗,得以达到最高的性能。
=> 著名的 F5 和 A10 公司的负载均衡产品。

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

服务端缓存

缓存(Cache)
软件开发中的缓存并非多多益善,它有收益,也有风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
上面介绍透明多级分流系统的逻辑脉络,开始是以流量从客户端中发出,结束是以流量到达服务器集群中真正处理业务的节点。
既然以“客户端缓存”为开篇,那“服务端缓存”作为结束,倒是十分合适的,在这一节里的“缓存”,是指服务端缓存。

# 缓存的负面性
为系统引入缓存之前,第一件事情是确认你的系统是否真的需要缓存。
很多人会有意无意地把硬件里那种常用于区分不同产品档次、“多多益善”的缓存(如 CPU L1/2/3 缓存、磁盘缓存,等等)代入软件开发中去,实际上这两者差别很大。
在软件开发中引入缓存的负面作用要明显大于硬件的缓存:
+ (复杂度高)从开发角度来说,引入缓存会提高系统复杂度,因为你要考虑缓存的失效、更新、一致性等问题;
+ (找问题难)从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;
+ (泄漏数据)从安全角度来说,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。

# 为什么引入缓存 => 减少 CPU、IO 压力,顺带提升性能
冒着上述种种风险,仍能说服你引入缓存的理由,总结起来无外乎以下两种:
+ 为缓解 CPU 压力而做缓存:例如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用,这可以节省 CPU 算力,顺带提升响应性能。
+ 为缓解 I/O 压力而做缓存:例如把原本对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,将原本对单点部件(如数据库)的读写访问变为到可扩缩部件(如缓存中间件)的访问,顺带提升响应性能。
请注意,缓存虽然是典型以空间换时间来提升性能的手段,但它的出发点是缓解 CPU 和 I/O 资源在峰值流量下的压力,“顺带”而非“专门”地提升响应性能。
这里的言外之意是如果可以通过增强 CPU、I/O 本身的性能(例如扩展服务器的数量)来满足需要的话,那升级硬件往往是更好的解决方案,即使需要一些额外的投入成本,也通常要优于引入缓存后可能带来的风险。

缓存属性

1
2
3
4
5
6
7
8
9
10
11
# 缓存也会复杂
有不少软件系统最初的缓存功能是以 HashMap 或者 ConcurrentHashMap 为起点演进的。
当开发人员发现系统中某些资源的构建成本比较高,而这些资源又有被重复使用的可能性时,会很自然地产生“循环再利用”的想法,将它们放到 Map 容器中,下次需要时取出重用,避免重新构建,这种原始朴素的复用就是最基本的缓存了。
不过,一旦我们专门把“缓存”看作一项技术基础设施,一旦它有了通用、高效、可统计、可管理等方面的需求,其中要考虑的因素就变得复杂起来。

# 四个属性
通常,我们设计或者选择缓存至少会考虑以下四个维度的属性:
+ 吞吐量:缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。
+ 命中率:缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。
+ 扩展功能:缓存除了基本读写功能外,还提供哪些额外的管理功能,例如最大容量、失效时间、失效事件、命中率统计,等等。
+ 分布式支持:缓存可分为“进程内缓存”和“分布式缓存”两大类,前者只为节点本身提供服务,无网络访问操作,速度快但缓存的数据不能在各个服务节点中共享,后者则相反。

1 吞吐量

1
2
3
4
5
6
7
8
9
10
11
12
13
# 线程安全 => 导致吞吐量损失
缓存的吞吐量只在并发场景中才有统计的意义,因为不考虑并发的话,即使是最原始的、以 HashMap 实现的缓存,访问效率也已经是常量时间复杂度,即 O(1),其中涉及到碰撞、扩容等场景的处理属于数据结构基础,这里不展开。
但 HashMap 并不是线程安全的容器,如果要让它在多线程并发下能正确地工作,就要用 Collections.synchronizedMap 进行包装,这相当于给 Map 接口的所有访问方法都自动加全局锁;
或者改用 ConcurrentHashMap 来实现,这相当于给 Map 的访问分段加锁(从 JDK 8 起已取消分段加锁,改为 CAS+Synchronized 锁单个元素)。
无论采用怎样的实现方法,线程安全措施都会带来一定的吞吐量损失。

# 功能考虑 => 方案权衡
进一步说,如果只比较吞吐量,完全不去考虑命中率、淘汰策略、缓存统计、过期失效等功能该如何实现,那也不必选择,JDK 8 改进之后的 ConcurrentHashMap 基本上就是你能找到的吞吐量最高的缓存容器了。
可是很多场景里,以上提及的功能至少有部分一两项是必须的,不可能完全不考虑,这才涉及到不同缓存方案的权衡问题。

# Caffeine 基准测试 => QPS
根据 Caffeine 给出的一组目前业界主流进程内缓存实现方案,包括有 Caffeine、ConcurrentLinkedHashMap、LinkedHashMap、Guava Cache、Ehcache 和 Infinispan Embedded 的对比,
从Benchmarks中体现出的它们在 “8 线程、75%读操作、25%写操作” 下的吞吐量来看,各种缓存组件库的性能差异还是十分明显的,最高与最低的相差了足有一个数量级,如下图所示:

(图片来自Caffeine—基准测试
在这里插入图片描述

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
# 吞吐量的影响面
这种并发读写的场景中,吞吐量受多方面因素的共同影响:
+ 怎样设计数据结构以尽可能避免数据竞争,存在竞争风险时怎样处理同步(主要有使用锁实现的悲观同步和使用CAS实现的乐观同步)
+ 如何避免伪共享现象(False Sharing,这也算是典型缓存提升开发复杂度的例子)发生
+ ...等等

# 避免竞争是关键
其中第一点尽可能避免竞争是最关键的,无论如何实现同步都不会比直接无须同步更快,下面以 Caffeine 为例,介绍一些缓存如何避免竞争、提高吞吐量的设计。

# 并发读写问题
缓存中最主要的数据竞争源于 “读取数据的同时,也会伴随着对数据状态的写入操作,写入数据的同时,也会伴随着数据状态的读取操作”。
例如:
+ 读取时要同时更新数据的最近访问时间和访问计数器的状态,以实现缓存的淘汰策略;
=> 下面会提到,为了追求高效,可能不会记录时间和次数(例如通过调整链表顺序来表达时间先后、通过 Sketch 结构来表达热度高低)
+ 又或者读取时要同时判断数据的超期时间等信息,以实现失效重加载等其他扩展功能。

# 读写状态维护 => 两种操作:同步处理、异步日志提交
对以上伴随读写操作而来的状态维护,有两种可选择的处理思路:
+ 一种是以 Guava Cache 为代表的同步处理机制,即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少竞争。
+ 另一种是以 Caffeine 为代表的异步日志提交机制,这种机制参考了经典的数据库设计“ARIES 理论”,将对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程。
=> 尽管日志也涉及到写入操作,有并发的数据变更就必然面临锁竞争,但异步提交的日志已经将原本在 Map 内的锁转移到日志的追加写操作上,日志里优化的余地就比在 Map 中要大得多。


# 额外知识:环形缓冲
所谓环形缓冲,并非 Caffeine 的专有概念,它是一种拥有读、写两个指针的数据复用结构,在计算机科学中有非常广泛的应用。
举个具体例子,例如一台计算机通过键盘输入,并通过 CPU 读取“HELLO WIKIPEDIA”这个长 14 字节的单词,通常需要一个至少 14 字节以上的缓冲区才行。
但如果是环形缓冲结构,读取和写入就应当一起进行,在读取指针之前的位置均可以重复使用。
理想情况下,只要读取指针不落后于写入指针一整圈,这个缓冲区就可以持续工作下去,能容纳无限多个新字符。否则,就必须阻塞写入操作去等待读取清空缓冲区。

(图片来自维基百科:环形缓存区工作原理
在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
# Caffeine 的实现
在 Caffeine 的实现中,设有专门的环形缓存区(Ring Buffer,也常称作 Circular Buffer)来记录由于数据读取而产生的状态变动日志。
为进一步减少竞争,Caffeine 给每条线程都设置一个专用的环形缓冲(对线程取 Hash,哈希值相同的使用同一个缓冲区)。

# 从 Caffeine 读取数据时(相比 ConcurrentHashMap,读差不多)
数据本身会在其内部的 ConcurrentHashMap 中直接返回,而数据的状态信息变更就存入环形缓冲中,由后台线程异步处理。
如果异步处理的速度跟不上状态变更的速度,导致缓冲区满了,那此后接收的状态的变更信息就会直接被丢弃掉,直至缓冲区重新富余。
通过环形缓冲和容忍有损失的状态变更,Caffeine 大幅降低了由于数据读取而导致的垃圾收集和锁竞争,因此 Caffeine 的读取性能几乎能与 ConcurrentHashMap 的读取性能相同。

# 向 Caffeine 写入数据时(相比 ConcurrentHashMap,写满10%)
将使用传统的有界队列(ArrayQueue)来存放状态变更信息,写入带来的状态变更是无损的,不允许丢失任何状态,这是考虑到许多状态的默认值必须通过写入操作来完成初始化,因此写入会有一定的性能损失。
根据 Caffeine 官方给出的数据,相比 ConcurrentHashMap,Caffeine 在写入时大约会慢 10%左右(多了一个写状态)。

2 命中率与淘汰策略

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
# 缓存的淘汰策略
有限的物理存储决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡,这要求缓存必须能够自动或者由人工淘汰掉缓存中的低价值数据,由人工管理的缓存淘汰主要取决于开发者如何编码,不能一概而论,这里只讨论由缓存自动进行淘汰的情况。
所说的“缓存如何自动地实现淘汰低价值目标”,现在被称为缓存的淘汰策略,也常称作替换策略或者清理策略。

# 统计结果 => 确定 => 缓存价值
缓存实现自动淘汰低价值数据的容器之前,首先要定义怎样的数据才算是“低价值”?由于缓存的通用性,这个问题的答案必须是与具体业务逻辑是无关的,
只能从缓存工作过程收集到的统计结果来确定数据是否有价值,通用的统计结果包括但不限于数据何时进入缓存、被使用过多少次、最近什么时候被使用,等等。
由此决定了一旦确定选择何种统计数据,及如何通用地、自动地判定缓存中每个数据价值高低,也相当于决定了缓存的淘汰策略是如何实现的。

# 淘汰策略
目前,最基础的淘汰策略实现方案有以下三种:
+ FIFO(First In First Out):优先淘汰最早进入被缓存的数据。
FIFO 实现十分简单,但一般来说它并不是优秀的淘汰策略,越是频繁被用到的数据,往往会越早被存入缓存之中。
如果采用这种淘汰策略,很可能会大幅降低缓存的命中率。
+ LRU(Least Recent Used):优先淘汰最久未被使用访问过的数据。
LRU 通常会采用 HashMap 加 LinkedList 双重结构(如 LinkedHashMap)来实现,以 HashMap 来提供访问接口,保证常量时间复杂度的读取性能,
以 LinkedList 的链表元素顺序来表示数据的时间顺序,每次缓存命中时把返回对象调整到 LinkedList 开头,每次缓存淘汰时从链表末端开始清理数据。
对大多数的缓存场景来说,LRU 都明显要比 FIFO 策略合理,尤其适合用来处理短时间内频繁访问的热点对象。
但相反,它的问题是如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,此时这些热点数据依然要面临淘汰的命运,LRU 依然可能错误淘汰价值更高的数据。
+ LFU(Least Frequently Used):优先淘汰最不经常使用的数据。
LFU 会给每个数据添加一个访问计数器,每访问一次就加 1,需要淘汰时就清理计数器数值最小的那批数据。
LFU 可以解决上面 LRU 中热点数据间隔一段时间不访问就被淘汰的问题,但同时它又引入了两个新的问题:
+ 需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,在上一节“吞吐量”里解释了这样做会带来高昂的维护开销;
+ 不便于处理随时间变化的热度变化,例如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存。

# 没有完美的策略
缓存淘汰策略直接影响缓存的命中率,没有一种策略是完美的、能够满足全部系统所需的。

# LFU 的延伸
不过,随着淘汰算法的发展,近年来的确出现了许多相对性能要更好的,也更为复杂的新算法。
以 LFU 分支为例,针对它存在的两个问题,近年来提出的 TinyLFU 和 W-TinyLFU 算法就往往会有更好的效果。
+ TinyLFU(Tiny Least Frequently Used):TinyLFU 是 LFU 的改进版本。
为了缓解 LFU 每次访问都要修改计数器所带来的性能负担,TinyLFU 会首先采用 Sketch 对访问数据进行分析,
所谓 Sketch 是统计学上的概念,指用少量的样本数据来估计全体数据的特征,这种做法显然牺牲了一定程度的准确性,
但是只要样本数据与全体数据具有相同的概率分布,Sketch 得出的结论仍不失为一种高效与准确之间权衡的有效结论。
借助Count–Min Sketch算法(可视为布隆过滤器的一种等价变种结构),TinyLFU 可以用相对小得多的记录频率和空间来近似地找出缓存中的低价值数据。
为了解决 LFU 不便于处理随时间变化的热度变化问题,TinyLFU 采用了基于“滑动时间窗”(在“流量控制”中我们会更详细地分析这种算法)的热度衰减算法,
简单理解就是每隔一段时间,便会把计数器的数值减半,以此解决“旧热点”数据难以清除的问题。
+ W-TinyLFU(Windows-TinyLFU):W-TinyLFU 又是 TinyLFU 的改进版本。
# TinyLFU 的问题
TinyLFU 在实现减少计数器维护频率的同时,也带来了无法很好地应对稀疏突发访问的问题,
所谓稀疏突发访问是指有一些绝对频率较小,但突发访问频率很高的数据,例如某些运维性质的任务,也许一天、一周只会在特定时间运行一次,其余时间都不会用到,
此时 TinyLFU 就很难让这类元素通过 Sketch 的过滤,因为它们无法在运行期间积累到足够高的频率。
# W-TinyLFU => 整体 LFU、局部 LRU
应对短时间的突发访问是 LRU 的强项,W-TinyLFU 就结合了 LRU 和 LFU 两者的优点,从整体上看是它是 LFU 策略,从局部实现上看又是 LRU 策略。
具体做法是将新记录暂时放入一个名为 Window Cache 的前端 LRU 缓存里面,让这些对象可以在 Window Cache 中累积热度,
如果能通过 TinyLFU 的过滤器,再进入名为 Main Cache 的主缓存中存储,主缓存根据数据的访问频繁程度分为不同的段(LFU 策略,实际上 W-TinyLFU 只分了两段),
但单独某一段局部来看又是基于 LRU 策略去实现的(称为 Segmented LRU)。
每当前一段缓存满了之后,会将低价值数据淘汰到后一段中去存储,直至最后一段也满了之后,该数据就彻底清理出缓存。
仅靠以上简单的、有限的介绍,你不一定能完全理解 TinyLFU 和 W-TinyLFU 的工作原理,但肯定能看出这些改进算法比起原来基础版本的 LFU 要复杂了许多。
有时候为了取得理想的效果,采用较为复杂的淘汰策略是不得已的选择。
Caffeine 官方给出的 W-TinyLFU 以及另外两种高级淘汰策略ARC(Adaptive Replacement Cache)、LIRS(Low Inter-Reference Recency Set)与基础的 LFU 策略之间的对比,如图所示:

在搜索场景中,三种高级策略的命中率较为接近于理想曲线(Optimal),而 LRU 则差距最远,Caffeine 官方给出的数据库、网站、分析类等应用场景中,这几种策略之间的绝对差距不尽相同,但相对排名基本上没有改变,最基础的淘汰策略的命中率是最低的。对其他缓存淘汰策略感兴趣的读者可以参考维基百科中对Cache Replacement Policies的介绍。

(图片来自Caffeine
在这里插入图片描述


3 扩展功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
一般来说,一套标准的 Map 接口(或者来自JSR 107的 javax.cache.Cache 接口)就可以满足缓存访问的基本需要。

# 缓存的额外功能
不过在“访问”之外,专业的缓存往往还会提供很多额外的功能:
+ 加载器:许多缓存都有“CacheLoader”之类的设计,加载器可以让缓存从只能被动存储外部放入的数据,变为能够主动通过加载器去加载指定 Key 值的数据,加载器也是实现自动刷新功能的基础前提。
+ 淘汰策略:有的缓存淘汰策略是固定的,也有一些缓存能够支持用户自己根据需要选择不同的淘汰策略。
+ 失效策略:要求缓存的数据在一定时间后自动失效(移除出缓存)或者自动刷新(使用加载器重新加载)。
+ 事件通知:缓存可能会提供一些事件监听器,让你在数据状态变动(如失效、刷新、移除)时进行一些额外操作。有的缓存还提供了对缓存数据本身的监视能力(Watch 功能)。
+ 并发级别:对于通过分段加锁来实现的缓存(以 Guava Cache 为代表),往往会提供并发级别的设置。可以简单将其理解为缓存内部是使用多个 Map 来分段存储数据的,并发级别就用于计算出使用 Map 的数量。如果将这个参数设置过大,会引入更多的 Map,需要额外维护这些 Map 而导致更大的时间和空间上的开销;如果设置过小,又会导致在访问时产生线程阻塞,因为多个线程更新同一个 ConcurrentMap 的同一个值时会产生锁竞争。
+ 容量控制:缓存通常都支持指定初始容量和最大容量,初始容量目的是减少扩容频率,这与 Map 接口本身的初始容量含义是一致的。最大容量类似于控制 Java 堆的-Xmx 参数,当缓存接近最大容量时,会自动清理掉低价值的数据。
+ 引用方式:支持将数据设置为软引用或者弱引用,提供引用方式的设置是为了将缓存与 Java 虚拟机的垃圾收集机制联系起来。
+ 统计信息:提供诸如缓存命中率、平均加载时间、自动回收计数等统计。
+ 持久化:支持将缓存的内容存储到数据库或者磁盘中,进程内缓存提供持久化功能的作用不是太大,但分布式缓存大多都会考虑提供持久化功能。

# 进程内缓存方案
至此,已简要介绍了缓存的三项属性:吞吐量、命中率和扩展功能,下面是几款主流进程内缓存方案,仅供参考:

在这里插入图片描述

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
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
# 进程内缓存、分布式缓存
相比起缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多,
所以对分布式缓存来说,处理与网络有相关的操作是对吞吐量影响更大的因素,往往也是比淘汰策略、扩展功能更重要的关注点,
这决定了尽管也有 Ehcache、Infinispan 这类能同时支持分布式部署和进程内嵌部署的缓存方案,但通常进程内缓存和分布式缓存选型时会有完全不同的候选对象及考察点。
我们决定使用哪种分布式缓存前,首先必须确认自己需求是什么?

# 访问角度分析
+ 读少更新多,通常是不会有人把它拿去做缓存的,因为这样做没有收益。
+ 读多更新少,理论上更适合做复制式缓存;
+ 读多更新多,理论上就更适合做集中式缓存。


# 复制式缓存
# 简介
复制式缓存可以看作是“能够支持分布式的进程内缓存”,它的工作原理与 Session 复制类似。
缓存中所有数据在分布式集群的每个节点里面都存在有一份副本,读取数据时无须网络访问,直接从当前节点的进程内存中返回,理论上可以做到与进程内缓存一样高的读取性能;
当数据发生变化时,就必须遵循复制协议,将变更同步到集群的每个节点中,复制性能随着节点的增加呈现平方级下降,变更数据的代价十分高昂。
# JBossCache
复制式缓存的代表是 JBossCache,这是 JBoss 针对企业级集群设计的缓存方案,支持 JTA 事务,依靠 JGroup 进行集群节点间数据同步。
以 JBossCache 为典型的复制式缓存曾有一段短暂的兴盛期,但今天基本上已经很难再见到使用这种缓存形式的大型信息系统了。
# JBossCache 问题 => 大规模集群,写入性能差
JBossCache 被淘汰的主要原因是写入性能实在差到不堪入目的程度,它在小规模集群中同步数据尚算差强人意,
但在大规模集群下,很容易就因网络同步的速度跟不上写入速度,进而导致在内存中累计大量待重发对象,最终引发 OutOfMemory 崩溃。
如果对 JBossCache 没有足够了解的话,稍有不慎就要被埋进坑里。
# Infinispan
为了缓解复制式同步的写入效率问题,JBossCache 的继任者 Infinispan 提供了另一种分布式同步模式(这种同步模式的名字就叫做“分布式”),允许用户配置数据需要复制的副本数量,
例如集群中有八个节点,可以要求每个数据只保存四份副本,此时,缓存的总容量相当于是传统复制模式的一倍,
如果要访问的数据在本地缓存中没有存储,Infinispan 完全有能力感知网络的拓扑结构,知道应该到哪些节点中寻找数据。

# 集中式缓存(主流方案) => 读写需要网络
# 简介
集中式缓存是目前分布式缓存的主流形式,集中式缓存的读、写都需要网络访问。
# 好坏
其好处是不会随着集群节点数量的增加而产生额外的负担,其坏处自然是读、写都不再可能达到进程内缓存那样的高性能。
# 可异构
集中式缓存还有一个必须提到的关键特点,它与使用缓存的应用分处在独立的进程空间中,其好处是它能够为异构语言提供服务,
例如用 C 语言编写的 Memcached 完全可以毫无障碍地为 Java 语言编写的应用提供缓存服务;
# 序列化问题
但其坏处是如果要缓存对象等复杂类型的话,基本上就只能靠序列化来支撑具体语言的类型系统(支持 Hash 类型的缓存,可以部分模拟对象类型),不仅有序列化的成本,还很容易导致传输成本也显著增加。
举个例子,假设某个有 100 个字段的大对象变更了其中 1 个字段的值,通常缓存也不得不把整个对象所有内容重新序列化传输出去才能实现更新,因此,一般集中式缓存更提倡直接缓存原始数据类型而不是对象。
相比之下,JBossCache 通过它的字节码自审(Introspection)功能和树状存储结构(TreeCache),做到了自动跟踪、处理对象的部分变动,用户修改了对象中哪些字段的数据,缓存就只会同步对象中真正变更那部分数据。
# 无脑上 Redis(热的死)
如今Redis广为流行,基本上已经打败了 Memcached 及其他集中式缓存框架,成为集中式缓存的首选,甚至可以说成为了分布式缓存的实质上的首选,几乎到了不必管读取、写入哪种操作更频繁,都可以无脑上 Redis 的程度。
也因如此,之前说到哪些数据适合用复制式缓存、哪些数据适合集中式缓存时,都在开头加了个拗口的“理论上”。
尽管 Redis 最初设计的本意是 NoSQL 数据库而不是专门用来做缓存的,可今天它确实已经成为许多分布式系统中无可或缺的基础设施,广泛用作缓存的实现方案。

# 数据一致性角度分析 => 高性能高可用,并不保证强一致性
从数据一致性角度说,缓存本身也有集群部署的需求,理论上你应该认真考虑一下是否能接受不同节点取到的缓存数据有可能存在差异。
例如刚刚放入缓存中的数据,另外一个节点马上访问发现未能读到;刚刚更新缓存中的数据,另外一个节点访问在短时间内读取到的仍是旧的数据,等等。

根据分布式缓存集群是否能保证数据一致性,可以将它分为 AP 和 CP 两种类型(在“分布式事务”中已介绍过 CAP 各自的含义)。
此处又一次出现了“理论上”,是因为我们实际开发中通常不太会把追求强一致性的数据使用缓存来处理,可以这样做,但是没必要(可类比 MESI 等缓存一致性协议)。
例如,Redis 集群就是典型的 AP 式,有着高性能高可用等特点,却并不保证强一致性。
而能够保证强一致性的 ZooKeeper、Doozerd、Etcd 等分布式协调框架,通常不会有人将它们当为“缓存框架”来使用,这些分布式协调框架的吞吐量相对 Redis 来说是非常有限的。
不过 ZooKeeper、Doozerd、Etcd 倒是常与 Redis 和其他分布式缓存搭配工作,用来实现其中的通知、协调、队列、分布式锁等功能。

# 多级缓存
分布式缓存与进程内缓存各有所长,也有各有局限,它们是互补而非竞争的关系,如有需要,完全可以同时把进程内缓存和分布式缓存互相搭配,构成透明多级缓存(Transparent Multilevel Cache,TMC),如下图:
先不考虑“透明”的话,多级缓存是很好理解的,使用进程内缓存做一级缓存,分布式缓存做二级缓存,
如果能在一级缓存中查询到结果就直接返回,否则便到二级缓存中去查询,再将二级缓存中的结果回填到一级缓存,以后再访问该数据就没有网络请求了。
如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。

# 多级缓存
# 管理问题
尽管多级缓存结合了进程内缓存和分布式缓存的优点,但它的代码侵入性较大,需要由开发者承担多次查询、多次回填的工作,也不便于管理。
如超时、刷新等策略都要设置多遍,数据更新更是麻烦,很容易会出现各个节点的一级缓存、以及二级缓存里数据互相不一致的问题。
必须“透明”地解决以上问题,多级缓存才具有实用的价值。
# 常见做法
一种常见的设计原则是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。
大致做法是当数据发生变动时,在集群内发送推送通知(简单点的话可采用 Redis 的 PUB/SUB,求严谨的话引入 ZooKeeper 或 Etcd 来处理),让各个节点的一级缓存自动失效掉相应数据。
当访问缓存时,提供统一封装好的一、二级缓存联合查询接口,接口外部是只查询一次,接口内部自动实现优先查询一级缓存,未获取到数据再自动查询二级缓存的逻辑。

在这里插入图片描述

缓存风险

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
# 奥卡姆剃刀
本节开篇就提到,缓存不是多多益善,它属于有利有弊,是真正到必须使用时才考虑的解决方案。本节将介绍使用几种常见的缓存风险及其应对办法。

# 缓存穿透 => Cache、DB 都没有
# 简介
缓存的目的是为了缓解 CPU 或者 I/O 的压力,例如对数据库做缓存,大部分流量都从缓存中直接返回,只有缓存未能命中的数据请求才会流到数据库中,这样数据库压力自然就减小了。
但是如果查询的数据在数据库中根本不存在的话,缓存里自然也不会有,这类请求的流量每次都不会命中,每次都会触及到末端的数据库,缓存就起不到缓解压力的作用了,这种查询不存在数据的现象被称为缓存穿透。

# 解决方案
缓存穿透有可能是业务逻辑本身就存在的固有问题,也有可能是被恶意攻击的所导致,为了解决缓存穿透,通常会采取下面两种办法:
+ 对于业务逻辑本身就不能避免的缓存穿透。
可以约定在一定时间内对返回为空的 Key 值依然进行缓存(注意是正常返回但是结果为空,不应把抛异常的也当作空值来缓存了),使得在一段时间内缓存最多被穿透一次。
如果后续业务在数据库中对该 Key 值插入了新记录,那应当在插入之后主动清理掉缓存的 Key 值。如果业务时效性允许的话,也可以将对缓存设置一个较短的超时时间来自动处理。
+ 对于恶意攻击导致的缓存穿透。
通常会在缓存之前设置一个布隆过滤器来解决(布隆过滤器,代价最小)。
所谓恶意攻击是指请求者刻意构造数据库中肯定不存在的 Key 值,然后发送大量请求进行查询。
布隆过滤器是用最小的代价来判断某个元素是否存在于某个集合的办法。如果布隆过滤器给出的判定结果是请求的数据不存在,那就直接返回即可,连缓存都不必去查。
虽然维护布隆过滤器本身需要一定的成本,但比起攻击造成的资源损耗仍然是值得的。

# 缓存击穿 => 单个热点失效,大批请求
# 简介
我们都知道缓存的基本工作原理是首次从真实数据源加载数据,完成加载后回填入缓存,以后其他相同的请求就从缓存中获取数据,缓解数据源的压力。
如果缓存中某些热点数据忽然因某种原因失效了,例如典型地由于超期而失效,此时又有多个针对该数据的请求同时发送过来,这些请求将全部未能命中缓存,都到达真实数据源中去,导致其压力剧增,这种现象被称为缓存击穿。

# 解决方案
要避免缓存击穿问题,通常会采取下面的两种办法:
+ 加锁同步,以请求该数据的 Key 值为锁,使得只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。
如果是进程内缓存出现问题,施加普通互斥锁即可,如果是分布式缓存中出现的问题,就施加分布式锁,这样数据源就不会同时收到大量针对同一个数据的请求了。
+ 热点数据由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理。

# 缓存雪崩 => 大批失效,大批请求
# 简介
缓存击穿是针对单个热点数据失效,由大量请求击穿缓存而给真实数据源带来压力。
有另一种可能是更普遍的情况,不需要是针对单个热点数据的大量请求,而是由于大批不同的数据在短时间内一起失效,导致了这些数据的请求都击穿了缓存到达数据源,同样令数据源在短时间内压力剧增。

# 出现情况 => 缓存预热、冷启动、鸡掰重启
出现这种情况,往往是系统有专门的缓存预热功能,也可能大量公共数据是由某一次冷操作加载的,这样都可能出现由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。
还有一种情况是缓存服务由于某些原因崩溃后重启,此时也会造成大量数据同时失效,这种现象被称为缓存雪崩。

# 解决方案
要避免缓存雪崩问题,通常会采取下面的三种办法:
+ (集群抗流量)提升缓存系统可用性,建设分布式缓存的集群。
+ (分散过期时间)启用透明多级缓存,各个服务节点一级缓存中的数据通常会具有不一样的加载时间,也就分散了它们的过期时间。
+ (一段时间随机失效)将缓存的生存期从固定时间改为一个时间段内的随机时间。例如原本是一个小时过期,那可以缓存不同数据时,设置生存期为 55 分钟到 65 分钟之间的某个随机时间。

缓存污染

污染也属于上面的风险,内容多,就单拎出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 缓存污染 => Cache、DB 不一致
缓存污染是指缓存中的数据与真实数据源中的数据不一致的现象。
尽管在前面是说过缓存通常不追求强一致性,但这显然不能等同于缓存和数据源间连最终的一致性都可以不要求了。
缓存污染多数是由开发者更新缓存不规范造成的。

# 缓存污染 => 案例
看到好些人在写更新缓存数据代码时,先删缓存,再更新数据库。然而,这个是逻辑是错误的。
试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。
于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了,如下:
读 写
----------------------------
删Cache
未命中Cache
读DB
写Cache
写DB
我不知道为什么这么多人用的都是这个逻辑,我发现好些人给了好多非常复杂和诡异的方案,所以,我想写下几个缓存更新的Design Pattern(让我们多一些套路吧)。

# 缓存一致性方案
为了尽可能的提高使用缓存时的一致性,已经总结不少更新缓存可以遵循设计模式
+ Cache Aside
+ Read/Write Through
+ Write Behind Caching
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
# Cache Aside => 最简单、成本最低的(这是最常用最常用的pattern了)
+ 读数据时:先读缓存,缓存命中,直接响应;缓存未命中的话,再读数据源,然后将数据放入缓存,再响应请求。
+ 写数据时:先写数据源,然后失效(而不是更新)掉缓存。

# 案例问题分析
那么,这种方式是否存在上面那个案例的问题呢?我们可以脑补一下。
读 写
----------------------------
写DB
未命中Cache
读DB
写Cache
删Cache
这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。

# 写分析
读数据方面一般没什么出错的余地,但是写数据时,就有必要专门强调两点:
1. 【应写DB删Cache,而不是删Cache写DB】 => 参考上面缓存污染案例
2. 【应写DB删Cache,而不是写DB写Cache】
这很容易理解,如果去更新缓存,更新过程中数据源又被其他请求再次修改的话,缓存又要面临处理多次赋值的复杂时序问题。
所以直接失效缓存,等下次用到该数据时自动回填,期间无论数据源中的值被改了多少次都不会造成任何影响。
写 写
----------------------------
写DB
写DB
写Cache
写Cache

# 极端问题
那么,是不是Cache Aside这个就不会有并发问题了?
不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
读 写
----------------------------
未命中Cache
读DB
写DB
删Cache
写Cache
但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。
而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

所以,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos这样的共识算法太复杂(后面也会讲到 Paxos)。
当然,最好还是为缓存设置上过期时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
# Read/Write Through
我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。
所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。
可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

# Read Through
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

# Write Through
Write Through 套路和Read Through相仿,不过是在更新数据时发生。
当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Write Behind Caching
Write Behind 又叫 Write Back。
一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。
所以,基础很重要,我已经不是一次说过基础很重要这事了。

# 异步的好处 => 性能高
Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。
这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

# 异步的坏处 => 数据丢失
但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。

# 没有银弹 => 只有权衡
在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理。
有时候,强一致性和高性能,高可用和高性性是有冲突的。
=> 软件设计从来都是取舍Trade-Off。

# 复杂性
另外,Write Back实现逻辑比较复杂,因为他需要知道有哪数据是被更新了的,需要刷到持久层上。
操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

在wikipedia上有一张write back的流程图,基本逻辑如下:

在这里插入图片描述

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
再多唠叨一些

# 1. 遵循最佳实践
上面讲的这些Design Pattern,其实并不是软件架构里的mysql数据库和memcache/redis的更新策略,这些东西都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。
基本上来说,这些缓存更新的设计模式都是非常老古董的,而且历经长时间考验的策略,所以这也就是,工程学上所谓的Best Practice,遵从就好了。

# 2. 宏观来源于微观
有时候,我们觉得能做宏观的系统架构的人一定是很有经验的,其实,宏观系统架构中的很多设计都来源于这些微观的东西。
例如:
+ 云计算中的很多虚拟化技术的原理,和传统的虚拟内存不是很像么?
+ Unix下的那些I/O模型,也放大到了架构里的同步异步的模型,还有Unix发明的管道不就是数据流式计算架构吗?
+ TCP的好些设计也用在不同系统间的通讯中,
+ ...
仔细看看这些微观层面,你会发现有很多设计都非常精妙……
所以,请允许我在这里放句观点鲜明的话 “如果你要做好架构,首先你得把计算机体系结构以及很多老古董的基础技术吃透了”。

# 3. 不要想当然的设计
在软件开发或设计中,我非常建议在之前先去参考一下已有的设计和思路,看看相应的guideline,best practice或design pattern,
吃透了已有的这些东西,再决定是否要重新发明轮子。千万不要似是而非地,想当然的做软件设计。

# 4. 事务问题
上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题。
比如,更新Cache成功,更新数据库失败了怎么吗?或是反过来。
关于这个事,如果你需要强一致性,你需要使用“两阶段提交协议”——prepare, commit/rollback,比如Java 7 的XAResource,还有MySQL 5.7的 XA Transaction,有些cache也支持XA,比如EhCache。
当然,XA这样的强一致性的玩法会导致性能下降,关于分布式的事务的相关话题,上面已经介绍了。

【日期标记】2022-08-12 16:01:06 以上同步完成

架构安全性

1
2
3
4
5
6
7
8
9
10
11
12
13
# 计算机安全
即使只限定在“软件架构设计”这个语境下,系统安全仍然是一个很大的话题。
我们谈论的计算机系统安全,不仅仅是指“防御系统被黑客攻击”这样狭隘的安全,例如:
+ 认证(Authentication):系统如何正确分辨出操作用户的真实身份?
+ 授权( Authorization):系统如何控制一个用户该看到哪些数据、能操作哪些功能?
+ 凭证(Credential):系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
+ 保密(Confidentiality):系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
+ 传输(Transport Security):系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
+ 验证(Verification):系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?

# 麻烦 => 业界有解决方案
与安全相关的问题,一般不会直接创造价值,解决起来又烦琐复杂,费时费力,因此经常性被开发者有意无意地忽略掉。
庆幸的是这些问题基本上也都是与具体系统、具体业务无关的通用性问题,这意味着它们往往会存在着业界通行的、已被验证过是行之有效的解决方案,乃至已经形成行业标准,不需要开发者自己从头去构思如何解决。

认证

认证(Authentication)
系统如何正确分辨出操作用户的真实身份?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 三个基本问题
# “你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)
认证、授权和凭证可以说是一个系统中最基础的安全设计,哪怕再简陋的信息系统,大概也不可能忽略掉“用户登录”功能。
信息系统为用户提供服务之前,总是希望先弄清楚“你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)这三个基本问题。

# 基础设施负责
然而,这三个基本问题又并不像部分开发者认为的那样,只是一个“系统登录”功能,仅仅是校验一下用户名、密码是否正确这么简单。
账户和权限信息作为一种必须最大限度保障安全和隐私,同时又要兼顾各个系统模块甚至系统间共享访问的基础主数据,它的存储、管理与使用都面临一系列复杂的问题。
对于某些大规模的信息系统,账户和权限的管理往往要由专门的基础设施来负责,例如微软的活动目录(Active Directory,AD)或者轻量目录访问协议(Lightweight Directory Access Protocol,LDAP),跨系统的共享使用甚至会用到区块链技术。

# 早期认证:代码 -> 用户
另外还有一个认知偏差:尽管“认证”是解决“你是谁?”的问题,但这里的“你”并不一定是指人(真不是在骂你),也可能是指外部的代码,即第三方的类库或者服务。
最初,对代码认证的重要程度甚至高于对最终用户的认证,例如在早期的 Java 系统里,安全认证默认是特指“代码级安全”,即你是否信任要在你的电脑中运行的代码。
这是由 Java 当时的主要应用形式 Java Applets 所决定的:类加载器从远端下载一段字节码,以 Applets 的形式在用户的浏览器中运行,由于 Java 操控计算机资源的能力要远远强于 JavaScript,因此必须先确保这些代码不会损害用户的计算机,否则就谁都不敢去用。
这一阶段的安全观念催生了现在仍然存在于 Java 技术体系中的“安全管理器”(java.lang.SecurityManager)、“代码权限许可”(java.lang.RuntimePermission)等概念。
如今,对外部类库和服务的认证需求依然普遍,但相比起五花八门的最终用户认证来说,代码认证的研究发展方向已经很固定,基本上都统一到证书签名上。
在本节中,认证的范围只限于对最终用户的认证,而代码认证会安排在“分布式的基石”中的“服务安全”去讲解。

认证的标准

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Java Applets 之后 => 代码安全 -> 用户安全
世纪之交,Java 迎来了 Web 时代的辉煌,互联网的迅速兴起促使 Java 进入了快速发展时期。
这时候,基于 HTML 和 JavaScript 的超文本 Web 应用迅速超过了“Java 2 时代”之前的 Java Applets 应用,B/S 系统对最终用户认证的需求使得“安全认证”的重点逐渐从“代码级安全”转为“用户级安全”,即你是否信任正在操作的用户。

# 标准麻烦,可选方案 -> 不用造轮
在 1999 年,随 J2EE 1.2(它是 J2EE 的首个版本,为了与 J2SE 同步,初始版本号直接就是 1.2)发布的 Servlet 2.2 中,添加了一系列用于认证的 API,主要包括下列两部分内容:
+ 标准方面,添加了四种内置的、不可扩展的认证方案,即 Client-Cert、Basic、Digest 和 Form。
+ 实现方面,添加了与认证和授权相关的一套程序接口,例如HttpServletRequest::isUserInRole()、HttpServletRequest::getUserPrincipal()等方法。
一项发布超过 20 年的老旧技术,原本并没有什么专门提起的必要性,之所以引用这件事,是希望从它包含的两部分内容中引出一个架构安全性的经验原则:
=> 以标准规范为指导、以标准接口去实现。安全涉及的问题很麻烦,但解决方案已相当成熟,对于 99%的系统来说,在安全上不去做轮子,不去想发明创造,严格遵循标准就是最恰当的安全设计。

# 三种认证方式
引用 J2EE 1.2 对安全的改进还有另一个原因,它内置的 Basic、Digest、Form 和 Client-Cert 这四种认证方案都很有代表性,刚好分别覆盖了通信信道、协议和内容层面的认证。
而这三种层面认证恰好涵盖了主流的三种认证方式,具体含义和应用场景列举如下。
+ (TLS)通信信道上的认证:你和我建立通信连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于 SSL/TLS 传输安全层的认证。
+ (HTTP)通信协议上的认证:你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于 HTTP 协议的认证。
+ (Web 内容)通信内容上的认证:你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于 Web 内容的认证。

下面重点了解基于通信协议和通信内容的两种认证方式(TLS后面传输讲到)。

1. HTTP 认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
前文已经提前用到了一个技术名词:认证方案(Authentication Schemes),它是指生成用户身份凭证的某种方法,这个概念最初源于 HTTP 协议的认证框架(Authentication Framework)。

# 1. 请求 => 401
IETF 在RFC 7235中定义了 HTTP 协议的通用认证框架,要求所有支持 HTTP 协议的服务器,在未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized 的状态码,
同时应在响应报文头里附带以下两个分别代表网页认证和代理认证的 Header 之一,告知客户端应该采取何种方式产生能代表访问者身份的凭证信息:
WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>
# 2. 输入凭证 => 200/403
接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,由服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden 错误。
请求头报文应包含以下 Header 项之一:
Authorization: <认证方案> <凭证内容>
Proxy-Authorization: <认证方案> <凭证内容>

# 产生凭证、如何产生凭证 => 分开实现
HTTP 认证框架提出认证方案是希望能把认证“要产生身份凭证”的目的与“具体如何产生凭证”的实现分离开来,无论客户端通过生物信息(指纹、人脸)、用户密码、数字证书抑或其他方式来生成凭证,都属于是如何生成凭证的具体实现,都可以包容在 HTTP 协议预设的框架之内。
HTTP 认证框架的工作流程如图所示:

在这里插入图片描述


举个栗子

1
2
3
4
5
6
7
8
9
10
# 四步认证流程
以上概念性的介绍可能会有些枯燥抽象,下面将以最基础的认证方案:HTTP Basic 认证为例来介绍认证是如何工作的。
HTTP Basic 认证是一种主要以演示为目的的认证方案,也应用于一些不要求安全性的场合,例如家里的路由器登录等。
Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。
1. 例如请求资源GET /admin后,浏览器会收到来自服务端的如下响应:
HTTP/1.1 401 Unauthorized
Date: Mon, 24 Feb 2020 16:50:53 GMT
WWW-Authenticate: Basic realm="example from icyfenix.cn"

2. 此时,浏览器必须询问最终用户,即弹出类似下图所示的 HTTP Basic 认证对话框,要求提供用户名和密码。

在这里插入图片描述

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
3. 用户在对话框中输入密码信息,例如输入用户名icyfenix,密码123456,浏览器会将字符串icyfenix:123456编码为aWN5ZmVuaXg6MTIzNDU2,然后发送给服务端,HTTP 请求如下所示:
GET /admin HTTP/1.1
Authorization: Basic aWN5ZmVuaXg6MTIzNDU2

4. 服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就返回/admin的资源,否则就返回 403 Forbidden 错误,禁止下一步操作。
注意 Base64 只是一种编码方式,并非任何形式的加密,所以 Basic 认证的风险是显而易见的。

# 其他认证方案
除 Basic 认证外,IETF 还定义了很多种可用于实际生产环境的认证方案,列举如下。
+ Digest => Basic 加强版(加盐)
RFC 7616,HTTP 摘要认证,可视为 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后再通过 MD5/SHA 等哈希算法取摘要发送出去。
但是这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了 Nonce 这样的动态盐值去抵御重放和冒认,遇到中间人攻击时依然存在显著的安全风险【关于加解密问题,后面讨论】。
+ Bearer => 很熟悉,对吧(同时认证与授权)
RFC 6750,基于 OAuth 2 规范来完成认证,OAuth2 是一个同时涉及认证与授权的协议【在后续“授权”将详细介绍OAuth2】。
+ HOBA => 自签名证书认证
RFC 7486 ,HOBA(HTTP Origin-Bound Authentication)是一种基于自签名证书的认证方案。
基于数字证书的信任关系主要有两类模型:一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;
另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificate)自签名证书模型【在后续“传输”将详细介绍数字证书】。

# 自定义扩展认证
HTTP 认证框架中的认证方案是允许自行扩展的,并不要求一定由 RFC 规范来定义,只要用户代理(User Agent,通常是浏览器,泛指任何使用 HTTP 协议的程序)能够识别这种私有的认证方案即可。
因此,很多厂商也扩展了自己的认证方案。
+ AWS4-HMAC-SHA256:亚马逊 AWS 基于 HMAC-SHA256 哈希算法的认证。
+ NTLM / Negotiate:这是微软公司 NT LAN Manager(NTLM)用到的两种认证方式。
+ Windows Live ID:微软开发并提供的“统一登入”认证。
+ Twitter Basic:一个不存在的网站所改良的 HTTP 基础认证。
+ ...

2. Web 认证

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
# Web认证 => 表单认证
IETF 为 HTTP 认证框架设计了可插拔(Pluggable)的认证方案,原本是希望能涌现出各式各样的认证方案去支持不同的应用场景。
尽管上节列举了一些还算常用的认证方案,但目前的信息系统,尤其是在系统对终端用户的认证场景中,直接采用 HTTP 认证框架的比例其实十分低,这不难理解,HTTP 是“超文本传输协议”,传输协议的根本职责是把资源从服务端传输到客户端,至于资源具体是什么内容,只能由客户端自行解析驱动。
以 HTTP 协议为基础的认证框架也只能面向传输协议而不是具体传输内容来设计,如果用户想要从服务器中下载文件,弹出一个 HTTP 服务器的对话框,让用户登录是可接受的;
但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由 HTTP 服务器来负责认证。
这种依靠内容而不是传输协议来实现的认证方式,在万维网里被称为“Web 认证”,由于实现形式上登录表单占了绝对的主流,因此通常也被称为“表单认证”(Form Authentication)。

# 表单认证优点 => 无标准 => 自行实现
直至 2019 年以前,表单认证都没有什么行业标准可循,表单是什么样,其中的用户字段、密码字段、验证码字段是否要在客户端加密,采用何种方式加密,接受表单的服务地址是什么等,都完全由服务端与客户端的开发者自行协商决定。
“没有标准的约束”反倒成了表单认证的一大优点,表单认证允许我们做出五花八门的页面,各种程序语言、框架或开发者本身都可以自行决定认证的全套交互细节。

# 实现层面、架构层面
可能你还记得开篇中说的“遵循规范、别造轮子就是最恰当的安全”,这里又将表单认证的高自由度说成是一大优点,好话都给说全了。
提倡用标准规范去解决安全领域的共性问题,这条原则完全没有必要与界面是否美观合理、操作流程是否灵活便捷这些应用需求对立起来。
+ 实现层面满足
例如,想要支持密码或扫码等多种登录方式、想要支持图形验证码来驱逐爬虫与机器人、想要支持在登录表单提交之前进行必要的表单校验,等等,
这些需求十分具体,不具备写入标准规范的通用性,却具备足够的合理性,应当在实现层面去满足。
+ 架构层面遵循
同时,如何控制权限保证不产生越权操作、如何传输信息保证内容不被窃听篡改、如何加密敏感内容保证即使泄漏也不被逆推出明文,等等,
这些问题已有通行的解决方案,明确定义在规范之中,也应当在架构层面去遵循。

# 表单认证 不等于 HTTP认证 => 表单=>Vue页面,认证=>OAuth2
表单认证与 HTTP 认证不见得是完全对立的,两者有不同的关注点,可以结合使用。
以 Fenix’s Bookstore 的登录功能为例,页面表单是一个自行设计的 Vue.js 页面,但认证的整个交互过程遵循 OAuth2 规范的密码模式。

# WebAuthn 放弃密码 => 生物识别、实体密钥
2019 年 3 月,万维网联盟(World Wide Web Consortium,W3C)批准了由FIDO(Fast IDentity Online,一个安全、开放、防钓鱼、无密码认证标准的联盟)领导起草的世界首份 Web 内容认证的标准“WebAuthn”(在这节里,我们只讨论 WebAuthn,不会涉及 CTAP、U2F 和 UAF),
这里也许又有一些思维严谨的读者会感到矛盾与奇怪,不是才说了 Web 表单长什么样、要不要验证码、登录表单是否在客户端校验等等是十分具体的需求,不太可能定义在规范上的吗?确实如此,
所以 WebAuthn 彻底抛弃了传统的密码登录方式,改为直接采用生物识别(指纹、人脸、虹膜、声纹)或者实体密钥(以 USB、蓝牙、NFC 连接的物理密钥容器)来作为身份凭证,
从根本上消灭了用户输入错误产生的校验需求和防止机器人模拟产生的验证码需求等问题,甚至可以省掉表单界面,所以这个规范不关注界面该是什么样子、要不要验证码、是否要前端校验这些问题。

# WebAuthn 软、硬支持
由于 WebAuthn 相对复杂,在阅读下面内容之前,如果你的设备和环境允许,建议先在GitHub 网站的 2FA 认证功能中实际体验一下如何通过 WebAuthn 完成两段式登录,再继续阅读后面的内容。
+ 硬件方面,要求用带有 Touch ID 的 MacBook,或者其他支持指纹、FaceID 验证的手机(目前在售的移动设备基本都带有生物识别装置)。
+ 软件方面,直至 iOS 13.6,iPhone 和 iPad 仍未支持 WebAuthn,但 Android 和 Mac OS 系统中的 Chrome,以及 Windows 的 Edge 浏览器都已经可以正常使用 WebAuthn 了。

下图展示了使用 WebAuthn 登录不同浏览器的操作界面。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
# WebAuthn => 注册流程
WebAuthn 规范涵盖了“注册”与“认证”两大流程,先来介绍注册流程,它大致可以分为以下步骤:
1. 用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息均不包含在 WebAuthn 标准的定义范围内。
2. 当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为 Challenge)和用户的 UserID(在规范中称作凭证 ID),返回给客户端。
3. 客户端的 WebAuthn API 接收到 Challenge 和 UserID,把这些信息发送给验证器(Authenticator),验证器可理解为用户设备上 TouchID、FaceID、实体密钥等认证设备的统一接口。
4. 验证器提示用户进行验证,如果支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),由验证器存储私钥、用户信息以及当前的域名。
然后使用私钥对 Challenge 进行签名,并将签名结果、UserID 和公钥一起返回浏览器。
5. 浏览器将验证器返回的结果转发给服务器。
6. 服务器核验信息,检查 UserID 与之前发送的是否一致,并用公钥解密后得到的结果与之前发送的 Challenge 相比较,一致即表明注册通过,由服务端存储该 UserID 对应的公钥。

以上步骤的时序如图所示:

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# WebAuthn => 登录流程
登录流程与注册流程类似,如果你理解了注册流程,就很容易理解登录流程了。

登录流程大致可以分为以下步骤:
1. 用户访问登录页面,填入用户名后即可点击登录按钮。
2. 服务器返回随机字符串 Challenge、用户 UserID。
3. 浏览器将 Challenge 和 UserID 转发给验证器。
4. 验证器提示用户进行认证操作。
由于在注册阶段验证器已经存储了该域名的私钥和用户信息,所以如果域名和用户都相同的话,就不需要生成密钥对了,直接以存储的私钥加密 Challenge,然后返回给浏览器。
5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。

# WebAuthn 非对称加密
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案。
+ 私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;
+ 公钥是公开的,可以被任何人看到或存储。公钥可用于验证私钥生成的签名,但不能用来签名,除了得知私钥外,没有其他途径能够生成可被公钥验证为有效的签名,这样服务器就可以通过公钥是否能够解密来判断最终用户的身份是否合法。

# WebAuthn 好处 => 安全,便捷
WebAuthn 还一揽子地解决了传统密码在网络传输上的风险,在下面“保密”中我们会讲到无论密码是否客户端进行加密、如何加密,对防御中间人攻击来说都是没有意义的。
更值得夸赞的是 WebAuthn 为登录过程带来极大的便捷性,不仅注册和验证的用户体验十分优秀,而且彻底避免了用户在一个网站上泄漏密码,所有使用相同密码的网站都受到攻击的问题,这个优点使得用户无须再为每个网站想不同的密码。

当前的 WebAuthn 还很年轻,普及率暂时还很有限,但几年之内它必定会发展成 Web 认证的主流方式,被大多数网站和系统所支持。

认证的实现

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
# JAAS => Java认证授权服务
了解过业界标准的认证规范以后,这部分简要介绍一下在 Java 技术体系内通常是如何实现安全认证的。
Java 其实也有自己的认证规范,第一个系统性的 Java 认证规范发布于 Java 1.3 时代,是由 Sun 公司提出的同时面向代码级安全和用户级安全的认证授权服务
=> JAAS(Java Authentication and Authorization Service,Java 认证和授权服务,Java 1.3 处于扩展包中,Java 1.4 时纳入标准包)。

# JAAS 已不用,但规范好
尽管 JAAS 已经考虑了最终用户的认证,但代码级安全在规范中仍然占更主要的地位。
可能今天用过甚至听过 JAAS 的 Java 程序员都已经不多了,但是这个规范提出了很多在今天仍然活跃于主流 Java 安全框架中的概念。
例如一般把用户存放在“Principal”之中、密码存在“Credentials”之中、登录后从安全上下文“Context”中获取状态等常见的安全概念,都可以追溯到这一时期所定下的 API:
+ LoginModule (javax.security.auth.spi.LoginModule)
+ LoginContext (javax.security.auth.login.LoginContext)
+ Subject (javax.security.auth.Subject)
+ Principal (java.security.Principal)
+ Credentials(javax.security.auth.Destroyable、javax.security.auth.Refreshable)

# JAAS 难推广 => 1、复杂 2、依赖容器
JAAS 开创了这些沿用至今的安全概念,但规范本身实质上并没有得到广泛的应用,认为有两大原因:
1. 一方面是由于 JAAS 同时面向代码级和用户级的安全机制,使得它过度复杂化,难以推广。
在这个问题上 Java 社区一直有做持续的增强和补救,例如 Java EE 6 中的 JASPIC、Java EE 8 中的 EE Security:
+ JSR 115:Java Authorization Contract for Containers(JACC)
+ JSR 196:Java Authentication Service Provider Interface for Containers(JASPIC)
+ JSR 375: Java EE Security API(EE Security)
2. 而另一方面,可能是更重要的一个原因,在 21 世纪的第一个十年里,以“With EJB”为口号,以 WebSphere、Jboss 等为代表的 J2EE 容器环境,
与以“Without EJB”为口号、以 Spring、Hibernate 等为代表的轻量化开发框架产生了激烈的竞争,结果是后者获得了全面胜利。
这个结果使得依赖于容器安全的 JAAS 无法得到大多数人的认可。

# Apache Shiro、Spring Security
在今时今日,实际活跃于 Java 安全领域的是两个私有的(私有的意思是不由 JSR 所规范的,即没有 java/javax.*作为包名的)的安全框架:Apache Shiro和Spring Security。
相较而言,Shiro 更便捷易用,而 Spring Security 的功能则要复杂强大一些。
无论是单体架构还是微服务架构的 Fenix’s Bookstore,都选择了 Spring Security 作为安全框架,这个选择与功能、性能之类的考量没什么关系,就只是因为 Spring Boot、Spring Cloud 全家桶的缘故。

# 安全框架四功能
只从目标上看,两个安全框架提供的功能都很类似,大致包括以下四类:
+ 认证功能:以 HTTP 协议中定义的各种认证、表单等认证方式确认用户身份,这是本节的主要话题。
+ 安全上下文:用户获得认证之后,要开放一些接口,让应用可以得知该用户的基本资料、用户拥有的权限、角色,等等。
+ 授权功能:判断并控制认证后的用户对什么资源拥有哪些操作许可,这部分内容会放到“授权”介绍。
+ 密码的存储与验证:密码是烫手的山芋,存储、传输还是验证都应谨慎处理,我们会放到“保密”去具体讨论。

授权

=> 访问控制

授权( Authorization)
系统如何控制一个用户该看到哪些数据、能操作哪些功能?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
认证、授权、审计、账号通常一起出现,并称为 AAAA(Authentication、Authorization、Audit、Account,也有一些领域把 Account 解释为计费的意思)。
授权行为在程序中的应用非常广泛,给某个类或某个方法设置范围控制符(public、protected、private、<Package>)在本质上也是一种授权(访问控制)行为。而在安全领域中所说的授权就更具体一些,通常涉及以下两个相对独立的问题:

# 过程、结果
+ 确保授权的过程可靠
对于单一系统来说,授权的过程是比较容易做到可控的,以前很多语境上提到授权,实质上讲的都是访问控制,理论上两者是应该分开的。
而在涉及多方的系统中,授权过程则是一个比较困难却必须严肃对待的问题:如何既让第三方系统能够访问到所需的资源,又能保证其不泄露用户的敏感数据呢?
常用的多方授权协议主要有 OAuth2 和 SAML 2.0(两个协议涵盖的功能并不是直接对等的)。
+ 确保授权的结果可控
授权的结果用于对程序功能或者资源的访问控制(Access Control),成理论体系的权限控制模型有很多,例如:
+ 自主访问控制(Discretionary Access Control,DAC)、
+ 强制访问控制(Mandatory Access Control,MAC)、
+ 基于属性的访问控制(Attribute-Based Access Control,ABAC),
+ 还有最为常用的基于角色的访问控制(Role-Based Access Control,RBAC)。

我们只介绍日常开发中最常用到的 RBAC 和 OAuth2 这两种访问控制和授权方案。

RBAC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 访问控制的实质
所有的访问控制模型,实质上都是在解决同一个问题:“谁(User)拥有什么权限(Authority)去操作(Operation)哪些资源(Resource)”。

# 权限 => 用户 => 坏处:用户多,不好控制
这个问题初看起来并不难,一种直观的解决方案就是在用户对象上设定一些权限,当用户使用资源时,检查是否有对应的操作权限即可。
很多著名的安全框架,例如 Spring Security 的访问控制本质上就是支持这么做的。
不过,这种把权限直接关联在用户身上的简单设计,在复杂系统上确实会导致一些比较烦琐的问题。
试想一下,如果某个系统涉及到成百上千的资源,又有成千上万的用户,一旦两者搅合到一起,要为每个用户访问每个资源都分配合适的权限,必定导致巨大的操作量和极高的出错概率,这也正是 RBAC 所关注的问题之一。

# 权限 => 角色(RBAC)
RBAC 模型在业界中有多种说法,其中以美国 George Mason 大学信息安全技术实验室提出的 RBAC96 模型最具有系统性,得到普遍的认可。
为了避免对每一个用户设定权限,RBAC 将权限从用户身上剥离,改为绑定到“角色”(Role)上,将权限控制变为对“角色拥有操作哪些资源的许可”这个逻辑表达式的值是否为真的求解过程。

RBAC 的主要元素的关系可以下图来表示:

在这里插入图片描述

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
# 许可:权限
图中出现了一个新的名词“许可”(Permission)。
许可是抽象权限的具象化体现,权限在 RBAC 系统中的含义是“允许哪些操作作用于哪些资源之上”,这句话的具体实例即为“许可”。
提出许可这个概念的目的其实与提出角色的目的是完全一致的,只是更为抽象。

# 角色:解耦用户与权限多对多
# 许可:解耦操作与资源多对多
角色为的是解耦用户与权限之间的多对多关系,而许可为的是解耦操作与资源之间的多对多关系,例如不同的数据都能够有增、删、改等操作,如果将数据与操作搅和在一起也会面临配置膨胀问题。

# 举个栗子
例如某个论文管理系统的 UserStory 中,与访问控制相关的 Backlog 可能会是这样描述的:
Backlog:
陶同学(User)是某 SCI 杂志的审稿人(Role),职责之一是在系统中审核论文(Authority)。
在审稿过程(Session)中,当他认为某篇论文(Resource)达到了可以公开发表标准时,就会在后台点击通过按钮(Operation)来完成审核。
———————————————————————
以上 Backlog 中“给论文点击通过按钮”就是一种许可,它是“审核论文”这项权限的具象化体现。

# RBAC 最小特权原则 => Linux用户组
采用 RBAC 不仅是为了简化配置操作,还天然地满足了计算机安全中的“最小特权原则”(Least Privilege)。
在 RBAC 模型中,角色拥有许可的数量是根据完成该角色工作职责所需的最小权限来赋予的,
最典型例子是操作系统权限管理中的用户组,根据对不同角色的职责分工,如管理员(Administrator)、系统用户(System)、
验证用户(Authenticated Users)、普通用户(Users)、来宾用户(Guests)等分配各自的权限,既保证用户能够正常工作,也避免用户出现越权操作的风险。
当用户的职责发生变化时,在系统中就体现为它所隶属的角色被改变,例如将“普通用户角色”改变“管理员角色”,就可以迅速让该用户具备管理员的多个细分权限,降低权限分配错误的风险。

# 继承性
RBAC 还允许对不同角色之间定义关联与约束,进一步强化它的抽象描述能力。
如不同的角色之间可以有继承性,典型的是 RBAC-1 模型的角色权限继承关系。
例如描述开发经理应该和开发人员一样具有代码提交的权限,描述开发人员都应该和任何公司员工一样具有食堂就餐的权限,
就可以直接将食堂就餐赋予公司员工的角色上,把代码提交赋予到开发人员的角色上,再让开发人员的角色从公司员工派生,开发经理的角色从开发人员中派生即可。

# 互斥性
不同角色之间也可以具有互斥性,典型的是 RBAC-2 模型的角色职责分离关系。
互斥性要求权限被赋予角色时,或角色被赋予用户时应遵循的强制性职责分离规定。
举个例子,角色的互斥约束可限制同一用户只能分配到一组互斥角色集合中至多一个角色,例如不能让同一名员工既当会计,也当出纳,否则资金安全无法保证。
角色的基数约束可限制某一个用户拥有的最大角色数目,例如不能让同一名员工从产品、设计、开发、测试全部包揽,否则产品质量无法保证。

# 垂直权限(功能权限) => 框架做
建立访问控制模型的基本目的是为了管理垂直权限和水平权限。
垂直权限即功能权限,例如前面提到的审稿编辑有通过审核的权限、开发经理有代码提交的权限、出纳有从账户提取资金的权限,这一类某个角色完成某项操作的许可,都可以直接翻译为功能权限。
由于实际应用与权限模型具有高度对应关系,将权限从具体的应用中抽离出来,放到通用的模型中是相对容易的,Spring Security、Apache Shiro 等权限框架就是这样的抽象产物,大多数系统都能采用这些权限框架来管理功能权限。

# 水平权限(数据权限) => 系统自己做
与此相对,水平权限即数据权限管理起来则要困难许多。
例如用户 A、B 都属于同一个角色,但它们各自在系统中产生的数据完全有可能是私有的,A 访问或删除了 B 的数据也照样属于越权。
一般来说,数据权限是很难抽象与通用的,仅在角色层面控制并不能满足全部业务的需要,很多时候只能具体到用户,
甚至要具体管理到发生数据的某一行、某一列之上,因此数据权限基本只能由信息系统自主来来完成,并不存在能放之四海皆准的通用数据权限框架。

# Spring Security => 用户和角色都可以拥有权限
后面讲的“重要角色” Kubernetes 完全遵循了 RBAC 来进行服务访问控制,Fenix’s Bookstore 所使用的 Spring Security 也参考了(但并没有完全遵循)RBAC 来设计它的访问控制功能。
Spring Security 的设计里,用户和角色都可以拥有权限,例如在它的 HttpSecurity 接口就同时有着 hasRole()和 hasAuthority()方法,可能刚接触的程序员会疑惑,混淆它们之间的关系。
Spring Security 的访问控制模型如下图所示,可与前面 RBAC 的关系图对比一下。

在这里插入图片描述

1
2
3
4
5
6
7
# Role角色、Authority权限    => 实现差不多,使用有差异
从实现角度来看,Spring Security 中的 Role 和 Authority(权限) 的差异很小,它们完全共享同一套存储结构,唯一的差别仅是 Role 会在存储时自动带上“ROLE_”前缀罢了。
但从使用角度来看,Role 和 Authority 的差异可以很大,用户可以自行决定系统中到底 Permission 只能对应到角色身上,还是可以让用户也拥有某些角色中没有的权限。
这一点不符合 RBAC 的思想,但个人认同这是一种创新而非破坏,在 Spring Security 的文档上说的很清楚:这取决于你自己如何使用。
角色和权限的核心差异取决于用户打算如何使用这些特性,在框架层面它们的差别是极小的,基本采用了完全相同的方式来进行处理。
通过 RBAC 很容易控制最终用户在广义和精细级别上能够做什么,可以指定用户是管理员、专家用户抑或普通用户,并使角色和访问权限与组织中员工的身份职位保持一致,仅根据需要为员工完成工作的最低限度来分配权限。
这些都是大量软件系统、长时间积累下来的经验,将这些经验运用在软件产品上,绝大多数情况下要比自己发明创造一个新的轮子更加安全。

OAuth2

=> Token(令牌) 面向第三方,认证授权协议

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
# OAuth2 => 面向第三方应用(Third-Party Application)的认证授权协议
了解过 RBAC 的内容后,下面我们再来看看相对更复杂烦琐的 OAuth2 认证授权协议(更烦琐的 OAuth1 已经完全被废弃了)。
OAuth2 是在RFC 6749中定义的国际标准,在 RFC 6749 正文的第一句就阐明了 OAuth2 是面向于解决第三方应用(Third-Party Application)的认证授权协议。

# 为什么第三方?
如果你的系统并不涉及第三方,例如我们单体架构的 Fenix’s Bookstore 中就既不为第三方提供服务,也不使用第三方的服务,那引入 OAuth2 其实并无必要。
为什么强调第三方?在多方系统授权过程具体会有什么问题需要专门制订一个标准协议来解决呢?下面举个现实的例子来解释。

# 举个栗子
=> 流程:MD(本地) -> GitHub <- Travis-CI(检测:编译,生成,GitHub Pages,CDN刷新)
例如你现在正在阅读的这个网站(https://icyfenix.cn),它的建设和更新大致流程是:以 Markdown 形式写好了某篇文章,上传到由GitHub 提供的代码仓库,
接着由Travis-CI提供的持续集成服务会检测到该仓库发生了变化,触发一次 Vuepress 编译活动,生成目录和静态的 HTML 页面,然后推送回GitHub Pages,再触发国内的 CDN 缓存刷新。
这个过程要能顺利进行,就存在一系列必须解决的授权问题,Travis-CI 只有得到了我的明确授权,GitHub 才能同意它读取我代码仓库中的内容,问题是它该如何获得我的授权呢?
# OAuth2 所要解决的问题
一种最简单粗暴的方案是把我的用户账号和密码都告诉 Travis-CI,但这显然导致了以下这些问题:
+ 密码泄漏:如果 Travis-CI 被黑客攻破,将导致我的 GitHub 的密码也同时被泄漏。
+ 访问范围:Travis-CI 将有能力读取、修改、删除、更新我放在 GitHub 上的所有代码仓库,而我并不希望它能够修改删除文件。
+ 授权回收:只有修改密码才能回收我授予给 Travis-CI 的权力,可是我在 GitHub 的密码只有一个,授权的应用除了 Travis-CI 之外却还有许多,修改了意味着所有别的第三方的应用程序会全部失效。

以上列举的这些问题,也正是 OAuth2 所要解决的问题,尤其是要求第三方系统没有支持 HTTPS 传输安全的环境下依然能够解决这些问题,这并非易事。

# 解决方案:令牌 ===> 密码泄漏,权限范围,时效性,独立性
OAuth2 给出了多种解决办法,这些办法的共同特征是以令牌(Token)代替用户密码作为授权的凭证。
+ 有了令牌之后,哪怕令牌被泄漏,也不会导致密码的泄漏;
+ 令牌上可以设定访问资源的范围以及时效性;
+ 每个应用都持有独立的令牌,哪个令牌失效都不会影响其他令牌。

这样上面提出的三个问题就都解决了。

# 加令牌后的授权流程
有了一层令牌之后,整个授权的流程如下图所示:
这里再放出之前的流程:MD(本地) -> GitHub <- Travis-CI(检测:编译,生成,GitHub Pages,CDN刷新)

这个时序图里面涉及到了 OAuth2 中几个关键术语,我们通过前面那个具体的上下文语境来解释其含义,这对理解后续几种认证流程十分重要:
+ 第三方应用(Third-Party Application):需要得到授权访问我资源的那个应用,即此场景中的“Travis-CI”。
+ 授权服务器(Authorization Server):能够根据我的意愿提供授权(授权之前肯定已经进行了必要的认证过程,但它与授权可以没有直接关系)的服务器,即此场景中的“GitHub”。
+ 资源服务器(Resource Server):能够提供第三方应用所需资源的服务器,它与认证服务可以是相同的服务器,也可以是不同的服务器,此场景中的“我的代码仓库”。
+ 资源所有者(Resource Owner): 拥有授权权限的人,即此场景中的“我”。
+ 操作代理(User Agent):指用户用来访问服务器的工具,对于人类用户来说,这个通常是指浏览器,但在微服务中一个服务经常会作为另一个服务的用户,此时指的可能就是 HttpClient、RPCClient 或者其他访问途径。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
# OAuth2 四种授权
“用令牌代替密码”确实是解决问题的好方法,但这充其量只能算个思路,距离可实施的步骤还是不够具体的。
时序图中的“要求/同意授权”、“要求/同意发放令牌”、“要求/同意开放资源”几个服务请求、响应该如何设计,这就是执行步骤的关键了。

对此,OAuth2 一共提出了四种不同的授权方式(这也是 OAuth2 复杂烦琐的主要原因),分别为:
+ 授权码模式(Authorization Code)
+ 隐式授权模式(Implicit)
+ 密码模式(Resource Owner Password Credentials)
+ 客户端模式(Client Credentials)

1. 授权码模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 最严谨,最啰嗦
授权码模式是四种模式中最严(luō)谨(suō)的,它考虑到了几乎所有敏感信息泄漏的预防和后果。具体步骤的时序如下图所示。

# 授权流程
开始进行授权过程以前,第三方应用先要到授权服务器上进行注册,所谓注册,是指向认证服务器提供一个域名地址,然后从授权服务器中获取 ClientID 和 ClientSecret,以便能够顺利完成如下授权过程:
(联想到迪卡侬京东后台URI)
1. (获取code授权码:ClientID,回调URI)第三方应用将资源所有者(用户)导向授权服务器的授权页面,并向授权服务器提供 ClientID 及用户同意授权后的回调 URI,这是一次客户端页面转向。
2. (同意授权:code)授权服务器根据 ClientID 确认第三方应用的身份,用户在授权服务器中决定是否同意向该身份的应用进行授权,用户认证的过程未定义在此步骤中,在此之前应该已经完成。
3. (获取token:带着code授权码)如果用户同意授权,授权服务器将转向第三方应用在第 1 步调用中提供的回调 URI,并附带上一个授权码和获取令牌的地址作为参数,这是第二次客户端页面转向。
4. (换取令牌)第三方应用通过回调地址收到授权码,然后将授权码与自己的 ClientSecret 一起作为参数,通过服务器向授权服务器提供的获取令牌的服务地址发起请求,换取令牌。该服务器的地址应与注册时提供的域名处于同一个域中。
5. (访问刷新:token)授权服务器核对授权码和 ClientSecret,确认无误后,向第三方应用授予令牌。
令牌可以是一个或者两个,其中必定要有的是访问令牌(Access Token),可选的是刷新令牌(Refresh Token)。
访问令牌 => 用于到资源服务器获取资源,有效期较短;
刷新令牌 => 用于在访问令牌失效后重新获取,有效期较长;
6. (访问请求:带着token)资源服务器根据访问令牌所允许的权限,向第三方应用提供资源。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# OAuth2 解决意外情况
这个过程设计,已经考虑到了几乎所有合理的意外情况,下面再举几个最容易遇到的意外状况,以便你能够更好地理解为何要这样设计 OAuth2。

1. 会不会有其他应用冒充第三方应用骗取授权? => 会校验 ClientSecret
ClientID 代表一个第三方应用的“用户名”,这项信息是可以完全公开的。
但 ClientSecret 应当只有应用自己才知道,这个代表了第三方应用的“密码”。
在第 5 步发放令牌时,调用者必须能够提供 ClientSecret 才能成功完成。
只要第三方应用妥善保管好 ClientSecret,就没有人能够冒充它。

2. 为什么要先发放授权码,再用授权码换令牌? => 防止令牌泄漏
这是因为客户端转向(通常就是一次 HTTP 302 重定向)对于用户是可见的,换而言之,授权码可能会暴露给用户以及用户机器上的其他程序,
但由于用户并没有 ClientSecret,光有授权码也是无法换取到令牌的,所以避免了令牌在传输转向过程中被泄漏的风险。

3. 为什么要设计一个时限较长的刷新令牌和时限较短的访问令牌?不能直接把访问令牌的时间调长吗? => 解决令牌无法失效问题
这是为了缓解 OAuth2 在实际应用中的一个主要缺陷,通常访问令牌一旦发放,除非超过了令牌中的有效期,否则很难(需要付出较大代价)有其他方式让它失效,所以访问令牌的时效性一般设计的比较短,
例如几个小时,如果还需要继续用,那就定期用刷新令牌去更新,授权服务器就可以在更新过程中决定是否还要继续给予授权。至于为什么说很难让它失效,我们将放到后面“凭证”中去解释。

# 坏处:第三方应用必须有应用服务器
尽管授权码模式是严谨的,但是它并不够好用,这不仅仅体现在它那繁复的调用过程上,
还体现在它对第三方应用提出了一个“貌似不难”的要求:第三方应用必须有应用服务器,因为第 4 步要发起服务端转向,而且要求服务端的地址必须与注册时提供的地址在同一个域内。
不要觉得要求一个系统要有应用服务器是天经地义理所当然的事情,除基于浏览器的应用外,现在越来越普遍的是移动或桌面端的客户端 Web 应用(Client-Side Web Applications),
例如现在大量的基于 Cordova、Electron、Node-Webkit.js 的PWA 应用,它们都没有应用服务器的支持。
由于有这样的实际需求,因此引出了 OAuth2 的第二种授权模式:隐式授权。

2. 隐式授权模式

1
2
3
4
5
6
7

# 隐式授权模式 => 省略掉了通过授权码换取令牌的步骤
# 无法抵御中间人攻击
隐式授权省略掉了通过授权码换取令牌的步骤,整个授权过程都不需要服务端支持,一步到位。
代价是在隐式授权中,授权服务器不会再去验证第三方应用的身份,因为已经没有应用服务器了,ClientSecret 没有人保管,就没有存在的意义了。
但其实还是会限制第三方应用的回调 URI 地址必须与注册时提供的域名一致,尽管有可能被 DNS 污染之类的攻击所攻破,但仍算是尽可能努力一下。
同样的原因,也不能避免令牌暴露给资源所有者,不能避免用户机器上可能意图不轨的其他程序、HTTP 的中间人攻击等风险了。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 时序图
隐式授权的调用时序如图所示:
(从此之后的授权模式,时序中就不再画出资源访问部分的内容了,就是前面 opt 框中的那一部分,以便更聚焦重点)

在时序图所示的交互过程里,隐式模式与授权码模式的显著区别是授权服务器在得到用户授权后,直接返回了访问令牌,这显著地降低了安全性,
但 OAuth2 仍然努力尽可能地做到相对安全,例如在前面提到的隐式授权中,尽管不需要用到服务端,但仍然需要在注册时提供回调域名,此时会要求该域名与接受令牌的服务处于同一个域内。
此外,同样基于安全考虑,在隐式模式中明确禁止发放刷新令牌。

# Fragment 特性 => 操作代理 -> 第三方应用(脚本取令牌)
还有一点,在 RFC 6749 对隐式授权的描述中,特别强调了令牌必须是“通过 Fragment 带回”的。部分对超文本协议没有了解的读者,可能还根本不知道Fragment是个什么东西?
额外知识:Fragment => #井号 锚链接
In computer hypertext, a fragment identifier is a string of characters that refers to a resource that is subordinate to another, primary resource.
在计算机超文本中,fragment 标识符是一串字符,指的是从属于另一个主要资源的资源。
The primary resource is identified by a Uniform Resource Identifier (URI), and the fragment identifier points to the subordinate resource.
主要资源由统一资源标识符 (URI) 标识,片段标识符指向从属资源。

例如:Fragment 就是地址中#号后面的部分,例如这个地址: http://bookstore.icyfenix.cn/#/detail/1

后面的/detail/1便是 Fragment,这个语法是在RFC 3986中定义的,RFC 3986 中解释了 Fragment 是用于客户端定位的 URI 从属资源,例如 HTML 中就可以使用 Fragment 来做文档内的跳转而不会发起服务端请求,你现在可以点击一下这篇文章左边菜单中的几个子标题,看看浏览器地址栏的变化。
此外,RFC 3986 还规定了如果浏览器对一个带有 Fragment 的地址发出 Ajax 请求,那 Fragment 是不会跟随请求被发送到服务端的,只能在客户端通过 Script 脚本来读取。
所以隐式授权巧妙地利用这个特性,尽最大努力地避免了令牌从操作代理到第三方服务之间的链路存在被攻击而泄漏出去的可能性。

# 操作代理 -> 授权服务器 => HTTPS
至于操作代理到授权服务器之间的这一段链路的安全,则只能通过 TLS(即 HTTPS)来保证中间不会受到攻击了,我们可以要求认证服务器必须都是启用 HTTPS 的,但无法要求第三方应用同样都支持 HTTPS。

3. 密码模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 密码模式:认证+授权
前面所说的授权码模式和隐私模式属于纯粹的授权模式,它们与认证没有直接的联系,如何认证用户的真实身份是与进行授权互相独立的过程。
但在密码模式里,认证和授权就被整合成了同一个过程了。

# 用户(密码明文) -> 第三方应用 => 高度信任
密码模式原本的设计意图是仅限于用户对第三方应用是高度可信任的场景中使用,因为用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌。
这种高度可信的第三方是极为较罕见的,尽管介绍 OAuth2 的材料中,经常举的例子是“操作系统作为第三方应用向授权服务器申请资源”,但真实应用中极少遇到这样的情况,合理性依然十分有限。

# 认证+授权同一个系统
如果要采用密码模式,那“第三方”属性就必须弱化,把“第三方”视作是系统中与授权服务器相对独立的子模块,在物理上独立于授权服务器部署,
但是在逻辑上与授权服务器仍同属一个系统,这样将认证和授权一并完成的密码模式才会有合理的应用场景。

# 时序图
理解了密码模式的用途,它的调用过程就很简单了,就是第三方应用拿着用户名和密码向授权服务器换令牌而已。具体时序如图所示:
密码模式下“如何保障安全”的职责无法由 OAuth2 来承担,只能由用户和第三方应用来自行保障,尽管 OAuth2 在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何技术上的约束力。

在这里插入图片描述


4. 客户端模式

1
2
3
4
5
6
7
8
9
10
11
12
13
# 最简单
客户端模式是四种模式中最简单的,它只涉及到两个主体,第三方应用和授权服务器。
如果严谨一点,现在称“第三方应用”其实已经不合适了,因为已经没有了“第二方”的存在,资源所有者、操作代理在客户端模式中都是不必出现的。
甚至严格来说叫“授权”都已不太恰当,资源所有者都没有了,也就不会有谁授予谁权限的过程。

# 举个栗子 => 订单超时清理服务
客户端模式是指第三方应用(行文一致考虑,还是继续沿用这个称呼)以自己的名义,向授权服务器申请资源许可。此模式通常用于管理操作或者自动处理类型的场景中。
举个具体例子,例如开了一家叫 Fenix’s Bookstore 的书店,因为小本经营,不像京东那样全国多个仓库可以调货,因此必须保证只要客户成功购买,书店就必须有货可发,不允许超卖。
但经常有顾客下了订单又拖着不付款,导致部分货物处于冻结状态。所以 Fenix’s Bookstore 中有一个订单清理的定时服务,自动清理超过两分钟还未付款的订单。
在这个场景里,订单肯定是属于下单用户自己的资源,如果把订单清理服务看作一个独立的第三方应用的话,它是不可能向下单用户去申请授权来删掉订单的,而应该直接以自己的名义向授权服务器申请一个能清理所有用户订单的授权。

# 时序图
客户端模式的时序如图所示:

在这里插入图片描述

1
2
3
4
5
6
# 客户端模式 => 服务间认证授权常用
微服务架构并不提倡同一个系统的各服务间有默认的信任关系,所以服务之间调用也需要先进行认证授权,然后才能通信。
此时,客户端模式便是一种常用的服务间认证授权的解决方案。
Spring Cloud 版本的 Fenix’s Bookstore是采用这种方案来保证微服务之间的合法调用的;
Istio 版本的 Fenix’s Bookstore则启用了双向 mTLS 通信,使用客户端证书来保障安全。
(它们可作为上面介绍认证时提到的“通信信道认证”和“通信内容认证”例子,感兴趣的读者可以对比一下这两种方式的差异优劣)

5. 设备码模式

1
2
3
4
5
6
7
# 类似 客户端模式
OAuth2 中还有一种与客户端模式类似的授权模式,在RFC 8628中定义为“设备码模式”(Device Code),这里顺带提一下。
设备码模式用于在无输入的情况下区分设备是否被许可使用,典型的应用便是手机锁网解锁(锁网在国内较少,但在国外很常见)或者设备激活(例如某游戏机注册到某个游戏平台)的过程。

它的时序如图所示:
进行验证时,设备需要从授权服务器获取一个 URI 地址和一个用户码,然后需要用户手动或设备自动地到验证 URI 中输入用户码。
在这个过程中,设备会一直循环,尝试去获取令牌,直到拿到令牌或者用户码过期为止。(令牌:迪卡侬、QQ、Stream、安踏MFA)

在这里插入图片描述

凭证

凭证(Credentials)
系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?

1
2
3
4
5
6
7
8
9
10
11
# 填坑 OAuth2难失效
在前面介绍 OAuth2 的内容中,每一种授权模式的最终目标都是拿到访问令牌,但从未涉及过拿回来的令牌应该长什么样子。
反而还挖了一些坑没有填(为何说 OAuth2 的一个主要缺陷是令牌难以主动失效)。
这节讨论的主角是令牌,同时,还会讨论如果不使用 OAuth2,如何以最传统的方式完成认证、授权。

# 分布式前(Client维护状态)=> HTTP 协议的 Cookie-Session(30年主流)
# 分布式后(Server维护状态)=> JWT 令牌方案(近10年,共享数据,CAP问题)
“如何承载认证授权信息”这个问题的不同看法,代表了软件架构对待共享状态信息的两种不同思路:状态应该维护在服务端,又或是在客户端之中?
在分布式系统崛起以前,这个问题原本已是有了较为统一的结论的,以 HTTP 协议的 Cookie-Session 机制为代表的服务端状态存储在三十年来都是主流的解决方案。
不过,到了最近十年,由于分布式系统中共享数据必然会受到 CAP 不兼容原理的打击限制,迫使人们重新去审视之前已基本放弃掉的客户端状态存储,这就让原本通常只在多方系统中采用的 JWT 令牌方案,在分布式系统中也有了另一块用武之地。
本节的话题,也就围绕着 Cookie-Session 和 JWT 之间的相同与不同而展开。
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
# HTTP 无状态 => 没有上下文、请求独立
大家知道 HTTP 协议是一种无状态的传输协议,无状态是指协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的,但是我们中肯定有许多人并没有意识到 HTTP 协议无状态的重要性。
假如你做了一个简单的网页,其中包含了 1 个 HTML、2 个 Script 脚本、3 个 CSS、还有 10 张图片,这个网页成功展示在用户屏幕前,需要完成 16 次与服务端的交互来获取上述资源,
由于网络传输各种等因素的影响,服务器发送的顺序与客户端请求的先后并没有必然的联系,按照可能出现的响应顺序,理论上最多会有 P(16,16) = 20,922,789,888,000 种可能性。
试想一下,如果 HTTP 协议不是设计成无状态的,这 16 次请求每一个都有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影响的话,那协调工作会有多么复杂。

# 获取身份 => Cookie由来
可是,HTTP 协议的无状态特性又有悖于我们最常见的网络应用场景,典型就是认证授权,系统总得要获知用户身份才能提供合适的服务,
因此,我们也希望 HTTP 能有一种手段,让服务器至少有办法能够区分出发送请求的用户是谁。

为了实现这个目的,RFC 6265规范定义了 HTTP 的状态管理机制,在 HTTP 协议中增加了 Set-Cookie 指令,该指令的含义是以键值对的方式向客户端发送一组信息,
此信息将在此后一段时间内的每次 HTTP 请求中,以名为 Cookie 的 Header 附带着重新发回给服务端,以便服务端区分来自不同客户端的请求。

一个典型的 Set-Cookie 指令如下所示:
Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly
收到该指令以后,客户端再对同一个域的请求中就会自动附带有键值对信息id=icyfenix,例如以下代码所示:
GET /index.html HTTP/2.0
Host: icyfenix.cn
Cookie: id=icyfenix

# 区分用户 => 传输负担,恶意修改 => Session由来(Server维护状态)
根据每次请求传到服务端的 Cookie,服务器就能分辨出请求来自于哪一个用户。
由于 Cookie 是放在请求头上的,属于额外的传输负担,不应该携带过多的内容,而且放在 Cookie 中传输也并不安全,容易被中间人窃取或被篡改,所以通常是不会像例子中设置id=icyfenix这样的明文信息。

一般来说,系统会把状态信息保存在服务端,在 Cookie 里只传输的是一个无字面意义的、不重复的字符串,习惯上以sessionid或者jsessionid为名,
服务器拿这个字符串为 Key,在内存中开辟一块空间,以 Key/Entity 的结构存储每一个在线用户的上下文状态,再辅以一些超时自动清理之类的管理措施。
这种服务端的状态管理机制就是今天大家非常熟悉的 Session,Cookie-Session 也是最传统但今天依然广泛应用于大量系统中的,由服务端与客户端联动来完成的状态管理机制。

# 好处:主动管理 => 清楚信息,强制用户下线
Cookie-Session 方案在本章的主题“安全性”上其实是有一定先天优势的:
状态信息都存储于服务器,只要依靠客户端的同源策略和 HTTPS 的传输层安全,保证 Cookie 中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉上下文信息在传输过程中被泄漏和篡改的风险。
Cookie-Session 方案的另一大优点是服务端有主动的状态管理能力,可根据自己的意愿随时修改、清除任意上下文信息,例如很轻易就能实现强制某用户下线的这样功能。

# 坏处:水平扩展麻烦(增加副本) => 水平扩展三选一(Cookie-Session)
Session-Cookie 在单节点的单体服务环境中是最合适的方案,但当需要水平扩展服务能力,要部署集群时就开始面临麻烦了。
由于 Session 存储在服务器的内存中,当服务器水平拓展成多节点时,设计者必须在以下三种方案中选择其一:
+ Session路由(负载均衡算法) => 一个节点挂了,上面的状态全没了
牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法,例如根据用户 IP 或者 Session 来分配节点,每一个特定用户发出的所有请求都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
+ Session广播 => 同步代价高
牺牲集群的可用性(Availability),让各个节点之间采用复制式的 Session,每一个节点中的 Session 变动都会发送到组播地址的其他服务器上,这样某个节点崩溃了,不会中断都某个用户的服务,但 Session 之间组播复制的同步代价高昂,节点越多时,同步成本越高。
+ 中间件存储(Redis) => 中间件单点问题,分区问题
牺牲集群的分区容忍性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不再能提供服务。

# JWT 解决 Cookie-Session 认证授权问题
通过前面章节的内容,我们已经知道只要在分布式系统中共享信息,CAP 就不可兼得,所以分布式环境中的状态管理一定会受到 CAP 的局限,无论怎样都不可能完美。
但如果只是解决分布式下的认证授权问题,并顺带解决少量状态的问题,就不一定只能依靠共享信息去实现。
这句话的言外之意是提醒读者,接下来的 JWT 令牌与 Cookie-Session 并不是完全对等的解决方案,
它只用来处理认证授权问题,充其量能携带少量非敏感的信息,只是 Cookie-Session 在认证授权问题上的替代品,而不能说 JWT 要比 Cookie-Session 更加先进,更不可能全面取代 Cookie-Session 机制。

JWT

1
2
3
4
5
6
7
8
9
10
# Session服务器存储不行 => JWT客户端存储(每次发送携带)
# JWT 信息受限,防中间人
Cookie-Session 机制在分布式环境下会遇到 CAP 不可兼得的问题,而在多方系统中,就更不可能谈什么 Session 层面的数据共享了,哪怕服务端之间能共享数据,客户端的 Cookie 也没法跨域。
所以我们不得不重新捡起最初被抛弃的思路,当服务器存在多个,客户端只有一个时,把状态信息存储在客户端,每次随着请求发回服务器去。
才说过这样做的缺点是无法携带大量信息,而且有泄漏和篡改的安全风险。
信息量受限的问题并没有太好的解决办法,但是要确保信息不被中间人篡改则还是可以实现的,JWT 便是这个问题的标准答案。

# JWT 三结构
JWT(JSON Web Token)定义于RFC 7519标准之中,是目前广泛使用的一种令牌格式,尤其经常与 OAuth2 配合应用于分布式的、涉及多方的应用系统中。
介绍 JWT 的具体构成之前,我们先来直观地看一下它是什么样子的,如图所示:

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 前缀 Bearer
以上截图来自 JWT 官网(https://jwt.io),数据则是随意编的。右边的 JSON 结构是 JWT 令牌中携带的信息,左边的字符串呈现了 JWT 令牌的本体。
它最常见的使用方式是附在名为 Authorization 的 Header 发送给服务端,前缀在RFC 6750中被规定为 Bearer。
如果你没有忘记“认证方案”与“OAuth 2”的内容,那看到 Authorization 这个 Header 与 Bearer 这个前缀时,便应意识到它是 HTTP 认证框架中的 OAuth 2 认证方案。
如下代码展示了一次采用 JWT 令牌的 HTTP 实际请求:
GET /restful/products/1 HTTP/1.1
Host: icyfenix.cn
Connection: keep-alive
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJpY3lmZW5peCIsInNjb3BlIjpbIkFMTCJdLCJleHAiOjE1ODQ5NDg5NDcsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwianRpIjoiOWQ3NzU4NmEtM2Y0Zi00Y2JiLTk5MjQtZmUyZjc3ZGZhMzNkIiwiY2xpZW50X2lkIjoiYm9va3N0b3JlX2Zyb250ZW5kIiwidXNlcm5hbWUiOiJpY3lmZW5peCJ9.539WMzbjv63wBtx4ytYYw_Fo1ECG_9vsgAn8bheflL8

# JWT 防篡改,不防泄漏
图中右边的状态信息是对令牌使用 Base64URL 转码后得到的明文,请特别注意是明文。
JWT 只解决防篡改的问题,并不解决防泄漏的问题,因此令牌默认是不加密的。
尽管你自己要加密也并不难做到,接收时自行解密即可,但这样做其实没有太大意义,原因将在后面“保密”中去解释。

从明文中可以看到 JWT 令牌是以 JSON 结构(毕竟名字就叫 JSON Web Token)存储的,结构总体上可划分为三个部分,每个部分间用点号.分隔开。

# 部分1:Header => 令牌类型、签名算法
第一部分是令牌头(Header),内容如下所示:
{
"alg": "HS256",
"typ": "JWT"
}
它描述了令牌的类型(统一为 typ:JWT)以及令牌签名的算法,示例中 HS256 为 HMAC SHA256 算法的缩写,其他各种系统支持的签名算法可以参考https://jwt.io/网站所列。

额外知识:散列消息认证码
在本节及后面其他关于安全的内容中,经常会在某种哈希算法前出现“HMAC”的前缀,这是指散列消息认证码(Hash-based Message Authentication Code,HMAC)。可以简单将它理解为一种带有密钥的哈希摘要算法,实现形式上通常是把密钥以加盐方式混入,与内容一起做哈希摘要。
HMAC 哈希与普通哈希算法的差别是普通的哈希算法通过 Hash 函数结果易变性保证了原有内容未被篡改,HMAC 不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。如图 5-14 所示。
在这里插入图片描述

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
# 部分2:Payload => 消息体
令牌的第二部分是负载(Payload),这是令牌真正需要向服务端传递的信息。
针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息,针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。
JWT 的负载部分是可以完全自定义的,根据具体要解决的问题不同,设计自己所需要的信息,只是总容量不能太大,毕竟要受到 HTTP Header 大小的限制。一个 JWT 负载的例子如下所示:
{
"username": "icyfenix",
"authorities": [
"ROLE_USER",
"ROLE_ADMIN"
],
"scope": [
"ALL"
],
"exp": 1584948947,
"jti": "9d77586a-3f4f-4cbb-9924-fe2f77dfa33d",
"client_id": "bookstore_frontend"
}
而 JWT 在 RFC 7519 中推荐(非强制约束)了七项声明名称(Claim Name),如有需要用到这些内容,建议字段名与官方的保持一致:
+ iss(Issuer):签发人。
+ exp(Expiration Time):令牌过期时间。
+ sub(Subject):主题。
+ aud (Audience):令牌受众。
+ nbf (Not Before):令牌生效时间。
+ iat (Issued At):令牌签发时间。
+ jti (JWT ID):令牌编号。

此外在 RFC 8225、RFC 8417、RFC 8485 等规范文档,以及 OpenID 等协议中,都定义有约定好公有含义的名称,内容比较多,就不贴出来了,可以参考IANA JSON Web Token Registry。

# 部分3:Signature => 签名算法
令牌的第三部分是签名(Signature),签名的意思是:使用在对象头中公开的特定签名算法。
通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,以例子里使用的 JWT 默认的 HMAC SHA256 算法为例,将通过以下公式产生签名值:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)

# 签名 => 放篡改(牵一发动全身)
签名的意义在于确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。
因为被签名的内容哪怕发生了一个字节的变动,也会导致整个签名发生显著变化。
此外,由于签名这件事情只能由认证授权服务器完成(只有它知道 Secret),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的 JWT 中的负载信息。

# 单体: 授权服务、应用同一进程(秘钥的哈希摘要) ==> 加密,验证都是授权服务
# 分布式: 多系统、授权服务(非对称加密) ======> 私钥(授权服务)=颁发签名,公钥(其他服务)=验证签名
JWT 默认的签名算法 HMAC SHA256 是一种带密钥的哈希摘要算法,加密与验证过程均只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。
在多方系统或者授权服务与资源服务分离的分布式应用中,通常会采用非对称加密算法来进行签名,这时候除了授权服务端持有的可以用于签名的私钥外,还会对其他服务器公开一个公钥,公开方式一般遵循JSON Web Key 规范。
公钥不能用来签名,但是能被其他服务用于验证签名是否由私钥所签发的。这样其他服务器也能不依赖授权服务器、无须远程通信即可独立判断 JWT 令牌中的信息的真伪。

# 单体:哈希摘要 HMAC SHA256
# 分布式:非对称加密 RSA SHA256
在 Fenix’s Bookstore 的单体服务版本中,采用了默认的 HMAC SHA256 算法来加密签名,而 Istio 服务网格版本里,终端用户认证会由服务网格的基础设施参来完成,此时就改用了非对称加密的 RSA SHA256 算法来进行签名。
希望深入了解凭证安全的读者,不妨对比一下这两部分的代码。更多关于哈希摘要、对称和非对称加密的讨论,将会在后面“传输”中讲解。

# JWT 优点:安全,无状态,水平扩展,少量信息 (无状态=重启也无所谓,有状态=重启要重新登录)
JWT 令牌是多方系统中一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现,是准确、完整、不可篡改、且不可抵赖的。
同时,由于 JWT 本身可以携带少量信息,这十分有利于 RESTful API 的设计,能够较容易地做成无状态服务,在做水平扩展时就不需要像前面 Cookie-Session 方案那样考虑如何部署的问题。
现实中也确实有一些项目直接采用 JWT 来承载上下文来实现完全无状态的服务端,这能获得任意加入或移除服务节点的巨大便利,天然具有完美的水平扩缩能力。
例如,在调试 Fenix’s Bookstore 的代码时,你随时都可以重启服务,重启后,客户端仍然能毫无感知地继续操作流程;而对于有状态的系统,就必须通过重新登录、进行前置业务操作来为服务端重建状态。
尽管大型系统中只使用 JWT 来维护上下文状态,服务端完全不持有状态是不太现实的,不过将热点的服务单独抽离出来做成无状态,仍是一种有效提升系统吞吐能力的架构技巧。

# JWT 缺点:难失效,重放攻击,数据有限,Client储存
但是,JWT 也并非没有缺点的完美方案,它存在着以下几个经常被提及的缺点:
+ 令牌难以主动失效
JWT 令牌一旦签发,理论上就和认证服务器再没有什么瓜葛了,在到期之前就会始终有效,除非服务器部署额外的逻辑去处理失效问题,这对某些管理功能的实现是很不利的。
例如一种颇为常见的需求是:要求一个用户只能在一台设备上登录,在 B 设备登录后,之前已经登录过的 A 设备就应该自动退出。
如果采用 JWT,就必须设计一个“黑名单”的额外的逻辑,用来把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在 Session、Redis 或者数据库中,都会让服务退化成有状态服务,
降低了 JWT 本身的价值,但黑名单在使用 JWT 时依然是很常见的做法,需要维护的黑名单一般是很小的状态量,许多场景中还是有存在价值的。
+ 相对更容易遭受重放攻击
首先说明 Cookie-Session 也是有重放攻击问题的,只是因为 Session 中的数据控制在服务端手上,应对重放攻击会相对主动一些。
要在 JWT 层面解决重放攻击需要付出比较大的代价,无论是加入全局序列号(HTTPS 协议的思路)、Nonce 字符串(HTTP Digest 验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。
真要处理重放攻击,建议的解决方案是在信道层次(例如启用 HTTPS)上解决,而不提倡在服务层次(例如在令牌或接口其他参数上增加额外逻辑)上解决。
+ 只能携带相当有限的数据
HTTP 协议并没有强制约束 Header 的最大长度,但是,各种服务器、浏览器都会有自己的约束,
例如 Tomcat 就要求 Header 最大不超过 8KB,而在 Nginx 中则默认为 4KB,因此在令牌中存储过多的数据不仅耗费传输带宽,还有额外的出错风险。
+ 必须考虑令牌在客户端如何存储
严谨地说,这个并不是 JWT 的问题而是系统设计的问题。如果授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化才是最理想的方案。
但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登录的。
这样的话,想想客户端该把令牌存放到哪里?Cookie?localStorage?Indexed DB?它们都有泄漏的可能,而令牌一旦泄漏,别人就可以冒充用户的身份做任何事情。
+ 无状态也不总是好的
这个其实不也是 JWT 的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态 JWT 的方案,做一个在线用户实时统计功能。兄弟,难搞哦。

保密

保密(Confidentiality)
系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 保密=加密+解密
# 得到加密信息,也不知解密算法;知道解密算法也无盐;
保密是加密和解密的统称。
是指以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密的方法,
或者知晓解密的算法但缺少解密所需的必要信息,仍然无法了解数据的真实内容。

# Client保密,传输保密,Server保密 => 出发点,路上,目的地
按照需要保密信息所处的环节不同,可以划分为“信息在客户端时的保密”、“信息在传输时的保密”和“信息在服务端时的保密”三类,或者进一步概括为“端的保密”和“链路的保密”两类。

我们把最复杂、最有效,又早有标准解决方案的“传输环节”单独提取出来,放到下一个小节去讨论。
本节将结合一些个人观点,重点讨论:
+ 密码等敏感信息如何保障安全等级、
+ 是否应该从客户端开始加密、
+ 应该如何存储及如何验证
+ ...等常见的安全保密问题。

保密的强度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 保密有成本
保密是有成本的,追求越高的安全等级,就要付出越多的工作量与算力消耗。
连国家保密法都会把秘密信息划分为秘密、机密、绝密三级来区别对待,可见即使是信息安全,也应该有所取舍。

# 不同强度的保密手段
以用户登录为例,列举几种不同强度的保密手段,讨论它们的防御关注点与弱点:
1. (摘要:防明文)以摘要代替明文:如果密码本身比较复杂,那一次简单的哈希摘要至少可以保证即使传输过程中有信息泄漏,也不会被逆推出原信息;
即使密码在一个系统中泄漏了,也不至于威胁到其他系统的使用,但这种处理不能防止弱密码被彩虹表攻击所破解。
2. (加盐:防彩虹表)先加盐值再做哈希是应对弱密码的常用方法:盐值可以替弱密码建立一道防御屏障,一定程度上防御已有的彩虹表攻击,但并不能阻止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行冒认。
3. (动态盐:防中间人)将盐值变为动态值能有效防止冒认:如果每次密码向服务端传输时都掺入了动态的盐值,让每次加密的结果都不同,那即使传输给服务端的加密结果被窃取了,也不能冒用来进行另一次调用。
尽管在双方通信均可能泄漏的前提下协商出只有通信双方才知道的保密信息是完全可行的(后续介绍“传输安全层”时会提到),但这样协商出盐值的过程将变得极为复杂,而且每次协商只保护一次操作,也难以阻止对其他服务的重放攻击。
4. (动态令牌:防重放)给服务加入动态令牌,在网关或其他流量公共位置建立校验逻辑,服务端愿意付出在集群中分发令牌信息等代价的前提下,可以做到防止重放攻击,但是依然不能抵御传输过程中被嗅探而泄漏信息的问题。
5. (HTTPS:放嗅探)启用 HTTPS 可以防御链路上的恶意嗅探,也能在通信层面解决了重放攻击的问题。
但是依然有因客户端被攻破产生伪造根证书风险、有因服务端被攻破产生的证书泄漏而被中间人冒认的风险、有因CRL更新不及时或者OCSP Soft-fail 产生吊销证书被冒用的风险、有因 TLS 的版本过低或密码学套件选用不当产生加密强度不足的风险。
6. (双重验证:防证书伪造)为了抵御上述风险,保密强度还要进一步提升,例如银行会使用独立于客户端的存储证书的物理设备(俗称的 U 盾)来避免根证书被客户端中的恶意程序窃取伪造;
大型网站涉及到账号、金钱等操作时,会使用双重验证开辟一条独立于网络的信息通道(如手机验证码、电子邮件)来显著提高冒认的难度;甚至一些关键企业(如国家电网)或机构(如军事机构)会专门建设遍布全国各地的与公网物理隔离的专用内部网络来保障通信安全。

# 绝对安全
听了上述这些逐步升级的保密措施,你应该能对“更高安全强度同时也意味着更多代价”有更具体的理解,不是任何一个网站、系统、服务都需要无限拔高的安全性。
也许这时候你会好奇另一个问题:安全的强度有尽头吗?存不存在某种绝对安全的保密方式?答案可能出乎多数人的意料,确实是有的。
信息论之父香农严格证明了一次性密码(One Time Password)的绝对安全性。
但是使用一次性密码必须有个前提,就是已经提前安全地把密码或密码列表传达给对方。
例如,给你给朋友人肉送去一本存储了完全随机密码的密码本,然后每次使用其中一条密码来进行加密通信,用完一条丢弃一条,理论上这样可以做到绝对的安全,但显然这种绝对安全对于互联网没有任何的可行性。

客户端加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 数据库不应该保存明文密码
客户端在用户登录、注册一类场景里是否需要对密码进行加密,这个问题一直存有争议。
的观点很明确:为了保证信息不被黑客窃取而做客户端加密没有太多意义,对绝大多数的信息系统来说,启用 HTTPS 可以说是唯一的实际可行的方案。
但是!为了保证密码不在服务端被滥用,在客户端就开始加密是很有意义的。
大网站被拖库的事情层出不穷,密码明文被写入数据库、被输出到日志中之类的事情也屡见不鲜,做系统设计时就应该把明文密码这种东西当成是最烫手的山芋来看待,越早消灭掉越好,将一个潜在的炸弹从客户端运到服务端,对绝大多数系统来说都没有必要。

# 客户端加密无意义 => 中间人
为什么客户端加密对防御泄密会没有意义?原因是网络通信并非由发送方和接收方点对点进行的,客户端无法决定用户送出的信息能不能到达服务端,或者会经过怎样的路径到达服务端。
在传输链路必定是不安全的假设前提下,无论客户端做什么防御措施,最终都会沦为“马其诺防线”。

之前已经提到多次的中间人攻击,它是通过劫持掉了客户端到服务端之间的某个节点,
包括但不限于代理(通过 HTTP 代理返回赝品)、路由器(通过路由导向赝品)、DNS 服务(直接将你机器的 DNS 查询结果替换为赝品地址)等,
来给你访问的页面或服务注入恶意的代码,极端情况下,甚至可能把要访问的服务或页面整个给取代掉,此时不论你在页面上设计了多么精巧严密的加密措施,都不会有保护作用。
而攻击者只需地劫持路由器,或在局域网内其他机器释放 ARP 病毒便有可能做到这一点。

额外知识:中间人攻击(Man-in-the-Middle Attack,MitM)
在消息发出方和接收方之间拦截双方通信。
.
用日常生活中的写信来类比的话:
你给朋友写了一封信,邮递员可以把每一份你寄出去的信都拆开看,甚至把信的内容改掉,然后重新封起来,再寄出去给你的朋友。
朋友收到信之后给你回信,邮递员又可以拆开看,看完随便改,改完封好再送到你手上。
你全程都不知道自己寄出去的信和收到的信都经过邮递员这个“中间人”转手和处理
=> 换句话说,对于你和你朋友来讲,邮递员这个“中间人”角色是不可见的。

1
2
3
4
5
# Server不应该明文,防止数据库泄漏
对于“不应把明文传递到服务端”的观点,也是有一些不同意见的。
例如其中一种保存明文密码的理由是为了便于客户端做动态加盐,因为这需要服务端存储了明文,或者某种盐值/密钥固定的加密结果,才能每次用新的盐值重新加密来与客户端传上来的加密结果进行比对。
每次从服务端请求动态盐值,在客户端加盐传输的做法通常都得不偿失,客户端无论是否动态加盐,都不可能代替 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
44
以 Fenix’s Bookstore 中的真实代码为例,介绍对一个普通安全强度的信息系统,密码如何从客户端传输到服务端,然后存储进数据库的全过程。

“普通安全强度”是指在具有一定保密安全性的同时,避免消耗过多的运算资源,验证起来也相对便捷。
对多数信息系统来说,只要配合一定的密码规则约束,例如密码要求长度、特殊字符等,再配合 HTTPS 传输,已足防御大多数风险了。
即使在用户采用了弱密码、客户端通信被监听、服务端被拖库、泄漏了存储的密文和盐值等问题同时发生,也能够最大限度避免用户明文密码被逆推出来。

# 加密
下面先介绍密码创建的过程:
1. (明文密码)用户在客户端注册,输入明文密码:123456。
password = 123456
2. (加密)客户端对用户密码进行简单哈希摘要,可选的算法有 MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。为了突出“简单”的哈希摘要,这里故意没有排除掉 MD 系这些已经有了高效碰撞手段的算法。
client_hash = MD5(password) // e10adc3949ba59abbe56e057f20f883e
3. (加盐)为了防御彩虹表攻击应加盐处理,客户端加盐只取固定的字符串即可,如实在不安心,最多用伪动态的盐值(“伪动态”是指服务端不需要额外通信可以得到的信息,例如由日期或用户名等自然变化的内容,加上固定字符串构成)。
client_hash = MD5(MD5(password) + salt) // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu
4. (防彩虹表:慢哈希)假设攻击者截获了客户端发出的信息,得到了摘要结果和采用的盐值,那攻击者就可以枚举遍历所有 8 位字符以内(“8 位”只是举个例子,反正就是指弱密码,你如果拿 1024 位随机字符当密码用,加不加盐,彩虹表都跟你没什么关系)的弱密码,然后对每个密码再加盐计算,就得到一个针对固定盐值的对照彩虹表。
为了应对这种暴力破解,并不提倡在盐值上做动态化,更理想的方式是引入慢哈希函数来解决。

慢哈希函数=> 是指这个函数执行时间是可以调节的哈希函数,通常是以控制调用次数来实现的。
BCrypt 算法就是一种典型的慢哈希函数,它做哈希计算时接受盐值 Salt 和执行成本 Cost 两个参数(代码层面 Cost 一般是混入在 Salt 中,例如上面例子中的 Salt 就是混入了 10 轮运算的盐值,10 轮的意思是 210次哈希,Cost 参数是放在指数上的,最大取值就 31)。
如果我们控制 BCrypt 的执行时间大概是 0.1 秒完成一次哈希计算的话,按照 1 秒生成 10 个哈希值的速度,算完所有的 10 位大小写字母和数字组成的弱密码大概需要 P(62,10)/(3600×24×365)/0.1=1,237,204,169 年时间。
client_hash = BCrypt(MD5(password) + salt) // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
5. (随机盐)只需防御被拖库后针对固定盐值的批量彩虹表攻击。具体做法是为每一个密码(指客户端传来的哈希值)产生一个随机的盐值。
建议采用“密码学安全伪随机数生成器”(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG)来生成一个长度与哈希值长度相等的随机字符串。
对于 Java 语言,从 Java SE 7 起提供了java.security.SecureRandom类,用于支持 CSPRNG 字符串生成。
SecureRandom random = new SecureRandom();
byte server_salt[] = new byte[36];
random.nextBytes(server_salt); // tq2pdxrblkbgp8vt8kbdpmzdh1w8bex
6. (再哈希)将动态盐值混入客户端传来的哈希值再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中。由于慢哈希算法占用大量处理器资源,并不推荐在服务端中采用。
不过,如果你阅读了 Fenix’s Bookstore 的源码,会发现这步依然采用了 Spring Security 5 中的BcryptPasswordEncoder,但是请注意它默认构造函数中的 Cost 参数值为-1,经转换后实际只进行了 210=1024 次计算,并不会对服务端造成太大的压力。
此外,代码中并未显式传入 CSPRNG 生成的盐值,这是因为BCryptPasswordEncoder本身就会自动调用 CSPRNG 产生盐值,并将该盐值输出在结果的前 32 位之中,因此也无须专门在数据库中设计存储盐值字段。
这个过程以伪代码表示如下:
server_hash = SHA256(client_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
DB.save(server_hash, server_salt);

以上加密存储的过程相对复杂,但是运算压力最大的过程(慢哈希)是在客户端完成的,对服务端压力很小,也不惧怕因网络通信被截获而导致明文密码泄漏。

# 验证
密码存储后,以后验证的过程与加密是类似的,步骤如下:
1. 客户端,用户在登录页面中输入密码明文:123456,经过与注册相同的加密过程,向服务端传输加密后的结果。
authentication_hash = MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
2. 服务端,接受到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,对客户端传来的哈希值、服务端存储的盐值计算摘要结果。
result = SHA256(authentication_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
3. 比较上一步的结果和数据库储存的哈希值是否相同,如果相同那么密码正确,反之密码错误。
authentication = compare(result, server_hash) // yes

在这里插入图片描述

传输

传输(Transport Security)
系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?

1
2
3
前文中已经为传输安全层挖下了不少坑,例如:基于信道的认证是怎样实现的?为什么 HTTPS 是绝大部分信息系统防御通信被窃听和篡改的唯一可行手段?传输安全层难道不也是一种自动化的加密吗?为何说客户端如何加密都不能代替 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
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
# 密码算法三用途:摘要、加密与签名
# JWT 可信 -> 被签名
我们从 JWT 令牌的一小段“题外话”来引出现代密码学算法的三种主要用途:摘要、加密与签名。
JWT 令牌携带信息的可信度源自于它是被签名过的信息,因此是不可篡改的,是令牌签发者真实意图的体现。
然而,你是否了解过签名具体做了什么?为什么有签名就能够让负载中的信息变得不可篡改和不可抵赖呢?要解释数字签名(Digital Signature),必须先从密码学算法的另外两种基础应用“摘要”和“加密”说起。

# 摘要
摘要也称之为数字摘要(Digital Digest)或数字指纹(Digital Fingerprint)。
JWT 令牌中默认的签名信息是对令牌头、负载和密钥三者通过令牌头中指定的哈希算法(HMAC SHA256)计算出来的摘要值,如下所示:
signature = SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)

# 哈希算法:两大特性
理想的哈希算法都具备两个特性:
+ 易变性
这是指算法的输入端发生了任何一点细微变动,都会引发雪崩效应(Avalanche Effect),使得输出端的结果产生极大的变化。
这个特性常被用来做校验,保护信息未被篡改,例如互联网上下载大文件,常会附有一个哈希校验码,以确保下载下来的文件没有因网络或其他原因与原文件产生任何偏差。
+ 不可逆性
摘要的过程是单向的,不可能从摘要的结果中逆向还原出输入值来。
这点只要具备初中数学知识就能想明白,世间的信息有无穷多种,而摘要的结果无论其位数是 32、128、512 Bits,再大也总归是个有限的数字,
因此输入数据与输出的摘要结果必然不是一一对应的关系,如果我把一部电影做摘要形成 256 Bits 的哈希值,应该没有人会指望能从这个哈希值中还原出一部电影的。
偶尔能听到 MD5、SHA1 或其他哈希算法被破解了的新闻,这里的“破解”并不是“解密”的意思,而是指找到了该算法的高效率碰撞方法,
能够在合理的时间内生成两个摘要结果相同的输入比特流,但并不能指定这两个输入流中的某一个,更不代表碰撞产生的比特流就会是原来的输入源(可能碰撞,不一定相同)。

# 摘要的意义 => 在源信息不泄漏的前提下辨别其真伪
易变性保证了从公开的特征上可以识别出是否来自于源信息,不可逆性保证了从公开的特征并不会暴露出源信息,这与今天用做身份识别的指纹、面容和虹膜的生物特征是具有高度可比性的。
在一些场合中,摘要也会被借用来做加密(如“保密”中介绍的慢哈希 Bcrypt 算法)和签名(如 JWT 签名中的 HMAC SHA256 算法),但在严格意义上看,摘要与这两者是有本质的区别。

# 大数质因数分解 数学yyds(逆运算 => 算力不够) => 加密算法公开:计算简单,反推难
加密与摘要的本质区别在于加密是可逆的,逆过程就是解密。
在经典密码学时代,加密的安全主要是依靠机密性来保证的,即依靠保护加密算法或算法的执行参数不被泄漏来保障信息的安全。
而现代密码学不依靠机密性,加解密算法都是完全公开的,安全建立在特定问题的计算复杂度之上,具体是指算法根据输入端计算输出结果耗费的算力资源很小,但根据输出端的结果反过来推算原本的输入,耗费的算力就极其庞大。
一个经常在课堂中用来说明计算复杂度的例子是大数的质因数分解,我们可以轻而易举的地(以 O(nlogn)的复杂度)计算出两个大素数的乘积,例如:
97667323933 * 128764321253 = 12576066674829627448049
根据算术基本定理,质因数的分解形式是唯一的,且前面计算条件中给出的运算因子已经是质数,所以 12,576,066,674,829,627,448,049 的分解形式就只有唯一的形式,即上面所示的唯一答案。
然而如何对大数进行质因数分解,迄今没有找到多项式时间的算法,甚至无法确切地知道这个问题属于哪个复杂度类(Complexity Class)。
所以尽管这个过程理论上一定是可逆的,但实际上算力差异决定了逆过程无法实现。
(注:24 位十进制数的因数分解完全在现代计算机的暴力处理能力范围内,这里只是举例。
但目前很多计算机科学家都相信大数分解问题就是一种P!=NP的证例,尽管也并没有人能证明它一定不存在多项式时间的解法。除了质因数分解外,离散对数和椭圆曲线也是具备实用性的复杂问题)

# 对称加密(加/解密 同密钥) => 密钥分发问题(蛋鸡悖论)
根据加密与解密是否采用同一个密钥,现代密码学算法可分为对称加密算法和非对称加密两大类型,这两类算法各自有很明确的优劣势与应用场景。
对称加密的缺点显而易见,加密和解密使用相同的密钥,当通信的成员数量增加时,为保证两两通信都都采用独立的密钥,密钥数量就与成员数量的平方成正比,这必然面临密钥管理的难题。
而更尴尬的难题是当通信双方原本就不存在安全的信道时,如何才能将一个只能让通信双方才能知道的密钥传输给对方?如果有通道可以安全地传输密钥,那为何不使用现有的通道传输信息?
这个“蛋鸡悖论”曾在很长的时间里严重阻碍了密码学在真实世界中推广应用。

# 非对称加密(加/解密 不同密钥)
20 世纪 70 年代中后期出现的非对称加密算法从根本上解决了密钥分发的难题,它将密钥分成公钥和私钥,公钥可以完全公开,无须安全传输的保证。
私钥由用户自行保管,不参与任何通信传输。根据这两个密钥加解密方式的不同,使得算法可以提供两种不同的功能:

+ 加密(防止得知内容)
公钥加密,私钥解密;
用于向私钥所有者发送信息,这个信息可能被他人篡改,但是无法被他人得知。
如果甲想给乙发一个安全保密的数据,那么应该甲乙各自有一个私钥,甲先用乙的公钥加密这段数据,再用自己的私钥加密这段加密后的数据。
最后再发给乙,这样确保了内容即不会被读取,也不能被篡改。
+ 签名(防修改)
私钥加密,公钥解密;
用于让所有公钥所有者验证私钥所有者的身份,并且用来防止私钥所有者发布的内容被篡改。但是不用来保证内容不被他人获得。

# 非对称加密 => 不适用:大量数据加密
这两种用途理论上肯定是成立的,现实中却一般不成立,单靠非对称加密算法,既做不了加密也做不了签名。
原因是不论是加密还是解密,非对称加密算法的计算复杂度都相当高,性能比对称加密要差上好几个数量级(不是好几倍)。
加解密性能不仅影响速度,还导致了现行的非对称加密算法都没有支持分组加密模式。
分组是指由于明文长度与密钥长度在安全上具有相关性,通俗地说就是多长的密钥决定了它能加密多长的明文,如果明文太短就需要进行填充,太长就需要进行分组。
因非对称加密本身的效率所限,难以支持分组,所以主流的非对称加密算法都只能加密不超过密钥长度的数据,这决定了非对称加密不能直接用于大量数据的加密。

# 加密 => 对称+非对称加密
在加密方面,现在一般会结合对称与非对称加密的优点,以混合加密来保护信道安全。
具体做法是用非对称加密来安全地传递少量数据给通信的另一方,然后再以这些数据为密钥,采用对称加密来安全高效地大量加密传输数据,这种由多种加密算法组合的应用形式被称为“密码学套件”。
非对称加密在这个场景中发挥的作用称为“密钥协商”。

# 签名 => 摘要+非对称加密
在签名方面,现在一般会结合摘要与非对称加密的优点,以对摘要结果做加密的形式来保证签名的适用性。
由于对任何长度的输入源做摘要之后都能得到固定长度的结果,所以只要对摘要的结果进行签名,即相当于对整个输入源进行了背书,
保证一旦内容遭到篡改,摘要结果就会变化,签名也就马上失效了。

下面汇总了前面提到的三种算法,并列举了它们的主要特征、用途和局限性。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
# JWT x
现在,让我们再回到开篇关于 JWT 令牌的几个问题中来。
有了哈希摘要、对称和非对称加密,JWT 令牌的签名就能保证负载中的信息不可篡改、不可抵赖吗?
其实还是不行的,这个场景里,数字签名的安全性仍存在一个致命的漏洞:公钥虽然是公开的,但在网络世界里“公开”具体是一种什么操作?
如何保证每一个获取公钥的服务,拿到的公钥就是授权服务器所希望它拿到的?

# 公钥篡改问题 => 公钥传输不可信
在网络传输是不可信任的前提下,公钥在网络传输过程中可能已经被篡改,如果获取公钥的网络请求被攻击者截获并篡改,返回了攻击者自己的公钥,那以后攻击者就可以用自己的私钥来签名,让资源服务器无条件信任它的所有行为了。
现实世界中公开公钥,可以通过打电话、发邮件、短信息、登报纸、同时发布在多个网站上等等,很多网络通信之外的途径来达成,但在程序与网络的世界中,就必须找到一种可信任的公开方法,而且这种方法不能依赖加密来实现,否则又将陷入蛋鸡问题之中。

数字证书

1
2
3
4
5
6
7
8
9
10
11
# 现实世界:两种信任 => 共同私密,权威公证
当我们无法以“签名”的手段来达成信任时,就只能求助于其他途径。不妨想想真实的世界中,我们是如何达成信任的,其实不外乎以下两种:

+ 基于共同私密信息的信任
例如某个陌生号码找你,说是你的老同学,生病了要找你借钱。你能够信任他的方式是向对方询问一些你们两个应该知道,且只有你们两个知道的私密信息,如果对方能够回答上来,他有可能真的是你的老同学,否则他十有八九就是个诈骗犯。
+ 基于权威公证人的信任
如果有个陌生人找你,说他是警察,让你把存款转到他们的安全账号上。你能够信任他的方式是去一趟公安局,如果公安局担保他确实是个警察,那他有可能真的是警察,否则他十有八九就是个诈骗犯。

# 网络世界:权威公证
回到网络世界中,我们并不能假设授权服务器和资源服务器是互相认识的,所以通常不太会采用第一种方式,
而第二种就是目前标准的保证公钥可信分发的标准,这个标准有一个名字:公开密钥基础设施(Public Key Infrastructure,PKI)。

额外知识:公开密钥基础设施
又称公开密钥基础架构、公钥基础建设、公钥基础设施、公开密码匙基础建设或公钥基础架构,
是一组由硬件、软件、参与者、管理政策与流程组成的基础架构,其目的在于创造、管理、分配、使用、存储以及撤销数字证书。
.
密码学上,公开密钥基础建设借着数字证书认证中心(Certificate Authority,CA)将用户的个人身份跟公开密钥链接在一起。
对每个证书中心用户的身份必须是唯一的。链接关系通过注册和发布过程创建,根据担保级别的差异,创建过程可由 CA 的各种软件或者在人为监督下完成。
PKI 的确定链接关系的这一角色称为注册管理中心(Registration Authority,RA)。RA 确保公开密钥和个人身份链接,可以防抵赖。

1
2
3
4
5
# 公钥不可数,权威CA中心是有限的
咱们不必纠缠于 PKI 概念上的内容,只要知道里面定义的“数字证书认证中心”相当于前面例子中“权威公证人”的角色,是负责发放和管理数字证书的权威机构即可。任何人包括你我都可以签发证书,只是不权威罢了。
CA 作为受信任的第三方,承担公钥体系中公钥的合法性检验的责任。可是,这里和现实世界仍然有一些区别,现实世界你去找公安局,那大楼不大可能是剧场布景冒认的;
而网络世界,在假设所有网络传输都有可能被截获冒认的前提下,“去 CA 中心进行认证”本身也是一种网络操作,这与之前的“去获取公钥”本质上不是没什么差别吗?
其实还是有差别的,世间公钥成千上万不可枚举,而权威的 CA 中心则应是可数的,“可数”意味着可以不通过网络,而是在浏览器与操作系统出厂时就预置好,或者提前安装好(如银行的证书),下图是电脑上现存的根证书。

在这里插入图片描述

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
# 证书(公钥) => 打破蛋鸡悖论       => 证书保证公钥不被修改(CA 证书或者根证书:系统预设)
到这里出现了本节的主角之一:证书(Certificate),证书是权威 CA 中心对特定公钥信息的一种公证载体,也可以理解为是权威 CA 对特定公钥未被篡改的签名背书。
由于客户的机器上已经预置了这些权威 CA 中心本身的证书(称为 CA 证书或者根证书),使得我们能够在不依靠网络的前提下,使用根证书里面的公钥信息对其所签发的证书中的签名进行确认。
到此,终于打破了鸡生蛋、蛋生鸡的循环,使得整套数字签名体系有了坚实的逻辑基础。

# PKI 证书格式
PKI 中采用的证书格式是X.509 标准格式,它定义了证书中应该包含哪些信息,并描述了这些信息是如何编码的,里面最关键的就是认证机构的数字签名和公钥信息两项内容。

一个数字证书具体包含以下内容:
1. 版本号(Version)
指出该证书使用了哪种版本的 X.509 标准(版本 1、版本 2 或是版本 3),版本号会影响证书中的一些特定信息,目前的版本为 3。
Version: 3 (0x2)
2. 序列号(Serial Number)
由证书颁发者分配的本证书的唯一标识符。
Serial Number: 04:00:00:00:00:01:15:4b:5a:c3:94
3. 签名算法标识符(Signature Algorithm ID)
用于签发证书的算法标识,由对象标识符加上相关的参数组成,用于说明本证书所用的数字签名算法。
例如,SHA1 和 RSA 的对象标识符就用来说明该数字签名是利用 RSA 对 SHA1 的摘要结果进行加密。
Signature Algorithm: sha1WithRSAEncryption
4. 认证机构的数字签名(Certificate Signature)
这是使用证书发布者私钥生成的签名,以确保这个证书在发放之后没有被篡改过。
5. 认证机构(Issuer Name)
证书颁发者的可识别名。
Issuer: C=BE, O=GlobalSign nv-sa, CN=GlobalSign Organization Validation CA - SHA256 - G2
6. 有效期限(Validity Period)
证书起始日期和时间以及终止日期和时间,指明证书在这两个时间内有效。
Validity
Not Before: Nov 21 08:00:00 2020 GMT
Not After : Nov 22 07:59:59 2021 GMT
7. 主题信息(Subject)
证书持有人唯一的标识符(Distinguished Name),这个名字在整个互联网上应该是唯一的,通常使用的是网站的域名。
Subject: C=CN, ST=GuangDong, L=Zhuhai, O=Awosome-Fenix, CN=*.icyfenix.cn
8. 公钥信息(Public-Key)
包括证书持有人的公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数。

传输安全层

关于HTTPS的文章:
申请免费SSL证书,实现 https 访问
*https 的简单实现原理
https请求 SSL 证书校验失败
解决:postman 不能访问 https
Google浏览器显示URL的 http 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
到此为止,数字签名的安全性已经可以完全自洽了,但相信你大概也已经感受到了这条信任链的复杂与烦琐。
如果从确定加密算法、生成密钥、公钥分发、CA 认证、核验公钥、签名、验证,每一个步骤都要由最终用户来完成的话,这种意义的“安全”估计只能一直是存于实验室中的阳春白雪。
如何把这套烦琐的技术体系自动化地应用于无处不在的网络通信之中,便是本节的主题。

# 隔离=分层(一层不够就再加一层)
在计算机科学里,隔离复杂性的最有效手段(没有之一)就是分层,如果一层不够就再加一层,这点在网络中更是体现得淋漓尽致。
OSI 模型、TCP/IP 模型将网络从物理特性(比特流)开始,逐层封装隔离,到了 HTTP 协议这种面向应用的协议里,使用者就已经不会去关心网卡/交换机如何处理数据帧、MAC 地址;不会去关心 ARP 如何做地址转换;不会去关心 IP 寻址、TCP 传输控制等细节。

# 传输层 -> 安全层 -> 应用层
想要在网络世界中让用户无感知地实现安全通信,最合理的做法就是在传输层之上、应用层之下加入专门的安全层来实现,这样对上层原本基于 HTTP 的 Web 应用来说,影响甚至是无法察觉的。
构建传输安全层这个想法,几乎可以说是和万维网的历史一样长,早在 1994 年,就已经有公司开始着手去实践了:

# 安全层的发展
(SSL 1.0,未公开)1994 年,网景(Netscape)公司开发了 SSL 协议(Secure Sockets Layer)的 1.0 版,这是构建传输安全层的起源,但是 SSL 1.0 从未正式对外发布过。
(SSL 2.0,发布后有漏洞)1995 年,Netscape 把 SSL 升级到 2.0 版,正式对外发布,但是刚刚发布不久就被发现有严重漏洞,所以并未大规模使用。
(SSL 3.0,网络安全标准)1996 年,修补好漏洞的 SSL 3.0 对外发布,这个版本得到了广泛的应用,很快成为 Web 网络安全层的事实标准。
(SSL -> TLS 1.0 还在继续)1999 年,互联网标准化组织接替 Netscape,将 SSL 改名 TLS(Transport Layer Security)后形成了传输安全层的国际标准。
第一个正式的版本是RFC 2246定义的 TLS 1.0,该版 TLS 的生命周期极长,直至 2020 年 3 月,主流浏览器(Chrome、Firefox、IE、Safari)才刚刚宣布同时停止 TLS 1.0/1.1 的支持。
而讽刺的是,由于停止后许多政府网站无法被浏览,此时又正值新冠病毒(COVID-19)爆发期,Firefox 紧急发布公告宣布撤回该改动,TLS 1.0 的生命还在顽强延续。
(TLS 1.1 被遗忘)2006 年,TLS 的第一个升级版 1.1 发布(RFC 4346),但却沦为了被遗忘的孩子,很少人使用 TLS 1.1,甚至到了 TLS 1.1 从来没有已知的协议漏洞被提出的程度。
(TLS 1.2 主流)2008 年,TLS 1.1 发布 2 年之后,TLS 1.2 标准发布(RFC 5246),迄今超过 90%的互联网 HTTPS 流量是由 TLS 1.2 所支持的,现在仍在使用的浏览器几乎都完美支持了该协议。
(TLS 1.3 复用连接)2018 年,最新的 TLS 1.3(RFC 8446)发布,比起前面版本相对温和的升级,TLS 1.3 做了出了一些激烈的改动,修改了从 1.0 起一直没有大变化的两轮四次(2-RTT)握手,首次连接仅需一轮(1-RTT)握手即可完成,在有连接复用支持时,甚至将 TLS 1.2 原本的 1-RTT 下降到了 0-RTT,显著提升了访问速度。

# 四次通信:防监听,防篡改,防冒充
接下来,以 TLS 1.2 为例,介绍传输安全层是如何保障所有信息都是第三方无法窃听(加密传输)、无法篡改(一旦篡改通信算法会立刻发现)、无法冒充(证书验证身份)的。
TLS 1.2 在传输之前的握手过程一共需要进行上下两轮、共计四次通信,时序如图所示。

在这里插入图片描述

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
1. 客户端请求:Client Hello
客户端向服务器请求进行加密通信,在这个请求里面,它会以明文的形式,向服务端提供以下信息。
+ 支持的协议版本,例如 TLS 1.2。但是要注意,1.0 至 3.0 分别代表 SSL1.0 至 3.0,TLS1.0 则是 3.1,一直到 TLS1.3 的 3.4。
+ 一个客户端生成的 32 Bytes 随机数,这个随机数将稍后用于产生加密的密钥。
+ 一个可选的 SessionID,注意不要和前面 Cookie-Session 机制混淆了,这个 SessionID 是指传输安全层的 Session,是为了 TLS 的连接复用而设计的。
+ 一系列支持的密码学算法套件,例如TLS_RSA_WITH_AES_128_GCM_SHA256,代表着密钥交换算法是 RSA,加密算法是 AES128-GCM,消息认证码算法是 SHA256
+ 一系列支持的数据压缩算法。
+ 其他可扩展的信息,为了保证协议的稳定,后续对协议的功能扩展大多都添加到这个变长结构中。
(1.0 一个证书,1.1 多个证书)
例如 TLS 1.0 中由于发送的数据并不包含服务器的域名地址,导致了一台服务器只能安装一张数字证书,这对虚拟主机来说就很不方便,所以 TLS 1.1 起就增加了名为“Server Name”的扩展信息,以便一台服务器给不同的站点安装不同的证书。

2. 服务器回应:Server Hello
服务器接收到客户端的通信请求后,如果客户端声明支持的协议版本和加密算法组合与服务端相匹配的话,就向客户端发出回应。
如果不匹配,将会返回一个握手失败的警告提示。这次回应同样以明文发送的,包括以下信息:
+ 服务端确认使用的 TLS 协议版本。
+ 第二个 32 Bytes 的随机数,稍后用于产生加密的密钥。
+ 一个 SessionID,以后可通过连接复用减少一轮握手。
+ 服务端在列表中选定的密码学算法套件。
+ 服务端在列表中选定的数据压缩方法。
+ 其他可扩展的信息。
+ (发证书)如果协商出的加密算法组合是依赖证书认证的,服务端还要发送出自己的 X.509 证书,而证书中的公钥是什么,也必须根据协商的加密算法组合来决定。
+ 密钥协商消息,这部分内容对于不同密码学套件有着不同的价值,
例如对于 ECDH + anon 这样得密钥协商算法组合(基于椭圆曲线的ECDH 算法可以在双方通信都公开的情况下协商出一组只有通信双方知道的密钥)就不需要依赖证书中的公钥,而是通过 Server Key Exchange 消息协商出密钥。

3. 客户端确认:Client Handshake Finished
由于密码学套件的组合复杂多样,这里仅以 RSA 算法为密钥交换算法为例介绍后续过程。
客户端收到服务器应答后,先要验证服务器的证书合法性。如果证书不是可信机构颁布的,或者证书中信息存在问题,
例如域名与实际域名不一致、或者证书已经过期、或通过在线证书状态协议得知证书已被吊销,等等,都会向访问者显示一个“证书不可信任”的警告,由用户自行选择是否还要继续通信(原来如此)。
如果证书没有问题,客户端就会从证书中取出服务器的公钥,并向服务器发送以下信息:
+ 客户端证书(可选:是否使用mTLS)。部分服务端并不是面向全公众,只对特定的客户端提供服务,此时客户端需要发送它自身的证书来证明身份。
如果不发送,或者验证不通过,服务端可自行决定是否要继续握手,或者返回一个握手失败的信息。
客户端需要证书的 TLS 通信也称为“双向 TLS”(Mutual TLS,常简写为 mTLS),这是云原生基础设施的主要认证方法,也是基于信道认证的最主流形式。
+ 第三个 32 Bytes 的随机数,这个随机数不再是明文发送,而是以服务端传过来的公钥加密的,它被称为 PreMasterSecret,
将与前两次发送的随机数一起,根据特定算法计算出 48 Bytes 的 MasterSecret ,这个 MasterSecret 即为后续内容传输时的对称加密算法所采用的私钥。
+ 编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
+ 客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时也是前面发送的所有内容的哈希值,以供服务器校验。

4. 服务端确认:Server Handshake Finished
服务端向客户端回应最后的确认通知,包括以下信息。
+ 编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
+ 服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时也是前面发送的所有内容的哈希值,以供客户端校验。

# HTTPS => HTTP over SSL/TLS
至此,整个 TLS 握手阶段宣告完成,一个安全的连接就已成功建立。每一个连接建立时,客户端和服务端均通过上面的握手过程协商出了许多信息,
例如一个只有双方才知道的随机产生的密钥、传输过程中要采用的对称加密算法(例子中的 AES128)、压缩算法等,此后该连接的通信将使用此密钥和加密算法进行加密、解密和压缩。
这种处理方式对上层协议的功能上完全透明的,在传输性能上会有下降,但在功能上完全不会感知到有 TLS 的存在。
建立在这层安全传输层之上的 HTTP 协议,就被称为“HTTP over SSL/TLS”,也即是大家所熟知的 HTTPS。

# HTTPS 安枕无忧 x
从上面握手协商的过程中我们还可以得知,HTTPS 并非不是只有“启用了 HTTPS”和“未启用 HTTPS”的差别,
采用不同的协议版本、不同的密码学套件、证书是否有效、服务端/客户端对面对无效证书时的处理策略如何都导致了不同 HTTPS 站点的安全强度的不同,因此并不能说只要启用了 HTTPS 就必定能够安枕无忧。

验证

验证(Verification)
系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?

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
数据验证与程序如何编码是密切相关的,许多开发者都不会把它归入安全的范畴之中。但请细想一下,关注“你是谁”(认证)、“你能做什么”(授权)等问题是很合理的,关注“你做得对不对”(验证)不也同样合理吗?
从数量来讲,数据验证不严谨而导致的安全问题比其他安全攻击导致的要多得多;
而风险上讲,由数据质量导致的问题,风险有高有低,真遇到高风险的数据问题,面临的损失不一定就比被黑客拖库来得小。

相比其他富有挑战性的安全措施,如防御与攻击两者缠斗的精彩,数学、心理、社会工程和计算机等跨学科知识的结合运用,数据验证确实有些无聊、枯燥,这项常规的工作在日常的开发中贯穿于代码的各个层次,每个程序员都肯定写过。
但这种常见的代码反而是迫切需要被架构约束的,缺失的校验影响数据质量,过度的校验不会使得系统更加健壮,某种意义上反而会制造垃圾代码,甚至有副作用。请来看看下面这个实际的段子:
前 端: 提交一份用户数据(姓名:某, 性别:男, 爱好:女, 签名:xxx, 手机:xxx, 邮箱:null)
控制器: 发现邮箱是空的,抛ValidationException("邮箱没填")
前 端: 已修改,重新提交
安 全: 发送验证码时发现手机号少一位,抛RemoteInvokeException("无法发送验证码")
前 端: 已修改,重新提交
服务层: 邮箱怎么有重复啊,抛BusinessRuntimeException("不允许开小号")
前 端: 已修改,重新提交
持久层: 签名字段超长了插不进去,抛SQLException("插入数据库失败,SQL:xxx")
…… ……
前 端: 你们这些坑管挖不管埋的后端,各种异常都往前抛!
用 户: 这系统牙膏厂生产的?

最基础的数据问题可以在前端做表单校验来处理,但服务端验证肯定也是要做的,看完了上面的段子后,请想一想,服务端应该在哪一层去做校验?可能会有这样的答案:
+ 在 Controller 层做,在 Service 层不做。理由是从 Service 开始会有同级重用,出现 ServiceA.foo(params)调用 ServiceB.bar(params)时,就会对 params 重复校验了两次。
+ 在 Service 层做,在 Controller 层不做。理由是无业务含义的格式校验已在前端表单验证处理过,有业务含义的校验,放在 Controller 层无论如何不合适。
+ 在 Controller、Service 层各做各的。Controller 做格式校验,Service 层做业务校验,听起来很合理,但这其实就是上面段子中被嘲笑的行为。
+ 还有其他一些意见,例如还有提在持久层做校验,理由是这是最终入口,把守好写入数据库的质量最重要。

上述的讨论大概是不会有统一、正确结论,但是在 Java 里确实是有验证的标准做法,提倡的做法是把校验行为从分层中剥离出来,不是在哪一层做,而是在 Bean 上做。即 Java Bean Validation。
从 2009 年的JSR 303的 1.0,到 2013 年的JSR 349更新的 1.1,到目前最新的 2017 年发布的JSR 380,定义了 Bean 验证的全套规范。

单独将验证提取、封装,可以获得不少好处:
+ 对于无业务含义的格式验证,可以做到预置。
+ 对于有业务含义的业务验证,可以做到重用,一个 Bean 被用于多个方法用作参数或返回值是很常见的,针对 Bean 做校验比针对方法做校验更有价值。
利于集中管理,例如统一认证的异常体系,统一做国际化、统一给客户端的返回格式等等。
+ 避免对输入数据的防御污染到业务代码,如果你的代码里面如果很多下面这样的条件判断,就应该考虑重构了:
// 一些已执行的逻辑
if (someParam == null) {
throw new RuntimeExcetpion("客官不可以!")
}
+ 利于多个校验器统一执行,统一返回校验结果,避免用户踩地雷、挤牙膏式的试错体验。

国内的项目使用 Bean Validation 的并不少见,但多数程序员都只使用到它的 Built-In Constraint(内置的约束) 来做一些与业务逻辑无关的通用校验,即下面这堆注解,含义基本上看类名就能明白
@Null、@NotNull、@NotEmpty、@NotBlank、@AssertTrue、@AssertFalse、@Min、@Max、@DecimalMin、@DecimalMax、
@Negative、@NegativeOrZero、@Positive、@PositiveOrZero、@Size、@Digits、@Past、@PastOrPresent、
@Future、@FutureOrPresent、@Pattern、@Email

【日期标记】2022-08-16 09:55:04 以上同步完成

技术方法论

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 特性的必要条件。

前提:微服务需要的条件

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