凤凰架构学习

周志明老师的几本业内享誉的书,包括凤凰架构:构建可靠的大型分布式系统 | 凤凰架构,深入理解Java虚拟机,智慧的疆界等。都是很好的学习资料,这里对凤凰架构做一些简单读后感

早期的分布式探索

可能与绝大多数人心中的认知会有差异,“使用多个独立的分布式服务共同构建一个更大型系统”的设想与实际尝试,反而要比今天大家所了解的大型单体系统出现的时间更早。

由于早期单个计算机硬件处理能力优先,不同机构开始寻找使用多台计算机共同协作来支撑同一套软件系统运行的可行方案

其中最为重要的是OSF组织制定的DCE标准(分布式运算环境),DCE 包含一套相对完整的分布式服务组件规范与参考实现,譬如源自 NCA 的远程服务调用规范(Remote Procedure Call,RPC),而NCA是早期惠普公司提出的网络运算架构。 还有基于通用 TCP/IP 协议的远程服务标准ONC RPC被认为是现代 RPC 的共同鼻祖;源自 AFS 的分布式文件系统(Distributed File System,DFS)规范,当时被称为DCE/DFS;源自 Kerberos 的服务认证规范;还有时间服务、命名与目录服务,就连现在程序中很常用的通用唯一识别符 UUID 也是在 DCE 中发明出来的。

image-20260110120637940

这次尝试最大的收获就是对 RPC、DFS 等概念的开创,以及得到了一个价值千金的教训:某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自寻苦果。

由于当时微型计算机的性能以每两年即增长一倍的惊人速度提升,信息系统进入了以单台或少量几台计算机即可作为服务器来支撑大型信息系统运作的单体时代,且在很长的一段时间内,单体都将是软件架构的绝对主流。

单体系统

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

单体系统的不足,必须基于软件的性能需求超过了单机,软件的开发人员规模明显超过了“2 Pizza Team”范畴的前提下才有讨论的价值

在“拆分”这方面,单体系统的真正缺陷不在如何拆分,而在拆分之后的隔离与自治能力上的欠缺。由于所有代码都运行在同一个进程空间之内,所有模块、方法的调用都无须考虑网络分区、对象复制这些麻烦的事和性能损失。获得了进程内调用的简单、高效等好处的同时,也意味着如果任何一部分代码出现了缺陷,过度消耗了进程空间内的资源,所造成的影响也是全局性的、难以隔离的。譬如内存泄漏、线程爆炸、阻塞、死循环等问题,都将会影响整个程序,而不仅仅是影响某一个功能、模块本身的正常运作。如果消耗的是某些更高层次的公共资源,譬如端口号或者数据库连接池泄漏,影响还将会波及整台机器,甚至是集群中其他单体副本的正常工作。

面向服务架构SOA

SOA 的核心理念是将大型的业务系统拆解成一个个独立的、可复用的“服务”,这些服务就像积木一样,可以通过标准协议组合成更复杂的业务流程。

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

为了对大型的单体系统进行拆分,让每一个子系统都能独立地部署、运行、更新,开发者们曾经尝试过多种方案,这里列举以下三种较有代表性的架构模式,具体如下。

烟囱式架构

信息烟囱又名信息孤岛(Information Island),使用这种架构的系统也被称为孤岛式信息系统或者烟囱式信息系统。它指的是一种完全不与其他相关信息系统进行互操作或者协调工作的设计模式。这样的系统其实并没有什么“架构设计”可言。两个不发生交互的信息系统,让它们使用独立的数据库和服务器即可实现拆分,而唯一的问题,也是致命的问题是,对于两个信息系统来说,哪怕真的毫无业务往来关系,但系统的人员、组织、权限等主数据,会是完全独立、没有任何重叠的吗?这样“独立拆分”“老死不相往来”的系统,显然不可能是企业所希望见到的。

微内核架构

微内核架构也被称为插件式架构(Plug-in Architecture)。既然在烟囱式架构中,没有业务往来关系的系统也可能需要共享人员、组织、权限等一些的公共的主数据,那不妨就将这些主数据,连同其他可能被各子系统使用到的公共服务、数据、资源集中到一块,成为一个被所有业务系统共同依赖的核心(Kernel,也称为 Core System),具体的业务系统以插件模块(Plug-in Modules)的形式存在,这样也可提供可扩展的、灵活的、天然隔离的功能特性,即微内核架构

这种模式很适合桌面应用程序,也经常在 Web 应用程序中使用。任何计算机系统都是由各种软件互相配合工作来实现具体功能的,本节列举的不同架构实现的软件,都可视作整个系统的某种插件。对于平台型应用来说,如果我们希望将新特性或者新功能及时加入系统,微内核架构会是一种不错的方案。微内核架构也可以嵌入到其他的架构模式之中,通过插件的方式来提供新功能的定制开发能力,如果你准备实现一个能够支持二次开发的软件系统,微内核也会是一种良好的选择。
不过,微内核架构也有它的局限和使用前提,它假设系统中各个插件模块之间是互不认识,不可预知系统将安装哪些模块,因此这些插件可以访问内核中一些公共的资源,但不会直接交互。可是,无论是企业信息系统还是互联网应用,这一前提假设在许多场景中都并不成立,我们必须找到办法,既能拆分出独立的系统,也能让拆分后的子系统之间顺畅地互相调用通信。

事件驱动架构

为了能让子系统互相通信,一种可行的方案是在子系统之间建立一套事件队列管道(Event Queues),来自系统外部的消息将以事件的形式发送至管道中,各个子系统从管道里获取自己感兴趣、能够处理的事件消息,也可以为事件新增或者修改其中的附加信息,甚至可以自己发布一些新的事件到管道队列中去,如此,每一个消息的处理者都是独立的,高度解耦的,但又能与其他处理者(如果存在该消息处理者的话)通过事件管道进行互动

image-20260117161811392

微服务时代

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

SOA 旨在实现企业内部异构系统的“集成”与“重用”,而微服务旨在实现单个复杂应用的“解耦”与“敏捷”。

特性SOA (面向服务架构)微服务 (Microservices)
颗粒度粗粒度:如“财务系统”、“库存管理”。细粒度:如“库存查询”、“订单支付”。
通信机制重量级总线:依赖 ESB(企业服务总线)进行路由和协议转换。轻量级通信:使用 REST API、gRPC 或消息队列。
数据管理共享数据库:多个服务通常共用一个中心数据库。独立数据库:每个微服务拥有自己的数据库,实现数据隔离。
治理方式集中式治理:统一的标准、统一的技术栈。去中心化治理:团队可根据需要选择不同的技术栈(Polyglot)。
部署方式通常是整体部署或大块部署,变更影响范围大。独立部署:每个微服务都可以独立于其他服务进行更新。
架构重心重用性:如何让旧系统重新发挥价值。解耦性:如何让系统更易于扩展和快速迭代。

后微服务时代

从软件层面独力应对微服务架构问题,发展到软、硬一体,合力应对架构问题的时代,此即为“后微服务时代”。

分布式架构中出现的问题,如注册发现、跟踪治理、负载均衡、传输通信等,其实在 SOA 时代甚至可以说从原始分布式时代起就已经存在了,只要是分布式架构的系统,就无法完全避免,但我们不妨换个思路来想一下,这些问题一定要由软件系统自己来解决吗?

如果不局限于采用软件的方式,这些问题几乎都有对应的硬件解决方案。譬如,某个系统需要伸缩扩容,通常会购买新的服务器,部署若干副本实例来分担压力;如果某个系统需要解决负载均衡问题,通常会布置负载均衡器,选择恰当的均衡算法来分流;如果需要解决传输安全问题,通常会布置 TLS 传输链路,配置好 CA 证书以保证通信不被窃听篡改;如果需要解决服务发现问题,通常会设置 DNS 服务器,让服务访问依赖稳定的记录名而不是易变的 IP 地址,等等。经过计算机科学多年的发展,这些问题大多有了专职化的基础设施去解决,而之所以微服务时代,人们选择在软件的代码层面而不是硬件的基础设施层面去解决这些分布式问题,很大程度上是因为由硬件构成的基础设施,跟不上由软件构成的应用服务的灵活性的无奈之举。软件可以只使用键盘命令就能拆分出不同的服务,只通过拷贝、启动就能够伸缩扩容服务,硬件难道就不可以通过敲键盘就变出相应的应用服务器、负载均衡器、DNS 服务器、网络链路这些设施吗?

微服务时代所取得的成就,本身就离不开以 Docker 为代表的早期容器化技术的巨大贡献。在此之前,笔者从来没有提起过“容器”二字,这并不是刻意冷落,而是早期的容器只被简单地视为一种可快速启动的服务运行环境,目的是方便程序的分发部署,这个阶段针对单个应用进行封装的容器并未真正参与到分布式问题的解决之中。

被业界广泛认可、普遍采用的通过虚拟化基础设施去解决分布式架构问题的开端,应该要从 2017 年 Kubernetes 赢得容器战争的胜利开始算起。

KubernetesSpring Cloud
弹性伸缩AutoscalingN/A
服务发现KubeDNS / CoreDNSSpring Cloud Eureka
配置中心ConfigMap / SecretSpring Cloud Config
服务网关Ingress ControllerSpring Cloud Zuul
负载均衡Load BalancerSpring Cloud Ribbon
服务安全RBAC APISpring Cloud Security
跟踪监控Metrics API / DashboardSpring Cloud Turbine
降级熔断N/ASpring Cloud Hystrix

当虚拟化的基础设施从单个服务的容器扩展至由多个容器构成的服务集群、通信网络和存储设施时,软件与硬件的界限便已经模糊。一旦虚拟化的硬件能够跟上软件的灵活性,那些与业务无关的技术性问题便有可能从软件层面剥离,悄无声息地解决于硬件基础设施之内,让软件得以只专注业务,真正“围绕业务能力构建”团队与产品。

服务网格

Kubernetes 成为容器战争胜利者标志着后微服务时代的开端,但 Kubernetes 仍然没有能够完美解决全部的分布式问题。 有一些问题处于应用系统与基础设施的边缘,使得完全在基础设施层面中确实很难精细化地处理。

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

为了解决这一类问题,虚拟化的基础设施很快完成了第二次进化,引入了今天被称为“服务网格”(Service Mesh)的“边车代理模式”(Sidecar Proxy)

这个场景里指的具体含义是由系统自动在服务容器(通常是指 Kubernetes 的 Pod)中注入一个通信代理服务器,以类似网络安全里中间人攻击的方式进行流量劫持,在应用毫无感知的情况下,悄然接管应用所有对外通信。这个代理除了实现正常的服务间通信外(称为数据平面通信),还接收来自控制器的指令(称为控制平面通信)根据控制平面中的配置,对数据平面通信的内容进行分析处理,以实现熔断、认证、度量、监控、负载均衡等各种附加功能。这样便实现了既不需要在应用层面加入额外的处理代码,也提供了几乎不亚于程序代码的精细管理能力。

服务网格将会成为微服务之间通信交互的主流模式,把“选择什么通信协议”、“怎样调度流量”、“如何认证授权”之类的技术问题隔离于程序代码之外,取代今天 Spring Cloud 全家桶中大部分组件的功能,微服务只需要考虑业务本身的逻辑

image-20260117175734501

无服务时代Serverless

如果说微服务架构是分布式系统这条路的极致,那无服务架构,也许就是“不分布式”的云端系统这条路的起点。

无服务(Serverless Computing)并不是指真的不需要服务器,而是指开发者不再需要关心服务器的运维和管理。在无服务架构中,云服务商(如 AWS、阿里云、腾讯云)负责处理所有的底层资源管理,包括服务器的采购、配置、维护、更新以及根据流量进行的自动扩缩容。开发者只需要编写业务逻辑代码并上传即可

无服务现在还没有一个特别权威的“官方”定义,但它的概念并没有前面各种架构那么复杂,本来无服务也是以“简单”为主要卖点的,它只涉及两块内容:后端设施(Backend)和函数(Function)。

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

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

远程服务调用

它允许程序调用位于另一台计算机(或另一进程)上的子程序,而程序员不需要显式地编写网络通信相关的代码

RPC 的工作原理

为了实现“透明”的远程调用,RPC 引入了几个核心组件。

  1. 客户端(Client): 服务调用方。
  2. 客户端存根(Client Stub): 存放服务端的地址信息,负责将函数调用打包成网络消息(序列化)。
  3. 网络传输(Transport): 负责将消息从客户端发送到服务端。
  4. 服务端存根(Server Stub/Skeleton): 接收网络消息,将其解包(反序列化)并调用本地的实际函数。
  5. 服务端(Server): 服务提供方,执行真正的业务逻辑。

RPC 的核心步骤

一个完整的 RPC 调用过程通常包含以下五个阶段:

  1. 调用: 客户端程序像调用本地方法一样调用 Client Stub。
  2. 序列化(Marshaling): Client Stub 将参数转换成字节流,以便在网络上传输。
  3. 传输: 消息通过 TCP/UDP 等协议发送到远程服务器。
  4. 反序列化(Unmarshaling): Server Stub 接收到消息,将字节流还原为函数参数。
  5. 返回: 服务端处理完逻辑后,将结果通过同样的步骤返回给客户端。

在现代分布式架构(尤其是微服务)中,开发者通常使用现成的 RPC 框架:

  • gRPC: Google 开发的高性能框架,使用 Protocol Buffers 作为序列化协议,基于 HTTP/2。
  • Dubbo: 阿里巴巴开源的高性能 Java RPC 框架,在国内企业中应用广泛。
  • Thrift: Facebook 开发的跨语言 RPC 框架。
  • Spring Cloud OpenFeign: 虽然底层通常走 HTTP,但在 Spring 生态中实现了类似 RPC 的开发体验。

进程间通信

调用一个本地方法时,进程,确切的说是进程中的一个线程,会在自己的地址空间创建栈帧。

在完全不考虑编译器优化的前提下,程序运行至调用println()方法输出hello world这行时,计算机(物理机或者虚拟机)要完成以下几项工作。

  1. 传递方法参数:将字符串helloworld的引用地址压栈。
  2. 确定方法版本:根据println()方法的签名,确定其执行版本。这其实并不是一个简单的过程,不论是编译时静态解析也好,是运行时动态分派也好,总之必须根据某些语言规范中明确定义原则,找到明确的Callee,“明确”是指唯一的一个Callee,或者有严格优先级的多个Callee,譬如不同的重载版本。笔者曾在《深入理解 Java 虚拟机》中用一整章篇幅介绍该过程,有兴趣的读者可以参考,这里就不赘述了。
  3. 执行被调方法:从栈中弹出Parameter的值或引用,以此为输入,执行Callee内部的逻辑;这里我们只关心方法如何调用的,不关心方法内部具体是如何执行的。
  4. 返回执行结果:将Callee的执行结果压栈,并将程序的指令流恢复到Call Site的下一条指令,继续向下执行。

如果caller和callee不在同一进程,此时至少面临两个直接的障碍:

首先,第一步和第四步所做的传递参数、传回结果都依赖于栈内存的帮助,如果CallerCallee分属不同的进程,就不会拥有相同的栈内存,将参数在Caller进程的内存中压栈,对于 Callee 进程的执行毫无意义。

其次,第二步的方法版本选择依赖于语言规则的定义,如果CallerCallee不是同一种语言实现的程序,方法版本选择就将是一项模糊的不可知行为。

Caller (调用者): 发起函数调用的代码块。它负责准备执行环境,将必要的参数传递给目标函数,并等待(或异步接收)执行结果。

Callee (被调用者): 被执行的函数或过程。它接收来自调用者的参数,执行其内部定义的逻辑,并在完成后将控制权(以及可能的返回值)交还给调用者。

假设CallerCallee是使用同一种语言实现的,先来解决两个进程之间如何交换数据的问题,这件事情在计算机科学中被称为“进程间通信”(Inter-Process Communication,IPC)。可以考虑的办法有管道,信号,消息队列,共享内存以及socket网络编程。

管道是内核维护的一块缓冲区,用于在进程之间进行“单向字节流”通信。匿名管道能用于 有亲缘关系的进程,如父子进程。例如fork后父子进程通过pipe(fd);通信,以及shell中的|. 命名管道有文件路径可用于 无亲缘关系的进程,持久存在于文件系统,通过mkfifo创建文件

管道是最简单的 IPC,本质是内核中的 FIFO 字节流缓冲区; 匿名管道用于亲缘进程,命名管道用于任意进程; 单向、阻塞、容量有限,正确 close 是使用成败的关键。

信号(Signal):信号用于通知目标进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程自身。信号的典型应用是kill命令:

消息队列内核中的一个链表(或优先队列),进程可以按“消息”为单位发送和接收数据,而不是字节流。消息队列克服了信号承载信息量少,管道只能用于无格式字节流以及缓冲区大小受限等缺点,但实时性相对受限。

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

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

Beyond IPC 通信的成本

在跨机器的通信上有三个关键问题

如何表示数据:这里数据包括了传递给方法的参数,以及方法执行后的返回值。无论是将参数传递给另外一个进程,还是从另外一个进程中取回执行结果,都涉及到它们应该如何表示。进程内的方法调用,使用程序语言预置的和程序员自定义的数据类型,就很容易解决数据表示问题远程方法调用则完全可能面临交互双方各自使用不同程序语言的情况;即使只支持一种程序语言的 RPC 协议,在不同硬件指令集、不同操作系统下,同样的数据类型也完全可能有不一样表现细节,譬如数据宽度、字节序的差异等等。有效的做法是将交互双方所涉及的数据转换为某种事先约定好的中立数据流格式来进行传输,将数据流转换回不同语言中对应的数据类型来进行使用,这个过程说起来拗口,但相信大家一定很熟悉,就是序列化与反序列化。每种 RPC 协议都应该要有对应的序列化协议

如何传递数据:准确地说,是指如何通过网络,在两个服务的 Endpoint 之间相互操作、交换数据。这里“交换数据”通常指的是应用层协议,实际传输一般是基于标准的 TCP、UDP 等标准的传输层协议来完成的。两个服务交互不是只扔个序列化数据流来表示参数和结果就行的,许多在此之外信息,譬如异常、超时、安全、认证、授权、事务,等等,都可能产生双方需要交换信息的需求。

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

由于一直没有一个同时满足以上三点的“完美 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 框架,都不再去追求大而全的“完美”,而是有自己的针对性特点作为主要的发展方向

REST与RPC

REST 与 RPC 在思想上差异的核心是抽象的目标不一样,即面向资源的编程思想面向过程的编程思想两者之间的区别。面向过程编程、面向对象编程大家想必听说过,但什么是面向资源编程?这个问题等介绍完 REST 的特征之后我们再回头细说。

概念上的不同是指 REST 并不是一种远程服务调用协议,甚至可以把定语也去掉,它就不是一种协议。协议都带有一定的规范性和强制性,最起码也该有个规约文档,譬如 JSON-RPC,它哪怕再简单,也要有个《JSON-RPC Specification》来规定协议的格式细节、异常、响应码等信息,但是 REST 并没有定义这些内容,尽管有一些指导原则,但实际上并不受任何强制的约束。

如何理解REST

  • 资源(Resource):譬如你现在正在阅读一篇名为《REST 设计风格》的文章,这篇文章的内容本身(你可以将其理解为其蕴含的信息、数据)我们称之为“资源”。无论你是购买的书籍、是在浏览器看的网页、是打印出来看的文稿、是在电脑屏幕上阅读抑或是手机上浏览,尽管呈现的样子各不相同,但其中的信息是不变的,你所阅读的仍是同一份“资源”。
  • 表征(Representation):当你通过电脑浏览器阅读此文章时,浏览器向服务端发出请求“我需要这个资源的 HTML 格式”,服务端向浏览器返回的这个 HTML 就被称之为“表征”,你可能通过其他方式拿到本文的 PDF、Markdown、RSS 等其他形式的版本,它们也同样是一个资源的多种表征。可见“表征”这个概念是指信息与用户交互时的表示形式,这与我们软件分层架构中常说的“表示层”(Presentation Layer)的语义其实是一致的。
  • 状态(State):当你读完了这篇文章,想看后面是什么内容时,你向服务器发出请求“给我下一篇文章”。但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”才能正确回应,这类在特定语境中才能产生的上下文信息即被称为“状态”。我们所说的有状态(Stateful)抑或是无状态(Stateless),都是只相对于服务端来说的,服务器要完成“取下一篇”的请求,要么自己记住用户的状态:这个用户现在阅读的是哪一篇文章,这称为有状态;要么客户端来记住状态,在请求的时候明确告诉服务器:我正在阅读某某文章,现在要读它的下一篇,这称为无状态。
  • 转移(Transfer):无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供,因为只有服务端拥有该资源及其表征形式。服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移”。

什么是RESTful的系统

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

  1. 服务端与客户端分离(Client-Server)
    将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性,这一点正越来越受到广大开发者所认可,以前完全基于服务端控制和渲染(如 JSF 这类)框架实际用户已甚少,而在服务端进行界面控制(Controller),通过服务端或者客户端的模版渲染引擎来进行界面渲染的框架(如 Struts、SpringMVC 这类)也受到了颇大的冲击。这一点主要推动力量与 REST 可能关系并不大,前端技术(从 ES 规范,到语言实现,到前端框架等)的近年来的高速发展,使得前端表达能力大幅度加强才是真正的幕后推手。由于前端的日渐强势,现在还流行起由前端代码反过来驱动服务端进行渲染的 SSR(Server-Side Rendering)技术,在 Serverless、SEO 等场景中已经占领了一块领地。
  2. 无状态(Stateless)
    无状态是 REST 的一条核心原则,部分开发者在做服务接口规划时,觉得 REST 风格的服务怎么设计都感觉别扭,很有可能的一种原因是在服务端持有着比较重的状态。REST 希望服务器不要去负责维护状态,每一次从客户端发送的请求中,应包括所有的必要的上下文信息,会话信息也由客户端负责保存维护,服务端依据客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁。客户端承担状态维护职责以后,会产生一些新的问题,譬如身份认证、授权等可信问题,它们都应有针对性的解决方案
    但必须承认的现状是,目前大多数的系统都达不到这个要求,往往越复杂、越大型的系统越是如此。服务端无状态可以在分布式计算中获得非常高价值的好处,但大型系统的上下文状态数量完全可能膨胀到让客户端在每次请求时提供变得不切实际的程度,在服务端的内存、会话、数据库或者缓存等地方持有一定的状态成为一种是事实上存在,并将长期存在、被广泛使用的主流的方案。
  3. 可缓存(Cacheability)
    无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。“降低网络性”的通俗解释是某个功能如果使用有状态的设计只需要一次(或少量)请求就能完成,使用无状态的设计则可能会需要多次请求,或者在请求中带有额外冗余的信息。为了缓解这个矛盾,REST 希望软件系统能够如同万维网一样,允许客户端和中间的通讯传递者(譬如代理)将部分服务端的应答缓存起来。当然,为了缓存能够正确地运作,服务端的应答中必须明确地或者间接地表明本身是否可以进行缓存、可以缓存多长时间,以避免客户端在将来进行请求的时候得到过时的数据。运作良好的缓存机制可以减少客户端、服务器之间的交互,甚至有些场景中可以完全避免交互,这就进一步提高了性能。
  4. 分层系统(Layered System)
    这里所指的并不是表示层、服务层、持久层这种意义上的分层。而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。该原则的典型的应用是内容分发网络(Content Distribution Network,CDN)。如果你是通过网站浏览到这篇文章的话,你所发出的请求一般(假设你在中国国境内的话)并不是直接访问位于 GitHub Pages 的源服务器,而是访问了位于国内的 CDN 服务器,但作为用户,你完全不需要感知到这一点。
  5. 统一接口(Uniform Interface)
    这是 REST 的另一条核心原则,REST 希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。这条原则你可以类比计算机中对文件管理的操作来理解,管理文件可能会进行创建、修改、删除、移动等操作,这些操作数量是可数的,而且对所有文件都是固定的、统一的。如果面向资源来设计系统,同样会具有类似的操作特征,由于 REST 并没有设计新的协议,所以这些操作都借用了 HTTP 协议中固有的操作命令来完成。
  6. 按需代码(Code-On-Demand)
    按需代码被 Fielding 列为一条可选原则。它是指任何按照客户端(譬如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术,按需代码赋予了客户端无需事先知道所有来自服务端的信息应该如何处理、如何运行的宽容度。

事务处理

事务处理几乎在每一个信息系统中都会涉及,它存在的意义是为了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性

按照数据库的经典理论,要达成这个目标,需要三方面共同努力来保障。

  • 原子性Atomic):在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
  • 隔离性Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
  • 持久性Durability):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

