Dawn's Blogs

分享技术 记录成长

0%

凤凰架构笔记 (2) 事务处理

本地事务

以下都是基本原理,请不要与 MySQL 挂钩!MySQL 的基本思想肯定是这样,但肯定不是完全照搬。

原子性和持久性

按照事务提交时点为界,划分为 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。

通过日志实现事务的原子性和持久性是当今的主流方案,但并不是唯一的选择。除了日志外,还有一种称之为 Shadow Paging(影子分页)来实现,SqLite v3 就是这种机制。

Shadow Paging 在数据写入磁盘时,不会直接修改原来的数据,而是将原来的数据复制一份副本,保留原数据只修改副本。事务成功提交后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本(这个修改指针被认为是原子操作)。但是 Shadow Paging 并发能力不强,所以应用不多。

Commit Logging

Commit Logging 中只有一种日志,就是 redo 日志。对数据的操作都会记录在 redo 日志中,事务的提交就是在日志中写入代表提交成功的提交记录(Commit Record)。在数据库看到提交记录(Commit Record)后,才根据日志内容对数据进行真正的修改,修改完成后向日志中加入一条结束记录(End Record)表示事务已经成功完成持久化。

一旦写入了 Commit Record,即使在写入磁盘之前崩溃了,重启后也能根据日志恢复,保证了持久性。如果在日志没有 Commit Record 时就发生了崩溃,则整个任务就是失败的,因为没有真正写入磁盘,所以就好像发生了回滚一样,保证了原子性。

Commit Logging 的原理非常清晰,如 OceanBase 就是采用了这种方法。但是 Commit Logging 的一个缺陷就是:所有对数据的真实修改必须发生在提交之后,在提交之前即使磁盘 I/O 是空闲的也不允许写入数据,并且占用了大量的内存缓冲区。

因为 Commit Logging 只有 redo log,所以不允许 STEAL,否则无法回滚(回滚需要 undo log)。

Write-Ahead Logging

Write-Ahead Logging 是改进的方案,所谓 Write-Ahead(提前写入)指的就是在提交之前写入数据。Write-Ahead Logging 在 redo log 的基础上,引入了 undo log 用于回滚。当变动数据写入磁盘前,必须先记录 Undo Log,注明修改了哪个位置的数据、从什么值改成什么值等等。以便在事务回滚或者崩溃恢复时根据 undo log 对提前写入的数据变动进行擦除。

在崩溃恢复时,根据 redo log 对没有写入磁盘但是已经提交的数据进行持久化,根据 undo log 对没有提交但是已经写入磁盘的数据进行回滚。

redo log 保证事务的持久性,而 undo log 保证事务的原子性。

隔离性

实现隔离性最直观的方式,就是加锁。现代数据库均提供以下三种锁:

  • 写锁:对一行数据加写锁,写锁与其他锁都互斥。
  • 读锁:对一行数据加读锁,读锁与读锁不互斥,与写锁互斥。
  • 范围锁:对一个范围加锁。

注意,范围锁不等于对多行数据加锁,因为数据与数据之间有间隙(这就是为什么 MySQL 中有间隙锁)。

隔离性用隔离级别来体现,有以下几种隔离级别(这几种隔离级别都解决了脏读问题):

  • 可串行化(Serializable):可串行化是最高的隔离级别,也是并发程度最低的隔离级别。可串行化中相当于全局加锁,不存在幻读、不可重复读、脏读问题。
  • 可重复读(Repeatable Read):可重复读中,对于事务中涉及到的数据会加上读锁和写锁,但不会加范围锁。不加范围锁导致的问题,就是会出现幻读问题。
  • 读已提交(Read Commited):在读已提交中,写锁会持续到事务结束读锁在查询完成后会马上释放。读锁在查询结束后马上释放的带来的问题,就是会导致不可重复读
  • 读未提交(Read Uncommitted):读未提交中,写锁会持续到事务结束,但是完全不会加读锁。不加读锁带来的问题就是脏读,可能会读取到其他事务还未提交的数据。

在 MySQL 中的默认隔离级别未可重复读,但它在只读事务中可以完全避免幻读问题

