数据库中的事务

原文地址:https://blog.fengqingmo.top/articles/137

前言

在一个苛刻的数据存储环境中,会有许多可能出错的情况,例如:

  • 数据库软件或硬件可能会随时失效(包括正在执行写操作的过程)
  • 应用程序可能随时崩溃 (包括一系列操作执行到中间某一步)。
  • 应用与数据库节点之间的链接可能随时会中断,数据库节点之间也存在同样问题。
  • 多个客户端可能同时写人数据库,导致数据覆盖。
  • 客户端可能读到一些无意义的、 部分更新的数据。
  • 客户端之间由于边界条件竞争所引入的各种奇怪问题。

​ 为了系统高可靠的目标,我们必须处理好上述问题,万一发生类似情况确保不会导致系统级的失效。然而, 完善的容错机制需要大量的工作,要仔细考虑各种可能出错的可能,并进行充分的测试才能确保方案切实可靠。

​ 事务是简化这些问题的首选机制。它将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元,即事务中的所有读写是一个执行的整体,整个事务要么成功(提交),要么失败(中止或回滚)。这样由于不需要担心部分失败的情况,应用层的错误处理就变得简单很多。

ACID的含义

事务所提供的安全保证即大家所熟知的 ACID,分别代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation) 与持久性(Durability)。

不符合 ACID标准的系统有时 被冠以 BASE,即基本可用性(Basically Available),软状态(Soft state)和最终一致性( Eventual consistency).

原子性

特征:在出错时中止事务,并将部分完成的写入全部丢弃

原子性其实描述了客户端发起一个包含多个写请求的操作可能发生的情况,例如

  • 在完成了一部分写入后,系统发生故障,包括进程崩溃,网络中断,磁盘变满或者违反了某种完整性约束;
  • 把多个写操作纳入到一个原子事务,万一出现了上述故障而导致没法完成最终提交时,事务会中止。

假如没有原子性保证,当多个更新操作中间发生了错误,就需要知道哪些更改已经生效,哪些没有生效,非常麻烦。

一致性

数据库处于应用程序所期待的 “预期状态”

A 与 B 互相转钱,不管怎么转,他们的总额应当是不变的。即任何数据更改必须满足某个状态约束(或者恒等条件)

这种一致性本质上要求 应用层 来维护状态一张(或者恒等),应用程序有责任正确地定义事务来保证一致性 (如转钱的操作必须开启事务)。

这不是数据库可以保证的事情,即如果提供的数据不正确,如 A转了80,A减少80,B增加40,数据库很难检测并阻止该操作。

Tip:

原子性、隔离性、持久性是数据库自身的属性,而ACID中的一致性更多是应用层的属性。应用程序可能借助数据库提供的原子性和隔离性,以达到一致性,

但一致性本身并不源于数据库。因此,字母 C其实并不应该属于 ACID

隔离性

大多数数据库都支持多个客户端同时访问。如果读取和写入的是不同数据,这肯定没什么问题。但如果访问相同的记录,则可能会遇到并发问题(即有竞争)

如图,假设有两个客户端同时增加数据库中的一个计数器。每个客户首先读取当前值,再客户端增加1,然后写回新值(这里假设数据库尚不支持自增操作)。图中, 由于有两次相加,计数器应该由42增加到44,但实际上由于竞争条件最终结果却是43。

图7-1 隔离性.drawio.png

ACID语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。它们不能互相交叉。经典的数据库教材把隔离定义为可审行化,这意味着可以假装它是发据库上运行的唯一事务。虽然实际上它们可能同时运行,但数据库系统要确保当事务提交时,其结果与串行执行(- 一个接一个执行)完全相同。

持久性

一旦事务提交成功,即便存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失

弱隔离级别

如果两个事务操作的是不同的数据,即不存在数据依赖关系,则它们可以安全地并行执行。只有出现某个事务修改数据而另一个事务同时要读取该数据,或者两个事务同时修改相同数据是,才会引发并发问题。

