凤凰架构

⭐⭐⭐⭐⭐

这是一副帮助开发者、技术决策者的技能地图,一份参考手册。

从架构演进开始,循序渐进走向分布式和云原生。除了不同架构主流方案外,更重要的是叙述了这些方案的来龙去脉、利弊选择,同时提供了代码工程。虽然周老师文笔谦逊,但能感受到文字中蕴含的海纳百川的容量。

技术决策者重要的是取舍,在能满足需求的前提下,最简单的系统就是最好的系统

在线阅读

技术架构者的第一职责就是做决策权衡,有利有弊才需要决策,有取有舍才需要权衡,如果架构者本身的知识面不足以覆盖所需要决策的内容,不清楚其中利弊,恐怕也就无可避免地陷入选择困难的困境之中。

演进中的架构

架构不是被发明出来的,而是持续演进的。

原始分布式时代

UNIX 的分布式设计

保持接口与实现的简单性,比系统的任何其他属性,包括准确性、一致性和完整性,都来得更加重要。

教训

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

原因在于:将一个系统拆分到不同的机器中运行,解决服务发现、跟踪、通信、容错、隔离、配置、传输、数据一致性和编码复杂度等带来的问题,所付出的代价远远超过了分布式所取得的收益。

结论

现实情况下,有两条通往更大规模软件系统的道路。

  1. 尽快提升单机的处理能力,以避免分布式带来的种种问题;
  2. 找到更完美的解决如何构筑分布式系统的解决方案。

单体系统时代

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

优势

对于小型系统,单台机器就足以支撑其良好运行的系统,单体不仅易于开发、易于测试、易于部署,且由于系统中各个功能、模块、方法的调用过程都是进程内调用,不会发生进程间通信因此也是运行效率最高的一种架构风格。

劣势

劣势必须是基于软件性能需求超过单机,开发人员规模超过 “2 Pizza”范畴的前提下才有价值。

单体系统的缺陷不是系统如何拆分,而是拆分之后的隔离与自治能力上的欠缺。

  1. 获得了进程内调用的简单、高效等好处的同时,也意味着如果任何一部分代码出现了缺陷,过度消耗了进程空间内的资源,所造成的影响也是全局性的、难以隔离的;
  2. 无法做到单独停止、更新、升级某一部分代码;
  3. 程序升级、修改缺陷往往需要制定专门的停机更新计划,做灰度发布、A/B 测试也相对更复杂;
  4. 技术异构困难;
  5. 最重要的一点:单体系统潜在的希望每个组件和代码都尽量可靠,用减少缺陷来搭建可靠系统。由于墨菲定律的存在,观念的转变才是微服务替代单体的底气;

结论

摩尔定律减速的同时,为了允许程序出错,为了获得隔离、自治的能力,为了技术异构等目标,程序选择分布式再次成为焦点。

分布式除了适用微服务架构外,将一个大的单体系统拆分为若干个更小的、不运行在同一个进程的独立服务,衍生出了面向服务架构,也就是 SOA。

SOA 时代

为了解决单体服务拆分的问题,面向服务的架构是一次具体地、系统性地成功解决分布式服务主要问题的架构模式。

烟囱式

被拆分的服务完全不与其他系统进行互操作和协调。

微内核

将各个子系统使用到的公共服务、资源和数据集中到一起,形成一个核心系统,具体业务系统以插件模块形式存在。缺点是:插件模块只能和核心系统交互,相互之间无法通信。

事件驱动

在子系统之间建立一套事件队列管道(Event Queues),来自系统外部的消息以事件的形式发送至管道中,各个子系统从管道里获取自己感兴趣、能够处理的事件消息,同时能发送事件到管道中。

SOA

是一套软件设计的基础平台。

结论

由于 SOA 过于严格的规范定义,带来过度的复杂性,注定了它只能是少数系统阳春白雪式的精致奢侈品,很难作为一种具有广泛普适性的软件架构风格来推广。

微服务时代

微服务是一种通过多个小型服务组合来构建单个应用的架构风格,这些服务围绕业务能力而非特定的技术标准来构建。各个服务可以采用不同的编程语言,不同的数据存储技术,运行在不同的进程之中。服务采取轻量级的通信机制和自动化的部署机制实现通信与运维。

特点

  1. 围绕业务能力构建——康威定律

    有怎样结构、规模、能力的团队,就会产生出对应结构、规模、能力的产品。这个结论不是某个团队、某个公司遇到的巧合,而是必然的演化结果。

  2. 分散治理

    服务对应的开发团队有直接对服务运行质量负责的责任,并且有选择性掌控服务各个方面的权力。

  3. 通过服务来实现组件

    类库是在编译器期静态链接到程序中,而服务是进程外组件。虽然远程服务有更高昂的调用成本,但这是需要隔离与自治能力的必要代价。

  4. 产品化思维

    开发者不仅应该知道软件如何开发,还应该知道它如何运作,用户如何反馈,乃至售后支持工作是怎样进行的。

  5. 数据去中心化

    数据应该按领域分散管理、更新、维护、存储。

  6. 强终端弱管道

    终端是 Endpoint,对应于 RESTful 风格的接口。管道是 SOAP 协议中的概念。

  7. 容错性设计

    承认服务中总会出错的现实,有机制进行快速的故障检测、服务隔离和恢复重连。

  8. 演进式设计

    承认服务会被报废淘汰。假如系统中出现不可更改、无可替代的服务,这并不能说明这个服务是多么的优秀、多么的重要,反而是一种系统设计上脆弱的表现。

  9. 基础设施自动化

    人工难以支持很多服务。

结论

微服务所带来的自由是一把双刃开锋的宝剑,当架构者拿起这把宝剑,一刃指向 SOA 定下的复杂技术标准,将选择的权力夺回的同一时刻,另外一刃也正朝向着自己映出冷冷的寒光。

后微服务时代

软、硬一体,合力应对微服务架构的问题。

