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

数据库的本质就是提供数据的读写能力。然而,细节是魔鬼。这后面隐藏着各种细节需要处理。数据库执行期间会遇到各种问题「机器断电、数据库崩溃、应用服务链接中断、多个客户端同时写数据库造成数据覆盖」。事务「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.解锁阶段,逐步释放锁,释放期间不能再加锁。为了解决丢失更新、写入偏差问题,分别使用独占锁、共享锁机制和谓词锁、索引范围锁来控制事务的执行顺序。

  • 可串行化快照

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

相关推荐
NiNg_1_2343 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
Chrikk4 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*4 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue4 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man4 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
Ai 编码助手5 小时前
MySQL中distinct与group by之间的性能进行比较
数据库·mysql
陈燚_重生之又为程序员6 小时前
基于梧桐数据库的实时数据分析解决方案
数据库·数据挖掘·数据分析
caridle6 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
白云如幻6 小时前
MySQL排序查询
数据库·mysql
萧鼎6 小时前
Python并发编程库:Asyncio的异步编程实战
开发语言·数据库·python·异步