以上四种属性即事务的“ACID”特性,这四种特性并不正交,A、I、D 是手段,C 是目的,前者是因,后者是果,弄到一块去完全是为了拼凑个单词缩写。

事务的概念虽然最初起源于数据库系统,但今天已经有所延伸,而不再局限于数据库本身了,所有需要保证数据一致性的应用场景,包括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等等,都有可能会用到事务

  • 当一个服务只使用一个数据源时,通过 A、I、D 来获得一致性是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事务间一致性被称为“内部一致性”。
  • 当一个服务使用到多个不同的数据源,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得相对困难了许多。此时,并发执行甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据源来决定,这种涉及多个数据源的事务间一致性被称为“外部一致性”。

外部一致性问题通常很难再使用 A、I、D 来解决,因为这样需要付出很大乃至不切实际的代价;但是外部一致性又是分布式系统中必然会遇到且必须要解决的问题,为此我们要转变观念,将一致性从“是或否”的二元属性转变为可以按不同强度分开讨论的多元属性,在确保代价可承受的前提下获得强度尽可能高的一致性保障,也正因如此,事务处理才从一个具体操作上的“编程问题”上升成一个需要全局权衡的“架构问题”。

本地事务

本地事务是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务

本地事务是最基础的一种事务解决方案,只适用于单个服务使用单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层标准化的包装(如 JDBC 接口),并不能深入参与到事务的运作过程当中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能工作,这一点与后续的 XA、TCC、SAGA 等主要靠应用程序代码来实现的事务有着十分明显的区别。

ARIES 是现代数据库的基础理论。在 20 世纪 90 年代,IBM Almaden 研究院总结了研发原型数据库系统“IBM System R”的经验,发表了 ARIES 理论中最主要的三篇论文,其中《ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging》着重解决了 ACID 的其中两个属性:原子性(A)和持久性(D)在算法层面上应当如何实现。而另一篇《ARIES/KVL: A Key-Value Locking Method for Concurrency Control of Multiaction Transactions Operating on B-Tree Indexes》则是现代数据库隔离性(I)奠基式的文章,

如何实现原子性和持久性

原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

众所周知,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到应用程序忽然崩溃,或者数据库、操作系统一侧的崩溃,甚至是机器突然断电宕机等情况就会丢失,后文我们将这些意外情况都统称为“崩溃”(Crash)。

实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子的,不仅有“写入”与“未写入”状态,还客观地存在着“正在写”的中间状态。正因为写入中间状态与崩溃都不可能消除,所以如果不做额外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持久性。下面通过具体事例来说明。

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

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

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

为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。

只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”(提交日志)

Commit Logging 保障数据持久性、原子性的原理

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

Commit Logging 的原理很清晰,也确实有一些数据库就是直接采用 Commit Logging 机制来实现事务的,譬如较具代表性的是阿里的OceanBase。但是,Commit Logging 存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即日志写入了 Commit Record 之后。在此之前,即使磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大占用了大量的内存缓冲区无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是 Commit Logging 成立的前提,却对提升数据库的性能十分不利。为了解决这个问题,前面提到的 ARIES 理论终于可以登场。

ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,划分为 FORCE 和 STEAL 两类情况。

  • FORCE当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE。现实中绝大多数数据库采用的都是 NO-FORCE 策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行
  • STEAL:在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。

Commit Logging 允许 NO-FORCE,但不允许 STEAL。因为假如事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。

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

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

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

重做阶段和回滚阶段的操作都应该设计为幂等的.数据库按照是否允许 FORCE 和 STEAL 可以产生共计四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看 NO-FORCE 加 STEAL 组合的复杂度无疑也是最高的。

此外还有shaow paing,SQLite Version 3 采用的事务机制就是 Shadow Paging

Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。Shadow Paging 实现事务要比 Commit Logging 更加简单,但涉及隔离性与并发锁时,Shadow Paging 实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多。

实现隔离性

隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。

如何保证并发下实现串行操作?现代数据库均提供了以下三种锁。

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

事务隔离的不同等级,可串行化,可重复读,读已提交以及读未提交。

串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。可串行化完全符合普通程序员对数据竞争加锁的理解。如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化(实际还是很复杂的,要分成 Expanding 和 Shrinking 两阶段去处理读锁、写锁与数据间的关系,称为Two-Phase Lock,2PL)。

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

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

可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁可重复读可串行化弱化的地方在于幻读问题,它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。

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

读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。读已提交可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。

原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务 T2 中的更新语句可以马上提交成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是可重复读的话,由于数据已被事务 T1 施加了读锁且读取后不会马上释放,所以事务 T2 无法获取到写锁,更新就会被阻塞,直至事务 T1 被提交或回滚后才能提交。

读未提交对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁。读未提交读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。

除了都以锁来实现外,以上四种隔离级别还有另一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性,针对这种“一个事务读+另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

MVCC 是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID,事务 ID 是一个全局严格递增的数值,然后根据以下规则写入数据。

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

此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。

  • 隔离级别是可重复读总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

另外两个隔离级别都没有必要用到 MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而 MVCC 是做读取时无锁优化的,自然就不会放到一起用

MVCC 是只针对“读+写”场景的优化如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁的策略是“乐观加锁”(Optimistic Locking)还是“悲观加锁”(Pessimistic Locking)。

前面介绍的加锁都属于悲观加锁策略,即认为如果不先做加锁再访问数据,就肯定会出现问题。相对地,乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。这种思路被称为“乐观并发控制”(Optimistic Concurrency Control,OCC)没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而更慢。

全局事务

与本地事务相对的是全局事务(Global Transaction),有一些资料中也将其称为外部事务(External Transaction),在这里全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案

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

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

基于 XA 模式在 Java 语言中的实现了全局事务处理的标准,这也就是我们现在所熟知的 JTA。JTA 最主要的两个接口是:

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

XA 将事务提交拆分成为两阶段过程:

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

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

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

上面所说的协调者、参与者都是可以由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生的,而应用程序相对于数据库来说只扮演客户端的角色。

image-20260119134801487

两段式提交原理简单,并不难实现,但有几个非常显著的缺点:

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

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

  • 一致性风险:前面已经提到,两段式提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985 年 Fischer、Lynch、Paterson 提出了“FLP 不可能原理”,证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与“CAP 不可兼得原理“齐名的理论。

    而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然被断开,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交,也没有办法回滚,产生了数据不一致的问题。

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

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

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

image-20260119141444338

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

共享事务

与全局事务里讨论的单个服务使用多个数据源正好相反,共享事务(Share Transaction)是指多个服务共用同一个数据源。

数据源是指提供数据的逻辑设备,不必与物理设备一一对应。在部署应用集群时最常采用的模式是将同一套程序部署到多个中间件服务器上,构成多个副本实例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有自己的专属的数据源,通常是中间件以 JNDI 的形式开放给程序代码使用。

假设一个购物场景,一个数据库中存储了用户账户,商家账户以及商品信息。但用户、商户和仓库每个领域都部署了独立的微服务,此时一次购书的业务操作将贯穿三个微服务,它们都要在数据库中修改数据。

为了实现共享事务,就必须新增一个“交易服务器”的中间角色,无论是用户服务、商家服务还是仓库服务,它们都通过同一台交易服务器来与数据库打交道。如果将交易服务器的对外接口按照 JDBC 规范来实现的话,那它完全可以视为是一个独立于各个服务的远程数据库连接池,或者直接作为数据库代理来看待。此时三个服务所发出的交易请求就有可能做到交由交易服务器上的同一个数据库连接,通过本地事务的方式完成。

image-20260119142742825

但该方案是与实际生产系统中的压力方向相悖的,一个服务集群里数据库才是压力最大而又最不容易伸缩拓展的重灾区,所以现实中只有类似ProxySQLMaxScale这样用于对多个数据库实例做负载均衡的数据库代理(其实用 ProxySQL 代理单个数据库,再启用 Connection Multiplexing,已经接近于前面所提及的交易服务器方案了),而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。这也是说它更有可能是个伪需求的原因。

分布式事务

所说的分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制

人们曾经寄希望于 XA 的事务机制可以在本节所说的分布式环境中也能良好地应用,但这个美好的愿望今天已经被 CAP 理论彻底地击碎了

CAP理论与ACID

CAP是分布式计算领域所公认的著名定理。这个定理里描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:

  • 一致性Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,
  • 可用性Availability):代表系统不间断地提供服务的能力,理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如 99.9999%可用,即代表平均年故障修复时间为 32 秒。
  • 分区容忍性Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。

在这套系统中,每一个单独的服务节点都有自己的数据库,假设某次交易请求分别由“账号节点 1”、“商家节点 2”、“仓库节点 N”联合进行响应。当用户购买一件价值 100 元的商品后,账号节点 1 首先应给该用户账号扣减 100 元货款,它在自己数据库扣减 100 元很容易,但它还要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况。

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

如果舍弃 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 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。

将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求“弱一致性”。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”(Eventual Consistency),它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。

可靠事务队列

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

BASE 分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)的缩写。有 ACID vs BASE(酸 vs 碱)这个梗,这篇论文本身作为最终一致性的概念起源,并系统性地总结了一种针对分布式事务的技术手段,是非常有价值的。

以购买商品为例,

  1. 用户向 Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
  2. 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 的名字所示,它分为以下三个阶段。

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
  • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
  • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
  1. 最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
  2. 创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:
    • 用户服务:检查业务可行性,可行的话,将该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
    • 仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
    • 商家服务:检查业务可行性,不需要冻结资源。
  3. 如果第 2 步所有业务均反馈业务可行,将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:
    • 用户服务:完成业务操作(扣减那被冻结的 100 元)。
    • 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。
    • 商家服务:完成业务操作(收款 100 元)。
  4. 第 3 步如果全部完成,事务宣告正常结束,如果第 3 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,即进行最大努力交付。
  5. 如果第 2 步有任意一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
    • 用户服务:取消业务操作(释放被冻结的 100 元)。
    • 仓库服务:取消业务操作(释放被冻结的 1 本书)。
    • 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
  6. 第 5 步如果全部完成,事务宣告以失败回滚结束,如果第 5 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,即进行最大努力交付。

TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,尽量减轻一些编码工作量。

SAGA事务

TCC 的最主要限制是它的业务侵入性很强,这里并不是重复上一节提到的它需要开发编码配合所带来的工作量,而更多的是指它所要求的技术可控性上的约束。