微服务使用容器作为载体,管理载体的平台则是 k8s。

Kubernetes Spring Cloud
弹性伸缩 Autoscaling N/A
服务发现 KubeDNS / CoreDNS Spring Cloud Eureka
配置中心 ConfigMap / Secret Spring Cloud Config
服务网关 Ingress Controller Spring Cloud Gateway
负载均衡 Load Balancer Spring Cloud Ribbon
服务安全 RBAC API Spring Cloud Security
跟踪监控 Metrics API / Dashboard Spring Cloud Turbine
降级熔断 N/A Spring Cloud Hystrix

k8s 是针对整个容器来管理的,粒度相对粗旷,只能到容器层面,对单个远程服务就很难有效管控。

为了解决这个问题,虚拟化的基础设施进化到“服务网格”。具体含义是由系统自动在服务容器中注入一个通信代理服务器,在应用毫无感知的情况下,悄然接管应用所有对外通信。这个代理除了实现正常的服务间通信外(称为数据平面通信),还接收来自控制器的指令(称为控制平面通信),根据控制平面中的配置,对数据平面通信的内容进行分析处理,以实现熔断、认证、度量、监控、负载均衡等各种附加功能。这样便实现了既不需要在应用层面加入额外的处理代码,也提供了几乎不亚于程序代码的精细管理能力。

结论

未来 Kubernetes 将会成为服务器端标准的运行环境,如同现在 Linux 系统;服务网格将会成为微服务之间通信交互的主流模式,把“选择什么通信协议”、“怎样调度流量”、“如何认证授权”之类的技术问题隔离于程序代码之外。

无服务时代

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

结论

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

架构师的视角

远程服务

远程服务将计算机程序的工作范围从单机扩展到网络,从本地延伸至远程,是构建分布式系统的首要基础。

远程服务调用 RPC

进程内调用

1
2
3
4
5
6
7
8
// 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”);
}

计算机执行步骤:

  1. 传递方法参数:将字符串helloworld的引用地址压栈;
  2. 确定方法版本:根据println()方法的签名,确定其执行版本‘
  3. 执行被调方法:从栈中弹出Parameter的值或引用,以此为输入,执行Callee内部的逻辑;
  4. 返回执行结果:将Callee的执行结果压栈,并将程序的指令流恢复到Call Site的下一条指令,继续向下执行;

mainprintln 分属不同进程中,则:

  1. 压栈执行毫无意义;
  2. 不同语言实现 2 个方法的情况下,版本选择是不可知行为;

进程间调用 IPC

  • 管道 | 具名管道:类似于 2 个进程之间的桥梁,通过管道在进程间传递少量字符流或字节流;
  • 信号:通知目标进程产生某种行为;
  • 信号量: OS 提供的特殊变量,用于 2 进程之间同步;
  • 消息队列:可以传递更多消息的管道;
  • 共享内存:多个进程共享公共内存空间,效率最高;
  • 套接字接口:消息队列和共享内存只适合于单机多进程通讯,Socket 更加普适;

RPC 最初是作为 IPC 的一种特例来处理的。但忽略了 8 个问题:

  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 —— 网络是同质化的。

基于以上 8 大问题,证明了 RPC 应该是一种高层次的或者说语言层次的特征,而非 IPC 这样低层次的或者说系统层次的特征,同时否定了 RPC 最初是作为 IPC 的一种特例来处理的可能。

RPC 的三个基本问题

  1. 如何表示数据

    包括了传递给方法的参数,以及方法执行后的返回值。。有效的做法是将交互双方所涉及的数据转换为某种事先约定好的中立数据流格式来进行传输,将数据流转换回不同语言中对应的数据类型来进行使用。也就是说,每种 RPC 协议都要对应各自的序列化协议。例如,gRPC 的 Protocol Buffers

  2. 如何传递数据

    两个服务交互不仅仅使用序列化数据流来表示参数和结果就行,还有譬如异常、超时、安全、认证、授权、事务,等等,都可能产生双方需要交换信息的需求。例如,JSON-RPC 直接使用 HTTP 协议。

  3. 如何确定方法

    对于不同语言实现的方法,需要根据方法签名转换为进程空间中子过程入口位置的指针。

总结

不同的 RPC 框架所提供的特性或多或少是有矛盾的,很难有某一种 RPC 框架既有面向对象特性、又有高性能,还有简化的大一统框架。

RESTful 风格

理解 REST

REST 即”表征状态转移“。

  • 资源:信息、数据本身就称为资源;
  • 表征:服务端向客户端返回资源的特征称为表征。例如,服务端可以返回 HTML、JSON、MD、PDF 等等;
  • 状态:在特定语境中才能产生的上下文信息称为“状态”。客户端记住状态,请求时告诉服务器称为无状态;服务端保持客户端状态则成为有状态;
  • 转移:服务端通过某种方式,将资源转换,就是称为转移;

RESTful 系统

  • 客户端与服务端分离:服务端提供数据,客户端进行渲染。有利于提高用户界面的跨平台的移植性;
  • 无状态:服务端无状态在分布式计算中价值很大。但实际上服务端持有内存、会话、数据库或者缓存等已经事实存在,并将长期存在、被广泛使用的主流的方案;
  • 可缓存:无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。因为有状态的设计只需要一次或者少量请求,但无状态设计就要多次请求,为了缓解多次请求的压力,就要增加缓存设计;
  • 分层系统:客户端不需要知道是否连接到最终的服务器。因为 CDN 可以加速访问过程;
  • 统一接口:HTTP 协议中提前约定好的 GET、POST 等 7 种动作;

不足

  1. 面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑。因为复杂的场景,很难用 HTTP 的统一接口去描述;
  2. REST 与 HTTP 完全绑定,不适合应用于要求高性能传输的场景中;
  3. 不利于事务;
  4. REST 没有传输可靠性支持;
  5. REST 缺乏对资源进行“部分”和“批量”的处理能力。由于 HTTP 协议完全没有对请求资源的结构化描述能力,所以返回资源的哪些内容、以什么数据类型返回等等,都不可能得到协议层面的支持;

