关于事务,你不得不知道的一些细节

数据库的本质就是提供数据的读写能力。然而,细节是魔鬼。这后面隐藏着各种细节需要处理。数据库执行期间会遇到各种问题「机器断电、数据库崩溃、应用服务链接中断、多个客户端同时写数据库造成数据覆盖」。事务「transcation」一直是简化这些问题的首选机制。事务是应用程序将多个读写操作合并成一个逻辑单元的一种方式。事务将多个操作视为一个逻辑单元来执行,整个事务要么成功提交「commit」,要么中止「abor」或回滚「rollback」。如果事务执行失败,可以直接进行重试。

事务的引入,可以让应用程序忽略多个操作部分成功,部分失败的逻辑,简化了错误处理逻辑。然而,复杂度并不会减少,只会转移到数据库的实现上。事务的引入会让数据库的实现复杂度升高,性能降低,有些数据库「部分非关系数据库」甚至选择牺牲事务来保证极致的性能。

在本章将介绍事务的基本概念,提供的安全保证。并讨论一下事务提供的不同隔离级别的定义、遇到的问题、实现方式等。

ACID 的含义

提起事务,大家第一时间想到的就是 ACID,这是事务提供安全保证特性的缩写。ACID 代表原子性「Atomicity」、一致性「Consistency」、隔离性「Isolation」、持久性「Durability」。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出,旨在为数据库中的容错机制建立精确的术语。

原子性「Atomicity」

一般来说,原子是指不能再进行分割的最小单元。在并发编程中,经常会提到原子操作。是指其他线程看不到这个操作的中间状态,只能看到原子操作执行前或执行后的状态。

事务中的原子性和并发操作中的原子操作并没有任何关系,隔离性会用来说明并发场景下不同事务的执行情况。ACID 的原子性是指:能够在错误时终止事务,丢弃该事务进行的所有写入变更的能力。

例如:一个客户端执行转账操作,会先从账户 A 执行扣款操作,再从账户 B 执行增款操作。在这两个操作执行期间会发生各种故障而导致转账失败。如果这些操作被分配到一个事务中,数据库会保证这两个操作要么全部成功,要么全部失败。

一致性「Consistency」

在数据库复制的章节中,介绍了最终一致性,即在操作完成一段时间后,不同节点最终会达到数据一致的状态。在 ACID 中,一致性是指:对数据的一组特定约束必须始终成立。例如,外键约束、唯一约束、主键自增等。

隔离性「Isolation」

为了提升数据库的读写性能,可以将多个事务并行运行。如果不同的事务读写不同的数据那是没有问题的。然而,如果多个事务读写相同的数据,则会遇到并发问题。即:多个事务读写操作执行顺序的不确定性,导致读到的数据不符合预期。

下图是一个增加计数的例子。user1 和 user2 同时分别执行 +1 操作,由于并发操作而导致数据实际上只增加了 1「42->43」。

ACID 中的隔离性:同时执行的事务是相互隔离的,不同事务互不干扰。最简单的形式是可串行化,即每个事务是串行运行的。如上图的例子中,user1 执行完成之后,user2 才开始执行。

在实践中,很少会有数据库按照可串行化的方式执行。它的缺点是执行效率太差,会导致大量的事务排队,导致数据库负载升高。因此,衍生出各种隔离级别「读未提交,读以提交,可重复读,可串行化」,在性能和隔离性之间相互妥协,从而保证数据库的吞吐。

持久性「Durability」

数据的目的是提供一个地方存储数据。持久性是数据库提供的一个保障,即一旦事务执行成功,即使数据库发生崩溃、机器断电等,写入数据也不会丢失。

在单节点数据库中,持久性保证意味着数据已经被写入非易失性存储设备,如硬盘或 SSD。在非易失性存储设备中,如果发生故障时,数据库可以重新加载出完整数据。但如果非易失性存储设备发生故障时,则无法保证持久性。在带复制的数据库中,持久性保证意味着数据被复制到一些节点中。为了保证持久性,数据库需等到数据被复制到其他节点,才能报告事务写入成功。

