本地事务相对比较简单,很容易实现。但是上升到全局事务再到分布式事务就比较麻烦,对保证数据一致性就需要做很多额外的处理。

从全局事务(Global Transactions)谈起

全局事务即外部事务(External Transaction):一种适用于单个服务使用多个数据源场景的事务解决方案。

实际上DTP(Distributed Transaction Processing)中没有上边这种限定。本篇分为两大类:

  1. 在分布式(单服务多个数据源)环境中追求强一致性。
  2. 在分布式(微服务)中放弃ACID追求弱一致性。

XA(Extended Architecture)协议

定义了全局事务管理器(Transaction Manager):用于协调全局事务局部的资源管理器(Resources Manager):用于驱动本地事务之间的通讯接口。

XA接口是双向的,一个事务管理器和多个资源管理器之间通信的桥梁,协调多个数据源保持一致,来实现全局事务的统一提交或者统一回滚。XADataSource XAResource。XA并不是java规范,是一套通用技术。 Java后来专门定义了一套全局事务处理标准JTA

JTA(Java Transaction API)

Java定义的一套全局事务处理标准。

  • 事务管理器接口: 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)。有了 JOTM 的支持,我们就可以在 Tomcat、Jetty 这样的 Java SE 环境下使用 JTA 了。

XA 和 JTA的关系

XA refers to eXtended Architecture, which is a specification for distributed transaction processing. The goal of XA is to provide atomicity in global transactions involving heterogeneous components.

XA specification provides integrity through a protocol known as a two-phase commit. Two-phase commit is a widely-used distributed algorithm to facilitate the decision to commit or rollback a distributed transaction.

Java Transaction API (JTA) is a Java Enterprise Edition API developed under the Java Community Process. It enables Java applications and application servers to perform distributed transactions across XA resources.

JTA is modeled around XA architecture, leveraging two-phase commit. JTA specifies standard Java interfaces between a transaction manager and the other parties in a distributed transaction.

XA指的是eXtended Architecture,是一种用于分布式事务处理的规范。XA的目标是在涉及异构组件的全局事务中提供原子性。

XA规范通过一个称为两阶段提交的协议提供了一致性。两阶段提交是一种广泛使用的分布式算法,用于促进决定是提交还是回滚分布式事务。

Java事务API(JTA)是在Java社区流程下开发的Java企业版API。它使Java应用程序和应用服务器能够跨XA资源执行分布式事务。

JTA围绕XA架构建模,利用两阶段提交。JTA规定了分布式事务中事务管理器与其他参与方之间的标准Java接口。

两阶段提交(2 Phase Commit 2PC)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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();
} catch(Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}

如上开启了三个事务,业务处理完成后做了三次事务提交。但是如果三个commit中第二个和第三个commit出现了Exception那么已经提交的事务rollback不了,这样就破坏了全局事务的一致性。为了解决这种问题提出了两阶段提交。

  • 准备阶段:投票阶段,协调者询问所有事务参与者是否已经准备好,准备好:Prepared 否则:Non-Prepared。对于数据库来讲,准备操作是是在重做日志中记录全部事务提交操作所要做的内容,与本地事务主要区别是:暂时不写入最后一条Commit Record。这意味着做完数据持久化后暂时不会释放隔离性,也就是依然持有锁。
  • 提交阶段:协调者受到所有事务参与者恢复的Prepared消息,就会首先在本地持久化事务状态未Commit,然后向所有参与者发送Commit指令。否则任意一个参与者回复Non-Prepared消息,协调者都会将自己事务状态持久化未Abort并且发送给所有参与者。

因为提交阶段相对轻量级,仅仅是持久化一条指令 Commit Record能够快速完成。回滚阶段则相对耗时,收到Abort时需要根据Undo log清理已经提交的数据。