大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA 由两部分操作组成。

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

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

  • 正向恢复(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 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多

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

基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,例如Seata的AT事务模式。

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

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

透明多级分流系统

客户端缓存

在 HTTP 协议设计之初,便确定了服务端与客户端之间“无状态”(Stateless)的交互原则,即要求每次请求是独立的,每次请求无法感知也不能依赖另一个请求的存在,这既简化了 HTTP 服务器的设计,也为其水平扩展能力留下了广袤的空间.

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

HTTP 协议对此问题的解决方案便是客户端缓存,在 HTTP 从 1.0 到 1.1,再到 2.0 版本的每次演进中,逐步形成了现在被称为“状态缓存”、“强制缓存”和“协商缓存”的 HTTP 缓存机制。

HTTP 缓存中,状态缓存是指不经过服务器,客户端直接根据缓存信息对目标网站的状态判断,以前只有 301/Moved Permanently(永久重定向)这一种。后来后HSTS避免301跳转到HTTPS的降级中间人攻击。

强制缓存

HTTP 的强制缓存对一致性处理的策略就如它的名字一样,十分直接:假设在某个时点到来以前,譬如收到响应后的 10 分钟内,资源的内容和状态一定不会被改变,因此客户端可以无须经过任何请求,在该时点前一直持有和使用该资源的本地缓存副本

根据约定,强制缓存在浏览器的地址输入、页面链接跳转、新开窗口、前进和后退中均可生效,但在用户主动刷新页面时应当自动失效。HTTP 协议中设有以下两类 Header 实现强制缓存。

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

Expires 是 HTTP 协议最初版本中提供的缓存机制,设计非常直观易懂,但考虑得并不够周全,它至少存在以下显而易见的问题:

  • 受限于客户端的本地时间。譬如,在收到响应后,客户端修改了本地时间,将时间前后调整几分钟,就可能会造成缓存提前失效或超期持有。
  • 无法处理涉及到用户身份的私有资源,譬如,某些资源被登录用户缓存在自己的浏览器上是合理的,但如果被代理服务器或者内容分发网络缓存起来,则可能被其他未认证的用户所获取。
  • 无法描述“缓存”的语义。譬如,浏览器为了提高性能,往往会自动在当次会话中缓存某些 MIME 类型的资源,在 HTTP/1.0 的服务器中就缺乏手段强制浏览器不允许缓存某个资源。以前为了实现这类功能,通常不得不使用脚本,或者手工在资源后面增加时间戳(譬如如“xx.js?t=1586359920”、“xx.jpg?t=1586359350”)来保证每次资源都会重新获取。
    关于“不缓存”的语义,在 HTTP/1.0 中其实预留了“Pragma: no-cache”来表达,但 Pragma 参数在 HTTP/1.0 中并没有确切描述其具体行为,随后就被 HTTP/1.1 中出现过的 Cache-Control 所替代,现在,尽管主流浏览器通常都会支持 Pragma,但行为仍然是不确定的,实际并没有什么使用价值。

  • Cache-Control:Cache-Control 是 HTTP/1.1 协议中定义的强制缓存 Header,它的语义比起 Expires 来说就丰富了很多,如果 Cache-Control 和 Expires 同时存在,并且语义存在冲突(譬如 Expires 与 max-age / s-maxage 冲突)的话,规定必须以 Cache-Control 为准。Cache-Control 的使用示例如下:

    1
    2
    HTTP/1.1 200 OK
    Cache-Control: max-age=600

    Cache-Control 在客户端的请求 Header 或服务器的响应 Header 中都可以存在,它定义了一系列的参数,且允许自行扩展(即不在标准 RFC 协议中,由浏览器自行支持的参数),其标准的参数主要包括有:

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

协商缓存

强制缓存是基于时效性的,但无论是人还是服务器,其实多数情况下都并没有什么把握去承诺某项资源多久不会发生变化。另外一种基于变化检测的缓存机制,在一致性上会有比强制缓存更好的表现,但需要一次变化检测的交互开销,性能上就会略差一些,这种基于检测的缓存机制,通常被称为“协商缓存”。另外,应注意在 HTTP 中协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的,譬如,当强制缓存存在时,直接从强制缓存中返回资源,无须进行变动检查;而当强制缓存超过时效,或者被禁止(no-cache / must-revalidate),协商缓存仍可以正常地工作。

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

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

如果此时服务端发现资源在该时间之后有变动,就会返回 200/OK 的完整响应,在消息体中包含最新的资源。

Etag 和 If-None-Match:Etag 是服务器的响应 Header,用于告诉客户端这个资源的唯一标识。HTTP 服务器可以根据自己的意愿来选择如何生成这个标识,譬如 Apache 服务器的 Etag 值默认是对文件的索引节点(INode),大小和最后修改时间进行哈希计算后得到的。对于带有这个 Header 的资源,当客户端需要再次请求时,会通过 If-None-Match 把之前收到的资源唯一标识发送回服务端

如果此时服务端计算后发现资源的唯一标识与上传回来的一致,说明资源没有被修改过,就只要返回一个 304/Not Modified 的响应即可,无须附带消息体,达到节省流量的目的。如果此时服务端发现资源的唯一标识有变动,就会返回 200/OK 的完整响应,在消息体中包含最新的资源

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

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

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

域名解析

DNS解析流程

无论是使用浏览器抑或是在程序代码中访问某个网址域名,譬如以www.icyfenix.com.cn为例,如果没有缓存的话,都会先经过 DNS 服务器的解析翻译,找到域名对应的 IP 地址才能开始通信,这项操作是操作系统自动完成的,一般不需要用户程序的介入。

不过,DNS 服务器并不是一次性地将“www.icyfenix.com.cn”直接解析成 IP 地址,需要经历一个递归的过程。首先 DNS 会将域名还原为“www.icyfenix.com.cn.”,注意最后多了一个点“.”,它是“.root”的含义。早期的域名必须带有这个点才能被 DNS 正确解析,如今几乎所有的操作系统、DNS 服务器都可以自动补上结尾的点号,然后开始如下解析步骤。

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

  1. 客户端先检查本地的 DNS 缓存,查看是否存在并且是存活着的该域名的地址记录。DNS 是以存活时间(Time to Live,TTL)来衡量缓存的有效情况的,所以,如果某个域名改变了 IP 地址,DNS 服务器并没有任何机制去通知缓存了该地址的机器去更新或者失效掉缓存,只能依靠 TTL 超期后的重新获取来保证一致性。后续每一级 DNS 查询的过程都会有类似的缓存查询操作,再遇到时笔者就不重复叙述了。
  2. 客户端将地址发送给本机操作系统中配置的本地 DNS(Local DNS),这个本地 DNS 服务器可以由用户手工设置,也可以在 DHCP 分配时或者在拨号时从 PPP 服务器中自动获取到。
  3. 本地 DNS 收到查询请求后,会按照“是否有www.icyfenix.com.cn的权威服务器”→“是否有icyfenix.com.cn的权威服务器”→“是否有com.cn的权威服务器”→“是否有cn的权威服务器”的顺序,依次查询自己的地址记录,如果都没有查询到,就会一直找到最后点号代表的根域名服务器为止。这个步骤里涉及了两个重要名词:
    • 权威域名服务器(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 的顺序一直查到根域名服务器之后,它将会得到“cn的权威服务器”的地址记录,然后通过“cn的权威服务器”,得到“com.cn的权威服务器”的地址记录,以此类推,最后找到能够解释www.icyfenix.com.cn的权威服务器地址。
  5. 通过“www.icyfenix.com.cn的权威服务器”,查询www.icyfenix.com.cn的地址记录,地址记录并不一定就是指 IP 地址,在 RFC 规范中有定义的地址记录类型多达十几种,譬如 IPv4 下的 IP 地址为 A 记录,IPv6 下的 AAAA 记录、主机别名 CNAME 记录,等等。

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

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

DNS的一个缺陷是位于递归链底层或者来自本地运营商的 Local DNS 服务器的安全防护则相对松懈,甚至不少地区的运营商自己就会主动进行劫持,专门返回一个错的 IP,通过在这个 IP 上代理用户请求,以便给特定类型的资源(主要是 HTML)注入广告,以此牟利

传输链路

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

连接数优化

分析一下浏览网络的特点,上网平均每个页面停留的时间,以及每个页面中包含的资源(HTML、JS、CSS、图片等)数量,可以总结出 HTTP 传输对象的主要特征是数量多、时间短、资源小、切换快

另一方面,TCP 协议要求必须在三次握手完成之后才能开始数据传输,这是一个可能高达“百毫秒”为计时尺度的事件;另外,TCP 还有慢启动的特性,使得刚刚建立连接时传输速度是最低的,后面再逐步加速直至稳定。由于 TCP 协议本身是面向于长时间、大数据传输来设计的,在长时间尺度下,它连接建立的高昂成本才不至于成为瓶颈,它的稳定性和可靠性的优势才能展现出来。

HTTP over TCP 这种搭配在目标特征上确实是有矛盾的,以至于 HTTP/1.x 时代,大量短而小的 TCP 连接导致了网络性能的瓶颈。为了缓解 HTTP 与 TCP 之间的矛盾,聪明的程序员们一面致力于减少发出的请求数量,另外一方面也致力于增加客户端到服务端的连接数量

HTTP1.0的Tricks:减少请求数量:请求每次都需要建立通信链路进行数据传输,这些开销很昂贵,减少请求的数量可有效的提高访问性能:雪碧图,CSS,JS文件合并,分段文档,媒体内联,合并Ajax请求。扩大并发请求数:现代浏览器(Chrome、Firefox)一般对每个域名支持 6 个(IE 为 8-13 个)并发请求。压缩传输。避免页面重定向按重要性调节资源优先级

但是,通过开发人员的 Tricks 来节省 TCP 连接,这样的优化措施并非只有好处,它们同时也带来了诸多不良的副作用:

  • 如果你用 CSS Sprites 将多张图片合并,意味着任何场景下哪怕只用到其中一张小图,也必须完整加载整个大图片;任何场景下哪怕一张小图要进行修改,都会导致整个缓存失效,类似地,样式、脚本等其他文件的合并也会造成同样的问题。
  • 如果你使用了媒体内嵌,除了要承受 Base64 编码导致传输容量膨胀 1/3 的代价外(Base64 以 8 bit 表示 6 bit 数据),也将无法有效利用缓存。
  • 如果你合并了异步请求,这就会导致所有请求返回时间都受最慢的那个请求的拖累,整体响应速度下降.
  • 如果你把图片放到不同子域下面,将会导致更大的 DNS 解析负担,而且浏览器对两个不同子域下的同一图片必须持有两份缓存,也使得缓存效率的下降。

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

HTTP1.1中Keep-Alive机制默认开启,持久连接的原理是让客户端对同一个域名长期持有一个或多个不会用完即断的 TCP 连接。典型做法是在客户端维护一个 FIFO 队列,每次取完数据之后一段时间内不自动断开连接以便获取下一个资源时直接复用,避免创建 TCP 连接的成本。

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

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

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

image-20260130123741191

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

  • Header 的传输成本在 Ajax(尤其是只返回少量数据的请求)请求中可能是比重很大的开销,但在图片、样式、脚本这些静态资源的请求中,通常并不占主要。
  • 在 HTTP/2 中 Header 压缩的原理是基于字典编码的信息复用,简而言之是同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以 HTTP/2 是单域名单连接的机制,合并资源和域名分片反而对性能提升不利。
  • 与 HTTP/1.x 相反,HTTP/2 本身反而变得更适合传输小资源了,譬如传输 1000 张 10K 的小图,HTTP/2 要比 HTTP/1.x 快,但传输 10 张 1000K 的大图,则应该 HTTP/1.x 会更快。这一方面是 TCP 连接数量(相当于多点下载)的影响,更多的是由于 TCP 协议可靠传输机制导致的,一个错误的 TCP 包会导致所有的流都必须等待这个包重传成功,这个问题就是 HTTP/3 要解决的目标了。因此,把小文件合并成大文件,在 HTTP/2 下是毫无好处的

传输压缩

如何不以断开 TCP 连接为标志来判断资源已传输完毕。

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

静态预压缩:在网络时代的早期,服务器处理能力还很薄弱,为了启用压缩,会是把静态资源先预先压缩为.gz 文件的形式存放起来,当客户端可以接受压缩版本的资源时(请求的 Header 中包含 Accept-Encoding: gzip)就返回压缩后的版本,否则就返回未压缩的原版

即时压缩:整个压缩过程全部在内存的数据流中完成,不必等资源压缩完成再返回响应,这样可以显著提高“首字节时间”(Time To First Byte,TTFB),改善 Web 性能体验。而这个过程中唯一不好的地方就是服务器再也没有办法给出 Content-Length 这个响应 Header 了,因为输出 Header 时服务器还不知道压缩后资源的确切大小。

因此,HTTP/1.0在即时压缩下,持久连接和即时传输存在冲突,因为HTTP/1.0的持久连接是依靠 Content-Length 来判断传输结束的。

HTTP/1.1 版本中修复了这个缺陷,增加了另一种“分块传输编码”(Chunked Transfer Encoding)的资源结束判断机制,彻底解决了 Content-Length 与持久链接的冲突问题。分块编码原理相当简单:在响应 Header 中加入“Transfer-Encoding: chunked”之后,就代表这个响应报文将采用分块编码。此时,报文中的 Body 需要改为用一系列“分块”来传输。每个分块包含十六进制的长度值和对应长度的数据内容,长度值独占一行,数据从下一行开始。最后以一个长度值为 0 的分块来表示资源结束

HTTP/1.1 通过分块传输解决了即时压缩与持久连接并存的问题,到了 HTTP/2,由于多路复用和单域名单连接的设计,已经无须再刻意去提持久链接机制了,但数据压缩仍然有节约传输带宽的重要价值

快速UDP连接

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

Google 在它的服务器(如 Google.com、YouTube.com 等)及 Chrome 浏览器上同时启用了名为“快速 UDP 网络连接”(Quick UDP Internet Connections,QUIC)的全新传输协议

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

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

内容分发网络

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

  1. 网站服务器接入网络运营商的链路所能提供的出口带宽。
  2. 用户客户端接入网络运营商的链路所能提供的入口带宽。
  3. 从网站到用户之间经过的不同运营商之间互联节点的带宽,一般来说两个运营商之间只有固定的若干个点是互通的,所有跨运营商之间的交互都要经过这些点。
  4. 从网站到用户之间的物理链路传输时延。

以上四个网络问题,除了第二个只能通过换一个更好的宽带才能解决之外,其余三个都能通过内容分发网络来显著改善。

路由解析

翻译域名无须像查电话本一样刻板地一对一翻译,根据来访机器、网络链路、服务内容等各种信息,可以玩出很多花样,内容分发网络将用户请求路由到它的资源服务器上就是依靠 DNS 服务器来实现的。

CDN 路由解析的具体工作过程是:

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

image-20260130133535991

内容分发

CDN 获取源站资源的过程被称为“内容分发”,“内容分发网络”的名字正是由此而来

在 DNS 服务器的协助下,无论是对用户还是服务器,内容分发网络都可以是完全透明的,在两者都不知情的情况下,由 CDN 的缓存节点接管了用户向服务器发出的资源请求。后面随之而来的问题是缓存节点中必须有用户想要请求的资源副本,才可能代替源站来响应用户请求

  • 主动分发(Push):分发由源站主动发起,将内容从源站或者其他资源库推送到用户边缘的各个 CDN 缓存节点上。这个推送的操作没有什么业界标准可循,可以采用任何传输方式(HTTP、FTP、P2P,等等)、任何推送策略(满足特定条件、定时、人工,等等)、任何推送时间,只要与后面说的更新策略相匹配即可。由于主动分发通常需要源站、CDN 服务双方提供程序 API 接口层面的配合,所以它对源站并不是透明的,只对用户一侧单向透明。主动分发一般用于网站要预载大量资源的场景。譬如双十一之前一段时间内,淘宝、京东等各个网络商城就会开始把未来活动中所需用到的资源推送到 CDN 缓存节点中,特别常用的资源甚至会直接缓存到你的手机 APP 的存储空间或者浏览器的localStorage上。
  • 被动回源(Pull):被动回源由用户访问所触发全自动、双向透明的资源缓存过程。当某个资源首次被用户请求的时候,CDN 缓存节点发现自己没有该资源,就会实时从源站中获取,这时资源的响应时间可粗略认为是资源从源站到 CDN 缓存节点的时间,再加上资源从 CDN 发送到用户的时间之和。因此,被动回源的首次访问通常是比较慢的(但由于 CDN 的网络条件一般远高于普通用户,并不一定就会比用户直接访问源站更慢),不适合应用于数据量较大的资源。被动回源的优点是可以做到完全的双向透明,不需要源站在程序上做任何的配合,使用起来非常方便。这种分发方式是小型站点使用 CDN 服务的主流选择,如果不是自建 CDN,而是购买阿里云、腾讯云的 CDN 服务的站点,多数采用的就是这种方式。

DN 如何管理(更新)资源”这个问题,同样没有统一的标准可言,尽管在 HTTP 协议中,关于缓存的 Header 定义中确实是有对 CDN 这类共享缓存的一些指引性参数,譬如Cache-Control的 s-maxage,但是否要遵循,完全取决于 CDN 本身的实现策略。最常见的做法是超时被动失效与手工主动失效相结合。超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。而手工失效是指 CDN 服务商一般会提供给程序调用来失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新

负载均衡

一般实际用于生产的系统,几乎都离不开集群部署了。信息系统不论是采用单体架构多副本部署还是微服务架构,不论是为了实现高可用还是为了获得高性能,都需要利用到多台机器来扩展服务能力,希望用户的请求不管连接到哪台机器上,都能得到相同的处理。另一方面,如何构建和调度服务集群这事情,又必须对用户一侧保持足够的透明,即使请求背后是由一千台、一万台机器来共同响应的,也绝非用户所关心的事情,用户需记住的只有一个域名地址而已。调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”(Load Balancing)。

无论在网关内部建立了多少级的负载均衡,从形式上来说都可以分为两种:四层负载均衡和七层负载均衡。

  • 四层负载均衡的优势是性能高,七层负载均衡的优势是功能强。
  • 做多级混合负载均衡,通常应是低层的负载均衡在前,高层的负载均衡在后
数据单元功能
7应用层 Application Layer数据 Data提供为应用软件提供服务的接口,用于与其他应用软件之间的通信。典型协议:HTTP、HTTPS、FTP、Telnet、SSH、SMTP、POP3 等
6表达层 Presentation Layer数据 Data把数据转换为能与接收者的系统格式兼容并适合传输的格式。
5会话层 Session Layer数据 Data负责在数据传输中设置和维护计算机网络中两台计算机之间的通信连接。
4传输层 Transport Layer数据段 Segments把传输表头加至数据以形成数据包。传输表头包含了所使用的协议等发送信息。典型协议:TCP、UDP、RDP、SCTP、FCP 等
3网络层 Network Layer数据包 Packets决定数据的传输路径选择和转发,将网络表头附加至数据段后以形成报文(即数据包)。典型协议:IPv4/IPv6、IGMP、ICMP、EGP、RIP 等
2数据链路层 Data Link Layer数据帧 Frame负责点对点的网络寻址、错误侦测和纠错。当表头和表尾被附加至数据包后,就形成数据帧(Frame)。典型协议:WiFi(802.11)、Ethernet(802.3)、PPP 等。
1物理层 Physical Layer比特流 Bit在物理网络上传送数据帧,它负责管理电脑通信设备和网络媒体之间的互通。包括了针脚、电压、线缆规范、集线器、中继器、网卡、主机接口卡等。

“四层”的意思是说这些工作模式的共同特点是维持着同一个 TCP 连接,而不是说它只工作在第四层。事实上,这些模式主要都是工作在二层(数据链路层,改写 MAC 地址)和三层(网络层,改写 IP 地址)上

数据链路层的负载均衡

数据链路层传输的内容是数据帧(Frame),譬如常见的以太网帧、ADSL 宽带的 PPP 帧等。

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

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

image-20260130135809188

只有请求经过负载均衡器,而服务的响应无须从负载均衡器原路返回的工作模式,整个请求、转发、响应的链路形成一个“三角关系”,所以这种负载均衡模式也常被很形象地称为“三角传输模式”或”直接路由模式”.

数据链路层负载均衡效率很高,但它并不能适用于所有的场合,除了那些需要感知应用层协议信息的负载均衡场景它无法胜任外,它在网络一侧受到的约束也很大。二层负载均衡器直接改写目标 MAC 地址的工作原理决定了它与真实的服务器的通信必须是二层可达的,通俗地说就是必须位于同一个子网当中,无法跨 VLAN

网络层负载均衡

一个 IP 数据包由 Headers 和 Payload 两部分组成, Headers 长度最大为 60 Bytes,其中包括了 20 Bytes 的固定数据和最长不超过 40 Bytes 的可选的额外设置组成。

只要知道在 IP 分组数据包的 Headers 带有源和目标的 IP 地址即可。源和目标 IP 地址代表了数据是从分组交换网络中哪台机器发送到哪台机器的,我们可以沿用与二层改写 MAC 地址相似的思路,通过改变这里面的 IP 地址来实现数据包的转发。具体有两种常见的修改方式。

第一种是保持原来的数据包不变,新创建一个数据包,把原来数据包的 Headers 和 Payload 整体作为另一个新的数据包的 Payload,在这个新数据包的 Headers 中写入真实服务器的 IP 作为目标地址,然后把它发送出去。经过三层交换机的转发,真实服务器收到数据包后,必须在接收入口处设计一个针对性的拆包机制,把由负载均衡器自动添加的那层 Headers 扔掉,还原出原来的数据包来进行使用。这样,真实服务器就同样拿到了一个原本不是发给它(目标 IP 不是它)的数据包,达到了流量转发的目的。(IP隧道)

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

image-20260130143930455

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

对服务器进行虚拟 IP 的配置并不是在任何情况下都可行的,尤其是当有好几个服务共用一台物理服务器的时候,此时就必须考虑第二种修改方式——改变目标数据包:直接把数据包 Headers 中的目标地址改掉。

但问题是这种模式是通过修改目标 IP 地址才到达真实服务器的,如果真实服务器直接将应答包返回客户端的话,这个应答数据包的源 IP 是真实服务器的 IP,也即均衡器修改以后的 IP 地址,客户端不可能认识该 IP,自然就无法再正常处理这个应答了。因此,只能让应答流量继续回到负载均衡,由负载均衡把应答包的源 IP 改回自己的 IP,再发给客户端,这样才能保证客户端与真实服务器之间的正常通信。这类似NAT技术。

image-20260130144957103

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

应用层负载均衡

四层负载均衡工作模式都属于“转发”,即直接将承载着 TCP 报文的底层数据格式(IP 数据包或以太网帧)转发到真实服务器上,此时客户端到响应请求的真实服务器维持着同一条 TCP 通道。但工作在四层之后的负载均衡模式就无法再进行转发了,只能进行代理,此时真实服务器、负载均衡器、客户端三者之间由两条独立的 TCP 通道来维持通信。

image-20260130145203522

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

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

代理的工作模式相信大家应该是比较熟悉的,这里不再展开,只是简单列举了一些七层代理可以实现的功能,以便读者对它“功能强大”有个直观的感受。

  • 前面介绍 CDN 应用时,所有 CDN 可以做的缓存方面的工作(就是除去 CDN 根据物理位置就近返回这种优化链路的工作外),七层均衡器全都可以实现,譬如静态资源缓存、协议升级、安全防护、访问控制,等等。
  • 七层均衡器可以实现更智能化的路由。譬如,根据 Session 路由,以实现亲和性的集群;根据 URL 路由,实现专职化服务(此时就相当于网关的职责);甚至根据用户身份路由,实现对部分用户的特殊服务(如某些站点的贵宾服务器),等等。
  • 某些安全攻击可以由七层均衡器来抵御,譬如一种常见的 DDoS 手段是 SYN Flood 攻击,即攻击者控制众多客户端,使用虚假 IP 地址对同一目标大量发送 SYN 报文。从技术原理上看,由于四层均衡器无法感知上层协议的内容,这些 SYN 攻击都会被转发到后端的真实服务器上;而七层均衡器下这些 SYN 攻击自然在负载均衡设备上就被过滤掉,不会影响到后面服务器的正常运行。类似地,可以在七层均衡器上设定多种策略,譬如过滤特定报文,以防御如 SQL 注入等应用层面的特定攻击手段。
  • 很多微服务架构的系统中,链路治理措施都需要在七层中进行,譬如服务降级、熔断、异常注入,等等。譬如,一台服务器只有出现物理层面或者系统层面的故障,导致无法应答 TCP 请求才能被四层均衡器所感知,进而剔除出服务集群,如果一台服务器能够应答,只是一直在报 500 错,那四层均衡器对此是完全无能为力的,只能由七层均衡器来解决。
特性四层负载均衡 (L4)七层负载均衡 (L7)
所属层级传输层 (TCP/UDP)应用层 (HTTP/HTTPS/DNS)
分配依据IP 地址 + 端口URL、Header、Cookie、Query 参数
连接方式直接透传,不拆包代理模式,先拦截再转发
性能极高(消耗资源少)中等(解析报文消耗 CPU)
灵活性较低,无法识别具体业务极高,可实现复杂的业务逻辑
常见代表LVS, F5, Nginx (Stream 模块)Nginx, HAProxy, Envoy, Ingress Controller

均衡策略实现

负载均衡的两大职责是“选择谁来处理用户请求”和“将用户请求转发过去”。后者即请求的转发或代理过程。前者是指均衡器所采取的均衡策略,包括如下:

  • 轮循均衡(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 公司的负载均衡产品。

服务端缓存

为系统引入缓存之前,第一件事情是确认你的系统是否真的需要缓存。在软件开发中引入缓存的负面作用要明显大于硬件的缓存:从开发角度来说,引入缓存会提高系统复杂度,因为你要考虑缓存的失效、更新、一致性等问题(硬件缓存也有这些问题,只是不需要由你去考虑,主流的 ISA 也都没有提供任何直接操作缓存的指令);从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;从安全角度来说,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。冒着上述种种风险,仍能说服你引入缓存的理由,总结起来无外乎以下两种

  • 为缓解 CPU 压力而做缓存:譬如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用,这可以节省 CPU 算力,顺带提升响应性能。
  • 为缓解 I/O 压力而做缓存:譬如把原本对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,将原本对单点部件(如数据库)的读写访问变为到可扩缩部件(如缓存中间件)的访问,顺带提升响应性能。

缓存虽然是典型以空间换时间来提升性能的手段,但它的出发点是缓解 CPU 和 I/O 资源在峰值流量下的压力,“顺带”而非“专门”地提升响应性能。这里的言外之意是如果可以通过增强 CPU、I/O 本身的性能(譬如扩展服务器的数量)来满足需要的话,那升级硬件往往是更好的解决方案

缓存属性

通常,我们设计或者选择缓存至少会考虑以下四个维度的属性:

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

吞吐量

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

并发读写的场景中,吞吐量受多方面因素的共同影响,譬如,怎样设计数据结构以尽可能避免数据竞争,存在竞争风险时怎样处理同步(主要有使用锁实现的悲观同步和使用CAS实现的乐观同步)、如何避免伪共享现象(False Sharing,这也算是典型缓存提升开发复杂度的例子)发生,等等。其中第一点尽可能避免竞争是最关键的,无论如何实现同步都不会比直接无须同步更快.

缓存中最主要的数据竞争源于读取数据的同时,也会伴随着对数据状态的写入操作,写入数据的同时,也会伴随着数据状态的读取操作。譬如,读取时要同时更新数据的最近访问时间和访问计数器的状态,以实现缓存的淘汰策略;又或者读取时要同时判断数据的超期时间等信息,以实现失效重加载等其他扩展功能。

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

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

从 Caffeine 读取数据时,数据本身会在其内部的 ConcurrentHashMap 中直接返回,而数据的状态信息变更就存入环形缓冲中,由后台线程异步处理。如果异步处理的速度跟不上状态变更的速度,导致缓冲区满了,那此后接收的状态的变更信息就会直接被丢弃掉,直至缓冲区重新富余。通过环形缓冲和容忍有损失的状态变更,Caffeine 大幅降低了由于数据读取而导致的垃圾收集和锁竞争,因此 Caffeine 的读取性能几乎能与 ConcurrentHashMap 的读取性能相同。 因为缓存除了维护数据也要淘汰数据,Caffeine通过频率决定数据去留,而在 Caffeine 之前的缓存(如 Guava Cache)中,每次读取都会尝试更新 LRU 链表(把节点移到头部)。更新链表需要加锁。在高并发场景下,成千上万个线程同时读数据,却要为了更新同一个链表头而排队。这导致“读操作”由于竞争锁而变得极其缓慢。Caffeine 为了解决读操作的并发瓶颈,引入了类似 日志结构(Log-structured) 的思路,将“同步维护”改为“异步批处理”。

当发生读取时,Caffeine 会执行以下逻辑:

  1. 直接取值:从内部的 ConcurrentHashMap 中读取数据并立即返回给用户(极快)。
  2. 记录足迹:将这次“读取事件”丢进一个 Striped Ring Buffer(条带化环形缓冲)中。
    • 为什么是 Striped(条带化)? 它内部由多个 Ring Buffer 组成,根据线程 ID 散列。这样不同线程写不同的 Buffer,进一步减少了竞争。
    • 为什么是 Ring Buffer? 它是无锁的、高性能的,且如果缓冲区满了,Caffeine 会直接丢弃新的读取事件,优先保证读性能(即使丢掉一两次读取记录,对频率统计的准确性影响也微乎其微)。

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

命中率和淘汰策略

有限的物理存储决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡,这要求缓存必须能够自动或者由人工淘汰掉缓存中的低价值数据

目前,最基础的淘汰策略实现方案有以下三种:

  • 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 分支为例,针对它存在的两个问题,近年来提出的 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 就很难让这类元素通过 Sketch 的过滤,因为它们无法在运行期间积累到足够高的频率。应对短时间的突发访问是 LRU 的强项,W-TinyLFU 就结合了 LRU 和 LFU 两者的优点,从整体上看是 LFU 策略,从局部实现上看又是 LRU 策略。具体做法是将新记录暂时放入一个名为 Window Cache 的前端 LRU 缓存里面,让这些对象可以在 Window Cache 中累积热度,如果能通过 TinyLFU 的过滤器,再进入名为 Main Cache 的主缓存中存储,主缓存根据数据的访问频繁程度分为不同的段(LFU 策略,实际上 W-TinyLFU 只分了两段),但单独某一段局部来看又是基于 LRU 策略去实现的(称为 Segmented LRU)。每当前一段缓存满了之后,会将低价值数据淘汰到后一段中去存储,直至最后一段也满了之后,该数据就彻底清理出缓存。

扩展功能

在“访问”之外,专业的缓存往往还会提供很多额外的功能。笔者简要列举如下:

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

分布式缓存

相比起缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多,所以对分布式缓存来说,处理与网络有相关的操作是对吞吐量影响更大的因素,往往也是比淘汰策略、扩展功能更重要的关注点。

  • 从访问的角度来说,如果是频繁更新但甚少读取的数据,通常是不会有人把它拿去做缓存的,因为这样做没有收益。对于甚少更新但频繁读取的数据,理论上更适合做复制式缓存;对于更新和读取都较为频繁的数据,理论上就更适合做集中式缓存。笔者简要介绍这两种分布式缓存形式的差别与代表性产品:

    • 复制式缓存:复制式缓存可以看作是“能够支持分布式的进程内缓存”,它的工作原理与 Session 复制类似缓存中所有数据在分布式集群的每个节点里面都存在有一份副本,读取数据时无须网络访问,直接从当前节点的进程内存中返回,理论上可以做到与进程内缓存一样高的读取性能;当数据发生变化时,就必须遵循复制协议,将变更同步到集群的每个节点中,复制性能随着节点的增加呈现平方级下降,变更数据的代价十分高昂。
      复制式缓存的代表是JBossCache,这是 JBoss 针对企业级集群设计的缓存方案,支持 JTA 事务,依靠 JGroup 进行集群节点间数据同步。以 JBossCache 为典型的复制式缓存曾有一段短暂的兴盛期,但今天基本上已经很难再见到使用这种缓存形式的大型信息系统了,JBossCache 被淘汰的主要原因是写入性能实在差到不堪入目的程度,它在小规模集群中同步数据尚算差强人意,但在大规模集群下,很容易就因网络同步的速度跟不上写入速度,进而导致在内存中累计大量待重发对象,最终引发 OutOfMemory 崩溃。如果对 JBossCache 没有足够了解的话,稍有不慎就要被埋进坑里。
      为了缓解复制式同步的写入效率问题,JBossCache 的继任者Infinispan提供了另一种分布式同步模式(这种同步模式的名字就叫做“分布式”),允许用户配置数据需要复制的副本数量,譬如集群中有八个节点,可以要求每个数据只保存四份副本,此时,缓存的总容量相当于是传统复制模式的一倍,如果要访问的数据在本地缓存中没有存储,Infinispan 完全有能力感知网络的拓扑结构,知道应该到哪些节点中寻找数据。
    • 集中式缓存:集中式缓存是目前分布式缓存的主流形式,集中式缓存的读、写都需要网络访问,其好处是不会随着集群节点数量的增加而产生额外的负担,其坏处自然是读、写都不再可能达到进程内缓存那样的高性能。
      集中式缓存还有一个必须提到的关键特点,它与使用缓存的应用分处在独立的进程空间中,其好处是它能够为异构语言提供服务,譬如用 C 语言编写的Memcached完全可以毫无障碍地为 Java 语言编写的应用提供缓存服务;但其坏处是如果要缓存对象等复杂类型的话,基本上就只能靠序列化来支撑具体语言的类型系统(支持 Hash 类型的缓存,可以部分模拟对象类型),不仅有序列化的成本,还很容易导致传输成本也显著增加。举个例子,假设某个有 100 个字段的大对象变更了其中 1 个字段的值,通常缓存也不得不把整个对象所有内容重新序列化传输出去才能实现更新,因此,一般集中式缓存更提倡直接缓存原始数据类型而不是对象。相比之下,JBossCache 通过它的字节码自审(Introspection)功能和树状存储结构(TreeCache),做到了自动跟踪、处理对象的部分变动,用户修改了对象中哪些字段的数据,缓存就只会同步对象中真正变更那部分数据。
      如今Redis广为流行,基本上已经打败了 Memcached 及其他集中式缓存框架,成为集中式缓存的首选,甚至可以说成为了分布式缓存的实质上的首选,几乎到了不必管读取、写入哪种操作更频繁,都可以无脑上 Redis 的程度。
  • 从数据一致性角度说,缓存本身也有集群部署的需求,是否能接受不同节点取到的缓存数据有可能存在差异。譬如刚刚放入缓存中的数据,另外一个节点马上访问发现未能读到;刚刚更新缓存中的数据,另外一个节点访问在短时间内读取到的仍是旧的数据,等等。根据分布式缓存集群是否能保证数据一致性,可以将它分为 AP 和 CP 两种类型。实际开发中通常不太会把追求强一致性的数据使用缓存来处理,可以这样做,但是没必要(可类比 MESI 等缓存一致性协议)。譬如,Redis 集群就是典型的 AP 式,有着高性能高可用等特点,却并不保证强一致性。而能够保证强一致性的 ZooKeeper、Doozerd、Etcd 等分布式协调框架,通常不会有人将它们当为“缓存框架”来使用,这些分布式协调框架的吞吐量相对 Redis 来说是非常有限的。不过 ZooKeeper、Doozerd、Etcd 倒是常与 Redis 和其他分布式缓存搭配工作,用来实现其中的通知、协调、队列、分布式锁等功能。

分布式缓存与进程内缓存各有所长,也有各有局限,它们是互补而非竞争的关系,如有需要,完全可以同时把进程内缓存和分布式缓存互相搭配,构成透明多级缓存(Transparent Multilevel Cache,TMC)

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

image-20260130172503417

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

缓存风险

缓存的目的是为了缓解 CPU 或者 I/O 的压力,譬如对数据库做缓存,大部分流量都从缓存中直接返回,只有缓存未能命中的数据请求才会流到数据库中,这样数据库压力自然就减小了。

缓存穿透

如果查询的数据在数据库中根本不存在的话,缓存里自然也不会有,这类请求的流量每次都不会命中,每次都会触及到末端的数据库,缓存就起不到缓解压力的作用了,这种查询不存在数据的现象被称为缓存穿透

缓存穿透有可能是业务逻辑本身就存在的固有问题,也有可能是被恶意攻击的所导致,为了解决缓存穿透,通常会采取下面两种办法:

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

缓存击穿

缓存的基本工作原理是首次从真实数据源加载数据,完成加载后回填入缓存,以后其他相同的请求就从缓存中获取数据,缓解数据源的压力。如果缓存中某些热点数据忽然因某种原因失效了,譬如典型地由于超期而失效,此时又有多个针对该数据的请求同时发送过来,这些请求将全部未能命中缓存,都到达真实数据源中去,导致其压力剧增,这种现象被称为缓存击穿。要避免缓存击穿问题,通常会采取下面的两种办法:

  1. 加锁同步,以请求该数据的 Key 值为锁,使得只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。如果是进程内缓存出现问题,施加普通互斥锁即可,如果是分布式缓存中出现的问题,就施加分布式锁,这样数据源就不会同时收到大量针对同一个数据的请求了。
  2. 热点数据由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理
  3. 逻辑过期。缓存本身永不过期,添加缓存实际过期时间戳,访问数据时判断是否过期,如果已经过期则开启异步线程进行更新。如果允许短期不一致,则直接返回过期值。

缓存雪崩

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

出现这种情况,往往是系统有专门的缓存预热功能,也可能大量公共数据是由某一次冷操作加载的,这样都可能出现由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。还有一种情况是缓存服务由于某些原因崩溃后重启,此时也会造成大量数据同时失效,这种现象被称为缓存雪崩。要避免缓存雪崩问题,通常会采取下面的三种办法:

  1. 提升缓存系统可用性,建设分布式缓存的集群。
  2. 启用透明多级缓存,各个服务节点一级缓存中的数据通常会具有不一样的加载时间,也就分散了它们的过期时间。
  3. 将缓存的生存期从固定时间改为一个时间段内的随机时间,譬如原本是一个小时过期,那可以缓存不同数据时,设置生存期为 55 分钟到 65 分钟之间的某个随机时间

缓存污染

缓存污染是指缓存中的数据与真实数据源中的数据不一致的现象。尽管缓存通常不追求强一致性,但这显然不能等同于缓存和数据源间连最终的一致性都可以不要求了。

缓存污染多数是由开发者更新缓存不规范造成的,譬如你从缓存中获得了某个对象,更新了对象的属性,但最后因为某些原因,譬如后续业务发生异常回滚了,最终没有成功写入到数据库,此时缓存的数据是新的,数据库中的数据是旧的。为了尽可能的提高使用缓存时的一致性,已经总结不少更新缓存可以遵循设计模式,譬如 Cache Aside、Read/Write Through、Write Behind Caching 等。其中最简单、成本最低的 Cache Aside 模式是指:

  • 读数据时,先读缓存,缓存没有的话,再读数据源,然后将数据放入缓存,再响应请求。
  • 写数据时,先写数据源,然后失效(而不是更新)掉缓存。

读数据方面一般没什么出错的余地,但是写数据时,就有必要专门强调两点:一是先后顺序是先数据源后缓存。试想一下,如果采用先失效缓存后写数据源的顺序,那一定存在一段时间缓存已经删除完毕,但数据源还未修改完成,此时新的查询请求到来,缓存未能命中,就会直接流到真实数据源中。这样请求读到的数据依然是旧数据,随后又重新回填到缓存中。当数据源的修改完成后,结果就成了数据在数据源中是新的,在缓存中是老的,两者就会有不一致的情况。

另一点是应当失效缓存,而不是去尝试更新缓存,这很容易理解,如果去更新缓存,更新过程中数据源又被其他请求再次修改的话,缓存又要面临处理多次赋值的复杂时序问题。所以直接失效缓存,等下次用到该数据时自动回填,期间无论数据源中的值被改了多少次都不会造成任何影响。

Cache Aside 模式依然是不能保证在一致性上绝对不出问题的,否则就无须设计出Paxos这样复杂的共识算法了。典型的出错场景是如果某个数据是从未被缓存过的,请求会直接流到真实数据源中,如果数据源中的写操作发生在查询请求之后,结果回填到缓存之前,也会出现缓存中回填的内容与数据库的实际数据不一致的情况。但这种情况的概率是很低的,Cache Aside 模式仍然是以低成本更新缓存,并且获得相对可靠结果的解决方案。

架构安全性

  • 认证(Authentication):系统如何正确分辨出操作用户的真实身份?
  • 授权( Authorization):系统如何控制一个用户该看到哪些数据、能操作哪些功能?
  • 凭证(Credential):系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
  • 保密(Confidentiality):系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
  • 传输(Transport Security):系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
  • 验证(Verification):系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险

这些问题基本上也都是与具体系统、具体业务无关的通用性问题,这意味着它们往往会存在着业界通行的、已被验证过是行之有效的解决方案,乃至已经形成行业标准,不需要开发者自己从头去构思如何解决

认证

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

“你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)

随 J2EE 1.2发布的 Servlet 2.2 中,添加了一系列用于认证的 API,主要包括下列两部分内容:

  • 标准方面,添加了四种内置的、不可扩展的认证方案,即 Client-Cert、Basic、Digest 和 Form。
  • 实现方面,添加了与认证和授权相关的一套程序接口,譬如HttpServletRequest::isUserInRole()HttpServletRequest::getUserPrincipal()等方法

它内置的 Basic、Digest、Form 和 Client-Cert 这四种认证方案都很有代表性,刚好分别覆盖了通信信道、协议和内容层面的认证。而这三种层面认证恰好涵盖了主流的三种认证方式,具体含义和应用场景列举如下。

  • 通信信道上的认证:你和我建立通信连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于 SSL/TLS 传输安全层的认证。
  • 通信协议上的认证:你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于 HTTP 协议的认证。
  • 通信内容上的认证:你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于 Web 内容的认证。
HTTP认证

认证方案(Authentication Schemes),它是指生成用户身份凭证的某种方法,这个概念最初源于 HTTP 协议的认证框架。要求所有支持 HTTP 协议的服务器,在未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized 的状态码,同时应在响应报文头里附带以下两个分别代表网页认证和代理认证的 Header 之一,告知客户端应该采取何种方式产生能代表访问者身份的凭证信息:

1
2
WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>

接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,由服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden 错误。请求头报文应包含以下 Header 项之一:

1
2
Authorization: <认证方案> <凭证内容>
Proxy-Authorization: <认证方案> <凭证内容>

image-20260130194952557

Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。譬如请求资源GET /admin后,浏览器会收到来自服务端的如下响应:

1
2
3
HTTP/1.1 401 Unauthorized
Date: Mon, 24 Feb 2020 16:50:53 GMT
WWW-Authenticate: Basic realm="example from icyfenix.cn"

除 Basic 认证外,IETF 还定义了很多种可用于实际生产环境的认证方案,列举如下。

  • DigestRFC 7616,HTTP 摘要认证,可视为 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后再通过 MD5/SHA 等哈希算法取摘要发送出去。但是这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了 Nonce 这样的动态盐值去抵御重放和冒认,遇到中间人攻击时依然存在显著的安全风险。
  • BearerRFC 6750,基于 OAuth 2 规范来完成认证,OAuth2 是一个同时涉及认证与授权的协议,
  • HOBARFC 7486 ,HOBA(HTTP Origin-Bound Authentication)是一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型:一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificate)自签名证书模型。
Web认证

以 HTTP 协议为基础的认证框架也只能面向传输协议而不是具体传输内容来设计,如果用户想要从服务器中下载文件,弹出一个 HTTP 服务器的对话框,让用户登录是可接受的;但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由 HTTP 服务器来负责认证。这种依靠内容而不是传输协议来实现的认证方式,在万维网里被称为“Web 认证”,由于实现形式上登录表单占了绝对的主流,因此通常也被称为“表单认证”(Form Authentication)

授权

日常开发中最常用到的 RBAC 和 OAuth2 这两种访问控制和授权方案

RBAC

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

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

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

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

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

OAuth2

OAuth2 是面向于解决第三方应用(Third-Party Application)的认证授权协议。OAuth2 给出了多种解决办法,这些办法的共同特征是以令牌(Token)代替用户密码作为授权的凭证。有了令牌之后,哪怕令牌被泄漏,也不会导致密码的泄漏;令牌上可以设定访问资源的范围以及时效性;每个应用都持有独立的令牌,哪个失效都不会波及其他

image-20260130205237718

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

“用令牌代替密码”确实是解决问题的好方法,但这充其量只能算个思路,距离可实施的步骤还是不够具体的,时序图中的“要求/同意授权”、“要求/同意发放令牌”、“要求/同意开放资源”几个服务请求、响应该如何设计,这就是执行步骤的关键了。对此,OAuth2 一共提出了四种不同的授权方式(这也是 OAuth2 复杂烦琐的主要原因),分别为:

  • 授权码模式(Authorization Code)
  • 隐式授权模式(Implicit)
  • 密码模式(Resource Owner Password Credentials)
  • 客户端模式(Client Credentials)

凭证

以 HTTP 协议的 Cookie-Session 机制为代表的服务端状态存储在三十年来都是主流的解决方案。不过,到了最近十年,由于分布式系统中共享数据必然会受到 CAP 不兼容原理的打击限制,迫使人们重新去审视之前已基本放弃掉的客户端状态存储,这就让原本通常只在多方系统中采用的 JWT 令牌方案,在分布式系统中也有了另一块用武之地。

HTTP 协议是一种无状态的传输协议,无状态是指协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的,但是我们中肯定有许多人并没有意识到 HTTP 协议无状态的重要性。

HTTP 协议的无状态特性又有悖于我们最常见的网络应用场景,典型就是认证授权,系统总得要获知用户身份才能提供合适的服务,因此,我们也希望 HTTP 能有一种手段,让服务器至少有办法能够区分出发送请求的用户是谁。为了实现这个目的,)规范定义了 HTTP 的状态管理机制,在 HTTP 协议中增加了 Set-Cookie 指令,该指令的含义是以键值对的方式向客户端发送一组信息,此信息将在此后一段时间内的每次 HTTP 请求中,以名为 Cookie 的 Header 附带着重新发回给服务端,以便服务端区分来自不同客户端的请求。

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

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