处理错误和中止

事务的一个关键特性是当错误发生时,可以中止操作并进行安全的重试。使用事务可以简化应用程序对于错误的处理,但它并不完美:

  • 如果事务实际上执行成功了,但是在向客户端发送提交成功的消息时发生网络故障,那么重试事务将会导致事务执行两次。当然,可以使用一个应用级别的去重极致来避免这个问题。
  • 如果出错是因为负载过高导致的,那重复提交事务将会让问题变得更糟。可以限制重复次数,使用指数退避算法,并单独处理与过载相关的错误。
  • 如果事务在数据库外有副作用,即事务中止,也可能发生副作用。例如,向用户发送一个邮件,并在数据库中记录。如果事务重复执行将会导致重复发送邮件。如果想避免这一问题,就需要事务中的多个系统一起提交或放弃,这就涉及到分布式事务了。
  • 对于临时性的错误,重试是可以解决问题的。如果是永久性错误,如违反数据库约束,那重试将没有任何意义。

隔离级别

如果多个事务之间处理的数据并不重合,那它们可以并行的执行,不会造成任何问题。当一个事务读取另一个事务修改的数据,或两个事务修改同一个数据时,将会产生并发问题。需要使用对应的同步策略来解决这一问题。

最简单的策略就是让有并发的事务串行执行。然而,可串行化带来的性能开销是很多数据库不能容忍的。因此,数据库会采用更弱的隔离级别来保证其性能。

读未提交

最简单的隔离级别就是读未提交,即事务可以读到其他事务未提交的数据。这种方式的优势在实现简单,无需过多的并发控制,具有极高的读写性能。最大的问题也是可以读到其他事务未提交的数据「脏读」,如果其他事务进行了回滚,从而导致异常的数据逻辑。脏读会导致一下问题:

  • 如果事务涉及多条记录,脏读意味着一个事务可能只看到一部分更新。如下图所示,user 1 向 user 2 发送了一个邮件,为了保证查询效率,会用 emails 表存储收件信息,mailboxes 表存储未读数量。在读未提交的模式下,user2 看到自己有新邮件,但是未读邮件却是 0。
  • 如果事务回滚,则所有写入的操作都需要回滚。如果允许脏读,就意味着一个事务读取到另一个事务并未提交的数据。

另一个需要考虑的问题是脏写:即一个事务可以直接覆盖另一个事务未提交的写入。目前,所有的隔离级别均避免了脏写。即如果多个事务同时更新一条记录,则事务等待另一个事务提交或回滚之后再执行。

读以提交

在读以提交的隔离级别中可以解决脏读问题。即保证事务只能读到其他事务已经提交的数据。如下图所示,user 1 在一个事务中更新了 x 和 y。user2 只能在 user1 提交之后才能读到最新的值。

读以提交是一个非常流行的隔离级别。这是 Oracle 11g、PostgreSQL、SQL Server 2012、MemSQL 和其他许多数据库的默认设置。

在数据库的实现中,如何解决脏读和脏写的问题呢?对于这种读写操作场景,最容易想到的就是读写锁。当一个事务读取数据行时申请读锁,如果是更新数据行则申请写锁,可以将读锁升级为写锁。然而,在具体应用中却没有数据库使用这种方案。在现实场景下,往往会有一些事务长时间占用锁而导致系统负载升高。

解决脏写问题使用行锁,即锁定数据行或文档。任意时刻只能有一个事务持有某个对象的行锁,如果其他事务想要更新数据,需要等待上个事务提交或中止,然后释放行锁。

针对脏读问题,则在数据行中存储两个值。一个是最新的已提交的值,所有事务均读取这个值,且不用加锁。此外,还需存储已经更新但未提交的值,如果事务提交则更新已提交值。通过使用额外的空间来避免使用读锁。

