架构师的视角

第2章 访问远程服务

2.1 远程服务调用

2.1.1 进程间通信

调用本地方法:

1
2
3
4
5
6
7
8
// Caller: 调用者,代码里的main()
// Callee: 被调用者,代码里的println()
// Call Site: 调用点,即发生方法调用的指令流位置
// Parameter: 参数,由Caller传递给Callee的数据,即"hello world"
// Retval: 返回值,由Callee传递给Caller的数据;如果方法正常结束,返回void,如果方法异常完成,返回对应的异常。
public static void main(String[] args){
    System.out.println("hello world");
}

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

1)传递方法参数:将字符串hello world的引用地址压栈

2)确定方法版本:根据println()方法的签名,确定其执行版本。这其实并不是一个简单的过程,无论是编译时静态解析,还是运行时动态分派,都必须根据某些语言规范中明确定义的原则,找到明确的Callee,“明确”是指唯一的一个Callee,或者有严格优先级的多个Callee,譬如不同的重载版本。

3)执行被调方法:从栈中弹出Parameter的值或引用,并以此为输入,执行Callee内部的逻辑。这里我们只关心方法是如何调用的,而不关心方法内部具体是如何执行的。

4)返回执行结果:将Callee的执行结果压栈,并将程序的指令流恢复到Call Site的下一条指令,继续向下执行。

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

因此就有了两个进程之间如何交换数据的问题,这件事情在计算机科学中被称为“进程间通信”(Inter-Process Communication,IPC)。

IPC的解决方法:

1、管道(Pipe)或者具名管道(Named Pipe):管道类似于两个进程间的桥梁,可通过管道在进程间传递少量的字符流或字节流。

普通管道只用于有亲缘关系的进程(由一个进程启动的另外一个进程)间的通信

具名管道摆脱了普通管道没有名字的限制,除具有管道的所有功能外,它还允许无亲缘关系的进程间的通信。

管道典型的应用就是命令行中的“|”操作符,譬如:

1
ps -ef | grep java

ps与grep都有独立的进程,以上命令就是通过管道操作符“|”将ps命令的标准输出连接到grep命令的标准输入上。

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

1
kill -9 pid

以上命令即表示由Shell进程向指定PID的进程发送SIGKILL信号。

3、信号量(Semaphore):信号量用于在两个进程之间同步协作手段,它相当于操作系统提供的一个特殊变量,程序可以在上面进行wait()和notify()操作。

4、消息队列(Message Queue):以上三种方式只适合传递少量消息,POSIX标准中定义了可用于进程间数据量较多的通信的消息队列。进程可以向队列添加消息,被赋予读权限的进程还可以从队列消费消息。消息队列克服了信号承载信息量少、管道只能用于无格式字节流以及缓冲区大小受限等缺点,但实时性相对受限。

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

6、本地套接字接口(IPC Socket):消息队列与共享内存只适合单机多进程间的通信,套接字接口则是更普适的进程间通信机制,可用于不同机器之间的进程通信。

套接字(Socket)起初是由UNIX系统的BSD分支开发出来的,现在已经移植到所有主流的操作系统上。出于效率考虑,当仅限于本机进程间通信时,套接字接口是被优化过的,不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只是简单地将应用层数据从一个进程复制到另一个进程,这种进程间通信方式即本地套接字接口(UNIX Domain Socket),又叫作IPC Socket。

2.1.2 通信的成本

IPC Socket不仅适用于本地相同机器的不同进程间通信,由于Socket是网络栈的统一接口,它也能支持基于网络的跨机进程间通信。譬如

  • Linux系统的图形化界面
  • X Window服务器
  • GUI程序之间的交互

就是由这套机制来实现的。这样做的好处是,由于Socket是各个操作系统都提供的标准接口,完全有可能把远程方法调用的通信细节隐藏在操作系统底层,从应用层面上来看可以做到远程调用与本地的进程间通信在编码上完全一致。

首次提出远程服务调用的定义:

远程服务调用是指位于互不重合的内存地址空间中的两个程序,在语言层面上,以同步的方式使用带宽有限的信道来传输程序控制信息。

2.1.3 三个基本问题

1、如何表示数据

每种RPC协议都应该要有对应的序列化协议,譬如:

  • ONC RPC的外部数据表示(External Data Representation, XDR)
  • CORBA的通用数据表示(Common Data Representation, CDR)
  • Java RMI的Java对象序列化流协议(Java Object Serialization Stream Protocol)
  • gRPC的Protocol Buffers
  • Web Service的XML序列化
  • 众多轻量级RPC支持的JSON序列化

2、如何传递数据

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