并发性相关的错误很难通过测试发现,这类错误通常只在某些特定时刻才会触发,且概率低,重现难。所有,数据库一直试图通过事务隔离来对应用开发者隐藏内别的各种并发问题。从理论上讲,隔离是假装没有发生并发,而可串行化意味着数据库保证事务的最终执行结果与串行(即一次一个,没有任何并发)执行结果相同。

可串行化隔离会严重影响性能,而许多数据库不愿意牺牲性能,因而更多倾向于较弱的隔离级别,它可以防止某些但并 全部的并发问题。

读-已提交

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

  1. 读数据库时,只能看到已经成功提交的数据 (防止 脏读
  2. 写数据库时,只会覆盖已经成功提交的数据(防止 脏写

防止脏读

假定某个事务已经完成部分数据写入,但事务尚未提交(或中止),此时另一个事务是否可以看到尚未提交的数据?如果是的话,那就是脏读。

如图所示,用户1设置了 x = 3,在用户1的事务未提交之前,用户2的 *** get x***操作依旧返回的旧值2。

图7-4 脏读.drawio.png

防止脏写

如果两个事务同时尝试更新相同的对象,会发生什么情况呢?我们不清楚写入的顺序,但可以想象后写的操作会覆盖较早的写入。

但是,如果之前是写入是尚未提交事务的一部分,是否还是被覆盖,如果是,那就是脏写。读-提交隔离级别下所提交的事务可以防止脏写,通常的方式是推迟第二个写请求,直到前面的事务完成提交(或者中止)

防止脏写可以避免以下并发问题:

  • 如果事务需要更新多个对象,脏写会带来非预期的错误结果。例如,考虑图7-5的二手车销售网站,Alice和Bob两个 人试图购买同一辆车。而购买汽车需要两次数据库写人:网站上商品买主需要更新为新买家,销售发票也需要随之更新。对于7-5的例子,车主被改为Bob (因为他成功地抢先更新了车辆表单)、但发票却发给了Alice (因为她成功的先执行了发票表单)。读提交隔离要防止这种事故
  • 但是,读-提交隔离不能解决 图7-1的计数器增量的竞争情况。对于后者,第二次写人确实在第一个事务提交后才执行, 虽然不属于脏写,但结果仍然是错误的。在接下来的“防止更新丢失”中,我们将讨论如何安全递增计数器。

图7-5 脏写drawio.drawio.png

实现读-提交

防止脏写:大多数数据库采用 行级锁

防止脏读:大多数数据库采用 如图7-4所示方法:对于每个待更新的对象,数据库都会维护其旧值和当前持有锁事务将要设置的新值 两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。

快照级别隔离与可重复读

表面上看 读-提交级别隔离,仍然有很多场景可能导致并发错误。如图 7-6所示

图7-6 读倾斜/不可重复读.drawio.png

假设Alice在银行有1000美元的存款,分为两个账户,每个500美元。现在有这样一-笔转账交易从账户1转100元到账户2。如果在她提交转账请求之后数据库系统正在执行转账的过程中间,来查看两个账户的余额。她有可能看到账号1有500,账号2有400。对于用户来说,貌似她的账户总共只有900,有100元消失了。

这种异常现象被称为不可重复读取(nonrepeatble read)或读倾斜(readskew).如果Alice在交易结束时再次读取账户1的余额,她将看到不同的值(600美元)。读倾斜在读-提交隔离语义下是可以接受的,Alice所看到的账户余额的确都是账户当时的最新值。

对于Alice这个例子,这并非一个永久性问题,例如几秒中之后当她重新加载银行页面,可能就能看到一致的账户余额。但是还有些场景则不能容忍这种暂时的不一致:

备份场景

​ 备份任务要复制整个数据库,这可能要数小时才能完成。在备份过程中,可以继续写入数据库。因此,得到镜像里可能包含部分旧版本数据和部分新版本数据。如果从这样的备份进行恢复,最终就导致了永久性的不一致 ( 备份操作是上图中的读)

分析查询与完整性检查场景:

​ 有时查询可能会扫描几乎大半个数据库。比如分析业务,定期的数据完整性检查。如果这些查询在不同时间点观察数据库,可能会返回无意义的结果。

快照级别隔离是解决上述问题最常见的手段。其总体思想是,每个事务都从数据库的一致性快照中读取,事务一开始所看到是最近提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只看到特定时间点的旧数据。

实现快照级别隔离

防止脏写:与读-提交类似,采用行级锁

防止脏读:多版本并发控制(MVCC)

​ 当事务开始时,首先赋予一个唯一的、单调递增的事务 Id(txid)。每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务 ID,表中的每一行都有一个created_by 字段,其中包含了创建该行的事务 ID,每一行还有一个 deleted_by字段,初始为空。如果事务要删除某行,该行实际上并未从数据库中删除,而只是将deleted_by字段设置为 请求删除的事务 ID (仅仅标记为删除),事后,当确定没有其他事务引用该标记删除的行时,数据库的垃圾回收进程才去真正删除并释放存储空间。

mvcc.drawio.png

一致性快照的可见性规则

当事务读数据库时,通过事务ID可以决定哪些对象可见,哪些不可见。要想对上层应用维护好快照的一 致性,需要精心定义数据的可见性规则。例如:

  1. 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务(即尚未提交或中止),然后忽略这些事务完成的部分写人(尽管之后可能会被提交),即不可见
  2. 所有中止事务所做的修改全部不可见。
  3. 较晚事务ID (即晚于当前事务)所做的任何修改不可见,不管这些事务是否完成了提交。
  4. 除此之外,其他所有的写人都对应用查询可见。

以上规则可以适用于创建和删除操作

防止更新丢失(并发写事务冲突)

总结一下,我们所讨论的 读-提交和快照级别隔离主要都是为了解决只读事务遇到并发写时可以看到什么(虽然中间也设计脏写问题),总体而言我们还没有触及另一种情况,即两个写事务并发,而脏写只是写并发的一个问题。

写并发还会带来一些其他值得关注的冲突问题,最著名的更新丢失问题,以下是一些例子。

  • 递增计数器,或更新用户余额(需要读取当前值,计算新值并写回更新后的值)
  • 两个用户同时编辑 wiki 页面

并发写事务冲突是一个普遍问题,目前是多种可行的解决方案

原子写操作

许多数据库提供了原子更新操作,以避免在应用层代码完成 “读-修改-写回” 操作。如果支持的话,通常这就是最好的解决方案。例如,以下指令在多数关系数据库中都是并发安全的。

UPDATE countes SET value = value + 1 WHERE key = 'foo';

原子操作采用对读取对象加 独占锁 的方式来实现,即读之前加锁。另一种实现方式是强制所有的原子操作在单线程上执行

显式加锁

由应用程序显式锁定待更新的对象。需要仔细考虑清楚应用层的逻辑。

自动更新检测丢失

原子操作和锁都是强制 “读-修改-写回” 操作序列串行执行来防止丢失更新。另一种思路则是让它们先并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的 “读-修改-写回”方式。

原子比较和设置(CAS)

在不提供事务支持的数据库中,有时你会发现它们支持原子 “比较和设置”操作。即 只有 在上次读取的数据没有发生变化时才允许更新),如果已经发生了变化,则回退到 “读-修改-写回” 方式。

UPDATE wiki_pages SET content = 'new content'
 WHERE id = 1234 AND content = 'old content'

写倾斜与幻读

当多个事务同时写入同一对象时引发了两种竞争条件,即前面章节所讨论的脏写和更新丢失。为了避免数据不一致,需要借助数据库的一些内置机制,或者采取手动加锁,执行原子操作等

然而,这还不是并发写所引发的全部问题。

首先,设想这样文个例子:你正在开发一个应用程序来帮助医生管理医院的轮班。通常,医院会安排多个医生值班,医生也可以申请调整班次(例如他们自己生病了),但前提是确保至少一位医生还在该班次中值班。

现在情况是,Alice和Bob是两位值班医生。两人碰巧都感到身体不适,因而都决定请假。不幸的是,他们几乎同一时刻点击了调班按钮。接下来发生的事情如图7-8所示。

写倾斜.drawio.png

每笔事务总是首先检查是否至少有两名医生目前在值班。如果是的话,则有一名医生可以安全离开。由于数据库正在使用快照级别隔离,两个检查都返回有两名医生,所以两个事务都安全地进人到下一个阶段。接下来Alice更新自己的值班记录为离开,同样,Bob也更新自己的记录。两个事务都成功提交,最后的结果却是没有任何医生在值班,显然这违背了至少 一 名医生值班的业务要求。

定义写倾斜

这种异常情况称为写倾斜。它既不是一种脏写,也不是更新丢失,两笔事务更新的是两个不同的对象。这里的写冲突并不那么直接,但很显然这的确是某种竞争状态。

可以将写倾斜视为一种更广义的更新丢失问题。即如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜;而不同的事务更新同一个对象,则可能发生脏写或更新丢失

解决方案:

​ 如果不能使用可串行化级别隔离,一个次优的选择是对事务依赖的行进行显示的加锁。对于上述医生值班的例子,可以这样:

BEGIN transaction;
Select * from doctors
	where on_call = true
	For  UPDATE
UPDATE dockers
	SET on_call = false
	where name 'Alice';
COMMIT;

FOR UPDATE 会通知数据库对所有返回的结果行自动加锁

更多写倾斜的例子

写倾斜可能看起来很晦涩拗口,可一旦深刻认识到问题的本质,就会注意到还有更多可能发生的场景。例如:

会议室预订系统

​ 假设要求同一时间、同一个会议室不能被预订两次。当有人想要预订时,首先检查是否有冲突的预订,如果没有,则提交申请。

多人游戏

​ 两个玩家将不同的数字移动到棋盘上同一个位置,或者其他可能违反游戏规则的移动。

声明一个用户名

​ 网站通常要求每个用户有唯一的用户名,两个用户可能同时尝试创建相同的用户名。可以采用事务的方式首先检查名称是否被使用,如果没有,则使用该名称创建账户。但是,和之前的例子类似,在快照级别隔离下这是不安全的。不过,对于该例子,一个简单的方案是采用唯一性约束。

为何产生写倾斜

上述所有写倾斜的例子都遵循以下类似的模式:

  1. 首先输入一些匹配条件,即采用SELECT查询所有满足条件的行
  2. 根据查询的结果,应用层代码来决定下一步的操作
  3. 如果应用程序决定继续执行,它将发起数据库写入并提交事务

而这个写操作会改变步骤2做出决定的前提条件。换句话说,如果提交写入之后再重复执行步骤1的SELECT查询,就会返回完全不同的结果,原因是更改的写操作改变了决定的前提条件。

这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读

解决方法:实体化冲突

如果问题的关键是查询对象中没有对象(空)可以加锁,或许可以人为引入一些可加锁的对象?

例如,对于会议室预订的例子,构造一个时间-房间表,表的每一行对应于特定时间段的特定房间。我们提前,例如对接下来的6个月,创建好所有可能的房间与时间的组合。

以下是一个简化的时间-房间表的结构:

时间段会议室A会议室B会议室C
9:00-10:00 1月1日可用可用可用
10:00-11:00 1月1日可用可用可用
16:00-17:00 1月1日可用可用可用
9:00-10:00 1月2日可用可用可用
16:00-17:00 1月31日可用可用可用
9:00-10:00 6月30日可用可用可用
16:00-17:00 6月30日可用可用可用

在这个表格中,每个单元格代表一个特定房间在特定时间段的预订状态。一开始,所有的单元格都会标记为“可用”,因为没有人预订会议室。随着时间的推移,当有人预订了会议室,相应的单元格会被更新为“已预订”或包含预订者的信息。

例如,如果张三在1月10日的9:00-10:00预订了会议室A,那么表格将更新如下:

时间段会议室A会议室B会议室C
9:00-10:00 1月10日张三可用可用

这种方法称为实体化冲突(或物化冲突),它把幻读问题转变为针对数据库中一组具体行的锁冲突问题。然而,弄清楚如何实现实体化往往也具体挑战性,实现过程也很容易出错,这种把一个并发控制机制降级为数据模型的思路总是不够优雅。而在大多数情况下,可串行化隔离方案更为可行

串行化

我们已经分析了很多容易出现竞争条件的例子。采用读-提交和快照隔离可以防止其中一部分,但并发对所有情况都有效,例如写倾斜和幻读所导致的棘手问题。最后你会发下面临以下挑战:

  • 隔离级别通常难以理解,而且不同的数据库的实现不尽一致(例如 “可重复读”的含义在各家数据库的差别很大)
  • 如果去检查应用层的代码,往往很难判断它在特定的隔离级别下是否安全,特别是对于大型应用系统,几乎无法预测所有可能并发情况

可串行化隔离通常被认为是最强的隔离级别。它保证即时事务可能会并行执行,但最终的结果与每次一个即串行执行结果相同。

如果可串行化隔离比其他弱隔离级别好得多,那么为什么没有广泛使用呢?要回答这个问题,我们需要看看可串行化究竟是什么,以及如何执行。目前大多数提供可串行化的数据库都使用了以下三种技术之一,我们将依次探讨:

  • 严格按照串行顺序执行
  • 两阶段锁定,几十年来这几乎是唯一可行的选择
  • 乐观并发控制技术

实际串行执行

解决并发问题最直接的方法是避免并发:即在一个线程上按顺序方式每次只执行一个事务。这样我们完全回避了诸如检测、防止事务冲突等问题,其对应的隔离级别一定是严格串行化的。

Redis采用串行方式执行事务。单线程执行有时可能会比支持并发的系统效率更高,尤其是可以避免锁开销。但是,其吞吐量上限是单个 CPU 核的吞吐量。

二阶段加锁

可串行化的快照隔离(SSI)

小结

事务作为一个抽象层,使得应用程序可以忽略数据库内别一些复杂的并发问题,以及某些硬件、软件故障,从而简化应用层的处理逻辑,大量的错误可以转化为简单的事务中止和应用层重试。

通过分析如何处理边界条件来阐述这些隔离级别的要点:

脏读

​ 客户端读到了其他客户端尚未提交的写。读提交以及更强的隔离级别可以防止脏读。

脏写

​ 客户端覆盖了另一个客户端尚未提交的写入。几平所有的数据库实现都可以防止脏写

读倾斜 (不可重复读)

​ 客户在不同的时间点看到了不同值。快照隔离是最用的防范手段,即事务总是在某个时间点的一致性快 照中读取数据。通常采用多版本并发控制(MVCC)来实现快照隔离。

更新丢失

​ 两个客户端同时执行读-修改写人操作序列,出现了其中一一个覆盖了另一个的写入,但又没有包含对方最新值的情况,最终导致了部分修改数据发生了丢失。快照隔离的一些实现可以自动防 止这种异常,而另一些则需要手动锁定查询结果(SELECT FOR UPDATE)

写倾斜

​ 事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异常。

幻读

​ 事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理,例如采用区间范围锁。

弱隔离级别可以防止上面的某些异常,但还需要应用开发人员手动处理其他复杂情况(例如,显式加锁)。只有可串行化的隔离可以防止所有这些问题。

实现可串行化隔离的三种不同方法:

严格串行执行事务

​ 如果每个事务的执行速度非常快, 且单个CPU核可以满足事务的吞吐量要求时,是简单有效的方案。

两阶段加锁

​ 几十年来,这一直是实现可审行化的标准方式,但还是有很多系统出于性能原因放弃使用

可串行化的快照隔离(SSI)

​ 一种最 新的算法,可以避免前面方法的大部分缺点。它秉持乐观预期的原则,允许多个事务并发执行而不互相阻塞:仅当事务尝试提交时,才检查可能的冲突,如果发现违背了串行化,则某些事务会被中止。

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值