可重复读

在大多数场景中,读以提交似乎已经可以解决它们的问题。但是,在此隔离场景下仍然会产生一些并发问题。如下图所示:Alice 有两个账户,各有 500。现在有两个事务,一个是查看两个账户的余额,一个是从 account1 转账 100 到 account2。读取 account 1 时转账事务未开始,看到了 account1: 500 元,读取 account2 时转账事务已经提交,看到了 account2: 400。account1 + account2 < 1000,从而造成困惑。

针对这种场景可以使用可重复读/快照隔离的隔离级别。它可以保证同一个事务中,读取到的数据不会发生变更。即每个事务从自己的快照中读取数据。

为了实现可重复读,需要保留一个对象的几个不同的版本,从而保证各个事务可以看到数据库在不同时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为多版本并发控制「MVCC,multi-version concurreny control」。

如果数据库只需要提供读已提交的隔离级别,那只需要保留一个对象的两个版本就足够了:已提交的最新版本和被覆盖但未提交的版本。在具体实现的时候,也可以将读已提交看作可重复读的一种特例。可重复读时为每个事务创建一个快照,而读已提交为每次查询创建一个快照。

下图展示的是 PG 数据库的 MVCC 实现。当一个事务开始时,它会被赋予一个唯一的,永远增长的事务 ID。每当事务向数据库写入任何内容时,所写入的数据均会被标记写入者的事务 ID。后台任务对异步的对事务 ID 进行清理,释放空间。

在下面的例子中, Account 2 向 Account 1 转账 100 元。txid: 12 是一个读事务,分别读取 Account 1 和 Account 2 的余额;txid: 13 是一个转账事务,负责转账。Account 1 增加 100 元时,会新增一个版本「created by = 13、deleted by = nil」,同时更新旧版本的 deleted by = 13。created by 标识这个记录是哪个事务写入的,deleted by 是指这个记录是哪个事务删除的。读取 account 2 的数据时,读取 txid:12 创建时提交的数据,即 500。

丢失更新

无论是可重复读还是读已提交,主要保证了只读事务在并发写入时可以看到什么。然后,并发写入还会有其他的问题,最常见的就是丢失更新。例如下面的计数器的例子。

如果应用程序读取了一些值,修改它并写回修改值「读取-修改-写入」,则会发生丢失更新问题。两个事务同时执行,则其中的一个的修改可能会丢失,因此另一个事务修改的内容并没有包含次事务的修改。常见场景:

  • 例子中的增加计数器
  • 两个用户同时编辑一份数据,每个用户均将编辑后的页面发送到数据库,可能会丢失其中一个用户更新。

针对丢失更新问题,也有相关的解决方案:

  • 可串行化

本质上还是由于「读取-修改-写入」并非原子操作,并发执行出现数据异常。一次可以使用可串行化的隔离级别,让其顺序执行。

  • 原子写

在并发问题中,首先想到的就是原子操作。在数据中也不例外,本质到就是并发场景下「读取-更新-修改」操作的乱序导致的数据异常。如果能保证这三个操作时原子执行「任何时刻只有一个事务在执行」的就可以解决问题。例如,对于计数器,可以使用下面的原子写。value 总会基于最新值进行 +1 操作,且是原子的。

ini 复制代码
UPDATE counters SET value = value + 1 WHERE key = 'foo';
  • 显式锁定

对于某些复杂的场景,无法使用原子的操作来处理,则需要显式的锁定要更新的对象,确保操作按照顺序执行。

例如,上面的计数器例子,我们可以显示锁定行,来确保不同事务按顺序执行「读取-修改-写入」操作。在下面的 sql 中,使用for update 子句告诉数据库增加行锁。只有当前事务提交或中止之后才会释放行锁。

sql 复制代码
select counter from c where id = 1 for update
update counter set counter = ? where id = 1
  • 业务层加锁