缺点:

  • 单点问题:协调者单点
  • 性能问题: 两阶段提交过程中,所有参与者相当于被绑定成为一个统一调度整体,期间要经历两次远程服务调用,三次数据持久化(准备阶段写Redo Log,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的处理操作结束为止。
  • 一致性风险: 网络稳定性带来的一致性风险。尽管提交阶段时间很短,但仍是明确存在的危险期。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。

java中如何使用两阶段提交

Java-atomikos: https://www.baeldung.com/java-atomikos

三段式提交

将两阶段提交的准备阶段再细分两个阶段:

  1. CanCommit: 询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务释放有可能顺利完成。
  2. PreCommit:

提交阶段改为:

  1. DoCommit

将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。

在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。

如果协调者在 PreCommit 阶段开始之后发生了宕机,参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。

image-20220715092405378

共享事务

  1. 全局事务是单个服务使用多个数据源,共享事务是指多个服务共用一个数据源。

  2. “数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。

  3. 现实中只有类似ProxySQL和MaxScale这样用于对多个数据库实例做负载均衡的数据库代理,而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。

  4. 让多个微服务去共享一个数据库这个方案,其实还有另一种应用形式:使用消息队列服务器来代替交易服务器,用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,然后通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这就是“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。

  5. “共享事务”这种叫法,以及我们刚刚讲到的通过交易服务器或者通过消息驱动来更新单个数据库这两种处理方式,在实际应用中并不常见,也几乎没有相应的成功案例,能够查到的资料几乎都来源于十多年前 Spring 的核心开发者Dave Syer的文章“Distributed Transactions in Spring, with and without XA”。

两段式提交和三段式提交仍然追求 ACID 的强一致性,这个目标不仅给它带来了很高的复杂度,而且吞吐量和使用效果上也不佳。因此,现在系统设计的主流,已经变成了不追求 ACID 而是强调 BASE 的弱一致性事务,这就是我们要在下一讲学习的分布式事务了。

分布式事务

多个服务同时访问多个数据源的处理机制。

CAP理论

  1. 一致性(Consistency):代表在任何时刻,任何分布式节点中,我们所看到的数据都是没有矛盾的。与ACID中的C单词相同含义不同。
  2. 可用性(Available):代表系统不间断提供服务。
  3. 分区容忍性(Partition Tolerance):代表在分布式环境中,当部分节点因为网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍然能够正常的工作。

放弃分区容忍性:

CA: 这意味着,我们将假设节点之间的通讯永远是可靠的。可是永远可靠的通讯在分布式系统中必定是不成立的,这不是你想不想的问题,而是网络分区现象始终会存在。

在现实场景中,主流的 RDBMS(关系数据库管理系统)集群通常就是采用放弃分区容错性的工作模式。以 Oracle 的 RAC 集群为例,它的每一个节点都有自己的 SGA(系统全局区)、重做日志、回滚日志等,但各个节点是共享磁盘中的同一份数据文件和控制文件的,也就是说,RAC 集群是通过共享磁盘的方式来避免网络分区的出现。

放弃可用性:

CP: 这意味着,我们将假设一旦发生分区,节点之间的信息同步时间可以无限制地延长,那么这个问题就相当于退化到了上一讲所讨论的全局事务的场景之中,即一个系统可以使用多个数据源。我们可以通过 2PC/3PC 等手段,同时获得分区容错性和一致性。

在现实中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统。以它的集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个时间通常会是很长的。

放弃一致性:

这意味着,我们将假设一旦发生分区,节点之间所提供的数据可能不一致。

AP : 系统目前是分布式系统设计的主流选择,大多数的 NoSQL 库和支持分布式的缓存都是 AP 系统。因为 P 是分布式网络的天然属性,你不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就没有存在的价值了(除非银行这些涉及到金钱交易的服务,宁可中断也不能出错)。

以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那也不妨碍每个节点仍然会以自己本地的数据对外提供服务。但这时有可能出现这种情况,即请求分配到不同节点时,返回给客户端的是不同的数据。

ACID和CAP

把前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability),而把牺牲了 C 的 AP 系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性”。

弱一致性:最终一致性

强一致性:刚性事务

可靠消息队列

eBay系统架构师:丹.普利切特(Dan Pritchett)提出了BASE理论提出了最终一致性概念。

系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到下游关联系统。

有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况 2、4 也可以交给消息框架来保障。

最大努力交付(Best-Effort Delivery)

靠着持续重试来保证可靠性的操作。比如 TCP 协议中的可靠性保障,就属于最大努力交付。

最大努力一次交付(Best-Effort 1PC)

