访问远程服务
远程服务将计算机程序的工作范围从单机扩展到网络,从本地延伸至远程,是构建分布式系统的首要基础。而远程服务又不仅仅是为了分布式系统服务的,在网络时代,浏览器、移动设备、桌面应用和服务端的程序,普遍都有跟其他设备交互的需求,所以今天已经很难找到没有开发和使用过远程服务的程序员了,但是没有正确理解远程服务的程序员却仍比比皆是。
远程服务调用
远程服务调用(Remote Procedure Call,RPC)在计算机科学中已经存在了超过四十年时间,但在今天仍然可以在各种论坛、技术网站上时常遇见“什么是 RPC?”、“如何评价某某 RPC 技术?”、“RPC 更好还是 REST 更好?”之类的问题,仍然“每天”都有新的不同形状的 RPC 轮子被发明制造出来,仍然有层出不穷的文章去比对 Google gRPC、Facebook Thrift 等各家的 RPC 组件库的优劣。
像计算机科学这种知识快速更迭的领域,一项四十岁高龄的技术能有如此关注度,可算是相当稀罕的现象,这一方面是由于微服务风潮带来的热度,另外一方面,也不得不承认,确实有不少开发者对 RPC 本身解决什么问题、如何解决这些问题、为什么要这样解决都或多或少存在认知模糊。本节,笔者会从历史到现状,从表现到本质,尽可能深入地解释清楚 RPC 的来龙去脉。
进程间通信
尽管今天的大多数 RPC 技术已经不再追求这个目标了,但无可否认,RPC 出现的最初目的,就是为了让计算机能够跟调用本地方法一样去调用远程方法。所以,我们先来看一下本地方法调用时,计算机是如何处理的。笔者通过以下这段 Java 风格的伪代码,来定义几个稍后要用到的概念:
// Caller : 调用者,代码里的main()
// Callee : 被调用者,代码里的println()
// Call Site : 调用点,即发生方法调用的指令流位置
// Parameter : 参数,由Caller传递给Callee的数据,即“hello world”
// Retval : 返回值,由Callee传递给Caller的数据.以下代码中如果方法能够正常结束,它是void,如果方法异常完成,它是对应的异常
public static void main(String[] args) {例如,说到 DCOM 的失败和 Web Service 的式微,微软在它们的基础上推出了 .NET WCF
## REST 设计风格
很多人会拿 REST 与 RPC 互相比较,其实,REST 无论是在思想上、概念上,还是使用范围上,与 RPC 都不尽相同,充其量只能算是有一些相似,应用会有一部分重合之处,但本质上并不是同一类型的东西.
REST 与 RPC 在思想上差异的核心是抽象的目标不一样,即面向资源的编程思想与面向过程的编程思想两者之间的区别.面向过程编程、面向对象编程大家想必听说过,但什么是面向资源编程?这个问题等介绍完 REST 的特征之后我们再回头细说.
而概念上的不同是指 REST 并不是一种远程服务调用协议,甚至可以把定语也去掉,它就不是一种协议.协议都带有一定的规范性和强制性,最起码也该有个规约文档,譬如 JSON-RPC,它哪怕再简单,也要有个《JSON-RPC Specification》来规定协议的格式细节、异常、响应码等信息,但是 REST 并没有定义这些内容,尽管有一些指导原则,但实际上并不受任何强制的约束.常有人批评某个系统接口“设计得不够 RESTful”,其实这句话本身就有些争议,REST 只能说是风格而不是规范、协议,并且能完全达到 REST 所有指导原则的系统也是不多见的,这一点我们同样将在后文中详细讨论.
至于使用范围,REST 与 RPC 作为主流的两种远程调用方式,在使用上是确有重合的,但重合的区域有多大就见仁见智了.上一节提到了当前的 RPC 协议框架都各有侧重点,并且列举了 RPC 一些发展方向,如分布式对象、提升调用效率、简化调用复杂性等等.这里面分布式对象这一条线的应用与 REST 可以说是毫无关联;而能够重视远程服务调用效率的应用场景,就基本上已经排除了 REST 应用得最多的供浏览器端消费的远程服务,因为以浏览器作为前端,对于传输协议、序列化器这两点都不会有什么选择的权力,哪怕想要更高效率也有心无力.而在移动端、桌面端或者分布式服务端的节点之间通讯这一块,REST 虽然照样有宽阔的用武之地,只要支持 HTTP 就可以用于任何语言之间的交互,不过通常都会以网络没有成为性能瓶颈为使用前提,在需要追求传输效率的场景里,REST 提升传输效率的潜力有限,死磕 REST 又想要好的网络性能,一般不会有好效果;对追求简化调用的场景——前面提到的浏览器端就属于这一类的典型,众多 RPC 里也就 JSON-RPC 有机会与 REST 竞争,其他 RPC 协议与框架,哪怕是能够支持 HTTP 协议,哪怕提供了 JavaScript 版本的客户端(如 gRPC-Web),也只是具备前端使用的理论可行性,很少见有实际项目把它们真的用到浏览器上的.
但尽管有着种种不同,这两者还是产生了很频繁的比较与争论,这两种分别面向资源和面向过程的远程调用方式,就如同当年面向对象与面向过程的编程思想一样,非得分出个高低不可.
### 理解 REST
个人会有好恶偏爱,但计算机科学是务实的,有了 RPC,还会提出 REST,有了面向过程编程之后,还能产生面向资源编程,并引起广泛的关注、使用和讨论,说明后者一定是有些前者没有的闪光点,或者解决、避免了一些面向过程中的缺陷.我们不妨先去理解 REST 为什么会出现,然后再来讨论评价它.
简单搜索就能找到 REST 源于 Roy Thomas Fielding 在 2000 年发表的博士论文:《Architectural Styles and the Design of Network-based Software Architectures》,此文的确是 REST 的源头,但我们不应该忽略 Fielding 的身份和此前的工作背景,这些信息对理解 REST 的设计思想至关重要.
首先,Fielding 是一名很优秀的软件工程师,他是 Apache 服务器的核心开发者,后来成为了著名的 Apache 软件基金会的联合创始人;同时,Fielding 也是 HTTP 1.0 协议(1996 年发布)的专家组成员,后来还晋升为 HTTP 1.1 协议(1999 年发布)的负责人.HTTP 1.1 协议设计得极为成功,以至于发布之后长达十年的时间里,都没有收到多少修订的意见.用来指导 HTTP 1.1 协议设计的理论和思想,最初是以备忘录的形式用作专家组成员之间交流,除了 IETF、W3C 的专家外,并没有在外界广泛流传.
从时间上看,对 HTTP 1.1 协议的设计工作贯穿了 Fielding 的整个博士研究生涯,当起草 HTTP 1.1 协议的工作完成后,Fielding 回到了加州大学欧文分校继续攻读自己的博士学位.第二年,他更为系统、严谨地阐述了这套理论框架,并且以这套理论框架导出了一种新的编程思想,他为这种程序设计风格取了一个很多人难以理解,但是今天已经广为人知的名字 REST,即“表征状态转移”的缩写.
哪怕对编程和网络都很熟悉的同学,只从标题中也不太可能直接弄明白什么叫“表征”、啥东西的“状态”、从哪“转移”到哪.尽管在论文原文中确有论述这些概念,但写得确实相当晦涩(不想读英文的同学从此处获得中文翻译版本),笔者推荐比较容易理解 REST 思想的途径是先理解什么是 HTTP,再配合一些实际例子来进行类比,你会发现“REST”(Representational State Transfer)实际上是“HTT”(Hypertext Transfer)的进一步抽象,两者就如同接口与实现类的关系一般.
HTTP 中使用的“超文本”(Hypertext)一词是美国社会学家 Theodor Holm Nelson 在 1967 年于《Brief Words on the Hypertext》一文里提出的,下面引用的是他本人在 1992 年修正后的定义:
> **Hypertext**
> By now the word "hypertext" has become generally accepted for branching and responding text, but the corresponding word "hypermedia", meaning complexes of branching and responding graphics, movies and sound – as well as text – is much less used.
> 现在,"超文本"一词已被普遍接受,它指的是能够进行分支判断和差异响应的文本,相应地,"超媒体"一词指的是能够进行分支判断和差异响应的图形、电影和声音(也包括文本)的复合体.
> —— Theodor Holm Nelson *Literary Machines*, 1992
以上定义描述的“超文本(或超媒体,Hypermedia)”是一种“能够对操作进行判断和响应的文本(或声音、图像等)”,这个概念在上世纪 60 年代提出时应该还属于科幻的范畴,但是今天大众已经完全接受了它,互联网中一段文字可以点击、可以触发脚本执行、可以调用服务端,这一切已毫不稀奇.下面我们继续尝试从“超文本”或者“超媒体”的含义来理解什么是“表征”以及 REST 中其他关键概念,这里使用一个具体事例将其描述如下.
- **资源(Resource)**:譬如你现在正在阅读一篇名为《REST 设计风格》的文章,这篇文章的内容本身(你可以将其理解为其蕴含的信息、数据)我们称之为“资源”.无论你是购买的书籍、是在浏览器看的网页、是打印出来看的文稿、是在电脑屏幕上阅读抑或是手机上浏览,尽管呈现的样子各不相同,但其中的信息是不变的,你所阅读的仍是同一份“资源”.
- **表征(Representation)**:当你通过电脑浏览器阅读此文章时,浏览器向服务端发出请求“我需要这个资源的 HTML 格式”,服务端向浏览器返回的这个 HTML 就被称之为“表征”,你可能通过其他方式拿到本文的 PDF、Markdown、RSS 等其他形式的版本,它们也同样是一个资源的多种表征.可见“表征”这个概念是指信息与用户交互时的表示形式,这与我们软件分层架构中常说的“表示层”(Presentation Layer)的语义其实是一致的.
- **状态(State)**:当你读完了这篇文章,想看后面是什么内容时,你向服务器发出请求“给我下一篇文章”.但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”才能正确回应,这类在特定语境中才能产生的上下文信息即被称为“状态”.我们所说的有状态(Stateful)抑或是无状态(Stateless),都是只相对于服务端来说的,服务器要完成“取下一篇”的请求,要么自己记住用户的状态:这个用户现在阅读的是哪一篇文章,这称为有状态;要么客户端来记住状态,在请求的时候明确告诉服务器:我正在阅读某某文章,现在要读它的下一篇,这称为无状态.
- **转移(Transfer)**:无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供,因为只有服务端拥有该资源及其表征形式.服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移”.
通过“阅读文章”这个例子,笔者对资源等概念进行通俗的释义,你应该能够理解 REST 所说的“表征状态转移”的含义了.借着这个故事的上下文状态,笔者再继续介绍几个现在不涉及但稍后要用到的概念名词.
- **统一接口(Uniform Interface)**:上面说的服务器“通过某种方式”让表征状态发生转移,具体是什么方式?如果你真的是用浏览器阅读本文电子版的话,请把本文滚动到结尾处,右下角有下一篇文章的 URI 超链接地址,这是服务端渲染这篇文章时就预置好的,点击它让页面跳转到下一篇,就是所谓“某种方式”的其中一种方式.任何人都不会对点击超链接网页会出现跳转感到奇怪,但你细想一下,URI 的含义是统一资源标识符,是一个名词,如何能表达出“转移”动作的含义呢?答案是 HTTP 协议中已经提前约定好了一套“统一接口”,它包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS 七种基本操作,任何一个支持 HTTP 协议的服务器都会遵守这套规定,对特定的 URI 采取这些操作,服务器就会触发相应的表征状态转移.
- **超文本驱动(Hypertext Driven)**:尽管表征状态转移是由浏览器主动向服务器发出请求所引发的,该请求导致了“在浏览器屏幕上显示出了下一篇文章的内容”这个结果的出现.但是,你我都清楚这不可能真的是浏览器的主动意图,浏览器是根据用户输入的 URI 地址来找到网站首页,服务器给予的首页超文本内容后,浏览器再通过超文本内部的链接来导航到了这篇文章,阅读结束时,也是通过超文本内部的链接来再导航到下一篇.浏览器作为所有网站的通用的客户端,任何网站的导航(状态转移)行为都不可能是预置于浏览器代码之中,而是由服务器发出的请求响应信息(超文本)来驱动的.这点与其他带有客户端的软件有十分本质的区别,在那些软件中,业务逻辑往往是预置于程序代码之中的,有专门的页面控制器(无论在服务端还是在客户端中)来驱动页面的状态转移.
- **自描述消息(Self-Descriptive Messages)**:由于资源的表征可能存在多种不同形态,在消息中应当有明确的信息来告知客户端该消息的类型以及应如何处理这条消息.一种被广泛采用的自描述方法是在名为“Content-Type”的 HTTP Header 中标识出互联网媒体类型(MIME type),譬如“Content-Type : application/json; charset=utf-8”,则说明该资源会以 JSON 的格式来返回,请使用 UTF-8 字符集进行处理.
除了以上列出的这些看名字不容易弄懂的概念外,在理解 REST 的过程中,还有一个常见的误区值得注意,Fielding 提出 REST 时所谈论的范围是“架构风格与网络的软件架构设计”(Architectural Styles and Design of Network-based Software Architectures),而不是现在被人们所狭义理解的一种“远程服务设计风格”,这两者的范围差别就好比本书所谈论的话题“软件架构”与本章谈论话题“访问远程服务”的关系那样,前者是后者的一个很大的超集,尽管基于本节的主题和多数人的关注点考虑,我们确实是会以“远程服务设计风格”作为讨论的重点,但至少应该说明清楚它们范围上的差别.
### RESTful 的系统
如果你已经理解了上面这些概念,我们就可以开始讨论面向资源的编程思想与 Fielding 所提出的几个具体的软件架构设计原则了.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 协议中固有的操作命令来完成.
统一接口也是 REST 最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更合适,这事情哪怕是很长时间里都不会有个定论,也许永远都没有.但是,已经有一个基本清晰的结论是:面向资源编程的抽象程度通常更高.抽象程度高意味着坏处是往往距离人类的思维方式更远,而好处是往往通用程度会更好.用这样的语言去诠释 REST,大概本身就挺抽象的,笔者还是举个例子来说明:譬如,几乎每个系统都有的登录和注销功能,如果你理解成登录对应于 login() 服务,注销对应于 logout() 服务这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是 PUT Session,注销是 DELETE Session,这样你只需要设计一种“Session 资源”即可满足需求,甚至以后对 Session 的其他需求,如查询登陆用户的信息,就是 GET Session 而已,其他操作如修改用户信息等都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处.
想要在架构设计中合理恰当地利用统一接口,Fielding 建议系统应能做到每次请求中都包含资源的 ID,所有操作均通过资源 ID 来进行;建议每个资源都应该是自描述的消息;建议通过超文本来驱动应用状态的转移.
6. **按需代码(Code-On-Demand)**
按需代码被 Fielding 列为一条可选原则.它是指任何按照客户端(譬如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术,按需代码赋予了客户端无需事先知道所有来自服务端的信息应该如何处理、如何运行的宽容度.举个具体例子,以前的 Java Applet 技术,今天的 WebAssembly 等都属于典型的按需代码,蕴含着至此,REST 中的主要概念与思想原则已经介绍完毕,我们再回过头来讨论本节开篇提出的 REST 与 RPC 在思想上的差异.REST 的基本思想是面向资源来抽象问题,它与此前流行的编程思想——面向过程的编程在抽象主体上有本质的差别.在 REST 提出以前,人们设计分布式系统服务的唯一方案就只有 RPC,RPC 是将本地的方法调用思路迁移到远程方法调用上,开发者是围绕着“远程方法”去设计两个系统间交互的,譬如 CORBA、RMI、DCOM,等等.这样做的坏处不仅是“如何在异构系统间表示一个方法”、“如何获得接口能够提供的方法清单”都成了需要专门协议去解决的问题(RPC 的三大基本问题之一),更在于服务的每个方法都是完全独立的,服务使用者必须逐个学习才能正确地使用它们.Google 在《Google API Design Guide》中曾经写下这样一段话:
> Traditionally, people design RPC APIs in terms of API interfaces and methods, such as CORBA and Windows COM. As time goes by, more and more interfaces and methods are introduced. The end result can be an overwhelming number of interfaces and methods, each of them different from the others. Developers have to learn each one carefully in order to use it correctly, which can be both time consuming and error prone
以前,人们面向方法去设计 RPC API,譬如 CORBA 和 DCOM,随着时间推移,接口与方法越来越多却又各不相同,开发人员必须了解每一个方法才能正确使用它们,这样既耗时又容易出错.
—— Google API Design Guide, 2017
REST 提出以资源为主体进行服务设计的风格,能为它带来不少好处(自然也有坏处,笔者将在下一节集中谈论 REST 的不足与争议),譬如:
- **降低的服务接口的学习成本**.统一接口(Uniform Interface)是 REST 的重要标志,将对资源的标准操作都映射到了标准的 HTTP 方法上去,这些方法对于每个资源的用法都是一致的,语义都是类似的,不需要刻意去学习,更不需要有什么 Interface Description Language 之类的协议存在.
- **资源天然具有集合与层次结构**.以方法为中心抽象的接口,由于方法是动词,逻辑上决定了每个接口都是互相独立的;但以资源为中心抽象的接口,由于资源是名词,天然就可以产生集合与层次结构.举个具体例子,你想像一个商城用户中心的接口设计:用户资源会拥有多个不同的下级的资源,譬如若干条短消息资源、一份用户资料资源、一部购物车资源,购物车中又会有自己的下级资源,譬如多本书籍资源.很容易在程序接口中构造出这些资源的集合关系与层次关系,而且是符合人们长期在单机或网络环境中管理数据的直觉的.相信你不需要专门阅读接口说明书,也能轻易推断出获取用户 `icyfenix` 的购物车中的第 2 本书的 REST 接口应该表示为:
GET /users/icyfenix/cart/2
- **REST 绑定于 HTTP 协议**.面向资源编程不是必须构筑在 HTTP 之上,但 REST 是,这是缺点,也是优点.因为 HTTP 本来就是面向资源而设计的网络协议,纯粹只用 HTTP(而不是 SOAP over HTTP 那样在再构筑协议)带来的好处是 RPC 中的 Wire Protocol 问题就无需再多考虑了,REST 将复用 HTTP 协议中已经定义的概念和相关基础支持来解决问题.HTTP 协议已经有效运作了三十年,其相关的技术基础设施已是千锤百炼,无比成熟.而坏处自然是,当你想去考虑那些 HTTP 不提供的特性时,便会彻底地束手无策.
......
以上列举了一些面向资源的优点,笔者并非要证明它比面向过程、面向对象更优秀,是否选用 REST 的 API 设计风格,需要权衡的是你的需求场景、你团队的设计和开发人员是否能够适应面向资源的思想来设计软件,来编写代码.在互联网中,面向资源来进行网络传输是这三十年来 HTTP 协议精心培养出来的用户习惯,如果开发者能够适应 REST 不太符合人类思维习惯的抽象方式,那 REST 通常能够更好地匹配在 HTTP 基础上构建的互联网,在效率与扩展性方面会有可观的收益.
### RMM 成熟度
前面我们花费大量篇幅讨论了 REST 的思想、概念和指导原则等理论方面的内容,在这个小节里,我们将把重心放在实践上,把目光从整个软件架构设计进一步聚焦到 REST 接口设计上,以切合本节的题目“REST 设计风格”,也顺带填了前面埋下的“如何评价服务是否 RESTful”的坑.
《RESTful Web APIs》和《RESTful Web Services》的作者 Leonard Richardson 曾提出过一个衡量“服务有多么 REST”的 **Richardson 成熟度模型(Richardson Maturity Model)**,便于那些原本不使用 REST 的系统,能够逐步地导入 REST.Richardson 将服务接口“REST 的程度”从低到高,分为 0 至 3 级:
0. **The Swamp of Plain Old XML**:完全不 REST.另外,关于 Plain Old XML 这说法,SOAP 表示感觉有被冒犯到.
1. **Resources**:开始引入资源的概念.
2. **HTTP Verbs**:引入统一接口,映射到 HTTP 协议的方法上.
3. **Hypermedia Controls**:超媒体控制在本文里面的说法是“超文本驱动”,在 Fielding 论文里的说法是“Hypertext As The Engine Of Application State,HATEOAS”,其实都是指同一件事情.
笔者借用 Martin Fowler 撰写的关于 RMM 成熟度模型的文章中的实际例子(原文是 XML 写的,这里简化为 JSON 表示),来具体展示一下四种不同程度的 REST 反应到实际接口中会是怎样的.假设你是一名软件工程师,接到需求(原文中的需求复杂一些,这里简化了)的 UserStory 描述是这样的:
> **医生预约系统**
> 作为一名病人,我想要从系统中得知指定日期内我熟悉的医生是否具有空闲时间,以便于我向该医生预约就诊.
#### 第 0 级
医院开放了一个 `/appointmentService` 的 Web API,传入日期、医生姓名作为参数,可以得到该时间段该名医生的空闲时间,该 API 的一次 HTTP 调用如下所示:
POST /appointmentService?action=query HTTP/1.1
然后服务器会传回一个包含了所需信息的回应:
```json
{date: "2020-03-04", doctor: "mjones"}
HTTP/1.1 200 OK
[
{start:"14:00", end: "14:50", doctor: "mjones"},
{start:"16:00", end: "16:50", doctor: "mjones"}
]得到医生空闲的结果后,我觉得 14:00 的时间比较合适,于是进行预约确认,并提交了我的基本信息:
POST /appointmentService?action=confirm HTTP/1.1
{
appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"},
patient: {name: icyfenix, age: 30, ……}
}如果预约成功,那我能够收到一个预约成功的响应:
HTTP/1.1 200 OK
{
code: 0,
message: "Successful confirmation of appointment"
}如果发生了问题,譬如有人在我前面抢先预约了,那么我会在响应中收到某种错误信息:
HTTP/1.1 200 OK
{
code: 1,
message: "doctor not available"
}到此,整个预约服务宣告完成,直接明了,我们采用的是非常直观的基于 RPC 风格的服务设计似乎很容易就解决了所有问题…了吗?
第 1 级
第 0 级是 RPC 的风格,如果需求永远不会变化,也不会增加,那它完全可以良好地工作下去.但是,如果你不想为预约医生之外的其他操作、为获取空闲时间之外的其他信息去编写额外的方法,或者改动现有方法的接口,那还是应该考虑一下如何使用 REST 来抽象资源.
通往 REST 的第一步是引入资源的概念,在 API 中基本的体现是围绕着资源而不是过程来设计服务,说的直白一点,可以理解为服务的 Endpoint 应该是一个名词而不是动词.此外,每次请求中都应包含资源的 ID,所有操作均通过资源 ID 来进行,譬如,获取医生指定时间的空闲档期:
POST /doctors/mjones HTTP/1.1
{date: "2020-03-04"}然后服务器传回一组包含了 ID 信息的档期清单,注意,ID 是资源的唯一编号,有 ID 即代表“医生的档期”被视为一种资源:
HTTP/1.1 200 OK
[
{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
]我还是觉得 14:00 的时间比较合适,于是又进行预约确认,并提交了我的基本信息:
POST /schedules/1234 HTTP/1.1
{name: icyfenix, age: 30, ……}后面预约成功或者失败的响应消息在这个级别里面与之前一致,就不重复了.比起第 0 级,第 1 级的特征是引入了资源,通过资源 ID 作为主要线索与服务交互,但第 1 级至少还有三个问题并没有解决:一是只处理了查询和预约,如果我临时想换个时间,要调整预约,或者我的病忽然好了,想删除预约,这都需要提供新的服务接口.二是处理结果响应时,只能靠着结果中的 code、message 这些字段做分支判断,每一套服务都要设计可能发生错误的 code,这很难考虑全面,而且也不利于对某些通用的错误做统一处理;三是并没有考虑认证授权等安全方面的内容,譬如要求只有登录用户才允许查询医生档期时间,某些医生可能只对 VIP 开放,需要特定级别的病人才能预约,等等.
第 2 级
第 1 级遗留三个问题都可以靠引入统一接口来解决.HTTP 协议的七个标准方法是经过精心设计的,只要架构师的抽象能力够用,它们几乎能涵盖资源可能遇到的所有操作场景.REST 的做法是把不同业务需求抽象为对资源的增加、修改、删除等操作来解决第一个问题;使用 HTTP 协议的 Status Code,可以涵盖大多数资源操作可能出现的异常,而且 Status Code 可以自定义扩展,以此解决第二个问题;依靠 HTTP Header 中携带的额外认证、授权信息来解决第三个问题,这个在实战中并没有体现,请参考安全架构中的“凭证”相关内容.
按这个思路,获取医生档期,应采用具有查询语义的 GET 操作进行:
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1然后服务器会传回一个包含了所需信息的回应:
HTTP/1.1 200 OK
[
{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
]我仍然觉得 14:00 的时间比较合适,于是又进行预约确认,并提交了我的基本信息,用以创建预约,这是符合 POST 的语义的:
POST /schedules/1234 HTTP/1.1
{name: icyfenix, age: 30, ……}如果预约成功,那我能够收到一个预约成功的响应:
HTTP/1.1 201 Created
Successful confirmation of appointment如果发生了问题,譬如有人在我前面抢先预约了,那么我会在响应中收到某种错误信息:
HTTP/1.1 409 Conflict
doctor not available第 3 级
第 2 级是目前绝大多数系统所到达的 REST 级别,但仍不是完美的,至少还存在一个问题:你是如何知道预约 mjones 医生的档期是需要访问 /schedules/1234 这个服务 Endpoint 的?也许你甚至第一时间无法理解为何我会有这样的疑问,这当然是程序代码写的呀!但 REST 并不认同这种已烙在程序员脑海中许久的想法.RMM 中的 Hypermedia Controls、Fielding 论文中的 HATEOAS 和现在提的比较多的“超文本驱动”,所希望的是除了第一个请求是由你在浏览器地址栏输入所驱动之外,其他的请求都应该能够自己描述清楚后续可能发生的状态转移,由超文本自身来驱动.所以,当你输入了查询的指令之后:
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1服务器传回的响应信息应该包括诸如如何预约档期、如何了解医生信息等可能的后续操作:
HTTP/1.1 200 OK
{
schedules:[
{
id: 1234, start:"14:00", end: "14:50", doctor: "mjones",
links: [
{rel: "comfirm schedule", href: "/schedules/1234"}
]
},
{
id: 5678, start:"16:00", end: "16:50", doctor: "mjones",
links: [
{rel: "comfirm schedule", href: "/schedules/5678"}
]
}
],
links: [
...
]
}如果做到了第 3 级 REST,那服务端的 API 和客户端也是完全解耦的,你要调整服务数量,或者同一个服务做 API 升级将会变得非常简单.
不足与争议
以下是笔者所见过的怀疑 REST 能否在实践中真正良好应用的部分争议问题,笔者将自己的观点总结如下:
面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑
这是遇到最多的一个问题.HTTP 的四个最基础的命令 POST、GET、PUT 和 DELETE 很容易让人直接联想到 CRUD 操作,以至于在脑海中自然产生了直接的对应.REST 所能涵盖的范围当然远不止于此,不过要说 POST、GET、PUT 和 DELETE 对应于 CRUD 其实也没什么不对,只是这个 CRUD 必须泛化去理解,它们涵盖了信息在客户端与服务端之间如何流动的几种主要方式,所有基于网络的操作逻辑,都可以对应到信息在服务端与客户端之间如何流动来理解,有的场景里比较直观,而另一些场景中可能比较抽象.
针对那些比较抽象的场景,如果真不好把 HTTP 方法映射为资源的所需操作,REST 也并非刻板的教条,用户是可以使用自定义方法的,按 Google 推荐的 REST API 风格,自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀.譬如,我可以把删除操作映射到标准 DELETE 方法上,如果此外还要提供一个恢复删除的 API,那它可能会被设计为:
POST /schedules/1234:restore
POST /appointment/5678/delete:undelete HTTP/1.1
Host: api.example.com
然后尝试通过一种可被路由的机制,在代码观感上能够以标准的 HTTP 协议来实现远程服务调用.本节的责任人是 RPC,既然 REST 的抽象意图是面向资源的编程,那么 REST 在理论上只适合做抽象,不适合做实现,如果将其用于实现,就需要在这上面绑定的协议都必须是标准的 HTTP,但 HTTP 协议并非是为 REST 而设计的,一定会遇到许多语义方面的兼容性问题,本章我们不再展开讨论.此外,如何在这个约束之下解决如下问题,等后续到了事务、安全等章节再具体讨论.
- 如何表达那些非资源性的操作(如登录、验证、授权、计算等)?
- 如何将多个操作(比如下单时的高并发扣库存、即减库存并支付)合并为一个原子事务?
- 如何向客户端或其他服务隐藏数据来源,比如在微服务架构中的 BFF 层聚合多个服务数据后返回?
- 如何在 API 调用中携带身份、权限、隐私、审计等信息?
- 如何认证、鉴权,以及怎么保证传输安全?
- …
4 事务处理
事务是数据库系统最核心的特性,也是系统设计中确保数据一致性的关键技术.另外,由于事务中的 ACID 特性的影响力实在太大,以至于大家在架构设计中处理分布式系统一致性时,往往也习惯向 ACID 这种强一致性去靠,或者直接把 ACID 概念扩展到分布式场景.下面我们引入本地事务、全局事务、共享资源等模型中反复出现的一些核心概念.
在系统设计的时候,有经验的架构师往往都有一种直感:能够将复杂的业务流程化简成一个事务的,最好就放到一个事务里面,不能化简的,就必须面对分布式事务的挑战.几乎所有涉及金融、交易、支付等关键业务领域,都需要确保最终一致性,哪怕在一段时间内的状态可能会不一致,系统也必须通过某种机制使其收敛至一致状态.
[CONTEXT_OVERLAP] {rel: “doctor info”, href: “/doctors/mjones/info”} [/CONTEXT_OVERLAP]
```sql
SELECT price FROM books WHERE name = '深入理解Java虚拟机' /* 时间顺序:1,事务: T1 */
UPDATE books SET price = 110 WHERE name = '深入理解Java虚拟机' /* 时间顺序:2,事务: T2 */
SELECT price FROM books WHERE name = '深入理解Java虚拟机' /* 时间顺序:3,事务: T1 */
如果隔离级别是读已提交,这两次重复执行的查询结果将会不同。同一行数据,读已提交 允许事务在中间被其他事务修改,结果读出了不一样的值,这也是一个事务受到其他事务 影响,隔离性被破坏的表现。注意,读已提交与可重复读对是否加读锁是有区别的,读已 提交加入了读锁,但因查询完成后立即释放,所以不会一直持有。不过即使不加读锁,只 读事务完全也能靠回滚日志(Undo Log)来避免不可重复读的问题,例如 MySQL/InnoDB 的默认隔离级别为可重复读,但通过MVCC,在读已提交下也能避免不可重复读的问题。
- 读已提交的下一个隔离级别是读未提交(Read Uncommitted),它完全不添加读锁,仅有写锁会在事务结束后释放。读未提交的隔离级别是最低的,并发吞吐量是最高的,但隔离性也是最差的,会出现脏读问题(Dirty Reads),它是指一个事务读取到另一个事务尚未提交的数据,具体表现是在事务执行过程中,对同一行数据的查询结果发生了改变,且这个改变并不是数据被真的修改了,而是读到了另一个事务修改后准备回滚的数据。例如,在某次统计 Fenix’s Bookstore 所有书籍的售价总额时,执行了一条查询语句,此时另有事务正在出售《深入理解 Java 虚拟机》这本书,它执行了将书价改为 110 元的 SQL,还未来得及提交,就发生了崩溃,导致事务被回滚,书籍价格最终仍为 90 元。这条修改语句的执行过程如下面的 SQL 所示:
SELECT sum(price) FROM books /* 时间顺序:1,事务: T1 */
UPDATE books SET price = 110 WHERE name = '深入理解Java虚拟机' /* 时间顺序:2,事务: T2 */
SELECT sum(price) FROM books /* 时间顺序:3,事务: T1 */注意,在时间顺序 1 与 3 之间执行了尚未提交的修改,此时虽然最终价格并未被修改,但在读未提交下,查询到的统计总额却已经包括了 110 元的结果。脏读与另外几种并不完全一样,虽然同样是在事务执行中受到了其他事务的影响,但因为没有读到真正被提交的数据,读到的是一个随后可能被回滚的中间状态,甚至可能对统计结果造成污染。脏读在多数场合都是无害的,但确实有可能给应用程序带来困扰,譬如一个事务读取到另一个事务未提交的脏数据,并基于该数据做出决策写入数据库,就会产生因果不一致。不过在所有隔离级别中,读未提交的并发性能是最好的,在一些对一致性要求不高的场景下可以使用。
以上四种隔离等级属于数据库理论的基础知识,多数开发者的熟知程度都较高,所以这里并不打算过多展开细节。但有一个细节仍值得强调:数据库的锁并不是用来实现隔离性的唯一手段。除了锁,另一种被现代数据库广泛采用的实现隔离性的技术是多版本并发控制(Multi-Version Concurrency Control,MVCC)。MVCC 是一种读取优化策略,它通过在数据上维护多个版本,使得读操作不用阻塞写操作,写操作也不用阻塞读操作,只阻塞写操作。这种设计让数据库在高并发读写场景下的吞吐量大幅提升。MVCC 的实现原理并不复杂,它在每一行记录的后面额外保存两个隐藏的列:创建时间(或版本号)和删除时间(或过期版本号)。每开始一个新的事务时,系统会分配一个全局单调递增的事务 ID,版本号就是根据事务 ID 来确定的。当事务对某条记录进行修改时,Undo Log 不会真的去覆盖已经存在的数据,而是产生一个新的版本,并将旧版本标记为删除,当前事务只能看到小于或等于它的事务 ID 的数据版本。这样做的效果相当于在数据的视角上为所有事务建立了一份快照,因此 MVCC 在可重复读隔离级别下的读取操作常被称为“快照读”(Snapshot Read)。
额外知识:MVCC 与可重复读
MVCC 与可重复读是两码事,很多人认为可重复读必须通过 MVCC 来实现,或者 MVCC 天然就能达到可重复读级别,这其实是个误解。MVCC 仅是一种优化手段,它并不是隔离级别的定义,即便没有 MVCC,单靠锁也能实现可重复读(比如每次读取都加读锁并直到事务结束再释放)。同样,即便有 MVCC,也未必能达到可重复读的要求。例如 MySQL 在读已提交级别下同样启用了 MVCC,此时 MVCC 只保证读取到的数据是已提交的,但并不能保证在同一事务中重复读取同一行数据得到的结果一定相同,因为其他事务的提交会造成版本号的变化,这会导致可重复读被破坏。
介绍完数据库如何实现原子性、持久性和隔离性之后,最后还剩下一个一致性(C)。一致性其实并不是数据库本身要去保证的特性,而是由应用程序使用数据库的这些原子性、持久性、隔离性手段来保证的。简而言之,A、I、D 是工具,C 是目的。数据库本身只提供工具,最终数据的一致性需要应用程序来保障。因此,本书中的“事务”都不会脱离应用程序的视角,尤其是在之后讲解全局事务时,将涉及大量应用编码的事务实现模式。
SELECT count(1) FROM books WHERE price < 100/* 时间顺序:
1,事务: T1 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)
/* 时间顺序:
2,事务: T2 */
SELECT count(1) FROM books WHERE price < 100
/* 时间顺序:
3,事务: T1 */
SELECT * FROM books WHERE id = 1;本地事务
如果隔离级别是读已提交,这两次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务 T2 中的更新语句可以马上提交成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是可重复读的话,由于数据已被事务 T1 施加了读锁且读取后不会马上释放,所以事务 T2 无法获取到写锁,更新就会被阻塞,直至事务 T1 被提交或回滚后才能提交。
读已提交的下一个级别是读未提交(Read Uncommitted),读未提交对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁。读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。譬如笔者觉得《深入理解 Java 虚拟机》从 90 元涨价到 110 元是损害消费者利益的行为,又执行了一条更新语句把价格改回了 90 元,在提交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的,按 90 元卖要亏本,于是笔者随即回滚了事务,场景如下 SQL 所示:
/* 时间顺序:1,事务: T1 */
/* 注意没有COMMIT */
UPDATE books SET price = 90 WHERE id = 1;
/* 时间顺序:2,事务: T2 */
/* 这条SELECT模拟购书的操作的逻辑 */
SELECT * FROM books WHERE id = 1;
/* 时间顺序:3,事务: T1 */
ROLLBACK;
/* 时间顺序:4,事务: T2 */不过,在之前修改价格后,事务 T1 已经按 90 元的价格卖出了几本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,即上述事务 T1 中两条查询语句得到的结果并不相同。如果你不能理解这句话中的“反而”二字,请再重读一次写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据,如果事务 T1 读取数据并不需要去加读锁的话,就会导致事务 T2 未提交的数据也马上就能被事务 T1 所读到。这同样是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是读已提交的话,由于事务 T2 持有数据的写锁,所以事务 T1 的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此 T1 中的查询就会被阻塞,直至事务 T2 被提交或者回滚后才能得到结果。
理论上还存在更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务的没提交之前的修改可以被另外一个事务的修改覆盖掉,脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性都无法实现,所以一般谈论隔离级别时不会将它纳入讨论范围内,而将读未提交视为是最低级的隔离级别。
以上四种隔离级别属于数据库理论的基础知识,多数大学的计算机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的某种固有属性或设定来讲解,这导致很多同学只能对这些现象死记硬背。其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。
除了都以锁来实现外,以上四种隔离级别还有另一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性,针对这种“一个事务读+另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。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),在本节里,全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案。请注意,理论上真正的全局事务并没有“单个服务”的约束,它本来就是 DTP(Distributed Transaction Processing)模型中的概念,但本节所讨论的内容是一种在分布式环境中仍追求强一致性的事务处理方案,对于多节点而且互相调用彼此服务的场合(典型的就是现在的微服务系统)是极不合适的,今天它几乎只实际应用于单服务多数据源的场合中,为了避免与后续介绍的放弃了 ACID 的弱一致性事务处理方式相互混淆,所以这里的全局事务所指范围有所缩减,后续涉及多服务多数据源的事务,笔者将称其为“分布式事务”。
1991 年,为了解决分布式事务的一致性问题,X/Open 组织(后来并入了 The Open Group)提出了一套名为 X/Open XA(XA 是 eXtended Architecture 的缩写)的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口。XA 接口是双向的,能在一个事务管理器和多个资源管理器(Resource Manager)之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚,现在我们在 Java 代码中还偶尔能看见的 XADataSource、XAResource 这些名字都源于此。
不过,XA 并不是 Java 的技术规范(XA 提出那时还没有 Java),而是一套语言无关的通用规范,所以 Java 中专门定义了 JSR 907 Java Transaction API,基于 XA 模式在 Java 语言中的实现了全局事务处理的标准,这也就是我们现在所熟知的 JTA。JTA 最主要的两个接口是:
- 事务管理器的接口:
javax.transaction.TransactionManager。这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的,还提供了另外一套javax.transaction.UserTransaction接口,用于通过程序代码手动开启、提交和回滚事务。 - 满足 XA 规范的资源定义接口:
javax.transaction.xa.XAResource,任何资源(JDBC、JMS 等等)如果想要支持 JTA,只要实现XAResource接口中的方法即可。
JTA 原本是 Java EE 中的技术,一般情况下应该由 JBoss、WebSphere、WebLogic 这些 Java EE 容器来提供支持,但现在 Bittronix、Atomikos 和 JBossTM(以前叫 Arjuna)都以 JAR 包的形式实现了 JTA 的接口,称为 JOTM(Java Open Transaction Manager),使得我们能够在 Tomcat、Jetty 这样的 Java SE 环境下也能使用 JTA。
现在,我们对本章的场景事例做另外一种假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件仍与之前相同,那情况会发生什么变化呢?假如你平时以声明式事务来编码,那它与本地事务看起来可能没什么区别,都是标个 @Transactional 注解而已,但如果以编程式事务来实现的话,就能在写法上看出差异,伪代码如下所示:
public void buyBook(PaymentBill bill) {
userTransaction.begin();
warehouseTransaction.begin();
businessTransaction.begin();
try {
userAccountService.pay(bill.getMoney());
warehouseService.deliver(bill.getItems());
businessAccountService.receipt(bill.getMoney());
userTransaction.commit();
warehouseTransaction.commit();
businessTransaction.commit();从代码上可看出,程序的目的是要做三次事务提交,但实际上代码并不能这样写,试想一下,如果在 businessTransaction.commit() 中出现错误,代码转到 catch 块中执行,此时 userTransaction 和 warehouseTransaction 已经完成提交,再去调用 rollback() 方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证了。为了解决这个问题,XA 将事务提交拆分成为两阶段过程:
- 准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。这里所说的准备操作跟人类语言中通常理解的准备并不相同,对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂
1.4 架构师的视角
全局事务
不写入最后一条 Commit Record 而已,这意味着在做完数据持久化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。
提交阶段:又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条 Commit Record 而已,通常能够快速完成,只有收到 Abort 指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。
以上这两个过程被称为 “两段式提交”(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件。
- 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通信在全过程都不会出现误差,即可以丢失消息,但不会传递错误的消息,XA 的设计目标并不是解决诸如拜占庭将军一类的问题。两段式提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这也是为了尽量控制网络风险的考虑。
- 必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,并向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。
上面所说的协调者、参与者都是可以由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生的,而应用程序相对于数据库来说只扮演客户端的角色。
两段式提交的交互时序如图 3-2 所示。
图 3-1 两段式提交的交互时序示意图
协调者要求所有参与者进入准备阶段(参与者已进入准备阶段)→要求所有参与者进入提交阶段(参与者已进入提交阶段)→或者要求所有参与者回滚事务(参与者已回滚事务)。协调者与参与者之间存在失败或超时选择分支。
两段式提交原理简单,并不难实现,但有几个非常显著的缺点:
- 单点问题:协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。
- 性能问题:两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。
- 一致性风险:前面已经提到,两段式提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985 年 Fischer、Lynch、Paterson 提出了 “FLP 不可能原理”,证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与“CAP 不可兼得原理”齐名的理论。而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然被断开,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交,也没有办法回滚,产生了数据不一致的问题。
为了缓解两段式提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了 “三段式提交”(3 Phase Commit,3PC)协议。三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改称为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。
同样也是由于事务失败回滚概率变小的原因,在三段式提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。三段式提交的操作时序如图 3-2 所示。
图 3-3 三段式提交的操作时序
协调者询问阶段:是否有把握完成事务(参与者是)→ 准备阶段:写入日志,锁定资源(确认 Ack)→ 提交阶段:提交事务(已提交)→ 或者要求回滚(已回滚)。存在失败和超时的分支处理。
从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。譬如,进入 PreCommit 阶段之后,协调者发出的指令不是 Ack 而是 Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的 Abort 指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。
共享事务
与全局事务里讨论的单个服务使用多个数据源正好相反,共享事务(Share Transaction) 是指多个服务共用同一个数据源。这里有必要再强调一次“数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。在部署应用集群时最常采用的模式是将同一套程序部署到多个中间件服务器上,构成多个副本实例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有自己的专属的数据源,通常是中间件以 JNDI 的形式开放给程序代码使用。这种情况下,所有副本实例的数据访问都是完全独立的,并没有任何交集,每个节点使用的仍是最简单的本地事务。而本节讨论的是多个服务之间会产生业务交集的场景,举个具体例子,在 Fenix’s Bookstore 的场景事例中,假设用户账户、商家账户和商品仓库都存储于同一个数据库之中,但用户、商户和仓库每个领域都部署了独立的微服务,此时一次购书的业务操作将贯穿三个微服务,它们都要在数据库中修改数据。如果我们直接将不同数据源就视为是不同数据库,那上一节所讲的全局事务和下一节要讲的分布式事务都是可行的,不过,针对这种每个数据源连接的都是同一个物理数据库的特例,共享事务则有机会成为另一条可能提高性能、降低复杂度的途径,当然,也很有可能是一个伪需求。
一种理论可行的方案是直接让各个服务共享数据库连接,在同一个应用进程中的不同持久化工具(JDBC、ORM、JMS 等)间共享数据库连接并不困难,某些中间件服务器,譬如 WebSphere 会内置有“可共享连接”功能来专门给予这方面的支持。但这种共享的前提是数据源的使用者都在同一个进程内,由于数据库连接的基础是网络连接,它是与 IP 地址和端口号绑定的,字面意义上的“不同服务节点共享数据库连接”很难做到,所以为了实现共享事务,就必须新增一个“交易服务器”的中间角色,无论是用户服务、商家服务还是仓库服务,它们都通过同一台交易服务器来与数据库打交道。如果将交易服务器的对外接口按照 JDBC 规范来实现的话,那它完全可以视为是一个独立于各个服务的远程数据库连接池,或者直接作为数据库代理来看待。此时三个服务所发出的交易请求就有可能做到交由交易服务器上的同一个数据库连接,通过本地事务的方式完成。譬如,交易服务器根据不同服务节点传来的同一个事务 ID,使用同一个数据库连接来处理跨越多个服务的交易事务,如图 3-4 所示。
图 3-4 使用同一个数据库处理多个交易服务
用户账户、交易服务器、商家账户、商品仓库都与同一个数据库相连。
之所以强调理论可行,是因为该方案是与实际生产系统中的压力方向相悖的,一个服务集群里数据库才是压力最大而又最不容易伸缩拓展的重灾区,所以现实中只有类似 ProxySQL、MaxScale 这样用于对多个数据库实例做负载均衡的数据库代理(其实用 ProxySQL 代理单个数据库,再启用 Connection Multiplexing,已经接近于前面所提及的交易服务器方案了),而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。这也是说它更有可能是个伪需求的原因,如果你有充足理由让多个微服务去共享数据库,就必须找到更加站得住脚的理由来向团队解释拆分微服务的目的是什么才行。
在日常开发中,上述方案还存在一类更为常见的变种形式:使用消息队列服务器来代替交易服务器。用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这被称作 “单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。“共享事务”的提法和这里所列的两种处理方式在实际应用中并不值得提倡,鲜有采用这种方式的成功案例,能够查询到的资料几乎都发源于十余年前 Spring 的核心开发者 Dave Syer 撰写的文章《Distributed Transactions in Spring, with and without XA》。笔者把共享事务列为本章四种事务类型之一只是为了叙述逻辑的完备,尽管拆分微服务后仍然共享数据库的情况在现实中并不少见,但笔者个人不赞同将共享事务作为一种常规的解决方案来考量。
分布式事务
本章中所说的 分布式事务(Distributed Transaction) 特指多个服务同时访问多个数据源的事务处理机制,请注意它与 DTP 模型中“分布式事务”的差异。DTP 模型所指的“分布式”是相对于数据源而言的,并不涉及服务,这部分内容已经在“全局事务”一节里进行过讨论。本节所指的“分布式”是相对于服务而言的,如果严谨地说,它更应该被称为 “在分布式服务环境下的事务处理机制”。
在 2000 年以前,人们曾经寄希望于 XA 的事务机制可以在本节所说的分布式环境中也能良好地应用,但这个美好的愿望今天已经被 CAP 理论彻底地击碎了,接下来就先从 CAP 与 ACID 的矛盾说起。
CAP 与 ACID
CAP 定理(Consistency、Availability、Partition Tolerance Theorem),也称为 Brewer 定理,起源于在 2000 年 7 月,是加州大学伯克利分校的 Eric Brewer 教授于“ACM 分布式计算原理研讨会(PODC)”上提出的一个猜想。
图 3-5 CAP 理论原稿(那时候还只是猜想)
图示略。
两年之后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 以严谨的数学推理证明了 CAP 猜想。自此,CAP 正式从猜想变为分布式计算领域所公认的著名定理。这个定理里描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
- 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,以后讨论分布式共识算法时,我们还会再提到一致性,那种面向副本复制的一致性与这里面向数据库状态的一致性严格来说并不完全等同,具体差别我们将在后续分布式共识算法中再作探讨。
- 可用性(Availability):代表系统不间断地提供服务的能力,理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如 99.9999%可用,即代表平均年故障修复时间为 32 秒。
- 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。
单纯只列概念,CAP 是比较抽象的,笔者仍以本章开头所列的场景事例来说明这三种特性对分布式系统来说将意味着什么。假设 Fenix’s Bookstore 的服务拓扑如图 3-6 所示,一个来自最终用户的交易请求,将交由账号、商家和仓库服务集群中某一个节点来完成响应:
图 3-6 Fenix's Bookstore 的服务拓扑示意图
最终用户 → Fenix’s Bookstore。其中包含账号服务集群(账号节点1、2…N)、商家服务集群(商家节点1、2…N)、仓库服务集群(仓库节点1、2…N),各自连接各自的数据库。
在这套系统中,每一个单独的服务节点都有自己的数据库(这里是为了便于说明问题的假设,在实际生产系统中,一般应避免将用户余额这样的数据设计成存储在多个可写的数据库中),假设某次交易请求分别由“账号节点 1”、“商家节点 2”、“仓库节点 N”联合进行响应。当用户购买一件价值 100 元的商品后,账号节点 1 首先应给该用户账号扣减 100 元货款,它在自己数据库扣减 100 元很容易,但它还要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况。
- 如果该变动信息没有及时同步给其他账号节点,将导致有可能发生用户购买另一商品时,被分配给到另一个节点处理,由于看到账号上有不正确的余额而错误地发生了原本无法进行的交易,此为一致性问题。
- 如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统暂时无法提供服务而被拒绝交易,此为可用性问题。
- 如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的,整个集群能否承受由于部分节点之间的连接中断而仍然能够正确地提供服务,此为分区容忍性。
以上还仅仅涉及了账号服务集群自身的 CAP 问题,对于整个 Fenix’s Bookstore 站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的 CAP 问题,譬如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售。又譬如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了的可用性问题,等等。
由于 CAP 定理已有严格的证明,本节不去探讨为何 CAP 不可兼得,而是直接分析如果舍弃 C、A、P 时所带来的不同影响。
- 如果放弃分区容忍性(CA without P),意味着我们将假设节点之间通信永远是可靠的。永远可靠的通信在分布式系统中必定不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就会始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以 Oracle 的 RAC 集群为例,它的每一个节点均有自己独立的 SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据的,通过共享磁盘的方式来避免出现网络分区。因而 Oracle RAC 虽然也是由多个实例组成的数据库,但它并不能称作是分布式数据库。
- 如果放弃可用性(CP without A),意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题相当于退化到前面“全局事务”中讨论的一个系统使用多个数据源的场景之中,我们可以通过 2PC/3PC 等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的 CP 系统情况一般用于对数据质量要求很高的场合中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统,以 HBase 集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预先估计的。
- 如果放弃一致性(AP without C),意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数 NoSQL 库和支持分布式的缓存框架都是 AP 系统,以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。
读到这里,不知道你是否对“选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题“事务”原本的目的就是获得“一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。但无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为 “强一致性”(Strong Consistency),有时也称为 “线性一致性”(Linearizability,通常是在讨论共识算法的场景中),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求 “弱一致性”。不过,如果单纯只说“弱一致性”那其实就是“不保证一致性”的意思……人类语言这东西真的是博大精深。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为 “最终一致性”(Eventual Consistency),它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。
在本节讨论的主题“分布式事务”中,目标同样也不得不从之前三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人们把使用 ACID 的事务称为“刚性事务”,而把笔者下面将要介绍几种分布式事务的常见做法统称为 “柔性事务”。
可靠事件队列
最终一致性的概念是 eBay 的系统架构师 Dan Pritchett 在 2008 年在 ACM 发表的论文《Base: An Acid Alternative》中提出的,该论文总结了一种独立于 ACID 获得的强一致性之外的、使用 BASE 来达成一致性目的的途径。BASE 分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)的缩写。BASE 这提法简直是把数据库科学家酷爱凑缩写的恶趣味发挥到淋漓尽致,不过有 ACID vs BASE(酸 vs 碱)这个朗朗上口的梗,该论文的影响力的确传播得足够快。在这里笔者就不多谈 BASE 中的概念问题了,虽然调侃它是恶趣味,但这篇论文本身作为最终一致性的概念起源,并系统性地总结了一种针对分布式事务的技术手段,是非常有价值的。
我们继续以本章的场景事例来解释 Dan Pritchett 提出的“可靠事件队列”的具体做法,目标仍然是交易过程中正确修改账号、仓库和商家服务中的数据,图 3-7 列出了修改过程的时序图。
图 3-7 具体修改步骤时序图
最终用户 → Fenix’s Bookstore → 账号服务:启动事务,扣减货款,提交本地事务,发出消息 → 消息队列 → 仓库服务:扣减库存(成功/失败),直至成功 → 更新消息表,仓库服务完成 → 商家服务:货款收款(成功/失败),直至成功 → 更新消息表,商家服务完成。
- 最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
- Fenix’s Bookstore 首先应对用户账号扣款、商家账号收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序,这种评估一般直接体现在程序代码中,有一些大型系统也可能会实现动态排序。譬如,根据统计,最有可能的出现的交易异常是用户购买了商品,但是不同意扣款,或者账号余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收款,如果到了商家收款环节,一般就不会出什么意外了。那顺序就应该安排成最容易出错的最先进行,即:账号扣款 → 仓库出库 → 商家收款。
- 账号服务进行扣款业务,如扣款成功,则在自己的数据库建立一张消息表,里面存入一条消息:“事务 ID:某 UUID,扣款:100 元(状态:已完成),仓库出库《深入理解 Java 虚拟机》:1 本(状态:进行中),某商家收款:100 元(状态:进行中)”,注意,这个步骤中“扣款业务”和“写入消息”是使用同一个本地事务写入账号服务自己的数据库的。
- 在系统中建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去(也可以串行地发,即一个成功后再发送另一个,但在我们讨论的场景中没必要)。这时候可能产生以下几种情况。
- 商家和仓库服务都成功完成了收款和出库工作,向用户账号服务器返回执行结果,用户账号服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。
- 商家或仓库服务中至少一个因网络原因,未能收到来自用户账号服务的消息。此时,由于用户账号服务器中存储的消息状态一直处于“进行中”,所以消息服务器将在每次轮询的时候持续地向未响应的服务重复发送消息。这个步骤的可重复性决定了所有被消息服务器发送的消息都必须具备幂等性,通常的设计是让消息带上一个唯一的事务 ID,以保证一个事务中的出库、收款动作会且只会被处理一次。
- 商家或仓库服务有某个或全部无法完成工作,譬如仓库发现《深入理解 Java 虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(譬如补充了新库存),或者被人工介入为止。由此可见,可靠事件队列只要第一步业务完成了,后续就没有失败回滚的概念,只许成功,不许失败。
- 商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失,此时,用户账号服务仍会重新发出下一条消息,但因操作具备幂等性,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息,此过程重复直至双方网络通信恢复正常。
- 也有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候上述情况 2、4 也可以交由消息框架来保障。
以上这种靠着持续重试来保证可靠性的解决方案谈不上是 Dan Pritchett 的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字叫作 “最大努力交付”(Best-Effort Delivery),譬如 TCP 协议中未收到 ACK 应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为 “最大努力一次提交”(Best-Effort 1PC),指的就是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。
TCC 事务
TCC 是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写,是由数据库专家 Pat Helland 在 2007 年撰写的论文《Life beyond Distributed Transactions: An Apostate’s Opinion》中提出。
前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠的,过程也足够简单(相对于 TCC 来说),但整个过程完全没有任何隔离性可言,有一些业务中隔离性是无关紧要的,但有一些业务中缺乏隔离性就会带来许多麻烦。譬如在本章的场景事例中,缺乏隔离性会带来的一个显而易见的问题便是“超售”:完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部分属于数据库本地事务方面的知识,可以参考前面的讲解。如果业务需要隔离,那架构师通常就应该重点考虑 TCC 方案,该方案天生适合用于需要强隔离性的分布式事务中。
在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段也可能会重复执行,因此也需要具备幂等性。
TCC 的整个过程如图 3-8 所示。
图 3-8 TCC 事务的交互时序
业务发起方 → 参与者:Try → 成功 → Confirm → 成功;若 Try 失败或 Confirm 失败进入 Cancel 阶段。协调者在流程中居中控制。
TCC 实际上是一种应用层面的两段式提交,它通过将业务逻辑拆分为三个阶段,把原本数据库层面处理的冲突检测、资源锁定和提交/回滚等操作上升到应用层,从而允许跨多个服务、跨多个数据源进行事务处理。而正是因为它需要业务逻辑有意识地实现这三个操作,所以被认为具有较强侵入性。但好处是,TCC 能够提供比最大努力一次提交更好的隔离性,非常适合金融、订单等要求强隔离性的分布式场景。
SAGA 事务
SAGA 事务的历史十分悠久,其概念最早可以追溯到 1987 年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM SIGMOD 上发表的论文《SAGAS》。不过 SAGA 在当时被提出时,尚未有分布式事务的明确提法,它更多是指代一种将长事务拆分为多个本地事务的协作模式,后来在分布式服务环境中被重新发掘,成为分布式事务的一种实现模式。
SAGA 由一连串的本地事务组成,每一个本地事务负责更新数据并发布一个消息或者事件来触发下一个本地事务。如果某个本地事务因为业务规则或者约束失败,SAGA 会执行一系列补偿事务来抵消前面本地事务已经提交的修改。这听起来和可靠事件队列有几分相似,但关键区别在于 SAGA 是明确以“补偿”机制来应对失败处理的,这种补偿逻辑需要开发者显示写出,即系统不仅要能正向执行,还要有能力反向撤销。
SAGA 有两种常见的实现方式:
- 编排式 (Choreography):事务参与者之间通过事件进行通信,没有中央协调器,每个参与者产生并监听其他参与者的事件,并据此决定自己的操作及补偿。
- 控制式 (Orchestration):由一个集中的协调器(Orchestrator)来负责决策事务的执行顺序和补偿策略,告知每个参与者应该执行什么操作。这种方式可以更容易地管理复杂的流程和异常路径。
SAGA 事务的优点在于它不需要像 TCC 那样在执行业务之前预留资源,因此不会长时间持有资源锁,更适合长时间运行的事务。其缺点是对开发者要求较高,必须设计和实现对应的补偿操作,并且补偿过程中可能出现差之毫厘谬以千里的数据错漏,通常需要配合对账、异常检测等机制来保障精确性。
AT 事务
AT 事务是 Seata(Simple Extensible Autonomous Transaction Architecture)框架所提供的一种分布式事务解决方案,Seata 是由阿里巴巴开源的一款分布式事务中间件。AT 模式是 Seata 中主推的事务模式,它基于两段式提交协议,但对其做了优化,试图做到对业务无侵入,让开发者像使用本地事务一样使用分布式事务。
AT 模式的原理是:在业务数据库中加入一张回滚日志表(undo_log),在第一阶段 Seata 对业务 SQL 进行解析,自动生成对应的前置镜像(Before Image)和后置镜像(After Image)并保存到回滚日志中,然后将业务数据和回滚日志在同一个本地事务中提交,释放本地锁。在第二阶段,如果全局事务决定提交,则异步删除回滚日志;如果全局事务决定回滚,则通过回滚日志中的前置镜像来恢复数据,但注意恢复前会检查是否存在脏写(即数据被其他事务修改过),若存在脏写则可能需要人工介入。
AT 模式相比 TCC,最大的优势在于对业务的无侵入性;相比可靠事件队列,它能够提供更接近强一致性的保证(读已提交隔离级别)。但它的不足之处是必须在第二阶段执行数据校对和回滚,需要一个独立的协调器(TC),并且不支持对应用进行了跨语言、跨平台的多样性限制。
总结
分布式事务的解决方案多种多样,从 CAP 理论可以看出,强一致性、高可用性和网络分区容忍性在分布式系统中是矛盾的。柔性事务正是为了在保证基本可用性的前提下,追求最终一致性而出现的。其中,可靠事件队列、TCC、SAGA 和 AT 事务等都是在不同场景下用来平衡一致性、隔离性、性能和复杂度的工具。架构师需要根据业务容忍的错误程度、性能要求、实施复杂度等因素,选择恰当的分布式事务方案。
1.4 架构师的视角(续)
讲解。如果业务需要隔离,那架构师通常就应该重点考虑 TCC 方案,该方案天生适合用于需要强隔离性的分布式事务中。
在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认 / 释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
按照我们的场景事例,TCC 的执行过程应该如图 3-8 所示。
- 最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
- 创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:
- 用户服务:检查业务可行性,可行的话,将该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
- 仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
- 商家服务:检查业务可行性,不需要冻结资源。
- 如果第 2 步所有业务均反馈业务可行,将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:
- 用户服务:完成业务操作(扣减那被冻结的 100 元)。
- 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。
- 商家服务:完成业务操作(收款 100 元)。
- 第 3 步如果全部完成,事务宣告正常结束,如果第 3 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,即进行最大努力交付。
- 如果第 2 步有任意一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
- 用户服务:取消业务操作(释放被冻结的 100 元)。
- 仓库服务:取消业务操作(释放被冻结的 1 本书)。
- 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
- 第 5 步如果全部完成,事务宣告以失败回滚结束,如果第 5 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,即进行最大努力交付。
由上述操作过程可见,TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(譬如阿里开源的 Seata)去完成,尽量减轻一些编码工作量。
SAGA 事务
TCC 事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。TCC 的最主要限制是它的业务侵入性很强,这里并不是重复上一节提到的它需要开发编码配合所带来的工作量,而更多的是指它所要求的技术可控性上的约束。譬如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,允许直接在购物时通过 U 盾或扫码支付,在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以 TCC 中的第一步 Try 阶段往往无法施行。我们只能考虑采用另外一种柔性事务方案:SAGA 事务。SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。
SAGA 事务模式的历史十分悠久,还早于分布式事务概念的提出。它源于 1987 年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)。文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA 由两部分操作组成。
- 大事务拆分若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T₁,T₂,…,Tᵢ,…,Tₙ。每个子事务都应该是或者能被视为是原子行为。如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 Tᵢ 等价。
- 为每一个子事务设计对应的补偿动作,命名为 C₁,C₂,…,Cᵢ,…,Cₙ。Tᵢ 与 Cᵢ 必须满足以下条件:
- Tᵢ 与 Cᵢ 都具备幂等性。
- Tᵢ 与 Cᵢ 满足交换律(Commutative),即先执行 Tᵢ 还是先执行 Cᵢ,其效果都是一样的。
- Cᵢ 必须能成功提交,即不考虑 Cᵢ 本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。
如果 T₁ 到 Tₙ 均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:
- 正向恢复(Forward Recovery):如果 Tᵢ 事务提交失败,则一直对 Tᵢ 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T₁,T₂,…,Tᵢ(失败),Tᵢ(重试)…,Tᵢ₊₁,…,Tₙ。
- 反向恢复(Backward Recovery):如果 Tᵢ 事务提交失败,则一直执行 Cᵢ 对 Tᵢ 进行补偿,直至成功为止(最大努力交付)。这里要求 Cᵢ 必须(在持续重试后)执行成功。反向恢复的执行模式为:T₁,T₂,…,Tᵢ(失败),Cᵢ(补偿),…,C₂,C₁。
与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。譬如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到 Fenix’s Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是由 Fenix’s Bookstore 系统将货款转回到用户账上作为补偿措施却是完全可行的。
SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结 / 撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式。
基于数据补偿的其他应用 —— AT 事务模式
基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,这些方案笔者就不开独立小节,放到这里一起来解释。举个具体例子,譬如阿里的 GTS(Global Transaction Service,Seata 由 GTS 开源而来)所提出的“AT 事务模式”就是这样的一种应用。
从整体上看是 AT 事务是参照了 XA 两段提交协议实现的,但针对 XA 2PC 的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),设计了针对性的解决方案。大致的做法是在业务数据提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL”。基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交的模式,相比起 2PC 极大地提升了系统的吞吐量水平。而代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不一定是总能成功的。譬如,当本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Write),这时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,人工其实也很难进行有效处理。所以 GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待,这种设计以牺牲一定性能为代价,避免了有两个分布式事务中包含的本地事务修改了同一个数据,从而避免脏写。在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。
透明多级分流系统
奥卡姆剃刀原则
Entities should not be multiplied without necessity
如无必要,勿增实体
—— Occam’s Razor,William of Ockham
现代的企业级或互联网系统,“分流”是必须要考虑的设计,分流所使用手段数量之多、涉及场景之广,可能连它的开发者本身都未必能全部意识到。这听起来似乎并不合理,但笔者认为这恰好是优秀架构设计的一种体现,“分布广阔”源于“多级”,“意识不到”谓之“透明”,也即本章我们要讨论的主题“透明多级分流系统”(Transparent Multilevel Diversion System,“透明多级分流系统”这个词是笔者自己创造的,业内通常只提“Transparent Multilevel Cache”,但我们这里谈的并不仅仅涉及到缓存)的来由。
在用户使用信息系统的过程中,请求从浏览器出发,在域名服务器的指引下找到系统的入口,经过网关、负载均衡器、缓存、服务集群等一系列设施,最后触及到末端存储于数据库服务器中的信息,然后逐级返回到用户的浏览器之中。这其中要经过很多技术部件。作为系统的设计者,我们应该意识到不同的设施、部件在系统中有各自不同的价值。
- 有一些部件位于客户端或网络的边缘,能够迅速响应用户的请求,避免给后方的 I/O 与 CPU 带来压力,典型如本地缓存、内容分发网络、反向代理等。
- 有一些部件的处理能力能够线性拓展,易于伸缩,可以使用较小的代价堆叠机器来获得与用户数量相匹配的并发性能,应尽量作为业务逻辑的主要载体,典型如集群中能够自动扩缩的服务节点。
- 有一些部件稳定服务对系统运行有全局性的影响,要时刻保持着容错备份,维护着高可用性,典型如服务注册中心、配置中心。
- 有一些设施是天生的单点部件,只能依靠升级机器本身的网络、存储和运算性能来提升处理能力,如位于系统入口的路由、网关或者负载均衡器(它们都可以做集群,但一次网络请求中无可避免至少有一个是单点的部件)、位于请求调用链末端的传统关系数据库等,都是典型的容易形成单点部件。
对系统进行流量规划时,我们应该充分理解这些部件的价值差异,有两条简单、普适的原则能指导我们进行设计:
- 第一条原则是尽可能减少单点部件,如果某些单点是无可避免的,则应尽最大限度减少到达单点部件的流量。在系统中往往会有多个部件能够处理、响应用户请求,譬如要获取一张存储在数据库的用户头像图片,浏览器缓存、内容分发网络、反向代理、Web 服务器、文件服务器、数据库都可能提供这张图片。恰如其分地引导请求分流至最合适的组件中,避免绝大多数流量汇集到单点部件(如数据库),同时依然能够在绝大多数时候保证处理结果的准确性,使单点系统在出现故障时自动而迅速地实施补救措施,这便是系统架构中多级分流的意义。
- 另一条更关键的原则是奥卡姆剃刀原则。作为一名架构设计者,你应对多级分流的手段有全面的理解与充分的准备,同时清晰地意识到这些设施并不是越多越好。在实际构建系统时,你应当在有明确需求、真正必要的时候再去考虑部署它们。不是每一个系统都要追求高并发、高可用的,根据系统的用户量、峰值流量和团队本身的技术与运维能力来考虑如何部署这些设施才是合理的做法,在能满足需求的前提下,最简单的系统就是最好的系统。
本章,笔者将会根据流量从客户端发出到服务端处理这个过程里,所流经的与功能无关的技术部件为线索,解析这里面每个部件的透明工作原理与起到的分流作用。这节所讲述的客户端缓存、域名服务器、传输链路、内容分发网络、负载均衡器、服务端缓存,都是为了达成“透明分流”这个目标所采用的工具与手段,高可用架构、高并发则是通过“透明分流”所获得的价值。
客户端缓存
客户端缓存(Client Cache)
HTTP 协议的无状态性决定了它必须依靠客户端缓存来解决网络传输效率上的缺陷。
浏览器的缓存机制几乎是在万维网刚刚诞生时就已经存在,在 HTTP 协议设计之初,便确定了服务端与客户端之间“无状态”(Stateless)的交互原则,即要求每次请求是独立的,每次请求无法感知也不能依赖另一个请求的存在,这既简化了 HTTP 服务器的设计,也为其水平扩展能力留下了广袤的空间。但无状态并不只有好的一面,由于每次请求都是独立的,服务端不保存此前请求的状态和资源,所以也不可避免地导致其携带有重复的数据,造成网络性能降低。HTTP 协议对此问题的解决方案便是客户端缓存,在 HTTP 从 1.0 到 1.1,再到 2.0 版本的每次演进中,逐步形成了现在被称为“状态缓存”、“强制缓存”(许多资料中简称为“强缓存”)和“协商缓存”的 HTTP 缓存机制。
HTTP 缓存中,状态缓存是指不经过服务器,客户端直接根据缓存信息对目标网站的状态判断,以前只有 301/Moved Permanently(永久重定向)这一种;后来在 RFC6797 中增加了 HSTS(HTTP Strict Transport Security)机制,用于避免依赖 301/302 跳转 HTTPS 时可能产生的降级中间人劫持(详细可见安全架构中的“传输”),这也属于另一种状态缓存。由于状态缓存所涉内容只有这么一点,后续我们就只聚焦讨论强制缓存与协商缓存两种机制。
强制缓存
HTTP 的强制缓存对一致性处理的策略就如它的名字一样,十分直接:假设在某个时点到来以前,譬如收到响应后的 10 分钟内,资源的内容和状态一定不会被改变,因此客户端可以无须经过任何请求,在该时点前一直持有和使用该资源的本地缓存副本。
根据约定,强制缓存在浏览器的地址输入、页面链接跳转、新开窗口、前进和后退中均可生效,但在用户主动刷新页面时应当自动失效。HTTP 协议中设有以下两类 Header 实现强制缓存。
- Expires:Expires 是 HTTP/1.0 协议中开始提供的 Header,后面跟随一个截至时间参数。当服务器返回某个资源时带有该 Header 的话,意味着服务器承诺截止时间之前资源不会发生变动,浏览器可直接缓存该数据,不再重新发请求,示例:
HTTP/1.1 200 OK
Expires: Wed, 8 Apr 2020 07:28:00 GMTExpires 是 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 的使用示例如下:
HTTP/1.1 200 OK
Cache-Control: max-age=600Cache-Control 在客户端的请求 Header 或服务器的响应 Header 中都可以存在,它定义了一系列的参数,且允许自行扩展(即不在标准 RFC 协议中,由浏览器自行支持的参数),其标准的参数主要包括有:
- max-age 和 s-maxage:max-age 后面跟随一个以秒为单位的数字,表明相对于请求时间(在 Date Header 中会注明请求时间)多少秒以内缓存是有效的,资源不需要重新从服务器中获取。相对时间避免了 Expires 中采用的绝对时间可能受客户端时钟影响的问题。s-maxage 中的“s”是“Share”的缩写,意味“共享缓存”的有效时间,即允许被 CDN、代理等持有的缓存有效时间,用于提示 CDN 这类服务器应在何时让缓存失效。
- public 和 private:指明是否涉及到用户身份的私有资源,如果是 public,则可以被代理、CDN 等缓存,如果是 private,则只能由用户的客户端进行私有缓存。
- no-cache 和 no-store:no-cache 指明该资源不应该被缓存,哪怕是同一个会话中对同一个 URL 地址的请求,也必须从服务端获取,令强制缓存完全失效,但此时下一节中的协商缓存机制依然是生效的;no-store 不强制会话中相同 URL 资源的重复获取,但禁止浏览器、CDN 等以任何形式保存该资源。
- no-transform:禁止资源被任何形式地修改。譬如,某些 CDN、透明代理支持自动 GZip 压缩图片或文本,以提升网络性能,而 no-transform 就禁止了这样的行为,它要求 Content-Encoding、Content-Range、Content-Type 均不允许进行任何形式的修改。
- min-fresh 和 only-if-cached:这两个参数是仅用于客户端的请求 Header。min-fresh 后续跟随一个以秒为单位的数字,用于建议服务器能返回一个不少于该时间的缓存资源(即包含 max-age 且不少于 min-fresh 的数字)。only-if-cached 表示客户端要求不必给它发送资源的具体内容,此时客户端就仅能使用事先缓存的资源来进行响应,若缓存不能命中,就直接返回 503/Service Unavailable 错误。
- must-revalidate 和 proxy-revalidate:must-revalidate 表示在资源过期后,一定需要从服务器中进行获取,即超过了 max-age 的时间后,就等同于 no-cache 的行为,proxy-revalidate 用于提示代理、CDN 等设备资源过期后的缓存行为,除对象不同外,语义与 must-revalidate 完全一致。
协商缓存
强制缓存是基于时效性的,但无论是人还是服务器,其实多数情况下都并没有什么把握去承诺某项资源多久不会发生变化。另外一种基于变化检测的缓存机制,在一致性上会有比强制缓存更好的表现,但需要一次变化检测的交互开销,性能上就会略差一些,这种基于检测的缓存机制,通常被称为“协商缓存”。另外,应注意在 HTTP 中协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的,譬如,当强制缓存存在时,直接从强制缓存中返回资源,无须进行变动检查;而当强制缓存超过时效,或者被禁止(no-cache / must-revalidate),协商缓存仍可以正常地工作。协商缓存有两种变动检查机制,分别是根据资源的修改时间进行检查,以及根据资源唯一标识是否发生变化来进行检查,它们都是靠一组成对出现的请求、响应 Header 来实现的:
- Last-Modified 和 If-Modified-Since:Last-Modified 是服务器的响应 Header,用于告诉客户端这个资源的最后修改时间。对于带有这个 Header 的资源,当客户端需要再次请求时,会通过 If-Modified-Since 把之前收到的资源最后修改时间发送回服务端。
如果此时服务端发现资源在该时间后没有被修改过,就只要返回一个 304/Not Modified 的响应即可,无须附带消息体,达到节省流量的目的,如下所示:
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=600
Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT如果此时服务端发现资源在该时间之后有变动,就会返回 200/OK 的完整响应,在消息体中包含最新的资源,如下所示:
HTTP/1.1 200 OK
Cache-Control: public, max-age=600
Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT客户端缓存
ETag 与 If-None-Match
ETag 是服务器响应中的 Header,用于告知客户端该资源的唯一标识。HTTP 服务器可以根据自身需要自由决定如何生成这个标识,例如 Apache 服务器的 ETag 值默认是对文件的索引节点(INode)、大小和最后修改时间进行哈希计算后得到的。对于带有 ETag Header 的资源,当客户端需要再次请求时,会通过 If-None-Match 将之前收到的资源唯一标识发送回服务端。
如果此时服务端经计算后发现资源的唯一标识与客户端上传回来的一致,说明资源没有被修改过,就只需要返回一个 304 Not Modified 的响应,且不必附带消息体,从而达到节省流量的目的,如下所示:
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=600
ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"如果此时服务端发现资源的唯一标识发生变动,就会返回 200 OK 的完整响应,并在消息体中包含最新的资源,如下所示:
HTTP/1.1 200 OK
Cache-Control: public, max-age=600
ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"ETag 是 HTTP 中一致性最强的缓存机制。例如,Last-Modified 标注的最后修改时间只能精确到秒级——如果某些文件在 1 秒以内被修改多次,就无法准确标注文件的修改时间;又或者某些文件会被定期生成,其内容可能根本没有变化,但 Last-Modified 却改变了,导致文件无法有效利用缓存。在这些情况下,Last-Modified 都可能产生资源一致性问题,只能使用 ETag 来解决。
然而 ETag 也是 HTTP 中性能开销最大的缓存机制,体现在每次请求时,服务端都必须对资源进行哈希计算,这比简单地获取一下修改时间要消耗更多资源。ETag 和 Last-Modified 是允许一起使用的,服务器会优先验证 ETag,在 ETag 一致的前提下再去对比 Last-Modified,这是为了防止某些 HTTP 服务器未将文件修改日期纳入哈希计算范围。
客户端缓存
到这里为止,HTTP 的协商缓存机制已经能很好地处理通过 URL 获取单个资源的场景,为什么要强调“单个资源”呢?在 HTTP 协议的设计中,一个 URL 地址是有可能能够提供多份不同版本的资源,譬如,一段文字的不同语言版本,一个文件的不同编码格式版本,一份数据的不同压缩方式版本,等等。因此针对请求的缓存机制,也必须能够提供对应的支持。为此,HTTP 协议设计了以 Accept*(Accept、Accept-Language、Accept-Charset、Accept-Encoding)开头的一套请求 Header 和对应的以 Content-*(Content-Language、Content-Type、Content-Encoding)开头的响应 Header,这些 Headers 被称为 HTTP 的内容协商机制。与之对应的,对于一个 URL 能够获取多个资源的场景中,缓存也同样也需要有明确的标识来获知根据什么内容来对同一个 URL 返回给用户正确的资源。这个就是 Vary Header 的作用,Vary 后面应该跟随一组其他 Header 的名字,譬如:
HTTP/1.1 200 OK
Vary: Accept, User-Agent
以上响应的含义是应该根据 MIME 类型和浏览器类型来缓存资源,获取资源时也需要根据请求 Header 中对应的字段来筛选出适合的资源版本。
根据约定,协商缓存不仅在浏览器的地址输入、页面链接跳转、新开窗口、前进、后退中生效,而且在用户主动刷新页面(F5)时也同样是生效的,只有用户强制刷新(Ctrl+F5)或者明确禁用缓存(譬如在 DevTools 中设定)时才会失效,此时客户端向服务端发出的请求会自动带有“Cache-Control: no-cache”。
域名解析
域名缓存(DNS Lookup)
DNS 也许是全世界最大、使用最频繁的信息查询系统,如果没有适当的分流机制,DNS 将会成为整个网络的瓶颈。
大家都知道 DNS 的作用是将便于人类理解的域名地址转换为便于计算机处理的 IP 地址,也许你会觉得好笑:笔者在接触计算机网络的开头一段不短的时间里面,都把 DNS 想像成一个部署在全世界某个神秘机房中的大型电话本式的翻译服务。后来,当笔者第一次了解到 DNS 的工作原理,并得知世界根域名服务器的 ZONE 文件只有 2MB 大小,甚至可以打印出来物理备份的时候,对 DNS 系统的设计是非常惊叹的。
域名解析对于大多数信息系统,尤其是对于基于互联网的系统来说是必不可少的组件,却属于没有太高存在感,通常都不会受重点关注的设施,不过 DNS 本身的工作过程,以及它对系统流量能够施加的影响,却还是有许多程序员不太了解;而且 DNS 本身就堪称是示范性的透明多级分流系统,非常符合本章的主题,值得我们去借鉴。
无论是使用浏览器抑或是在程序代码中访问某个网址域名,譬如以 www.icyfenix.com.cn 为例,如果没有缓存的话,都会先经过 DNS 服务器的解析翻译,找到域名对应的 IP 地址才能开始通信,这项操作是操作系统自动完成的,一般不需要用户程序的介入。不过,DNS 服务器并不是一次性地将 www.icyfenix.com.cn 直接解析成 IP 地址,需要经历一个递归的过程。首先 DNS 会将域名还原为 www.icyfenix.com.cn.,注意最后多了一个点“.”,它是“.root”的含义。早期的域名必须带有这个点才能被 DNS 正确解析,如今几乎所有的操作系统、DNS 服务器都可以自动补上结尾的点号,然后开始如下解析步骤:
-
客户端先检查本地的 DNS 缓存,查看是否存在并且是存活着的该域名的地址记录。DNS 是以存活时间(Time to Live,TTL)来衡量缓存的有效情况的,所以,如果某个域名改变了 IP 地址,DNS 服务器并没有任何机制去通知缓存了该地址的机器去更新或者失效掉缓存,只能依靠 TTL 超期后的重新获取来保证一致性。后续每一级 DNS 查询的过程都会有类似的缓存查询操作,再遇到时笔者就不重复叙述了。
-
客户端将地址发送给本机操作系统中配置的本地 DNS(Local DNS),这个本地 DNS 服务器可以由用户手工设置,也可以在 DHCP 分配时或者在拨号时从 PPP 服务器中自动获取到。
-
本地 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 组地址记录,由此而来的限制。
-
现在假设本地 DNS 是全新的,上面不存在任何域名的权威服务器记录,所以当 DNS 查询请求按步骤 3 的顺序一直查到根域名服务器之后,它将会得到“
cn的权威服务器”的地址记录,然后通过“cn的权威服务器”,得到“com.cn的权威服务器”的地址记录,以此类推,最后找到能够解释www.icyfenix.com.cn的权威服务器地址。 -
通过“
www.icyfenix.com.cn的权威服务器”,查询www.icyfenix.com.cn的地址记录,地址记录并不一定就是指 IP 地址,在 RFC 规范中有定义的地址记录类型已经多达数十种,譬如 IPv4 下的 IP 地址为 A 记录,IPv6 下的 AAAA 记录、主机别名 CNAME 记录,等等。
前面提到过,每种记录类型中还可以包括多条记录,以一个域名下配置多条不同的 A 记录为例,此时权威服务器可以根据自己的策略来进行选择,典型的应用是智能线路:根据访问者所处的不同地区(譬如华北、华南、东北)、不同服务商(譬如电信、联通、移动)等因素来确定返回最合适的 A 记录,将访问者路由到最合适的数据中心,达到智能加速的目的。
DNS 系统多级分流的设计使得 DNS 系统能够经受住全球网络流量不间断的冲击,但也并非全无缺点。典型的问题是响应速度,当极端情况(各级服务器均无缓存)下的域名解析可能导致每个域名都必须递归多次才能查询到结果,显著影响传输的响应速度,譬如图 4-1 所示高达 310 毫秒的 DNS 查询。
图 4-1 首次 DNS 请求耗时(图片来自网络)
专门有一种被称为“DNS 预取”(DNS Prefetching)的前端优化手段用来避免这类问题:如果网站后续要使用来自于其他域的资源,那就在网页加载时生成一个 link 请求,促使浏览器提前对该域名进行预解释,譬如下面代码所示:
<link rel="dns-prefetch" href="//domain.not-icyfenx.cn">而另一种可能更严重的缺陷是 DNS 的分级查询意味着每一级都有可能受到中间人攻击的威胁,产生被劫持的风险。要攻陷位于递归链条顶层的(譬如根域名服务器,cn 权威服务器)服务器和链路是非常困难的,它们都有很专业的安全防护措施。但很多位于递归链底层或者来自本地运营商的 Local DNS 服务器的安全防护则相对松懈,甚至不少地区的运营商自己就会主动进行劫持,专门返回一个错的 IP,通过在这个 IP 上代理用户请求,以便给特定类型的资源(主要是 HTML)注入广告,以此牟利。
为此,最近几年出现了另一种新的 DNS 工作模式:HTTPDNS(也称为 DNS over HTTPS,DoH)。它将原本的 DNS 解析服务开放为一个基于 HTTPS 协议的查询服务,替代基于 UDP 传输协议的 DNS 域名解析,通过程序代替操作系统直接从权威 DNS 或者可靠的 Local DNS 获取解析数据,从而绕过传统 Local DNS。这种做法的好处是完全免去了“中间商赚差价”的环节,不再惧怕底层的域名劫持,能够有效避免 Local DNS 不可靠导致的域名生效缓慢、来源 IP 不准确、产生的智能线路切换错误等问题。
传输链路
传输链路优化(Transmission Optimization)
今天的传输链路优化原则,在若干年后的未来再回头看它们时,其中多数已经成了奇技淫巧,有些甚至成了反模式。
经过客户端缓存的节流、经过 DNS 服务的解析指引,程序发出的请求流量便正式离开客户端,踏上以服务器为目的地的旅途了,这个过程就是本节的主角:传输链路。
可能不少人的第一直觉会认为传输链路是开发者完全不可控的因素,网络路由跳点的数量、运营商铺设线路的质量决定了线路带宽的大小、速率的高低。然而事实并非如此,程序发出的请求能否与应用层、传输层协议提倡的方式相匹配,对传输的效率也会有极大影响。最容易体现这点的是那些前端网页的优化技巧,只要简单搜索一下,就能找到很多以优化链路传输为目的的前端设计原则,譬如经典的雅虎 YSlow-23 条规则中与传输相关的内容如下。
-
Minimize HTTP Requests。
减少请求数量:请求每次都需要建立通信链路进行数据传输,这些开销很昂贵,减少请求的数量可有效的提高访问性能,对于前端开发者,可能用来减少请求数量的手段包括:- 雪碧图(CSS Sprites)
- CSS、JS 文件合并/内联(Concatenation / Inline)
- 分段文档(Multipart Document)
- 媒体(图片、音频)内联(Data Base64 URI)
- 合并 Ajax 请求(Batch Ajax Request)
- ……
-
Split Components Across Domains。
扩大并发请求数:现代浏览器(Chrome、Firefox)一般对每个域名支持 6 个(IE 为 8-13 个)并发请求,如果希望更快地加载大量图片或其他资源,需要进行域名分片(Domain Sharding),将图片同步到不同主机或者同一个主机的不同域名上。 -
GZip Components。
启用压缩传输:启用压缩能够大幅度减少需要在网络上传输内容的大小,节省网络流量。 -
Avoid Redirects。
避免页面重定向:当页面发生了重定向,就会延迟整个文档的传输。在 HTML 文档到达之前,页面中不会呈现任何东西,降低了用户体验。 -
Put Stylesheets at the Top,Put Scripts at the Bottom。
按重要性调节资源优先级:将重要的、马上就要使用的、对客户端展示影响大的资源,放在 HTML 的头部,以便优先下载。 -
…………
这些原则在今天暂时仍算得上有一定价值,但在若干年后再回头看它们,大概率其中多数已经成了奇技淫巧,有些甚至成了反模式。导致这种变化的原因是 HTTP 协议还在持续发展,从 20 世纪 90 年代的 HTTP/1.0 和 HTTP/1.1,到 2015 年发布的 HTTP/2,再到 2019 年的 HTTP/3,由于 HTTP 协议本身的变化,使得“适合 HTTP 传输的请求”的特征也在不断变化。
连接数优化
我们知道 HTTP(特指 HTTP/3 以前)是以 TCP 为传输层的应用层协议,但 HTTP over TCP 这种搭配只能说是 TCP 在当今网络中统治性地位所造就的结果,而不能说它们两者配合工作就是合适的。回想一下你上网平均每个页面停留的时间,以及每个页面中包含的资源(HTML、JS、CSS、图片等)数量,可以总结出 HTTP 传输对象的主要特征是数量多、时间短、资源小、切换快。另一方面,TCP 协议要求必须在三次握手完成之后才能开始数据传输,这是一个可能高达“百毫秒”为计时尺度的事件;另外,TCP 还有慢启动的特性,使得刚刚建立连接时传输速度是最低的,后面再逐步加速直至稳定。由于 TCP 协议本身是面向于长时间、大数据传输来设计的,在长时间尺度下,它连接建立的高昂成本才不至于成为瓶颈,它的稳定性和可靠性的优势才能展现出来。因此,可以说 HTTP over TCP 这种搭配在目标特征上确实是有矛盾的,以至于 HTTP/1.x 时代,大量短而小的 TCP 连接导致了网络性能的瓶颈。为了缓解 HTTP 与 TCP 之间的矛盾,聪明的程序员们一面致力于减少发出的请求数量,另外一方面也致力于增加客户端到服务端的连接数量,这就是上面 YSlow 规则中“Minimize HTTP Requests”与“Split Components Across Domains”两条优化措施的根本依据所在。
通过前端开发者的各种 Tricks,的确能够减少消耗 TCP 连接数量,这是有数据统计作为支撑的。图 4-2 和图 4-3 展示了 HTTP Archive 对最近五年来数百万个 URL 地址采样得出的结论:页面平均请求没有改变的情况下(桌面端下降 3.8%,移动端上升 1.4%),TCP 连接正在持续且幅度较大地下降(桌面端下降 36.4%,移动端下降 28.6%)。
图 4-2 HTTP 平均请求数量,70 余个,没有明显变化
图 4-3 TCP 连接数量,约 15 个,有明显下降趋势
但是,通过开发人员的 Tricks 来节省 TCP 连接,这样的优化措施并非只有好处,它们同时也带来了诸多不良的副作用:
- 如果你用 CSS Sprites 将多张图片合并,意味着任何场景下哪怕只用到其中一张小图,也必须完整加载整个大图片;任何场景下哪怕一张小图要进行修改,都会导致整个缓存失效,类似地,样式、脚本等其他文件的合并也会造成同样的问题。
- 如果你使用了媒体内嵌,除了要承受 Base64 编码导致传输容量膨胀 1/3 的代价外(Base64 以 8 bit 表示 6 bit 数据),也将无法有效利用缓存。
- 如果你合并了异步请求,这就会导致所有请求返回时间都受最慢的那个请求的拖累,整体响应速度下降.
- 如果你把图片放到不同子域下面,将会导致更大的 DNS 解析负担,而且浏览器对两个不同子域下的同一图片必须持有两份缓存,也使得缓存效率的下降。
- ……
由此可见,一旦在技术根基上出现问题,依赖使用者通过各种 Tricks 去解决,无论如何都难以摆脱“两害相权取其轻”的权衡困境,否则这就不是 Tricks 而是会成为一种标准的设计模式了。
在另一方面,HTTP 的设计者们并不是没有尝试过在协议层面去解决连接成本过高的问题,即使是 HTTP 协议的最初版本(指 HTTP/1.0,忽略非正式的 HTTP/0.9 版本)就已经支持了(连接复用技术在 HTTP/1.0 中并不是默认开启的,是在 HTTP/1.1 中变为默认开启)连接复用技术,即今天大家所熟知的持久连接(Persistent Connection),也称为连接 Keep-Alive 机制。持久连接的原理是让客户端对同一个域名长期持有一个或多个不会用完即断的 TCP 连接。典型做法是在客户端维护一个 FIFO 队列,每次取完数据(如何在不断开连接下判断取完数据将会放到稍后传输压缩部分去讨论)之后一段时间内不自动断开连接,以便获取下一个资源时直接复用,避免创建 TCP 连接的成本。
但是,连接复用技术依然是不完美的,最明显的副作用是“队首阻塞”(Head-of-Line Blocking)问题。请设想以下场景:浏览器有 10 个资源需要从服务器中获取,此时它将 10 个资源放入队列,入列顺序只能按照浏览器遇见这些资源的先后顺序来决定的。但如果这 10 个资源中的第 1 个就让服务器陷入长时间运算状态会怎样呢?当它的请求被发送到服务端之后,服务端开始计算,而运算结果出来之前 TCP 连接中并没有任何数据返回,此时后面 9 个资源都必须阻塞等待。因为服务端虽然可以并行处理另外 9 个请求(譬如第 1 个是复杂运算请求,消耗 CPU 资源,第 2 个是数据库访问,消耗数据库资源,第 3 个是访问某张图片,消耗磁盘 I/O 资源,消耗磁盘 I/O 资源,这就很适合并行),但问题是处理结果无法及时返回客户端,服务端不能哪个请求先完成就返回哪个,更不可能将所有要返回的资源混杂到一起交叉传输,原因是只使用一个 TCP 连接来传输多个资源的话,如果顺序乱了,客户端就很难区分哪个数据包归属哪个资源了。
2014 年,IETF 发布的 RFC 7230 中提出了名为“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)技术,如图 4-4 所示。
图 4-4 HTTP/2 的多路复用(图片来源)
有了多路复用的支持,HTTP/2 就可以对每个域名只维持一个 TCP 连接(One Connection Per Origin)来以任意顺序传输任意数量的资源,既减轻了服务器的连接压力,开发者也不用去考虑域名分片这种事情来突破浏览器对每个域名最多 6 个连接数限制了。而更重要的是,没有了 TCP 连接数的压力,就无须刻意压缩 HTTP 请求了,所有通过合并、内联文件(无论是图片、样式、脚本)以减少请求数的需求都不再成立,甚至反而是徒增副作用的反模式。
说这是反模式,也许还有一些前端开发者会不同意,认为 HTTP 请求少一些总是好的,减少请求数量,最起码还减少了传输中耗费的 Headers。必须先承认一个事实,在 HTTP 传输中 Headers 占传输成本的比重是相当的大,对于许多小资源,甚至可能出现 Headers 的容量比 Body 的还要大,以至于在 HTTP/2 中必须专门考虑如何进行 Header 压缩的问题。但是,以下几个因素决定了通过合并资源文件减少请求数,对节省 Headers 成本也并没有太大帮助:
- 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 图片,便不会被二次压缩,空耗性能。
不过,大概就没有多少人想过压缩与之前提到的用于节约 TCP 的持久连接机制是存在冲突的。在网络时代的早期,服务器处理能力还很薄弱,为了启用压缩,会是把静态资源先预先压缩为 .gz 文件的形式存放起来,当客户端可以接受压缩版本的资源时(请求的 Header 中包含 Accept-Encoding: gzip)就返回压缩后的版本(响应的 Header 中包含 Content-Encoding: gzip),否则就返回未压缩的原版,这种方式被称为“静态预压缩”(Static Precompression)。而现代的 Web 服务器处理能力有了大幅提升,已经没有人再采用麻烦的预压缩方式了,都是由服务器对符合条件的请求将在输出时进行“即时压缩”(On-The-Fly Compression),整个压缩过程全部在内存的数据流中完成,不必等资源压缩完成再返回响应,这样可以显著提高“首字节时间”(Time To First Byte,TTFB),改善 Web 性能体验。而这个过程中唯一不好的地方就是服务器再也没有办法给出 Content-Length 这个响应 Header 了,因为输出 Header 时服务器还不知道压缩后资源的确切大小。
到这里,大家想明白即时压缩与持久链接的冲突在哪了吗?持久链接机制不再依靠 TCP 连接是否关闭来判断资源请求是否结束,它会重用同一个连接以便向同一个域名请求多个资源,这样,客户端就必须要有除了关闭连接之外的其他机制来判断一个资源什么时候算传递完毕,这个机制最初(在 HTTP/1.0 时)就只有 Content-Length,即靠着请求 Header 中明确给出资源的长度,传输到达该长度即宣告一个资源的传输已结束。由于启用即时压缩后就无法给出 Content-Length 了,如果是 HTTP/1.0 的话,持久链接和即时压缩只能二选其一,事实上在 HTTP/1.0 中两者都支持,却默认都是不启用的。依靠 Content-Length 来判断传输结束的缺陷,不仅仅在于即时压缩这一种场景,譬如对于动态内容(Ajax、PHP、JSP 等输出),服务器也同样无法事项得知 Content-Length。
HTTP/1.1 版本中修复了这个缺陷,增加了另一种“分块传输编码”(Chunked Transfer Encoding)的资源结束判断机制,彻底解决了 Content-Length 与持久链接的冲突问题。分块编码原理相当简单:在响应 Header 中加入“Transfer-Encoding: chunked”之后,就代表这个响应报文将采用分块编码。此时,报文中的 Body 需要改为用一系列“分块”来传输。每个分块包含十六进制的长度值和对应长度的数据内容,长度值独占一行,数据从下一行开始。最后以一个长度值为 0 的分块来表示资源结束。举个具体例子(例子来自于维基百科,为便于观察,只分块,未压缩):
HTTP/1.1 200 OK
Date: Sat, 11 Apr 2020 04:44:00 GMT
Transfer-Encoding: chunked
Connection: keep-alive
25
This is the data in the first chunk
1C
and this is the second one
0
传输链路
25
This is the data in the first chunk
1C
and this is the second one
3
con
8
sequence
0
根据分块长度可知,前两个分块包含显式的回车换行符(CRLF,即\r\n 字符),所以解码后的内容为:
"This is the data in the first chunk\r\n" (37 字符 => 十六进制: 0x25)
"and this is the second one\r\n" (28 字符 => 十六进制: 0x1C)
"con" (3 字符 => 十六进制: 0x03)
"sequence" (8 字符 => 十六进制: 0x08)
最终得到:
This is the data in the first chunk
and this is the second one
consequence
一般来说,Web 服务器给出的数据分块大小应该(但并不强制)是一致的,而不是如例子中那样随意。HTTP/1.1 通过分块传输解决了即时压缩与持久连接并存的问题,到了 HTTP/2,由于多路复用和单域名单连接的设计,已经无须再刻意去提持久链接机制了,但数据压缩仍然有节约传输带宽的重要价值。
快速 UDP 网络连接
HTTP 是应用层协议而不是传输层协议,它的设计原本并不应该过多地考虑底层的传输细节,从职责上讲,持久连接、多路复用、分块编码这些能力,已经或多或少超过了应用层的范畴。要从根本上改进 HTTP,必须直接替换掉 HTTP over TCP 的根基,即 TCP 传输协议,这便最新一代 HTTP/3 协议的设计重点。
推动替换 TCP 协议的先驱者并不是 IETF,而是 Google 公司。目前,世界上只有 Google 公司具有这样的能力,这并不是因为 Google 的技术实力雄厚,而是由于它同时持有着占浏览器市场 70%份额的 Chrome 浏览器与占移动领域半壁江山的 Android 操作系统。
2013 年,Google 在它的服务器(如 Google.com、YouTube.com 等)及 Chrome 浏览器上同时启用了名为“快速 UDP 网络连接”(Quick UDP Internet Connections,QUIC)的全新传输协议。在 2015 年,Google 将 QUIC 提交给 IETF,并在 IETF 的推动下对 QUIC 进行重新规范化(为以示区别,业界习惯将此前的版本称为 gQUIC,规范化后的版本称为 iQUIC),使其不仅能满足 HTTP 传输协议,日后还能支持 SMTP、DNS、SSH、Telnet、NTP 等多种其他上层协议。2018 年末,IETF 正式批准了 HTTP over QUIC 使用 HTTP/3 的版本号,将其确立为最新一代的互联网标准。
从名字上就能看出 QUIC 会以 UDP 协议为基础,而 UDP 协议没有丢包自动重传的特性,因此 QUIC 的可靠传输能力并不是由底层协议提供,而是完全由自己来实现。由 QUIC 自己实现的好处是能对每个流能做单独的控制,如果在一个流中发生错误,协议栈仍然可以独立地继续为其他流提供服务。这对提高易出错链路的性能非常有用,因为在大多数情况下,TCP 协议接到数据包丢失或损坏通知之前,可能已经收到了大量的正确数据,但是在纠正错误之前,其他的正常请求都会等待甚至被重发,这也是在连接数优化一节中,笔者提到 HTTP/2 未能解决传输大文件慢的根本原因。
QUIC 的另一个设计目标是面向移动设备的专门支持,由于以前 TCP、UDP 传输协议在设计时根本不可能设想到今天移动设备盛行的场景,因此肯定不会有任何专门的支持。QUIC 在移动设备上的优势体现在网络切换时的响应速度上,譬如当移动设备在不同 WiFi 热点之间切换,或者从 WiFi 切换到移动网络时,如果使用 TCP 协议,现存的所有连接都必定会超时、中断,然后根据需要重新创建。这个过程会带来很高的延迟,因为超时和重新握手都需要大量时间。为此,QUIC 提出了连接标识符的概念,该标识符可以唯一地标识客户端与服务器之间的连接,而无须依靠 IP 地址。这样,切换网络后,只需向服务端发送一个包含此标识符的数据包即可重用既有的连接,因为即使用户的 IP 地址发生变化,原始连接连接标识符依然是有效的。
无论是 TCP 协议还是 HTTP 协议,都已经存在了数十年时间。它们积累了大量用户的同时,也承载了很重的技术惯性,要使 HTTP 从 TCP 迁移走,即使由 Google 和 IETF 来推动依然不是一件容易的事情。一个最显著的问题是互联网基础设施中的许多中间设备,都只面向 TCP 协议去建造,仅对 UDP 提供很基础的支持,有的甚至完全阻止 UDP 的流量。因此,Google 在 Chromium 的网络协议栈中同时启用了 QUIC 和传统 TCP 连接,并在 QUIC 连接失败时以零延迟回退到 TCP 连接,尽可能让用户无感知地逐步地扩大 QUIC 的使用面。
根据 W3Techs 的数据,截至 2020 年 10 月,全球已有 48.9%的网站支持了 HTTP/2 协议,按照维基百科中的记录,这个数字在 2019 年 6 月时还只是 36.5%。在 HTTP/3 方面,今天也已经得到了 7.2%网站的支持。可以肯定地说,目前网络链路传输领域正处于新旧交替的时代,许多既有的设备、程序、知识都会在未来几年时间里出现重大更新。
内容分发网络
内容分发网络(Content Distribution Network,CDN,也有写作 Content Delivery Network)是一种十分古老而又十分透明,没什么存在感的分流系统,许多人都说听过它,但真正了解过它的人却很少。
前面几节介绍了客户端缓存、域名解析、链路优化,这节我们来讨论它们的一个经典的综合运用案例:内容分发网络(Content Distribution Network,CDN)。
如果把某个互联网系统比喻为一家企业,那内容分发网络就是它遍布世界各地的分支销售机构,现在有客户要买一块 CPU,那么订机票飞到美国加州 Intel 总部肯定是不合适的,到本地电脑城找个装机铺才是通常的做法,在此场景中,内容分发网络就相当于电脑城里的本地经销商。
如果抛却其他影响服务质量的因素,仅从网络传输的角度看,一个互联网系统的速度取决于以下四点因素:
- 网站服务器接入网络运营商的链路所能提供的出口带宽。
- 用户客户端接入网络运营商的链路所能提供的入口带宽。
- 从网站到用户之间经过的不同运营商之间互联节点的带宽,一般来说两个运营商之间只有固定的若干个点是互通的,所有跨运营商之间的交互都要经过这些点。
- 从网站到用户之间的物理链路传输时延。爱打游戏的同学应该都清楚,延迟(Ping 值)比带宽更重要。
以上四个网络问题,除了第二个只能通过换一个更好的宽带才能解决之外,其余三个都能通过内容分发网络来显著改善。一个运作良好的内容分发网络,能为互联网系统解决跨运营商、跨地域物理距离所导致的时延问题,能为网站流量带宽起到分流、减负的作用。举个例子,如果不是有遍布全国乃至全世界的阿里云 CDN 网络支持,哪怕把整个杭州所有市民上网的权力都剥夺了,把带宽全部让给淘宝的机房,恐怕也撑不住全国乃至全球用户在双十一期间的疯狂“围殴”。
内容分发网络的工作过程,主要涉及路由解析、内容分发、负载均衡和所能支持的 CDN 应用内容四个方面,由于下一节会专门讨论负载均衡的内容,所以这部分在本节就暂不涉及,我们来逐一了解 CDN 其余三个方面。
路由解析
介绍 DNS 域名解析时,笔者曾提到翻译域名无须像查电话本一样刻板地一对一翻译,根据来访机器、网络链路、服务内容等各种信息,可以玩出很多花样,内容分发网络将用户请求路由到它的资源服务器上就是依靠 DNS 服务器来实现的。根据我们对 DNS 域名解析的了解,一次没有内容分发网络参与的用户访问,其解析过程如图 4-5 所示。