另一种解决方案是在应用程序控制事务的执行顺序,从而保证事务的顺序执行。

  • 自动检测

原子操作和锁是通过强制「读取 - 修改 - 写入」按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其「读取 - 修改 - 写入」序列。

这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测丢失更新。一些作者认为,数据库必须能防止丢失更新才称得上是提供了 快照隔离,所以在这个定义下,MySQL 下不提供快照隔离。

丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。

  • 比较设置

只有当前值和上次读取时一直未改变,才允许更新发生。如果当前值与之前值不配置则意味着有其他事务执行了更新操作。类似于乐观锁,先直接执行,提交时判断是否有冲突。

例如,为了防止两个人同时更新同一份数据而造成数据丢失,可以使用以下 SQL:

ini 复制代码
-- 根据数据库的实现情况,这可能安全也可能不安全
UPDATE wiki_pages SET content = '新内容'
  WHERE id = 1234 AND content = '旧内容';

使用这种方法需要额外注意 ABA 问题。即另一个事务将值从 A 改为 B 又改为 A。当前事务读取到的是 A,执行时可以执行成功。但其他事务其实已经修改了一次值。可以考虑对此方法进行变种,增加一列版本号或者时间戳,来验证是否有其他事务更新了数据。

写入偏差与幻读

丢失更新指的是多个事务同时读取相同的记录,并作出相应的更改并回写数据。从而导致一个事务的更改「没有包含其他事务的更改」覆盖上一个事务的更改。下面是一个与丢失更新并不相同的例子,两个事务没有修改相同的数据,却导致业务异常。

下面是一个值班系统,需要保证每个 shift_id 至少有一个医生值班。当 Alice 和 Bob 申请休假,同时执行两个事务时,判断条件均成立,都进入休假状态,但 shitf_id:1234 却没有人值班。

这种异常行为称为写入偏差。下面是一些写入偏差常见的例子:

  • 会议室预定系统。提前检查会议是否空闲,空闲则预定。并发场景下,两个事务同时预定,会导致结果异常。
sql 复制代码
BEGIN TRANSACTION;

-- 检查所有现存的与 12:00~13:00 重叠的预定
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
  end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';

-- 如果之前的查询返回 0
INSERT INTO bookings(room_id, start_time, end_time, user_id)
  VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);

COMMIT;
  • 抢注用户名。在系统中的用户名必须是唯一的。最常见的解决方案就是使用唯一约束。
  • 防止双重开支。允许用户花钱或使用积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值。在写入偏差场景下,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。

其实观察上面的例子,写入偏差是有一些共性存在,都遵循类似的模式:

  1. 一个 select 查询出符合条件的行。
  2. 按照 1 的查询结果,应用代码决定是否继续。
  3. 如果应用程序决定继续操作,就执行写入「插入、更新或删除」,并提交事务。

3 中的写入改变了 2 的先决条件。也就是提交写入后,重复执行 1 的查询会得到不同的结果。从而导致业务异常。

一个事务中的写入改变另一个事务的搜索查询结果,被称为幻读。可重复读避免了只读查询中的幻读,但是对于上面的例子,幻读会导致棘手的写入偏差情况。可以考虑使用for update 子句对查询结果进行加锁,强制其顺序执行。

串行化

在上面介绍了事务的隔离级别,读未提交、读以提交、可重复读,其隔离级别越来越严格。依次解决了脏写、脏读、不可重复读的问题,其实现难度也越来越复杂。在可重复读中,为了解决写入偏差、更新丢失、幻读等问题,甚至考虑使用原子操作、显式锁定、比较设置等方法。这一切的工作都是要尽可能的避免串行化,来尽可能提升数据库负载。

但某些场景下将不得不考虑串行化,下面将讨论串行化实现的一些方法:

  • 字面意义上串行顺序执行事务。
  • 两阶段锁定「2PL,two-phase locking」,主流做法。
  • 乐观并发锁控制技术,例如可串行化快照隔离。