在计算机科学中,专门有一个名词“Wire Protocol”来表示这种两个Endpoint之间交换这类数据的行为,常见的Wire Protocol如下:

  • Java RMI的Java远程消息交换协议(Java Remote Message Protocol, JRMP,也支持RMI-IIOP)
  • CORBA的互联网ORB间协议(Internet Inter ORB Protocol,IIOP,是GIOP协议在IP协议上的实现版本)
  • DDS的实时发布订阅协议(Real Time Publish Subscribe Protocol, RTPS)
  • Web Service的简单对象访问协议(Simple Object Access Protocol, SOAP)

如果要求足够简单,双方都是HTTP Endpoint,直接使用HTTP协议也是可以的(如JSON-RPC)

3、如何表示方法

表示方法的协议有:

  • 唯一的绝不重复的编码方案UUID(Universally Unique Identifier)
  • Android的Android接口定义语言(Android Interface Definition Language, AIDL)
  • CORBA的OMG接口定义语言(OMG Interface Definition Language, OMG IDL)
  • Web Service的Web服务描述语言(Web Service Description Language, WSDL)
  • JSON-RPC的JSON Web服务协议(JSON Web Service Protocol, JSON-WSP)

2.1.3 统一的RPC

面向透明的、简单的RPC协议,如DCE/RPC、DCOM、Java RMI,要么依赖于操作系统,要么依赖于特定语言,总有一些先天约束;

那些面向通用的、普适的RPC协议,如CORBA,就无法逃过使用复杂性的困扰,CORBA烦琐的OMG IDL、ORB都是很好的佐证;

而那些意图通过技术手段来屏蔽复杂性的RPC协议,如Web Service,又不免受到性能问题的束缚。

简单、普适、高性能这三点,似乎真的很难同时满足。

2.1.3 分裂的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将面向过程的编码方式带到分布式,希望在分布式系统中也能够进行跨进程的面向对象编程,代表为RMI、.NET Remoting,之前的CORBA和DCOM也可以归入这类。这种方式有一个别名叫作分布式对象(Distributed Object)

2、朝着性能发展,代表为gRPC和Thrift。

决定RPC性能的主要因素有两个:序列化效率和信息密度。序列化效率很好理解,序列化输出结果的容量越小,速度越快,效率自然越高;信息密度则取决于协议中有效负载(Payload)所占总传输数据的比例大小,使用传输协议的层次越高,信息密度就越低,SOAP使用XML拙劣的性能表现就是前车之鉴。

gRPC和Thrift都有自己优秀的专有序列化器,而传输协议方面,gRPC是基于HTTP/2的,支持多路复用和Header压缩,Thrift则直接基于传输层的TCP协议来实现,省去了应用层协议的额外开销。

3、朝着简化发展,代表为JSON-RPC,说要选功能最强、速度最快的RPC可能会很有争议,但选功能弱的、速度慢的,JSON-RPC肯定会是候选人之一。牺牲了功能和效率,换来的是协议的简单轻便,接口与格式都更为通用,尤其适合用于Web浏览器这类一般不会有额外协议支持、额外客户端支持的应用场合。

经历了RPC框架的“战国时代”,开发者们终于认可了不同的RPC框架所提供的特性或多或少是有矛盾的,很难有某一种框架能满足所有需求。

2.2 REST设计风格

REST: 面向资源的调用方式

RPC: 面向过程的调用方式

2.2.1 理解REST

Architectural Styles and the Design of Network-based Software Architectures

中文翻译版:《架构风格与基于网络的软件架构设计》

REST(Representational State Transfer, 表征状态转移)

先理解什么是HTTP,再配合一些实际例子来对两者进行类比,以更清楚地了解REST,会发现REST实际上是“HTT”(Hypertext Transfer)的进一步抽象,两者的关系就如同接口与实现类的关系一般。

现在,“超文本”一词已被普遍接受,它指的是能够进行分支判断和差异响应的文本,相应地,“超媒体”一词指的是能够进行分支判断和差异响应的图像、电影和声音(也包括文本)的复合体。

——Theodor Holm Nelson,Literary Machines,1992

2.2.2 RESTful的系统

REST风格系统的六大原则:

1、客户端与服务端分离(Client-Server)

将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性。

2、无状态(Stateless)

REST希望服务端不用负责维护状态,每一次从客户端发送的请求中,应包括所有必要的上下文信息,会话信息也由客户端负责保存维护服务端只依据客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁。客户端承担状态维护职责以后,会产生一些新的问题,譬如身份认证、授权等可信问题,它们都应有针对性的解决方案。

3、可缓存(Cacheability)

无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。