把可能出错的业务,以本地事务的方式完成后,经过不断的重试(不限于消息系统)来促使同个事务的关联业务完成。

TCC(Try-Confirm-Cancel)和SAGA

可靠消息队列的缺点就是没有隔离性。

TCC

TCC方案,它天生适用于需要强调隔离性的分布式事务中。它是一中业务侵入性比较强的事务方案,要求处理过程必须拆分为:“预留业务资源”和“确认/释放消费资源”两个子过程。

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

实际的业务请求如下:

  1. 业务发出更新请求。
  2. 创建事务,生成事务ID,记录在活动日志中,进入Try阶段。
  3. 如果第二步中所有的业务都反馈业务可行,就将活动日志中的记录为Confirm,进入Confirm阶段
  4. 如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的 Confirm 操作,即进行“最大努力交付”。
  5. 如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
  6. 如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的 Cancel 操作,即进行“最大努力交付”。

优点:

  • TCC 其实有点类似于 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。

  • TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。

缺点: TCC 最主要的限制是它的业务侵入性很强,但并不是指由此给开发编码带来的工作量,而是指它所要求的技术可控性上的约束。

SAGA

TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。

来源:SAGA 事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA 的意思是“长篇故事、长篇记叙、一长串事件”,它起源于 1987 年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)

文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出 SAGA 的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。

SAGA的组成部分
  1. 把大事务T拆分成为若干小事务 T1…Tn,每个子事务都能被看作是原子行为。如果分布式事务T能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。

    T - commit == T1 - commit + T2 - commit …Ti - commit… Tn - commit

  2. 每个子事务T1…Tn涉及对应的补偿动作,命名为:C1,C2,…, Ci, …,Cn

    • Ti 与 Ci 都具备幂等性;
    • Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;
    • Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。
恢复模式

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

  • 正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
  • 反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。

SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。

AT 事务

AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。

  1. 自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
  2. 如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。
  3. 基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
  4. 当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
  5. 一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
  6. GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。
  7. 这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
  8. 在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。

Comment and share

引言

事务管理是日常开发过程中绕不开的技术,引入事务的目的是为了保证数据持久化过程中的 一致性(Consistency) 。事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。这篇总结主要是总结清楚事务的来龙去脉,无关具体的某个中间件。深度推荐《周志明的软件架构课》

定义事务的ACID

  • 原子性(Atomic)同一业务处理过程中,事务保证了多项对数据的修改,要么全部成功,要么全部失败。
  • 隔离性(Isolation)在不同的业务处理过程中,事务保证了各自读写的业务数据相互不可见,不会彼此影响。
  • 持久性(Durability)事务保证了当业务处理完成后,事务提交后能够被持久化,不会丢失。
  • 一致性(Consistency)一致性。利用A, I, D 保证了业务数据的一致性。

A, I, D是手段,C 是目的。 ACID更像是凑单词。

本地事务(局部事务)Local Transaction

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

  • 本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。

  • 本地事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持。

  • 例如:Spring提供的事务注解,如果数据库引擎不支持那么这个注解也是无意义的。

    1
    2
    3
    4
    @Transactional(propagation = Propagation.REQUIRED, //传播方式
    rollbackFor = Exception.class, //回滚异常
    isolation = ISOLATION_REPEATABLE_READ //隔离级别
    )

原子性和持久性

扩展知识:ARIES 理论,现代主流数据库的事务实现都受到该理论的影响

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

崩溃回复(Crash Recovery)

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

这种数据恢复操作被称作是:崩溃恢复。

Commit Logging

为了能够顺利完成崩溃恢复,那么需要记录下数据操作的全部信息:比如修改的什么数据,位于哪个内存页和磁盘块中,从什么值改成了什么值等等,以顺序追加的文件写入方式记录的磁盘中(顺序写磁盘是高效的)。只有日志记录全部落盘,见到代表事务成功提交的“Commit Record”,数据库才会根据日志上的信息真正的对数据进行修改。修改完成后在日志中加一条“End Record”标识已经完成持久化,这种实现方式被称为“Commit Logging”

另外一种实现:Shadow Paging

SQLite Version 3采用这种方式实现。