真正的串行

避免并发最直接的解决办法就是没有并发:在单个线程上依次执行每个事务。这样就不用处理重冲突检测、原子操作、锁机制,从而做到真正意义上串行化。

例如:在 Redis 中就是使用单线程处理数据的读取和更新。单线程能够高效的运行其实还是依赖于 RAM 的快速操作,以及数据操作本身瓶颈不在于 CPU。使用单线程还可以避免锁的协调开销。

单线程顺序执行事务可以保证事务的串行执行。但为了利用多核的能力,可以与分区技术相结合。每个分区对应一个单线程串行执行事务,来提升数据库吞吐。当然,使用分区需要保证每次事务只处理分区内的数据,而不涉及到其他分区的数据。否则就涉及到跨分区的事务处理,增加了技术复杂度。

两阶段锁定

两阶段锁是一种广泛使用的串行化算法,用于 Mysql 和 SQL Server 中。与真正的串行不同的是,两阶段锁定仍然是并行执行,只是对于有冲突的事务会保证其串行执行。

它的基本思想是将事务的执行过程分为两个阶段:加锁阶段和解锁阶段。

  1. 加锁阶段(Growing Phase) :在这个阶段,事务可以获取需要的锁,但不能释放任何锁。这意味着事务开始时可以逐步获取更多的锁,以便访问需要的资源。这个阶段会持续到事务找到了它所需的所有锁位置。
  2. 解锁阶段(Shrinking Phase) :一旦事务释放了其第一个锁,它就进入了解锁阶段。在这个阶段,事务不能再请求新的锁,只能逐步释放已经获取的锁。当事务释放了所有的锁,这个阶段就结束了。

两阶段锁协议的名称来源于这两个明确的阶段。在整个事务中,先是加锁阶段,然后是解锁阶段。

两阶段锁核心要解决的问题就是更新丢失「并行执行的两个事务中,一个事务覆盖了另一个事务的写且未携带上个事务的变更」、写入偏差「两个事务更改了不同的值,但是导致事务的先前依赖的查询结果变更」的问题。为了解决这些问题,则需要用到共享锁、独占锁、谓词锁、索引范围锁,根据不同 SQL 会选择获取不同锁。

在丢失更新场景中,其本质原因是对于同一条记录,不同事务并发执行「读取-修改-写入」而导致数据异常。针对这种问题可以通过对对象加行锁来实现。锁分为共享锁或独占锁「本质上就是一个读写锁」:

  • 当事务要读取对象时,申请共享锁。允许多个事务同时持有共享锁。如果有一个事务已经获取了或正在申请独占锁,则事务必须等待。
  • 当事务要修改对象时,申请独占锁。没有其他事务可以同时持有锁「无论是共享锁还是独占锁」,如果对象上有任何锁,都需要等待。
  • 如果对象先读取在写入,则可能会将共享锁升级为独占锁。升级锁的工作和直接获取独占锁相同。
  • 事务获得锁之后,必须继续持有锁直到事务结束「提交或回滚」。

此外,在可串行化中还需要解决的一个问题就是写入偏差问题。使用共享锁和独占锁可以处理现存对象的并发读写问题,但是对于未插入的数据却无法处理。

以预定会议室为例子,会先检查时间是否空闲,然后预定会议室。然而,并发场景下会导致重复预定问题。

sql 复制代码
SELECT * FROM bookings
WHERE room_id = 123 AND
      end_time > '2018-01-01 12:00' AND
      start_time < '2018-01-01 13:00';

