读薄凤凰架构系列 - 事务1

"Transaction"

Posted by Lin on November 5, 2021

这里,展开讲讲事务。

事务处理的意义是为了保证系统所有数据状态的一致性(Consistency)。

基础理论

最早了解到的事务一般都是数据库事务,数据库事务的经典理论是 通过以下三个方面来达到这个目的:

  • 原子性(Atomic):在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
  • 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
  • 持久性(Durability):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

以上就是事务的”ACID”特性。

在以前,单体架构的时代,事务的保障 主要依赖于数据库的事务能力。如今,分布式架构的环境下,数据的一致性不单单依赖于数据库层面去实现,越来越多地转移到了应用层面。

引用《DDIA》的观点:

Atomicity, isolation, and durability are properties of the database, whereas consis‐tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.

接下来,展开讲讲 数据库事务(本地事务),是最基础的一种事务解决方案,仅适用于单个服务使用单个数据的场景。

数据库事务(本地事务)

本地事务是最基础的一种事务解决方案,只适用于单个服务使用单个数据源的场景,直接依赖于数据源本身提供的事务能力来实现。

原子性和持久性

原子性是一个事务中的多个操作要么全部成功、要么全部失败;持久性是事务一旦成功后,相关的数据不会因任何原因丢失。

但,数据从内存写入到磁盘(等其他持久化储存硬件),这个写入操作并不是原子的,存在”写入中”的中间状态,还客观地存在”写入中”发生崩溃的场景。所以,没有额外的恢复措施,无法保证以下两个场景的原子性和持久性:

  • *这里举个栗子: 电商中常见的下单场景,用户提交购物订单,这是一个事务操作: 1) 冻结物品库存;2) 生成支付单;3) 开启冲销解冻计时任务**

伪代码:

public Payment executeBySettlement(Settlement bill) {
    // 计算账单总额度
    productService.replenishProductInformation(bill);
    // 冻结库存
    Payment payment = paymentService.producePayment(bill);
    // 开启定时器 --用于释放库存和资金
    paymentService.setupAutoThawedTrigger(payment);
    return payment;
}
  • 事务未提交,但写入成功: 完成冻结库存,但生成支付单时,数据库发生崩溃,重启后,数据库需要回滚这次不完整的下单操作,将已经修改的数据(冻结库存)从磁盘中恢复回来,已保障原子性;
  • 事务已提交,但写入失败: 完成所有操作,但数据库未将数据变动写入磁盘,发生崩溃,重启后,数据库需要将还没写入磁盘的数据重新写入,已保障持久性。

为了保证原子性和持久性,数据库会采取恢复的补救措施,称为“崩溃恢复(Crash Recovery)”。

以 MySQL/InnoDB 为例,展开讲讲“崩溃恢复(Crash Recovery)”的具体实现流程。

MySQL/InnoDB使用两个关键功能 Redo log 和 Undo log 来实现崩溃恢复:

  • 当崩溃发生在 事务未提交,但写入成功时,恢复机制是通过 回滚日志(undo log) 实现,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可。并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务,保证了事务原子性。
  • 当崩溃发生在 事务已提交,但写入失败 时,数据库重启时,InnoDB存储引擎会使用redo log恢复数据,保证数据的持久性与完整性。

这是通过日志的形式来实现 事务的原子性和持久性,是当前广泛使用的方式。此外,轻量级数据库SQLite使用了另外一种称为“Shadow Paging”(“影子分页”)的事务实现机制。https://en.wikipedia.org/wiki/Shadow_paging

隔离性

在并发场景下,隔离性保证了每个事务各自读、写的数据相互独立,不会相互影响。

不做好隔离性,会遇到以下问题:

  • 脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。
  • 丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。
  • 不可重复读(Unrepeatableread): 在一个事务内两次读到的数据是不一样的情况。
  • 幻读(Phantom read): 在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

如何在高并发场景下保证数据的串行操作?一般想到的就是 加锁。是的,数据库提供了三种锁: 写锁、读锁、范围锁。

大学的CS课程都会讲到四种隔离级别的数据库理论:  以下隔离级别依次递减

  • 可串行化(Serializable): 对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化;
  • 可重复读(Repeatable Read): 是 MySQL/InnoDB 的默认隔离级别,对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • 读已提交(Read Committed): 是 Oracle等多数数据库默认隔离级别,允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
  • 读未提交(Read Uncommitted): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。

但数据库是一个高并发应用,根据并发控制理论,隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。

除了锁的实现手段外,现代数据库引擎引用了多版本并发控制(MVCC)来提高数据库高并发场景下的吞吐性能(针对“一个事务读+另一个事务写”的隔离问题)。MVCC 是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。

下讲预告: 全局事务

参考资料