对比

RPC 是将本地的方法调用思路迁移到远程方法调用上,开发者是围绕着“远程方法”去设计两个系统间交互的。这样做的坏处不仅是“如何在异构系统间表示一个方法”、“如何获得接口能够提供的方法清单”,更在于服务的每个方法都是完全独立的,服务使用者必须逐个学习才能正确地使用它们。

REST 中因为有统一接口的存在,降低服务接口的学习成本;方法是动词,逻辑上每个接口都相互独立,而资源是名词,天生具有集合与层次结构;REST 绑定于 HTTP 协议;

事务处理

事务处理的意义是为了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)

单服务单数据源

这是最基础的事务解决方案。

基于语义的恢复与隔离算法是数据库关系系统实现 ACID 事务的理论依据。

每当一本书被成功售出时,需要确保以下三件事情被正确地处理:

  • 用户的账号扣减相应的商品款项。
  • 商品仓库中扣减库存,将商品标识为待配送状态。
  • 商家的账号增加相应的商品款项。
  • 未提交事务,写入后崩溃:程序还没修改完三个数据,但数据库已经将其中一个或两个数据的变动写入磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性
  • 已提交事务,写入前崩溃:程序已经修改完三个数据,但数据库还未将全部三个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性

为了处理崩溃恢复,必须将修改数据这个操作所需的全部信息,以日志的形式记录到磁盘中。数据库会根据日志的提交记录(Commit Record)对真正的数据进行修改,完成后再在日志中加入一条“结束记录”(End Record),表示持久化完成。

Commit Record 方式存在的前提是:所有对数据的真实修改都必须发生在事务提交以后。这对提升数据库性能十分不利。

原子性和持久性

原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子的,不仅有“写入”与“未写入”状态,还客观地存在着“正在写”的中间状态。

为了解决 Commit Record 的问题,ARIES 提出了“Write-Ahead Logging”的日志改进方案,就是允许在事务提交之前,提前写入变动数据。

Write-Ahead Logging 在崩溃恢复时会执行以下三个阶段的操作:

  • 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合,这个集合至少会包括 Transaction Table 和 Dirty Page Table 两个组成部分。
  • 重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作为:找出所有包含 Commit Record 的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条 End Record,然后移除出待恢复事务集合。
  • 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为 Loser,根据 Undo Log 中的信息,将已经提前写入磁盘的信息重新改写回去,以达到回滚这些 Loser 事务的目的。

隔离性

隔离性保证了并发事务各自读、写的数据互相独立,不会彼此影响。

现代数据库均提供了以下三种锁:

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

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

  • 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子:

    SELECT * FROM books WHERE price < 100 FOR UPDATE;

  1. 可串行化

  2. 可重复读

    对事务所涉及的数据加读锁和写锁,且直到事务结束,但不再加范围锁。会产生幻读问题,即在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    -- 可重复读
    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 */

    -- 可串行化
    SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:1,事务: T1 */
    /*
    * 由于没有获得范围锁,故阻塞
    * 直到 T1 完成提交
    * 解决幻读问题
    */
    INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90) /* 时间顺序:2,事务: T2 */
    SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */
  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
    -- 读已提交
    /*
    * T1 第一次读取后,读锁就释放了
    * 缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化
    */
    SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
    UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */
    /*
    * T1 的第二次读到 110
    * 和第一次读到的数据不一致
    * 不可重复读问题
    */
    SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */

    -- 可重复读
    /*
    * T1 第一次读取后,读锁会一直持有
    */
    SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
    /*
    * 由于 T2 获取不到写锁,会阻塞
    * 直到 T1 提交
    * 解决不可重复读问
    */
    UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */
    /*
    * 正常读取
    */
    SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */
  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
    -- 读未提交
    SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
    /*
    * 注意没有COMMIT
    * T2 只添加写锁,并未禁止读操作
    */
    UPDATE books SET price = 90 WHERE id = 1; /* 时间顺序:2,事务: T2 */
    /*
    * 读到 T2 还未提交的数据 90
    * 脏读问题
    */
    SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */
    ROLLBACK; /* 时间顺序:4,事务: T2 */

    -- 读已提交
    SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
    /*
    * 注意没有COMMIT
    * T2 添加写锁和读锁
    */
    UPDATE books SET price = 90 WHERE id = 1; /* 时间顺序:2,事务: T2 */
    /*
    * 由于 id = 1 的数据需要获取读锁后才能读数据
    * 故在 T1 没有提交前,都会阻塞
    * 解决脏读的问题
    */
    SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */
    ROLLBACK; /* 时间顺序:4,事务: T2 */

结论

四种隔离级别是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。

四种隔离级别产生的问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。也就是“读 + 写”的模式。多版本并发控制 MVCC 是优化该模式的主流方案。

如果是两个事务同时修改数据,即“写+写”的情况,加锁几乎是唯一可行的解决方案,就要选择是“乐观加锁”还是“悲观加锁”。

单服务多数据源

X/Open 组织提出了一套处理事务架构 X/Open XA,它定义了全局的事务管理器和局部的资源管理器之间的通信接口。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 假设:书店的用户、商家、仓库分别处于不同的数据库中
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();
// 此时出现错误,userTransaction 和 warehouseTransaction 已经提交,catch 中 rollback 无济于事
// 导致一部分数据被提交(user、warehouse),一部分被回滚(business),一致性无法保证
businessTransaction.commit();
} catch(Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}

二阶段提交

前提
  1. 提交阶段的网络短时间内可靠。
  2. 节点失联可恢复。
步骤

准备阶段(投票阶段):协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared;

提交阶段(执行阶段):协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作;