将原来数据复制一份副本也可以称之为临时数据,原来数据保持不变,在副本数据上进行操作。再修改完成后改变“两份数据的指针”。

Shadow Paging 相对简单,但涉及到隔离性与锁时,Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。

Commit Logging保证持久性:日志一旦写入Commit Logging,那么整个事务就是成功的。即使崩溃,重启后根据日志进行恢复现场,继续修改即可。

Commit Logging保证原子性:如果日志没有写入成功就发生崩溃,重启后会看到一部分没有Commit Logging的数据,那么将这部分数据标识为回滚状态即可,整个事务就像没有发生过。保证了原子性。

Commit Logging 存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。

Write-Ahead Logging(先写日志)

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

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

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

Commit Logging允许NO-FORCE但是不允许STEAL。因为在事务提交前写入了一部分数据,如果发生崩溃,那么提前写入的数据就变成了脏数据。

Write-Ahead Logging允许NO-FORCE也允许STEAL,给出的解决办法是增加了Undo Log日志。当数据写入磁盘前,必须先记录Undo Log,写明修改了什么值并生成对应的回滚段。以便事务再发生崩溃恢复的时候根据Undo Log对提前写入的数据变动进行擦除。

Undo Log 现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志,就相应被命名为 Redo Log,一般翻译为“重做日志”。

Write-Ahead Logging 在崩溃恢复时,会以此经历以下三个阶段:

  1. 分析阶段(Analysis):从最后一次检查点Checkpoint(从这个检查点之前所有的数据都成功落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table);
  2. 重做阶段(Redo):这个阶段依据分析阶段产生的待恢复的事务集合来重演历史(Repeat History)找出所有的包含Commit Record的日志,将它们写入磁盘,写入完成后追加一个End Record,然后移除待恢复 集合。
  3. 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。

数据库按照“是否允许 FORCE 和 STEAL”可以产生四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE 加 STEAL 组合的复杂度无疑是最高的。这四种组合与 Undo Log、Redo Log 之间的具体关系如下图所示:

image-20220705093501892

隔离性

隔离性保证了每个事务各自读写的相互独立性。主要是为了解决事务并发问题,有并发的地方就有锁。

  • 写锁(Write Lock 也叫排他锁eXclusive Lock,简写X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加写锁时,其它事务不能写入数据,也不能施加读锁(写锁禁止其他事务施加读锁,而不是禁止事务读取数据。)。

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

  • 范围锁(Range Lock)对某个范围数据直接添加排他锁,在这个范围的数据不能被读取,也不能被写入。如 select * from books where price < 100 for update;

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

可串行化(Serializable)

对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为 Two-Phase Lock)。

强度最高的隔离性。粗粒度锁,事务顺序执行那么就不会互相产生影响。但是吞吐量最低。

可重复读(Repeatable Read)

可重复读的意思是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但是不再添加范围锁。

缺点:在可读事务中的幻读问题(MySQL 中的InnoDB在只读事务中不存在幻读问题,只在读写事务提升为当前读后存在这个问题)如下读事务:

1
2
3
4
5
6
7
8
9

/* 时间顺序:1,事务: T1 */
SELECT count(1) FROM books WHERE price < 100

/* 时间顺序:2,事务: T2 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)

/* 时间顺序:3,事务: T1 会查出来 T2 插入的数据,但是InnoDB不会*/
SELECT count(1) FROM books WHERE price < 100

读提交(Read Committed)

读提交会对事务涉及到的数据加写锁,会一直持续到事务结束,加的读锁会在读完之后立马释放。

读提交有了不可重复读—-对同一行数据两次查询得到了不同的结果。

原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。

读未提交(Read UnCommitted)

读未提交只会对事务所涉及到的数据加写锁,不会加读锁。

读未提交有了脏读—-一个事务读到了另外一个事务未提交的数据

其实,不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读 + 另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

多版本并发控制MVCC(Multi-Version Concurrency Control)无锁化。

MVCC是一种读取优化策略,它的“无锁”是特指读取数据时不需要加锁。

MVCC只针对读+写的场景优化,如果是两个事务同时修改数据,即写+写的场景那么几乎没有什么优化的空间

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

  1. 数据被插入时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。
  2. 数据被删除时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。
  3. 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。
  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China