Session-Cookie 在单节点的单体服务环境中是最合适的方案,但当需要水平扩展服务能力,要部署集群时就开始面临麻烦了,由于 Session 存储在服务器的内存中,当服务器水平拓展成多节点时,设计者必须在以下三种方案中选择其一:

  • 牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法,譬如根据用户 IP 或者 Session 来分配节点,每一个特定用户发出的所有请求都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
  • 牺牲集群的可用性(Availability),让各个节点之间采用复制式的 Session,每一个节点中的 Session 变动都会发送到组播地址的其他服务器上,这样某个节点崩溃了,不会中断对某个用户的服务,但 Session 之间组播复制的同步代价高昂,节点越多时,同步成本越高。
  • 牺牲集群的分区容忍性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不再能提供服务
JWT令牌

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

它最常见的使用方式是附在名为 Authorization 的 Header 发送给服务端.JWT 令牌是以 JSON 结构存储的,结构总体上可划分为三个部分,每个部分间用点号.分隔开。第一部分是令牌头(Header)

某种哈希算法前出现“HMAC”的前缀,这是指散列消息认证码(Hash-based Message Authentication Code,HMAC)。可以简单将它理解为一种带有密钥的哈希摘要算法,实现形式上通常是把密钥以加盐方式混入,与内容一起做哈希摘要。

HMAC 哈希与普通哈希算法的差别是普通的哈希算法通过 Hash 函数结果易变性保证了原有内容未被篡改,HMAC 不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。

令牌的第二部分是负载(Payload),这是令牌真正需要向服务端传递的信息。针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息,针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。JWT 的负载部分是可以完全自定义的,根据具体要解决的问题不同,设计自己所需要的信息,只是总容量不能太大,毕竟要受到 HTTP Header 大小的限制

令牌的第三部分是签名(Signature),签名的意思是:使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算.

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

JWT 默认的签名算法 HMAC SHA256 是一种带密钥的哈希摘要算法,加密与验证过程均只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。

在多方系统或者授权服务与资源服务分离的分布式应用中,通常会采用非对称加密算法来进行签名,这时候除了授权服务端持有的可以用于签名的私钥外,还会对其他服务器公开一个公钥.公钥不能用来签名,但是能被其他服务用于验证签名是否由私钥所签发的。这样其他服务器也能不依赖授权服务器、无须远程通信即可独立判断 JWT 令牌中的信息的真伪。

例如 Istio 服务网格,终端用户认证会由服务网格的基础设施参来完成,此时就改用了非对称加密的 RSA SHA256 算法来进行签名。