常见的解决方式就是使用谓词锁。其流程如下:

  • 如果事务 A 想要读取匹配某些条件的对象,如上面的 SELECT,则需要先获得查询条件上的共享谓词锁。如果另一个事务 B 持有任何满足这一查询的的独占锁,则 A 需要等待 B 释放时候才能继续查询。
  • 如果事务 A 想要插入、更新或删除任何对象,则先检查变更值是否与现有的任何谓词锁匹配。如果事务 B 持有匹配的锁,那么 A 必须等待 B 释放锁。

不幸的是谓词锁的性能不是那么理想:如果活跃事务持有很多谓词锁,检查配置锁将会非常耗时。因此,大多数使用 2PL 的数据库实际上使用的是索引范围锁,这是一个简化版本的近似版谓词锁。

以上面的房间预定为例子,你可能会在 room_id 或者 start_time 和 end_time 上有索引:

  • 在 room_id 上有索引,使用索引查询 room_id = 123 的现有预定。数据库可以简单的将共享锁附加到这个索引上,标识事务以搜索 123 用于预定。
  • 如果在时间上有索引,则将共享锁增到索引中的一系列值。

当另一个事务插入、删除或更改预定时,则不得不更新索引。这样就会遇到共享锁,将被迫等待共享锁的释放。

索引范围锁的优势在于利用索引提升了加锁的速度,但是不像谓词锁那样能够精确的配置查询条件,可能会导致锁定更大范围的对象。如果没有合适的索引范围锁进行挂载,数据库可能会退化到整个表加上共享锁。

可串行化快照隔离

在两阶段锁中,是以一种悲观锁的方式来运行。即假定冲突一定会发生,则先获取锁,保证可以安全执行之后再执行。与之对应的是乐观锁,即假定冲突不一定会发生,先执行事务,最后提交时判断是否发生冲突,用来决定提交还是回滚。

一个称为可串行化快照隔离(SSI, serializable snapshot isolation) 的算法是非常有前途的。它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI 是相当新的:它在 2008 年首次被描述,并且是 Michael Cahill 的博士论文的主题。

故名思义,SSI 基于快照隔离,事务中的所有读取都是来自数据库的一致性快照。在此基础上,SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止那些事务。

基于过时前提的决策

可重复读带来的问题在于一个事务中的操作依赖查询的结果,而另一事务的变更会导致当前事务的查询结果发生变更,从而导致的数据异常。可以得知,只要其他事务不影响当前事务的查询结果,即保证了可串行化。

数据库如何知道查询结果已经发生变更了呢?有两种情况需要考虑:

  • 检测对就 MVCC 对象版本的读取「读之前存在未提交的写入」
  • 检测影响先前读取的写入「读之后发生写入」

检测对就 MVCC 对象版本的读取

仍以医院的排班系统为例。如果其他事务不影响「科室:1234 值班人数大于 2」这一查询结果,可以直接执行。如下图所示,事务 43 在事务 42 发生数据更新后执行了查询,当 43 提交的时候,42 已经先提交,从而导致 43 的查询结果发生变更,因此终止 43。

检测影响先前读取的写入

第二种情况就是事务 43 在 42 更新数据之前发生了读取。当 42 提交时,通知 42 数据发生变更,提交时发生终止。

总结

事务概念的引入有效的简化应用程序的错误处理。基于数据库提供的事务保证,可以有效的忽略数据库断电,崩溃,磁盘写满等意外问题。

事务是应用程序将多个读写操作看作一个逻辑处理单元的方式。事务本身提供了 ACID 的保证。

  • 原子性:事务要么提交成功,全部执行,要么回滚,丢失之间的写入操作。
  • 一致性:写入的数据永远满足数据库的约束,否则写入失败。
  • 隔离性:不同事务之间的执行是相互隔离的,避免相互影响。
  • 持久性:事务提交成功意味着数据写入了非易失性存储设备,数据库重启可以恢复数据。

