事务

Feb 21, 2024 20:00 · 3356 words · 7 minute read Database

事务是将多个读写操作组合成一个单一的逻辑单元,作为一个操作来执行。整个事务要么全部成功要么全部失败(中止,回滚)。事务使得应用程序不用担心部分失败,并可以安全地重试这些操作,因为数据库提供了这些安全保证。

有时为了提升性能或可用性,最好减弱事务保证。几乎所有关系型数据库和某些非关系型数据库都支持事务。在关系型数据库中,BEGIN TRANSACTIONCOMMIT 之间的所有语句都属于同一个事务。

ACID

事务提供的安全保障,也被称为 ACID:

  • Atomicity(原子性)
  • Consistency(一致性)
  • Isolation(隔离性)
  • Durability(持久性)

不保证 ACID 的数据库被称为 BASE:

  • Basically Available(基本可用)
  • Soft State(软状态)
  • Eventual Consistency(最终一致性)

原子性(Atomicity)

原子性是在出错时中止并撤销已进行的写入的能力(要么全部成功要么全部失败)。

不要和多线程编程中的原子操作混淆,原子操作是如果一个线程执行该操作,另一个线程无法看到该操作的半成品结果。

原子性可以通过诸如 B 树(B-Tree)的日志来实现故障恢复。

一致性(Consistency)

一致性是应用程序认为数据库处于“良好状态”,数据始终为真。比如在会计系统中,账户间的借贷必须保持平衡。

一致性是应用程序的属性,而原子性、隔离性、持久性是数据库的属性。

不要和副本一致性、最终一致性、一致性哈希(分区)还有 CAP 定理中的一致性混淆。

隔离性

隔离性是每个并发运行的事务都不能互相干扰。当事务提交后,即使它们是并发运行的,结果也应当和它们一个接一个跑(串行)相同。

持久性

持久性是一旦事务被提交,数据将永不丢失的保证,防范任何硬件故障、数据库崩溃等问题。

隔离级别

ANSI SQL-92 提出了最经典的隔离级别定义:

脏读 不可重复读 幻读
读未提交(Read Uncommitted) 可能 可能 可能
读已提交(Read Committed) 不可能 可能 可能
可重复读(Repeatable Read) 不可能 不可能 可能
串行化(Serializable) 不可能 不可能 不可能

读已提交和快照(可重复读)都是不那么强的隔离级别。串行化隔离保证事务和串行运行(一次一个,没有任何并发)效果相同。在现实中,串行化隔离会严重影响性能,很多数据库直接选择不支持。有个比较流行的活法,“如果你正在处理财务数据,就使用 ACID 数据库!”,但即使很多被认为是 ACID 的关系型数据库用的也只是某种形式的弱隔离。

读已提交

读提交是最基本的事务隔离级别。它保证:

  • 读数据库,只有已经被提交的数据会被返回(没有脏读)
  • 写数据库,只有已经被提交的数据会被覆写(没有脏写)

脏读

脏读是指一个事务读取了另一个事务尚未提交的写入。由于数据处于部分更新状态,这会导致其他事务做出错误的决策。另外如果一个事务中止,其他事务可能已经读到将被回滚的数据。

大部分数据库通过记住旧的已提交值和当前持写锁事务设置的新值来防止脏读。在事务进行中,其他事务会读到旧值。一旦新值被提交,其他事务就能读取到新值。

读锁很少使用因为它会严重影响只读事务的响应时间。一个长的写入事务可能会破事许多只读事务等待,直到写入事务完成。

脏写

脏写是一个事务覆盖了另一个正在进行的事务未提交的数据。数据库要延迟第二个事务的写入,直到第一个事务写入提交。

大部分数据库通过行锁防止脏写。当事务试图修改一个对象时(行或者文档),必须先获取到该对象的锁。该锁会保持到事务提交或中止。其他想要修改此对象的事务必须等到锁被释放。

Read Skew(不可重复读)

Read Skew 出现在当一个事务正在写入多个对象时,同时第二个事务并行地读取某些对象中的新数据和其他对象中的旧数据。这导致第二个事务在数据库中看到不一致的数据。

读已提交不能防止 Read Skew(不可重复读),这要通过快照隔离(可重复读)解决。

快照隔离(可重复读)

可重复度是事务只会从其开始时的数据库状态读取数据,即使其他事务后来更改了数据,每个事务仍从其特定的快照中读取数据。

不同的数据库对快照隔离有不同的命名,在 Oracle 中的实现为串行化隔离;在 PostgreSQL 和 MySQL 中,被实现为“可重复读”。

多版本并发控制(MVCC)