尽管大型系统中只使用 JWT 来维护上下文状态,服务端完全不持有状态是不太现实的,不过将热点的服务单独抽离出来做成无状态,仍是一种有效提升系统吞吐能力的架构技巧。但是,JWT 也并非没有缺点的完美方案,它存在着以下几个经常被提及的缺点:

  • 令牌难以主动失效:JWT 令牌一旦签发,理论上就和认证服务器再没有什么瓜葛了,在到期之前就会始终有效,除非服务器部署额外的逻辑去处理失效问题,这对某些管理功能的实现是很不利的。譬如一种颇为常见的需求是:要求一个用户只能在一台设备上登录,在 B 设备登录后,之前已经登录过的 A 设备就应该自动退出。如果采用 JWT,就必须设计一个“黑名单”的额外的逻辑,用来把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在 Session、Redis 或者数据库中,都会让服务退化成有状态服务,降低了 JWT 本身的价值,但黑名单在使用 JWT 时依然是很常见的做法,需要维护的黑名单一般是很小的状态量,许多场景中还是有存在价值的。
  • 相对更容易遭受重放攻击:首先说明 Cookie-Session 也是有重放攻击问题的,只是因为 Session 中的数据控制在服务端手上,应对重放攻击会相对主动一些。要在 JWT 层面解决重放攻击需要付出比较大的代价,无论是加入全局序列号(HTTPS 协议的思路)、Nonce 字符串(HTTP Digest 验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。真要处理重放攻击,建议的解决方案是在信道层次(譬如启用 HTTPS)上解决,而不提倡在服务层次(譬如在令牌或接口其他参数上增加额外逻辑)上解决。

重放攻击(Replay Attack) 是一种网络攻击手段,指攻击者截获了发送方发出的合法数据包,并在一段时间后重新发送给接收方,以此来欺骗接收方执行非授权的操作

  • 只能携带相当有限的数据:HTTP 协议并没有强制约束 Header 的最大长度,但是,各种服务器、浏览器都会有自己的约束,譬如 Tomcat 就要求 Header 最大不超过 8KB,而在 Nginx 中则默认为 4KB,因此在令牌中存储过多的数据不仅耗费传输带宽,还有额外的出错风险。
  • 必须考虑令牌在客户端如何存储:严谨地说,这个并不是 JWT 的问题而是系统设计的问题。如果授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化才是最理想的方案。但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登录的。这样的话,想想客户端该把令牌存放到哪里?Cookie?localStorage?Indexed DB?它们都有泄漏的可能,而令牌一旦泄漏,别人就可以冒充用户的身份做任何事情。
  • 无状态也不总是好的:这个其实不也是 JWT 的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态 JWT 的方案,做一个在线用户实时统计功能。

保密

保密是加密和解密的统称,是指以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密的方法,或者知晓解密的算法但缺少解密所需的必要信息,仍然无法了解数据的真实内容。

按照需要保密信息所处的环节不同,可以划分为“信息在客户端时的保密”、“信息在传输时的保密”和“信息在服务端时的保密”三类

客户端加密

为了保证信息不被黑客窃取而做客户端加密没有太多意义,对绝大多数的信息系统来说,启用 HTTPS 可以说是唯一的实际可行的方案。但为了保证密码不在服务端被滥用,在客户端就开始加密是很有意义的。大网站被拖库的事情层出不穷,密码明文被写入数据库、被输出到日志中之类的事情也屡见不鲜,做系统设计时就应该把明文密码这种东西当成是最烫手的山芋来看待,越早消灭掉越好

密码存储和验证

多数信息系统来说,只要配合一定的密码规则约束,譬如密码要求长度、特殊字符等,再配合 HTTPS 传输,已足防御大多数风险了。即使在用户采用了弱密码、客户端通信被监听、服务端被拖库、泄漏了存储的密文和盐值等问题同时发生,也能够最大限度避免用户明文密码被逆推出来

一种方式是在客户端做密码哈希/伪动态加盐+慢哈希,服务端再动态生成盐值混入客户端传来的哈希值再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中。

慢哈希函数是指这个函数执行时间是可以调节的哈希函数,通常是以控制调用次数来实现的。

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

登陆时,经过与注册相同的加密过程,向服务端传输加密后的结果。服务端,接受到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,对客户端传来的哈希值、服务端存储的盐值计算摘要结果最后进行对比。

传输

摘要、加密与签名

摘要也称之为数字摘要(Digital Digest)或数字指纹(Digital Fingerprint)。JWT 令牌中默认的签名信息是对令牌头、负载和密钥三者通过令牌头中指定的哈希算法(HMAC SHA256)计算出来的摘要值

理想哈希算法:

  • 易变性,这是指算法的输入端发生了任何一点细微变动,都会引发雪崩效应,使得输出端的结果产生极大的变化
  • 不可逆性,摘要的过程是单向的,不可能从摘要的结果中逆向还原出输入值来。

在一些场合中,摘要也会被借用来做加密(如保密中介绍的慢哈希 Bcrypt 算法)和签名(如 JWT 签名中的 HMAC SHA256 算法),但在严格意义上看,摘要与这两者是有本质的区别。

加密与摘要的本质区别在于加密是可逆的,逆过程就是解密。

根据加密与解密是否采用同一个密钥,现代密码学算法可分为对称加密算法和非对称加密两大类型,这两类算法各自有很明确的优劣势与应用场景。对称加密的缺点显而易见,加密和解密使用相同的密钥,为保证两两通信都采用独立的密钥,密钥数量就与成员数量的平方成正比,这必然面临密钥管理的难题。而更尴尬的难题是当通信双方原本就不存在安全的信道时,如何才能将一个只能让通信双方才能知道的密钥传输给对方

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

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

单靠非对称加密算法,既做不了加密也做不了签名。原因是不论是加密还是解密,非对称加密算法的计算复杂度都相当高,性能比对称加密要差上好几个数量级.

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

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

类型特点常见实现主要用途主要局限
哈希摘要不可逆,即不能解密,所以并不是加密算法,只是一些场景把它当作加密算法使用。 易变性,输入发生 1 Bit 变动,就可能导致输出结果 50%的内容发生改变。 无论输入长度多少,输出长度固定(2 的 N 次幂)。MD2/4/5/6、SHA0/1/256/512摘要无法解密
对称加密加密是指加密和解密是一样的密钥。 设计难度相对较小,执行速度相对较块。 加密明文长度不受限制。DES、AES、RC4、IDEA加密要解决如何把密钥安全地传递给解密者。
非对称加密加密和解密使用的是不同的密钥。 明文长度不能超过公钥长度。RSA、BCDSA、ElGamal签名、传递密钥性能与加密明文长度受限。

数字证书

网络中利用公开密钥基础设施,利用不通过网络,而是在浏览器与操作系统出厂时就预置好,或者提前安装好的根证书。

证书(Certificate),证书是权威 CA (Certificate Authority)中心对特定公钥信息的一种公证载体,也可以理解为是权威 CA 对特定公钥未被篡改的签名背书。由于客户的机器上已经预置了这些权威 CA 中心本身的证书(称为 CA 证书或者根证书),使得我们能够在不依靠网络的前提下,使用根证书里面的公钥信息对其所签发的证书中的签名进行确认。一个数字证书包含:版本号,序列号,签名算法,数字签名,认证机构,有效期,主题信息以及公钥。

传输安全层

TLS传输流程。TLS 1.2 在传输之前的握手过程一共需要进行上下两轮、共计四次通信,

客户端请求:Client Hello
客户端向服务器请求进行加密通信,在这个请求里面,它会以明文的形式,向服务端提供支持的协议版本,密码学算法以及一个随机数。这个随机数将稍后用于产生加密的密钥

服务器回应:Server Hello
服务器接收到客户端的通信请求后,如果客户端声明支持的协议版本和加密算法组合与服务端相匹配的话,就向客户端发出回应。如果不匹配,将会返回一个握手失败的警告提示。这次回应同样以明文发送的,包括第二个 32 Bytes 的随机数,稍后用于产生加密的密钥,服务端选定的密码学算法,证书(包括公钥)以及TLS版本等。

客户端确认:Client Handshake Finished
客户端收到服务器应答后,先要验证服务器的证书合法性。如果证书不是可信机构颁布的,或者证书中信息存在问题,譬如域名与实际域名不一致、或者证书已经过期、或通过在线证书状态协议得知证书已被吊销,等等,都会向访问者显示一个“证书不可信任”的警告,由用户自行选择是否还要继续通信。如果证书没有问题,客户端就会从证书中取出服务器的公钥,并向服务器发送第三个随机数(以服务端传过来的公钥加密的,它被称为 PreMasterSecret,将与前两次发送的随机数一起,根据特定算法计算出 48 Bytes 的 MasterSecret ,这个 MasterSecret 即为后续内容传输时的对称加密算法所采用的私钥

服务端确认:Server Handshake Finished
服务端向客户端回应最后的确认通知,随后的信息都将用双方商定的加密方法和密钥发送。

这种处理方式对上层协议的功能上完全透明的,在传输性能上会有下降,但在功能上完全不会感知到有 TLS 的存在。建立在这层安全传输层之上的 HTTP 协议,就被称为“HTTP over SSL/TLS”,也即 HTTPS。

阶段任务使用的算法类型常见算法握手阶段身份验证、密钥交换非对称加密RSA, ECDHE数据传输加密实际内容对称加密AES, ChaCha20完整性校验防止数据被篡改哈希算法SHA-256

阶段任务使用的算法类型常见算法
握手阶段身份验证、密钥交换非对称加密RSA, ECDHE
数据传输加密实际内容对称加密AES, ChaCha20
完整性校验防止数据被篡改哈希算法SHA-256

验证

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

最基础的数据问题可以在前端做表单校验来处理,但服务端验证肯定也是要做的,可能会有这样的答案:

  • 在 Controller 层做,在 Service 层不做。理由是从 Service 开始会有同级重用,出现 ServiceA.foo(params)调用 ServiceB.bar(params)时,就会对 params 重复校验了两次。
  • 在 Service 层做,在 Controller 层不做。理由是无业务含义的格式校验已在前端表单验证处理过,有业务含义的校验,放在 Controller 层无论如何不合适。
  • 在 Controller、Service 层各做各的。Controller 做格式校验,Service 层做业务校验,听起来很合理,但这其实就是上面段子中被嘲笑的行为。
  • 还有其他一些意见,譬如还有提在持久层做校验,理由是这是最终入口,把守好写入数据库的质量最重要。

把校验行为从分层中剥离出来,不是在哪一层做,而是在 Bean 上做。即 Java Bean Validation。

对校验项预置好默认的提示信息,这样当校验不通过时用户能获得明确的修正提示.

将不带业务含义的格式校验注解放到 Bean 的类定义之上,将带业务逻辑的校验放到 Bean 的类定义的外面。这两者的区别是放在类定义中的注解能够自动运行,而放到类外面则需要像前面代码那样,明确标出注解时才会运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. Bean 类:只管格式
public class UserDTO {
@NotBlank(message = "用户名不能为空") // 格式校验
private String username;
}

// 2. Service 层:管业务逻辑
@Service
public class UserService {
public void register(UserDTO dto) {
// 业务校验:放在外面
if (userRepository.existsByUsername(dto.getUsername())) {
throw new BusinessException("用户名已占用");
}
// 执行注册...
}
}

分布式共识算法

在软件系统里,要保障系统的可靠性,采用的办法与那几个备份硬盘并没有什么区别。单个节点的系统宕机无法访问数据的原因可能有很多,譬如程序出错、硬件损坏、网络分区、电源故障,等等,一年中出现系统宕机的概率也许还要高于 5%,这决定了软件系统也必须有多台机器能够拥有一致的数据副本,才有可能对外提供可靠的服务。

在软件系统里,要保障系统的可用性,面临的困难与硬盘备份却又有着本质的区别。硬盘之间是孤立的,不需要互相通信,备份数据是静态的,初始化后状态就不会发生改变,由人工进行的文件复制操作,很容易就保障了数据在各个备份盘中是一致的;然而分布式系统里面,我们必须考虑动态的数据如何在不可靠的网络通信条件下,依然能在各个节点之间正确复制的问题。

以同步为代表的数据复制方法,被称为状态转移(State Transfer),这类方法是较符合人类思维的可靠性保障手段,但通常要以牺牲可用性为代价。我们在建设分布式系统的时候,往往不能承受这样的代价,一些关键系统,必须保障数据正确可靠的前提下,对可用性的要求也非常苛刻,譬如系统要保证数据要达到 99.999999%可靠,同时系统也要达到 99.999%可用的程度

可靠性与可用性的矛盾造成了增加机器数量反而带来可用性的降低,为缓解这个矛盾,在分布式系统里主流的数据复制方法是以操作转移(Operation Transfer)为基础的。我们想要改变数据的状态,除了直接将目标状态赋予它之外,还有另一种常用的方法是通过某种操作,令源状态转换为目标状态。能够使用确定的操作,促使状态间产生确定的转移结果的计算模型,在计算机科学中被称为状态机(State Machine)。

根据状态机的特性,要让多台机器的最终状态一致,只要确保它们的初始状态是一致的,并且接收到的操作指令序列也是一致的即可,无论这个操作指令是新增、修改、删除抑或是其他任何可能的程序行为,都可以理解为要将一连串的操作日志正确地广播给各个分布式节点。广播指令与指令执行期间,允许系统内部状态存在不一致的情况,即并不要求所有节点的每一条指令都是同时开始、同步完成的,只要求在此期间的内部状态不能被外部观察到,且当操作指令序列执行完毕时,所有节点的最终的状态是一致的,这种模型就被称为状态机复制(State Machine Replication)

Paxos算法

Paxos 算法将分布式系统中的节点分为三类:

  • 提案节点:称为 Proposer,提出对某个值进行设置操作的节点,设置值这个行为就被称之为提案(Proposal),值一旦设置成功,就是不会丢失也不可变的。请注意,Paxos 是典型的基于操作转移模型而非状态转移模型来设计的算法,这里的“设置值”不要类比成程序中变量赋值操作,应该类比成日志记录操作,在后面介绍的 Raft 算法中就直接把“提案”叫作“日志”了。
  • 决策节点:称为 Acceptor,是应答提案的节点,决定该提案是否可被投票、是否可被接受。提案一旦得到过半数决策节点的接受,即称该提案被批准(Accept),提案被批准即意味着该值不能再被更改,也不会丢失,且最终所有节点都会接受该它。
  • 记录节点:被称为 Learner,不参与提案,也不参与决策,只是单纯地从提案、决策节点中学习已经达成共识的提案,譬如少数派节点从网络分区中恢复时,将会进入这种状态。

使用 Paxos 算法的分布式系统里的,所有的节点都是平等的,它们都可以承担以上某一种或者多种的角色,不过为了便于确保有明确的多数派,决策节点的数量应该被设定为奇数个,且在系统初始化时,网络中每个节点都知道整个网络所有决策节点的数量、地址等信息.

在分布式环境下,如果我们说各个节点“就某个值(提案)达成一致”,指的是“不存在某个时刻有一个值为 A,另一个时刻又为 B 的情景”。解决这个问题的复杂度主要来源于以下两个方面因素的共同影响:

  • 系统内部各个节点通信是不可靠的,不论对于系统中企图设置数据的提案节点抑或决定是否批准设置操作的决策节点,其发出、收到的信息可能延迟送达、也可能会丢失,但不去考虑消息有传递错误的情况。
  • 系统外部各个用户访问是可并发的,如果系统只会有一个用户,或者每次只对系统进行串行访问,那单纯地应用 Quorum 机制,少数节点服从多数节点,就已经足以保证值被正确地读写。

分布式环境中的锁必须是可抢占的。Paxos 算法包括两个阶段,其中,第一阶段“准备”(Prepare)就相当于上面抢占锁的过程。如果某个提案节点准备发起提案,必须先向所有的决策节点广播一个许可申请

Basic Paxos 只能对单个值形成决议,并且决议的形成至少需要两次网络请求和应答(准备和批准阶段各一次),高并发情况下将产生较大的网络开销,极端情况下甚至可能形成活锁.实际的应用都是基于 Multi Paxos 和 Fast Paxos 算法的

image-20260131213344942

Gossip协议

Gossip 相比 Paxos、Raft 等算法,Gossip 的过程十分简单,它可以看作是以下两个步骤的简单循环:

  • 如果有某一项信息需要在整个网络中所有节点中传播,那从信息源开始,选择一个固定的传播周期(譬如 1 秒),随机选择它相连接的 k 个节点(称为 Fan-Out)来传播消息。
  • 每一个节点收到消息后,如果这个消息是它之前没有收到过的,将在下一个周期内,选择除了发送消息给它的那个节点外的其他相邻 k 个节点发送相同的消息,直到最终网络中所有节点都收到了消息,尽管这个过程需要一定时间,但是理论上最终网络的所有节点都会拥有相同的消息。

消息最终是通过多个轮次的散播而到达全网的,因此它必然会存在全网各节点状态不一致的情况,而且由于是随机选取发送消息的节点,所以尽管可以在整体上测算出统计学意义上的传播速率,但对于个体消息来说,无法准确地预计到需要多长时间才能达成全网一致。另外一个缺点是消息的冗余,同样是由于随机选取发送消息的节点,也就不可避免的存在消息重复发送给同一节点的情况,增加了网络的传输的压力,也给消息节点带来额外的处理负载。

从类库到服务

采用服务来构建程序,获得的收益是软件系统“整体”与“部分”在物理层面的真正隔离,这对构筑可靠的大型软件系统来说无比珍贵,但另一面,其付出的代价也同样无可忽视,微服务架构在复杂性与执行性能方面做出了极大的让步。一套由多个微服务相互调用才能正常运作的分布式系统中,每个节点都互相扮演着服务的生产者与消费者的多重角色,形成了一套复杂的网状调用关系,此时,至少有(但不限于)以下三个问题是必须考虑并得到妥善解决的:

  • 对消费者来说,外部的服务由谁提供?具体在什么网络位置? 服务发现
  • 对生产者来说,内部哪些服务需要暴露?哪些应当隐藏?应当以何种形式暴露服务?以什么规则在集群中分配请求? 服务的网关路由
  • 对调用过程来说,如何保证每个远程服务都接收到相对平均的流量,获得尽可能高的服务质量与可靠性?服务的负载均衡

服务发现

所有的远程服务调用都是使用全限定名端口号服务标识所构成的三元组来确定一个远程服务的精确坐标的。

全限定名代表了网络中某台主机的精确位置,端口代表了主机上某一个提供了 TCP/UDP 网络服务的程序,服务标识则代表了该程序所提供的某个具体的方法入口。其中“全限定名、端口号”的含义对所有的远程服务来说都一致,而“服务标识”则与具体的应用层协议相关,不同协议具有不同形式的标识,譬如 REST 的远程服务,标识是 URL 地址;RMI 的远程服务,标识是 Stub 类中的方法;SOAP 的远程服务,标识是 WSDL 中定义方法

服务发现原本依赖DNS,但随着微服务的逐渐流行,服务的非正常宕机、重启和正常的上线、下线变得越发频繁,仅靠着 DNS 服务器和负载均衡器等基础设施就显得逐渐疲于应对,一开始使用Zookeeper这样的分布式 K/V 框架,再到后来使用Eureka,Nacos等框架。而云原生时代来临,基础设施的灵活性得到大幅度的增强,最初的使用基础设施来透明化地做服务发现的方式又重新被人们所重视,如何在基础设施和网络协议层面,对应用尽可能无感知、方便地实现服务发现是目前服务发现的一个主要发展方向

可用和可靠

服务注册:当服务启动的时候,它应该通过某些形式(如调用 API、产生事件消息、在 ZooKeeper/Etcd 的指定位置记录、存入数据库,等等)将自己的坐标信息通知到服务注册中心,这个过程可能由应用程序本身来完成,称为自注册模式,譬如 Spring Cloud 的@EnableEurekaClient 注解;也可能由容器编排框架或第三方注册工具来完成,称为第三方注册模式,譬如 Kubernetes 和 Registrator。

服务维护:服务发现框架必须要自己去保证所维护的服务列表的正确性,以避免告知消费者服务的坐标后,得到的服务却不能使用的尴尬情况.服务发现框架,往往都能支持多种协议(HTTP、TCP 等)、多种方式(长连接、心跳、探针、进程状态等)去监控服务是否健康存活,将不健康的服务自动从服务注册表中剔除。

服务发现:这里的发现是特指狭义上消费者从服务发现框架中,把一个符号(譬如 Eureka 中的 ServiceID、Nacos 中的服务名、或者通用的 FQDN)转换为服务实际坐标的过程,这个过程现在一般是通过 HTTP API 请求或者通过 DNS Lookup 操作来完成,也还有一些相对少用的方式,譬如 Kubernetes 也支持注入环境变量来做服务发现。

实际用于生产的分布式系统,服务注册中心都是以集群的方式进行部署的,通常使用三个或者五个节点(通常最多七个,一般也不会更多了,否则日志复制的开销太高)来保证高可用

image-20260131223832253

集群下的服务注册中心也面临着CAP问题,我们洗完后服务注册中心一直可用永远健康的同时,也能够在访问每一个节点中都能取到可靠一致的数据,而不是从注册中心拿到的服务地址可能已经下线,这两个需求就构成了 CAP 矛盾。

Eureka 的选择是优先保证高可用性,相对牺牲系统中服务状态的一致性。Eureka 的各个节点间采用异步复制来交换服务注册信息,当有新服务注册进来时,并不需要等待信息在其他节点复制完成,而是马上在该服务发现节点宣告服务可见,只是不保证在其他节点上多长时间后才会可见。同时,当有旧的服务发生变动,譬如下线或者断网,只会由超时机制来控制何时从哪一个服务注册表中移除,变动信息不会实时的同步给所有服务端与客户端。这样的设计使得不论是 Eureka 的服务端还是客户端,都能够持有自己的服务注册表缓存,并以 TTL(Time to Live)机制来进行更新,哪怕服务注册中心完全崩溃,客户端在仍然可以维持最低限度的可用。Eureka 的服务发现模型对节点关系相对固定,服务一般不会频繁上下线的系统是很合适的,以较小的同步代价换取了最高的可用性;Eureka 能够选择这种模型的底气在于万一客户端拿到了已经发生变动的错误地址,也能够通过 Ribbon 和 Hystrix 模块配合来兜底,实现故障转移(Failover)或者快速失败(Failfast)。

Consul 的选择是优先保证高可靠性,相对牺牲系统服务发现的可用性。Consul 采用Raft算法,要求多数派节点写入成功后服务的注册或变动才算完成,严格地保证了在集群外部读取到的服务发现结果必定是一致的;同时采用 Gossip 协议,支持多数据中心之间更大规模的服务同步。

注册中心实现

在分布式 K/V 存储框架上自己开发的服务发现,这类的代表是 ZooKeeper、Doozerd、Etcd。

K/V 框架提供了分布式环境下读写操作的共识算法,Etcd 采用的是 Raft 算法,ZooKeeper 采用的是 ZAB 算法,这也是一种 Multi Paxos 的派生算法,所以采用这种方案,都是 CP 的

以基础设施(主要是指 DNS 服务器)来实现服务发现,这类的代表是 SkyDNS、CoreDNS。如果是基于 Etcd 实现的,那自然是 CP 的,如果是基于内存异步复制的方案实现的,那就是 AP 的

专门用于服务发现的框架和工具,这类的代表是 Eureka、Consul 和 Nacos。可以自己决定是 CP 还是 AP 的问题,譬如 CP 的 Consul、AP 的 Eureka,还有同时支持 CP 和 AP 的 Nacos

网关路由

微服务中网关的首要职责就是作为统一的出口对外提供服务,将外部访问网关地址的流量,根据适当的规则路由到内部集群中正确的服务节点之上,因此,微服务中的网关,也常被称为“服务网关”或者“API 网关”,微服务中的网关首先应该是个路由器,在满足此前提的基础上,网关还可以根据需要作为流量过滤器来使用,提供某些额外的可选的功能,譬如安全、认证、授权、限流、监控、缓存

网关 = 路由器(基础职能) + 过滤器(可选职能)

从技术实现角度来看,对于路由这项工作,负载均衡器与服务网关在实现上是没有什么差别的,很多服务网关本身就是基于老牌的负载均衡器来实现的,譬如基于 Nginx、HAProxy 开发的 Ingress Controller,基于 Netty 开发的 Zuul 2.0 等;但从目的角度看,负载均衡器与服务网关会有一些区别,具体在于前者是为了根据均衡算法对流量进行平均地路由,后者是为了根据流量中的某种特征进行正确地路由。网关必须能够识别流量中的特征,这意味着网关能够支持的网络通信协议的层次将会直接限制后端服务节点能够选择的服务通信方式。如果服务集群只提供像 Etcd 这样直接基于 TCP 的访问的服务,那只部署四层网关便可满足,网关以 IP 报文中源地址、目标地址为特征进行路由;如果服务集群要提供 HTTP 服务的话,那就必须部署一个七层网关,网关根据 HTTP 报文中的 URL、Header 等信息为特征进行路由;如果服务集群还要提供更上层的 WebSocket、SOAP 等服务,那就必须要求网关同样能够支持这些上层协议,才能从中提取到特征。

网关的另一个主要关注点是它的性能与可用性。由于网关是所有服务对外的总出口,是流量必经之地,所以网关的路由性能将导致全局的、系统性的影响,如果经过网关路由会有 1 毫秒的性能损失,就意味着整个系统所有服务的响应延迟都会增加 1 毫秒。因为今天 REST 和 JSON-RPC 等基于 HTTP 协议的服务接口在对外部提供的服务中占绝对主流的地位,所以我们所讨论的服务网关默认都必须支持七层路由,通常就默认无法直接进行流量转发,只能采用代理模式。在这个前提约束下,网关的性能主要取决于它们如何代理网络请求,也即它们的网络 I/O 模型

网络IO模型

在套接字接口抽象下,网络 I/O 的出入口就是 Socket 的读和写,Socket 在操作系统接口中被抽象为数据流,网络 I/O 可以理解为对流的操作。每一次网络访问,从远程主机返回的数据会先存放到操作系统内核的缓冲区中,然后内核的缓冲区复制到应用程序的地址空间,所以当发生一次网络请求发生后,将会按顺序经历“等待数据从远程主机到达缓冲区”和“将数据从缓冲区拷贝到应用程序地址空间”两个阶段,根据实现这两个阶段的不同方法,人们把网络 I/O 模型总结为两类、五种模型:两类是指同步 I/O异步 I/O,五种是指在同步 IO 中又分有划分出阻塞 I/O非阻塞 I/O多路复用 I/O信号驱动 I/O四种细分模型。

同步是指调用端发出请求之后,得到结果之前必须一直等待,与之相对的就是异步,发出调用请求之后将立即返回,不会马上得到处理结果,结果将通过状态变化和回调来通知调用者。

阻塞和非阻塞是针对请求处理过程,指收到调用请求之后,返回结果之前,当前处理线程是否会被挂起

  • 异步 I/O(Asynchronous I/O):好比你在美团外卖订了个盒饭,付款之后你自己该干嘛还干嘛去,饭做好了骑手自然会到门口打电话通知你。异步 I/O 中数据到达缓冲区后,不需要由调用进程主动进行从缓冲区复制数据的操作,而是复制完成后由操作系统向线程发送信号,所以它一定是非阻塞的。

  • 同步 I/O

    (Synchronous I/O):好比你自己去饭堂打饭,这时可能有如下情形发生:

    • 阻塞 I/O(Blocking I/O):你去到饭堂,发现饭还没做好,你也干不了别的,只能打个瞌睡(线程休眠),直到饭做好,这就是被阻塞了。阻塞 I/O 是最直观的 I/O 模型,逻辑清晰,也比较节省 CPU 资源,但缺点就是线程休眠所带来的上下文切换,这是一种需要切换到内核态的重负载操作,不应当频繁进行。
    • 非阻塞 I/O(Non-Blocking I/O):你去到饭堂,发现饭还没做好,你就回去了,然后每隔 3 分钟来一次饭堂看饭做好了没,直到饭做好。非阻塞 I/O 能够避免线程休眠,对于一些很快就能返回结果的请求,非阻塞 I/O 可以节省切换上下文切换的消耗,但是对于较长时间才能返回的请求,非阻塞 I/O 反而白白浪费了 CPU 资源,所以目前并不常用。
    • 多路复用 I/O(Multiplexing I/O):多路复用 I/O 本质上是阻塞 I/O 的一种,但是它的好处是可以在同一条阻塞线程上处理多个不同端口的监听。类比的情景是你名字叫雷锋,代表整个宿舍去饭堂打饭,去到饭堂,发现饭还没做好,还是继续打瞌睡,但哪个舍友的饭好了,你就马上把那份饭送回去,然后继续打着瞌睡哼着歌等待其他的饭做好。多路复用 I/O 是目前的高并发网络应用的主流,它下面还可以细分 select、epoll、kqueue 等不同实现,这里就不作展开了。
    • 信号驱动 I/O(Signal-Driven I/O):你去到饭堂,发现饭还没做好,但你跟厨师熟,跟他说饭做好了叫你,然后回去该干嘛干嘛,等收到厨师通知后,你把饭从饭堂拿回宿舍。这里厨师的通知就是那个“信号”,信号驱动 I/O 与异步 I/O 的区别是“从缓冲区获取数据”这个步骤的处理,前者收到的通知是可以开始进行复制操作了,即要你自己从饭堂拿回宿舍,在复制完成之前线程处于阻塞状态,所以它仍属于同步 I/O 操作,而后者收到的通知是复制操作已经完成,即外卖小哥已经把饭送到了

异步 I/O 模型是最方便的,但异步 I/O 受限于操作系统,Windows NT 内核早在 3.5 以后,就通过IOCP实现了真正的异步 I/O 模型。而 Linux 系统下,是在 Linux Kernel 2.6 才首次引入,目前也还并不算很完善,因此在 Linux 下实现高并发网络编程时仍是以多路复用 I/O 模型模式为主。

由于网关的地址具有唯一性,就不像之前服务发现那些注册中心那样直接做个集群,随便访问哪一台都可以解决问题。为此,对网关的可用性方面,我们应该考虑到以下几点:

  • 网关应尽可能轻量,尽管网关作为服务集群统一的出入口,可以很方便地做安全、认证、授权、限流、监控,等等的功能,但给网关附加这些能力时还是要仔细权衡,取得功能性与可用性之间的平衡,过度增加网关的职责是危险的。
  • 网关选型时,应该尽可能选择较成熟的产品实现,譬如 Nginx Ingress Controller、KONG、Zuul 这些经受过长期考验的产品,而不能一味只考虑性能选择最新的产品,性能与可用性之间的平衡也需要权衡。
  • 在需要高可用的生产环境中,应当考虑在网关之前部署负载均衡器或者等价路由器(ECMP),让那些更成熟健壮的设施(往往是硬件物理设备)去充当整个系统的入口地址,这样网关也可以进行扩展了

客户端负载均衡

随着微服务日渐流行,服务集群的收到的请求来源不再局限于外部,越来越多的访问请求是由集群内部的某个服务发起,由集群内部的另一个服务进行响应的,对于这类流量的负载均衡,既有的方案依然是可行的,但针对内部流量的特点,直接在服务集群内部消化掉,肯定是更合理更受开发者青睐的办法。由此客户端负载均衡器流行起来。

客户端均衡器是和服务实例一一对应的,而且与服务实例并存于同一个进程之内。这个特点能为它带来很多好处,如:

  • 均衡器与服务之间信息交换是进程内的方法调用,不存在任何额外的网络开销。
  • 不依赖集群边缘的设施,所有内部流量都仅在服务集群的内部循环,避免了出现前文那样,集群内部流量要“绕场一周”的尴尬局面。
  • 分散式的均衡器意味着天然避免了集中式的单点问题,它的带宽资源将不会像集中式均衡器那样敏感,这在以七层均衡器为主流、不能通过 IP 隧道和三角传输这样方式节省带宽的微服务环境中显得更具优势。

image-20260201120640854

客户端均衡器也不是银弹,它得到上述诸多好处的同时,缺点同样也是不少的:

  • 它与服务运行于同一个进程之内,意味着它的选型受到服务所使用的编程语言的限制,譬如用 Golang 开发的微服务就不太可能搭配 Spring Cloud Load Balancer 来使用,要为每种语言都实现对应的能够支持复杂网络情况的均衡器是非常难的。客户端均衡器的这个缺陷有违于微服务中技术异构不应受到限制的原则。
  • 从个体服务来看,由于是共用一个进程,均衡器的稳定性会直接影响整个服务进程的稳定性,消耗的 CPU、内存等资源也同样影响到服务的可用资源。从集群整体来看,在服务数量达成千乃至上万规模时,客户端均衡器消耗的资源总量是相当可观的。

代理负载均衡器

客户端均衡器中最具代表性的产品是 Netflix Ribbon 和 Spring Cloud Load Balancer,随着微服务的流行,它们在 Java 微服务中已积聚了相当可观的使用者。直到最近两三年,服务网格(Service Mesh)开始逐渐盛行,另外一种被称为“代理客户端负载均衡器”(Proxy Client-Side Load Balancer,后文简称“代理均衡器”)的客户端均衡器变体形式开始引起不同编程语言的微服务开发者共同关注,它解决了此前客户端均衡器的大多数缺陷。代理均衡器对此前的客户端负载均衡器的改进是将原本嵌入在服务进程中的均衡器提取出来,作为一个进程之外,同一 Pod 之内的特殊服务,放到边车代理中去实现。

image-20260201121001909

虽然代理均衡器与服务实例不再是进程内通信,而是通过网络协议栈进行数据交换的,数据要经过操作系统的协议栈,要进行打包拆包、计算校验和、维护序列号等网络数据的收发步骤,流量比起之前的客户端均衡器确实多增加了一系列处理步骤。不过,Kubernetes 严格保证了同一个 Pod 中的容器不会跨越不同的节点,这些容器共享着同一个网络名称空间,因此代理均衡器与服务实例的交互,实质上是对本机回环设备的访问,仍然要比真正的网络交互高效且稳定得多。

  • 代理均衡器不再受编程语言的限制。发展一个支持 Java、Golang、Python 等所有微服务应用服务的通用的代理均衡器具有很高的性价比。集中不同编程语言的使用者的力量,更容易打造出能面对复杂网络情况的、高效健壮的均衡器。即使退一步说,独立于服务进程的均衡器也不会由于自身的稳定性影响到服务进程的稳定。
  • 在服务拓扑感知方面代理均衡器也要更有优势。由于边车代理接受控制平面的统一管理,当服务节点拓扑关系发生变化时,控制平面就会主动向边车代理发送更新服务清单的控制指令,这避免了此前客户端均衡器必须长期主动轮询服务注册中心所造成的浪费。

边车代理这种通过同一个 Pod 的独立容器实现的负载均衡器是目前处理微服务集群内部流量最理想的方式

负载均衡中的地域和区域

  • Region 是地域的意思,譬如华北、东北、华东、华南,这些都是地域范围。面向全球或全国的大型系统的服务集群往往会部署在多个不同地域,譬如本节开头列举的案例场景,大型系统就是通过不同地域的机房来缩短用户与服务器之间的物理距离,提升响应速度,对于小型系统,地域一般就只在异地容灾时才会涉及到。需要注意,不同地域之间是没有内网连接的,所有流量都只能经过公众互联网相连如果微服务的流量跨越了地域,实际就跟调用外部服务商提供的互联网服务没有任何差别了。所以集群内部流量是不会跨地域的,服务发现、负载均衡器默认也是不会支持跨地域的服务发现和负载均衡
  • Zone 是区域的意思,它是可用区域(Availability Zones)的简称,区域指在地理上位于同一地域内,但电力和网络是互相独立的物理区域,譬如在华东的上海、杭州、苏州的不同机房就是同一个地域的几个可用区域。同一个地域的可用区域之间具有内网连接,流量不占用公网带宽,因此区域是微服务集群内流量能够触及的最大范围。但你的应用是只部署在同一区域内,还是部署到几个不同可用区域中,要取决于你是否有做异地双活的需求,以及对网络延时的容忍程度。

  • 地域和区域原本是云计算中的概念,对于一些中小型的微服务系统,尤其是非互联网的企业信息系统,很多仍然没有使用云计算设施,只部署在某个专有机房内部,只为特定人群提供服务,这就不需要涉及地理上地域、区域的概念了。此时完全可以自己灵活延拓 Region、Zone 参数的含义,达到优化虚拟化基础设施流量的目的。譬如,将服务发现的区域设置与 Kubernetes 的标签、选择器配合,实现内部服务请求其他服务时,优先使用同一个 Node 中提供的服务进行应答,以降低真实的网络消耗。

流量治理

随着拆分出的服务越来越多,随之而来会面临以下两个问题的困扰:

  • 由于某一个服务的崩溃,导致所有用到这个服务的其他服务都无法正常工作,一个点的错误经过层层传递,最终波及到调用链上与此有关的所有服务,这便是雪崩效应。如何防止雪崩效应便是微服务架构容错性设计原则的具体实践,否则服务化程度越高,整个系统反而越不稳定。
  • 服务虽然没有崩溃,但由于处理能力有限,面临超过预期的突发请求时,大部分请求直至超时都无法完成处理。这种现象产生的后果跟交通堵塞是类似的,如果一开始没有得到及时的治理,后面就需要长时间才能使全部服务都恢复正常。

服务容错

容错性设计不能妥协源于分布式系统的本质是不可靠的,一个大的服务集群中,程序可能崩溃、节点可能宕机、网络可能中断,这些“意外情况”其实全部都在“意料之中”。原本信息系统设计成分布式架构的主要动力之一就是为了提升系统的可用性.

常见的容错策略有以下几种:

  • 故障转移(Failover):高可用的服务集群中,多数的服务——尤其是那些经常被其他服务所依赖的关键路径上的服务,均会部署有多个副本。这些副本可能部署在不同的节点(避免节点宕机)、不同的网络交换机(避免网络分区)甚至是不同的可用区(避免整个地区发生灾害或电力、骨干网故障)中。故障转移是指如果调用的服务器出现故障,系统不会立即向调用者返回失败结果,而是自动切换到其他服务副本,尝试其他副本能否返回成功调用的结果,从而保证了整体的高可用性。
    故障转移的容错策略应该有一定的调用次数限制,譬如允许最多重试三个服务,如果都发生报错,那还是会返回调用失败。原因不仅是因为重试是有执行成本的,更是因为过度的重试反而可能让系统处于更加不利的状况。譬如有以下调用链:

    Service A → Service B → Service C

    假设 A 的超时阈值为 100 毫秒,而 B 调用 C 花费 60 毫秒,然后不幸失败了,这时候做故障转移其实已经没有太大意义了,因为即时下一次调用能够返回正确结果,也很可能同样需要耗费 60 毫秒时间,时间总和就已经触及 A 服务的超时阈值,所以在这种情况下故障转移反而对系统是不利的。

  • 快速失败(Failfast):还有另外一些业务场景是不允许做故障转移的,故障转移策略能够实施的前提是要求服务具备幂等性,对于非幂等的服务,重复调用就可能产生脏数据,引起的麻烦远大于单纯的某次服务调用失败,此时就应该以快速失败作为首选的容错策略。譬如,在支付场景中,需要调用银行的扣款接口,如果该接口返回的结果是网络异常,程序是很难判断到底是扣款指令发送给银行时出现的网络异常,还是银行扣款后返回结果给服务时出现的网络异常的。为了避免重复扣款,此时最恰当可行的方案就是尽快让服务报错,坚决避免重试,尽快抛出异常,由调用者自行处理。

  • 安全失败(Failsafe):在一个调用链路中的服务通常也有主路和旁路之分,并不见得其中每个服务都是不可或缺的,有部分服务失败了也不影响核心业务的正确性。譬如开发基于 Spring 管理的应用程序时,通过扩展点、事件或者 AOP 注入的逻辑往往就属于旁路逻辑,典型的有审计、日志、调试信息,等等。属于旁路逻辑的另一个显著特征是后续处理不会依赖其返回值,或者它的返回值是什么都不会影响后续处理的结果,譬如只是将返回值记录到数据库,并不使用它参与最终结果的运算。对这类逻辑,一种理想的容错策略是即使旁路逻辑调用实际失败了,也当作正确来返回,如果需要返回值的话,系统就自动返回一个符合要求的数据类型的对应零值,然后自动记录一条服务调用出错的日志备查即可,这种策略被称为安全失败。

  • 沉默失败(Failsilent):如果大量的请求需要等到超时(或者长时间处理后)才宣告失败,很容易由于某个远程服务的请求堆积而消耗大量的线程、内存、网络等资源,进而影响到整个系统的稳定。面对这种情况,一种合理的失败策略是当请求失败后,就默认服务提供者一定时间内无法再对外提供服务,不再向它分配请求流量,将错误隔离开来,避免对系统其他部分产生影响,此即为沉默失败策略。

  • 故障恢复(Failback):故障恢复一般不单独存在,而是作为其他容错策略的补充措施,一般在微服务管理框架中,如果设置容错策略为故障恢复的话,通常默认会采用快速失败加上故障恢复的策略组合。它是指当服务调用出错了以后,将该次调用失败的信息存入一个消息队列中,然后由系统自动开始异步重试调用。
    故障恢复策略一方面是尽力促使失败的调用最终能够被正常执行,另一方面也可以为服务注册中心和负载均衡器及时提供服务恢复的通知信息。故障恢复显然也是要求服务必须具备幂等性的,由于它的重试是后台异步进行,即使最后调用成功了,原来的请求也早已经响应完毕,所以故障恢复策略一般用于对实时性要求不高的主路逻辑,同时也适合处理那些不需要返回值的旁路逻辑。为了避免在内存中异步调用任务堆积,故障恢复与故障转移一样,应该有最大重试次数的限制。

  • 并行调用(Forking):上面五种以“Fail”开头的策略是针对调用失败时如何进行弥补的,以下这两种策略则是在调用之前就开始考虑如何获得最大的成功概率。并行调用策略很符合人们日常对一些重要环节进行的“双重保险”或者“多重保险”的处理思路,它是指一开始就同时向多个服务副本发起调用,只要有其中任何一个返回成功,那调用便宣告成功,这是一种在关键场景中使用更高的执行成本换取执行时间和成功概率的策略。

  • 广播调用(Broadcast):广播调用与并行调用是相对应的,都是同时发起多个调用,但并行调用是任何一个调用结果返回成功便宣告成功,广播调用则是要求所有的请求全部都成功,这次调用才算是成功,任何一个服务提供者出现异常都算调用失败,广播调用通常会被用于实现“刷新分布式缓存”这类的操作。

容错策略优点缺点应用场景
故障转移系统自动处理,调用者对失败的信息不可见增加调用时间,额外的资源开销调用幂等服务 对调用时间不敏感的场景
快速失败调用者有对失败的处理完全控制权 不依赖服务的幂等性调用者必须正确处理失败逻辑,如果一味只是对外抛异常,容易引起雪崩调用非幂等的服务 超时阈值较低的场景
安全失败不影响主路逻辑只适用于旁路调用调用链中的旁路服务
沉默失败控制错误不影响全局出错的地方将在一段时间内不可用频繁超时的服务
故障恢复调用失败后自动重试,也不影响主路逻辑重试任务可能产生堆积,重试仍然可能失败调用链中的旁路服务 对实时性要求不高的主路逻辑也可以使用
并行调用尽可能在最短时间内获得最高的成功率额外消耗机器资源,大部分调用可能都是无用功资源充足且对失败容忍度低的场景
广播调用支持同时对批量的服务提供者发起调用资源消耗大,失败概率高只适用于批量操作的场景

容错设计模式

断路器模式

断路器的基本思路是通过代理(断路器对象)来一对一地(一个远程服务对应一个断路器对象)接管服务调用者的远程请求。断路器会持续监控并统计服务返回的成功、失败、超时、拒绝等各种结果,当出现故障(失败、超时、拒绝)的次数达到断路器的阈值时,它状态就自动变为“OPEN”,后续此断路器代理的远程访问都将直接返回调用失败,而不会发出真正的远程服务请求。通过断路器对远程服务的熔断,避免因持续的失败或拒绝而消耗资源,因持续的超时而堆积请求,最终的目的就是避免雪崩效应的出现。由此可见,断路器本质是一种快速失败策略的实现方式.

从调用序列来看,断路器就是一种有限状态机,断路器模式就是根据自身状态变化自动调整代理请求策略的过程。一般要设置以下三种断路器的状态:

  • CLOSED:表示断路器关闭,此时的远程请求会真正发送给服务提供者。断路器刚刚建立时默认处于这种状态,此后将持续监视远程请求的数量和执行结果,决定是否要进入 OPEN 状态。
  • OPEN:表示断路器开启,此时不会进行远程请求,直接给服务调用者返回调用失败的信息,以实现快速失败策略。
  • HALF OPEN:这是一种中间状态。断路器必须带有自动的故障恢复能力,当进入 OPEN 状态一段时间以后,将“自动”(一般是由下一次请求而不是计时器触发的,所以这里自动带引号)切换到 HALF OPEN 状态。该状态下,会放行一次远程调用,然后根据这次调用的结果成功与否,转换为 CLOSED 或者 OPEN 状态,以实现断路器的弹性恢复。

服务熔断和服务降级之间的联系与差别。断路器做的事情是自动进行服务熔断,这是一种快速失败的容错策略的实现方法。在快速失败策略明确反馈了故障信息给上游服务以后,上游服务必须能够主动处理调用失败的后果,而不是坐视故障扩散,这里的“处理”指的就是一种典型的服务降级逻辑,降级逻辑可以包括,但不应该仅仅限于是把异常信息抛到用户界面去,而应该尽力想办法通过其他路径解决问题,譬如把原本要处理的业务记录下来,留待以后重新处理是最低限度的通用降级逻辑。

服务降级不一定是在出现错误后才被动执行的,许多场景里面,人们所谈论的降级更可能是指需要主动迫使服务进入降级逻辑的情况。譬如,出于应对可预见的峰值流量,或者是系统检修等原因,要关闭系统部分功能或关闭部分旁路服务,这时候就有可能会主动迫使这些服务降级。

舱壁隔离模式

舱壁隔离模式是常用的实现服务隔离的设计模式,舱壁这个词是来自造船业的舶来品,它原本的意思是设计舰船时,要在每个区域设计独立的水密舱室,一旦某个舱室进水,也只是影响这个舱室中的货物,而不至于让整艘舰艇沉没。这种思想就很符合容错策略中失败静默策略。

由于目前主流的网络访问大多是基于 TPR 并发模型(Thread per Request)来实现的,只要请求一直不结束(无论是以成功结束还是以失败结束),就要一直占用着某个线程不能释放。而线程是典型的整个系统的全局性资源,尤其是 Java 这类将线程映射为操作系统内核线程来实现的语言环境中,为了不让某一个远程服务的局部失败演变成全局性的影响,就必须设置某种止损方案,这便是服务隔离的意义。

当分布式系统所依赖的某个服务,在高流量的访问下——或者更具体点,假设平均 1 秒钟内对该服务的调用会发生 50 次,这就意味着该服务如果长时间不结束的话,每秒会有 50 条用户线程被阻塞。如果这样的访问量一直持续,我们按 Tomcat 默认的 HTTP 超时时间 20 秒来计算,20 秒内将会阻塞掉 1000 条用户线程,此后才陆续会有用户线程因超时被释放出来,回归 Tomcat 的全局线程池中。一般 Java 应用的线程池最大只会设置到 200 至 400 之间,这意味着此时系统在外部将表现为所有服务的全面瘫痪

对于这类情况,一种可行的解决办法是为每个服务单独设立线程池,这些线程池默认不预置活动线程,只用来控制单个服务的最大连接数。譬如,对出问题的“服务 I”设置了一个最大线程数为 5 的线程池,这时候它的超时故障就只会最多阻塞 5 条用户线程,而不至于影响全局。此时,其他不依赖“服务 I”的用户线程依然能够正常对外提供服务.局部线程池有一个显著的弱点,它额外增加了 CPU 的开销,每个独立的线程池都要进行排队、调度和下文切换工作。

为应对这种情况,还有一种更轻量的可以用来控制服务最大连接数的办法:信号量机制(Semaphore)。如果不考虑清理线程池、客户端主动中断线程这些额外的功能,仅仅是为了控制一个服务并发调用的最大次数,可以只为每个远程服务维护一个线程安全的计数器即可,并不需要建立局部线程池。具体做法是当服务开始调用时计数器加 1,服务返回结果后计数器减 1,一旦计数器超过设置的阈值就立即开始限流,在回落到阈值范围之前都不再允许请求了。由于不需要承担线程的排队、调度、切换工作,所以单纯维护一个作为计数器的信号量的性能损耗,相对于局部线程池来说几乎可以忽略不计。

重试模式

使用断路器模式实现快速失败策略,使用舱壁隔离模式实现静默失败策略,在断路器中举例的主动对非关键的旁路服务进行降级,亦可算作是对安全失败策略的一种体现。那还剩下故障转移和故障恢复两种策略的实现尚未涉及.

故障转移和故障恢复策略都需要对服务进行重复调用,差别是这些重复调用有可能是同步的,也可能是后台异步进行;有可能会重复调用同一个服务,也可能会调用到服务的其他副本。无论具体是通过怎样的方式调用、调用的服务实例是否相同,都可以归结为重试设计模式的应用范畴。重试模式适合解决系统中的瞬时故障,简单的说就是有可能自己恢复(Resilient,称为自愈,也叫做回弹性)的临时性失灵,网络抖动、服务的临时过载(典型的如返回了 503 Bad Gateway 错误)这些都属于瞬时故障

同时满足以下几个前提条件:

  • 仅在主路逻辑的关键服务上进行同步的重试,不是关键的服务,一般不把重试作为首选容错方案,尤其不该进行同步重试。
  • 仅对由瞬时故障导致的失败进行重试。尽管一个故障是否属于可自愈的瞬时故障并不容易精确判定,但从 HTTP 的状态码上至少可以获得一些初步的结论,譬如,当发出的请求收到了 401 Unauthorized 响应,说明服务本身是可用的,只是你没有权限调用,这时候再去重试就没有什么意义。功能完善的服务治理工具会提供具体的重试策略配置(如 Envoy 的Retry Policy),可以根据包括 HTTP 响应码在内的各种具体条件来设置不同的重试参数。
  • 仅对具备幂等性的服务进行重试。如果服务调用者和提供者不属于同一个团队,那服务是否幂等其实也是一个难以精确判断的问题,但仍可以找到一些总体上通用的原则。譬如,RESTful 服务中的 POST 请求是非幂等的,而 GET、HEAD、OPTIONS、TRACE 由于不会改变资源状态,这些请求应该被设计成幂等的;PUT 请求一般也是幂等的,因为 n 个 PUT 请求会覆盖相同的资源 n-1 次;DELETE 也可看作是幂等的,同一个资源首次删除会得到 200 OK 响应,此后应该得到 204 No Content 响应。这些都是 HTTP 协议中定义的通用的指导原则,虽然对于具体服务如何实现并无强制约束力,但我们自己建设系统时,遵循业界惯例本身就是一种良好的习惯。
  • 重试必须有明确的终止条件,常用的终止条件有两种:
    • 超时终止:并不限于重试,所有调用远程服务都应该要有超时机制避免无限期的等待。这里只是强调重试模式更加应该配合上超时机制来使用,否则重试对系统很可能反而是有害的,笔者已经在前面介绍故障转移策略时举过具体的例子,这里就不重复了。
    • 次数终止:重试必须要有一定限度,不能无限制地做下去,通常最多就只重试 2 至 5 次。重试不仅会给调用者带来负担,对于服务提供者也是同样是负担。所以应避免将重试次数设的太大。此外,如果服务提供者返回的响应头中带有Retry-After的话,尽管它没有强制约束力,我们也应该充分尊重服务端的要求,做个“有礼貌”的调用者。

熔断、隔离、重试、降级、超时等概念都是建立具有韧性的微服务系统必须的保障措施。目前,这些措施的正确运作,还主要是依靠开发人员对服务逻辑的了解,以及运维人员的经验去静态调整配置参数和阈值,但是面对能够自动扩缩(Auto Scale)的大型分布式系统,静态的配置越来越难以起到良好的效果,这就需要系统不仅要有能力自动根据服务负载来调整服务器的数量规模,同时还要有能力根据服务调用的统计结果,或者启发式搜索)的结果来自动变更容错策略和参数,这方面研究现在还处于各大厂商在内部分头摸索的初级阶段,是服务治理的未来重要发展方向之一。

流量控制

需要妥善解决以下三个问题:

  • 依据什么限流?:要不要控制流量,要控制哪些流量,控制力度要有多大,等等这些操作都没法在系统设计阶段静态地给出确定的结论,必须根据系统此前一段时间的运行状况,甚至未来一段时间的预测情况来动态决定。
  • 具体如何限流?:解决系统具体是如何做到允许一部分请求能够通行,而另外一部分流量实行受控制的失败降级,这必须了解掌握常用的服务限流算法和设计模式。
  • 超额流量如何处理?:超额流量可以有不同的处理策略,也许会直接返回失败(如 429 Too Many Requests),或者被迫使它们进入降级逻辑,这种被称为否决式限流。也可能让请求排队等待,暂时阻塞一段时间后继续处理,这种被称为阻塞式限流。

流量统计指标

经常用于衡量服务流量压力,但又较容易混淆的三个指标的定义:

  • 每秒事务数(Transactions per Second,TPS):TPS 是衡量信息系统吞吐量的最终标准。“事务”可以理解为一个逻辑上具备原子性的业务操作。譬如你在 Fenix’s Bookstore 买了一本书,将要进行支付,“支付”就是一笔业务操作,支付无论成功还是不成功,这个操作在逻辑上是原子的,即逻辑上不可能让你买本书还成功支付了前面 200 页,又失败了后面 300 页。
  • 每秒请求数(Hits per Second,HPS):HPS 是指每秒从客户端发向服务端的请求数(请将 Hits 理解为 Requests 而不是 Clicks,国内某些翻译把它理解为“每秒点击数”多少有点望文生义的嫌疑)。如果只要一个请求就能完成一笔业务,那 HPS 与 TPS 是等价的,但在一些场景(尤其常见于网页中)里,一笔业务可能需要多次请求才能完成。譬如你在 Fenix’s Bookstore 买了一本书要进行支付,尽管逻辑上它是原子的,但技术实现上,除非你是直接在银行开的商城中购物能够直接扣款,否则这个操作就很难在一次请求里完成,总要经过显示支付二维码、扫码付款、校验支付是否成功等过程,中间不可避免地会发生多次请求。
  • 每秒查询数(Queries per Second,QPS):QPS 是指一台服务器能够响应的查询次数。如果只有一台服务器来应答请求,那 QPS 和 HPS 是等价的,但在分布式系统中,一个请求的响应往往要由后台多个服务节点共同协作来完成。譬如你在 Fenix’s Bookstore 买了一本书要进行支付,尽管扫描支付二维码时客户端只发送了一个请求,但这背后服务端很可能需要向仓储服务确认库存信息避免超卖、向支付服务发送指令划转货款、向用户服务修改用户的购物积分,等等,这里面每次内部访问都要消耗掉一次或多次查询数。

限流设计模式

流量计数器模式

做限流最容易想到的一种方法就是设置一个计算器,根据当前时刻的流量计数结果是否超过阈值来决定是否限流。但其缺点如下:

  1. 即使每一秒的统计流量都没有超过 80 TPS,也不能说明系统没有遇到过大于 80 TPS 的流量压力。
    你可以想像如下场景,如果系统连续两秒都收到 60 TPS 的访问请求,但这两个 60 TPS 请求分别是前 1 秒里面的后 0.5 秒,以及后 1 秒中的前面 0.5 秒所发生的。这样虽然每个周期的流量都不超过 80 TPS 请求的阈值,但是系统确实曾经在 1 秒内实在在发生了超过阈值的 120 TPS 请求
  2. 即使连续若干秒的统计流量都超过了 80 TPS,也不能说明流量压力就一定超过了系统的承受能力。
    你可以想像如下场景,如果 10 秒的时间片段中,前 3 秒 TPS 平均值到了 100,而后 7 秒的平均值是 30 左右,此时系统是否能够处理完这些请求而不产生超时失败?答案是可以的,因为条件中给出的超时时间是 10 秒,而最慢的请求也能在 8 秒左右处理完毕。如果只基于固定时间周期来控制请求阈值为 80 TPS,反而会误杀一部分请求,造成部分请求出现原本不必要的失败。

流量计数器的缺陷根源在于它只是针对时间点进行离散的统计,为了弥补该缺陷,一种名为“滑动时间窗”的限流模式被设计出来,它可以实现平滑的基于时间片段统计。

滑动时间窗口

当频率固定每秒一次的定时器被唤醒时,它应该完成以下几项工作,这也就是滑动时间窗的工作过程:

  1. 将数组最后一位的元素丢弃掉,并把所有元素都后移一位,然后在数组第一个插入一个新的空元素。这个步骤即为“滑动窗口”。
  2. 将计数器中所有统计信息写入到第一位的空元素中。
  3. 对数组中所有元素进行统计,并复位清空计数器数据供下一个统计周期使用

滑动时间窗口模式的限流完全解决了流量计数器的缺陷,可以保证任意时间片段内,只需经过简单的调用计数比较,就能控制住请求次数一定不会超过限流的阈值,在单机限流或者分布式服务单点网关中的限流中很常用。不过,这种限流也有其缺点,它通常只适用于否决式限流,超过阈值的流量就必须强制失败或降级,很难进行阻塞等待处理,也就很难在细粒度上对流量曲线进行整形,起不到削峰填谷的作用第一步:定义结构

你需要一个环形数组或列表来存储子窗口。每个子窗口包含:

  • 开始时间戳
  • 当前计数值

第二步:定位桶(Bucket)

当一个请求进来时:

  1. 获取当前时间戳 $T$。
  2. 计算该时间戳属于哪个子窗口:索引 = (T / 子窗口长度) % 总桶数
  3. 如果该桶的开始时间戳已经过期(说明是上一轮循环留下的),则清空该桶的计数并更新开始时间。

第三步:统计总数

  1. 遍历所有当前有效的子窗口,将它们的计数累加。
  2. 判断 累加值 + 1 是否超过阈值。
  3. 如果没超过,当前桶计数 +1,放行;否则,拒绝。

在分布式环境下,可以使用 Redis 的 ZSET(有序集合) 实现:

  1. 每个请求进来,ZADD 一个元素到 ZSET,scorevalue 都设为当前时间戳。
  2. 使用 ZREMRANGEBYSCORE 删除“当前时间 - 1分钟”之前的旧数据(窗口滑动)。
  3. 使用 ZCARD 统计剩余的元素个数。
  4. 判断个数是否超限。

子窗口越多:限流越平滑,越接近真实的流量曲线,但占用内存更多,统计时的遍历耗时也稍长。

通常做法:将 1 秒分为 10 个桶(每桶 100ms),或者将 1 分钟分为 60 个桶(每桶 1s)。

漏桶模式

流量整形通常都需要用到缓冲区来实现,当报文的发送速度过快时,首先在缓冲区中暂存,然后再在控制算法的调节下均匀地发送这些被缓冲的报文。常用的控制算法有漏桶算法(Leaky Bucket Algorithm)和令牌桶算法(Token Bucket Algorithm)两种,这两种算法的思路截然相反,但达到的效果又是相似的。

漏桶在代码实现上非常简单,它其实就是一个以请求对象作为元素的先入先出队列(FIFO Queue),队列长度就相当于漏桶的大小,当队列已满时便拒绝新的请求进入。漏桶实现起来很容易,困难在于如何确定漏桶的两个参数:桶的大小和水的流出速率。

令牌桶模式

假设我们要限制系统在 X 秒内最大请求次数不超过 Y,那就每间隔 X/Y 时间就往桶中放一个令牌,当有请求进来时,首先要从桶中取得一个准入的令牌,然后才能进入系统处理。任何时候,一旦请求进入桶中却发现没有令牌可取了,就应该马上失败或进入服务降级逻辑。与漏桶类似,令牌桶同样有最大容量,这意味着当系统比较空闲时,桶中令牌累积到一定程度就不再无限增加,预存在桶中的令牌便是请求最大缓冲的余量。上面这段话,可以转化为以下步骤来指导程序编码:

  1. 让系统以一个由限流目标决定的速率向桶中注入令牌,譬如要控制系统的访问不超过 100 次每秒,速率即设定为 100 个令牌每秒,每个令牌注入间隔为 1/100=10 毫秒。
  2. 桶中最多可以存放 N 个令牌,N 的具体数量是由超时时间和服务处理能力共同决定的。如果桶已满,第 N+1 个进入的令牌会被丢弃掉。
  3. 请求到时先从桶中取走 1 个令牌,如果桶已空就进入降级逻辑。

令牌桶模式的实现看似比较复杂,每间隔固定时间就要放新的令牌到桶中,但其实并不需要真的用一个专用线程或者定时器来做这件事情,只要在令牌中增加一个时间戳记录,每次获取令牌前,比较一下时间戳与当前时间,就可以轻易计算出这段时间需要放多少令牌进去,然后一次性放入即可

分布式限流

此前讨论的限流算法和模式全部是针对整个系统的限流,总是有意无意地假设或默认系统只提供一种业务操作,或者所有业务操作的消耗都是等价的,并不涉及到不同业务请求进入系统的服务集群后,分别会调用哪些服务、每个服务节点处理能力有何差别等问题。直接使用在单体架构的集群上是完全可行的,但到了微服务架构下,它们就最多只能应用于集群最入口处的网关上,对整个服务集群进行流量控制,而无法细粒度地管理流量在内部微服务节点中的流转情况

微服务架构下,它们就最多只能应用于集群最入口处的网关上,对整个服务集群进行流量控制,而无法细粒度地管理流量在内部微服务节点中的流转情况。把前面介绍的限流模式都统称为单机限流,把能够精细控制分布式集群中每个服务消耗量的限流算法称为分布式限流

单机限流很好办,指标都是存储在服务的内存当中,而分布式限流的目的就是要让各个服务节点的协同限流,无论是将限流功能封装为专门的远程服务,抑或是在系统采用的分布式框架中有专门的限流支持,都需要将原本在每个服务节点自己内存当中的统计数据给开放出来,让全局的限流服务可以访问到才行。

一种常见的简单分布式限流方法是将所有服务的统计结果都存入集中式缓存(如 Redis)中,以实现在集群内的共享,并通过分布式锁、信号量等机制,解决这些数据的读写访问时并发控制的问题。在可以共享统计数据的前提下,原本用于单机的限流模式理论上也是可以应用于分布式环境中的,可是其代价也显而易见:每次服务调用都必须要额外增加一次网络开销,所以这种方法的效率肯定是不高的,流量压力大时,限流本身反倒会显著降低系统的处理能力。

只要集中式存储统计信息,就不可避免地会产生网络开销,为了缓解这里产生的性能损耗,一种可以考虑的办法是在令牌桶限流模式基础上进行“货币化改造”,即不把令牌看作是只有准入和不准入的“通行证”,而看作数值形式的“货币额度”。当请求进入集群时,首先在 API 网关处领取到一定数额的“货币”,为了体现不同等级用户重要性的差别,他们的额度可以有所差异,譬如让 VIP 用户的额度更高甚至是无限的。我们将用户 A 的额度表示为 QuanityA。由于任何一个服务在响应请求时都需要消耗集群一定量的处理资源,所以访问每个服务时都要求消耗一定量的“货币”,假设服务 X 要消耗的额度表示为 CostX,那当用户 A 访问了 N 个服务以后,他剩余的额度 LimitN即表示为:

LimitN = QuanityA - ∑NCostX

此时,我们可以把剩余额度 LimitN作为内部限流的指标,规定在任何时候,只要一旦剩余额度 LimitN小于等于 0 时,就不再允许访问其他服务了。此时必须先发生一次网络请求,重新向令牌桶申请一次额度,成功后才能继续访问,不成功则进入降级逻辑。除此之外的任何时刻,即 LimitN不为零时,都无须额外的网络访问,因为计算 LimitN是完全可以在本地完成的。

基于额度的限流方案对限流的精确度有一定的影响,可能存在业务操作已经进行了一部分服务调用,却无法从令牌桶中再获取到新额度,因“资金链断裂”而导致业务操作失败。这种失败的代价是比较高昂的,它白白浪费了部分已经完成了的服务资源,但总体来说,它仍是一种并发性能和限流效果上都相对折衷可行的分布式限流方案。

可靠通信

分散治理打破了由技术栈带来的约束,好处是不言自明的。但在运维阶段部署服务时,尤其是考量安全问题时,由 Java、Golang、Python、Node.js 等多种语言和框架共同组成的微服务系统,出现安全漏洞的概率肯定要比只采用其中某种语言、某种框架所构建的单体系统更高。

零信任安全

主流的网络安全观念提倡根据某类与宿主机相关的特征,譬如机器所处的位置,或者机器的 IP 地址、子网等,把网络划分为不同的区域,不同的区域对应于不同风险级别和允许访问的网络资源权限,将安全防护措施集中部署在各个区域的边界之上,重点关注跨区域的网络流量。 VPN、DMZ、防火墙、内网、外网等概念,都可以说是因此而生的,这种安全模型今天被称为是基于边界的安全模型

零信任安全的中心思想是不应当以某种固有特征来自动信任任何流量,除非明确得到了能代表请求来源(不一定是人,更可能是另一个服务)的身份凭证,否则一律不会有默认的信任关系

传统、边界安全模型云原生、零信任安全模型具体需求
基于防火墙等设施,认为边界内可信服务到服务通信需认证,环境内的服务之间默认没有信任保护网络边界(仍然有效);服务之间默认没有互信
用于特定的 IP 和硬件(机器)资源利用率、重用、共享更好,包括 IP 和硬件受信任的机器运行来源已知的代码
基于 IP 的身份基于服务的身份同上
服务运行在已知的、可预期的服务器上服务可运行在环境中的任何地方,包括私有云/公有云混合部署同上
安全相关的需求由应用来实现,每个应用单独实现由基础设施来实现,基础设施中集成了共享的安全性要求。集中策略实施点(Choke Points),一致地应用到所有服务
对服务如何构建、评审、实施的安全需求的约束力较弱安全相关的需求一致地应用到所有服务同上
安全组件的可观测性较弱有安全策略及其是否生效的全局视图同上
发布不标准,发布频率较低标准化的构建和发布流程,每个微服务变更独立,变更更频繁简单、自动、标准化的变更发布流程
工作负载通常作为虚拟机部署或部署到物理主机,并使用物理机或管理程序进行隔离封装的工作负载及其进程在共享的操作系统中运行,并有管理平台提供的某种机制来进行隔离在共享的操作系统的工作负载之间进行隔离

可观测性

学术界一般会将可观测性分解为三个更具体方向进行研究,分别是:事件日志链路追踪聚合度量,这三个方向各有侧重,又不是完全独立

  • 日志(Logging):日志的职责是记录离散事件,通过这些记录事后分析出程序的行为,譬如曾经调用过什么方法,曾经操作过哪些数据,等等。打印日志被认为是程序中最简单的工作之一,调试问题时常有人会说“当初这里记得打点日志就好了”,可见这就是一项举手之劳的任务。输出日志的确很容易,但收集和分析日志却可能会很复杂,面对成千上万的集群节点,面对迅速滚动的事件信息,面对数以 TB 计算的文本,传输与归集都并不简单。
  • 追踪(Tracing):单体系统时代追踪的范畴基本只局限于栈追踪(Stack Tracing),调试程序时,在 IDE 打个断点,看到的 Call Stack 视图上的内容便是追踪;编写代码时,处理异常调用了 Exception::printStackTrace()方法,它输出的堆栈信息也是追踪。微服务时代,追踪就不只局限于调用栈了,一个外部请求需要内部若干服务的联动响应,这时候完整的调用轨迹将跨越多个服务,同时包括服务间的网络传输信息与各个服务内部的调用堆栈信息,因此,分布式系统中的追踪在国内常被称为“全链路追踪”(后文就直接称“链路追踪”了)
  • 度量(Metrics):度量是指对系统中某一类信息的统计聚合。譬如,证券市场的每一只股票都会定期公布财务报表,通过财报上的营收、净利、毛利、资产、负债等等一系列数据来体现过去一个财务周期中公司的经营状况,这便是一种信息聚合。Java 天生自带有一种基本的度量,就是由虚拟机直接提供的 JMX(Java Management eXtensions)度量,诸如内存大小、各分代的用量、峰值的线程数、垃圾收集的吞吐量、频率,等等都可以从 JMX 中获得。度量的主要目的是监控(Monitoring)和预警(Alert),如某些度量指标达到风险阈值时触发事件,以便自动处理或者提醒管理员介入。

日志、度量两个领域的胜利者算是基本尘埃落定。日志收集和分析大多被统一到 Elastic Stack(ELK)技术栈上。度量方面,跟随着 Kubernetes 统一容器编排的步伐,Prometheus 也击败了度量领域里以 Zabbix 为代表的众多前辈,即将成为云原生时代度量监控的事实标准

追踪方面的情况与日志、度量有所不同,追踪是与具体网络协议、程序语言密切相关的,收集日志不必关心这段日志是由 Java 程序输出的还是由 Golang 程序输出的,对程序来说它们就只是一段非结构化文本而已,同理,度量对程序来说也只是一个个聚合的数据指标而已。通常是以插件式的探针来实现;也决定了追踪领域很难出现一家独大的情况,通常要有多种产品来针对不同的语言和网络。近年来各种链路追踪产品层出不穷,市面上主流的工具既有像 Datadog 这样的一揽子商业方案,也有 AWS X-Ray 和 Google Stackdriver Trace 这样的云计算厂商产品,还有像 SkyWalking、Zipkin、Jaeger 这样来自开源社区的优秀产品。

事件日志

日志用来记录系统运行期间发生过的离散事件。相信没有哪一个生产系统能够缺少日志功能,然而也很少人会把日志作为多么关键功能来看待。日志就像阳光与空气,无可或缺却不太被重视。

image-20260201170835045

收集与缓冲

写日志是在服务节点中进行的,但我们不可能在每个节点都单独建设日志查询功能。这不是资源或工作量的问题,而是分布式系统处理一个请求要跨越多个服务节点,为了能看到跨节点的全部日志,就要有能覆盖整个链路的全局日志系统。这个需求决定了每个节点输出日志到文件后,必须将日志文件统一收集起来集中存储、索引,由此便催生了专门的日志收集器。

一种最常用的缓解压力的做法是将日志接收者从 Logstash 和 Elasticsearch 转移至抗压能力更强的队列缓存,譬如在 Logstash 之前架设一个 Kafka 或者 Redis 作为缓冲层,面对突发流量,Logstash 或 Elasticsearch 处理能力出现瓶颈时自动削峰填谷,甚至当它们短时间停顿,也不会丢失日志数据。

加工与聚合

将日志集中收集之后,存入 Elasticsearch 之前,一般还要对它们进行加工转换和聚合处理。这是因为日志是非结构化数据,一行日志中通常会包含多项信息,如果不做处理,那在 Elasticsearch 就只能以全文检索的原始方式去使用日志,既不利于统计对比,也不利于条件过滤。

Logstash 的基本职能是把日志行中的非结构化数据,通过 Grok 表达式语法转换为上面表格那样的结构化数据,进行结构化的同时,还可能会根据需要,调用其他插件来完成时间处理(统一时间格式)、类型转换(如字符串、数值的转换)、查询归类(譬如将 IP 地址根据地理信息库按省市归类)等额外处理工作,然后以 JSON 格式输出到 Elasticsearch 中(这是最普遍的输出形式,Logstash 输出也有很多插件可以具体定制不同的格式)。有了这些经过 Logstash 转换,已经结构化的日志,Elasticsearch 便可针对不同的数据项来建立索引,进行条件查询、统计、聚合等操作的了。

提到聚合,这也是 Logstash 的另一个常见职能。日志中存储的是离散事件,离散的意思是每个事件都是相互独立的,譬如有 10 个用户访问服务,他们操作所产生的事件都在日志中会分别记录。如果想从离散的日志中获得统计信息,譬如想知道这些用户中正常返回(200 OK)的有多少、出现异常的(500 Internal Server Error)的有多少,再生成个可视化统计图表,一种解决方案是通过 Elasticsearch 本身的处理能力做实时的聚合统计,这很便捷,不过要消耗 Elasticsearch 服务器的运算资源。另一种解决方案是在收集日志后自动生成某些常用的、固定的聚合指标,这种聚合就会在 Logstash 中通过聚合插件来完成。

存储与查询

经过收集、缓冲、加工、聚合的日志数据,终于可以放入 Elasticsearch 中索引存储了

链路追踪

从客户端发起请求抵达系统的边界开始,记录请求流经的每一个服务,直到到向客户端返回响应为止,这整个过程就称为一次“追踪”(Trace,为了不产生混淆,后文就直接使用英文 Trace 来指代了)。由于每次 Trace 都可能会调用数量不定、坐标不定的多个服务,为了能够记录具体调用了哪些服务,以及调用的顺序、开始时点、执行时长等信息,每次开始调用服务前都要先埋入一个调用记录,这个记录称为一个“跨度”(Span)。Span 的数据结构应该足够简单,以便于能放在日志或者网络协议的报文头里;也应该足够完备,起码应含有时间戳、起止时间、Trace 的 ID、当前 Span 的 ID、父 Span 的 ID 等能够满足追踪需要的信息。每一次 Trace 实际上都是由若干个有顺序、有层级关系的 Span 所组成一颗“追踪树”(Trace Tree)

踪系统根据数据收集方式的差异,可分为三种主流的实现方式,分别是基于日志的追踪(Log-Based Tracing),基于服务的追踪(Service-Based Tracing)和基于边车代理的追踪(Sidecar-Based Tracing)

聚合度量

度量(Metrics)的目的是揭示系统的总体运行状态。度量总体上可分为客户端的指标收集、服务端的存储查询以及终端的监控预警三个相对独立的过程,每个过程在系统中一般也会设置对应的组件来实现

指标收集

指标收集部分要解决两个问题:“如何定义指标”以及“如何将这些指标告诉服务端”.

  • 计数度量器(Counter):这是最好理解也是最常用的指标形式,计数器就是对有相同量纲、可加减数值的合计量,譬如业务指标像销售额、货物库存量、职工人数等等;技术指标像服务调用次数、网站访问人数等都属于计数器指标。
  • 瞬态度量器(Gauge):瞬态度量器比计数器更简单,它就表示某个指标在某个时点的数值,连加减统计都不需要。譬如当前 Java 虚拟机堆内存的使用量,这就是一个瞬态度量器;又譬如,网站访问人数是计数器,而网站在线人数则是瞬态度量器。
  • 吞吐率度量器(Meter):吞吐率度量器顾名思义是用于统计单位时间的吞吐量,即单位时间内某个事件的发生次数。譬如交易系统中常以 TPS 衡量事务吞吐率,即每秒发生了多少笔事务交易;又譬如港口的货运吞吐率常以“吨/每天”为单位计算,10 万吨/天的港口通常要比 1 万吨/天的港口的货运规模更大。
  • 直方图度量器(Histogram):直方图是常见的二维统计图,它的两个坐标分别是统计样本和该样本对应的某个属性的度量,以长条图的形式表示具体数值。譬如经济报告中要衡量某个地区历年的 GDP 变化情况,常会以 GDP 为纵坐标,时间为横坐标构成直方图来呈现。
  • 采样点分位图度量器(Quantile Summary):分位图是统计学中通过比较各分位数的分布情况的工具,用于验证实际值与理论值的差距,评估理论值与实际值之间的拟合度。譬如,我们说“高考成绩一般符合正态分布”,这句话的意思是:高考成绩高低分的人数都较少,中等成绩的较多,将人数按不同分数段统计,得出的统计结果一般能够与正态分布的曲线较好地拟合。
  • 除了以上常见的度量器之外,还有 Timer、Set、Fast Compass、Cluster Histogram 等其他各种度量器,采用不同的度量系统,支持度量器类型的范围肯定会有差别,譬如 Prometheus 支持了上面提到五种度量器中的 Counter、Gauge、Histogram 和 Summary 四种。

对于“如何将这些指标告诉服务端”这个问题,通常有两种解决方案:拉取式采集(Pull-Based Metrics Collection)和推送式采集(Push-Based Metrics Collection)。所谓 Pull 是指度量系统主动从目标系统中拉取指标,相对地,Push 就是由目标系统主动向度量系统推送指标。

存储查询

专门针对该类型数据的数据库了,即“时序数据库”。时序数据库用于存储跟随时间而变化的数据,并且以时间(时间点或者时间区间)来建立索引的数据库。

写操作,时序数据通常只是追加,很少删改或者根本不允许删改。针对数据热点只集中在近期数据、多写少读、几乎不删改、数据只顺序追加这些特点,时序数据库被允许做出很激进的存储、访问和保留策略(Retention Policies):

  • 以日志结构的合并树(Log Structured Merge Tree,LSM-Tree)代替传统关系型数据库中的B+Tree作为存储结构,LSM 适合的应用场景就是写多读少,且几乎不删改的数据。
  • 设置激进的数据保留策略,譬如根据过期时间(TTL)自动删除相关数据以节省存储空间,同时提高查询性能。对于普通数据库来说,数据会存储一段时间后就会被自动删除这种事情是不可想象的。
  • 对数据进行再采样(Resampling)以节省空间,譬如最近几天的数据可能需要精确到秒,而查询一个月前的数据时,只需要精确到天,查询一年前的数据时,只要精确到周就够了,这样将数据重新采样汇总就可以极大节省存储空间。

监控预警

良好的可视化能力对于提升度量系统的产品力十分重要,长期趋势分析(譬如根据对磁盘增长趋势的观察判断什么时候需要扩容)、对照分析(譬如版本升级后对比新旧版本的性能、资源消耗等方面的差异)、故障分析(不仅从日志、追踪自底向上可以分析故障,高维度的度量指标也可能自顶向下寻找到问题的端倪)等分析工作,既需要度量指标的持续收集、统计,往往还需要对数据进行可视化,才能让人更容易地从数据中挖掘规律,毕竟数据最终还是要为人类服务的。

不可变基础设施

云原生:云原生技术有利于各组织在公有云、私有云和混合云等新型动态环境中构建和运行可弹性扩展的应用云原生的代表技术包括容器、服务网格、微服务、不可变基础设施和声明式 API。

这些技术能够构建容错性好、易于管理和便于观察的松耦合系统。结合可靠的自动化手段,云原生技术使工程师能够轻松地对系统作出频繁和可预测的重大变更。

“不可变基础设施”提升到了与微服务平级的重要程度,此时它的内涵已不再局限于方便运维、程序升级和部署的手段,而是升华为向应用代码隐藏分布式架构复杂度、让分布式架构得以成为一种可普遍推广的普适架构风格的必要前提。

虚拟化容器

容器是云计算、微服务等诸多软件业界核心技术的共同基石,容器的首要目标是让软件分发部署过程从传统的发布安装包、靠人工部署转变为直接发布已经部署好的、包含整套运行环境的虚拟化镜像。

在容器技术成熟之前,主流的软件部署过程是由系统管理员编译或下载好二进制安装包,根据软件的部署说明文档准备好正确的操作系统、第三方库、配置文件、资源权限等各种前置依赖以后,才能将程序正确地运行起来

不设前提的“到处运行”,仅靠 Java 语言和 Java 虚拟机是不可能达成的,因为一个计算机软件要能够正确运行,需要有以下三方面的兼容性来共同保障(这里仅讨论软件兼容性,不去涉及“如果没有摄像头就无法运行照相程序”这类问题):

  • ISA 兼容:目标机器指令集兼容性,譬如 ARM 架构的计算机无法直接运行面向 x86 架构编译的程序。
  • ABI 兼容:目标系统或者依赖库的二进制兼容性,譬如 Windows 系统环境中无法直接运行 Linux 的程序,又譬如 DirectX 12 的游戏无法运行在 DirectX 9 之上。
  • 环境兼容:目标环境的兼容性,譬如没有正确设置的配置文件、环境变量、注册中心、数据库地址、文件系统的权限等等,任何一个环境因素出现错误,都会让你的程序无法正常运行

容器的崛起

容器的最初的目的不是为了部署软件,而是为了隔离计算机中的各类资源,以便降低软件开发、测试阶段可能产生的误操作风险

隔离文件

chroot功能是当某个进程经过chroot操作之后,它的根目录就会被锁定在命令参数所指定的位置,以后它或者它的子进程将不能再访问和操作该目录之外的其他文件。

隔离访问

Linux 的名称空间是一种由内核直接提供的全局资源封装,是内核针对进程设计的访问隔离机制进程在一个独立的 Linux 名称空间中朝系统看去,拥有这台 Linux 主机上的一切资源,不仅文件系统是独立的,还有着独立的 PID 编号(譬如拥有自己的 0 号进程,即系统初始化的进程)、UID/GID 编号(譬如拥有自己独立的 root 用户)、网络(譬如完全独立的 IP 地址、网络栈、防火墙等设置),等等

隔离资源

如果要让一台物理计算机中的各个进程看起来像独享整台虚拟计算机的话,不仅要隔离各自进程的访问操作,还必须能独立控制分配给各个进程的资源使用配额,不然的话,一个进程发生了内存溢出或者占满了处理器,其他进程就莫名其妙地被牵连挂起。

Linux 系统解决以上问题的方案是控制群组)(Control Groups,目前常用的简写为cgroups),它与名称空间一样都是直接由内核提供的功能,用于隔离或者说分配并限制某个进程组能够使用的资源配额,资源配额包括处理器时间、内存大小、磁盘 I/O 速度