流程图
sequenceDiagram
participant coordinators as 协调者
participant participants as 参与者
    coordinators ->> participants: 要求所有参与者进入准备阶段
    activate participants
    participants -->> coordinators: 已进入准备阶段
    deactivate participants
    
    coordinators ->> participants: 要求所有参与者进入提交阶段
    activate participants
    participants -->> coordinators: 已进入提交阶段
    deactivate participants

    opt 失败或超时
 	   coordinators ->> participants: 要求所有参与者回滚
 	   activate participants
 	   participants -->> coordinators: 已回滚事务
 	   deactivate participants
    end
问题
  1. 单点问题。协调者很重要;
  2. 性能问题:参与者进行 2 次 RPC,3 次持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record);
  3. 一致性风向:前提会导致一致性风险;

三段式提交

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

CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。增加一轮询问阶段,意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。

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

由于事务失败回滚概率变小的原因,PreCommit 阶段之后发生了协调者宕机(即参与者没有能等到 DoCommit),默认策略是提交事务相当于避免了协调者单点问题的风险。

从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。

sequenceDiagram
participant coordinators as 协调者
participant participants as 参与者
    coordinators ->> participants: 询问阶段:是否有把握完成事务
    activate participants
    participants -->> coordinators: 是
    deactivate participants
    
    coordinators ->> participants: 准备阶段:写入日志,锁定资源
    activate participants
    participants -->> coordinators: ACK
    deactivate participants
    
    coordinators ->> participants: 提交阶段:提交事务
    activate participants
    participants -->> coordinators: 已提交
    deactivate participants
    opt 失败
 	   coordinators ->> participants: 要求回滚
 	   activate participants
 	   participants -->> coordinators: 已回滚
 	   deactivate participants
    end
    
    opt 超时
 	   participants ->> participants: 提交事务
    end

多服务单数据源

flowchart LR

user[用户服务]
business[商家服务]
commodity[商品服务]
exchange(交易服务器或消息队列)
database[(数据库)]

user --> exchange
business --> exchange
commodity --> exchange

exchange --> database

一个服务集群里压力最大的往往是数据库,所以该方案往往与实际生产系统相悖。相应地,如果你有充足理由让多个微服务去共享数据库,就向团队解释为什么要服务拆分。

多服务多数据源

分布式服务环境下的事务处理机制。

CAP

分区容忍性Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。放弃分区容忍性(CA without P)意味着假设节点之间通信永远是可靠的,可靠系统的通讯在分布式系统中必定不成立。

可用性Availability):代表系统不间断地提供服务的能力,由可靠性(平均无故障时间)和可维护性(平均可修复时间)计算得出的比例值;放弃可用性(AP without C)意味着一旦网络发生分区,节点之间的信息同步时间可以无限制地延长。此时,问题相当于退化单服务多数据源的场景之中,我们可以通过 2PC/3PC 等手段,同时获得分区容忍性和一致性;

一致性Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。放弃可用性(CP without A)意味着假设一旦发生分区,节点之间所提供的数据可能不一致。==放弃一致性是目前分布式系统的主流选择==,因为分区容忍性是分布式网络的天然属性,可用性通常是建设分布式的目的。分布式系统种放弃追求强一致性,降低为最终一致性。由于一致性定义变动,ACID 的事务称为”刚性事务“,把分布式事务称为”柔性事务“。

可靠事件队列

将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。

sequenceDiagram
participant user as 外部调用
participant account as 账号服务
participant mq as 消息队列
participant warehorse as 仓库服务
participant business as 商家服务
    alt 有风险
        account ->> user: 失败
    else 评估通过
        account ->> user: 成功
    end
    
    user ->> account: 启动事务
    activate account
		account -->> account: 扣减货款,保存消息
	    account ->> mq: 提交本地事务,发出消息
    deactivate account
    loop 循环直至全部成功
        mq ->> warehorse: 扣减库存
        alt 扣减成功
            warehorse -->> mq: 成功
        else 业务或网络异常
            warehorse -->> mq: 失败
        end
    end
    mq -->> account: 更新消息表,仓库服务完成

    loop 循环直至全部成功
        mq ->> business: 货款收款
        alt 收款成功
            business -->> mq: 成功
        else 业务或网络异常
            business -->> mq: 失败
        end
    end
    mq -->> account: 更新消息表,商家服务完成

TCC 事务

TCC 事务是 Try-Confirm-Cancel 的缩写,主要用来解决分布式业务中的隔离性。在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源“(冻结)和“确认/释放消费资源”(消费)两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
  • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
  • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。

TCC 类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面。TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力,也可以根据资源锁定的粒度,带来较高灵活性。但同时带来了更高的开发成本和业务侵入性。我们通常基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,而非裸实现 TCC。

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

SAGA 事务

由于 Try 阶段的冻结操作可能不由自己控制,导致 Try 阶段无法实施。这时就需要另一种柔性事务方案:SAGA 事务,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。

  • 大事务拆分若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为;
  • 为每一个子事务设计对应的补偿动作,满足以下条件:
    1. Ti 与 Ci都具备幂等性。
    2. Ti 与 Ci 满足交换律(Commutative),即先执行 Ti 还是先执行 Ci,其效果都是一样的。
    3. Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。

所有事务提交成功,则事务完成。否则,要采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):不需要补偿,一直重试失败事务,直到成功。
  • 反向恢复(Backward Recovery):子事务执行失败,一直执行对应的补偿动作,直到成功。

SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫。我们通常基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,而非裸实现 SAGA。

总结

通常来说,脏写是一定要避免的,因为脏写情况一旦发生,人工其实也很难进行有效处理。所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写。分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。

透明多级分流系统

用户请求从浏览器触发,直至末端数据库,然后逐级返回用户浏览器之中。要历经很多部件,作为系统设计者,应该意识到不同的设施、部件在系统中不同的价值。比如:

  1. 本地缓存、CDN、反向代理等位于客户端或网络边缘,需要迅速响应用户请求,避免给后方的 I/O 与 CPU 带来压力;
  2. 能够伸缩的服务节点(后端服务),尽量作为业务逻辑的主要载体,以达到机器换并发;
  3. 注册中心、配置中心等,要时刻保持着容错备份以维护高可用;
  4. 系统入口的路由、网关、DB等单点不见,只能依靠机器本身的网络、存储和运算性能来提升处理能力;

