分享到

简介

事务是一种将多个语句封装成数据库可处理的单个操作的机制。数据库能够将这组命令作为一个内聚单元进行解释和执行,而不是逐个输入单个语句。这有助于确保在许多密切相关的语句执行过程中数据集的一致性。

在本指南中,我们将首先讨论什么是事务以及它们为何有益。之后,我们将探讨 PostgreSQL 如何实现事务以及在使用事务时可用的各种选项。

什么是事务?

事务是一种将多个语句组合并隔离起来,作为单个操作进行处理的方式。在事务中,命令被打包在一起并在与其他请求分离的上下文中执行,而不是在命令发送到服务器时立即单独执行每个命令。

隔离是事务的重要组成部分。在事务内部,执行的语句只能影响事务本身的环境。从事务内部看,语句可以修改数据,并且结果立即可见。从外部看,在事务提交之前,不会进行任何更改,而一旦提交,事务中的所有操作将同时可见。

这些特性通过提供原子性(事务中的操作要么全部提交,要么全部回滚)和隔离性(在事务外部,直到提交才发生任何变化;而在事务内部,语句会产生后果)帮助数据库实现ACID 合规性。这些特性共同帮助数据库维护一致性(通过保证不会发生部分数据转换)。此外,事务中的更改只有在提交到非易失性存储后才会被报告为成功,这提供了持久性

为了实现这些目标,事务采用了多种不同的策略,不同的数据库系统使用不同的方法。PostgreSQL 使用一种称为多版本并发控制(MVCC)的系统,它允许数据库在不进行不必要锁定的情况下,使用数据快照执行这些操作。所有这些系统共同构成了现代关系型数据库的基本组成部分之一,使其能够以抗崩溃的方式安全地处理复杂数据。

一致性失败的类型

人们使用事务的原因之一是为了获得对其数据及其处理环境的一致性保证。一致性可以通过多种不同方式被破坏,这会影响数据库如何尝试阻止它们。

根据事务的实现方式,不一致性主要有四种产生方式。您对这些情况可能出现的场景的容忍度将影响您在应用程序中如何使用事务。

脏读

脏读发生在事务中的语句能够读取其他正在进行中的事务写入的数据时。这意味着即使事务的语句尚未提交,它们也可以被读取并因此影响其他事务。

这通常被认为是严重破坏一致性,因为事务之间没有正确隔离。可能永远不会提交到数据库的语句可以影响其他事务的执行,从而修改它们的行为。

允许脏读的事务无法对其结果数据的一致性做出任何合理的声明。

不可重复读

不可重复读发生在事务外部的提交更改了事务内部可见的数据时。如果在一个事务中,同一数据被读取两次,但每次检索到的值不同,则可以识别出这类问题。

与脏读一样,允许不可重复读的事务不提供事务间的完全隔离。区别在于,对于不可重复读,影响事务的语句实际上已在事务外部提交。

幻读

幻读是一种特殊类型的不可重复读,发生在事务中查询返回的行在第二次执行时不同。

例如,如果事务内的查询在第一次执行时返回四行,但在第二次执行时返回五行,这就是幻读。幻读是由事务外部的提交更改了满足查询的行数引起的。

序列化异常

序列化异常发生在多个并发提交的事务导致的结果与它们一个接一个地提交的结果不同时。当事务允许两次提交同时修改同一张表或数据而未解决冲突时,就可能发生这种情况。

序列化异常是一种特殊类型的问题,早期的事务对此毫无概念。这是因为早期的事务是使用锁来实现的,如果另一个事务正在读取或修改相同的数据,则无法继续。

事务隔离级别

事务并非“一刀切”的解决方案。不同的场景需要在性能和保护之间进行不同的权衡。幸运的是,PostgreSQL 允许您指定所需的事务隔离类型。

大多数数据库系统提供的隔离级别包括:

读未提交

读未提交是提供最少数据一致性和隔离性保证的隔离级别。虽然使用 read uncommitted 的事务具有一些与事务相关的常见特性,例如一次提交多个语句或在出现错误时回滚语句的能力,但它们确实允许出现多种可能破坏一致性的情况。

使用 read uncommitted 隔离级别配置的事务允许:

  • 脏读
  • 不可重复读
  • 幻读
  • 序列化异常