封装系统

当文件系统、访问、资源都可以被隔离后,容器已经有它降生所需的全部前置支撑条件,并且 Linux 的开发者们也已经明确地看到了这一点。为降低普通用户综合使用namespacescgroups这些低级特性的门槛提供Linux容器LXC的系统虚拟化功能.

封装应用

器化能力直接来源于 LXC,它镜像分层组合的文件系统直接来源于AUFS.

后来,Docker 开源了自己用 Golang 开发的libcontainer,这是一个越过 LXC 直接操作namespacescgroups的核心模块,有了 libcontainer 以后,Docker 就能直接与系统内核打交道,不必依赖 LXC 来提供容器化隔离能力了。

在 Docker 的主导和倡议下,多家公司联合制定了“开放容器交互标准”(Open Container Initiative,OCI),这是一个关于容器格式和运行时的规范文件,其中包含运行时标准(runtime-spec )、容器镜像标准(image-spec)和镜像分发标准(distribution-spec,分发标准还未正式发布)。运行时标准定义了应该如何运行一个容器、如何管理容器的状态和生命周期、如何使用操作系统的底层特性(namespacescgrouppivot_root等)

为了符合 OCI 标准,Docker 推动自身的架构继续向前演进,首先将 libcontainer 独立出来,封装重构成runC 项目,并捐献给了 Linux 基金会管理。runC 是 OCI Runtime 的首个参考实现,提出了“让标准容器无所不在”(Make Standard Containers Available Everywhere)的口号。为了能够兼容所有符合标准的 OCI Runtime 实现,Docker 进一步重构了 Docker Daemon 子系统,将其中与运行时交互的部分抽象为containerd 项目,这是一个负责管理容器执行、分发、监控、网络、构建、日志等功能的核心模块,内部会为每个容器运行时创建一个 containerd-shim 适配进程,默认与 runC 搭配工作,但也可以切换到其他 OCI Runtime 实现