MVCC

MVCC 多版本并发控制是一种读优化策略,MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。

  • 如果隔离级别是可重复读:读取小于等于当前事务 ID 的最大版本。
  • 如果隔离级别是读已提交:读取最新版本。

读未提交直接修改原始数据即可,不需要版本。

分布式事务

XA

XA 又称为两阶段提交(2 Phrase Commit,2PC),核心是定义了全局的事务管理器(Transaction Manager)用于管理全局事务和局部的资源管理器(Resource Manager)用于驱动本地事务。XA 将事务分为两个阶段:

  • 准备阶段:协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。对于数据库来说就是记录除了最后一条 Commit Record 之外的所有操作(也就是意味着此时是持有锁的)。
  • 提交阶段:协调者如果收到了全部的 Prepare 消息,则向全体参与者发送 Commit 命令,所有参与者立即提交。如果有收到了任意一个 Non-Prepared 或者在第一阶段发生了超时,则向全体参与者发送 Abort 命令,参与者立即进行回滚操作。在第二阶段,事务协调者发出的命令是不允许失败的。

image-20230606152748286

两阶段提交有以下缺点:

  • 单点问题:如果事务协调者发生故障,则整个分布式事务都会产生影响。在第一阶段允许事务参与者下线发生超时;但是在第二阶段,不允许事务参与者发生超时,事务协调者会一直发送命令直到事务参与者恢复,而其他参与者则一直等待。
  • 性能问题:在第二阶段,不允许事务参与者发生超时,事务协调者会一直发送命令直到事务参与者恢复,而其他参与者则一直等待。事务的整个过程一直持续到最慢的参与者完成为止,在这过程中是不会释放锁的。

将 commit 或者 rollback 命令放入消息队列中,可以保证消息的可靠交付。

三阶段提交

为了缓解 2PC 的单点问题和性能问题,又提出了改进方案即三阶段提交 3PC。3PC 将准备阶段细分为 CanCommit 和 PreCommit,把提交阶段称之为 DoCommit 阶段。

其中 CanCommit 相当是在最前面添加了一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。因为一旦 PreCommit 阶段,则会锁住相应的资源,所以在这之前新增一个询问阶段,询问参与者是否有把握完成事务。

在 3PC 的 DoCommit 阶段,如果参与者没有等待 Commit 消息,默认的策略是提交事务而不是回滚事务,这就相当于避免了协调者单点问题的风险。

image-20230606152757614

TCC

TCC 是分布式事务的一种实现方式,它将事务分为了三个阶段 Try、Commit、Cancel。在具体实现上,TCC 是一种业务入侵较强的方案:

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

TCC 和 XA 本质原理上是一样的,但是二者还是有细微区别的:

  • XA 是底层实现的,就像之前所说的,Prepare 过程中数据库日志已经记录了对数据的操作,只是没有记录 Commit Record。
  • TCC 对业务的入侵较强,需要自己实现 Commit 和 Cancel。而 TCC 中,比如在账户扣款的场景下,可能需要增加一个冻结金额的属性用于自己实现 Commit 和 Cancel。

SAGA

TCC 的性能最好,但是对业务的入侵性非常强,但是不能满足所有的场景。如果用户的存款账户是由第三方(如银行)管理的,那么对于冻结金额这种操作是无法自己去实现的。

SAGA 用于解决这样一种场景,将事务分为一系列子事务补偿操作。如果冻结金额不可行,那么可以在扣除金额之后再补偿回来,这是可行的。

  • SAGA 中的子事务和其补偿操作都具有幂等性
  • 而且具有交换律,即最终结果和子事务与补偿操作的执行顺序无关。
  • 并且,补偿操作必须成功提交,如果出现失败则重试直至成功。

SAGA 按照子事务顺序执行,如果子事务失败则有两种恢复策略:

  • 正向恢复:重试子事务,直至子事务执行成功。这种恢复方式不需要补偿,适用于事务最终都要成功的场景
  • 反向恢复:从失败的子事务开始,反向执行补偿操作,直至全部执行成功。