对系统进行流量规划时,充分理解这些部件的价值差异,以及 2 条设计原则:

  1. 尽可能减少单点部件,如果某些单点是无可避免的,则应尽最大限度减少到达单点部件的流量;
  2. 更关键的是:奥卡姆剃刀原则。一方面,要对多级分流的手段有全面的理解和充分的准备,同时意识到这些设施不是越多越好;另一方面,并不是每个系统都追求三高,要依靠的是康威定律,有明确的需求采取部署,在能满足需求的前提下,最简单的系统就是最好的系统

客户端缓存

HTTP 协议的无状态性决定了它必须依靠客户端缓存来解决网络传输效率上的缺陷,手段包括:状态缓存、强制缓存和协商缓存。

强制缓存

强制缓存是基于时效性的,假设在某个时点到来以前,资源的内容和状态一定不会被改变。因此,客户端无须经过任何请求,在该时点前一直持有和使用该资源的本地缓存副本。

HTTP 协议中设有以下两类 Header 实现强制缓存。

  • Expires : HTTP/1.0
1
2
HTTP/1.1 200 OK
Expires: Wed, 8 Apr 2020 07:28:00 GMT

存在问题:

  1. 受限于客户端的本地时间;2. 无法处理涉及到用户身份的私有资源;3. 无法描述“不缓存”;
  • Cache-Control : HTTP/1.1
1
2
HTTP/1.1 200 OK
Cache-Control: max-age=600

Cache-Control 在客户端的请求 Header 或服务器的响应 Header 中都可以存在,它定义了一系列的参数,且允许自行扩展。主要参数:

参数 说明
max-age && s-maxage 相对于请求时间多少秒内缓存有效;s 是 share 的缩写,允许被 CDN、代理等持有的缓存有效时间
public && private public 可以被代理、CDN 等缓存;private 意味着只能由客户端缓存
no-cache && no-store no-cache 指明该资源不应该被缓存;no-store 不强制会话中相同 URL 资源的重复获取,但禁止浏览器、CDN 等以任何形式保存该资源
no-transform 禁止资源被任何形式地修改
min-fresh 仅用于客户端的请求 Header,建议服务器能返回一个不少于该时间的缓存资源
only-if-cached 求不必给它发送资源的具体内容,此时客户端就仅能使用事先缓存的资源来进行响应
must-revalidate 资源过期后,一定需要从服务器中进行获取
proxy-revalidate 除了用来提示代理、CDN 等对象外,语义与 must-revalidate 相同

协商缓存

协商缓存基于变化检测,在一致性上会有比强制缓存更好的表现,但需要一次变化检测的交互开销,性能上就会略差。HTTP 中协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的。

协商缓存有两种变动检查机制,分别是检查资源的修改时间资源唯一标识是否发生变化来检查。它们都是靠一组成对出现的请求、响应 Header 来实现的:

  • 修改时间的方式:Last-Modified 和 If-Modified-Since

    Last-Modified 是服务器的响应 Header,用于告诉客户端这个资源的最后修改时间。对于带有这个 Header 的资源,当客户端需要再次请求时,会通过 If-Modified-Since 把之前收到的资源最后修改时间发送回服务端。如果此时服务端发现资源在该时间后没有被修改过,就只要返回一个没有消息体的 304/Not Modified 的响应即可

    1
    2
    3
    HTTP/1.1 304 Not Modified
    Cache-Control: public, max-age=600
    Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT

    如果时间有变动,则返回带有 Last-Modified 的 Header,200 的完整响应

    1
    2
    3
    4
    5
    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,用于告诉客户端这个资源的唯一标识。当客户端需要再次请求时,会通过 If-None-Match 把之前收到的资源唯一标识发送回服务端。如果此时服务端计算后发现资源的唯一标识与上传回来的一致,说明资源没有被修改过,就只要返回一个没有消息体的 304/Not Modified 的响应即可

    1
    2
    3
    HTTP/1.1 304 Not Modified
    Cache-Control: public, max-age=600
    ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"

    如果资源有变动,则返回带有 Last-Modified 的 Header,200 的完整响应

    1
    2
    3
    4
    5
    HTTP/1.1 200 OK
    Cache-Control: public, max-age=600
    ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"

    Content

总结

强制缓存中,如果 Cache-Control 和 Expires 同时存在,并且语义存在冲突,必须以 Cache-Control 为准。

协商缓存中,Etag 是 HTTP 中一致性最强的缓存机制,却是最差的缓存机制。但 Etag 和 Last-Modified 是允许一起使用的,服务器会优先验证 Etag,在 Etag 一致的情况下,再去对比 Last-Modified。

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

更多信息:HTTP 权威指南

域名解析 DNS

DNS 就是将域名翻译为域名地址记录(通常是 ip)的系统。域名解析对于大多数信息系统,尤其是对于基于互联网的系统来说是必不可少的组件,却没有太高存在感。不过 DNS 本身的工作过程,本身堪称是示范性的透明多级分流系统。

步骤

www.baidu.com 为例,有如下步骤:

  1. DNS 会将域名还原为 www.baidu.com.;
  2. 客户端先检查本地的 DNS 缓存;
  3. 客户端将地址发送给本机操作系统中配置的本地 DNS;
  4. 本地 DNS 收到查询请求后,会按照 a. 是否有www.baidu.com的权威服务器; b. 是否有baidu.com的权威服务器; c. 是否有com的权威服务器的顺序,依次查询自己的地址记录,如果都没有查询到,就会一直找到最后点号代表的根域名服务器;
  5. 根据名服务器返回 com的地址记录,通过 com 的权威服务器,得到 baidu.com的地址记录,以此反推,得到 www.baidu.com的权威服务器地址;
  6. 通过 www.baidu.com的权威服务器地址,得到地址记录;