有内容分发网络介入会发生什么变化呢?我们不妨先来看一段对网站 icyfenix.cn 进行 DNS 查询的真实应答记录,这个网站就是通过国内的内容分发网络对位于 GitHub Pages 上的静态页面进行加速的。通过 dig 或者 host 命令,就能很方便地得到 DNS 服务器的返回结果(结果中头 4 个 IP 的城市地址是笔者手工加入的,后面的其他记录就不一个一个查了),如下所示:
$ dig icyfenix.cn
; <<>> DiG 9.11.3-1ubuntu1.8-Ubuntu <<>> icyfenix.cn
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 60630
;; flags: qr rd ra; QUERY: 1, ANSWER: 17, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;icyfenix.cn. IN A
;; ANSWER SECTION:
icyfenix.cn. 600 IN CNAME icyfenix.cn.cdn.dnsv1.com.
icyfenix.cn.cdn.dnsv1.com. 599 IN CNAME
4yi4q4z6.dispatch.spcdntip.com.
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
101.71.72.192 #浙江宁波市
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
113.200.16.234 #陕西省榆林市
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
116.95.25.196 #内蒙古自治区呼和浩特市
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
116.178.66.65 #新疆维吾尔自治区乌鲁木齐市
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
118.212.234.144
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
211.91.160.228
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
211.97.73.224
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
218.11.8.232
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
221.204.166.70
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
14.204.74.140
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
43.242.166.88
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
59.80.39.110
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
59.83.204.12
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
59.83.204.14
4yi4q4z6.dispatch.spcdntip.com. 60 IN
A
...(后续记录未完整列出)
根据以上解析信息,DNS 服务为 icyfenix.cn 的查询结果先返回了一个 CNAME 记录(icyfenix.cn.cdn.dnsv1.com.),递归查询该 CNAME 时候,返回了另一个看起来更奇怪…(原文到此中断,但直接按现有内容处理)
1.4 架构师的视角
59.83.218.235
内容分发网络
此处 DNS 查询示例中出现了 CDN 调度产生的 CNAME,继续追踪后将返回多个 A 记录,对应分布在全国各地的 CDN 缓存节点。
;; Query time: 74 msec ;; SERVER: 127.0.0.53#53(127.0.0.53) ;; WHEN: Sat Apr 11 22:33:56 CST 2020 ;; MSG SIZE rcvd: 152
上述查询得到的 CNAME(4yi4q4z6.dispatch.spcdntip.com.)展开后,返回了十几个位于全国不同地区的 A 记录,显然,这些 A 记录就是分布在全国各地、存储了本站缓存的 CDN 节点。CDN 路由解析的具体工作过程如下:
- 搭建好
icyfenix.cn的服务器后,将服务器的 IP 地址在 CDN 服务商侧注册为“源站”,注册后获得一个 CNAME,即本例中的icyfenix.cn.cdn.dnsv1.com.。 - 将得到的 CNAME 在购买域名的 DNS 服务商处注册为一条 CNAME 记录。
- 当第一位用户访问站点时,首先发生一次未命中缓存的 DNS 查询。域名服务商解析出 CNAME 后返回给本地 DNS,至此之后链路解析的主导权开始由内容分发网络的调度服务接管。
- 本地 DNS 查询 CNAME 时,能解析该 CNAME 的权威服务器只有 CDN 服务商架设的权威 DNS。该 DNS 服务会根据一定的均衡策略和参数(如拓扑结构、容量、时延等),在全国各地的 CDN 缓存节点中挑选一个最合适的节点,将其 IP 代替源站 IP 返回给本地 DNS。
- 浏览器从本地 DNS 拿到 IP 地址后,将其视为源站服务器进行访问。此时该 CDN 节点上可能缓存了源站资源,也可能没有,这一点将在“内容分发”小节讨论。
- 经过内容分发后的 CDN 节点,便有能力代替源站向用户提供所请求的资源。
以上步骤反映在时序图上,如图 4-6 所示。请与本节开头给出的没有 CDN 参与的图 4-5 进行对比。
sequenceDiagram participant Browser as 浏览器 participant LDNS as 本地DNS participant AuthDNS as 域名的权威DNS participant CNameAuth as CNAME的权威DNS participant CDN as CDN服务器 participant Origin as 源站服务器 Browser->>LDNS: 查询网站 icyfenix.cn LDNS->>AuthDNS: 查询网站 icyfenix.cn loop 递归查询 AuthDNS-->>LDNS: CNAME: icyfenix.cn.cdn.dnsv1.com. end LDNS->>CNameAuth: 查询 CNAME:icyfenix.cn.cdn.dnsv1.com. loop 递归查询及负载均衡 CNameAuth-->>LDNS: 地址: xx.xx.xx.xx (经过负载均衡选定) end LDNS-->>Browser: 地址: xx.xx.xx.xx Browser->>CDN: 请求 CDN->>Origin: 请求 (若未缓存) Origin-->>CDN: 响应 CDN-->>Browser: 缓存并响应
图 4-6 CDN 路由解析
内容分发
在 DNS 服务器的协助下,无论是对用户还是服务器,内容分发网络都可以是完全透明的。在双方都不知情的情况下,由 CDN 的缓存节点接管了用户向服务器发出的资源请求。随之而来的问题是:缓存节点中必须拥有用户所请求资源的副本,才可能代替源站响应用户请求。这又包含两个子问题:“如何获取源站资源”和“如何管理(更新)资源”。
CDN 获取源站资源的过程被称为“内容分发”,“内容分发网络”的名字正是由此而来,可见这是 CDN 的核心价值。目前主要有以下两种主流的内容分发方式:
-
主动分发(Push):分发由源站主动发起,将内容从源站或其他资源库推送到用户边缘的各个 CDN 缓存节点上。这个推送操作没有业界标准可循,可以采用任何传输方式(HTTP、FTP、P2P 等)、任何推送策略(满足特定条件、定时、人工等)、任何推送时间,只要与后续的更新策略相匹配即可。主动分发通常需要源站与 CDN 服务双方提供程序 API 接口层面的配合,所以它对源站并不是透明的,只对用户一侧单向透明。主动分发一般用于网站需要预载大量资源的场景。例如,双十一前一段时间,淘宝、京东等各大网络商城就会将活动中所需资源推送到 CDN 缓存节点,特别常用的资源甚至会直接缓存到手机 APP 的存储空间或浏览器的
localStorage上。 -
被动回源(Pull):被动回源由用户访问触发,是全自动、双向透明的资源缓存过程。当某个资源首次被用户请求时,CDN 缓存节点发现自己没有该资源,就会实时从源站获取。此时资源的响应时间可粗略认为是资源从源站到 CDN 缓存节点的时间,再加上资源从 CDN 发送到用户的时间之和。因此,被动回源的首次访问通常较慢(但因为 CDN 的网络条件一般远高于普通用户,并不一定比用户直接访问源站更慢),不适合数据量较大的资源。被动回源的优点是可以做到完全的双向透明,不需要源站在程序上做任何配合,使用非常方便。这种分发方式是小型站点使用 CDN 服务的主流选择。若不是自建 CDN,而是购买阿里云、腾讯云的 CDN 服务的站点,多数采用这种方式。
对于“CDN 如何管理(更新)资源”,同样没有统一的标准。尽管 HTTP 协议中关于缓存的 Header 定义确实存在一些针对 CDN 这类共享缓存的指引性参数,例如 Cache-Control 的 s-maxage,但是否遵循完全取决于 CDN 本身的实现策略。更令人无奈的是,由于大多数网站的开发和运维人员并不十分了解 HTTP 缓存机制,若 CDN 完全依照 HTTP Headers 来控制缓存失效和更新,效果反而会相当差,还可能引发其他问题。因此,CDN 缓存的管理不存在通用准则。
目前最常见的做法是超时被动失效与手工主动失效相结合。超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。手工失效是指 CDN 服务商通常会提供程序调用来失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新。例如 icyfenix.cn 就是依靠 Travis-CI 的持续集成服务来触发 CDN 失效和重新预热。
CDN 应用
内容分发网络最初是为了快速分发静态资源而设计的,但今天的 CDN 所能做的事情已经远远超越了最初的目标。这部分应用太多,无法逐一细说,只能对目前 CDN 可做的事情简要列举,以便读者有个总体认知。
- 加速静态资源:这是 CDN 的本职工作。
- 安全防御:CDN 在广义上可视作网站的堡垒机,源站只对 CDN 提供服务,由 CDN 对外界其他用户服务,这样恶意攻击者就不容易直接威胁源站。CDN 对某些攻击手段的防御,如对 DDoS 攻击 的防御尤其有效。但需注意,将安全都寄托在 CDN 上本身是不安全的,一旦源站真实 IP 被泄露,就会面临很高的风险。
- 协议升级:不少 CDN 提供商都同时对接(代售 CA 的)SSL 证书服务,可以实现源站是 HTTP 协议,而对外的网站是基于 HTTPS 的。同理,可以实现源站到 CDN 是 HTTP/1.x 协议,CDN 对外提供 HTTP/2 或 HTTP/3 服务;实现源站基于 IPv4 网络,CDN 对外支持 IPv6 等等。
- 状态缓存:第一节介绍客户端缓存时简要提到了状态缓存。CDN 不仅可以缓存源站的资源,还可以缓存源站的状态。例如源站的 301/302 转向就可以缓存起来让客户端直接跳转;还可以通过 CDN 开启 HSTS、通过 CDN 进行 OCSP 装订 加速 SSL 证书访问等。有时甚至可以配置 CDN 对任意状态码(比如 404)进行一定时间的缓存,以减轻源站压力,但此操作应当慎重,在网站状态发生改变时要及时刷新缓存。
- 修改资源:CDN 可以在返回资源给用户时修改其任何内容,以实现不同目的。例如,可以对源站未压缩的资源自动压缩并修改
Content-Encoding以节省带宽;对源站未启用客户端缓存的内容加上缓存 Header,自动启用客户端缓存;修改 CORS 相关 Header,使源站不支持跨域的资源提供跨域能力等。 - 访问控制:CDN 可以实现 IP 黑/白名单功能,根据不同的来访 IP 提供不同的响应结果,根据 IP 的访问流量实现 QoS 控制,根据 HTTP 的 Referer 实现防盗链等。
- 注入功能:CDN 可以在不修改源站代码的前提下,为源站注入各种功能。图 4-7 是国际 CDN 巨头 CloudFlare 提供的 Google Analytics、PACE、Hardenize 等第三方应用,在 CDN 下均能做到无需修改源站任何代码即可使用。
图 4-7 CloudFlare 提供的第三方应用 (示意图:展示了 CDN 可以注入的多种第三方服务,无需源站改动)
- 此外,CDN 还可以绕过某些“不存在的”网络措施,这也是在国内申请 CDN 必须实名备案的原因,不再细说。
负载均衡
负载均衡
负载均衡(Load Balancing):调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”。
在互联网时代的早期,网站流量较小,业务也比较简单,单台服务器便有可能满足访问需要。但时至今日,无论是互联网应用还是企业级应用,一般实际用于生产的系统几乎都离不开集群部署。信息系统不论采用单体架构多副本部署还是微服务架构,不论是为了实现高可用还是为了获得高性能,都需要利用多台机器来扩展服务能力,希望用户的请求不管连接到哪台机器上,都能得到相同的处理。另一方面,如何构建和调度服务集群这件事,又必须对用户一侧保持足够的透明,即使请求背后是由一千台、一万台机器共同响应,也绝非用户所关心的事,用户只需记住一个域名地址即可。调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”(Load Balancing)。
真正大型系统的负载均衡过程往往是多级的。例如,在各地建有多个机房,或者机房有不同网络链路入口的大型互联网站,会从 DNS 解析开始,通过“域名”→“CNAME”→“负载调度服务”→“就近的数据中心入口”的路径,先将来访用户根据 IP 地址(或其他条件)分配到一个合适的数据中心中,然后才到稍后将要讨论的各式负载均衡。在 DNS 层面的负载均衡与前面介绍的 DNS 智能线路、内容分发网络等在工作原理上是类似的,差别只在于数据中心能提供的不仅有缓存,而是全方位的服务能力。由于这种方式此前已经详细讲解过,后续我们所讨论的“负载均衡”就只聚焦于网络请求进入数据中心入口之后的其他级次的负载均衡。
无论在网关内部建立了多少级的负载均衡,从形式上来说都可以分为两种:四层负载均衡和七层负载均衡。在详细介绍它们是什么以及如何工作之前,我们先来建立两个总体概念:
- 四层负载均衡的优势是性能高,七层负载均衡的优势是功能强。
- 做多级混合负载均衡,通常应是低层的负载均衡在前,高层的负载均衡在后(想一想为什么?)。
我们所说的“四层”、“七层”,指的是经典的 OSI 七层模型 中第四层传输层和第七层应用层。表 4-1 是来自维基百科上对 OSI 七层模型的介绍(做了简单的中文翻译),这部分属于网络基础知识,这里不多解释。后面我们会多次使用到这张表,如你对网络知识并不是特别了解,可通过维基百科上的链接获得进一步的资料。
表 4-1 OSI 七层模型
| 层 | 名称 | 数据单元 | 功能 |
|---|---|---|---|
| 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 地址)上,单纯只处理第四层(传输层,可以改写 TCP、UDP 等协议的内容和端口)的数据无法做到负载均衡的转发,因为 OSI 的下三层是媒体层(Media Layers),上四层是主机层(Host Layers),既然流量都已经到达目标主机上了,也就谈不上什么流量转发,最多只能做代理了。但出于习惯和方便,现在几乎所有的资料都把它们统称为四层负载均衡,笔者也同样称呼它为四层负载均衡。如果读者在某些资料上看见“二层负载均衡”、“三层负载均衡”的表述,应该了解这是在描述它们工作的层次,与这里说的“四层负载均衡”并不是同一类意思。下面介绍几种常见的四层负载均衡的工作模式。
数据链路层负载均衡
参考上面 OSI 模型的表格,数据链路层传输的内容是数据帧(Frame),例如常见的以太网帧、ADSL 宽带的 PPP 帧等。我们讨论的具体上下文里,目标必定是以太网帧,按照 IEEE 802.3 标准,最典型的 1500 Bytes MTU 的以太网帧结构如表 4-2 所示。
表 4-2 最典型的 1500 Bytes MTU 的以太网帧结构说明
| 数据项 | 取值 |
|---|---|
| 前导码 | 10101010 7 Bytes |
| 帧开始符 | 10101011 1 Byte |
| MAC 目标地址 | 6 Bytes |
| MAC 源地址 | 6 Bytes |
| 802.1Q 标签(可选) | 4 Bytes |
| 以太类型 | 2 Bytes |
| 有效负载 | 1500 Bytes |
| 冗余校验 | 4 Bytes |
| 帧间距 | 12 Bytes |
帧结构中其他数据项的含义在本节中可以暂时不去理会,只需注意到“MAC 目标地址”和“MAC 源地址”两项即可。我们知道每一块网卡都有独立的 MAC 地址,以太帧上这两个地址告诉了交换机,此帧应该从连接在交换机上的哪个端口的网卡发出,送至哪块网卡。
数据链路层负载均衡所做的工作,是修改请求的数据帧中的 MAC 目标地址,让用户原本是发送给负载均衡器的请求的数据帧,被二层交换机根据新的 MAC 目标地址转发到服务器集群中对应的服务器(后文称为“真实服务器”,Real Server)的网卡上,这样真实服务器就获得了一个原本目标并不是发送给它的数据帧。
由于二层负载均衡器在转发请求过程中只修改了帧的 MAC 目标地址,不涉及更上层协议(没有修改(续)了 Payload 的数据),所以在更上层(第三层)看来,所有数据都是未曾被改变过的。由于第三层的数据包,即 IP 数据包中包含了源(客户端)和目标(均衡器)的 IP 地址,只有真实服务器保证自己的 IP 地址与数据包中的目标 IP 地址一致,这个数据包才能被正确处理。因此,使用这种负载均衡模式时,需要把真实物理服务器集群所有机器的虚拟 IP 地址(Virtual IP Address,VIP)配置成与负载均衡器的虚拟 IP 一样,这样经均衡器转发后的数据包就能在真实服务器中顺利地使用。也正是因为实际处理请求的真实物理服务器 IP 和数据请求中的目的 IP 是一致的,所以响应结果就不再需要通过负载均衡服务器进行地址交换,可将响应结果的数据包直接从真实服务器返回给用户的客户端,避免负载均衡器网卡带宽成为瓶颈,因此数据链路层的负载均衡效率是相当高的。整个请求到响应的过程如图 4-8 所示。
graph TD subgraph 用户 Client[客户端] end subgraph 网络 Switch[二层交换机] LB[负载均衡器] end subgraph 真实服务器集群 RS1[真实服务器1] RS2[真实服务器2] end Client -- 请求帧<目标MAC=LB_MAC> --> Switch Switch -- 转发 --> LB LB -- 修改目标MAC为RS1_MAC --> Switch Switch -- 转发 --> RS1 RS1 -- 响应直接回复给客户端 --> Client
图 4-8 数据链路层负载均衡
上述只有请求经过负载均衡器,而服务的响应无须从负载均衡器原路返回的工作模式,整个请求、转发、响应的链路形成一个“三角关系”,所以这种负载均衡模式也常被很形象地称为“三角传输模式”(Direct Server Return,DSR),也有叫“单臂模式”(Single Legged Mode)或者“直接路由”(Direct Routing)。
虽然数据链路层负载均衡效率很高,但它并不能适用于所有的场合,除了那些需要感知应用层协议信息的负载均衡场景它无法胜任外(所有的四层负载均衡器都无法胜任,将在后续介绍七层均衡器时一并解释),它在网络一侧受到的约束也很大。二层负载均衡器直接改写目标 MAC 地址的工作原理决定了它与真实的服务器的通信必须是二层可达的,通俗地说就是必须位于同一个子网当中,无法跨 VLAN。优势(效率高)和劣势(不能跨子网)共同决定了数据链路层负载均衡最适合用来做数据中心的第一级均衡设备,用来连接其他的下级负载均衡器。
网络层负载均衡
根据 OSI 七层模型,在第三层网络层传输的单位是分组数据包(Packets),这是一种在分组交换网络(Packet Switching Network,PSN)中传输的结构化数据单位。以 IP 协议为例,一个 IP 数据包由 Headers 和 Payload 两部分组成,Headers 长度最大为 60 Bytes,其中包括了 20 Bytes 的固定数据和最长不超过 40 Bytes 的可选的额外设置组成。按照 IPv4 标准,一个典型的分组数据包的 Headers 部分具有如表 4-3 所示的结构。
表 4-3 分组数据包的 Headers 部分说明
| 长度 | 存储信息 |
|---|---|
| 0-4 Bytes | 版本号(4 Bits)、首部长度(4 Bits)、分区类型(8 Bits)、总长度(16 Bits) |
| 5-8 Bytes | 报文计数标识(16 Bits)、标志位(4 Bits)、片偏移(12 Bits) |
| 9-12 Bytes | TTL 生存时间(8 Bits)、上层协议代号(8 Bits)、首部校验和(16 Bits) |
| 13-16 Bytes | 源地址(32 Bits) |
| 17-20 Bytes | 目标地址(32 Bits) |
| 20-60 Bytes | 可选字段和空白填充 |
在本节中,无须过多关注表格中的其他信息,只要知道在 IP 分组数据包的 Headers 带有源和目标的 IP 地址即可。源和目标 IP 地址代表了数据是从分组交换网络中哪台机器发送到哪台机器的,我们可以沿用与二层改写 MAC 地址相似的思路,通过改变这里面的 IP 地址来实现数据包的转发。具体有两种常见的修改方式。
第一种是保持原来的数据包不变,新创建一个数据包,把原来数据包的 Headers 和 Payload 整体作为另一个新的数据包的 Payload,在这个新数据包的 Headers 中写入真实服务器的 IP 作为目标地址,然后把它发送出去。经过三层交换机的转发,真实服务器收到数据包后,必须在接收入口处设计一个针对性的拆包机制,把由负载均衡器自动添加的那层 Headers 扔掉,还原出原来的数据包来进行使用。这样,真实服务器就同样拿到了一个原本不是发给它(目标 IP 不是它)的数据包,达到了流量转发的目的。那时候还没有流行起“禁止套娃”的梗,所以设计者给这种“套娃式”的传输起名叫做“IP 隧道”(IP Tunnel)传输,也还是相当的形象。
尽管因为要封装新的数据包,IP 隧道的转发模式比起直接路由模式效率会有所下降,但由于并没有修改原有数据包中的任何信息,所以 IP 隧道的转发模式仍然具备三角传输的特性,即负载均衡器转发来的请求,可以由真实服务器去直接应答,无须在经过均衡器原路返回。而且由于 IP 隧道工作在网络层,所以可以跨越 VLAN,因此摆脱了直接路由模式中网络侧的约束。此模式从请求到响应的过程如图 4-9 所示。
sequenceDiagram participant Client participant LB as 负载均衡器 participant RS as 真实服务器 Client->>LB: 原始 IP 数据包<br/>(源=Client IP, 目标=VIP) Note over LB: 封装新 IP 数据包<br/>(源=LB IP, 目标=RS IP)<br/>Payload=原始数据包 LB->>RS: 新 IP 数据包 Note over RS: 拆除外层 IP Headers<br/>还原原始数据包 RS->>Client: 响应数据包 (源=VIP)
图 4-9 IP 隧道模式的负载均衡
而这种转发方式也有缺点。第一个缺点是它要求真实服务器必须支持“IP 隧道协议”(IP Encapsulation),就是它得学会自己拆包扔掉一层 Headers,这个其实并不是什么大问题,现在几乎所有的 Linux 系统都支持 IP 隧道协议。另外一个缺点是这种模式仍必须通过专门的配置,必须保证所有的真实服务器与均衡器有着相同的虚拟 IP 地址,因为回复该数据包时,需要使用这个虚拟 IP 作为响应数据包的源地址,这样客户端收到这个数据包时才能正确解析。这个限制就相对麻烦一些,它与“透明”的原则冲突,需由系统管理员介入。
而且,对服务器进行虚拟 IP 的配置并不是在任何情况下都可行的,尤其是当有好几个服务共用一台物理服务器的时候,此时就必须考虑第二种修改方式——改变目标数据包:直接把数据包 Headers 中的目标地址改掉,修改后原本由用户发给均衡器的数据包,也会被三层交换机转发送到真实服务器的网卡上,而且因为没有经过 IP 隧道的额外包装,也就无须再拆包了。但问题是这种模式是通过修改目标 IP 地址才到达真实服务器的,如果真实服务器直接将应答包返回客户端的话,这个应答数据包的源 IP 是真实服务器的 IP,也即均衡器修改以后的 IP 地址,客户端不可能认识该 IP,自然就无法再正常处理这个应答了。因此,只能让应答流量继续回到负载均衡,由负载均衡把应答包的源 IP 改回自己的 IP,再发给客户端,这样才能保证客户端与真实服务器之间的正常通信。如果你对网络知识有些了解的话,肯定会觉得这种处理似曾相识,这不就是在家里、公司、学校上网时,由一台路由器带着一群内网机器上网的“网络地址转换”(Network Address Translation,NAT)操作吗?这种负载均衡的模式的确被称为 NAT 模式,此时,负载均衡器就是充当了家里、公司、学校的上网路由器的作用。NAT 模式的负载均衡器运维起来十分简单,只要机器将自己的网关地址设置为均衡器地址,就无须再进行任何额外设置了。此模式从请求到响应的过程如图 4-10 所示。
sequenceDiagram participant Client participant LB as 负载均衡器 (NAT) participant RS as 真实服务器 Client->>LB: 数据包 (源=Client IP, 目标=VIP) Note over LB: 修改目标 IP 为 RS IP LB->>RS: 数据包 (源=Client IP, 目标=RS IP) RS->>LB: 应答包 (源=RS IP, 目标=Client IP) Note over LB: 修改源 IP 为 VIP LB->>Client: 应答包 (源=VIP, 目标=Client IP)
图 4-10 NAT 模式的负载均衡
在流量压力比较大的时候,NAT 模式的负载均衡会带来较大的性能损失,比起直接路由和 IP 隧道模式,甚至会出现数量级上的下降。这点是显而易见的,由负载均衡器代表整个服务集群来进行应答,各个服务器的响应数据都会互相挣抢均衡器的出口带宽,这就好比在家里用 NAT 上网的话,如果有人在下载,你打游戏可能就会觉得卡顿是一个道理,此时整个系统的瓶颈很容易就出现在负载均衡器上。
还有一种更加彻底的 NAT 模式:即均衡器在转发时,不仅修改目标 IP 地址,连源 IP 地址也一起改了,源地址就改成均衡器自己的 IP,称作 Source NAT(SNAT)。这样做的好处是真实服务器无须配置网关就能够让应答流量经过正常的三层路由回到负载均衡器上,做到了彻底的透明。但是缺点是由于做了 SNAT,真实服务器处理请求时就无法拿到客户端的 IP 地址了,从真实服务器的视角看来,所有的流量都来自于负载均衡器,这样有一些需要根据目标 IP 进行控制的业务逻辑就无法进行。
应用层负载均衡
前面介绍的四层负载均衡工作模式都属于“转发”,即直接将承载着 TCP 报文的底层数据格式(IP 数据包或以太网帧)转发到真实服务器上,此时客户端到响应请求的真实服务器维持着同一条 TCP 通道。但工作在四层之后的负载均衡模式就无法再进行转发了,只能进行代理,此时真实服务器、负载均衡器、客户端三者之间由两条独立的 TCP 通道来维持通信,转发与代理的区别如图 4-11 所示。
graph TD subgraph 转发模式 C1[客户端] <-- 单条TCP连接 --> RS1[真实服务器] LB1[负载均衡器] -.-> RS1 end subgraph 代理模式 C2[客户端] <-- TCP连接1 --> LB2[负载均衡器] <-- TCP连接2 --> RS2[真实服务器] end
图 4-11 转发与代理
“代理”这个词,根据“哪一方能感知到”的原则,可以分为“正向代理”、“反向代理”和“透明代理”三类。正向代理就是我们通常简称的代理,指在客户端设置的、代表客户端与服务器通信的代理服务,它是客户端可知,而对服务器透明的。反向代理是指在设置在服务器这一侧,代表真实服务器来与客户端通信的代理服务,此时它对客户端来说是透明的。至于透明代理是指对双方都透明的,配置在网络中间设备上的代理服务,譬如,架设在路由器上的透明翻墙代理。
根据以上定义,很显然,七层负载均衡器它就属于反向代理中的一种。如果只论网络性能,七层均衡器肯定是无论如何比不过四层均衡器的,它比四层均衡器至少多一轮 TCP 握手,有着跟 NAT 转发模式一样的带宽问题,而且通常要耗费更多的 CPU,因为可用的解析规则远比四层丰富。所以如果用七层均衡器去做下载站、视频站这种流量应用是不合适的,起码不能作为第一级均衡器。但是,如果网站的性能瓶颈并不在于网络性能,要论整个服务集群对外所体现出来的服务性能,七层均衡器就有它的用武之地了。这里面七层均衡器的底气就是来源于它工作在应用层,可以感知应用层通信的具体内容,往往能够做出更明智的决策,玩出更多的花样来。
举个生活中的例子,四层均衡器就像银行的自助排号机,转发效率高且不知疲倦,每一个达到银行的客户根据排号机的顺序,选择对应的窗口接受服务;而七层均衡器就像银行大堂经理,他会先确认客户需要办理的业务,再安排排号。这样办理理财、存取款等业务的客户,会根据银行内部资源得到统一协调处理,加快客户业务办理流程,有一些无须柜台办理的业务,由大堂经理直接就可以解决了,譬如,反向代理的就能够实现静态资源缓存,对于静态资源的请求就可以在反向代理上直接返回,无须转发到真实服务器。
代理的工作模式相信大家应该是比较熟悉的,这里不再展开,只是简单列举了一些七层代理可以实现的功能,以便读者对它“功能强大”有个直观的感受。
- 前面介绍 CDN 应用时,所有 CDN 可以做的缓存方面的工作(就是除去 CDN 根据物理位置就近返回这种优化链路的工作外),七层均衡器全都可以实现,譬如静态资源缓存、协议升级、安全防护、访问控制,等等。
- 七层均衡器可以实现更智能化的路由。譬如,根据 Session 路由,以实现亲和性的集群;根据 URL 路由,实现专职化服务(此时就相当于网关的职责);甚至根据用户身份路由,实现对部分用户的特殊服务(如某些站点的贵宾服务器),等等。
- 某些安全攻击可以由七层均衡器来抵御,譬如一种常见的 DDoS 手段是 SYN Flood 攻击,即攻击者控制众多客户端,使用虚假 IP 地址对同一目标大量发送 SYN 报文。从技术原理上看,由于四层均衡器无法感知上层协议的内容,这些 SYN 攻击都会被转发到后端的真实服务器上;而七层均衡器下这些 SYN 攻击自然在负载均衡设备上就被过滤掉,不会影响到后面服务器的正常运行。类似地,可以在七层均衡器上设定多种策略,譬如过滤特定报文,以防御如 SQL 注入等应用层面的特定攻击手段。
均衡策略与实现
很多微服务架构的系统中,链路治理措施都需要在七层中进行,譬如服务降级、熔断、异常注入,等等。譬如,一台服务器只有出现物理层面或者系统层面的故障,导致无法应答 TCP 请求才能被四层均衡器所感知,进而剔除出服务集群,如果一台服务器能够应答,只是一直在报 500 错,那四层均衡器对此是完全无能为力的,只能由七层均衡器来解决。
负载均衡的两大职责是“选择谁来处理用户请求”和“将用户请求转发过去”。到此我们仅介绍了后者,即请求的转发或代理过程。前者是指均衡器所采取的均衡策略,由于这一块涉及的均衡算法太多,笔者无法逐一展开,所以本节仅从功能和应用的角度去介绍一些常见的均衡策略。
常见均衡策略
- 轮循均衡(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 公司的负载均衡产品。
服务端缓存
缓存(Cache)软件开发中的缓存并非多多益善,它有收益,也有风险。
笔者介绍透明多级分流系统的逻辑脉络,是以流量从客户端中发出为起始,以流量到达服务器集群中真正处理业务的节点为终结,探索该过程中与业务无关的通用组件。很难清楚界定清楚服务端缓存到底算不算与业务逻辑无关,不过,既然本章以“客户端缓存”为开篇,那“服务端缓存”作为结束,倒是十分合适的,在这一节里,笔者所说的“缓存”,均特指服务端缓存。
引入缓存的挑战与理由
为系统引入缓存之前,第一件事情是确认你的系统是否真的需要缓存。很多人会有意无意地把硬件里那种常用于区分不同产品档次、“多多益善”的缓存(如 CPU L1/2/3 缓存、磁盘缓存,等等)代入软件开发中去,实际上这两者差别很大,在软件开发中引入缓存的负面作用要明显大于硬件的缓存:从开发角度来说,引入缓存会提高系统复杂度,因为你要考虑缓存的失效、更新、一致性等问题(硬件缓存也有这些问题,只是不需要由你去考虑,主流的 ISA 也都没有提供任何直接操作缓存的指令);从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;从安全角度来说,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。冒着上述种种风险,仍能说服你引入缓存的理由,总结起来无外乎以下两种:
- 为缓解 CPU 压力而做缓存:譬如把方法运行结果存储起来、把原本要实时计算的内容提前算好、把一些公用的数据进行复用,这可以节省 CPU 算力,顺带提升响应性能。
- 为缓解 I/O 压力而做缓存:譬如把原本对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,将原本对单点部件(如数据库)的读写访问变为到可扩缩部件(如缓存中间件)的访问,顺带提升响应性能。
请注意,缓存虽然是典型以空间换时间来提升性能的手段,但它的出发点是缓解 CPU 和 I/O 资源在峰值流量下的压力,“顺带”而非“专门”地提升响应性能。这里的言外之意是如果可以通过增强 CPU、I/O 本身的性能(譬如扩展服务器的数量)来满足需要的话,那升级硬件往往是更好的解决方案,即使需要一些额外的投入成本,也通常要优于引入缓存后可能带来的风险。
缓存属性
有不少软件系统最初的缓存功能是以 HashMap 或者 ConcurrentHashMap 为起点演进的。当开发人员发现系统中某些资源的构建成本比较高,而这些资源又有被重复使用的可能性时,会很自然地产生“循环再利用”的想法,将它们放到 Map 容器中,下次需要时取出重用,避免重新构建,这种原始朴素的复用就是最基本的缓存了。不过,一旦我们专门把“缓存”看作一项技术基础设施,一旦它有了通用、高效、可统计、可管理等方面的需求,其中要考虑的因素就变得复杂起来。通常,我们设计或者选择缓存至少会考虑以下四个维度的属性:
- 吞吐量:缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。
- 命中率:缓存的命中率即成功从缓存中返回结果次数与总请求次数的比值,反映了引入缓存的价值高低,命中率越低,引入缓存的收益越小,价值越低。
- 扩展功能:缓存除了基本读写功能外,还提供哪些额外的管理功能,譬如最大容量、失效时间、失效事件、命中率统计,等等。
- 分布式支持:缓存可分为“进程内缓存”和“分布式缓存”两大类,前者只为节点本身提供服务,无网络访问操作,速度快但缓存的数据不能在各个服务节点中共享,后者则相反。
吞吐量
缓存的吞吐量只在并发场景中才有统计的意义,因为不考虑并发的话,即使是最原始的、以 HashMap 实现的缓存,访问效率也已经是常量时间复杂度,即 O(1),其中涉及到碰撞、扩容等场景的处理属于数据结构基础,这里不展开。但 HashMap 并不是线程安全的容器,如果要让它在多线程并发下能正确地工作,就要用 Collections.synchronizedMap 进行包装,这相当于给 Map 接口的所有访问方法都自动加全局锁;或者改用 ConcurrentHashMap 来实现,这相当于给 Map 的访问分段加锁(从 JDK 8 起已取消分段加锁,改为 CAS + Synchronized 锁单个元素)。无论采用怎样的实现方法,线程安全措施都会带来一定的吞吐量损失。
进一步说,如果只比较吞吐量,完全不去考虑命中率、淘汰策略、缓存统计、过期失效等功能该如何实现,那也不必选择,JDK 8 改进之后的 ConcurrentHashMap 基本上就是你能找到的吞吐量最高的缓存容器了。可是很多场景里,以上提及的功能至少有部分一两项是必须的,不可能完全不考虑,这才涉及到不同缓存方案的权衡问题。
根据 Caffeine 给出的一组目前业界主流进程内缓存实现方案,包括有 Caffeine、ConcurrentLinkedHashMap、LinkedHashMap、Guava Cache、Ehcache 和 Infinispan Embedded 的对比,从 Benchmarks 中体现出的它们在 8 线程、75% 读操作、25% 写操作下的吞吐量来看,各种缓存组件库的性能差异还是十分明显的,最高与最低的相差了足有一个数量级,具体如图 4-12 所示。
图 4-12 8 线程、75% 读、25% 写的吞吐量比较(图片来自 Caffeine)
这种并发读写的场景中,吞吐量受多方面因素的共同影响,譬如,怎样设计数据结构以尽可能避免数据竞争,存在竞争风险时怎样处理同步(主要有使用锁实现的悲观同步和使用 CAS 实现的乐观同步)、如何避免伪共享现象(False Sharing,这也算是典型缓存提升开发复杂度的例子)发生,等等。其中第一点尽可能避免竞争是最关键的,无论如何实现同步都不会比直接无须同步更快,笔者下面以 Caffeine 为例,介绍一些缓存如何避免竞争、提高吞吐量的设计。
缓存中最主要的数据竞争源于读取数据的同时,也会伴随着对数据状态的写入操作,写入数据的同时,也会伴随着数据状态的读取操作。譬如,读取时要同时更新数据的最近访问时间和访问计数器的状态(后文会提到,为了追求高效,可能不会记录时间和次数,譬如通过调整链表顺序来表达时间先后、通过 Sketch 结构来表达热度高低),以实现缓存的淘汰策略;又或者读取时要同时判断数据的超期时间等信息,以实现失效重加载等其他扩展功能。对以上伴随读写操作而来的状态维护,有两种可选择的处理思路,一种是以 Guava Cache 为代表的同步处理机制,即在访问数据时一并完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少竞争。另一种是以 Caffeine 为代表的异步日志提交机制,这种机制参考了经典的数据库设计理论,将对数据的读、写过程看作是日志(即对数据的操作指令)的提交过程。尽管日志也涉及到写入操作,有并发的数据变更就必然面临锁竞争,但异步提交的日志已经将原本在 Map 内的锁转移到日志的追加写操作上,日志里腾挪优化的余地就比在 Map 中要大得多。
在 Caffeine 的实现中,设有专门的环形缓存区(Ring Buffer,也常称作 Circular Buffer)来记录由于数据读取而产生的状态变动日志。为进一步减少竞争,Caffeine 给每条线程(对线程取 Hash,哈希值相同的使用同一个缓冲区)都设置一个专用的环形缓冲。
额外知识:环形缓冲
所谓环形缓冲,并非 Caffeine 的专有概念,它是一种拥有读、写两个指针的数据复用结构,在计算机科学中有非常广泛的应用。举个具体例子,譬如一台计算机通过键盘输入,并通过 CPU 读取“HELLO WIKIPEDIA”这个长 14 字节的单词,通常需要一个至少 14 字节以上的缓冲区才行。但如果是环形缓冲结构,读取和写入就应当一起进行,在读取指针之前的位置均可以重复使用,理想情况下,只要读取指针不落后于写入指针一整圈,这个缓冲区就可以持续工作下去,能容纳无限多个新字符。否则,就必须阻塞写入操作去等待读取清空缓冲区。
环形缓存区工作原理(图片来自维基百科)
从 Caffeine 读取数据时,数据本身会在其内部的 ConcurrentHashMap 中直接返回,而数据的状态信息变更就存入环形缓冲中,由后台线程异步处理。如果异步处理的速度跟不上状态变更的速度,导致缓冲区满了,那此后接收的状态的变更信息就会直接被丢弃掉,直至缓冲区重新富余。通过环形缓冲和容忍有损失的状态变更,Caffeine 大幅降低了由于数据读取而导致的垃圾收集和锁竞争,因此 Caffeine 的读取性能几乎能与 ConcurrentHashMap 的读取性能相同。
向 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)。每当前一段缓存满了之后,会将低价值数据淘汰到后一段中去存储,直至最后一段也满了之后,该数据就彻底清理出缓存。
仅靠以上简单的、有限的介绍,你不一定能完全理解 TinyLFU 和 W-TinyLFU 的工作原理,但肯定能看出这些改进算法比起原来基础版本的 LFU 要复杂了许多。有时候为了取得理想的效果,采用较为复杂的淘汰策略是不得已的选择,Caffeine 官方给出的 W-TinyLFU 以及另外两种高级淘汰策略 ARC(Adaptive Replacement Cache)、LIRS(Low Inter-Reference Recency Set)与基础的 LFU 策略之间的对比,如图 4-13 所示。
图 4-13 几种淘汰算法在搜索场景下的命中率对比(图片来自 Caffeine)
在搜索场景中,三种高级策略的命中率较为接近于理想曲线(Optimal),而 LRU 则差距最远,Caffeine 官方给出的数据库、网站、分析类等应用场景中,这几种策略之间的绝对差距不尽相同,但相对排名基本上没有改变,最基础的淘汰策略的命中率是最低的。对其他缓存淘汰策略感兴趣的读者可以参考维基百科中对 Cache Replacement Policies 的介绍。
扩展功能
一般来说,一套标准的 Map 接口(或者来自 JSR 107 的 javax.cache.Cache 接口)就可以满足缓存访问的基本需要,不过在“访问”之外,专业的缓存往往还会提供很多额外的功能。笔者简要列举如下:
- 加载器:许多缓存都有“CacheLoader”之类的设计,加载器可以让缓存从只能被动存储外部放入的数据,变为能够主动通过加载器去加载指定 Key 值的数据,加载器也是实现自动刷新功能的基础前提。
- 淘汰策略:有的缓存淘汰策略是固定的,也有一些缓存能够支持用户自己根据需要选择不同的淘汰策略。
- 失效策略:要求缓存的数据在一定时间后自动失效(移除出缓存)或者自动刷新(使用加载器重新加载)。
- 事件通知:缓存可能会提供一些事件监听器,让你在数据状态变动(如失效、刷新、移除)时进行一些额外操作。有的缓存还提供了对缓存数据本身的监视能力(Watch 功能)。
- 并发级别:对于通过分段加锁来实现的缓存(以 Guava Cache 为代表),往往会提供并发级别的设置。可以简单将其理解为缓存内部是使用多个 Map 来分段存储数据的,并发级别就用于计算出使用 Map 的数量。如果将这个参数设置过大,会引入更多的 Map,需要额外维护这些 Map 而导致更大的时间和空间上的开销;如果设置过小,又会导致在访问时产生线程阻塞,因为多个线程更新同一个
ConcurrentMap的同一个值时会产生锁竞争。 - 容量控制:缓存通常都支持指定初始容量和最大容量,初始容量目的是减少扩容频率,这与 Map 接口本身的初始容量含义是一致的。最大容量类似于控制 Java 堆的
-Xmx参数,当缓存接近最大容量时,会自动清理掉低价值的数据。 - 引用方式:支持将数据设置为软引用或者弱引用,提供引用方式的设置是为了将缓存与 Java 虚拟机的垃圾收集机制联系起来。
- 统计信息:提供诸如缓存命中率、平均加载时间、自动回收计数等统计。
- 持久化:支持将缓存的内容存储到数据库或者磁盘中,进程内缓存提供持久化功能的作用不是太大,但分布式缓存大多都会考虑提供持久化功能。
至此,本节已简要介绍了缓存的三项属性:吞吐量、命中率和扩展功能,笔者将几款主流进程内缓存方案整理成表 4-4,供读者参考。
表 4-4 几款主流进程内缓存方案对比
| 缓存方案 | ConcurrentHashMap | Ehcache | Guava Cache | Caffeine |
|---|---|---|---|---|
| 访问性能 | 最高 | 一般 | 良好 | 优秀,接近于 ConcurrentHashMap |
| 淘汰策略 | 无 | 支持多种淘汰策略 FIFO、LRU、LFU 等 | LRU | W-TinyLFU |
| 扩展功能 | 只提供基础的访问接口 | 并发级别控制 失效策略 容量控制 事件通知 统计信息 …… | 大致同左 | 大致同左 |
分布式缓存
相比起缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多,所以对分布式缓存来说,处理与网络有相关的操作是对吞吐量影响更大的因素,往往也是比淘汰策略、扩展功能更重要的关注点,这决定了尽管也有 Ehcache、Infinispan 这类能同时支持分布式部署和进程内嵌部署的缓存方案,但通常进程内缓存和分布式缓存选型时会有完全不同的候选对象及考察点。我们决定使用哪种分布式缓存前,首先必须确认自己需求是什么?
从访问的角度来说,如果是频繁更新但甚少读取的数据,通常是不会有人把它拿去做缓存的,因为这样做没有收益。对于甚少更新但频繁读取的数据,理论上更适合做复制式缓存;对于更新和读取都较为频繁的数据,理论上就更适合做集中式缓存。笔者简要介绍这两种分布式缓存形式的差别与代表性产品:
-
复制式缓存:复制式缓存可以看作是“能够支持分布式的进程内缓存”,它的工作原理与 Session 复制类似。缓存中所有数据在分布式集群的每个节点里面都存在有一份副本,读取数据时无须网络访问,直接从当前节点的进程内存中返回,理论上可以做到与进程内缓存一样高的读取性能;当数据发生变化时,就必须遵循复制协议,将变更同步到集群的每个节点中,复制性能随着节点的增加呈现平方级下降,变更数据的代价十分高昂。
复制式缓存的代表是 JBossCache,这是 JBoss 针对企业级集群设计的缓存方案,支持 JTA 事务,依靠 JGroup 进行集群节点间数据同步。以 JBossCache 为典型的复制式缓存曾有一段短暂的兴盛期,但今天基本上已经很难再见到使用这种缓存形式的大型信息系统了,JBossCache 被淘汰的主要原因是写入性能实在差到不堪入目的程度,它在小规模集群中同步数据尚算差强人意,但在大规模集群下,很容易就因网络同步的速度跟不上写入速度,进而导致在内存中累计大量待重发对象,最终引发 OutOfMemory 崩溃。如果对 JBossCache 没有足够了解的话,稍有不慎就要被埋进坑里。
为了缓解复制式同步的写入效率问题,JBossCache 的继任者 Infinispan 提供了另一种分布式同步模式(这种同步模式的名字就叫做“分布式”),允许用户配置数据需要复制的副本数量,譬如集群中有八个节点,可以要求每个数据只保存四份副本,此时,缓存的总容量相当于是传统复制模式的一倍,如果要访问的数据在本地缓存中没有存储,Infinispan 完全有能力感知网络的拓扑结构,知道应该到哪些节点中寻找数据。
-
集中式缓存:集中式缓存是目前分布式缓存的主流形式,集中式缓存的读、写都需要网络访问,其好处是不会随着集群节点数量的增加而产生额外的负担,其坏处自然是读、写都不再可能达到进程内缓存那样的高性能。
集中式缓存还有一个必须提到的关键特点,它与使用缓存的应用分处在独立的进程空间中,其好处是它能够为异构语言提供服务,譬如用 C 语言编写的 Memcached 完全可以毫无障碍地为 Java 语言编写的应用提供缓存服务;但其坏处是如果要缓存对象等复杂类型的话,基本上就只能靠序列化来支撑具体语言的类型系统(支持 Hash 类型的缓存,可以部分模拟对象类型),不仅有序列化的成本,还很容易导致传输成本也显著增加。举个例子,假设某个有 100 个字段的大对象变更了其中 1 个字段的值,通常缓存也不得不把整个对象所有内容重新序列化传输出去才能实现更新,因此,一般集中式缓存更提倡直接缓存原始数据类型而不是对象。相比之下,JBossCache 通过它的字节码自审(Introspection)功能和树状存储结构(TreeCache),做到了自动跟踪、处理对象的部分变动,用户修改了对象中哪些字段的数据,缓存就只会同步对象中真正变更那部分数据。
如今 Redis 广为流行,基本上已经打败了 Memcached 及其他集中式缓存框架,成为集中式缓存的首选,甚至可以说成为了分布式缓存的实质上的首选,几乎到了不必管读取、写入哪种操作更频繁,都可以无脑上 Redis 的程度。也因如此,之前说到哪些数据适合用复制式缓存、哪些数据适合集中式缓存时,笔者都在开头加了个拗口的“理论上”。尽管 Redis 最初设计的本意是 NoSQL 数据库而不是专门用来做缓存的,可今天它确实已经成为许多分布式系统中无可或缺的基础设施,广泛用作缓存的实现方案。
从数据一致性角度说,缓存本身也有集群部署的需求,理论上你应该认真考虑一下是否能接受不同节点取到的缓存数据有可能存在差异。譬如刚刚放入缓存中的数据,另外一个节点马上访问发现未能读到;刚刚更新缓存中的数据,另外一个节点访问在短时间内读取到的仍是旧的数据,等等。根据分布式缓存集群是否能保证数据一致性,可以将它分为 AP 和 CP 两种类型(在“分布式事务”中已介绍过 CAP 各自的含义)。此处又一次出现了“理论上”,是因为我们实际开发中通常不太会把追求强一致性的数据使用缓存来
1.4 架构师的视角 (续)
架构安全性
即使只限定在“软件架构设计”这个语境下,系统安全仍然是一个很大的话题。我们谈论的计算机系统安全,不仅仅是指“防御系统被黑客攻击”这样狭隘的安全,还至少应包括(不限于)以下这些问题的具体解决方案:
- 认证(Authentication):系统如何正确分辨出操作用户的真实身份?
- 授权(Authorization):系统如何控制一个用户该看到哪些数据、能操作哪些功能?
- 凭证(Credential):系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
- 保密(Confidentiality):系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
- 传输(Transport Security):系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
- 验证(Verification):系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?
与安全相关的问题,一般不会直接创造价值,解决起来又烦琐复杂,费时费力,因此经常被开发者有意无意地忽略掉。庆幸的是这些问题基本上也都是与具体系统、具体业务无关的通用性问题,这意味着它们往往会存在着业界通行的、已被验证过是行之有效的解决方案,乃至已经形成行业标准,不需要开发者自己从头去构思如何解决。
在本章中,笔者会围绕系统安全的标准方案,逐一探讨以上问题的处理办法,并会以 Fenix’s Bookstore 作为案例实践。因篇幅所限,笔者没有在文中直接贴出代码,如有需要,可以在 Fenix’s Bookstore 的 GitHub 仓库获取。此外,还有其他一些安全相关的内容主要是由管理、运维、审计领域为主导,尽管也需要软件架构和开发的配合参与,但不列入本章的讨论范围之内,譬如:安全审计、系统备份与恢复、信息系统安全法规与制度、计算机防病毒制度、保护私有信息规则,等等。
认证
认证(Authentication)
系统如何正确分辨出操作用户的真实身份?
认证、授权和凭证可以说是一个系统中最基础的安全设计,哪怕再简陋的信息系统,大概也不可能忽略掉“用户登录”功能。信息系统为用户提供服务之前,总是希望先弄清楚“你是谁?”(认证)、“你能干什么?”(授权)以及“你如何证明?”(凭证)这三个基本问题。然而,这三个基本问题又并不像部分开发者认为的那样,只是一个“系统登录”功能,仅仅是校验一下用户名、密码是否正确这么简单。账户和权限信息作为一种必须最大限度保障安全和隐私,同时又要兼顾各个系统模块甚至系统间共享访问的基础主数据,它的存储、管理与使用都面临一系列复杂的问题。对于某些大规模的信息系统,账户和权限的管理往往要由专门的基础设施来负责,譬如微软的活动目录(Active Directory,AD)或者轻量目录访问协议(Lightweight Directory Access Protocol,LDAP),跨系统的共享使用甚至会用到区块链技术。
另外还有一个认知偏差:尽管“认证”是解决“你是谁?”的问题,但这里的“你”并不一定是指人(真不是在骂你),也可能是指外部的代码,即第三方的类库或者服务。最初,对代码认证的重要程度甚至高于对最终用户的认证,譬如在早期的 Java 系统里,安全认证默认是特指“代码级安全”,即你是否信任要在你的电脑中运行的代码。这是由 Java 当时的主要应用形式——Java Applets 所决定的:类加载器从远端下载一段字节码,以 Applets 的形式在用户的浏览器中运行,由于 Java 操控计算机资源的能力要远远强于 JavaScript,因此必须先确保这些代码不会损害用户的计算机,否则就谁都不敢去用。这一阶段的安全观念催生了现在仍然存在于 Java 技术体系中的“安全管理器”(java.lang.SecurityManager)、“代码权限许可”(java.lang.RuntimePermission)等概念。如今,对外部类库和服务的认证需求依然普遍,但相比起五花八门的最终用户认证来说,代码认证的研究发展方向已经很固定,基本上都统一到证书签名上。在本节中,认证的范围只限于对最终用户的认证,而代码认证会安排在“分布式的基石”中的“服务安全”去讲解。
认证的标准
世纪之交,Java 迎来了 Web 时代的辉煌,互联网的迅速兴起促使 Java 进入了快速发展时期。这时候,基于 HTML 和 JavaScript 的超文本 Web 应用迅速超过了“Java 2 时代”之前的 Java Applets 应用,B/S 系统对最终用户认证的需求使得“安全认证”的重点逐渐从“代码级安全”转为“用户级安全”,即你是否信任正在操作的用户。在 1999 年,随 J2EE 1.2(它是 J2EE 的首个版本,为了与 J2SE 同步,初始版本号直接就是 1.2)发布的 Servlet 2.2 中,添加了一系列用于认证的 API,主要包括下列两部分内容:
- 标准方面,添加了四种内置的、不可扩展的认证方案,即 Client-Cert、Basic、Digest 和 Form。
- 实现方面,添加了与认证和授权相关的一套程序接口,譬如
HttpServletRequest::isUserInRole()、HttpServletRequest::getUserPrincipal()等方法。
一项发布超过 20 年的老旧技术,原本并没有什么专门提起的必要性,笔者之所以引用这件事,是希望从它包含的两部分内容中引出一个架构安全性的经验原则:以标准规范为指导、以标准接口去实现。安全涉及的问题很麻烦,但解决方案已相当成熟,对于 99% 的系统来说,在安全上不去做轮子,不去想发明创造,严格遵循标准就是最恰当的安全设计。
引用 J2EE 1.2 对安全的改进还有另一个原因,它内置的 Basic、Digest、Form 和 Client-Cert 这四种认证方案都很有代表性,刚好分别覆盖了通信信道、协议和内容层面的认证。而这三种层面认证恰好涵盖了主流的三种认证方式,具体含义和应用场景列举如下。
- 通信信道上的认证:你和我建立通信连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于 SSL/TLS 传输安全层的认证。
- 通信协议上的认证:你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于 HTTP 协议的认证。
- 通信内容上的认证:你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于 Web 内容的认证。
关于通信信道上的认证,由于内容较多,又与后续介绍微服务安全方面的话题关系密切,将会独立放到本章的“传输”里,而且 J2EE 中的 Client-Cert 其实并不是用于 TLS 的,以它引出 TLS 并不合适。下面重点了解基于通信协议和通信内容的两种认证方式。
HTTP 认证
前文已经提前用到了一个技术名词——认证方案(Authentication Schemes),它是指生成用户身份凭证的某种方法,这个概念最初源于 HTTP 协议的认证框架(Authentication Framework)。IETF 在 RFC 7235 中定义了 HTTP 协议的通用认证框架,要求所有支持 HTTP 协议的服务器,在未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized 的状态码,同时应在响应报文头里附带以下两个分别代表网页认证和代理认证的 Header 之一,告知客户端应该采取何种方式产生能代表访问者身份的凭证信息:
WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>
接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,由服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden 错误。请求头报文应包含以下 Header 项之一:
Authorization: <认证方案> <凭证内容>
Proxy-Authorization: <认证方案> <凭证内容>
HTTP 认证框架提出认证方案是希望能把认证“要产生身份凭证”的目的与“具体如何产生凭证”的实现分离开来,无论客户端通过生物信息(指纹、人脸)、用户密码、数字证书抑或其他方式来生成凭证,都属于是如何生成凭证的具体实现,都可以包容在 HTTP 协议预设的框架之内。HTTP 认证框架的工作流程如图 5-1 所示。
图 5-1 HTTP 认证框架的工作流程时序图
(图中描述客户端与服务端的交互:客户端请求GET /admin,服务端返回 401 Unauthorized 及 WWW-Authenticate 头,客户端询问用户,再次请求并携带 Authorization 头,服务端校验后返回 200 OK 或 403 Forbidden。)
以上概念性的介绍可能会有些枯燥抽象,下面笔者将以最基础的认证方案——HTTP Basic 认证为例来介绍认证是如何工作的。HTTP Basic 认证是一种主要以演示为目的的认证方案,也应用于一些不要求安全性的场合,譬如家里的路由器登录等。Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。譬如请求资源 GET /admin 后,浏览器会收到来自服务端的如下响应:
HTTP/1.1 401 Unauthorized
Date: Mon, 24 Feb 2020 16:50:53 GMT
WWW-Authenticate: Basic realm="example from icyfenix.cn"
此时,浏览器必须询问最终用户,即弹出类似图 5-2 所示的 HTTP Basic 认证对话框,要求提供用户名和密码。
图 5-2 HTTP Basic 认证对话框
(图中展示一个典型的浏览器登录对话框,提示输入用户名和密码。)
用户在对话框中输入密码信息,譬如输入用户名 icyfenix,密码 123456,浏览器会将字符串 icyfenix:123456 编码为 aWN5ZmVuaXg6MTIzNDU2,然后发送给服务端,HTTP 请求如下所示:
GET /admin HTTP/1.1
Authorization: Basic aWN5ZmVuaXg6MTIzNDU2
服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就返回 /admin 的资源,否则就返回 403 Forbidden 错误,禁止下一步操作。注意 Base64 只是一种编码方式,并非任何形式的加密,所以 Basic 认证的风险是显而易见的。除 Basic 认证外,IETF 还定义了很多种可用于实际生产环境的认证方案,列举如下。
- Digest:RFC 7616,HTTP 摘要认证,可视为 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后再通过 MD5/SHA 等哈希算法取摘要发送出去。但是这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了 Nonce 这样的动态盐值去抵御重放和冒认,遇到中间人攻击时依然存在显著的安全风险。关于加解密的问题,将在“保密”小节中详细讨论。
- Bearer:RFC 6750,基于 OAuth 2 规范来完成认证,OAuth2 是一个同时涉及认证与授权的协议,在“授权”小节将详细介绍 OAuth 2。
- HOBA:RFC 7486,HOBA(HTTP Origin-Bound Authentication)是一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型:一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificate)自签名证书模型。在“传输”小节将详细介绍数字证书。
HTTP 认证框架中的认证方案是允许自行扩展的,并不要求一定由 RFC 规范来定义,只要用户代理(User Agent,通常是浏览器,泛指任何使用 HTTP 协议的程序)能够识别这种私有的认证方案即可。因此,很多厂商也扩展了自己的认证方案。
- AWS4-HMAC-SHA256:亚马逊 AWS 基于 HMAC-SHA256 哈希算法的认证。
- NTLM / Negotiate:这是微软公司 NT LAN Manager(NTLM)用到的两种认证方式。
- Windows Live ID:微软开发并提供的“统一登入”认证。
- Twitter Basic:一个不存在的网站所改良的 HTTP 基础认证。
- ……
Web 认证
IETF 为 HTTP 认证框架设计了可插拔(Pluggable)的认证方案,原本是希望能涌现出各式各样的认证方案去支持不同的应用场景。尽管上节列举了一些还算常用的认证方案,但目前的信息系统,尤其是在系统对终端用户的认证场景中,直接采用 HTTP 认证框架的比例其实十分低,这不难理解,HTTP 是“超文本传输协议”,传输协议的根本职责是把资源从服务端传输到客户端,至于资源具体是什么内容,只能由客户端自行解析驱动。以 HTTP 协议为基础的认证框架也只能面向传输协议而不是具体传输内容来设计,如果用户想要从服务器中下载文件,弹出一个 HTTP 服务器的对话框,让用户登录是可接受的;但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由 HTTP 服务器来负责认证。这种依靠内容而不是传输协议来实现的认证方式,在万维网里被称为“Web 认证”,由于实现形式上登录表单占了绝对的主流,因此通常也被称为“表单认证”(Form Authentication)。
直至 2019 年以前,表单认证都没有什么行业标准可循,表单是什么样,其中的用户字段、密码字段、验证码字段是否要在客户端加密,采用何种方式加密,接受表单的服务地址是什么等,都完全由服务端与客户端的开发者自行协商决定。“没有标准的约束”反倒成了表单认证的一大优点,表单认证允许我们做出五花八门的页面,各种程序语言、框架或开发者本身都可以自行决定认证的全套交互细节。
可能你还记得开篇中说的“遵循规范、别造轮子就是最恰当的安全”,这里又将表单认证的高自由度说成是一大优点,好话都让笔者给说全了。笔者提倡用标准规范去解决安全领域的共性问题,这条原则完全没有必要与界面是否美观合理、操作流程是否灵活便捷这些应用需求对立起来。譬如,想要支持密码或扫码等多种登录方式、想要支持图形验证码来驱逐爬虫与机器人、想要支持在登录表单提交之前进行必要的表单校验,等等,这些需求十分具体,不具备写入标准规范的通用性,却具备足够的合理性,应当在实现层面去满足。同时,如何控制权限保证不产生越权操作、如何传输信息保证内容不被窃听篡改、如何加密敏感内容保证即使泄漏也不被逆推出明文,等等,这些问题已有通行的解决方案,明确定义在规范之中,也应当在架构层面去遵循。
表单认证与 HTTP 认证不见得是完全对立的,两者有不同的关注点,可以结合使用。以 Fenix’s Bookstore 的登录功能为例,页面表单是一个自行设计的 Vue.js 页面,但认证的整个交互过程遵循 OAuth 2 规范的密码模式。
2019 年 3 月,万维网联盟(World Wide Web Consortium,W3C)批准了由 FIDO(Fast IDentity Online,一个安全、开放、防钓鱼、无密码认证标准的联盟)领导起草的世界首份 Web 内容认证的标准“WebAuthn”(在这节里,我们只讨论 WebAuthn,不会涉及 CTAP、U2F 和 UAF),这里也许又有一些思维严谨的读者会感到矛盾与奇怪,不是才说了 Web 表单长什么样、要不要验证码、登录表单是否在客户端校验等等是十分具体的需求,不太可能定义在规范上的吗?确实如此,所以 WebAuthn 彻底抛弃了传统的密码登录方式,改为直接采用生物识别(指纹、人脸、虹膜、声纹)或者实体密钥(以 USB、蓝牙、NFC 连接的物理密钥容器)来作为身份凭证,从根本上消灭了用户输入错误产生的校验需求和防止机器人模拟产生的验证码需求等问题,甚至可以省掉表单界面,所以这个规范不关注界面该是什么样子、要不要验证码、是否要前端校验这些问题。
由于 WebAuthn 相对复杂,在阅读下面内容之前,如果你的设备和环境允许,建议先在 GitHub 网站的 2FA 认证功能 中实际体验一下如何通过 WebAuthn 完成两段式登录,再继续阅读后面的内容。硬件方面,要求用带有 Touch ID 的 MacBook,或者其他支持指纹、FaceID 验证的手机(目前在售的移动设备基本都带有生物识别装置)。软件方面,直至 iOS 13.6,iPhone 和 iPad 仍未支持 WebAuthn,但 Android 和 Mac OS 系统中的 Chrome,以及 Windows 的 Edge 浏览器都已经可以正常使用 WebAuthn 了。图 5-3 展示了使用 WebAuthn 登录不同浏览器的操作界面。
图 5-3 不同浏览器上使用 WebAuthn 登录的对比
(图中对比了 Chrome、Edge 等浏览器调用 WebAuthn 时界面上的差异。)
WebAuthn 规范涵盖了“注册”与“认证”两大流程,先来介绍注册流程,它大致可以分为以下步骤:
- 用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息均不包含在 WebAuthn 标准的定义范围内。
- 当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为 Challenge)和用户的 UserID(在规范中称作凭证 ID),返回给客户端。
- 客户端的 WebAuthn API 接收到 Challenge 和 UserID,把这些信息发送给验证器(Authenticator),验证器可理解为用户设备上 TouchID、FaceID、实体密钥等认证设备的统一接口。
- 验证器提示用户进行验证,如果支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),由验证器存储5. 浏览器将验证器返回的结果转发给服务器。
- 服务器核验信息,检查 UserID 与之前发送的是否一致,并用公钥解密后得到的结果与之前发送的 Challenge 相比较,一致即表明注册通过,由服务端存储该 UserID 对应的公钥。
以上步骤的时序如图 5-4 所示。
图 5-4 注册流程时序图
(时序图描述了用户、浏览器、服务器、验证器之间的交互:用户访问登录页面,点击注册,浏览器请求 Challenge 和 UserID,服务器返回,浏览器调用验证器生成密钥对并签名,验证器完成认证后返回签名信息和公钥,浏览器转发给服务器,服务器验证通过后完成注册。)
认证流程与注册类似,但省略了生成密钥对的步骤,改为直接利用已存储的私钥进行签名,步骤如下:
- 用户访问登录页面,输入用户名(或其他标识),点击登录。
- 服务端根据用户名查出 UserID,生成 Challenge,返回给客户端。
- 客户端将 Challenge 和 UserID 发送给验证器。
- 验证器根据 UserID 和当前域名查找到之前存储的私钥,提示用户进行生物或物理认证。
- 用户完成认证后,验证器使用私钥对 Challenge 签名,将签名结果和 UserID 返回客户端。
- 浏览器将签名结果和 UserID 转发给服务器。
- 服务器取出该 UserID 对应的公钥,验证签名,若通过则登录成功。
关键点
- WebAuthn 用公私钥对替代密码,私钥永不出验证器,从根本上防钓鱼和撞库。
- 认证过程中,Challenge 保证每次签名都是新鲜的,防止重放攻击。
WebAuthn 的出现,使得无密码认证成为可能,但普及仍需时日。在大多数系统中,传统的密码认证、HTTP 认证方案仍将长期存在。
授权
授权(Authorization)
系统如何控制一个用户该看到哪些数据、能操作哪些功能?
授权是在认证之后,确定“你能干什么”的过程。授权的核心是权限模型,常见的模型有:
- 自主访问控制(DAC):由资源所有者决定谁可以访问,如 Linux 文件权限。
- 强制访问控制(MAC):由系统根据安全标签决定访问,如 SELinux。
- 基于角色的访问控制(RBAC):用户关联角色,角色关联权限,最为常见。
- 基于属性的访问控制(ABAC):根据用户属性、资源属性、环境属性动态决策。
在实际系统设计中,授权往往不是单一模型,而是混合使用。本节将重点介绍 OAuth 2,因为它既是授权协议,也常被用于认证(实际上它是一种委托授权框架)。
OAuth 2
OAuth 2 是当前互联网应用最广泛的授权协议,定义于 RFC 6749。它解决了一个常见问题:如何允许第三方应用在用户授权下,访问用户在服务提供商上的资源,而无需将用户名和密码交给第三方应用。例如,用 GitHub 账号登录某个博客系统,博客系统不需要知道你的 GitHub 密码,只需要 GitHub 授权它能获取你的基本信息即可。
OAuth 2 定义了四种授权模式:
- 授权码模式(Authorization Code):最完整、最安全的模式,适用于有后端服务器的第三方应用。用户先被重定向到授权服务器,确认授权后获得授权码,第三方应用再用授权码换取令牌。
- 隐式模式(Implicit):简化版,省去授权码步骤,直接返回令牌,适用于无后端的纯前端应用(如单页应用)。但安全性较低,已不被推荐。
- 密码模式(Resource Owner Password Credentials):用户直接提供用户名和密码给第三方应用,第三方应用用它们换取令牌。仅适用于高度信任的应用,如系统原生客户端。
- 客户端凭证模式(Client Credentials):适用于应用访问自己拥有的资源,而非代表用户,如后台服务之间调用。
OAuth 2 的核心角色包括:
- 资源所有者(Resource Owner):通常指用户。
- 资源服务器(Resource Server):存放受保护资源的服务器,如 GitHub 的用户数据 API。
- 客户端(Client):想要访问资源的第三方应用。
- 授权服务器(Authorization Server):负责认证用户并颁发令牌的服务器。
以 Fenix’s Bookstore 为例,它采用密码模式,用户在登录表单输入自己的用户名和密码,系统直接向授权服务器请求令牌。后续访问受保护资源时,请求头携带 Authorization: Bearer <token>。
注意
OAuth 2 并非认证协议,它的本意是授权。但实践中常被误用于认证,如“用 GitHub 登录”实际上是用 OAuth 2 获取用户信息来证明身份。OpenID Connect 是基于 OAuth 2 的认证层,提供了标准化的用户身份验证。
有关 OAuth 2 和 OpenID Connect 的深入内容,将在“分布式的基石”中进一步讨论。
凭证
凭证(Credential)
系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
凭证是认证和授权过程中传递的“信任物”,常见的凭证形式包括:
- 用户名/密码:最传统的凭证,但易被窃取、泄露。
- 数字证书:基于 PKI,利用公私钥证明身份。
- 令牌(Token):一段代表身份和权限的字符串,可以有多种实现,如 JWT(JSON Web Token)、OAuth 2 的 access token。
- 生物特征:指纹、人脸等,正在成为主流。
JWT 是一种非常流行的令牌格式,定义于 RFC 7519。它由三部分 Base64 编码组成:Header、Payload、Signature。Header 指明签名算法,Payload 包含声明(claims),Signature 用于防篡改。JWT 的自包含性使其非常适合分布式系统中的无状态认证。
然而,JWT 也有一些设计注意事项:
- 令牌泄露:由于令牌即授权,一旦泄露,攻击者可冒充用户。所以令牌应设置合理的有效期,并使用 HTTPS 传输。
- 令牌无法单点注销:因为是无状态的,服务端无法主动让某个令牌失效,除非引入黑名单(有状态)。
因此,在实际架构中,通常会结合使用 JWT 和 OAuth 2,将 access token 设计为 JWT 格式,时效较短,同时配合 refresh token 实现长期登录。
保密
保密(Confidentiality)
系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
保密性的核心是加密。加密分为对称加密和非对称加密:
- 对称加密:加密解密使用同一个密钥,速度快,适合大量数据加密,如 AES。挑战在于密钥的安全分发。
- 非对称加密:公钥加密、私钥解密,或私钥签名、公钥验签,如 RSA、ECC。解决了密钥分发问题,但计算量大,通常用于加密对称密钥或数字签名。
在实际系统中,通常混合使用:用非对称加密传输对称密钥(如 TLS 握手过程),然后用对称密钥加密通信内容。
存储加密也是保密的重要一环:
- 数据库字段加密:敏感字段加密存储,应用层加解密。
- 透明数据加密(TDE):数据库自动加密整个数据文件,对应用透明。
- 用户密码存储:绝不能明文存储,应使用加盐哈希(如 bcrypt、scrypt、Argon2)。
最佳实践
永远不要自己发明加密算法,使用经过验证的库和标准。
另外,保密还涉及日志脱敏、数据掩码等,防止敏感信息通过日志、监控外泄。
传输安全
传输安全(Transport Security)
系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
传输层安全主要依赖 TLS/SSL。TLS 1.3 是目前的最新版本。它的主要功能:
- 身份认证:通过证书链验证服务器身份(可选的客户端证书认证)。
- 加密:使用对称密钥加密流量,防止窃听。
- 完整性校验:使用消息认证码(MAC)防止数据篡改。
TLS 握手过程大致如下:
- 客户端发送 ClientHello,包含支持的密码套件列表。
- 服务端回应 ServerHello,选定密码套件,发送证书。
- 客户端验证证书,生成会话密钥,用服务端公钥加密后发送。
- 双方使用会话密钥开始对称加密通信。
在微服务架构中,服务间通信也应当启用 mTLS(双向 TLS),确保服务双方都能验证对方身份。Service Mesh 如 Istio 可以透明地实现 mTLS,而不需要修改应用代码。
对于 Web 应用,强制所有流量使用 HTTPS 是现代标准,可使用 HSTS(HTTP Strict Transport Security)头防止降级攻击。证书管理可通过 Let’s Encrypt 等自动化工具实现。
验证
验证(Verification)
系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?
这里的验证主要是指输入验证。输入验证是安全的第一道防线,防止注入攻击、XSS、跨站请求伪造等。应遵循的原则:
- 永远不信任用户输入:对所有来自外部的输入进行严格校验。
- 使用白名单:尽可能使用白名单而非黑名单。
- 在服务端执行验证:客户端验证只能提升体验,不可作为安全措施。
- 参数化查询:防止 SQL 注入。
- 编码输出:防止 XSS。
- CSRF Token:防止跨站请求伪造。
- 类型、长度、范围检查:确保数据符合预期。
框架通常提供了内置的验证机制,如 Spring Validation、Hibernate Validator,应充分利用。
总结
架构安全性贯穿系统设计的各个层面。本章从认证、授权、凭证、保密、传输安全、验证六个方面概述了核心问题和标准解决方案。这些看似繁琐的事务,恰恰是架构师必须严谨对待的基石。遵循标准,善用成熟方案,不重复造轮子,就能在复杂的安全需求中构建可靠的系统。
后续章节将进入“分布式的基石”,深入探讨微服务架构中的服务发现、负载均衡、容错、安全等主题。
登录流程
登录流程与注册流程类似,如果你理解了注册流程,就很容易理解登录流程了。登录流程大致可以分为以下步骤:
- 用户访问登录页面,填入用户名后即可点击登录按钮。
- 服务器返回随机字符串 Challenge、用户 UserID。
- 浏览器将 Challenge 和 UserID 转发给验证器。
- 验证器提示用户进行认证操作。由于在注册阶段验证器已经存储了该域名的私钥和用户信息,所以如果域名和用户都相同的话,就不需要生成密钥对了,直接以存储的私钥加密 Challenge,然后返回给浏览器。
- 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;公钥是公开的,可以被任何人看到或存储。公钥可用于验证私钥生成的签名,但不能用来签名,除了得知私钥外,没有其他途径能够生成可被公钥验证为有效的签名,这样服务器就可以通过公钥是否能够解密来判断最终用户的身份是否合法。
WebAuthn 还一揽子地解决了传统密码在网络传输上的风险,在“保密”一节中我们会讲到无论密码是否客户端进行加密、如何加密,对防御中间人攻击来说都是没有意义的。更值得夸赞的是 WebAuthn 为登录过程带来极大的便捷性,不仅注册和验证的用户体验十分优秀,而且彻底避免了用户在一个网站上泄漏密码,所有使用相同密码的网站都受到攻击的问题,这个优点使得用户无须再为每个网站想不同的密码。
当前的 WebAuthn 还很年轻,普及率暂时还很有限,但笔者相信几年之内它必定会发展成 Web 认证的主流方式,被大多数网站和系统所支持。
认证的实现
了解过业界标准的认证规范以后,这部分简要介绍一下在 Java 技术体系内通常是如何实现安全认证的。Java 其实也有自己的认证规范,第一个系统性的 Java 认证规范发布于 Java
密码模式
密码模式
前面所说的授权码模式和隐私模式属于纯粹的授权模式,它们与认证没有直接的联系,如何认证用户的真实身份是与进行授权互相独立的过程。但在密码模式里,认证和授权就被整合成了同一个过程了。
密码模式原本的设计意图是仅限于用户对第三方应用是高度可信任的场景中使用,因为用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌。这种高度可信的第三方是极为较罕见的,尽管介绍 OAuth2 的材料中,经常举的例子是“操作系统作为第三方应用向授权服务器申请资源”,但真实应用中极少遇到这样的情况,合理性依然十分有限。
笔者认为,如果要采用密码模式,那“第三方”属性就必须弱化,把“第三方”视作是系统中与授权服务器相对独立的子模块,在物理上独立于授权服务器部署,但是在逻辑上与授权服务器仍同属一个系统,这样将认证和授权一并完成的密码模式才会有合理的应用场景。
譬如 Fenix’s Bookstore 便直接采用了密码模式,将认证和授权统一到一个过程中完成,尽管 Fenix’s Bookstore 中的 Frontend 工程和 Account 工程都能直接接触到用户名和密码,但它们事实上都是整个系统的一部分,这个前提下密码模式才具有可用性。关于分布式系统各个服务之间的信任关系,后续会在“零信任网络”与“服务安全”中作进一步讨论。
理解了密码模式的用途,它的调用过程就很简单了,就是第三方应用拿着用户名和密码向授权服务器换令牌而已。具体时序如图 5-10 所示。
资源所有者
第三方应用
授权服务器
提供密码凭证
发送用户的密码凭证
发放访问令牌和刷新令牌
资源所有者
第三方应用
授权服务器
图 5-10 密码模式的调用时序图
密码模式下“如何保障安全”的职责无法由 OAuth2 来承担,只能由用户和第三方应用来自行保障,尽管 OAuth2 在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何技术上的约束力。
客户端模式
客户端模式是四种模式中最简单的,它只涉及到两个主体,第三方应用和授权服务器。如果严谨一点,现在称“第三方应用”其实已经不合适了,因为已经没有了“第二方”的存在,资源所有者、操作代理在客户端模式中都是不必出现的。甚至严格来说叫“授权”都已不太恰当,资源所有者都没有了,也就不会有谁授予谁权限的过程。
客户端模式是指第三方应用(行文一致考虑,还是继续沿用这个称呼)以自己的名义,向授权服务器申请资源许可。此模式通常用于管理操作或者自动处理类型的场景中。举个具体例子,譬如笔者开了一家叫 Fenix’s Bookstore 的书店,因为小本经营,不像京东那样全国多个仓库可以调货,因此必须保证只要客户成功购买,书店就必须有货可发,不允许超卖。但经常有顾客下了订单又拖着不付款,导致部分货物处于冻结状态。所以 Fenix’s Bookstore 中有一个订单清理的定时服务,自动清理超过两分钟还未付款的订单。在这个场景里,订单肯定是属于下单用户自己的资源,如果把订单清理服务看作一个独立的第三方应用的话,它是不可能向下单用户去申请授权来删掉订单的,而应该直接以自己的名义向授权服务器申请一个能清理所有用户订单的授权。客户端模式的时序如图 5-11 所示。
应用
授权服务器
申请授权
发放访问令牌
应用
授权服务器
图 5-11 客户端模式的调用时序图
微服务架构并不提倡同一个系统的各服务间有默认的信任关系,所以服务之间调用也需要先进行认证授权,然后才能通信。此时,客户端模式便是一种常用的服务间认证授权的解决方案。Spring Cloud 版本的 Fenix’s Bookstore是采用这种方案来保证微服务之间的合法调用的,Istio 版本的 Fenix’s Bookstore则启用了双向 mTLS 通信,使用客户端证书来保障安全,它们可作为上一节介绍认证时提到的“通信信道认证”和“通信内容认证”例子,感兴趣的读者可以对比一下这两种方式的差异优劣。
OAuth2 中还有一种与客户端模式类似的授权模式,在 RFC 8628 中定义为“设备码模式”(Device Code),这里顺带提一下。设备码模式用于在无输入的情况下区分设备是否被许可使用,典型的应用便是手机锁网解锁(锁网在国内较少,但在国外很常见)或者设备激活(譬如某游戏机注册到某个游戏平台)的过程。它的时序如图 5-12 所示。
设备
授权服务器
要求授权
提供验证URI和用户码
申请令牌
loop
[ 循环直至超时 ]
设备
授权服务器
图 5-12 设备码模式的调用时序图
进行验证时,设备需要从授权服务器获取一个 URI 地址和一个用户码,然后需要用户手动或设备自动地到验证 URI 中输入用户码。在这个过程中,设备会一直循环,尝试去获取令牌,直到拿到令牌或者用户码过期为止。
凭证(Credentials)
系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
在前面介绍 OAuth2 的内容中,每一种授权模式的最终目标都是拿到访问令牌,但从未涉及过拿回来的令牌应该长什么样子。反而还挖了一些坑没有填(为何说 OAuth2 的一个主要缺陷是令牌难以主动失效)。这节讨论的主角是令牌,同时,还会讨论如果不使用 OAuth2,如何以最传统的方式完成认证、授权。
“如何承载认证授权信息”这个问题的不同看法,代表了软件架构对待共享状态信息的两种不同思路:状态应该维护在服务端,抑或是在客户端之中?在分布式系统崛起以前,这个问题原本已是有了较为统一的结论的,以 HTTP 协议的 Cookie-Session 机制为代表的服务端状态存储在三十年来都是主流的解决方案。不过,到了最近十年,由于分布式系统中共享数据必然会受到 CAP 不兼容原理的打击限制,迫使人们重新去审视之前已基本放弃掉的客户端状态存储,这就让原本通常只在多方系统中采用的 JWT 令牌方案,在分布式系统中也有了另一块用武之地。本节的话题,也就围绕着 Cookie-Session 和 JWT 之间的相同与不同而展开。
Cookie-Session
大家知道 HTTP 协议是一种无状态的传输协议,无状态是指协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的,但是我们中肯定有许多人并没有意识到 HTTP 协议无状态的重要性。假如你做了一个简单的网页,其中包含了 1 个 HTML、2 个 Script 脚本、3 个 CSS、还有 10 张图片,这个网页成功展示在用户屏幕前,需要完成 16 次与服务端的交互来获取上述资源,由于网络传输各种等因素的影响,服务器发送的顺序与客户端请求的先后并没有必然的联系,按照可能出现的响应顺序,理论上最多会有 P(16,16) = 20,922,789,888,000 种可能性。试想一下,如果 HTTP 协议不是设计成无状态的,这 16 次请求每一个都有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影响的话,那协调工作会有多么复杂。
可是,HTTP 协议的无状态特性又有悖于我们最常见的网络应用场景,典型就是认证授权,系统总得要获知用户身份才能提供合适的服务,因此,我们也希望 HTTP 能有一种手段,让服务器至少有办法能够区分出发送请求的用户是谁。为了实现这个目的,RFC 6265 规范定义了 HTTP 的状态管理机制,在 HTTP 协议中增加了 Set-Cookie 指令,该指令的含义是以键值对的方式向客户端发送一组信息,此信息将在此后一段时间内的每次 HTTP 请求中,以名为 Cookie 的 Header 附带着重新发回给服务端,以便服务端区分来自不同客户端的请求。一个典型的 Set-Cookie 指令如下所示:
Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly
收到该指令以后,客户端再对同一个域的请求中就会自动附带有键值对信息 id=icyfenix,譬如以下代码所示:
GET /index.html HTTP/2.0
Host: icyfenix.cn
Cookie: id=icyfenix
根据每次请求传到服务端的 Cookie,服务器就能分辨出请求来自于哪一个用户。由于 Cookie 是放在请求头上的,属于额外的传输负担,不应该携带过多的内容,而且放在 Cookie 中传输也并不安全,容易被中间人窃取或被篡改,所以通常是不会像例子中设置 id=icyfenix 这样的明文信息。一般来说,系统会把状态信息保存在服务端,在 Cookie 里只传输的是一个无字面意义的、不重复的字符串,习惯上以 sessionid 或者 jsessionid 为名,服务器拿这个字符串为 Key,在内存中开辟一块空间,以 Key/Entity 的结构存储每一个在线用户的上下文状态,再辅以一些超时自动清理之类的管理措施。这种服务端的状态管理机制就是今天大家非常熟悉的 Session,Cookie-Session 也是最传统但今天依然广泛应用于大量系统中的,由服务端与客户端联动来完成的状态管理机制。
Cookie-Session 方案在本章的主题“安全性”上其实是有一定先天优势的:状态信息都存储于服务器,只要依靠客户端的同源策略和 HTTPS 的传输层安全,保证 Cookie 中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉上下文信息在传输过程中被泄漏和篡改的风险。Cookie-Session 方案的另一大优点是服务端有主动的状态管理能力,可根据自己的意愿随时修改、清除任意上下文信息,譬如很轻易就能实现强制某用户下线的这样功能。
Session-Cookie 在单节点的单体服务环境中是最合适的方案,但当需要水平扩展服务能力,要部署集群时就开始面临麻烦了,由于 Session 存储在服务器的内存中,当服务器水平拓展成多节点时,设计者必须在以下三种方案中选择其一:
- 牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法,譬如根据用户 IP 或者 Session 来分配节点,每一个特定用户发出的所有请求都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
- 牺牲集群的可用性(Availability),让各个节点之间采用复制式的 Session,每一个节点中的 Session 变动都会发送到组播地址的其他服务器上,这样某个节点崩溃了,不会中断都某个用户的服务,但 Session 之间组播复制的同步代价高昂,节点越多时,同步成本越高。
- 牺牲集群的分区容忍性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不再能提供服务。
通过前面章节的内容,我们已经知道只要在分布式系统中共享信息,CAP 就不可兼得,所以分布式环境中的状态管理一定会受到 CAP 的局限,无论怎样都不可能完美。但如果只是解决分布式下的认证授权问题,并顺带解决少量状态的问题,就不一定只能依靠共享信息去实现。这句话的言外之意是提醒读者,接下来的 JWT 令牌与 Cookie-Session 并不是完全对等的解决方案,它只用来处理认证授权问题,充其量能携带少量非敏感的信息,只是 Cookie-Session 在认证授权问题上的替代品,而不能说 JWT 要比 Cookie-Session 更加先进,更不可能全面取代 Cookie-Session 机制。
JWT
Cookie-Session 机制在分布式环境下会遇到 CAP 不可兼得的问题,而在多方系统中,就更不可能谈什么 Session 层面的数据共享了,哪怕服务端之间能共享数据,客户端的 Cookie 也没法跨域。所以我们不得不重新捡起最初被抛弃的思路,当服务器存在多个,客户端只有一个时,把状态信息存储在客户端,每次随着请求发回服务器去。笔者才说过这样做的缺点是无法携带大量信息,而且有泄漏和篡改的安全风险。信息量受限的问题并没有太好的解决办法,但是要确保信息不被中间人篡改则还是可以实现的,JWT 便是这个问题的标准答案。
JWT(JSON Web Token)定义于 RFC 7519 标准之中,是目前广泛使用的一种令牌格式,尤其经常与 OAuth2 配合应用于分布式的、涉及多方的应用系统中。介绍 JWT 的具体构成之前,我们先来直观地看一下它是什么样子的,如图 5-13 所示。
以上截图来自 JWT 官网(https://jwt.io),数据则是笔者随意编的。右边的 JSON 结构是 JWT 令牌中携带的信息,左边的字符串呈现了 JWT 令牌的本体。它最常见的使用方式是附在名为 Authorization 的 Header 发送给服务端,前缀在 RFC 6750 中被规定为 Bearer。如果你没有忘记“认证方案”与“OAuth 2”的内容,那看到 Authorization 这个 Header 与 Bearer 这个前缀时,便应意识到它是 HTTP 认证框架中的 OAuth 2 认证方案。如下代码展示了一次采用 JWT 令牌的 HTTP 实际请求:
GET /restful/products/1 HTTP/1.1
Host: icyfenix.cn
Connection: keep-alive
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJpY3lmZW5peCIsInNjb3BlIjpbIkFMTCJdLCJleHAiOjE1ODQ5NDg5NDcsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwianRpIjoiOWQ3NzU4NmEtM2Y0Zi00Y2JiLTk5MjQtZmUyZjc3ZGZhMzNkIiwiY2xpZW50X2lkIjoiYm9va3N0b3JlX2Zyb250ZW5kIiwidXNlcm5hbWUiOiJpY3lmZW5peCJ9.539WMzbjv63wBtx4ytYYw_Fo1ECG_9vsgAn8bheflL8
图 5-13 中右边的状态信息是对令牌使用 Base64URL 转码后得到的明文,请特别注意是明文,JWT 只解决防篡改的问题,并不解决防泄漏的问题,因此令牌默认是不加密的。尽管你自己要加密也并不难做到,接收时自行解密即可,但这样做其实没有太大意义,原因笔者将在下一节“保密”中去解释。
从明文中可以看到 JWT 令牌是以 JSON 结构(毕竟名字就叫 JSON Web Token)存储的,结构总体上可划分为三个部分,每个部分间用点号 . 分隔开。
第一部分是令牌头(Header),内容如下所示:
{
"alg": "HS256",
"typ": "JWT"
}它描述了令牌的类型(统一为 typ:JWT)以及令牌签名的算法,示例中 HS256 为 HMAC SHA256 算法的缩写,其他各种系统支持的签名算法可以参考 https://jwt.io/ 网站所列。
额外知识:散列消息认证码
在本节及后面其他关于安全的内容中,经常会在某种哈希算法前出现“HMAC”的前缀,这是指散列消息认证码(Hash-based Message Authentication Code,HMAC)。可以简单将它理解为一种带有密钥的哈希摘要算法,实现形式上通常是把密钥以加盐方式混入,与内容一起做哈希摘要。
HMAC 哈希与普通哈希算法的差别是普通的哈希算法通过 Hash 函数结果易变性保证了原有内容未被篡改,HMAC 不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。如图 5-14 所示。
令牌的第二部分是负载(Payload),这是令牌真正需要向服务端传递的信息。针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息,针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。JWT 的负载部分是可以完全自定义的,根据具体要解决的问题不同,设计自己所需要的信息,只是总容量不能太大,毕竟要受到 HTTP Header 大小的限制。一个 JWT 负载的例子如下所示:
{
"username": "icyfenix",
"authorities": [
"ROLE_USER",
"ROLE_ADMIN"
],
"scope": [
"ALL"
],
"exp": 1584948947,
"jti": "9d77586a-3f4f-4cbb-9924-fe2f77dfa33d",
"client_id": "bookstore_frontend"
}而 JWT 在 RFC 7519 中推荐(非强制约束)了七项声明名称(Claim Name),如有需要用到这些内容,建议字段名与官方的保持一致:
iss(Issuer):签发人。exp(Expiration Time):令牌过期时间。sub(Subject):主题。aud(Audience):令牌受众。nbf(Not Before):令牌生效时间。iat(Issued At):令牌签发时间。jti(JWT ID):令牌编号。
此外在 RFC 8225、RFC 8417、RFC 8485 等规范文档,以及 OpenID 等协议中,都定义有约定好公有含义的名称,内容比较多,笔者就不贴出来了,可以参考 IANA JSON Web Token Registry。
令牌的第三部分是签名(Signature),签名的意思是:使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,以例子里使用的 JWT 默认的 HMAC SHA256 算法为例,将通过以下公式产生签名值:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)签名的意义在于确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。因为被签名的内容哪怕发生了一个字节的变动,也会导致整个签名发生显著变化。此外,由于签名这件事情只能由认证授权服务器完成(只有它知道 Secret),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的 JWT 中的负载信息。
JWT 默认的签名算法 HMAC SHA256 是一种带密钥的哈希摘要算法,加密与验证过程均只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。在多方系统或者授权服务与资源服务分离的分布式应用中,通常会采用非对称加密算法来进行签名,这时候除了授权服务端持有的可以用于签名的私钥外,还会对其他服务器公开一个公钥,公开方式一般遵循 JSON Web Key 规范。公钥不能用来签名,但是能被其他服务用于验证签名是否由私钥所签发的。这样其他服务器也能不依赖授权服务器、无须远程通信即可独立判断 JWT 令牌中的信息的真伪。
在 Fenix’s Bookstore 的单体服务版本中,采用了默认的 HMAC SHA256 算法来加密签名,而 Istio 服务网格版本里,终端用户认证会由服务网格的基础设施参来完成,此时就改用了非对称加密的 RSA SHA256 算法来进行签名,希望深入了解凭证安全的读者,不妨对比一下这两部分的代码。更多关于哈希摘要、对称和非对称加密的讨论,将会在“传输”一节中继续进行。
JWT 令牌是多方系统中一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现,是准确、完整、不可篡改、且不可抵赖的。同时,由于 JWT 本身可以携带少量信息,这十分有利于 RESTful API 的设计,能够较容易地做成无状态服务,在做水平扩展时就不需要像前面 Cookie-Session 方案那样考虑如何部署的问题。现实中也确实有一些项目直接采用 JWT 来承载上下文来实现完全无状态的服务端,这能获得任意加入或移除服务节点的巨大便利,天然具有完美的水平扩缩能力。譬如,在调试 Fenix’s Bookstore 的代码时,你随时都可以重启服务,重启后,客户端仍然能毫无感知地继续操作流程;而对于有状态的系统,就必须通过重新登录、进行前置业务操作来为服务端重建状态。尽管大型系统中只使用 JWT 来维护上下文状态,服务端完全不持有状态是不太现实的,不过将热点的服务单独抽离出来做成无状态,仍是一种有效提升系统吞吐能力的架构技巧。但是,JWT 也并非没有缺点的完美方案,它存在着以下几个经常被提及的缺点:
- 令牌难以主动失效:JWT 令牌一旦签发,理论上就和认证服务器再没有什么瓜葛了,在到期之前就会始终有效,除非服务器部署额外的逻辑去处理失效问题,这对某些管理功能的实现是很不利的。譬如一种颇为常见的需求是:要求一个用户只能在一台设备上登录,在 B 设备登录后,之前已经登录过的 A 设备就应该自动退出。如果采用 JWT,就必须设计一个“黑名单”的额外的逻辑,用来把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在 Session、Redis 或者数据库中,都会让服务退化成有状态服务,降低了 JWT 本身的价值,但黑名单在使用 JWT 时依然是很常见的做法,需要维护的黑名单一般是很小的状态量,许多场景中还是有存在价值的。
- 相对更容易遭受重放攻击:首先说明 Cookie-Session 也是有重放攻击问题的,只是因为 Session 中的数据控制在服务端手上,应对重放攻击会相对主动一些。要在 JWT 层面解决重放攻击需要付出比较大的代价,无论是加入全局序列号(HTTPS 协议的思路)、Nonce 字符串(HTTP Digest 验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。真要处理重放攻击,建议的解决方案是在信道层次(譬如启用 HTTPS)上解决,而不提倡在服务层次(譬如在令牌或接口其他参数上增加额外逻辑)上解决。
- 只能携带相当有限的数据:HTTP 协议并没有强制约束 Header 的最大长度,但是,各种服务器、浏览器都会有自己的约束,譬如 Tomcat 就要求 Header 最大不超过 8KB,而在 Nginx 中则默认为 4KB,因此在令牌中存储过多的数据不仅耗费传输带宽,还有额外的出错风险。
- 必须考虑令牌在客户端如何存储:严谨地说,这个并不是 JWT 的问题而是系统设计的问题。如果授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化那是最理想的方案。但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登录的。这样的话,想想客户端该把令牌存放到哪里?Cookie?localStorage?Indexed DB?它们都有泄漏的可能,而令牌一旦泄漏,别人就可以冒充用户的身份做任何事情。
- 无状态也不总是好的:这个其实不也是 JWT 的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态 JWT 的方案,做一个在线用户实时统计功能。兄弟,难搞哦。
保密(Confidentiality)
系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
保密是加密和解密的统称,是指以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密的方法,或者知晓解密的算法但缺少解密所需的必要信息,仍然无法了解数据的真实内容。
按照需要保密信息所处的环节不同,可以划分为“信息在客户端时的保密”、“信息在传输时的保密”和“信息在服务端时的保密”三类,或者进一步概括为“端的保密”和“链路的保密”两类。我们把最复杂、最有效,又早有标准解决方案的“传输环节”单独提取出来,放到下一个小节去讨论,本节将结合笔者的一些个人观点,重点讨论密码等敏感信息如何保障安全等级、是否应该从客户端开始加密、应该如何存储及如何验证等常见的安全保密问题。
保密的强度
保密是有成本的,追求越高的安全等级,就要付出越多的工作量与算力消耗。连国家保密法都会把秘密信息划分为秘密、机密、绝密三级来区别对待,可见即使是信息安全,也应该有所取舍。笔者以用户登录为例,列举几种不同强度的保密手段,讨论它们的防御关注点与弱点:
-
以摘要代替明文:如果密码本身比较复杂,那一次简单的哈希摘要至少可以保证即使传输过程中有信息泄漏,也不会被逆推出原信息;即使密码在一个系统中泄漏了,也不至于威胁到其他系统的使用,但这种处理不能防止弱密码被彩虹表攻击所破解。
-
先加盐值再做哈希是应对弱密码的常用方法:盐值可以替弱密码建立一道防御屏障,一定程度上防御已有的彩虹表攻击,但并不能阻止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行冒认。
-
将盐值变为动态值能有效防止冒认:如果每次密码向服务端传输时都掺入了动态的盐值,让每次加密的结果都不同,那即使传输给服务端的加密结果被窃取了,也不能冒用来进行另一次调用。尽管在双方通信均可能泄漏的前提下协商出只有通信双方才知道的保密信息是完全可行的(后续介绍“传输安全层”时会提到),但这样协商出盐值的过程将变得极为复杂,而且每次协商只保护一次操作,也难以阻止对其他服务的重放攻击。
-
给服务加入动态令牌,在网关或其他流量公共位置建立校验逻辑,服务端愿意付出在集群中分发令牌信息等代价的前提下,可以做到防止重放攻击,但是依然不能抵御传输过程中被嗅探而泄漏信息的问题。
-
启用 HTTPS 可以防御链路上的恶意嗅探,也能在通信层面解决了重放攻击的问题。但是依然有因客户端被攻破产生伪造根证书风险、有因服务端被攻破产生的证书泄漏而被中间人冒认的风险、有因 CRL 更新不及时或者 OCSP Soft-fail 产生吊销证书被冒用的风险、有因 TLS 的版本过低或密码学套件选用不当产生加密强度不足的风险。
-
为了抵御上述风险,保密强度还要进一步提升,譬如银行会使用独立于客户端的存储证书的物理设备(俗称的 U 盾)来避免根证书被客户端中的恶意程序窃取伪造;大型网站涉及到账号、金钱等操作时,会使用双重验证开辟一条独立于网络的信息通道(如手机验证码、电子邮件)来显著提高冒认的难度;甚至一些关键企业(如国家电网)或机构(如军事机构)会专门建设遍布全国各地的与公网物理隔离的专用内部网络来保障通信安全。
听了上述这些逐步升级的保密措施,你应该能对“更高安全强度同时也意味着更多代价”有更具体的理解,不是任何一个网站、系统、服务都需要无限拔高的安全性。也许这时候你会好奇另一个问题:安全的强度有尽头吗?存不存在某种绝对安全的保密方式?答案可能出乎多数人的意料,确实是有的。信息论之父香农严格证明了一次性密码(One Time Password)的绝对安全性。但是使用一次性密码必须有个前提,就是已经提前安全地把密码或密码列表传达给对方。譬如,给你给朋友人肉送去一本存储了完全随机密码的密码本,
传输
传输安全(Transport Security)
系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
前文中笔者已经为传输安全层挖下了不少坑,譬如:基于信道的认证是怎样实现的?为什么 HTTPS 是绝大部分信息系统防御通信被窃听和篡改的唯一可行手段?传输安全层难道不也是一种自动化的加密吗?为何说客户端如何加密都不能代替 HTTPS?
本节将以“假设链路上的安全得不到保障,攻击者如何摧毁之前认证、授权、凭证、保密中所提到的种种安全机制”为场景,讲解传输层安全所要解决的问题,同时也是对前面这些疑问句的回答。
摘要、加密与签名
我们从 JWT 令牌的一小段“题外话”来引出现代密码学算法的三种主要用途:摘要、加密与签名。JWT 令牌携带信息的可信度源自于它是被签名过的信息,因此是不可篡改的,是令牌签发者真实意图的体现。然而,你是否了解过签名具体做了什么?为什么有签名就能够让负载中的信息变得不可篡改和不可抵赖呢?要解释数字签名(Digital Signature),必须先从密码学算法的另外两种基础应用“摘要”和“加密”说起。
摘要
也称之为数字摘要(Digital Digest)或数字指纹(Digital Fingerprint)。JWT 令牌中默认的签名信息是对令牌头、负载和密钥三者通过令牌头中指定的哈希算法(HMAC SHA256)计算出来的摘要值,如下所示:
signature = SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) ,
secret)理想的哈希算法都具备两个特性:一是易变性,这是指算法的输入端发生了任何一点细微变动,都会引发雪崩效应(Avalanche Effect),使得输出端的结果产生极大的变化。这个特性常被用来做校验,保护信息未被篡改,譬如互联网上下载大文件,常会附有一个哈希校验码,以确保下载下来的文件没有因网络或其他原因与原文件产生任何偏差。二是不可逆性,摘要的过程是单向的,不可能从摘要的结果中逆向还原出输入值来。这点只要具备初中数学知识就能想明白,世间的信息有无穷多种,而摘要的结果无论其位数是 32、128、512 Bits,再大也总归是个有限的数字,因此输入数据与输出的摘要结果必然不是一一对应的关系,如果我把一部电影做摘要形成 256 Bits 的哈希值,应该没有人会指望能从这个哈希值中还原出一部电影的。偶尔能听到 MD5、SHA1 或其他哈希算法被破解了的新闻,这里的“破解”并不是“解密”的意思,而是指找到了该算法的高效率碰撞方法,能够在合理的时间内生成两个摘要结果相同的输入比特流,但并不能指定这两个输入流中的某一个,更不代表碰撞产生的比特流就会是原来的输入源。
由这两个特点可见,摘要的意义是在源信息不泄漏的前提下辨别其真伪。易变性保证了从公开的特征上可以甄别出是否来自于源信息,不可逆性保证了从公开的特征并不会暴露出源信息,这与今天用做身份甄别的指纹、面容和虹膜的生物特征是具有高度可比性的。在一些场合中,摘要也会被借用来做加密(如保密中介绍的慢哈希 Bcrypt 算法)和签名(如 JWT 签名中的 HMAC SHA256 算法),但在严格意义上看,摘要与这两者是有本质的区别。
加密
加密与摘要的本质区别在于加密是可逆的,逆过程就是解密。在经典密码学时代,加密的安全主要是依靠机密性来保证的,即依靠保护加密算法或算法的执行参数不被泄漏来保障信息的安全。而现代密码学不依靠机密性,加解密算法都是完全公开的,安全建立在特定问题的计算复杂度之上,具体是指算法根据输入端计算输出结果耗费的算力资源很小,但根据输出端的结果反过来推算原本的输入,耗费的算力就极其庞大。一个经常在课堂中用来说明的计算复杂度的例子是大数的质因数分解,我们可以轻而易举地(以 O(nlogn)的复杂度)计算出两个大素数的乘积,譬如:
97667323933 * 128764321253 = 12576066674829627448049根据算术基本定理,质因数的分解形式是唯一的,且前面计算条件中给出的运算因子已经是质数,所以 12,576,066,674,829,627,448,049 的分解形式就只有唯一的形式,即上面所示的唯一答案。然而如何对大数进行质因数分解,迄今没有找到多项式时间的算法,甚至无法确切地知道这个问题属于哪个复杂度类(Complexity Class)。所以尽管这个过程理论上一定是可逆的,但实际上算力差异决定了逆过程无法实现。(注:24 位十进制数的因数分解完全在现代计算机的暴力处理能力范围内,这里只是举例。但目前很多计算机科学家都相信大数分解问题就是一种 P!=NP 的证例,尽管也并没有人能证明它一定不存在多项式时间的解法。除了质因数分解外,离散对数和椭圆曲线也是具备实用性的复杂问题)
根据加密与解密是否采用同一个密钥,现代密码学算法可分为对称加密算法和非对称加密算法两大类型,这两类算法各自有很明确的优劣势与应用场景。对称加密的缺点显而易见,加密和解密使用相同的密钥,当通信的成员数量增加时,为保证两两通信都采用独立的密钥,密钥数量就与成员数量的平方成正比,这必然面临密钥管理的难题。而更尴尬的难题是当通信双方原本就不存在安全的信道时,如何才能将一个只能让通信双方才能知道的密钥传输给对方?如果有通道可以安全地传输密钥,那为何不使用现有的通道传输信息?这个“蛋鸡悖论”曾在很长的时间里严重阻碍了密码学在真实世界中推广应用。
20 世纪 70 年代中后期出现的非对称加密算法从根本上解决了密钥分发的难题,它将密钥分成公钥和私钥,公钥可以完全公开,无须安全传输的保证。私钥由用户自行保管,不参与任何通信传输。根据这两个密钥加解密方式的不同,使得算法可以提供两种不同的功能:
- 公钥加密,私钥解密,这种就是加密,用于向私钥所有者发送信息,这个信息可能被他人篡改,但是无法被他人得知。如果甲想给乙发一个安全保密的数据,那么应该甲乙各自有一个私钥,甲先用乙的公钥加密这段数据,再用自己的私钥加密这段加密后的数据。最后再发给乙,这样确保了内容即不会被读取,也不能被篡改。
- 私钥加密,公钥解密,这种就是签名,用于让所有公钥所有者验证私钥所有者的身份,并且用来防止私钥所有者发布的内容被篡改。但是不用来保证内容不被他人获得。
这两种用途理论上肯定是成立的,现实中却一般不成立,单靠非对称加密算法,既做不了加密也做不了签名。原因是不论是加密还是解密,非对称加密算法的计算复杂度都相当高,性能比对称加密要差上好几个数量级(不是好几倍)。加解密性能不仅影响速度,还导致了现行的非对称加密算法都没有支持分组加密模式。分组是指由于明文长度与密钥长度在安全上具有相关性,通俗地说就是多长的密钥决定了它能加密多长的明文,如果明文太短就需要进行填充,太长就需要进行分组。因非对称加密本身的效率所限,难以支持分组,所以主流的非对称加密算法都只能加密不超过密钥长度的数据,这决定了非对称加密不能直接用于大量数据的加密。
在加密方面,现在一般会结合对称与非对称加密的优点,以混合加密来保护信道安全,具体做法是用非对称加密来安全地传递少量数据给通信的另一方,然后再以这些数据为密钥,采用对称加密来安全高效地大量加密传输数据,这种由多种加密算法组合的应用形式被称为“密码学套件”。非对称加密在这个场景中发挥的作用称为“密钥协商”。
在签名方面,现在一般会结合摘要与非对称加密的优点,以对摘要结果做加密的形式来保证签名的适用性。由于对任何长度的输入源做摘要之后都能得到固定长度的结果,所以只要对摘要的结果进行签名,即相当于对整个输入源进行了背书,保证一旦内容遭到篡改,摘要结果就会变化,签名也就马上失效了。
表 5-1 三种密码学算法的对比
| 类型 | 特点 | 常见实现 | 主要用途 | 主要局限 |
|---|---|---|---|---|
| 哈希摘要 | 不可逆,即不能解密,所以并不是加密算法,只是一些场景把它当作加密算法使用。 易变性,输入发生 1 Bit 变动,就可能导致输出结果 50%的内容发生改变。 无论输入长度多少,输出长度固定(2 的 N 次幂)。 | MD2/4/5/6、SHA0/1/256/512 | 摘要 | 无法解密 |
| 对称加密 | 加密是指加密和解密是一样的密钥。 设计难度相对较小,执行速度相对较快。 加密明文长度不受限制。 | DES、AES、RC4、IDEA | 加密 | 要解决如何把密钥安全地传递给解密者。 |
| 非对称加密 | 加密和解密使用的是不同的密钥。 明文长度不能超过公钥长度。 | RSA、ECDSA、ElGamal | 签名、传递密钥 | 性能与加密明文长度受限。 |
现在,让我们再回到开篇关于 JWT 令牌的几个问题中来。有了哈希摘要、对称和非对称加密,JWT 令牌的签名就能保证负载中的信息不可篡改、不可抵赖吗?其实还是不行的,这个场景里,数字签名的安全性仍存在一个致命的漏洞:公钥虽然是公开的,但在网络世界里“公开”具体是一种什么操作?如何保证每一个获取公钥的服务,拿到的公钥就是授权服务器所希望它拿到的?
在网络传输是不可信任的前提下,公钥在网络传输过程中可能已经被篡改,如果获取公钥的网络请求被攻击者截获并篡改,返回了攻击者自己的公钥,那以后攻击者就可以用自己的私钥来签名,让资源服务器无条件信任它的所有行为了。现实世界中公开公钥,可以通过打电话、发邮件、短信息、登报纸、同时发布在多个网站上等等,很多网络通信之外的途径来达成,但在程序与网络的世界中,就必须找到一种可信任的公开方法,而且这种方法不能依赖加密来实现,否则又将陷入蛋鸡问题之中。
数字证书
当我们无法以“签名”的手段来达成信任时,就只能求助于其他途径。不妨想想真实的世界中,我们是如何达成信任的,其实不外乎以下两种:
-
基于共同私密信息的信任
譬如某个陌生号码找你,说是你的老同学,生病了要找你借钱。你能够信任他的方式是向对方询问一些你们两个应该知道,且只有你们两个知道的私密信息,如果对方能够回答上来,他有可能真的是你的老同学,否则他十有八九就是个诈骗犯。 -
基于权威公证人的信任
如果有个陌生人找你,说他是警察,让你把存款转到他们的安全账号上。你能够信任他的方式是去一趟公安局,如果公安局担保他确实是个警察,那他有可能真的是警察,否则他十有八九就是个诈骗犯。
回到网络世界中,我们并不能假设授权服务器和资源服务器是互相认识的,所以通常不太会采用第一种方式,而第二种就是目前标准的保证公钥可信分发的标准,这个标准有一个名字:公开密钥基础设施(Public Key Infrastructure,PKI)。
额外知识:公开密钥基础设施
又称公开密钥基础架构、公钥基础建设、公钥基础设施、公开密码匙基础建设或公钥基础架构,是一组由硬件、软件、参与者、管理政策与流程组成的基础架构,其目的在于创造、管理、分配、使用、存储以及撤销数字证书。
密码学上,公开密钥基础建设借着数字证书认证中心(Certificate Authority,CA)将用户的个人身份跟公开密钥链接在一起。对每个证书中心用户的身份必须是唯一的。链接关系通过注册和发布过程创建,根据担保级别的差异,创建过程可由 CA 的各种软件或者在人为监督下完成。PKI 的确定链接关系的这一角色称为注册管理中心(Registration Authority,RA)。RA 确保公开密钥和个人身份链接,可以防抵赖。
咱们不必纠缠于 PKI 概念上的内容,只要知道里面定义的“数字证书认证中心”相当于前面例子中“权威公证人”的角色,是负责发放和管理数字证书的权威机构即可。任何人包括你我都可以签发证书,只是不权威罢了。CA 作为受信任的第三方,承担公钥体系中公钥的合法性检验的责任。可是,这里和现实世界仍然有一些区别,现实世界你去找公安局,那大楼不大可能是剧场布景冒认的;而网络世界,在假设所有网络传输都有可能被截获冒认的前提下,“去 CA 中心进行认证”本身也是一种网络操作,这与之前的“去获取公钥”本质上不是没什么差别吗?其实还是有差别的,世间公钥成千上万不可枚举,而权威的 CA 中心则应是可数的,“可数”意味着可以不通过网络,而是在浏览器与操作系统出厂时就预置好,或者提前安装好(如银行的证书),图 5-15 是笔者机器上现存的根证书。
图 5-15 Windows 系统的 CA 证书(此处为图片,展示了系统中预置的根证书列表)
到这里出现了本节的主角之一:证书(Certificate),证书是权威 CA 中心对特定公钥信息的一种公证载体,也可以理解为是权威 CA 对特定公钥未被篡改的签名背书。由于客户的机器上已经预置了这些权威 CA 中心本身的证书(称为 CA 证书或者根证书),使得我们能够在不依靠网络的前提下,使用根证书里面的公钥信息对其所签发的证书中的签名进行确认。到此,终于打破了鸡生蛋、蛋生鸡的循环,使得整套数字签名体系有了坚实的逻辑基础。
PKI 中采用的证书格式是 X.509 标准格式,它定义了证书中应该包含哪些信息,并描述了这些信息是如何编码的,里面最关键的就是认证机构的数字签名和公钥信息两项内容。一个数字证书具体包含以下内容:
-
版本号(Version):指出该证书使用了哪种版本的 X.509 标准(版本 1、版本 2 或是版本 3),版本号会影响证书中的一些特定信息,目前的版本为 3。
-
序列号(Serial Number): 由证书颁发者分配的本证书的唯一标识符。
-
签名算法标识符(Signature Algorithm ID):用于签发证书的算法标识,由对象标识符加上相关的参数组成,用于说明本证书所用的数字签名算法。譬如,SHA1 和 RSA 的对象标识符就用来说明该数字签名是利用 RSA 对 SHA1 的摘要结果进行加密。
-
认证机构的数字签名(Certificate Signature):这是使用证书发布者私钥生成的签名,以确保这个证书在发放之后没有被篡改过。
-
认证机构(Issuer Name): 证书颁发者的可识别名。
-
有效期限(Validity Period): 证书起始日期和时间以及终止日期和时间,指明证书在这两个时间内有效。
-
主题信息(Subject):证书持有人唯一的标识符(Distinguished Name),这个名字在整个互联网上应该是唯一的,通常使用的是网站的域名。
典型的证书内容示例如下:
Version: 3 (0x2)
Serial Number: 04:00:00:00:00:01:15:4b:5a:c3:94
Signature Algorithm: sha1WithRSAEncryption
Issuer: C=BE, O=GlobalSign nv-sa, CN=GlobalSign Organization Validation CA - SHA256 - G2
Validity
(后文接续有效性、主题、公钥等信息)
5.3.2 传输安全层
证书有效期 Not Before: Nov 21 08:00:00 2020 GMT Not After : Nov 22 07:59:59 2021 GMT
- 公钥信息(Public-Key):包括证书持有人的公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数。
到此为止,数字签名的安全性已经可以完全自洽了,但相信你大概也已经感受到了这条信任链的复杂与烦琐,如果从确定加密算法、生成密钥、公钥分发、CA 认证、核验公钥、签名、验证,每一个步骤都要由最终用户来完成的话,这种意义的“安全”估计只能一直是存于实验室中的阳春白雪。如何把这套烦琐的技术体系自动化地应用于无处不在的网络通信之中,便是本节的主题。
在计算机科学里,隔离复杂性的最有效手段(没有之一)就是分层,如果一层不够就再加一层,这点在网络中更是体现得淋漓尽致。OSI 模型、TCP/IP 模型将网络从物理特性(比特流)开始,逐层封装隔离,到了 HTTP 协议这种面向应用的协议里,使用者就已经不会去关心网卡/交换机如何处理数据帧、MAC 地址;不会去关心 ARP 如何做地址转换;不会去关心 IP 寻址、TCP 传输控制等细节。想要在网络世界中让用户无感知地实现安全通信,最合理的做法就是在传输层之上、应用层之下加入专门的安全层来实现,这样对上层原本基于 HTTP 的 Web 应用来说,影响甚至是无法察觉的。构建传输安全层这个想法,几乎可以说是和万维网的历史一样长,早在 1994 年,就已经有公司开始着手去实践了:
- 1994 年,网景(Netscape)公司开发了 SSL 协议(Secure Sockets Layer)的 1.0 版,这是构建传输安全层的起源,但是 SSL 1.0 从未正式对外发布过。
- 1995 年,Netscape 把 SSL 升级到 2.0 版,正式对外发布,但是刚刚发布不久就被发现有严重漏洞,所以并未大规模使用。
- 1996 年,修补好漏洞的 SSL 3.0 对外发布,这个版本得到了广泛的应用,很快成为 Web 网络安全层的事实标准。
- 1999 年,互联网标准化组织接替 Netscape,将 SSL 改名 TLS(Transport Layer Security)后形成了传输安全层的国际标准。第一个正式的版本是 RFC 2246 定义的 TLS 1.0,该版 TLS 的生命周期极长,直至笔者写下这段文字的 2020 年 3 月,主流浏览器(Chrome、Firefox、IE、Safari)才刚刚宣布同时停止 TLS 1.0/1.1 的支持。而讽刺的是,由于停止后许多政府网站无法被浏览,此时又正值新冠病毒(COVID-19)爆发期,Firefox 紧急发布公告宣布撤回该改动,TLS 1.0 的生命还在顽强延续。
- 2006 年,TLS 的第一个升级版 1.1 发布(RFC 4346),但却沦为了被遗忘的孩子,很少人使用 TLS 1.1,甚至到了 TLS 1.1 从来没有已知的协议漏洞被提出的程度。
- 2008 年,TLS 1.1 发布 2 年之后,TLS 1.2 标准发布(RFC 5246),迄今超过 90%的互联网 HTTPS 流量是由 TLS 1.2 所支持的,现在仍在使用的浏览器几乎都完美支持了该协议。
- 2018 年,最新的 TLS 1.3(RFC 8446)发布,比起前面版本相对温和的升级,TLS 1.3 做了一些激烈的改动,修改了从 1.0 起一直没有大变化的两轮四次(2-RTT)握手,首次连接仅需一轮(1-RTT)握手即可完成,在有连接复用支持时,甚至将 TLS 1.2 原本的 1-RTT 下降到了 0-RTT,显著提升了访问速度。
接下来,笔者以 TLS 1.2 为例,介绍传输安全层是如何保障所有信息都是第三方无法窃听(加密传输)、无法篡改(一旦篡改通信算法会立刻发现)、无法冒充(证书验证身份)的。TLS 1.2 在传输之前的握手过程一共需要进行上下两轮、共计四次通信,时序如图 5-16 所示。
图 5-16 TLS 连接握手时序
sequenceDiagram participant 客户端 participant 服务器 客户端->>服务器: 1. 客户端请求:Client Hello 服务器->>客户端: 2. 服务器回应:Server Hello 客户端->>服务器: 3. 客户端确认:Client Handshake Finished 服务器->>客户端: 4. 服务端确认:Server Handshake Finished
-
客户端请求:Client Hello 客户端向服务器请求进行加密通信,在这个请求里面,它会以明文的形式,向服务端提供以下信息。
- 支持的协议版本,譬如 TLS 1.2。但是要注意,1.0 至 3.0 分别代表 SSL1.0 至 3.0,TLS 1.0 则是 3.1,一直到 TLS1.3 的 3.4。
- 一个客户端生成的 32 Bytes 随机数,这个随机数将稍后用于产生加密的密钥。
- 一个可选的 SessionID,注意不要和前面 Cookie-Session 机制混淆了,这个 SessionID 是指传输安全层的 Session,是为了 TLS 的连接复用而设计的。
- 一系列支持的密码学算法套件,例如
TLS_RSA_WITH_AES_128_GCM_SHA256,代表着密钥交换算法是 RSA,加密算法是 AES128-GCM,消息认证码算法是 SHA256。 - 一系列支持的数据压缩算法。
- 其他可扩展的信息,为了保证协议的稳定,后续对协议的功能扩展大多都添加到这个变长结构中。譬如 TLS 1.0 中由于发送的数据并不包含服务器的域名地址,导致了一台服务器只能安装一张数字证书,这对虚拟主机来说就很不方便,所以 TLS 1.1 起就增加了名为“Server Name”的扩展信息,以便一台服务器给不同的站点安装不同的证书。
-
服务器回应:Server Hello 服务器接收到客户端的通信请求后,如果客户端声明支持的协议版本和加密算法组合与服务端相匹配的话,就向客户端发出回应。如果不匹配,将会返回一个握手失败的警告提示。这次回应同样以明文发送的,包括以下信息:
- 服务端确认使用的 TLS 协议版本。
- 第二个 32 Bytes 的随机数,稍后用于产生加密的密钥。
- 一个 SessionID,以后可通过连接复用减少一轮握手。
- 服务端在列表中选定的密码学算法套件。
- 服务端在列表中选定的数据压缩方法。
- 其他可扩展的信息。
- 如果协商出的加密算法组合是依赖证书认证的,服务端还要发送出自己的 X.509 证书,而证书中的公钥是什么,也必须根据协商的加密算法组合来决定。
- 密钥协商消息,这部分内容对于不同密码学套件有着不同的价值,譬如对于 ECDH + anon 这样的密钥协商算法组合(基于椭圆曲线的ECDH 算法可以在双方通信都公开的情况下协商出一组只有通信双方知道的密钥)就不需要依赖证书中的公钥,而是通过 Server Key Exchange 消息协商出密钥。
-
客户端确认:Client Handshake Finished 由于密码学套件的组合复杂多样,这里仅以 RSA 算法为密钥交换算法为例介绍后续过程。 客户端收到服务器应答后,先要验证服务器的证书合法性。如果证书不是可信机构颁布的,或者证书中信息存在问题,譬如域名与实际域名不一致、或者证书已经过期、或通过在线证书状态协议得知证书已被吊销等等,都会向访问者显示一个“证书不可信任”的警告,由用户自行选择是否还要继续通信。如果证书没有问题,客户端就会从证书中取出服务器的公钥,并向服务器发送以下信息:
- 客户端证书(可选)。部分服务端并不是面向全公众,只对特定的客户端提供服务,此时客户端需要发送它自身的证书来证明身份。如果不发送,或者验证不通过,服务端可自行决定是否要继续握手,或者返回一个握手失败的信息。客户端需要证书的 TLS 通信也称为“双向 TLS”(Mutual TLS,常简写为 mTLS),这是云原生基础设施的主要认证方法,也是基于信道认证的最主流形式。
- 第三个 32 Bytes 的随机数,这个随机数不再是明文发送,而是以服务端传过来的公钥加密的,它被称为
PreMasterSecret,将与前两次发送的随机数一起,根据特定算法计算出 48 Bytes 的MasterSecret,这个MasterSecret即为后续内容传输时的对称加密算法所采用的私钥。 - 编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
- 客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时也是前面发送的所有内容的哈希值,以供服务器校验。
-
服务端确认:Server Handshake Finished 服务端向客户端回应最后的确认通知,包括以下信息。
- 编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
- 服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时也是前面发送的所有内容的哈希值,以供客户端校验。
至此,整个 TLS 握手阶段宣告完成,一个安全的连接就已成功建立。每一个连接建立时,客户端和服务端均通过上面的握手过程协商出了许多信息,譬如一个只有双方才知道的随机产生的密钥、传输过程中要采用的对称加密算法(例子中的 AES128)、压缩算法等,此后该连接的通信将使用此密钥和加密算法进行加密、解密和压缩。这种处理方式对上层协议的功能上是完全透明的,在传输性能上会有下降,但在功能上完全不会感知到有 TLS 的存在。建立在这层安全传输层之上的 HTTP 协议,就被称为“HTTP over SSL/TLS”,也即是大家所熟知的 HTTPS。
从上面握手协商的过程中我们还可以得知,HTTPS 并非只有“启用了 HTTPS”和“未启用 HTTPS”的差别,采用不同的协议版本、不同的密码学套件、证书是否有效、服务端/客户端面对无效证书时的处理策略如何都导致了不同 HTTPS 站点的安全强度的不同,因此并不能说只要启用了 HTTPS 就必定能够安枕无忧。
5.4 验证
验证(Verification) 系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?
数据验证与程序如何编码是密切相关的,许多开发者都不会把它归入安全的范畴之中。但请细想一下,关注“你是谁”(认证)、“你能做什么”(授权)等问题是很合理的,关注“你做得对不对”(验证)不也同样合理吗?从数量来讲,数据验证不严谨而导致的安全问题比其他安全攻击导致的要多得多;而风险上讲,由数据质量导致的问题,风险有高有低,真遇到高风险的数据问题,面临的损失不一定就比被黑客拖库来得小。
相比其他富有挑战性的安全措施,如防御与攻击两者缠斗的精彩,数学、心理、社会工程和计算机等跨学科知识的结合运用,数据验证确实有些无聊、枯燥,这项常规的工作在日常的开发中贯穿于代码的各个层次,每个程序员都肯定写过。但这种常见的代码反而是迫切需要被架构约束的,缺失的校验影响数据质量,过度的校验不会使得系统更加健壮,某种意义上反而会制造垃圾代码,甚至有副作用。请来看看下面这个实际的段子:
前 端: 提交一份用户数据(姓名:某, 性别:男, 爱好:女, 签名:xxx, 手机:xxx, 邮箱:null) 控制器: 发现邮箱是空的,抛
ValidationException("邮箱没填")前 端: 已修改,重新提交 安 全: 发送验证码时发现手机号少一位,抛RemoteInvokeException("无法发送验证码")前 端: 已修改,重新提交 服务层: 邮箱怎么有重复啊,抛BusinessRuntimeException("不允许开小号")前 端: 已修改,重新提交 持久层: 签名字段超长了插不进去,抛SQLException("插入数据库失败,SQL:xxx")…… …… 前 端: 你们这些坑管挖不管埋的后端,各种异常都往前抛! 用 户: 这系统牙膏厂生产的?
最基础的数据问题可以在前端做表单校验来处理,但服务端验证肯定也是要做的,看完了上面的段子后,请想一想,服务端应该在哪一层去做校验?可能会有这样的答案:
- 在 Controller 层做,在 Service 层不做。理由是从 Service 开始会有同级重用,出现
ServiceA.foo(params)调用ServiceB.bar(params)时,就会对 params 重复校验了两次。 - 在 Service 层做,在 Controller 层不做。理由是无业务含义的格式校验已在前端表单验证处理过,有业务含义的校验,放在 Controller 层无论如何不合适。
- 在 Controller、Service 层各做各的。Controller 做格式校验,Service 层做业务校验,听起来很合理,但这其实就是上面段子中被嘲笑的行为。
还有其他一些意见,譬如还有提在持久层做校验,理由是这是最终入口,把守好写入数据库的质量最重要。
上述的讨论大概是不会有统一、正确结论,但是在 Java 里确实是有验证的标准做法,笔者提倡的做法是把校验行为从分层中剥离出来,不是在哪一层做,而是在 Bean 上做。即 Java Bean Validation。从 2009 年的 JSR 303 的 1.0,到 2013 年的 JSR 349 更新的 1.1,到目前最新的 2017 年发布的 JSR 380,定义了 Bean 验证的全套规范。单独将验证提取、封装,可以获得不少好处:
- 对于无业务含义的格式验证,可以做到预置。
- 对于有业务含义的业务验证,可以做到重用,一个 Bean 被用于多个方法用作参数或返回值是很常见的,针对 Bean 做校验比针对方法做校验更有价值。利于集中管理,譬如统一认证的异常体系,统一做国际化、统一给客户端的返回格式等等。
- 避免对输入数据的防御污染到业务代码,如果你的代码里面如果很多下面这样的条件判断,就应该考虑重构了:
// 一些已执行的逻辑 if (someParam == null) { throw new RuntimeException("客官不可以!"); } - 利于多个校验器统一执行,统一返回校验结果,避免用户踩地雷、挤牙膏式的试错体验。
据笔者所知,国内的项目使用 Bean Validation 的并不少见,但多数程序员都只使用到它的 Built-In Constraint 来做一些与业务逻辑无关的通用校验,即下面这堆注解,含义基本上看类名就能明白:
@Null、@NotNull、@AssertTrue、@AssertFalse、@Min、@Max、@DecimalMin、@DecimalMax、@Negative、@NegativeOrZero、@Positive、@PositiveOrZero、@Size、@Digits、@Past、@PastOrPresent、@Future、@FutureOrPresent、@Pattern、@NotEmpty、@NotBlank、@Email
但是与业务相关的校验往往才是最复杂的校验,将简单的校验交给 Bean Validation,而把复杂的校验留给自己,这简直是买椟还珠故事的程序员版本。其实以 Bean Validation 的标准方式来做业务校验是非常优雅的,以 Fenix’s Bookstore 的在用户资源上的两个方法为例:
/**
* 创建新的用户
*/
@POST
public Response createUser(@Valid @UniqueAccount Account user) {
return CommonResponse.op(() -> service.createAccount(user));
}
/**
* 更新用户信息
*/
@PUT
@CacheEvict(key = "#user.username")
public Response updateUser(@Valid @AuthenticatedAccount @NotConflictAccount Account user) {
return CommonResponse.op(() -> service.updateAccount(user));
}注意其中的三个自定义校验注解,它们的含义分别是:
@UniqueAccount:传入的用户对象必须是唯一的,不与数据库中任何已有用户的名称、手机、邮箱产生重复。@AuthenticatedAccount:传入的用户对象必须与当前登录的用户一致。@NotConflictAccount:传入的用户对象中的信息与其他用户是无冲突的,譬如将一个注册用户的邮箱,修改成与另外一个已存在的注册用户一致的值,这便是冲突。
这里的需求很容易理解,注册新用户时,应约束不与任何已有用户的关键信息重复;而修改自己的信息时,只能与自己的信息重复,而且只能修改当前登录用户的信息。这些约束规则不仅仅为这两个方法服务,它们可能会在用户资源中的其他入口被使用到,乃至在其他分层的代码中被使用到,在 Bean 上做校验就能一揽子地覆盖上述这些使用场景。下面代码是这三个自定义注解对应校验器的实现类:
public static class AuthenticatedAccountValidator extends AccountValidation<AuthenticatedAccount> {
> public void initialize(AuthenticatedAccount constraintAnnotation) {
> predicate = c -> {
> AuthenticAccount loginUser = (AuthenticAccount)
> SecurityContextHolder.getContext().getAuthentication().getPrincipal();
> return c.getId().equals(loginUser.getId());
> };
> }
> }
>
> public static class UniqueAccountValidator extends AccountValidation<UniqueAccount> {
> public void initialize(UniqueAccount constraintAnnotation) {
> predicate = c ->
> !repository.existsByUsernameOrEmailOrTelephone(c.getUsername(), c.getEmail(),
> c.getTelephone());
> }
> }
>
> public static class NotConflictAccountValidator extends AccountValidation<NotConflictAccount> {
> public void initialize(NotConflictAccount constraintAnnotation) {
> predicate = c -> {
> Collection<Account> collection =
> repository.findByUsernameOrEmailOrTelephone(c.getUsername(), c.getEmail(),
> c.getTelephone());
> // 将用户名、邮件、电话改成与现有完全不重复的,或者只与自己重复的,就不算冲突
> return collection.isEmpty() || (collection.size() == 1 &&
> collection.iterator().next().getId().equals(c.getId()));
> };
> }
> }这样业务校验便和业务逻辑就完全分离开来,在需要校验时用 @Valid 注解自动触发,或者通过代码手动触发执行,可根据你们项目的要求,将这些注解应用于控制器、服务层、持久层等任何层次的代码之中。此外,校验结果不满足时的提示信息,也便于统一处理,如提供默认值、提供国际化支持(这里没做)、提供统一的客户端返回格式(创建一个用于 ConstraintViolationException 的异常处理器来实现,代码中有但这里没有贴出来),以及批量执行全部校验,避免出开篇那个段子中挤牙膏的尴尬。
对于 Bean 与 Bean 校验器,笔者另外有两条编码建议。第一条是对校验项预置好默认的提示信息,这样当校验不通过时用户能获得明确的修正提示,以下是代码示例:
/**
* 表示一个用户的信息是无冲突的
*
* “无冲突”是指该用户的敏感信息与其他用户不重合,譬如将一个注册用户的邮箱,修改成与
* 另外一个已存在的注册用户一致的值,这便是冲突
**/
@Documented
@Retention(RUNTIME)
@Target({FIELD, METHOD, PARAMETER, TYPE})
@Constraint(validatedBy = AccountValidation.NotConflictAccountValidator.class)
public @interface NotConflictAccount {
String message() default "用户名称、邮箱、手机号码与现存用户产生重复";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}另外一条建议是将不带业务含义的格式校验注解放到 Bean 的类定义之上,将带业务逻辑的校验放到 Bean 的类定义的外面。这两者的区别是放在类定义中的注解能够自动运行,而放到类外面这需要想前面代码那样,明确标出注解时才会运行。譬如用户账号实体中的部分代码为:
public class Account extends BaseEntity {
@NotEmpty(message = "用户不允许为空")
private String username;
@NotEmpty(message = "用户姓名不允许为空")
private String name;
private String avatar;
@Pattern(regexp = "1\\d{10}", message = "手机号格式不正确")
private String telephone;
@Email(message = "邮箱格式不正确")
private String email;
}这些校验注解都直接放在类定义中,每次执行校验的时候它们都会被运行。由于 Bean Validation 是 Java 的标准规范,它执行的频率可能比编写代码的程序所预想的要更高,譬如使用 Hibernate 来做持久化时,便会自动执行 Data Object 上的校验注解。对于那些不带业务含义的注解,运行是不需要其他外部资源参与的,不会调用远程服务、访问数据库,这种校验重复执行并没有什么成本。
但带业务逻辑的校验,通常就需要外部资源参与执行,这不仅仅是多消耗一点时间和运算资源的问题,由于很难保证依赖的每个服务都是幂等的,重复执行校验很可能会带来额外的副作用。因此应该放到外面让使用者自行判断是否要触发。
还有一些“需要触发一部分校验”的非典型情况,譬如“新增”操作 A 需要执行全部校验规则,“修改”操作 B 中希望不校验某个字段,“删除”操作 C 中希望改变某一条校验规则,这时候要就要启用分组校验来处理,设计一套“新增”、“修改”、“删除”这样的标识类,置入到校验注解的 groups 参数中去实现。