服务架构演进史
1 | # 架构并不是被发明出来的,而是持续演进的结果 |
原始分布式时代
1 | # UNIX 的分布式设计哲学 |
单体系统时代
单体架构(Monolithic)
“单体”只是表明系统中主要的过程调用都是进程内调用,不会发生进程间通信,仅此而已。
微服务后才知“单体”一词
单体架构 称作“巨石系统”(Monolithic Application)。 “单体架构”在整个软件架构演进的历史进程里,是出现时间最早、应用范围最广、使用人数最多、统治历史最长的一种架构风格,但“单体”这个名称,却是在微服务开始流行之后才“事后追认”所形成的概念。
小型系统,单体好
对于小型系统——即由单台机器就足以支撑其良好运行的系统,单体不仅易于开发、易于测试、易于部署,且由于系统中各个功能、模块、方法的调用过程都是进程内调用,不会发生进程间通信(Inter-Process Communication,IPC)。
广义上讲,可以认为 RPC 属于 IPC 的一种特例,但请注意这里两个“PC”不是同个单词的缩写。因此也是运行效率最高的一种架构风格,完全不应该被贴上“反派角色”的标签,反倒是那些爱赶技术潮流却不顾需求现状的微服务吹捧者更像是个反派。
单体的好与坏
1 | # 纵向 |
单体案例
1 | # 大超市、小卖铺 |
SOA 时代
1. 烟囱式架构(Information Silo Architecture)
》》》无交互
信息烟囱又名信息孤岛(Information Island)
2. 微内核架构(Microkernel Architecture)
》》》插件互不知,通信由内核
微内核架构也被称为插件式架构(Plug-in Architecture)
适合桌面应用程序
它假设系统中各个插件模块之间是互不认识,不可预知系统将安装哪些模块,因此这些插件可以访问内核中一些公共的资源,但不会直接交互。
3. 事件驱动架构(Event-Driven Architecture)
》》》管道接收,放入管道
这时,SOA来了
4. 面向服务的架构(Service Oriented Architecture,SOA)
服务之间的松散耦合、注册、发现、治理,隔离、编排,等等
1 | # 更具体 |
微服务时代
1 | # 微服务 |
后微服务时代
Docker、Kubernetes、云原生(Cloud Native)
为什么软件,硬件不可?
1 | 如果不局限于采用软件的方式,这些问题几乎都有对应的硬件解决方案。 |
K8S 的胜利
1 | 通过虚拟化基础设施去解决分布式架构问题的开端,应该要从 2017 年 Kubernetes 赢得容器战争的胜利开始算起。 |
Kubernetes 与 Spring Cloud
1 | Kubernetes(硬) Spring Cloud(软) |
K8S 的不完美
1 | + 单个服务难管控 |
虚拟化基础设施的第二次进化
1 | “服务网格”(Service Mesh)的“边车代理模式”(Sidecar Proxy) |
无服务时代
无服务 => 无服务器(云)
1 | # 云计算平台 |
未来不会只存在某一种“最先进的”架构风格,多种具针对性的架构风格同时并存,是软件产业更有生命力的形态。
看似不远,实则路还很长。图灵
We can only see a short distance ahead, but we can see plenty there that needs to be done.
尽管目光所及之处,只是不远的前方,即使如此,依然可以看到那里有许多值得去完成的工作在等待我们。
—— Alan Turing,Computing Machinery and Intelligence,1950
访问远程服务
1 | # 使用过,但是没有正确理解 |
远程服务调用
远程服务调用(Remote Procedure Call,RPC)
远程服务调用(Remote Procedure Call,RPC)在计算机科学中已经存在了超过四十年时间,但在今天仍然可以在各种论坛、技术网站上时常遇见“什么是 RPC?”、“如何评价某某 RPC 技术?”、“RPC 更好还是 REST 更好?”之类的问题,仍然“每天”都有新的不同形状的 RPC 轮子被发明制造出来,仍然有层出不穷的文章去比对 Google gRPC、Facebook Thrift 等各家的 RPC 组件库的优劣。
像计算机科学这种知识快速更迭的领域,一项四十岁高龄的技术能有如此关注度,可算是相当稀罕的现象,这一方面是由于微服务风潮带来的热度,另外一方面,也不得不承认,确实有不少开发者对 RPC 本身解决什么问题、如何解决这些问题、为什么要这样解决都或多或少存在认知模糊。
进程间通信
进程间通信(Inter-Process Communication,IPC)
【日期标记】2022-07-29 14:47:55 以上同步完成
1 | # RPC 最初目的 |
通信的成本
注意:基于套接字接口的通信方式(IPC Socket),它不仅适用于本地相同机器的不同进程间通信,由于 Socket 是网络栈的统一接口,它也理所当然地能支持基于网络的跨机器的进程间通信。
此外,这样做有一个看起来无比诱人的好处,由于 Socket 是各个操作系统都有提供的标准接口,完全有可能把远程方法调用的通信细节隐藏在操作系统底层,从应用层面上看来可以做到远程调用与本地的进程间通信在编码上完全一致。
事实上,在原始分布式时代的早期确实是奔着这个目标去做的,但这种透明的调用形式却反而造成了程序员误以为通信是无成本的假象,因而被滥用以致于显著降低了分布式系统的性能。
1 | # IPC ≠ RPC |
三个基本问题
1 | # 如何表示数据 => 入参、返回值(序列化、反序列化) |
统一的 RPC
1 | DCE/RPC、ONE RPC => C语言设计,不是面向对象 |
分裂的 RPC
无完美 RPC
现在,已经相继出现过 RMI(Sun/Oracle)、Thrift(Facebook/Apache)、Dubbo(阿里巴巴/Apache)、gRPC(Google)、Motan1/2(新浪)、Finagle(Twitter)、brpc(百度/Apache)、.NET Remoting(微软)、Arvo(Hadoop)、JSON-RPC 2.0(公开规范,JSON-RPC 工作组)……等等难以穷举的协议和框架。
这些 RPC 功能、特点不尽相同,有的是某种语言私有,有的能支持跨越多门语言,有的运行在应用层 HTTP 协议之上,有的能直接运行于传输层 TCP/UDP 协议之上,但肯定不存在哪一款是“最完美的 RPC”。今时今日,任何一款具有生命力的 RPC 框架,都不再去追求大而全的“完美”,而是有自己的针对性特点作为主要的发展方向,举例分析如下。
1 | # => 面向对象 |
RPC 框架有明显的朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再追求独立地解决 RPC 的全部三个问题(表示数据、传递数据、表示方法),而是将一部分功能设计成扩展点,让用户自己去选择。框架聚焦于提供核心的、更高层次的能力,例如提供负载均衡、服务注册、可观察性等方面的支持。
这一类框架的代表有 Facebook 的 Thrift 与阿里的 Dubbo。
- 尤其是断更多年后重启的 Dubbo 表现得更为明显,它默认有自己的传输协议(Dubbo 协议),同时也支持其他协议;
- 默认采用 Hessian 2 作为序列化器,如果你有 JSON 的需求,可以替换为 Fastjson,如果你对性能有更高的追求,可以替换为Kryo、FST、Protocol Buffers 等效率更好的序列化器,
- 如果你不想依赖其他组件库,直接使用 JDK 自带的序列化器也是可以的。
这种设计在一定程度上缓和了 RPC 框架必须取舍,难以完美的缺憾。
REST 设计风格
1 | # REST 只能说是风格而不是规范、协议 |
理解 REST
REST 源于 Roy Thomas Fielding 在 2000 年发表的博士论文:《Architectural Styles and the Design of Network-based Software Architectures》
REST,即“表征状态转移”的缩写。
“REST”(Representational State Transfer)实际上是“HTT”(Hypertext Transfer)的进一步抽象,两者就如同接口与实现类的关系一般。
HTTP 中使用的“超文本”(Hypertext)一词是美国社会学家 Theodor Holm Nelson 在 1967 年于《Brief Words on the Hypertext》一文里提出的,下面引用的是他本人在 1992 年修正后的定义:
Hypertext
By now the word “hypertext” has become generally accepted for branching and responding text, but the corresponding word “hypermedia”, meaning complexes of branching and responding graphics, movies and sound – as well as text – is much less used.
现在,”超文本 “一词已被普遍接受,它指的是能够进行分支判断和差异响应的文本,相应地, “超媒体 “一词指的是能够进行分支判断和差异响应的图形、电影和声音(也包括文本)的复合体。
—— Theodor Holm Nelson Literary Machines, 1992
资源(Resource)
例如你现在正在阅读一篇名为《REST 设计风格》的文章,这篇文章的内容本身(你可以将其理解为其蕴含的信息、数据)我们称之为“资源”。无论你是购买的书籍、是在浏览器看的网页、是打印出来看的文稿、是在电脑屏幕上阅读抑或是手机上浏览,尽管呈现的样子各不相同,但其中的信息是不变的,你所阅读的仍是同一份“资源”
。表征(Representation)
当你通过电脑浏览器阅读此文章时,浏览器向服务端发出请求“我需要这个资源的 HTML 格式”,服务端向浏览器返回的这个 HTML 就被称之为“表征”,你可能通过其他方式拿到本文的PDF、Markdown、RSS
等其他形式的版本,它们也同样是一个资源的多种表征
。可见“表征”这个概念是指信息与用户交互时的表示形式,这与我们软件分层架构中常说的“表示层”(Presentation Layer)的语义其实是一致的。状态(State)
当你读完了这篇文章,想看后面是什么内容时,你向服务器发出请求“给我下一篇文章”。但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”才能正确回应,这类在特定语境中才能产生的上下文信息即被称为“状态”。我们所说的有状态(Stateful)抑或是无状态(Stateless),都是只相对于服务端来说的
,服务器要完成“取下一篇”的请求,要么自己记住用户的状态:这个用户现在阅读的是哪一篇文章,这称为有状态;要么客户端来记住状态,在请求的时候明确告诉服务器:我正在阅读某某文章,现在要读它的下一篇,这称为无状态。转移(Transfer)
无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供
,因为只有服务端拥有该资源及其表征形式。服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移”
。统一接口(Uniform Interface)
上面说的服务器“通过某种方式”让表征状态发生转移,具体是什么方式?如果你真的是用浏览器阅读本文电子版的话,请把本文滚动到结尾处,右下角有下一篇文章的 URI 超链接地址,这是服务端渲染这篇文章时就预置好的,点击它让页面跳转到下一篇,就是所谓“某种方式”的其中一种方式。任何人都不会对点击超链接网页会出现跳转感到奇怪,但你细想一下,URI 的含义是统一资源标识符,是一个名词,如何能表达出“转移”动作的含义呢?答案是 HTTP 协议中已经提前约定好了一套“统一接口”,它包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS 七种基本操作
,任何一个支持 HTTP 协议的服务器都会遵守这套规定,对特定的 URI 采取这些操作,服务器就会触发相应的表征状态转移。超文本驱动(Hypertext Driven)
尽管表征状态转移是由浏览器主动向服务器发出请求所引发的,该请求导致了“在浏览器屏幕上显示出了下一篇文章的内容”这个结果的出现。但是,你我都清楚这不可能真的是浏览器的主动意图,浏览器是根据用户输入的 URI 地址来找到网站首页,服务器给予的首页超文本内容后,浏览器再通过超文本内部的链接来导航到了这篇文章,阅读结束时,也是通过超文本内部的链接来再导航到下一篇
。浏览器作为所有网站的通用的客户端,任何网站的导航(状态转移)行为都不可能是预置于浏览器代码之中,而是由服务器发出的请求响应信息(超文本)来驱动的。这点与其他带有客户端的软件有十分本质的区别,在那些软件中,业务逻辑往往是预置于程序代码之中的,有专门的页面控制器(无论在服务端还是在客户端中)来驱动页面的状态转移。自描述消息(Self-Descriptive Messages)
由于资源的表征可能存在多种不同形态,在消息中应当有明确的信息来告知客户端该消息的类型
以及应如何处理这条消息
。一种被广泛采用的自描述方法是在名为“Content-Type”
的 HTTP Header 中标识出互联网媒体类型(MIME type),例如“Content-Type : application/json; charset=utf-8”,则说明该资源会以 JSON 的格式来返回,请使用 UTF-8 字符集进行处理。
RESTful 的系统
Fielding 认为,一套理想的、完全满足 REST 风格的系统应该满足以下六大原则。
服务端与客户端分离(Client-Server) => 前后端分离
将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性,这一点正越来越受到广大开发者所认可,以前完全基于服务端控制和渲染(如 JSF 这类)框架实际用户已甚少,而在服务端进行界面控制(Controller),通过服务端或者客户端的模版渲染引擎来进行界面渲染的框架(如 Struts、SpringMVC 这类)也受到了颇大的冲击。这一点主要推动力量与 REST 可能关系并不大,前端技术(从 ES 规范,到语言实现,到前端框架等)的近年来的高速发展,使得前端表达能力大幅度加强才是真正的幕后推手。由于前端的日渐强势,现在还流行起由前端代码反过来驱动服务端进行渲染的 SSR(Server-Side Rendering)技术,在 Serverless、SEO 等场景中已经占领了一块领地。无状态(Stateless)
无状态是 REST 的一条核心原则,部分开发者在做服务接口规划时,觉得 REST 风格的服务怎么设计都感觉别扭,很有可能的一种原因是在服务端持有着比较重的状态。REST 希望服务器不要去负责维护状态,每一次从客户端发送的请求中,应包括所有的必要的上下文信息,会话信息也由客户端负责保存维护,服务端依据客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁。客户端承担状态维护职责以后,会产生一些新的问题,例如身份认证、授权等可信问题,它们都应有针对性的解决方案(这部分内容可参见“安全架构”的内容)。
、、、但必须承认的现状是,目前大多数的系统都达不到这个要求,往往越复杂、越大型的系统越是如此。服务端无状态可以在分布式计算中获得非常高价值的好处,但大型系统的上下文状态数量完全可能膨胀到让客户端在每次请求时提供变得不切实际的程度,在服务端的内存、会话、数据库或者缓存等地方持有一定的状态成为一种是事实上存在,并将长期存在、被广泛使用的主流的方案(Kafka => ZK)
。可缓存(Cacheability)
无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。“降低网络性”的通俗解释是某个功能如果使用有状态的设计只需要一次(或少量)请求就能完成,使用无状态的设计则可能会需要多次请求,或者在请求中带有额外冗余的信息。为了缓解这个矛盾,REST 希望软件系统能够如同万维网一样,允许客户端和中间的通讯传递者(例如代理)将部分服务端的应答缓存起来。当然,为了缓存能够正确地运作,服务端的应答中必须明确地或者间接地表明本身是否可以进行缓存、可以缓存多长时间
,以避免客户端在将来进行请求的时候得到过时的数据。运作良好的缓存机制可以减少客户端、服务器之间的交互,甚至有些场景中可以完全避免交互,这就进一步提高了性能。分层系统(Layered System) => CDN
这里所指的并不是表示层、服务层、持久层这种意义上的分层。而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。该原则的典型的应用是内容分发网络(Content Distribution Network,CDN)
。如果你是通过网站浏览到这篇文章的话,你所发出的请求一般(假设你在中国国境内的话)并不是直接访问位于 GitHub Pages 的源服务器,而是访问了位于国内的 CDN 服务器,但作为用户,你完全不需要感知到这一点。我们将在“透明多级分流系统”中讨论如何构建自动的、可缓存的分层系统。统一接口(Uniform Interface) => GET、POST、…
、、、这是 REST 的另一条核心原则,REST 希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。这条原则你可以类比计算机中对文件管理的操作来理解,管理文件可能会进行创建、修改、删除、移动等操作,这些操作数量是可数的,而且对所有文件都是固定的、统一的。如果面向资源来设计系统,同样会具有类似的操作特征,由于 REST 并没有设计新的协议,所以这些操作都借用了 HTTP 协议中固有的操作命令来完成。
、、、统一接口也是 REST 最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更合适,这事情哪怕是很长时间里都不会有个定论,也许永远都没有。但是,已经有一个基本清晰的结论是:面向资源编程的抽象程度通常更高。抽象程度高意味着坏处是往往距离人类的思维方式更远,而好处是往往通用程度会更好。用这样的语言去诠释 REST,大概本身就挺抽象的,还是举个例子来说明:例如,几乎每个系统都有的登录和注销功能,如果你理解成登录对应于 login()服务,注销对应于 logout()服务这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是 PUT Session,注销是 DELETE Session,这样你只需要设计一种“Session 资源”即可满足需求,甚至以后对 Session 的其他需求,如查询登陆用户的信息,就是 GET Session 而已,其他操作如修改用户信息等都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处。
、、、想要在架构设计中合理恰当地利用统一接口,Fielding 建议系统应能做到每次请求中都包含资源的 ID,所有操作均通过资源 ID 来进行;建议每个资源都应该是自描述的消息;建议通过超文本来驱动应用状态的转移。按需代码(Code-On-Demand) => Server -> 代码 -> Client(执行并销毁)
按需代码被 Fielding 列为一条可选原则。它是指任何按照客户端(例如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术,按需代码赋予了客户端无需事先知道所有来自服务端的信息应该如何处理、如何运行的宽容度。举个具体例子,以前的Java Applet技术,今天的WebAssembly等都属于典型的按需代码,蕴含着具体执行逻辑的代码是存放在服务端,只有当客户端请求了某个 Java Applet 之后,代码才会被传输并在客户端机器中运行,结束后通常也会随即在客户端中被销毁掉。将按需代码列为可选原则的原因并非是它特别难以达到,而更多是出于必要性和性价比的实际考虑。
RMM 成熟度
不足与争议
面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑
面向资源的编程思想与另外两种主流编程思想只是抽象问题时所处的立场不同
,只有选择问题,没有高下之分:1
2
3
4
5
6
7
8# 面向过程编程
为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。
# 面向对象编程
为什么要将数据和行为统一起来、封装成对象?当然是为了符合现实世界的主流的交互方式。
# 面向资源编程
为什么要将数据(资源)作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络世界的主流的交互方式。REST 与 HTTP 完全绑定,不适合应用于要求高性能传输的场景中
个人很大程度上赞同此观点,但并不认为这是 REST 的缺陷,锤子不能当扳手用并不是锤子的质量有问题。面向资源编程与协议无关,但是 REST(特指 Fielding 论文中所定义的 REST,而不是泛指面向资源的思想)的确依赖着 HTTP 协议的标准方法、状态码、协议头等各个方面。HTTP 并不是传输层协议,它是应用层协议,如果仅将 HTTP 当作传输是不恰当的(SOAP:再次感觉有被冒犯到)。对于需要直接控制传输,如二进制细节、编码形式、报文格式、连接方式等细节的场景中,REST 确实不合适,这些场景往往存在于服务集群的内部节点之间,这也是之前曾提及的,REST 和 RPC 尽管应用场景的确有所重合,但重合的范围有多大就是见仁见智的事情。REST 不利于事务支持 => 看事务设计
这个问题首先要看你怎么看待“事务(Transaction)”这个概念。如果“事务”指的是数据库那种的狭义的刚性 ACID 事务,那除非完全不持有状态,否则分布式系统本身与此就是有矛盾的(CAP 不可兼得),这是分布式的问题而不是 REST 的问题。如果“事务”是指通过服务协议或架构,在分布式服务中,获得对多个数据同时提交的统一协调能力(2PC/3PC),例如WS-AtomicTransaction、WS-Coordination这样的功能性协议,这 REST 确实不支持,假如你已经理解了这样做的代价,仍决定要这样做的话,Web Service 是比较好的选择。如果“事务”只是指希望保障数据的最终一致性,说明你已经放弃刚性事务了,这才是分布式系统中的正常交互方式,使用 REST 肯定不会有什么阻碍,谈不上“不利于”。当然,对此 REST 也并没有什么帮助,这完全取决于你系统的事务设计,我们会在事务处理中再详细讨论。REST 没有传输可靠性支持 => 幂等性
是的,并没有。在 HTTP 中你发送出去一个请求,通常会收到一个与之相对的响应,例如 HTTP/1.1 200 OK 或者 HTTP/1.1 404 Not Found 诸如此类的。但如果你没有收到任何响应,那就无法确定消息到底是没有发送出去,抑或是没有从服务端返回回来,这其中的关键差别是服务端到底是否被触发了某些处理?应对传输可靠性最简单粗暴的做法是把消息再重发一遍。这种简单处理能够成立的前提是服务应具有幂等性(Idempotency),即服务被重复执行多次的效果与执行一次是相等的。HTTP 协议要求 GET、PUT 和 DELETE 应具有幂等性
,我们把 REST 服务映射到这些方法时,也应当保证幂等性。对于 POST 方法,曾经有过一些专门的提案(如POE,POST Once Exactly),但并未得到 IETF 的通过。对于 POST 的重复提交,浏览器会出现相应警告,如 Chrome 中“确认重新提交表单”的提示,对于服务端,就应该做预校验,如果发现可能重复,返回 HTTP/1.1 425 Too Early。另,Web Service 中有WS-ReliableMessaging功能协议用于支持消息可靠投递。类似的,由于 REST 没有采用额外的 Wire Protocol,所以除了事务、可靠传输这些功能以外,一定还可以在 WS-*协议中找到很多 REST 不支持的特性。REST 缺乏对资源进行“部分”和“批量”的处理能力 => 面向资源的不完美
这个观点是认同的,这很可能是未来面向资源的思想和 API 设计风格的发展方向。REST 开创了面向资源的服务风格,却肯定仍并不完美。以 HTTP 协议为基础给 REST 带来了极大的便捷(不需要额外协议,不需要重复解决一堆基础网络问题,等等),但也是 HTTP 本身成了束缚 REST 的无形牢笼。这里仍通过具体例子来解释 REST 这方面的局限性:例如你仅仅想获得某个用户的姓名,RPC 风格中可以设计一个“getUsernameById”的服务,返回一个字符串,尽管这种服务的通用性实在称不上“设计”二字,但确实可以工作;而 REST 风格中你将向服务端请求整个用户对象,然后丢弃掉返回的结果中该用户除用户名外的其他属性,这便是一种“过度获取”(Overfetching)
。REST 的应对手段是通过位于中间节点或客户端的缓存来缓解这种问题,但此缺陷的本质是由于 HTTP 协议完全没有对请求资源的结构化描述能力(但有非结构化的部分内容获取能力,即今天多用于断点续传的Range Header),所以返回资源的哪些内容、以什么数据类型返回等等,都不可能得到协议层面的支持,要做你就只能自己在 GET 方法的 Endpoint 上设计各种参数来实现。而另外一方面,与此相对的缺陷是对资源的批量操作的支持,有时候我们不得不为此而专门设计一些抽象的资源才能应对。例如你准备把某个用户的名字增加一个“VIP”前缀,提交一个 PUT 请求修改这个用户的名称即可,而你要给 1000 个用户加 VIP 时,如果真的去调用 1000 次 PUT,浏览器会回应你 HTTP/1.1 429 Too Many Requests,老板则会揍你一顿。此时,你就不得不先创建一个(如名为“VIP-Modify-Task”)任务资源,把 1000 个用户的 ID 交给这个任务,最后驱动任务进入执行状态。又例如你去网店买东西,下单、冻结库存、支付、加积分、扣减库存这一系列步骤会涉及到多个资源的变化,你可能面临不得不创建一种“事务”的抽象资源,或者用某种具体的资源(例如“结算单”)贯穿这个过程的始终,每次操作其他资源时都带着事务或者结算单的 ID(多个资源 => 事务控制)
。HTTP 协议由于本身的无状态性,会相对不适应(并非不能够)处理这类业务场景。
、、、目前,一种理论上较优秀的可以解决以上这几类问题的方案是GraphQL,这是由 Facebook 提出并开源的一种面向资源 API 的数据查询语言,如同 SQL 一样,挂了个“查询语言”的名字,但其实 CRUD 都有涉猎。比起依赖 HTTP 无协议的 REST,GraphQL 可以说是另一种“有协议”的、更彻底地面向资源的服务方式。然而凡事都有两面,离开了 HTTP,它又面临着几乎所有 RPC 框架所遇到的那个如何推广交互接口的问题。
事务处理
1 | # 事务存在的意义 |
本地事务
本地事务是最基础的一种事务解决方案,只适用于单服务单数据源
场景。
1 | # 本地事务 |
ARIES理论(Algorithms for Recovery and Isolation Exploiting Semantics,ARIES)“基于语义的恢复与隔离算法”。
ARIES 是现代数据库的基础理论,就算不能称所有的数据库都实现了 ARIES,至少也可以称现代的主流关系型数据库(Oracle、MS SQLServer、MySQL/InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响。
在 20 世纪 90 年代,IBM Almaden 研究院总结了研发原型数据库系统“IBM System R”的经验,发表了 ARIES 理论中最主要的三篇论文,
- 《使用预写日志记录支持细粒度锁定和部分回滚的事务恢复方法》着重解决了:
原子性(A)和持久性(D)在算法层面上应当如何实现
。 - 《基于b树索引的多操作事务并发控制的键值锁定方法》
现代数据库隔离性(I)奠基式的文章
。
原子性和持久性
1 | # 原子性 |
网购一本书需要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态。
由于写入存在中间状态,所以可能发生以下情形。
未提交事务,写入后崩溃
程序还没修改完三个数据,但数据库已经将其中一个或两个数据的变动写入磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性
。已提交事务,写入前崩溃
程序已经修改完三个数据,但数据库还未将全部三个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性
。
1 | # 想保证原子、持久 => 要解决 doing、crash |
“Commit Logging”提交日志 <=== 崩溃恢复方案
为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加
的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”(提交日志)。
1 | Commit Record 事务记录已提交(未落盘) |
额外补充: Shadow Paging
、、、通过日志实现事务的原子性和持久性是当今的主流方案,但并不是唯一的选择。除日志外,还有另外一种称为“Shadow Paging”(有中文资料翻译为“影子分页”)的事务实现机制,常用的轻量级数据库 SQLite Version 3 采用的事务机制就是 Shadow Paging。
、、、Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据
。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。Shadow Paging 实现事务要比 Commit Logging 更加简单,但涉及隔离性与并发锁时,Shadow Paging 实现的事务并发能力就相对有限
,因此在高性能的数据库中应用不多。
“Commit Logging” 是怎么保证原子、持久的 ???
、、、如果日志没有成功写入 Commit Record 就发生崩溃,那整个事务就是失败的,系统重启后会看到一部分没有 Commit Record 的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没好有发生过一样,这保证了原子性
。
、、、如果日志一旦成功写入 Commit Record,那整个事务就是成功的,即使真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性
;
Commit Logging 缺陷
、、、所有对数据的真实修改都必须发生在事务提交以后,即日志写入了 Commit Record 之后(必须写入 Commit Record 才能落盘,落盘完成再写入 End Record
)。在此之前,即使磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是 Commit Logging 成立的前提,却对提升数据库的性能十分不利。
、、、为了解决这个问题,前面提到的 ARIES 理论终于可以登场。ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前可落盘的意思。
FORCE、STEAL => 四种组合(如下图)
1 | *STEAL 写日志,落盘 => 同时进行【Undo Log 可回滚】 |
1 | # STEAL:事务提交前,可落盘; |
实现隔离性
解决事务并发
1 | # 事务的隔离性 |
可串行化(Serializable) => 读、写持续,有范围
串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。
可串行化完全符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化
。
(“即可”是简化理解,实际还是很复杂的,要分成 Expanding 和 Shrinking 两阶段去处理读锁、写锁与数据间的关系,称为Two-Phase Lock,2PL)
但数据库不考虑性能肯定是不行的,并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。
现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。
可重复读(Repeatable Read) => 读、写持续,无范围
可串行化的下一个隔离级别是可重复读(Repeatable Read),可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁
。
可重复读比可串行化弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。
例如现在准备统计一下 Fenix’s Bookstore 中售价小于 100 元的书有多少本,会执行以下第一条 SQL 语句:
1 | SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:1,事务: T1 */ |
(两次查询不一致 => 可重复读没有范围锁)
根据前面对范围锁、读锁和写锁的定义可知,假如这条 SQL 语句在同一个事务中重复执行了两次,且这两次执行之间恰好有另外一个事务在数据库插入了一本小于 100 元的书籍,这是会被允许的,那这两次相同的查询就会得到不一样的结果,原因是可重复读没有范围锁
来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。
(只读事务 与 读写事务的区别)
提醒注意一点,这里的介绍是以 ARIES 理论为讨论目标的,具体的数据库并不一定要完全遵照着理论去实现。一个例子是 MySQL/InnoDB 的默认隔离级别为可重复读,但它在只读事务中可以完全避免幻读问题,例如上面例子中事务 T1 只有查询语句,是一个只读事务,所以例子中的问题在 MySQL 中并不会出现。但在读写事务中,MySQL 仍然会出现幻读问题,例如例子中事务 T1 如果在其他事务插入新书后,不是重新查询一次数量,而是要将所有小于 100 元的书改名,那就依然会受到新插入书籍的影响。
读已提交(Read Committed) => 写持续,读短暂,无范围
可重复读的下一个隔离级别是读已提交(Read Committed),读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放
。
读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads)
,它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。
例如想要获取 Fenix’s Bookstore 中《深入理解 Java 虚拟机》这本书的售价,同样执行了两条 SQL 语句,在此两条语句执行之间,恰好另外一个事务修改了这本书的价格,将书的价格从 90 元调整到了 110 元,如下 SQL 所示:
1 | SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */ |
如果隔离级别是读已提交,这两次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务 T2 中的更新语句可以马上提交成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。
假如隔离级别是可重复读的话,由于数据已被事务 T1 施加了读锁且读取后不会马上释放,所以事务 T2 无法获取到写锁,更新就会被阻塞,直至事务 T1 被提交或回滚后才能提交。
读未提交(Read Uncommitted) => 写持续,无读,无范围
读已提交的下一个级别是读未提交(Read Uncommitted),读未提交对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁
。
读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads)
,它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。
例如个人觉得《深入理解 Java 虚拟机》从 90 元涨价到 110 元是损害消费者利益的行为,又执行了一条更新语句把价格改回了 90 元,在提交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的,按 90 元卖要亏本,于是随即回滚了事务,场景如下 SQL 所示:
1 | SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */ |
不过,在之前修改价格后,事务 T1 已经按 90 元的价格卖出了几本。
原因是读未提交在数据上完全不加读锁
,这反而令它能读到其他事务加了写锁的数据,即上述事务 T1 中两条查询语句得到的结果并不相同。
如果你不能理解这句话中的“反而”二字,请再重读一次写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据
。
如果事务 T1 读取数据并不需要去加读锁的话,就会导致事务 T2 未提交的数据也马上就能被事务 T1 所读到。这同样是一个事务受到其他事务影响,隔离性被破坏的表现。
假如隔离级别是读已提交的话,由于事务 T2 持有数据的写锁,所以事务 T1 的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此 T1 中的第二次查询就会被阻塞,直至事务 T2 被提交或者回滚后才能得到结果。
完全不隔离
理论上还存在更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务的没提交之前的修改可以被另外一个事务的修改覆盖掉,脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性都无法实现,所以一般谈论隔离级别时不会将它纳入讨论范围内,而将读未提交视为是最低级的隔离级别。
表面现象、根本原因 => 不要迷恋于表象
以上四种隔离级别属于数据库理论的基础知识,多数大学的计算机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的某种固有属性或设定来讲解,这导致很多同学只能对这些现象死记硬背。
其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象
。
各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因
。
多版本并发控制(Multi-Version Concurrency Control,MVCC)
除了都以锁来实现外,以上四种隔离级别还有另一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。
针对这种“一个事务读+另一个事务写”的隔离问题
,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。
MVCC 是一种读取优化策略,它的“无锁”是特指读取时不需要加锁
。
MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的
。
在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID,事务 ID 是一个全局严格递增的数值,然后根据以下规则写入数据。
1 | # 插入数据时 |
MVCC 下的四种隔离级别
1 | 此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。 |
悲观锁、乐观锁
MVCC 是只针对“读+写”场景的优化。
如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案。
稍微有点讨论余地的是加锁的策略是“乐观加锁”(Optimistic Locking)还是“悲观加锁”(Pessimistic Locking)。
1 | # 悲观锁 => 先加锁,再访问 |
【日期标记】2022-08-01 19:42:27 以上同步完成
全局事务
全局事务(Global Transaction),有一些资料中也将其称为外部事务(External Transaction)。
适用于单服务多数据源
场景的事务解决方案。
请注意,理论上真正的全局事务并没有“单个服务”的约束,为了避免与后续介绍的放弃了 ACID 的弱一致性事务处理方式相互混淆,所以这里的全局事务所指范围有所缩减,后续涉及多服务多数据源的事务,将称其为“分布式事务”。
X/Open XA(eXtended **Architecture)
1991 年,为了解决分布式事务的一致性问题,X/Open组织(后来并入了The Open Group)提出了一套名为X/Open XA(XA 是 eXtended Architecture 的缩写)
的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口
。
XA 接口是双向的,能在一个事务管理器和多个资源管理器(Resource Manager)之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚,现在我们在 Java 代码中还偶尔能看见的 XADataSource、XAResource 这些名字都源于此。
JTA(Java Transaction API)
不过,XA 并不是 Java 的技术规范(XA 提出那时还没有 Java),而是一套语言无关的通用规范
,所以 Java 中专门定义了JSR 907 Java Transaction API,基于 XA 模式在 Java 语言中的实现了全局事务处理的标准,这也就是我们现在所熟知的 JTA。JTA 最主要的两个接口是:
- 事务管理器的接口:javax.transaction.TransactionManager。这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的,还提供了另外一套javax.transaction.UserTransaction接口,用于通过程序代码手动开启、提交和回滚事务。
- 满足 XA 规范的资源定义接口:javax.transaction.xa.XAResource,任何资源(JDBC、JMS 等等)如果想要支持 JTA,只要实现 XAResource 接口中的方法即可。
JTA 原本是 Java EE 中的技术,一般情况下应该由 JBoss、WebSphere、WebLogic 这些 Java EE 容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫 Arjuna)都以 JAR 包的形式实现了 JTA 的接口,称为 JOTM(Java Open Transaction Manager),使得我们能够在 Tomcat、Jetty 这样的 Java SE 环境下也能使用 JTA。
假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件仍与之前相同,那情况会发生什么变化呢?
假如你平时以声明式事务来编码,那它与本地事务看起来可能没什么区别,都是标个@Transactional
注解而已,但如果以编程式事务来实现的话,就能在写法上看出差异,伪代码如下所示:
1 | public void buyBook(PaymentBill bill) { |
2PC(2 Phase Commit) <=== XA 解决方案
为了解决这个问题,XA 将事务提交拆分成为两阶段过程:
- 准备阶段(
已记录Undo Log、Redo Log,未记录Commit Record,一直持有锁
)
又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。这里所说的准备操作跟人类语言中通常理解的准备并不相同,对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record 而已,这意味着在做完数据持久化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。 - 提交阶段(
记录Commit Record 或 回滚,释放锁
)
又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条 Commit Record 而已,通常能够快速完成,只有收到 Abort 指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。
2PC 两个假设
以上这两个过程被称为“两阶段提交”(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件。
- 必须假设网络在提交阶段的短时间内是可靠的,即
提交阶段不会丢失消息
。同时也假设网络通信在全过程都不会出现误差,即可以丢失消息,但不会传递错误的消息,XA 的设计目标并不是解决诸如拜占庭将军一类的问题。两阶段提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这也是为了尽量控制网络风险的考虑。 - 必须假设因为网络分区、机器崩溃或者其他原因而导致
失联的节点最终能够恢复
,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,并向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。
2PC 时序图
上面所说的协调者、参与者都是可以由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生的,而应用程序相对于数据库来说只扮演客户端的角色。两阶段提交的交互时序如图所示:
2PC 缺点
单点问题(协调者单点)
协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。性能问题(短板效应)
两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两阶段提交的性能通常都较差。一致性风险(非一致性) ===> 协调者(已提交) => 断网 => 参与者(未提交)
前面已经提到,两阶段提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985 年 Fischer、Lynch、Paterson 提出了“FLP 不可能原理”,证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与“CAP 不可兼得原理“齐名的理论。而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然被断开,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交,也没有办法回滚,产生了数据不一致的问题。
3PC(3 Phase Commit) <=== 2PC 优化方案
为了缓解两阶段提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了“三阶段提交”(3 Phase Commit,3PC)协议。
三阶段提交把原本的两阶段提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改称为 DoCommit 阶段。
为什么一分为二??? => 避免2PC短板效应,2PC第一步因为一台不行,第二步大家再回滚(执行,再回滚)
其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小
。
因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。
3PC 的好 => 等不到do,就提交(避免了协调者单点问题)
同样也是由于事务失败回滚概率变小的原因,在三阶段提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险
。
3PC 的坏 => 不一致的问题
从下图可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。
例如,进入 PreCommit 阶段之后,协调者发出的指令不是 Ack 而是 Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的 Abort 指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题
。
3PC 时序图
三阶段提交的操作时序如图所示。
2PC 与 3PC 对比
1 | 2PC |
共享事务
共享事务(Share Transaction)是指多服务单数据源
(把共享事务列为四种事务类型之一只是为了叙述逻辑的完备)。
可以视为是一个独立于各个服务的远程数据库连接池,或 数据库代理来看待。
如图所示:
分布式事务
分布式事务(Distributed Transaction)特指多服务多数据源
的事务处理机制。
CAP 与 ACID
CAP 的诞生
CAP 定理(Consistency、Availability、Partition Tolerance Theorem),也称为 Brewer 定理。
起源于在 2000 年 7 月,是加州大学伯克利分校的 Eric Brewer 教授于“ACM 分布式计算原理研讨会(PODC)”上提出的一个猜想。
两年之后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 以严谨的数学推理证明了 CAP 猜想。自此,CAP 正式从猜想变为分布式计算领域所公认的著名定理。
CAP 的三特性
这个定理里描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
(符合预期)一致性(Consistency)
代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的
。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,以后讨论分布式共识算法时,我们还会再提到一致性,那种面向副本复制的一致性与这里面向数据库状态的一致性严格来说并不完全等同,具体差别我们将在后续分布式共识算法中再作探讨。(不鸡掰)可用性(Availability)
代表系统不间断地提供服务的能力
,理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,例如 99.9999%可用,即代表平均年故障修复时间为 32 秒。(孤立他人,一群孤立一个)分区容忍性(Partition Tolerance)
代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力
。
假设 Fenix’s Bookstore 的服务拓扑如图所示,一个来自用户的交易请求,将交由账号、商家和仓库服务集群中某一个节点来完成响应:
假设每一个单独的服务节点都有自己的数据库(这里是为了便于说明问题的假设,在实际生产系统中,一般应避免将用户余额这样的数据设计成存储在多个可写的数据库中)。
假设某次交易请求分别由“账号节点 1”、“商家节点 2”、“仓库节点 N”联合进行响应。
当用户购买一件价值 100 元的商品后,账号节点 1 首先应给该用户账号扣减 100 元货款,它在自己数据库扣减 100 元很容易,但它还要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况。
- 如果该变动信息没有及时同步给其他账号节点,将导致有可能发生用户购买另一商品时,被分配给到另一个节点处理,由于看到账号上有不正确的余额而错误地发生了原本无法进行的交易,此为
一致性问题
。 - 如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统暂时无法提供服务而被拒绝交易,此为
可用性问题
。 - 如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的,整个集群能否承受由于部分节点之间的连接中断而仍然能够正确地提供服务,此为
分区容忍性
。
以上还仅仅涉及了账号服务集群自身的 CAP 问题,对于整个 Fenix’s Bookstore 站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的 CAP 问题,例如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售
。
又例如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了的可用性问题
,等等。
由于 CAP 定理已有严格的证明,本节不去探讨为何 CAP 不可兼得,而是直接分析如果舍弃 C、A、P 时所带来的不同影响。
如果放弃分区容忍性(CA without P) => 分布式
意味着我们将假设节点之间通信永远是可靠的
。永远可靠的通信在分布式系统中必定不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就会始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以 Oracle 的 RAC 集群为例,它的每一个节点均有自己独立的 SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据的,通过共享磁盘的方式来避免出现网络分区。因而 Oracle RAC 虽然也是由多个实例组成的数据库,但它并不能称作是分布式数据库
。如果放弃可用性(CP without A) => 数据(暂停离线)
意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长
,此时,问题相当于退化到前面“全局事务”中讨论的单服务多数据源的场景之中,我们可以通过 2PC/3PC 等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的 CP 系统情况一般用于对数据质量要求很高的场合中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统,以 HBase 集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预先估计的
。如果放弃一致性(AP without C) => 可用(节点增加)
意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致
。选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的
。目前大多数 NoSQL 库和支持分布式的缓存框架都是 AP 系统,以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。
强一致性、弱一致性
读到这里,不知道你是否对“选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题“事务”原本的目的就是获得“一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。但无论如何,系统终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来
。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability,通常是在讨论共识算法的场景中),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求“弱一致性”。
最终一致性 => 比弱一致性强一点
在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”(Eventual Consistency),它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果
,有时候面向最终一致性的算法也被称为“乐观复制算法”。
强一致性(降低追求) => 最终一致性
在本节讨论的主题“分布式事务”中,目标同样也不得不从之前三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人们把使用 ACID 的事务称为“刚性事务”,而把下面将要介绍几种分布式事务的常见做法统称为“柔性事务”。
可靠事件队列
幂等重试(无隔离)
最终一致性的概念是 eBay 的系统架构师 Dan Pritchett 在 2008 年在 ACM 发表的论文《Base: An Acid Alternative》中提出的,该论文总结了一种独立于 ACID 获得的强一致性之外的、使用 BASE 来达成一致性目的的途径。
BASE 分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)
的缩写。
我们继续以本章的场景事例来解释 Dan Pritchett 提出的“可靠事件队列”的具体做法,目标仍然是交易过程中正确修改账号、仓库和商家服务中的数据,如图所示:
1)、最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
2)、Fenix’s Bookstore 首先应对用户账号扣款、商家账号收款、库存商品出库这三个操作有一个出错概率的先验评估
,根据出错概率的大小来安排它们的操作顺序,这种评估一般直接体现在程序代码中,有一些大型系统也可能会实现动态排序。例如,根据统计,最有可能的出现的交易异常是用户购买了商品,但是不同意扣款,或者账号余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收款,如果到了商家收款环节,一般就不会出什么意外了。那顺序就应该安排成最容易出错的最先进行
,即:账号扣款 → 仓库出库 → 商家收款。
3)、账号服务进行扣款业务,如扣款成功,则在自己的数据库建立一张消息表,里面存入一条消息
:“事务 ID:某 UUID,扣款:100 元(状态:已完成),仓库出库《深入理解 Java 虚拟机》:1 本(状态:进行中),某商家收款:100 元(状态:进行中)”,注意,这个步骤中“扣款业务”和“写入消息”是使用同一个本地事务写入账号服务自己的数据库的。
4)、在系统中建立一个消息服务,定时轮询消息表
,将状态是“进行中”的消息同时发送到库存和商家服务节点中去(也可以串行地发,即一个成功后再发送另一个,但在我们讨论的场景中没必要)。这时候可能产生以下几种情况。
- (
正常
)商家和仓库服务都成功完成了收款和出库工作,向用户账号服务器返回执行结果,用户账号服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。 - (
未收到。重发消息 => 支持幂等
)商家或仓库服务中至少一个因网络原因,未能收到来自用户账号服务的消息。此时,由于用户账号服务器中存储的消息状态一直处于“进行中”,所以消息服务器将在每次轮询的时候持续地向未响应的服务重复发送消息。这个步骤的可重复性决定了所有被消息服务器发送的消息都必须具备幂等性,通常的设计是让消息带上一个唯一的事务 ID,以保证一个事务中的出库、收款动作会且只会被处理一次。 - (
自动重发,直至成功 => 可人工介入
)商家或仓库服务有某个或全部无法完成工作,例如仓库发现《深入理解 Java 虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(例如补充了新库存),或者被人工介入为止。由此可见,可靠事件队列只要第一步业务完成了,后续就没有失败回滚的概念,只许成功,不许失败。 - (
未回复。重发消息 => 支持幂等
)商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失,此时,用户账号服务仍会重新发出下一条消息,但因操作具备幂等性,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息,此过程重复直至双方网络通信恢复正常。 - (
消息框架支持
)也有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候上述情况 2、4 也可以交由消息框架来保障。
以上这种靠着持续重试来保证可靠性的解决方案谈不上是 Dan Pritchett 的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字叫作“最大努力交付”(Best-Effort Delivery)
,例如 TCP 协议中未收到 ACK 应答自动重新发包的可靠性保障就属于最大努力交付
。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的就是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。
TCC 事务
预留资源(隔离性) => 技术不可控(银联不可预留资源)
TCC 是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel
”三个单词的缩写。
可靠消息队列:坏处(无隔离性)
前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠的,过程也足够简单(相对于 TCC 来说),但整个过程完全没有任何隔离性可言,有一些业务中隔离性是无关紧要的,但有一些业务中缺乏隔离性就会带来许多麻烦。例如在本章的场景事例中,缺乏隔离性会带来的一个显而易见的问题便是“超售”:完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,例如,以上场景就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部分属于数据库本地事务方面的知识,可以参考前面的讲解。
TCC:好处(隔离性)
如果业务需要隔离,那架构师通常就应该重点考虑 TCC 方案,该方案天生适合用于需要强隔离性的分布式事务中。
在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
1 | # 1. Try:尝试执行阶段 => 预留资源 |
1)、(发起交易
)最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
2)、(创建事务,try预留资源
)创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:
- 用户服务:检查业务可行性,可行的话,将该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
- 仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
- 商家服务:检查业务可行性,不需要冻结资源。
3)、(confirm 执行
)如果第 2 步所有业务均反馈业务可行,将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:
- 用户服务:完成业务操作(扣减那被冻结的 100 元)。
- 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。
- 商家服务:完成业务操作(收款 100 元)。
4)、(confirm业务异常,网络异常 => 幂等重试
)第 3 步如果全部完成,事务宣告正常结束,如果第 3 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,即进行最大努力交付。
5)、(任何一个 try 失败,全部 cancel
)如果第 2 步有任意一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
- 用户服务:取消业务操作(释放被冻结的 100 元)。
- 仓库服务:取消业务操作(释放被冻结的 1 本书)。
- 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
6)、(cancel业务异常,网络异常 => 幂等重试
)第 5 步如果全部完成,事务宣告以失败回滚结束,如果第 5 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,即进行最大努力交付。
TCC 灵活性,锁粒度
由上述操作过程可见,TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。
TCC 性能高
TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。
TCC 开发成本和业务侵入性
但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们不会手撸一套 TCC,可以使用 阿里开源的Seata来支持 TCC
,尽量减轻一些编码工作量。
1 | 2022-09-21 10:30:18 补充三个问题在这里 |
SAGA 事务
补偿代替回滚
SAGA:解决TCC问题
TCC 事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。
TCC 的最主要限制是它的业务侵入性很强,这里并不是它开发代码的工作量,而是指它第一阶段 Try 的约束
。
例如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,允许直接在购物时通过 U 盾或扫码支付,在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲的地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以 TCC 中的第一步 Try 阶段往往无法施行。
我们只能考虑采用另外一种柔性事务方案:SAGA 事务
。SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。
SAGA 由来
SAGA 事务模式的历史十分悠久,还早于分布式事务概念的提出。
它源于 1987 年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)。
文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合
。
原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式
。
SAGA 两部分
1 | # 1. 大 -> 小 |
SAGA 两种恢复策略
如果 T1到 Tn均成功提交,那事务顺利完成。否则,要采取以下两种恢复策略之一:
1 | # 重试 => 正向恢复(Forward Recovery) |
TCC、SAGA与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多
。
例如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到 Fenix’s Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉
之前的用户转账操作,但是由 Fenix’s Bookstore 系统将货款转回到用户账上作为补偿措施却是完全可行的
。
SAGA Log:崩溃恢复
SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)
以保证系统恢复后可以追踪到子事务的执行情况,例如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,例如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式
。
AT 事务
也是一种“补偿代替回滚”的方案
例如阿里的 GTS(Global Transaction Service,Seata 由 GTS 开源而来)所提出的“AT 事务模式”就是这样的一种应用。
AT 事务:解决 XA准备阶段短板效应
从整体上看是 AT 事务是参照了 XA 两段提交协议实现的,但针对 XA 2PC 的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),设计了针对性的解决方案。
AT 事务:工作流程
1 | GTS(Global Transaction Service) => AT事务 |
大致的做法是在业务数据提交时自动拦截所有 SQL
,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL
”。基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交
的模式,相比起 2PC 极大地提升了系统的吞吐量水平,而代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不一定是总能成功的。例如,当本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Write),这时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,人工其实也很难进行有效处理。所以 GTS 增加了一个“全局锁”(Global Lock)
的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待,这种设计以牺牲一定性能为代价,避免了有两个分布式事务中包含的本地事务修改了同一个数据,从而避免脏写。在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted)
,这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。
由此可见,分布式事务中没有一揽子包治百病的解决办法
,因地制宜地选用合适的事务处理方案才是唯一有效的做法(分布式事务:没有银弹)。
【日期标记】2022-08-02 17:23:43 以上同步完成
透明多级分流系统
奥卡姆剃刀原理
Entities should not be multiplied without necessity
如无必要,勿增实体
—— Occam’s Razor,William of Ockham
分流是必要的
现代的企业级或互联网系统,“分流”是必须要考虑的设计
,分流所使用手段数量之多、涉及场景之广,可能连它的开发者本身都未必能全部意识到。这听起来似乎并不合理,但这恰好是优秀架构设计的一种体现,“分布广阔”源于“多级”,“意识不到”谓之“透明”,也即本章我们要讨论的主题“透明多级分流系统”(Transparent Multilevel Diversion System, “透明多级分流系统”这个词是自己创造的,业内通常只提“Transparent Multilevel Cache”,但我们这里谈的并不仅仅涉及到缓存)的来由。
在用户使用信息系统的过程中,请求从浏览器出发,在域名服务器的指引下找到系统的入口,经过网关、负载均衡器、缓存、服务集群等一系列设施,最后触及到末端存储于数据库服务器中的信息,然后逐级返回到用户的浏览器之中。这其中要经过很多技术部件,它们各有不同的价值。
1 | + 缓存 |
对系统进行流量规划时,我们应该充分理解这些部件的价值差异,有两条简单、普适的原则能指导我们进行设计:
- (分流)第一条原则是
尽可能减少单点部件
,如果某些单点是无可避免的,则应尽最大限度减少到达单点部件的流量。在系统中往往会有多个部件能够处理、响应用户请求,例如要获取一张存储在数据库的用户头像图片,浏览器缓存、内容分发网络、反向代理、Web 服务器、文件服务器、数据库都可能提供这张图片。恰如其分地引导请求分流至最合适的组件中,避免绝大多数流量汇集到单点部件(如数据库),同时依然能够在绝大多数时候保证处理结果的准确性,使单点系统在出现故障时自动而迅速地实施补救措施,这便是系统架构中多级分流的意义。 - (奥卡姆剃刀)另一条更关键的原则是
奥卡姆剃刀原则
。作为一名架构设计者,你应对多级分流的手段有全面的理解与充分的准备,同时清晰地意识到这些设施并不是越多越好。在实际构建系统时,你应当在有明确需求、真正必要的时候再去考虑部署它们。不是每一个系统都要追求高并发、高可用的,根据系统的用户量、峰值流量和团队本身的技术与运维能力来考虑如何部署这些设施才是合理的做法,在能满足需求的前提下,最简单的系统就是最好的系统。
本章,将会根据流量从客户端发出到服务端处理这个过程里,所流经的与功能无关的技术部件为线索,解析这里面每个部件的透明工作原理与起到的分流作用。这节所讲述的客户端缓存、域名服务器、传输链路、内容分发网络、负载均衡器、服务端缓存
,都是为了达成“透明分流”这个目标所采用的工具与手段,高可用架构、高并发则是通过“透明分流”所获得的价值。
客户端缓存
客户端缓存(Client Cache)
HTTP 协议的无状态性决定了它必须依靠客户端缓存来解决网络传输效率上的缺陷。
1 | # HTTP 无状态 => 每次请求独立 |
强制缓存
1 | # 强制缓存:白话翻译 |
协商缓存
1 | # 强制缓存 => 时效性 |
之前你打开 F12 调试 Network,查看请求的时候都会看到上面几个 Header,却不知明确含义, 读到这里是否深有感触。
【日期标记】2022-08-05 09:22:02 以上同步完成
域名解析
域名缓存(DNS Lookup)
DNS 也许是全世界最大、使用最频繁的信息查询系统,如果没有适当的分流机制,DNS 将会成为整个网络的瓶颈。
1 | # DNS 的作用 |
【我真的是不想坚持写下去了,啊啊啊…】
【2022-08-09 17:23:19 再来。。。继续整理,挑选重点来】
传输链路
0. 优化传输链路
1 | # 传输链路 |
1. 优化连接数 => 减少 TCP,连接有成本
1 | # 1. HTTP => TCP 传输协议 |
1 | # 减少TCP连接的坏处 => 两害相权取其轻(鱼与熊掌) |
2. 传输压缩
1 | # 压缩 |
3. 快速 UDP 网络连接
1 | # 替换 TCP 传输协议 |
内容分发网络
内容分发网络(Content Distribution Network)
CDN 是一种十分古老而又十分透明,没什么存在感的分流系统,许多人都说听过它,但真正了解过它的人却很少。
0. 内容分发网络:讲解
1 | # 内容分发网络 |
【日期标记】2022-08-10 11:42:09 以上同步完成
1. 路由解析
1 | # DNS 域名解析 => 无 CDN |
1 | # DNS 域名解析 => 有 CDN |
2. 内容分发
1 | # 完全透明性 => 两个子问题:获取、更新 |
3. CDN 应用
1 | 内容分发网络最初是为了快速分发静态资源而设计的。 |
【日期标记】2022-08-11 09:07:51 以上同步完成
负载均衡
负载均衡(Load Balancing)
调度后方的多台机器,以统一的接口对外提供服务,承担此职责的技术组件被称为“负载均衡”。
0. 负载均衡:OSI 七层模型
1 | # 还记得吗? |
1 | # 四层负载均衡:同一个 TCP 连接(作用二层、三层,并非四层) |
1. 负载均衡(数据链路层) => 修改:目标 MAC
1 | 参考上面 OSI 模型的表格,数据链路层传输的内容是数据帧(Frame),例如常见的以太网帧、ADSL 宽带的 PPP 帧等。 |
1 | # 多名称 => 三角传输、单臂路由、直接路由 |
2. 负载均衡(网络层) => 修改:源IP、目标IP
1 | 根据 OSI 七层模型,在第三层网络层传输的单位是分组数据包(Packets),这是一种在分组交换网络(Packet Switching Network,PSN)中传输的结构化数据单位。 |
1 | # 方式二 |
3. 负载均衡(应用层) => 代理
1 | # 四层:转发(1条TCP) |
4. 均衡策略与实现
1 | # 两大职责 |
【日期标记】2022-08-11 17:02:59 以上同步完成
服务端缓存
缓存(Cache)
软件开发中的缓存并非多多益善,它有收益,也有风险。
1 | 上面介绍透明多级分流系统的逻辑脉络,开始是以流量从客户端中发出,结束是以流量到达服务器集群中真正处理业务的节点。 |
缓存属性
1 | # 缓存也会复杂 |
1 吞吐量
1 | # 线程安全 => 导致吞吐量损失 |
(图片来自Caffeine—基准测试)
1 | # 吞吐量的影响面 |
(图片来自维基百科:环形缓存区工作原理)
1 | # Caffeine 的实现 |
2 命中率与淘汰策略
1 | # 缓存的淘汰策略 |
在搜索场景中,三种高级策略的命中率较为接近于理想曲线(Optimal),而 LRU 则差距最远,Caffeine 官方给出的数据库、网站、分析类等应用场景中,这几种策略之间的绝对差距不尽相同,但相对排名基本上没有改变,最基础的淘汰策略的命中率是最低的。对其他缓存淘汰策略感兴趣的读者可以参考维基百科中对Cache Replacement Policies的介绍。
(图片来自Caffeine)
3 扩展功能
1 | 一般来说,一套标准的 Map 接口(或者来自JSR 107的 javax.cache.Cache 接口)就可以满足缓存访问的基本需要。 |
4 分布式缓存
1 | # 进程内缓存、分布式缓存 |
缓存风险
1 | # 奥卡姆剃刀 |
缓存污染
污染也属于上面的风险,内容多,就单拎出来了。
1 | # 缓存污染 => Cache、DB 不一致 |
1 | # Cache Aside => 最简单、成本最低的(这是最常用最常用的pattern了) |
1 | # Read/Write Through |
1 | # Write Behind Caching |
1 | 再多唠叨一些 |
【日期标记】2022-08-12 16:01:06 以上同步完成
架构安全性
1 | # 计算机安全 |
认证
认证(Authentication)
系统如何正确分辨出操作用户的真实身份?
1 | # 三个基本问题 |
认证的标准
1 | # Java Applets 之后 => 代码安全 -> 用户安全 |
1. HTTP 认证
1 | 前文已经提前用到了一个技术名词:认证方案(Authentication Schemes),它是指生成用户身份凭证的某种方法,这个概念最初源于 HTTP 协议的认证框架(Authentication Framework)。 |
1 | # 四步认证流程 |
1 | 3. 用户在对话框中输入密码信息,例如输入用户名icyfenix,密码123456,浏览器会将字符串icyfenix:123456编码为aWN5ZmVuaXg6MTIzNDU2,然后发送给服务端,HTTP 请求如下所示: |
2. Web 认证
1 | # Web认证 => 表单认证 |
1 | # WebAuthn => 注册流程 |
1 | # WebAuthn => 登录流程 |
认证的实现
1 | # JAAS => Java认证授权服务 |
授权
=> 访问控制
授权( Authorization)
系统如何控制一个用户该看到哪些数据、能操作哪些功能?
1 | 认证、授权、审计、账号通常一起出现,并称为 AAAA(Authentication、Authorization、Audit、Account,也有一些领域把 Account 解释为计费的意思)。 |
RBAC
1 | # 访问控制的实质 |
1 | # 许可:权限 |
1 | # Role角色、Authority权限 => 实现差不多,使用有差异 |
OAuth2
=> Token(令牌) 面向第三方,认证授权协议
1 | # OAuth2 => 面向第三方应用(Third-Party Application)的认证授权协议 |
1 | # OAuth2 四种授权 |
1. 授权码模式
1 | # 最严谨,最啰嗦 |
1 | # OAuth2 解决意外情况 |
2. 隐式授权模式
1 |
|
1 | # 时序图 |
3. 密码模式
1 | # 密码模式:认证+授权 |
4. 客户端模式
1 | # 最简单 |
1 | # 客户端模式 => 服务间认证授权常用 |
5. 设备码模式
1 | # 类似 客户端模式 |
凭证
凭证(Credentials)
系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?
1 | # 填坑 OAuth2难失效 |
Cookie-Session
1 | # HTTP 无状态 => 没有上下文、请求独立 |
JWT
1 | # Session服务器存储不行 => JWT客户端存储(每次发送携带) |
1 | # 前缀 Bearer |
额外知识:散列消息认证码
在本节及后面其他关于安全的内容中,经常会在某种哈希算法前出现“HMAC”的前缀,这是指散列消息认证码(Hash-based Message Authentication Code,HMAC)。可以简单将它理解为一种带有密钥的哈希摘要算法,实现形式上通常是把密钥以加盐方式混入,与内容一起做哈希摘要。
HMAC 哈希与普通哈希算法的差别是普通的哈希算法通过 Hash 函数结果易变性保证了原有内容未被篡改,HMAC 不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。如图 5-14 所示。
1 | # 部分2:Payload => 消息体 |
保密
保密(Confidentiality)
系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
1 | # 保密=加密+解密 |
保密的强度
1 | # 保密有成本 |
客户端加密
1 | # 数据库不应该保存明文密码 |
额外知识:中间人攻击(Man-in-the-Middle Attack,MitM)
在消息发出方和接收方之间拦截双方通信。
.
用日常生活中的写信来类比的话:
你给朋友写了一封信,邮递员可以把每一份你寄出去的信都拆开看,甚至把信的内容改掉,然后重新封起来,再寄出去给你的朋友。
朋友收到信之后给你回信,邮递员又可以拆开看,看完随便改,改完封好再送到你手上。
你全程都不知道自己寄出去的信和收到的信都经过邮递员这个“中间人”转手和处理
=> 换句话说,对于你和你朋友来讲,邮递员这个“中间人”角色是不可见的。
1 | # Server不应该明文,防止数据库泄漏 |
密码存储和验证
1 | 以 Fenix’s Bookstore 中的真实代码为例,介绍对一个普通安全强度的信息系统,密码如何从客户端传输到服务端,然后存储进数据库的全过程。 |
传输
传输(Transport Security)
系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?
1 | 前文中已经为传输安全层挖下了不少坑,例如:基于信道的认证是怎样实现的?为什么 HTTPS 是绝大部分信息系统防御通信被窃听和篡改的唯一可行手段?传输安全层难道不也是一种自动化的加密吗?为何说客户端如何加密都不能代替 HTTPS? |
摘要、加密与签名
1 | # 密码算法三用途:摘要、加密与签名 |
1 | # JWT x |
数字证书
1 | # 现实世界:两种信任 => 共同私密,权威公证 |
额外知识:公开密钥基础设施
又称公开密钥基础架构、公钥基础建设、公钥基础设施、公开密码匙基础建设或公钥基础架构,
是一组由硬件、软件、参与者、管理政策与流程组成的基础架构,其目的在于创造、管理、分配、使用、存储以及撤销数字证书。
.
密码学上,公开密钥基础建设借着数字证书认证中心(Certificate Authority,CA)将用户的个人身份跟公开密钥链接在一起。
对每个证书中心用户的身份必须是唯一的。链接关系通过注册和发布过程创建,根据担保级别的差异,创建过程可由 CA 的各种软件或者在人为监督下完成。
PKI 的确定链接关系的这一角色称为注册管理中心(Registration Authority,RA)。RA 确保公开密钥和个人身份链接,可以防抵赖。
1 | # 公钥不可数,权威CA中心是有限的 |
1 | # 证书(公钥) => 打破蛋鸡悖论 => 证书保证公钥不被修改(CA 证书或者根证书:系统预设) |
传输安全层
关于HTTPS的文章:
申请免费SSL证书,实现 https 访问
*https 的简单实现原理
https请求 SSL 证书校验失败
解决:postman 不能访问 https
Google浏览器显示URL的 http https
1 | 到此为止,数字签名的安全性已经可以完全自洽了,但相信你大概也已经感受到了这条信任链的复杂与烦琐。 |
1 | 1. 客户端请求:Client Hello |
验证
验证(Verification)
系统如何确保提交到每项服务中的数据是合乎规则的,不会对系统稳定性、数据一致性、正确性产生风险?
1 | 数据验证与程序如何编码是密切相关的,许多开发者都不会把它归入安全的范畴之中。但请细想一下,关注“你是谁”(认证)、“你能做什么”(授权)等问题是很合理的,关注“你做得对不对”(验证)不也同样合理吗? |
【日期标记】2022-08-16 09:55:04 以上同步完成
技术方法论
No Silver Bullet 没有银弹
- “软件研发中任何一项技术、方法、架构都不可能是银弹”
- 微服务不是银弹
目的:微服务的驱动力
微服务的目的是有效的拆分应用,实现敏捷开发和部署。
有人说:为了先进架构 > 废话
有人会说迈向微服务的目的是为了追求更先进的架构形式。这话对,但没有什么信息量可言,任何一次架构演进的目的都是为了更加先进,应该没谁是为“追求落后”而重构系统的。有人说:性能不行 > 单体也可以扩缩容
有人会说微服务是信息系统发展的必然阶段,为了应对日益庞大的压力,获得更好的性能,自然会演进至能够扩缩自如的微服务架构,这个观点看似合理、具体、正确,实则争议颇大。个人的态度是旗帜鲜明地反对以“获得更好的性能”为主要目的,将系统重构为微服务架构的,性能有可能会作为辅助性的理由,但仅仅为了性能而选择分布式的话,那应该是 40 年前“原始分布式时代”所追求的目标。现代的单体系统同样会采用可扩缩的设计,同样能够集群部署,更重要的是云计算数据中心的处理能力几乎可以认为是无限的,那能够通过扩展硬件的手段解决问题就尽量别使用复杂的软件方法,硬件成本平稳下降,而软件不行
其中原因在前面引用的《没有银弹》中已经解释过:“硬件的成本能够持续稳定地下降,而软件开发的成本则不可能”。而且,性能也不会因为采用了微服务架构而凭空产生。微服务不如单体
把系统拆成多个微服务,一旦在某个关键地方依然卡住了业务流程,其整体的结果往往还不如单体,没有清晰的职责划分,导致扩展性失效,多加机器往往还不如单机。
当意识到没有什么技术能够包打天下。
1
2
3
4# 异构
AI => Python
Redis => C
etcd => Go举个具体例子,某个系统选用了处于Tiobe 排行榜榜首多年的 Java 语言来开发,也会遇到很多想做但 Java 却不擅长的事情。例如想去做人工智能,进行深度学习训练,发现大量的库和开源代码都离不开 Python;想要引入分布式协调工具时,发现近几年 ZooKeeper 已经有被后起之秀 Golang 的 Etcd 蚕食替代的趋势;想要做集中式缓存,发现无可争议的首选是 ANSI C 编写的 Redis,等等。很多时候为异构能力进行的分布式部署,并不是你想或者不想的问题,而是没有选择、无可避免的。
当个人能力因素成为系统发展的明显制约。
1
2# 部分 => 全局
=> 局部:容错、快速迭代对于北上广深的信息技术企业这个问题可能不会成为主要矛盾,在其他地区,不少软件公司即使有钱也很难招到大量的靠谱的高端开发者。此时,无论是引入外包团队,抑或是让少量技术专家带着大量普通水平的开发者去共同完成一个大型系统,微服务都是一个更有潜力的选择。在单体架构下,没有什么有效阻断错误传播的手段,系统中“整体”与“部分”的关系没有物理的划分,系统质量只能靠研发与项目管理措施来尽可能地保障,少量的技术专家很难阻止大量螺丝钉式的程序员或者不熟悉原有技术架构的外包人员在某个不起眼的地方犯错并产生全局性的影响,不容易做出整体可靠的大型系统。这时微服务可以作为专家掌控架构约束力的技术手段,由高水平的开发、运维人员去保证关键的技术和业务服务靠谱,其他大量外围的功能即使不靠谱,甚至默认它们必定不靠谱,也能保证系统整体的稳定和局部的容错、自愈与快速迭代。
当遇到来自外部商业层面对内部技术层面提出的要求。
1
2# 甲方要求
(招标规范)对于那些以“自产自销”为主的互联网公司来说这一点体验不明显,但对于很多为企业提供信息服务的软件公司来说,甲方爸爸的要求往往才是具决定性的推动力。技术、需求上困难也许能变通克服,但当微服务架构变成大型系统先进性的背书时,甲方的招投标文件技术规范明文要求系统必须支持微服务架构、支持分布式部署,那就没有多少讨价还价的余地。
在系统和研发团队内部,也会有一些因素促使其向微服务靠拢:
变化发展特别快的创新业务系统往往会自主地向微服务架构靠近。
1
2# 上线活不过3天
可观测,可自愈需求喊着“要试错!要创新!要拥抱变化!”,开发喊着“资源永远不够!活干不完!”,运维喊着“你见过凌晨四点的洛杉矶吗!”,对于那种“一个功能上线平均活不过三天”的系统,如果团队本身能力能够支撑在合理的代价下让功能有快速迭代的可能,让代码能避免在类库层面的直接依赖而导致纠缠不清,让系统有更好的可观测性和回弹性(自愈能力),需求、开发、运维肯定都是很乐意接受微服务的,毕竟此时大家的利益一致,微服务的实施也会水到渠成。
大规模的、业务复杂的、历史包袱沉重的系统也可能主动向微服务架构靠近。
这类系统最后的结局不外乎三种:1
2
3
4
5
6
7
8
9
10
11
12
13# 爷爷辈
第一种是日渐臃肿,客户忍了,系统持续维持着,直到谁也替代不了却又谁也维护不了。
曾听说过国外有公司招聘 60、70 岁的爷爷辈程序员去维护上个世纪的 COBOL 编写的系统,没有求证过这到底是网络段子还是确有其事。
# 新老并行
第二种是日渐臃肿,客户忍不了了,痛下决心,宁愿付出一段时间内业务双轨运行,
忍受在新、旧系统上重复操作,期间业务发生震荡甚至短暂停顿的代价,也要将整套旧系统彻底淘汰掉,第二种情况亲眼看见过不少。
# 部分CRUD
第三种是日渐臃肿,客户忍不了,系统也很难淘汰。
此时迫于外部压力,微服务会作为一种能够将系统部分地拆除、修改、更新、替换的技术方案被严肃地论证,
若在重构阶段有足够靠谱的技术人员参与,该大型系统的应用代码和数据库都逐渐分离独立,直至孵化出一个个可替换可重生的微服务,
微服务的先驱 Netflix 曾在多次演讲中介绍说自己公司属于第三种的成功案例。
以上列举的这些内外部原因只是举例,肯定不是全部,促使你的产品最终选择微服务的具体理由可能是多种多样,相信你做出向微服务迈进的决策时,一定经过恰当的权衡,认为收益大于成本。
1 | # 微服务最主要的目的 |
前提:微服务需要的条件
系统的架构趋同于组织的沟通结构。 - 康威定律