sequenceDiagram
participant browser as 浏览器
participant local as 本地DNS
participant authority as 权威DNS
participant server as 网站服务器
    browser ->> local: 查询网站icyfenix.cn
    loop 递归查询
        local ->> authority: 查询网站icyfenix.cn
    end
    authority -->> local: 地址:xxx.xxx.xxx.xxx
    local -->> browser: 地址:xxx.xxx.xxx.xxx
    browser ->> server: 请求
    server -->> browser: 响应

问题

  1. 响应速度

    极端情况下,域名解析都需要递归到根域名服务器才能查询到结果,响应速度变慢;

  2. 中间人攻击

    这是更严重的问题。攻陷根域名或者权威域名是非常困难的,但很多递归底层和本地运营商的 DNS 服务器防护相对松懈,甚至会主动进行劫持(加入广告进行牟利)。一种解决办法是:HTTPDNS,通俗的讲:就是讲 DNS 查询转换为 HTTP 请求来查询地址记录。

传输链路

经过客户端缓存的节流和 DNS 服务的指引,程序发出的请求正式离开客户端,踏上以服务器为目的地的旅途。这就是:传输链路。

优化链路传输的前端设计原则:雅虎 YSlow-23 条规则

连接数优化

HTTP/3 以前TCP 为传输层的应用层协议,但 HTTP over TCP 这种搭配只能说是 TCP 在当今网络中统治性地位所造就的结果,而不能说它们两者配合工作就是合适的。

HTTP 的设计者们也尝试在协议层面去解决连接成本过高的问题,比如 HTTP/1.1 中的连接复用技术和 HTTP/2 中的多路复用技术,但都不完美。

传输压缩

静态预压缩(由于 Web 性能提升,已很少采用):静态资源预先以 .gz 文件形式存放起来,需要哪个返回哪个;

即时压缩:整个压缩过程全部在内存的数据流中完成,不必等资源压缩完成再返回响应。缺点是没法在 Header 中添加 Content-Length,因为输出 Header 服务器无法知道压缩后资源的确切大小。为保证持久连接,最初使用 Content-Length 来判断资源是否请求结束,但即时压缩与最初设计冲突。HTTP/1.1 版本中修复了这个缺陷,增加了另一种“分块传输编码”(Chunked Transfer Encoding)的资源结束判断机制。

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
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

"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

到了 HTTP/2,由于多路复用和单域名单连接的设计,已经无须再刻意去提持久链接机制了,但数据压缩仍然有节约传输带宽的重要价值。

快速 UDP 网络连接

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

快速 UDP 网络连接(Quick UDP Internet Connections,QUIC)是由 Google 公司推动,以 UDP 协议为基础,不仅能满足 HTTP 传输协议,日后还能支持 SMTP、DNS、SSH、Telnet、NTP 等多种其他上层协议的最新一代互联网标准,即 HTTP over QUIC 的 HTTP/3 版本号。

一方面,可靠传输能力完全是 QUIC 实现。另一方面,面向移动设备的专门支持。

总结

一旦在技术根基上出现问题,依靠使用者通过 Tricks 去解决,无论如何都难以摆脱“两害相权取其轻”的权衡困境,否则这就不是 Tricks 而是会成为一种标准的设计模式了。

内容分发网络

如果抛弃其他影响服务质量的因素,仅从网络传输的角度看,一个互联网系统的速度取决于以下四点因素:

  1. 网站服务器接入网络运营商的链路的出口带宽;
  2. 用户客户端接入网络运营商的链路的入口带宽;
  3. 不同运营商之间互联节点的带宽。一般不同运营商只有若干个节点是互通的;
  4. 从网站到用户之间的物理链路传输时延;

以上四个网络问题,除了第二个只能通过换一个更好的宽带才能解决之外,其余三个都能通过内容分发网络来显著改善。一个运作良好的内容分发网络,能为互联网系统解决跨运营商、跨地域物理距离所导致的时延问题,能为网站流量带宽起到分流、减负的作用。内容分发网络的工作过程,主要涉及域名解析、内容分发、负载均衡和所能支持的 CDN 应用内容四个方面。

域名解析

  1. 架设好“icyfenix.cn”的服务器后,会将服务器 IP 地址在 CDN 服务商上注册为“源站”,CDN 服务商会生成一个“源站”的 CNAME 记录;
  2. 将 CNAME 记录注册到域名权威 DNS 和 CNAME 的权威 DNS 上;
  3. 域名权威DNS 发现是 CNAME 记录后,返回给本地 DNS,之后链路解析的主导权就开始由内容分发网络的调度服务接管了;
  4. CNAME 的权威 DNS 根据负载均衡策略和参数,挑选一个最合适的 CDN 节点,将其 IP 进行返回;
  5. 访问 IP 即可;
sequenceDiagram
participant browser as 浏览器
participant local as 本地DNS
participant authority as 域名权威DNS
participant cname as CNAME的权威DNS
participant cdn as CDN服务器
participant server as 源站服务器
    browser ->> local: 查询网站icyfenix.cn
    loop 递归查询
        local ->> authority: 查询网站icyfenix.cn
    end
    authority -->> local: 返回 CNAME 记录
    local -->> cname: 查询 CNAME 记录
    loop 递归查询
        cname ->> cname: 经过递归查询和负载均衡,确定合适的CDN
    end
    cname -->> local: 地址:xxx.xxx.xxx.xxx
    local -->> browser: 地址:xxx.xxx.xxx.xxx
    browser ->> cdn: 请求
    cdn ->> server: 请求
    server -->> cdn: 响应
    cdn -->> browser: 响应

内容分发

内容分发就是 CDN 获取源站资源的过程,包括“如何获取源站资源”和“如何更新资源” 2 方面。