PostgreSQL 实际上并未实现此隔离级别。尽管 PostgreSQL 识别此隔离级别名称,但在内部它并不实际支持,而是将使用“读已提交”(下文描述)。

读已提交

读已提交是一种专门防止脏读的隔离级别。当事务使用 read committed 一致性级别时,未提交的数据永远不会影响事务的内部上下文。这通过确保未提交数据永远不会影响事务来提供基本的一致性级别。

尽管 read committed 提供了比 read uncommitted 更强的保护,但它并不能防止所有类型的不一致。以下问题仍然可能发生:

  • 不可重复读
  • 幻读
  • 序列化异常

如果未指定其他隔离级别,PostgreSQL 默认将使用 read committed 级别。

可重复读

可重复读隔离级别建立在 read committed 提供的保证之上。它像以前一样避免脏读,但同时阻止不可重复读。

这意味着事务外部提交的任何更改都不会影响事务内部读取的数据。除非由事务内的语句直接引起,否则在事务开始时执行的查询在事务结束时不会有不同的结果。

尽管 repeatable read 隔离级别的标准定义仅要求防止脏读和不可重复读,但 PostgreSQL 在此级别也防止幻读。这意味着事务外部的提交不能改变满足查询的行数。

由于事务内部可见的数据状态可能与数据库中最新的数据有所偏差,如果两个数据集无法调和,事务在提交时可能会失败。因此,这种隔离级别的一个缺点是,如果提交时发生序列化失败,您可能需要重试事务。

PostgreSQL 的 repeatable read 隔离级别阻止了大多数类型的一致性问题,但序列化异常仍然可能发生。

可串行化

可串行化隔离级别提供最高级别的隔离和一致性。它阻止了 repeatable read 级别所能阻止的所有场景,同时消除了序列化异常的可能性。

可串行化隔离保证并发事务的提交如同它们被一个接一个地执行一样。如果发生可能引入序列化异常的情况,其中一个事务将发生序列化失败,而不是向数据集引入不一致。

定义事务

现在我们已经介绍了 PostgreSQL 可以在事务中使用的不同隔离级别,接下来我们演示如何定义事务。

在 PostgreSQL 中,显式标记事务块外部的每个语句实际上都在其自身的单语句事务中执行。要显式启动一个事务块,您可以使用 BEGINSTART TRANSACTION 命令(它们是同义词)。要提交事务,请发出 COMMIT 命令。

因此,事务的基本语法如下:

BEGIN;
statements
COMMIT;

作为一个更具体的例子,假设我们正在尝试将 1000 美元从一个账户转移到另一个账户。我们希望确保这笔钱始终在两个账户中的一个,但绝不同时存在于两者之中。

我们可以将封装此转账的两个语句包装在一个事务中,如下所示:

BEGIN;
UPDATE accounts
SET balance = balance - 1000
WHERE id = 1;
UPDATE accounts
SET balance = balance + 1000
WHERE id = 2;
COMMIT;

这里,1000 美元不会在不将 1000 美元转入 id = 2 的账户的情况下从 id = 1 的账户中取出。虽然这两个语句在事务内部是按顺序执行的,但它们将同时提交,从而在底层数据集上同时执行。

回滚事务

在一个事务中,所有语句都将提交到数据库,或者都不提交。放弃事务中进行的语句和修改,而不是将其应用于数据库,这被称为“回滚”事务。

事务可以自动或手动回滚。如果事务中的某个语句导致错误,PostgreSQL 会自动回滚事务。如果所选隔离级别不允许序列化错误发生,它也会回滚事务。

要手动回滚在当前事务中给出的语句,您可以使用 ROLLBACK 命令。这将取消事务中的所有语句,本质上是将时间倒回到事务开始时。

例如,假设我们仍然使用之前的银行账户示例,如果我们发现执行 UPDATE 语句后意外转错了金额或使用了错误的账户,我们可以回滚更改而不是提交它们:

BEGIN;
UPDATE accounts
SET balance = balance - 1500
WHERE id = 1;
UPDATE accounts
SET balance = balance + 1500
WHERE id = 3; -- Wrong account number here! Must rollback
/* Gets us back to where we were before the transaction started */
ROLLBACK;