快照隔离通常用 MVCC 来实现。数据库将保留一个对象的多个不同的已提交版本,各种事务需要在不同的时间点读取数据库状态,每个事务被赋予一个唯一的、始终递增的事务 ID。

因为 MVCC 是无锁的,快照隔离能做到读不阻塞写,甚至写也不阻塞写。

修改

每当写入时,数据都会被标记上执行的事务 ID。当另一个事务读取时,所有后来的事务所做的写入都会被忽略。

一个垃圾回收进程会定期删除旧的对象版本,前提是所有事务都不需要看到它们。

实践中尽量不要使用长事务,长事务能够看到很老的对象版本(回滚日志),在事务提交前都不会被清理,会导致占用大量存储空间

删除

当事务删除数据时,该行实际上并未被删除,而是通过将 deleted_by 设置为事务 ID 来标记为删除。

垃圾回收进程也会定期删除标记为删除的对象,前提是所有事务都不再需要看到它们。

更新丢失

更新丢失是两个事务同时读取一个对象,修改并回写(读-改-写),因为第二次写入并未包含第一次的修改,第一次修改丢失了。后来的写入覆盖了先前的。

原子更新

为了防止更新丢失,许多数据库提供原子更新操作。应用程序使用原子更新而非读-改-写操作:

UPDATE counters SET value = value + 1 WHERE key = 'foo'

原子更新通过排它锁来实现,锁住行直到提交,防止其他事务读取同一个对象。关系型数据库、Redis 和 MongoDB 都提供原子更新功能。

原子检测

另一个解决方案是能够检测更新丢失的事务管理器。如果发生更新丢失,事务管理器将中止违规的事务,并强制其重试读-改-写周期。需要开启快照隔离,PostgreSQL、Oracle 和 SQL Server 的快照隔离级别会自动检测丢失的更新并中止事务,MySQL(InnoDB)不会。

解决冲突(数据复制)

复制式数据库允许并发写入时创建一个对象的多个冲突版本,由数据库来解决和合并这些版本。

Write Skew

当事务读取一个对象,处理完数据做出决策并准备写入数据库时,在提交前另一个事务已经变更了最初读取的对象,导致决策的前提就是不准确的。快照隔离不能防止 Write Skew,只有串行化才可以。

幻读

幻读是指一个事务在前后两次查询同一范围时,因为另一个事物的写提交了新的对象导致第二次查询到了前一次没有看到的对象。快照隔离虽然可以防止标准的幻读(读已提交隔离级别下出现的幻读现象),但是当前读的用法下也会出现幻读。

“当前读”就是要能读到所有已提交记录的最新值,查询加 FOR UPDATE

串行化

串行化是最强的隔离级别,保证了即使多个事务使并行的,最终结果也和它们一个接一个跑一样。这个隔离级别杜绝所有可能存在的竞争。

真串行执行

实现串行化隔离最简单的方法就是彻底移除并发。也就是单线程按顺序逐一执行事务。虽然多线程并发固然可以提升性能,但更大容量的 RAM 使得单线程执行也不是不可能。如今将整个活动的数据库保持在内存中时可行的,内存中的事务比在磁盘上要快得多。Redis 采用这种方法。由于吞吐量仅限于一个 CPU 核心,所以要以特殊的方式构建事务来充分利用单线程(epoll)。

将数据分区到多个节点就拥有了多个 CPU 核心,但要满足:

  1. 事务只会从单个分区读写数据,这样就无需跨区协调
  2. 数据库系统协调事务接触的分区

两阶段锁(2PL)

在两阶段锁中,只要没有事务正在写入对象,就允许多个事务同时读取同一个对象;事务必须等到其他写入该对象的事务提交或中止才能读取;反之亦然。MySQL(InnoDB)和 SQL Server 都使用两阶段锁。

两阶段锁使用共享锁和排它锁实现:

  • 当一个事务要想读取一个对象,它必须获取到共享锁,必须等待该对象上的排它锁被释放。
  • 当事务要想写入一个对象,它必须获取到排它锁,必须等到该对象上任意存在的锁(共享锁或排它锁)被释放。
  • 当事务先读后写一个对象,它可以将共享锁升级成排它锁,必须等到同一对象上的持有共享锁的其他事务完成。
  • 事务必须持锁直到提交或中止。

当事务卡在等待另一个事务释放锁时,很容易发生死锁。数据库要检测到事务之间的死锁,并中止其中一个来让另一个可以继续下去。错误将被返回,由应用程序重试被中止的事务。

为了防止幻读和 Write Skew,使用两阶段锁的数据库用索引范围锁,类似于共享锁或排它锁,但应用于一系列索引而非单个对象。

两阶段锁在事务吞吐量和响应时间上远不如弱隔离。这是由于获取和释放锁有开销,还有并发度的降低。