如何获取源站资源:

  1. 主动分发 PUSH

    由源站主动将资源推送到用户边缘的各个 CDN 缓存节点上。正因如此,它对源站并不是透明的,只对用户一侧单向透明。主动分发一般用于网站要预载大量资源的场景。

  2. 被动回源 PULL

    由用户访问所触发全自动、双向透明的资源缓存过程。

如何更新资源:

没有统一标准,取决于 CDN 服务商的实现策略。由于大多数网站的开发和运维人员并不十分了解 HTTP 缓存机制,导致如果 CDN 服务商完全照着 HTTP Headers 来控制缓存失效和更新,效果反而会相当的差,还可能引发其他问题。

现在,最常见的做法是超时被动失效与手工主动失效相结合。超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。而手工失效是指 CDN 服务商一般会提供给程序调用来失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新。

负载均衡

调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”。真正大型系统的负载均衡往往是多级的。

  • 四层负载均衡的优势是性能高,七层负载均衡的优势是功能强;
  • 做多级混合负载均衡,通常应是低层的负载均衡在前,高层的负载均衡在后;
  1. 数据链路层负载均衡

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

    image-20230621185013674

    数据链路层负载均衡器直接改写目标 MAC 地址的工作原理决定了它与真实的服务器的通信必须是二层可达的。通俗地说就是必须位于同一个子网当中,无法跨 VLAN。优势(效率高)和劣势(不能跨子网)共同决定了数据链路层负载均衡最适合用来做数据中心的第一级均衡设备,用来连接其他的下级负载均衡器。

  2. 网络层负载均衡

    与链路层类似,网络层通过改变 IP 地址来实现数据包转发。

    IP 隧道:新创建一个数据包,把原来数据包的 Headers 中目标 IP 修改后和 Payload 整体作为另一个新的数据包的 Payload 发送出去。一个缺点是服务器必须支持 IP 隧道协议,另一个是必须专门配置。

    image-20230621185454338

    网络地址转换:直接修改源数据包的目标 IP 后发出去。

    image-20230621185649892

    在流量压力比较大的时候,NAT 模式的负载均衡会带来较大的性能损失,比起直接路由和 IP 隧道模式,甚至会出现数量级上的下降。因为所有流量都在争抢均衡器出口带宽。

  3. 应用层负载均衡

    链路层和网络层的负载均衡模式都属于转发,此时客户端到响应请求维持同一条 TCP 通道,但工作在应用层的负载均衡只能进行代理,此时真实服务器、负载均衡器、客户端三者之间由两条独立的 TCP 通道来维持通信。

    image-20230622171828070

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

  4. 总结

    负载均衡的两大职责是“选择谁来处理用户请求”(均衡策略:轮询、权重轮询、随机、权重随机、一致性哈希、响应速度、最少连接数等)和“将用户请求转发过去”(转发和代理)。

    负载均衡器从实现上又分为软件和硬件。硬件会直接采用应用专用集成电路 ASIC 来实现,如 F5 和 A10。软件分为 OS 层,如 LVS 和应用层,如 Nginx、HAProxy、KeepAlived。

CDN 应用

  • 加速静态资源:本职工作;
  • 安全防御:CDN 可以视为网站的堡垒机;
  • 协议提升;
  • 状态缓存;
  • 修改资源;
  • 访问控制;
  • 注入功能;
  • 等等

服务端缓存

为系统引入缓存之前,第一件事情是确认你的系统是否真的需要缓存。软件开发中引入缓存的负面作用要明显大于硬件的缓存:1. 开发角度,引入缓存会提高系统复杂度(缓存失效、更新、一致性等问题);2. 运维角度,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;3. 安全角度,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。

冒着上述种种风险,仍能说服你引入缓存的理由,总结起来无外乎以下两种:

  1. 为缓解 CPU 压力而做缓存,顺带提升响应性能。譬如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用;
  2. 为缓解 I/O 压力而做缓存,顺带提升响应性能。譬如把原本对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问;

出发点是缓解压力,顺带提升性能。如果能通过增强 CPU、I/O 本身性能来满足需求,升级硬件往往是更好的解决方案。

缓存属性

  1. 吞吐量:缓存的吞吐量使用 OPS 值,反映了缓存对并发读写操作的效率

    image-20230625115703078

  2. 命中率与淘汰策略

    FIFO、LRU、LFU、TinyLFU等等

  3. 扩展功能:缓存提供的额外功能,如最大容量、失效时间、失效事件、命中率统计等等

    加载器、淘汰策略、失效策略、事件通知、并发级别、容量控制、引用方式、统计信息、持久化等。

  4. 分布式支持

    • 访问角度:理论上,更新少、读取多更适合复制式缓存;更新多、读取多更适合集中式缓存。

缓存风险

  1. 缓存穿透

    缓存每次都未命中,触及到末端数据库的现象。

    措施:

    • 可以约定在一定时间内对返回为空的 Key 值依然进行缓存,使得在一段时间内缓存最多被穿透一次;
    • 恶意攻击导致缓存穿透,通常会在缓存之前设置一个布隆过滤器;
  2. 缓存击穿

    缓存中某些热点数据突然失效,此时又有多条针对该数据的请求,导致请求穿透缓存,触及到末端数据库的现象。

    措施:

    • 加锁同步,以请求该数据的 Key 值为锁,使得只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略;
    • 热点数据由代码来手动管理;
  3. 缓存雪崩

    缓存击穿是针对单个热点数据失效,而大批不同的数据在短时间内一起失效,导致的缓存击穿的现象。

    措施:

    • 提升缓存系统可用性,建设分布式缓存的集群;
    • 启用透明多级缓存,各个服务节点一级缓存中的数据通常会具有不一样的加载时间;
    • 将缓存的生存期从固定时间改为一个时间段内的随机时间;
  4. 缓存污染

    缓存中的数据和数据源中不一样的现象。

多级缓存