封装集群

以 Docker 为代表的容器引擎将软件的发布流程从分发二进制安装包转变为直接分发虚拟化后的整个运行环境,令应用得以实现跨机器的绿色部署;那以 Kubernetes 为代表的容器编排框架,就是把大型软件系统运行所依赖的集群环境也进行了虚拟化,令集群得以实现跨数据中心的绿色部署,并能够根据实际情况自动扩缩。

早期,k8s通过docker manager->docker engine->containered实现容器管理,也就是:

Kubernetes Master → kubelet → DockerManager → Docker Engine → containerd → runC

Kubernetes 从 1.10 版本宣布开始支持 containerd 1.1,在调用链中已经能够完全抹去 Docker Engine 的存在:

Kubernetes Master → kubelet → KubeGenericRuntimeManager → containerd → runC

以容器构建系统

一个容器封装一个单进程应用已经成为被广泛认可的最佳实践。然而单体时代过去之后,分布式系统里应用的概念已不再等同于进程,此时的应用需要多个进程共同协作,通过集群的形式对外提供服务,以虚拟化方法实现这个目标的过程就被称为容器编排

image-20260201182024673

Pod 是隔离与调度的基本单位,也是我们接触的第一种 Kubernetes 资源。Kubernetes 将一切皆视为资源,不同资源之间依靠层级关系相互组合协作