理想的情况下,不同事务并发执行时可以互不影响,同时执行。然而,如果多个并发事务执行期间操作了下同的数据,则会产生冲突。当然,你可以选择串行化的方式执行事务。但现实中往往不能容忍串行化带来的性能损失。因此,定义了事务的隔离级别,隔离级别越弱,意味着实现简单,性能开销低。与此相反,低的隔离级别也意味着会产生不同的的问题。

  • 读未提交:

即事务可以读取其他事务未提交的数据「脏读」。如果其他事务进行了回滚,则意味着读取了一个不存的值,处理起来麻烦。

  • 读以提交:

事务只能读取到其他事务提交的数据,可以有效的解决脏读。但其会带来额外的问题,即同一个查询语句,不同时间执行会看到不同的结果「不可重复读」。例如,转账例子中,读取到的总额发生丢失。

在具体实现的时候通过给对象加锁来避免产生脏写「一个事务覆盖另一个事务未提交的写」。为了保证查询性能,同一个对象往往会存在两个版本,一个是最新提交的版本,一个是被覆盖但未提交的版本。

  • 可重复读/快照隔离

见名知义。这一隔离级别有效的解决了不可重复读的问题。具体实现时通常使用 MVCC 技术。即为每个对象保留多个版本。在 PG 的实现中,会额外增加 created_by 和 delted_by 字段,用于标识数据是哪个事务 ID 操作的。读取时,利用事务 ID 来保证可重复读。

可重复读似乎已经解决了需要并发问题。但仍然有一些问题无法解决。最常见的就是丢失更新「事务中,涉及读取-更新-写入操作,多个事务同时执行时,事务相互覆盖且未携带其他事务的更新」、写入偏差「一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再成立」。常见的解决方案有:

  • 可串行化:直接使用可串行化隔离级别,保证顺序执行。
  • 原子写:利用数据库本身的原子操作。
  • 数据约束:利用数据库的约束条件保证写入正确。例如,对 name 增加唯一索引。
  • 显式加锁:利用for update子句,给查询结果增加锁。
  • 应用层加锁:应用层控制事务的并发执行。
  • 比较设置:比较数据值是否发生变更,来进行更新。例如:update v = new_value where v = old_value and id = 1。但需要注意 ABA 问题。
  • 冲突检测:数据提供冲突检测的能力。
  • 串行化

如果使用其他方案也不能解决并发的问题,那只能使用串行化,来保证事务串行执行了。其实现方法大概分为几类:

  • 真正的串行:

    • 即使用单线程来执行事务,绝对的串行。为了发挥多核的优势,可以与分区相结合。
  • 两阶段锁:

整个执行过程中分为两个阶段,因此叫两阶段锁。1. 加锁阶段,获取需要的锁,但不释放锁。2.解锁阶段,逐步释放锁,释放期间不能再加锁。为了解决丢失更新、写入偏差问题,分别使用独占锁、共享锁机制和谓词锁、索引范围锁来控制事务的执行顺序。

  • 可串行化快照

这是一个较新的算法,是依赖于快照实现的。在此算法中,它假设所有的事务都没有冲突产生,先执行操作,事务提交时再进行具体的检查,如果有冲突直接中止。

相关推荐
小蜗牛慢慢爬行29 分钟前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
hanbarger32 分钟前
nosql,Redis,minio,elasticsearch
数据库·redis·nosql
微服务 spring cloud1 小时前
配置PostgreSQL用于集成测试的步骤
数据库·postgresql·集成测试
先睡1 小时前
MySQL的架构设计和设计模式
数据库·mysql·设计模式
弗罗里达老大爷1 小时前
Redis
数据库·redis·缓存
wm10431 小时前
java web springboot
java·spring boot·后端
仰望大佬0072 小时前
Avalonia实例实战五:Carousel自动轮播图
数据库·microsoft·c#
学不透java不改名2 小时前
sqlalchemy连接dm8 get_columns BIGINT VARCHAR字段不显示
数据库
一只路过的猫咪2 小时前
thinkphp6使用MongoDB多个数据,聚合查询的坑
数据库·mongodb