image-20230625120731535

使用进程内缓存做一级缓存,分布式缓存做二级缓存,如果能在一级缓存中查询到结果就直接返回,否则便到二级缓存中去查询,再将二级缓存中的结果回填到一级缓存,以后再访问该数据就没有网络请求了。如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。

当数据发生变动时,在集群内发送推送通知(简单点的话可采用 Redis 的 PUB/SUB,求严谨的话引入 ZooKeeper 或 Etcd 来处理),让各个节点的一级缓存自动失效掉相应数据。当访问缓存时,提供统一封装好的一、二级缓存联合查询接口,接口外部是只查询一次,接口内部自动实现优先查询一级缓存,未获取到数据再自动查询二级缓存的逻辑。

架构安全

认证、授权和凭证是一个系统中最基础的安全设计,哪怕再简陋的信息系统,大概也不可能忽略掉“用户登录”功能。

认证

系统如何正确分辨出操作用户的真实身份?

Spring Security 和 Apache Shiro

授权

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

RBAC

某用户隶属于什么角色拥有哪些许可可以操作多少资源。

OAuth2

面向与解决第三方用户的认证授权问题。

凭证

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

状态信息存储于服务端,在”安全性“有先天优势,但只是在单节点中是合适的。分布式环境中,由于 CAP 的缘故,状态管理受到局限。

JWT

JWT 令牌是多方系统中一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现。同时,由于 JWT 本身可以携带少量信息,能够较容易地做成无状态服务,在做水平扩展时就不需要像前面 Cookie-Session 方案那样考虑如何部署的问题。

保密

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

保密的强度

更高安全强度意味着更多代价。

客户端加密

这里的意思是客户端是否要对密码进行加密后,传输到服务端。

为了保证信息不被黑客窃取而做客户端加密没有太多意义,对绝大多数的信息系统来说,启用 HTTPS 可以说是唯一的实际可行的方案。

密码存储和验证

只要配合一定的密码规则约束,譬如密码要求长度、特殊字符等,再配合 HTTPS 传输,已足防御大多数风险了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
* greater hash combined with an 8-byte or greater randomly generated salt.
*/
public String encrypt(CharSequence rawPassword) {
return new BCryptPasswordEncoder().encode(Optional.ofNullable(rawPassword).orElse(""));
}

/**
* Verify the encoded password obtained from storage matches the submitted raw
* password after it too is encoded. Returns true if the passwords match, false if
* they do not. The stored password itself is never decoded.
*
* @param rawPassword the raw password to encode and match
* @param encodedPassword the encoded password from storage to compare with
* @return true if the raw password, after encoding, matches the encoded password from
* storage
*/
public static boolean matches(CharSequence rawPassword, String encodedPassword) {
return new BCryptPasswordEncoder().matches(rawPassword, encodedPassword);
}

传输

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

摘要、加密与签名

摘要也称之为数字摘要(Digital Digest)或数字指纹(Digital Fingerprint),意义是在源信息不泄漏的前提下辨别其真伪,易变性保证了从公开的特征上可以甄别出是否来自于源信息,不可逆性保证了从公开的特征并不会暴露出源信息。

加密与摘要的本质区别在于加密是可逆的,逆过程就是解密。根据加密与解密是否采用同一个密钥,现代密码学算法可分为对称加密算法和非对称加密。对称加密的问题在于:1. 当通信的成员数量增加时,面临密钥管理的难题;2. 密钥如何安全传输。非对称加密将密钥分成公钥和私钥,公钥可以完全公开,无须安全传输的保证。私钥由用户自行保管,不参与任何通信传输。根据这两个密钥加解密方式的不同,使得算法可以提供两种不同的功能:称为加密的公钥加密,私钥解密称为签名的私钥加密,公钥解密

类型 特点 常见实现 主要用途 主要局限
哈希摘要 易变和不可逆 MD2/4/5/6、SHA0/1/256/512 摘要 无法解密
对称加密 密钥相同速度快、设计难度小、明文长度不受限 DES、AES、RC4、IDEA 加密 要解决如何把密钥安全地传递给解密者
非对称加密 加解密密钥不同
明文长度不能超过公钥长度。
RSA、BCDSA、ElGamal 签名、传递密钥 性能与加密明文长度受限

数字证书

当我们无法以“签名”的手段来达成信任时,就只能求助于其他途径。1. 基于共同私密信息的信任;2. 基于权威公证人的信任。我们并不能假设授权服务器和资源服务器是互相认识的,所以通常不太会采用第一种方式,而第二种就是目前标准的保证公钥可信分发的标准,而数字证书认证中心(Certificate Authority,CA)就是权威公证人。

传输安全层

以 TLS 1.2 为例。

  1. 客户端请求:Client Hello。客户端向服务器请求进行加密通信,在这个请求里面,它会以明文的形式,向服务端提供以下信息;
  2. 服务器回应:Server Hello。服务器接收到客户端的通信请求后,如果客户端声明支持的协议版本和加密算法组合与服务端相匹配的话,就向客户端发出回应。如果不匹配,将会返回一个握手失败的警告提示;
  3. 客户端确认:Client Handshake Finished。客户端收到服务器应答后,先要验证服务器的证书合法性。如果证书没有问题,客户端就会从证书中取出服务器的公钥,并向服务器发送以下信息若干信息;
  4. 服务端确认:Server Handshake Finished。服务端向客户端回应最后的确认通知,包括以下信息;

至此,整个 TLS 握手阶段宣告完成,一个安全的连接就已成功建立。每一个连接建立时,客户端和服务端均通过上面的握手过程协商出了许多信息,此后该连接的通信将使用此密钥和加密算法进行加密、解密和压缩。这种处理方式对上层协议的功能上完全透明的,在传输性能上会有下降,建立在这层安全传输层之上的 HTTP 协议,就被称为“HTTP over SSL/TLS”,也即是大家所熟知的 HTTPS。

验证

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