DDIA | 事务

170 阅读9分钟

引言

在数据存储系统中,数据库、应用程序和客户端及彼此之间的连接,都有中断和崩溃的可能。为了实现系统高可靠的目标,同时与性能和高可用达成平衡,需要一个方案来简化这个问题。事务技术即创造出来简化应用层的编程模型。在 DDIA 的第七章中,详细地介绍了事务,分析可能出错的各种场景和数据库防范这些问题的基本方法和算法设计。

深入理解事务 ACID 的含义

原子性 Atomicity

在事务提供的安全性保证中,原子性表示在出错时终止事务,并将完成的写入部分全部丢弃。

原子性描述了一个客户端发起一个包含多个写操作请求时可能发生的情况,例如进程崩溃,网络中断,磁盘变满或违反某种完整性约束。如果没有原子性保证,在发生错误时难以定位生效和未生效部分,原子性简化了这个问题。

一致性 Consistency

一致性指数据有特定的预期状态,任何数据更改必须满足这些状态约束。

所以本质上一致性是对应用层的要求,应用层通过发起事务,使用事务的安全性保证(AID)来实现一致性。

同时,一致性在分布式环境中是非常重要的问题,我在另一篇 DDIA | 个人补充:一致性模型 中区分了各种一致性的区别。

隔离性 Isolation

隔离性意味着并发执行的多个事务相互隔离,运行的事务不能互相干扰。

持久性 Durability

持久性保证事务一旦提交成功,即使硬件故障或数据库崩溃,事务所写入的数据也不会丢失。

对于单节点数据库,持久性意味着数据已被写入非易失性设备同时保存预写日志。对于支持远程复制的数据库,持久性意味着数据已经复制到多个节点。

弱隔离级别

在多个事务同时读写相同数据时,引入了竞争条件,会触发并发问题。实现高级别的隔离固然可以解决,如使用可串行化保证事务的最终结果与串行没有区别,但是会严重影响性能,需要选择合适的隔离级别来保证一致性和性能之间的平衡。

非串行化的隔离级别也被统称为弱隔离级别,以下列举了弱隔离级别和可能发生的竞争条件。

读已提交

读已提交是最基本的事务隔离级别,提供以下两个保证:

  • 读数据库时只能看到已成功提交的数据(防止脏读)
  • 写数据库时只会覆盖已成功提交的数据(防止脏写)

数据库通常使用行级锁来防止脏写,当事务想修改某个对象的值时,需要首先获得该对象的锁并持有到事务提交或终止。

也可以选择同样的方式来防止脏读,所有试图获取该对象的值的事务必须获取锁并持有至事务提交。但是一个运行时间较长的写事务会导致许多只读的事务阻塞,严重影响只读事务的响应延迟。因此大多数数据库选择维护旧值和当前持锁事务要设置的新值两个版本,在事务提交前读取旧值,提交后读取新值。

快照级别隔离与可重复读

快照级别隔离指每个事务都从数据库的一致性快照中读取,即使数据在读取过程中被另一个事务修改,每个事务都只能看到该特定时间点的旧数据。SQL标准中并没有定义快照隔离级别,PostgreSQL和MySQL称快照级别为可重复读。

在读已提交的隔离级别下,一个事务在读取过程中对同一个值读取两次,在间隔中该值被另一个事务修改,则会出现两次读取数据不一致的情况。这种现象被称为不可重复读或读倾斜。在大多数场景中可以短暂容忍这种不一致,但是在备份和分析查询中则需要更高的隔离级别,也就是快照级别隔离。

数据库通过保留对象多个不同的提交版本来实现快照级别隔离,因此也被称为多版本并发控制(Multi-Version Concurrency Control,MVCC)。

  • 读已提交:在此隔离级别下,事务每次进行快照读时都会生成一个新的读视图。这意味着在同一个事务中,连续两次查询可能看到不同的数据,因为每次查询都可能遇到新提交的事务。
  • 可重复读 :事务只在第一次执行快照读时生成读视图。之后的所有快照读都会使用这个最初的视图,无论在此期间有多少其他事务提交。这保证了在事务内部的一致性和重复读的特性。

当事务开始时,首先赋予一个唯一的、单调递增的事务ID。每当事务向数据库写入新内容时,所有的数据都会标记写入者的ID。表中的每一行还有一个created_by字段和deleted_by字段,包含创建该行的ID和请求删除该行的ID,只有当数据库的垃圾回收进程确认没有事务引用该行时,才会真正删除并释放存储空间。

在此基础上,还需要定义一致性快照的可见性规则:

  • 每笔事务开始时,数据库列出所有尚未提交的事务并忽略这些事务的部分写入
  • 晚于当前事务ID做出的修改不可见

写事务并发问题

更新丢失

更新丢失可能发生在读-修改-写回(read-modify-write)过程。当两个事务在执行这一过程时,第二个写操作并不包括第一个事务修改后的值,导致第一个事务的修改值可能会丢失。

更新丢失与脏写的区别在于对事务状态的依赖性:

  • 脏写涉及到一个事务直接覆盖另一个未提交事务的修改,核心问题是对未提交或“脏”数据的依赖。
  • 更新丢失通常发生在两个事务都已经看到了某个数据项的某个状态,且根据这个状态做出了修改,最终导致其中一个事务的修改被另一个覆盖。

目前对并发写事务冲突有几种解决方法:

  • 原子写操作:通过使用数据库的原子更新操作,避免在应用层完成读-修改-写回操作。
  • 显式加锁:由应用程序显式锁定待更新的对象。
  • 自动检测更新丢失:利用数据库提供的快照隔离级别检查,但是MySQL的可重复读不支持检测更新丢失。
  • 原子比较和设置:在更新时只有在上次读取后没变化才允许更新;如果发生变化则退回读-修改-写回操作。

写倾斜

写倾斜是一种更为微妙的写冲突,指两笔事务更新的是两个不同的对象,但其中一个事务的写操作会影响另一个事务的做出决定的前提条件。如:支付过程中要求检查用户的花费不能超过限额,当两个事务并发地检查是否超额,同时扣款,可能导致最终超过限额。

换句话说,如果一个事务提交后再执行另一个事务,会出现完全不同的结果。

简化写倾斜的场景,一个事务的写入改变了另一个事务查询结果,这种现象被称为幻读。快照隔离级别可以避免只读查询的幻读,但无法解决写倾斜问题。

串行化

弱隔离级别难以解决写倾斜和幻读问题,这时最合适的方案便是选择最强的隔离级别:可串行化。可串行化保证即使事务并行执行,最终的结果与串行执行的结果相同。目前可串行化的实现大概有三种。

实际串行执行

解决并发问题最直接的问题是避免并发。一个线程按顺序方式每次只执行一个事务,便完全避免了防止事务冲突等问题,但实际串行化会严重影响效率,需要一些约束条件:

  • 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行。
  • 仅限于活动数据集可以完全加载到内存的场景
  • 写入吞吐量必须低,才能在单个cpu核上处理
  • 跨分区事务占比必须很小

两阶段加锁

多个事务可以同时读取同一对象,但只要出现任何写操作,则必须加锁以独占。两阶段加锁不仅在并发写之间互斥,读与写也会互斥,这是与快照级别隔离读写互不干扰的关键区别。

可串行化的快照隔离

可串行化的快照隔离是一种乐观并发控制机制。如果可能发生冲突,事务会继续执行而不是终止,只有当事务提交时,数据库会检查是否真正发生了冲突。

MySQL在可串行化级别中倾向于使用悲观并发控制策略,通过锁定数据来避免冲突。而PostgreSQL在其9.1及之后的版本中引入了可串行化快照隔离。