“降低网络性”:某个功能使用有状态的设计时只需要一次(或少量)请求就能完成,使用无状态的设计时则可能会需要多次请求,或者在请求中带有额外冗余的信息。为了缓解这个矛盾,REST希望软件系统能够如同万维网一样,允许客户端和中间的通信传递者(譬如代理)将部分服务端的应答缓存起来。

4、分层系统(Layered System)

这里所指的分层并不是表示层、服务层、持久层这种意义上的分层,而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。该原则的典型应用是内容分发网络(Content Distribution Network,CDN)。

5、统一接口(Uniform Interface)

这是REST的另一条核心原则,REST希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源,而不是抽象系统该有哪些行为(服务)上。

如果想要在架构设计中合理恰当地利用统一接口,Fielding建议系统应能做到每次请求中都包含资源的ID,所有操作均通过资源ID来进行;建议每个资源都应该是自描述的消息;建议通过超文本来驱动应用状态的转移。

6、按需代码(Code-On-Demand)

按需代码被Fielding列为一条可选原则。它是指任何按照客户端(譬如浏览器)的请求,将可执行的软件程序从服务端发送到客户端的技术。

2.2.3 RMM

RESTful Web APIs和RESTful Web Services的作者Leonard Richardson曾提出一个衡量“服务有多么REST”的Richardson成熟度模型(Richardson Maturity Model,RMM),以便让那些原本不使用REST的系统,能够逐步地导入REST。

Richardson将服务接口“REST的程度”从低到高,分为0至3级。

  • 第0级(The Swamp of Plain Old XML):完全不REST。
  • 第1级(Resources):开始引入资源的概念。
  • 第2级(HTTP Verbs):引入统一接口,映射到HTTP协议的方法上。
  • 第3级(Hypermedia Controls):超媒体控制,在本文里面的说法是“超文本驱动”,在Fielding论文里的说法是“Hypertext As The Engine Of Application State,HATEOAS”,其实都是指同一件事情。

2.2.3 不足与争议

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不支持的特性。

第3章 事务处理

原子性(Atomic):

在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。

隔离性(Isolation):

在不同的业务处理过程中,事务保证了各业务正在读、写的数据相互独立,不会彼此影响。

持久性(Durability):

事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

内部一致性:

当一个服务只使用一个数据源时,通过A、I、D来获得一致性是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事务间一致性被称为“内部一致性”。

外部一致性:

当一个服务使用到多个不同的数据源,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得困难了许多。此时,并发执行甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据源来决定,这种涉及多个数据源的事务间一致性被称为“外部一致性”。

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

3.1 本地事务

本地事务(Local Transaction):局部事务。本地事务是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。

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

举个例子,假设你的代码调用了JDBC中的Transaction::rollback()方法,方法的成功执行也并不一定代表事务就已经被成功回滚,如果数据表采用的引擎是MyISAM,那rollback()方法便是一项没有意义的空操作。因此,我们要想深入讨论本地事务,便不得不越过应用代码的层次,去了解一些数据库本身的事务实现原理,弄明白传统数据库管理系统是如何通过ACID来实现事务的。

ARIES理论(Algorithms for Recovery and Isolation Exploiting Semantic,ARIES): 基于语义的恢复与隔离算法。

ARIES是现代数据库的基础理论,就算不能称所有的数据库都实现了ARIES,至少可以称现代的主流关系型数据库(Oracle、MS SQLServer、MySQL/InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响。

3.1.1 实现原子性和持久性

ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging 着重解决了ACID的两个属性——原子性(A)和持久性(D)在算法层面上的实现问题。

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

Commit Logging提交日志 :

为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中的变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即以仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“提交日志”(Commit Logging)。

Write-Ahead Logging提前写入日志

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

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

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

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

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

Undo Log:回滚日志。

Redo Log:重做日志。用于崩溃恢复时重演数据变动的日志。

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

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

重做阶段和回滚阶段的操作都应该设计为幂等的。

Write-Ahead Logging是ARIES理论的一部分,整套ARIES拥有严谨、高性能等诸多优点,但这些也是以高度复杂为代价的。

数据库按照是否允许FORCE和STEAL可以产生四种组合。

FORCE和STEAL产生的四种组合与Undo Log、Redo Log之间的具体关系:

FORCE-STEAL

优化磁盘I/O的角度看,NO-FORCE加STEAL的组合的性能无疑是最高的;

算法实现与日志的角度看,NO-FORCE加STEAL的组合的复杂度无疑也是最高的。

3.1.2 实现隔离性

ARIES/KVL: A Key-Value Locking Method for Concurrency Control of Multiaction Transactions Operating on B-Tree Indexes 则是现代数据库隔离性(I)奠基式的文章。

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

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

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

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

    1
    
    SELECT * FROM books WHERE price<100 FOR UPDATE
    

    请注意“范围不能被写入”与“一批数据不能被写入”的差别,即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。

隔离级别

1、可串行化(Serializable)

串行化(Serializable)访问提供了最高强度的隔离性。

并发控制(Concurrency Control)理论决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户自主调节隔离级别,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

2、可重复读(Repeatable Read)

可串行化的下一个隔离级别是可重复读(Repeatable Read),可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。

可重复读比可串行化弱化的地方在于幻读问题(Phantom Read),它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。

MySQL/InnoDB的默认隔离级别为可重复读,但它在只读事务中可以完全避免幻读问题,譬如上面例子中事务T1只有查询语句,是一个只读事务,所以上述问题在MySQL中并不会出现。但在读写事务中,MySQL仍然会出现幻读问题,譬如例子中事务T1如果在其他事务插入新书后,不是重新查询一次数量,而是将所有小于100元的书改名,那就依然会受到新插入书的影响。

3、读已提交(Read Committed)

可重复读的下一个隔离级别是读已提交(Read Committed),读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后会马上释放。

读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Read),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。