一旦我们执行 ROLLBACK,1500 美元将仍然在 id = 1 的账户中。

回滚时使用保存点

默认情况下,ROLLBACK 命令将事务重置为首次调用 BEGINSTART TRANSACTION 命令时的状态。但是,如果我们只想恢复事务中的部分语句怎么办?

虽然在发出 ROLLBACK 命令时不能指定任意回滚点,但您可以回滚到在事务过程中设置的任何“保存点”。您可以使用 SAVEPOINT 命令预先标记事务中的位置,然后在需要回滚时引用这些特定位置。

这些保存点允许您创建中间回滚点。然后,您可以选择性地恢复当前位置和保存点之间所做的任何语句,然后继续执行您的事务。

要指定保存点,请发出 SAVEPOINT 命令,后跟保存点的名称:

SAVEPOINT save_1;

要回滚到该保存点,请使用 ROLLBACK TO 命令:

ROLLBACK TO save_1;

让我们继续我们一直在使用的以账户为中心的例子:

BEGIN;
UPDATE accounts
SET balance = balance - 1500
WHERE id = 1;
/* Set a save point that we can return to */
SAVEPOINT save_1;
UPDATE accounts
SET balance = balance + 1500
WHERE id = 3; -- Wrong account number here! We can rollback to the save point though!
/* Gets us back to the state of the transaction at `save_1` */
ROLLBACK TO save_1;
/* Continue the transaction with the correct account number */
UPDATE accounts
SET balance = balance + 1500
WHERE id = 4;
COMMIT;

在这里,我们能够在不丢失迄今为止在事务中完成的所有工作的情况下,从我们所犯的错误中恢复。回滚后,我们按照计划使用正确的语句继续事务。

设置事务的隔离级别

要设置事务的隔离级别,您可以在 START TRANSACTIONBEGIN 命令中添加 ISOLATION LEVEL 子句。基本语法如下:

BEGIN ISOLATION LEVEL <isolation_level>;
statements
COMMIT;

<isolation_level> 可以是以下任何一种(前面已详细描述):

  • READ UNCOMMITTED(将导致 READ COMMITTED,因为 PostgreSQL 未实现此级别)
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

SET TRANSACTION 命令也可以用于在事务开始后设置隔离级别。然而,您只能在执行任何查询或数据修改命令之前使用 SET TRANSACTION,因此它并不能增加灵活性。

链式事务

如果您有多个需要按顺序执行的事务,您可以选择使用 COMMIT AND CHAIN 命令将它们链接起来。

COMMIT AND CHAIN 命令通过提交其中的语句来完成当前事务。在提交处理完成后,它会立即开启一个新事务。这允许您将另一组语句组合到一个事务中。

该语句的作用与您发出 COMMIT; BEGIN 完全相同:

BEGIN;
UPDATE accounts
SET balance = balance - 1500
WHERE id = 1;
UPDATE accounts
SET balance = balance + 1500
WHERE id = 2;
/* Commit the data and start a new transaction that will take into account the committed from the last transaction */
COMMIT AND CHAIN;
UPDATE accounts
SET balance = balance - 1000
WHERE id = 2;
UPDATE accounts
SET balance = balance + 1000
WHERE id = 3;
COMMIT;

链式事务在功能上并没有提供太多新东西,但它有助于在自然边界提交数据,同时继续专注于相同类型的操作。

总结

事务并非万能药。各种隔离级别都伴随着许多权衡,了解您需要保护的数据一致性类型需要深思熟虑和规划。对于长时间运行的事务尤其如此,因为底层数据可能会发生显著变化,并且与其他并发事务发生冲突的可能性会增加。

话虽如此,事务机制提供了极大的灵活性和强大的功能。它在很大程度上确保即使在执行相互关联的并发操作时也能保持 ACID 保证。知道何时以及如何正确使用事务来执行复杂且安全的操作是无价的。

关于作者
Justin Ellingwood

Justin Ellingwood

Justin 自 2013 年以来一直撰写关于数据库、Linux、基础设施和开发者工具的文章。他目前与妻子和两只兔子住在柏林。他通常不需要用第三人称写作,这让所有相关方都松了一口气。
© . All rights reserved.