对于 Kubernetes 中的其他计算资源,像 Node、Cluster 等都有切实的物理对应物,很容易就能形成共同的认知:

  • 容器(Container):延续了自 Docker 以来一个容器封装一个应用进程的理念,是镜像管理的最小单位。
  • 生产任务(Pod):补充了容器化后缺失的与进程组对应的“容器组”的概念,Pod 中容器共享 UTS、IPC、网络等名称空间,是资源调度的最小单位
  • 节点(Node):对应于集群中的单台机器,这里的机器即可以是生产环境中的物理机,也可以是云计算环境中的虚拟节点,节点是处理器和内存等资源的资源池,是硬件单元的最小单位。
  • 集群(Cluster):对应于整个集群,Kubernetes 提倡理念是面向集群来管理应用。当你要部署应用的时候,只需要通过声明式 API 将你的意图写成一份元数据(Manifests),将它提交给集群即可,而无需关心它具体分配到哪个节点(尽管通过标签选择器完全可以控制它分配到哪个节点,但一般不需要这样做)、如何实现 Pod 间通信、如何保证韧性与弹性,等等,所以集群是处理元数据的最小单位。
  • 集群联邦(Federation):对应于多个集群,通过联邦可以统一管理多个 Kubernetes 集群,联邦的一种常见应用是支持跨可用区域多活、跨地域容灾的需求。

通过服务编排,对任何分布式系统自动实现以下三种通用的能力:

  1. Pod 出现故障时,能够自动恢复,不中断服务;
  2. Pod 更新程序时,能够滚动更新,不中断服务;
  3. Pod 遇到压力时,能够水平扩展,不中断服务;

通过副本集(ReplicaSet)来创建 Pod。ReplicaSet 也是一种资源,是属于工作负荷一类的资源,它代表一个或多个 Pod 副本的集合,你可以在 ReplicaSet 资源的元数据中描述你期望 Pod 副本的数量(即spec.replicas的值)。当 ReplicaSet 成功创建之后,副本集控制器就会持续跟踪该资源,如果一旦有 Pod 发生崩溃退出,或者状态异常(默认是靠进程返回值,你还可以在 Pod 中设置探针,以自定义的方式告诉 Kubernetes 出现何种情况 Pod 才算状态异常),ReplicaSet 都会自动创建新的 Pod 来替代异常的 Pod;如果异常多出现了额外数量的 Pod,也会被 ReplicaSet 自动回收掉,总之就是确保任何时候集群中这个 Pod 副本的数量都向期望状态靠拢。

可以由 Deployment 来创建 ReplicaSet,再由 ReplicaSet 来创建 Pod,当你更新 Deployment 中的信息。

遇到流量压力时,管理员完全可以手动修改 Deployment 中的副本数量,或者通过kubectl scale命令指定副本数量,促使 Kubernetes 部署更多的 Pod 副本来应对压力,然而这种扩容方式不仅需要人工参与,且只靠人类经验来判断需要扩容的副本数量,不容易做到精确与及时。为此 Kubernetes 又提供了 Autoscaling 资源和自动扩缩控制器,能够自动根据度量指标,如处理器、内存占用率、用户自定义的度量值等,来设置 Deployment(或者 ReplicaSet)的期望状态,实现当度量指标出现变化时,系统自动按照“Autoscaling→Deployment→ReplicaSet→Pod”这样的顺序层层变更,最终实现根据度量指标自动扩容缩容。

以应用为中心封装

使用k8s部署一套系统,需要分别部署一个到多个的配置中心、注册中心、服务网关、安全认证、用户服务、商品服务、交易服务,对每个微服务都配置好相应的 Kubernetes 工作负载与服务访问,为每一个微服务的 Deployment、ConfigMap、StatefulSet、HPA、Service、ServiceAccount、Ingress 等资源都编写好元数据配置。这些困难的实质源于 Docker 容器镜像封装了单个服务,Kubernetes 通过资源封装了服务集群,却没有一个载体真正封装整个应用,将原本属于应用内部的技术细节圈禁起来,不要暴露给最终用户、系统管理员和平台维护者,让使用者去埋单;应用难以管理矛盾在于封装应用的方法没能将开发、运维、平台等各种角色的关注点恰当地分离

相关的解决技术包括Kustomize,Helm,Chart,Operator等,定义了如何封装一个应用。

容器间网络

Linux 目前提供的八种名称空间里,网络名称空间无疑是隔离内容最多的一种,它为名称空间内的所有进程提供了全套的网络设施,包括独立的设备界面、路由表、ARP 表,IP 地址表、iptables/ebtables 规则、协议栈,等等。虚拟化容器是以 Linux 名称空间的隔离性为基础来实现的,那解决隔离的容器之间、容器与宿主机之间、乃至跨物理网络的不同容器间通信问题的责任,很自然也落在了 Linux 网络虚拟化技术的肩上。

网络通信模型

image-20260201184503977

图中传输模型的箭头展示的是数据流动的方向,它体现了信息从程序中发出以后,到被另一个程序接收到之前,将经历如下几个阶段:

  • Socket:应用层的程序是通过 Socket 编程接口来和内核空间的网络协议栈通信的。Linux Socket 是从 BSD Socket 发展而来的,现在 Socket 已经不局限于某个操作系统的专属功能,成为各大主流操作系统共同支持的通用网络编程接口,是网络应用程序实际上的交互基础。应用程序通过读写收、发缓冲区(Receive/Send Buffer)来与 Socket 进行交互,在 UNIX 和 Linux 系统中,出于“一切皆是文件”的设计哲学,对 Socket 操作被实现为对文件系统(socketfs)的读写访问操作,通过文件描述符(File Descriptor)来进行。
  • TCP/UDP:传输层协议族里最重要的协议无疑是传输控制协议(Transmission Control Protocol,TCP)和用户数据报协议(User Datagram Protocol,UDP)两种,它们也是在 Linux 内核中被直接支持的协议。此外还有流控制传输协议(Stream Control Transmission Protocol,SCTP)、数据报拥塞控制协议(Datagram Congestion Control Protocol,DCCP)等等。
    不同的协议处理流程大致是一样的,只是封装的报文以及头、尾部信息会有所不同,这里以 TCP 协议为例,内核发现 Socket 的发送缓冲区中有新的数据被拷贝进来后,会把数据封装为 TCP Segment 报文,常见网络协议的报文基本上都是由报文头(Header)和报文体(Body,也叫荷载“Payload”)两部分组成。系统内核将缓冲区中用户要发送出去的数据作为报文体,然后把传输层中的必要控制信息,譬如代表哪个程序发、由哪个程序收的源、目标端口号,用于保证可靠通信(重发与控制顺序)的序列号、用于校验信息是否在传输中出现损失的校验和(Check Sum)等信息封装入报文头中。
  • IP:网络层协议最主要就是网际协议(Internet Protocol,IP),其他还有因特网组管理协议(Internet Group Management Protocol,IGMP)、大量的路由协议(EGP、NHRP、OSPF、IGRP、……)等等。
    以 IP 协议为例,它会将来自上一层(本例中的 TCP 报文)的数据包作为报文体,再次加入自己的报文头,譬如指明数据应该发到哪里的路由地址、数据包的长度、协议的版本号,等等,封装成 IP 数据包后发往下一层。
  • Device:网络设备(Device)是网络访问层中面向系统一侧的接口,这里所说的设备与物理硬件设备并不是同一个概念,Device 只是一种向操作系统端开放的接口,其背后既可能代表着真实的物理硬件,也可能是某段具有特定功能的程序代码,譬如即使不存在物理网卡,也依然可以存在回环设备(Loopback Device)。许多网络抓包工具,如tcpdumpWirshark便是在此处工作的,前面介绍微服务流量控制时曾提到过的网络流量整形,通常也是在这里完成的。Device 主要的作用是抽象出统一的界面,让程序代码去选择或影响收发包出入口,譬如决定数据应该从哪块网卡设备发送出去;还有就是准备好网卡驱动工作所需的数据,譬如来自上一层的 IP 数据包、下一跳)(Next Hop)的 MAC 地址(这个地址是通过ARP Request得到的)等等。
  • Driver:网卡驱动程序(Driver)是网络访问层中面向硬件一侧的接口,网卡驱动程序会通过DMA将主存中的待发送的数据包复制到驱动内部的缓冲区之中。数据被复制的同时,也会将上层提供的 IP 数据包、下一跳 MAC 地址这些信息,加上网卡的 MAC 地址、VLAN Tag 等信息一并封装成为以太帧(Ethernet Frame),并自动计算校验和。对于需要确认重发的信息,如果没有收到接收者的确认(ACK)响应,那重发的处理也是在这里自动完成的

干预网络通信

从 Linux Kernel 2.4 版开始,内核开放了一套通用的、可供代码干预数据在协议栈中流转的过滤器框架。这套名为 Netfilter 的框架是 Linux 防火墙和网络的主要维护者 Rusty Russell 提出并主导设计的,它围绕网络层(IP 协议)的周围,埋下了五个钩子(Hooks),每当有数据包流到网络层,经过这些钩子时,就会自动触发由内核模块注册在这里的回调函数

  • PREROUTING:来自设备的数据包进入协议栈后立即触发此钩子。PREROUTING 钩子在进入 IP 路由之前触发,这意味着只要接收到的数据包,无论是否真的发往本机,都会触发此钩子。一般用于目标网络地址转换(Destination NAT,DNAT)。
  • INPUT:报文经过 IP 路由后,如果确定是发往本机的,将会触发此钩子,一般用于加工发往本地进程的数据包。
  • FORWARD:报文经过 IP 路由后,如果确定是发往本机的,将会触发此钩子,一般用于处理转发到其他机器的数据包。
  • OUTPUT:从本机程序发出的数据包,在经过 IP 路由前,将会触发此钩子,一般用于加工本地进程的输出数据包。
  • POSTROUTING:从本机网卡出去的数据包,无论是本机的程序所发出的,还是由本机转发给其他机器的,都会触发此钩子,一般用于源网络地址转换(Source NAT,SNAT)

image-20260201185637387

虚拟化网络设备

网卡:tun/tap、veth

目前主流的虚拟网卡方案有tun/tapveth两种,在时间上 tun/tap 出现得更早,它是一组通用的虚拟驱动程序包,里面包含了两个设备,分别是用于网络数据包处理的虚拟网卡驱动,以及用于内核空间与用户空间交互的字符设备驱动,具体指/dev/net/tun

tun 和 tap 是两个相对独立的虚拟网络设备,其中 tap 模拟了以太网设备,操作二层数据包(以太帧),tun 则模拟了网络层设备,操作三层数据包(IP 报文)。使用 tun/tap 设备的目的是实现把来自协议栈的数据包先交由某个打开了/dev/net/tun字符设备的用户进程处理后,再把数据包重新发回到链路中。

image-20260201204443005

虚拟化网卡驱动一端连接着网络协议栈,另一端连接着用户态程序,而普通的网卡驱动则是一端连接则网络协议栈,另一端连接着物理网卡.

应用程序通过 tun 设备对外发送数据包后,tun 设备如果发现另一端的字符设备已被 VPN 程序打开(这就是一端连接着网络协议栈,另一端连接着用户态程序),便会把数据包通过字符设备发送给 VPN 程序,VPN 收到数据包,会修改后再重新封装成新报文,譬如数据包原本是发送给 A 地址的,VPN 把整个包进行加密,然后作为报文体,封装到另一个发送给 B 地址的新数据包当中。这种将一个数据包套进另一个数据包中的处理方式被形象地形容为“隧道”(Tunneling),隧道技术是在物理网络中构筑逻辑网络的经典做法

veth 是另一种主流的虚拟网卡方案,在 Linux Kernel 2.6 版本,Linux 开始支持网络名空间隔离的同时,也提供了专门的虚拟以太网(Virtual Ethernet,习惯简写做 veth)让两个隔离的网络名称空间之间可以互相通信。直接把 veth 比喻成是虚拟网卡其实并不十分准确,如果要和物理设备类比,它应该相当于由交叉网线连接的一对物理网卡。

veth 实际上不是一个设备,而是一对设备,因而也常被称作 veth pair。要使用 veth,必须在两个独立的网络名称空间中进行才有意义,因为 veth pair 是一端连着协议栈,另一端彼此相连的,在 veth 设备的其中一端输入数据,这些数据就会从设备的另外一端原样不变地流出

image-20260201205015629

由于两个容器之间采用 veth 通信不需要反复多次经过网络协议栈,这让 veth 比起 tap/tun 具有更好的性能,也让 veth pair 的实现变的十分简单,内核中只用了几十行代码实现了一个数据复制函数就完成了 veth 的主体功能。veth 以模拟网卡直连的方式很好地解决了两个容器之间的通信问题,然而对多个容器间通信,如果仍然单纯只用 veth pair 的话,事情就会变得非常麻烦,让每个容器都为与它通信的其他容器建立一对专用的 veth pair 并不实际,这时就迫切需要有一台虚拟化的交换机来解决多容器之间的通信问题了

交换机:Linux Bridge

brctl命令创建和管理。Linux Bridge 创建以后,便能够接入任何位于二层的网络设备,无论是真实的物理设备(譬如 eth0)抑或是虚拟的设备(譬如 veth 或者 tap)都能与 Linux Bridge 配合工作。当有二层数据包(以太帧)从网卡进入 Linux Bridge,它将根据数据包的类型和目标 MAC 地址,按如下规则转发处理:

  • 如果数据包是广播帧,转发给所有接入网桥的设备。
  • 如果数据包是单播帧:
    • 且 MAC 地址在地址转发表中不存在,则泛洪(Flooding)给所有接入网桥的设备,并将响应设备的接口与 MAC 地址学习(MAC Learning)到自己的 MAC 地址转发表中。
    • 且 MAC 地址在地址转发表中已存在,则直接转发到地址表中指定的设备。
    • 如果数据包是此前转发过的,又重新发回到此 Bridge,说明冗余链路产生了环路。由于以太帧不像 IP 报文那样有 TTL 来约束,因此一旦出现环路,如果没有额外措施来处理的话就会永不停歇地转发下去。对于这种数据包就需要交换机实现生成树协议(Spanning Tree Protocol,STP)来交换拓扑信息,生成唯一拓扑链路以切断环路。

image-20260201210210580

网络:VXLAN

副本网卡:MACVLAN

容器间通信

容器间的网络方案多种多样,但通信主体都是固定的,不外乎没有物理设备的虚拟主体(容器、Pod、Service、Endpoints 等等)、不需要跨网络的本地主机、以及通过网络连接的外部主机三种层次,所有的容器网络通信问题,都可以归结为本地主机内部的多个容器之间、本地主机与内部容器之间和跨越不同主机的多个容器之间的通信问题

安装 Docker 过程中会自动在宿主机上创建一个名为 docker0 的网桥,以及三种不同的 Docker 网络,分别是 bridge、host 和 none,你可以通过docker network ls命令查看到这三种网络

  • 桥接模式,使用--network=bridge指定,这种也是未指定网络参数时的默认网络。桥接模式下,Docker 会为新容器分配独立的网络名称空间,创建好 veth pair,一端接入容器,另一端接入到 docker0 网桥上。Docker 为每个容器自动分配好 IP 地址,默认配置下地址范围是 172.17.0.0/24,docker0 的地址默认是 172.17.0.1,并且设置所有容器的网关均为 docker0,这样所有接入同一个网桥内的容器直接依靠二层网络来通信,在此范围之外的容器、主机就必须通过网关来访问
  • 主机模式,使用--network=host指定。主机模式下,Docker 不会为新容器创建独立的网络名称空间,这样容器一切的网络设施,如网卡、网络栈等都直接使用宿主机上的真实设施,容器也就不会拥有自己独立的 IP 地址。此模式下与外界通信无须进行 NAT 转换,没有性能损耗,但缺点也十分明显,没有隔离就无法避免网络资源的冲突,譬如端口号就不允许重复。
  • 空置模式,使用--network=none指定,空置模式下,Docker 会给新容器创建独立的网络名称空间,但是不会创建任何虚拟的网络设备,此时容器能看到的只有一个回环设备(Loopback Device)而已。提供这种方式是为了方便用户去做自定义的网络配置,如自己增加网络设备、自己管理 IP 地址,等等。

除了三种开箱即用的网络外,Docker 还支持以下由用户自行创建的网络:

  • 容器模式,创建容器后使用--network=container:容器名称指定。容器模式下,新创建的容器将会加入指定的容器的网络名称空间,共享一切的网络资源,但其他资源,如文件、PID 等默认仍然是隔离的。两个容器间可以直接使用回环地址(localhost)通信,端口号等网络资源不能有冲突。
  • MACVLAN 模式:使用docker network create -d macvlan创建,此网络允许为容器指定一个副本网卡,容器通过副本网卡的 MAC 地址来使用宿主机上的物理设备,在追求通信性能的场合,这种网络是最好的选择。Docker 的 MACVLAN 只支持 Bridge 通信模式,因此在功能表现上与桥接模式相类似。
  • Overlay 模式:使用docker network create -d overlay创建,Docker 说的 Overlay 网络实际上就是特指 VXLAN,这种网络模式主要用于 Docker Swarm 服务之间进行通信。然而由于 Docker Swarm 败于 Kubernetes,并未成为主流,所以这种网络模式实际很少使用

持久化存储

k8s中的存储设计

Mount和Volume

Docker 内建支持了三种挂载类型,分别是 Bind(--mount type=bind)、Volume(--mount type=volume)和 tmpfs(--mount type=tmpfs),其中 tmpfs 用于在内存中读写临时数据

Bind Mounts 是将宿主机上的任意特定路径映射到容器内部。

  • 路径自定义:可以是 /home/user/my_app,也可以是 /etc/config
  • 实时同步:宿主机上的修改会立即反映到容器内,反之亦然。
  • 依赖宿主机:如果宿主机的路径变了,容器就会报错。
  • 权限隐患:容器内的进程如果有 Root 权限,可能会修改宿主机的关键系统文件。

Volumes 是 Docker 最推荐的持久化方式。它完全由 Docker 管理,存储在宿主机的特定目录下(Linux 上通常是 /var/lib/docker/volumes/)。

  • 完全受管:你不需要关心它在硬盘的哪个角落,Docker 会负责创建、迁移和备份。
  • 隔离性好:非 Docker 进程通常无法直接访问这些文件,安全性更高。
  • 跨平台一致:无论在 Windows 还是 Linux 上,行为完全一致。
  • 支持驱动扩展:可以轻松挂载到云存储(如 AWS S3)或 NFS 上

服务网格

容器编排系统管理的最细粒度只能到达容器层次,在此粒度之下的技术细节,仍然只能依赖程序员自己来管理,编排系统很难提供有效的支持。

服务网格是一种用于管控服务间通信的的基础设施,职责是为现代云原生应用支持网络请求在复杂的拓扑环境中可靠地传递。在实践中,服务网格通常会以轻量化网络代理的形式来体现,这些代理与应用程序代码会部署在一起,对应用程序来说,它完全不会感知到代理的存在

它只是一种处理程序间通信的基础设施,典型的存在形式是部署在应用旁边,一对一为应用提供服务的边车代理,及管理这些边车代理的控制程序。“边车”(Sidecar)本来就是一种常见的容器设计模式,用来形容外挂在容器身上的辅助程序。

数据平面

数据平面由一系列边车代理所构成,核心职责是转发应用的入站(Inbound)和出站(Outbound)数据包。为了在不可靠的物理网络中保证程序间通信最大的可靠性,数据平面必须根据控制平面下发策略的指导,在应用无感知的情况下自动完成服务路由、健康检查、负载均衡、认证鉴权、产生监控数据等一系列工作。为了达成上述的工作目标,至少需要妥善解决以下三个关键问题:

  • 代理注入:边车代理是如何注入到应用程序中的?
  • 流量劫持:边车代理是如何劫持应用程序的通信流量的?
  • 可靠通信:边车代理是如何保证应用程序的通信可靠性的?

代理注入

注入边车代理是控制平面的工作。把边车代理注入到应用的过程并不一定全都是透明的,现在的服务网格产品存在有以下三种方式将边车代理接入到应用程序中:

  • 基座模式(Chassis):这种方式接入的边车代理对程序就是不透明的,它至少会包括一个轻量级的 SDK,通信由 SDK 中的接口去处理。基座模式的好处是在程序代码的帮助下,有可能达到更好的性能,功能也相对更容易实现,但坏处是对代码有侵入性,对编程语言有依赖性。

  • 注入模式

    (Injector):根据注入方式不同,又可以分为:

    • 手动注入模式:这种接入方式对使用者来说不透明,但对程序来说是透明的。由于边车代理的定义就是一个与应用共享网络名称空间的辅助容器,这天然就契合了 Pod 的设定,因此在 Kubernetes 中要进行手动注入是十分简单的——就只是为 Pod 增加一个额外容器而已,即使没有工具帮助,自己修改 Pod 的 Manifest 也能轻易办到。如果你以前未曾尝试过,不妨找一个 Pod 的配置文件,用istioctl kube-inject -f YOUR_POD.YAML命令来查看一下手动注入会对原有的 Pod 产生什么变化。
    • 自动注入模式:这种接入方式对使用者和程序都是透明的,也是 Istio 推荐的代理注入方式。在 Kubernetes 中,服务网格一般是依靠“动态准入控制”(Dynamic Admission Control)中的Mutating Webhook控制器来实现自动注入的。

流量劫持

边车代理做流量劫持最典型的方式是基于 iptables 进行的数据转发。

可靠通信

Envoy 在这方面进行了创新,它将代理的转发的行为规则抽象成 Listener、Router、Cluster 三种资源,以此为基础,又定义了应该如何发现和访问这些资源的一系列 API,现在这些资源和 API 被统称为“xDS 协议族”。自此以后,数据平面就有了如何描述各种配置和策略的事实标准,控制平面也有了与数据平面交互的标准接口。

控制平台

控制平面的特点是不直接参与程序间通信,而只会与数据平面中的代理通信,在程序不可见的背后,默默地完成下发配置和策略,指导数据平面工作。

服务网格生态

  • Envoy:2016 年 9 月开源的Envoy是目前边车代理产品中市场占有率最高的一款,已经在很多个企业的生产环境里经受过大量检验。Envoy 最初由 Lyft 公司开发,后来 Lyft 与 Google 和 IBM 三方达成合作协议,Envoy 就成了 Istio 的默认数据平面。Envoy 使用 C++语言实现,比起 Linkerd 在资源消耗方面有了明显的改善。此外,由于采用了公开的 xDS 协议进行控制,Envoy 并不只为 Istio 所私有,这个特性让 Envoy 被很多其他的管理平面选用,为它夺得市场占有率桂冠做出了重要贡献。2017 年 9 月,Envoy 加入 CNCF,成为 CNCF 继 Linkerd 之后的第二个数据平面项目。

  • Istio:Google、IBM 和 Lyft 公司联手打造的产品,以自己的 Envoy 为默认数据平面。Istio 是目前功能最强大的服务网格,如果你苦恼于这方面产品的选型,直接挑选 Istio 不一定是最合适的,但起码能保证应该是不会有明显缺陷的选择;同时 Istio 也是市场占有率第一的控制平面,不少公司的发布的服务网格产品都是在它基础上派生增强而来,譬如蚂蚁金服的 SOFAMesh、Google Cloud Service Mesh 等。不过,服务网格毕竟比容器运行时、容器编排要年轻,Istio 在服务网格领域尽管占有不小的优势,但统治力还远远不能与容器运行时领域的 Docker 和容器编排领域的 Kubernetes 相媲美。

-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道