4、读未提交(Read Uncommitted)

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

MVCC:

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

MVCC是一种读取优化策略,它的“无锁”特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存,以此达到读取时可以完全不加锁的目的。在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSIONDELETE_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最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被提交的那个版本的数据记录。

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

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

前面介绍的加锁都属于悲观加锁策略,即认为如果不先加锁再访问数据,就肯定会出现问题。相对地,乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。这种思路也被称为“乐观并发控制”(Optimistic Concurrency Control,OCC)。

3.2 全局事务

全局事务(Global Transaction):外部事务(External Transaction),与本地事务相对的是全局事务(Global Transaction)。全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案。

JTA:

XA并不是Java的技术规范(XA提出那时还没有Java),而是一套语言无关的通用规范,所以Java中专门定义了JSR 907 Java Transaction API,基于XA模式在Java语言中实现了全局事务处理的标准,这也是我们现在所熟知的JTA。

JTA最主要的两个接口如下。

  • 事务管理器的接口:javax.transaction.TransactionManager。这套接口用于为Java EE服务器提供容器事务(由容器自动负责事务管理)。

    JTA还提供了另外一套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,Java开源事务管理器),使得我们也能够在Tomcat、Jetty这样的Java SE环境下使用JTA。

如果书店的用户、商家、仓库分别处于不同的数据库中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public void buyBook(PaymentBill bill){
    userTransaction.begin();
    warehouseTransaction.begin();
    bussinessTransaction.begin();
    try{
        userAccountService.pay(bill.getMoney());
        warehouseService.deliver(bill.getItems());
        businessAccountService.receipt(bill.getMoney());
        userTransaction.commit();
        warehouseTransaction.commit();
        businessTransaction.commit();
    }(catch Exception e){
        userTransaction.rollback();
        warehouserTransaction.rollback();
        businessTransaction.rollback();
    }
}

从代码可以看出,程序的目的是做三次事务提交,但实际上代码并不能这样写,试想一下,如果在businessTransaction.commit()中出现错误,代码转到catch块中执行,此时userTransactionwarehouseTransaction已经完成提交,再去调用rollback()方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证了。

为了解决这个问题,XA将事务提交拆分成两阶段。

1、准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复Prepared,否则回复Non-Prepared。这里所说的准备操作跟人类语言中通常理解的准备不同,对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条Commit Record而已,这意味着在做完数据持久化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

2、提交阶段:又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的Prepared消息,则先自己在本地持久化事务状态为Commit,然后向所有参与者发送Commit指令,让所有参与者立即执行提交操作;否则,任意一个参与者回复了Non-Prepared消息,或任意一个参与者超时未回复时,协调者将在自己完成事务状态为Abort持久化后,向所有参与者发送Abort指令,让参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条Commit Record而已,通常能够快速完成,只有收到Abort指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。

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

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

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

  • 单点问题:协调者在两段式提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送Commit或者Rollback的指令,那所有参与者都必须一直等待。
  • 性能问题:在两段式提交过程中,所有参与者相当于被绑定为一个统一调度的整体,期间要经过两次远程服务调用三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入提交记录),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。
  • 一致性风险:前面已经提到,两段式提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985年Fischer、Lynch、Paterson提出了“FLP不可能原理”,证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与CAP不可兼得原理齐名的理论。而网络稳定性带来的一致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然断开,无法再通过网络向所有参与者发出Commit指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)未提交,且没有办法回滚,产生数据不一致的问题。

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

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

第4章 透明多级分流系统

第5章 架构安全性