分布式数据库难题(四):单机事务

1. ACID的含义 

     在数据库中,“事务”是由多个操作构成的序列。1970 年詹姆斯 · 格雷(Jim Gray)提出了事务的 ACID 四大特性,将广义上的事务一致性具化到了原子性、一致性、隔离性和持久性这 4 个方面。我们先来看一下他在 Transaction Processing Concepts and Techniques 中给出的定义:

  • 原子性:事务中的所有变更要么全部发生,要么一个也不发生。
  • 隔离性:多事务并行执行所得到的结果,与串行执行(一个接一个)完全相同。
  • 持久性:一旦事务提交,它对数据的改变将被永久保留,不应受到任何系统故障的影响。
  • 一致性:事务要保持数据的完整性,即数据库处于应用程序所期待的“预期状态”。

    虽然 ACID 名义上并列为事务的四大特性,但它们对于数据库的重要程度并不相同。 

   

(1)原子性

    数据库区别于其他存储系统的重要标志。在单体数据库时代,原子性问题已经得到妥善解决,但随着向分布式架构的转型,在引入不可靠的网络因素后,原子性又成为一个新的挑战。要在分布式架构下支持原子性并不容易,所以不少 NoSQL 产品都选择绕过这个问题,聚焦到那些对原子性不敏感的细分场景。例如,大名鼎鼎的 Google BigTable 甚至是不支持跨行事务的。但是,这种妥协也造成了 NoSQL 的通用性不好。分布式数据库是在分布式架构上实现的关系型数据库,那么就必须支持事务,首先就要支持原子性。原子性,在实现机制上较为复杂,目标却很简单,和分成多个级别的隔离性不同,原子性就只有支持和不支持的区别。

(2)隔离性

    它是事务中最复杂的特性。隔离性分为多个隔离级别,较低的隔离级别就是在正确性上做妥协,将一些异常现象交给应用系统的开发人员去解决,从而获得更好的性能。可以说,事务模型的发展过程就是在隔离性和性能之间不断地寻找更优的平衡点,甚至可以说事务的核心就是隔离性。而不同产品在事务一致性上的差别,也完全体现在隔离性的实现等级上,所以我们必须搞清楚隔离等级具体是指什么。

(3)持久性

    它不仅是对数据库的基本要求。如果你仔细琢磨下持久性的定义,就会发现它的核心思想就是要应对系统故障。怎么理解系统故障呢?我们可以把故障分为两种。

  • 存储硬件无损、可恢复的故障。这种情况下,主要依托于预写日志(Write Ahead Log, WAL)保证第一时间存储数据。WAL 采用顺序写入的方式,可以保证数据库的低延时响应。WAL 是单体数据库的成熟技术,NoSQL 和分布式数据库都借鉴了过去。
  • 存储硬件损坏、不可恢复的故障。这种情况下,需要用到日志复制技术,将本地日志及时同步到其他节点。实现方式大体有三种:第一种是单体数据库自带的同步或半同步的方式,其中半同步方式具有一定的容错能力,实践中被更多采用;第二种是将日志存储到共享存储系统上,后者会通过冗余存储保证日志的安全性,亚马逊的 Aurora 采用了这种方式,也被称为 Share Storage;第三种是基于 Paxos/Raft 的共识算法同步日志数据,在分布式数据库中被广泛使用。无论采用哪种方式,目的都是保证在本地节点之外,至少有一份完整的日志可用于数据恢复。

(4)一致性

    ACID中的一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。例如,对于一个账单系统,账户的贷款余额应和借款余额保持平衡。如果事务从一个有效的状态开始,并且事务中任何更新操作都没有违背约束,那么最后的结果依然符合有效状态。这种一致性本质上要求应用层来维护状态一致,应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情:即如果提供的数据修改违背了恒等条件,数据库很难检测进而阻止该操作。

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

2. 弱隔离级别

2.1 读-提交

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

(1)防止“脏读”:读数据时,只能看到已成功提交的数据

    以下需求时,需要防止脏读:1)事务需要更新多个对象,脏读意味着另一个事务可能会看到部分更新,而非全部;2)如果事务发生终止,则所有写入的操作都要回滚。

(2)防止“脏写”:写数据时,只会覆盖已成功提交的数据

    防止脏写可以避免以下并发问题,如果事务需要更新多个对象,脏写会带来非预期的错误结果。

  如何实现读-提交?

    防止脏写:数据库通常采用行级锁来实现,当事务想修改某个对象时,它必须首先获得该对象的锁,然后一直持有锁直到事务提交。

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

2.2 快照隔离级别与可重复读

(1)不可重复读或读倾斜

    Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户2转移了 100 美元到另一个账户1。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会在收到付款之前先看到一个账户的余额(收款账户1,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户2,新余额为 400 美元)。对 Alice 来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。

    这种异常现象被称为不可重复读或者读倾斜。快照隔离级别是解决上述问题最常见的手段。其总体想法是:每个事务都从数据库的一致性快照中读取,事务一开始所看到是最近提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只看到该特定时间点的旧数据。 

(2)实现快照隔离级别

    与读-提交隔离类似,快照隔离级别的实现通常采用写锁来防止脏写,这意味着正在进行写操作的事务会阻止同一对象上的其他事务。

    为了实现可重复读,数据库保留了对象多个不同的提交版本,这种技术因为也被称为多版本并发控制技术(Multi-Version Concurrent Control,MVCC)。如果只是为了提供读-提交隔离级别,而不是完整的快照隔离级别,则只保留对象的两个版本旧足够了。所以,支持快照隔离级别的存储引擎往往直接采用MVCC机制来实现读-提交隔离。典型做法是:在读-提交级别下,对一个事务中每一个不同的查询单独创建一个快照;而快照隔离级别则是使用一个快照来运行整个事务。

2.3 防止更新丢失

(1)防止更新丢失是什么

    读-提交和快照隔离级别都是为了解决只读事务遇到并发写时可以看到什么,总体而言我们还没有触及到另一种情况,即两个事务并发,而脏读只是写并发的一个特例。

    更新丢失可能发生在这样一个操作场景中:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值(读取 - 修改 - 写入序列)。当有两个事务在同样的数据对象上执行类似的操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务的修改值可能丢失。

(2)可行的解决方案

    1)原子写操作:数据库自身提供了原子更新操作,以避免应用层代码完成“读-修改-写回”操作,如果支持的话,通常这就是最好的解决方案。原子操作通常对读取对象加独占锁的方式来实现,这样在更新被提交之前其他事务不会读它。另一种实现方式是强制所有的原子操作都在单线程上执行。

    2)显示加锁:如果数据库不支持原子锁操作,另一种防止丢失更新的方法是由应用程序显式锁定待更新的对象。然后,应用程序可以执行“读-修改-写回”这样的操作序列。

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

2.4 写倾斜与幻读

(1)什么是写倾斜

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

    现在的情况是,Alic和Bob是两位值班医生,两人碰巧都身体不适,因而都决定请假,不幸的是,他们几乎同一时刻点击了调班按钮,如下图所示:

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

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

(2)为何产生写倾斜

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

  1)首先输入一些匹配条件,即采用select查询所有满足条件的行

  2)根据查询结果,应用层代码决定下一步操作

  3)如果应用程序决定继续执行,它将发起数据库写入(insert、update或delete)并提交事务。而这个写操作会改变步骤2做出决定的前提条件。

    这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。快照隔离级别可以避免只读查询的幻读,但是对于我们上面提到的读-写事务,它却无法解决棘手的写倾斜问题。

3. 强隔离级别——可串行化

    可串行化隔离通常被认为是最强的隔离级别,它保证即使事务可能会并行执行,但最终的结果与每次一个即串行执行结果相同。这意味着,如果事务在单独运行时表现正常,那么它们在并发运行时结果仍然正确。目前大多数提供可串行化的数据库都使用了以下三种技术之一:严格串行化执行、两阶段加锁和乐观并发技术。

3.1 实际串行执行

(1)单线程执行

    最简单的方法时避免并发,即在一个线程上按顺序方式每次只执行一个事务,其对应的隔离级别一定是严格串行化的。单线程执行有时可能会比支持并发的系统更高效,尤其是可以避免锁开销,但是其吞吐量上限是单个CPU核的吞吐量。

(2)存储过程封装事务

  对于交互式事务,例如应用程序提交查询,读取结果,可能会根据前一个查询的结果来进行其他查询。交互式事务大量时间花费在应用程序和数据库之间的网络通信,如果不允许并发,而是一次仅处理一个,那么吞吐量将非常低。

    出于这个原因,采用单线程串行执行的系统往往不支持交互式的多语句事务,应用程序必须提交整个事务代码作为存储过程打包发送到数据库。把事务所需的所有数据全部加载到内存中,使存储过程高效执行,而无需等待网络或磁盘IO。

(3)分区

    串行执行所有事务使得并发控制变得更加简单,但是数据库的吞吐量被限制在单机单个CPU核。为了扩展到多个CPU和多节点,可以对数据进行分区。如果能找到一个方法对数据集进行分区,使得每个事务只在单个分区内读写数据,这样每个分区都可以有自己的事务处理线程且独立运行。

    但是,对于跨分区事务,数据库必须在涉及的所有分区之间协调事务。存储过程需要跨越所有分区加锁执行,以确保整个系统的可串行化。

3.2 两阶段加锁

    近三十年来,可以说数据库只有一种被广泛使用的串行化算法,那就是两阶段加锁(two-phase locking,2PL)。可以使用加锁的方法来防止脏写,即如果两个事务同时尝试写入同一个对象时,以加锁的方式来确保第二个写入等待前面的事务完成。两阶段加锁的方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,但只要出现任何写操作,则必须加锁以独占访问。

(1)实现两阶段加锁

    目前2PL已经用于MySQL和SQL Server中的“可串行化执行”。此时数据库的每个对象都有一个读写锁来隔离读写操作,即锁可以处于共享模式或者独占模式。基本用法如下:

  • 如果事务要读取对象,必须先以共享模式获得锁
  • 如果事务要修改对象,必须以独占模式获取锁
  • 如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁
  • 事务获得锁之后,一直持有锁直到事务结束(包括提交或者中止)

    使用这么多锁机制,很容易出现死锁。数据库系统会自动检测事务之间的死锁情况,并强行中止其中的一个以打破僵局,这样另一个可以继续向前执行。而被中止的事务需要由应用层来重试。

(2)两阶段加锁的性能

    两阶段加锁的缺点主要在于性能:其事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。部分原因在于锁的获取和释放本身的开销,但更重要的是其降低了事务的并发性。

    两个并发事务如果试图做任何可能导致竞争条件的事情,其中一个必须等待对方完成,最终结果是,当一个事务还需要等待另一个事务时,那么最终的等待时间几乎是没有上限的。即使可以保证自己的事务足够简短、高效,但一旦出现多个事务同时访问一个对象,会形成一个等待队列,事务就必须等待队列前面所有其他事务完成之后才能继续。

(3)谓词锁

    对于写倾斜和幻读问题,可串行化隔离液必须防止幻读问题。以会议室预定为例,如果事务在查询某个时间段内一个房间的预定情况,则另一个事务不能同时去插入或更新同一个时间段内该房间的预定情况,但它可以修改其他房间的预定情况。

    如何实现呢,可以引入一种谓词锁,它的作用类似与共享锁或者排他锁,而区别在于,它并不属于某个特定的对象,如表的某一行,而是作用于满足某些搜索条件的所有查询对象。谓词锁会限制如下访问:

  • 如果事务A想要读取某些满足匹配条件的对象,例如采用select查询,它必须以共享模式获得查询条件的谓词锁。如果另外一个事务B正在持有任何一个匹配对象的互斥锁,那么A必须等到B释放了锁之后才能继续执行查询。
  • 如果事务A想要插入、更新或删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁匹配。如果事务B持有这样的谓词锁,那么A必须等待B完成提交后才能继续。

   这里的谓词锁可以保护数据库中那些尚不存在但可能马上会被插入的对象。将2PL和谓词锁结合使用,数据库可以防止所有写形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。

(4)索引区间锁

    不幸的是,谓词锁性能不佳。因此,大多数使用2PL的数据库实际上实现的是索引区间锁(或next locking),本质上它是对谓词锁的简化或者近似。

    简化谓词锁的方式是将其保护的对象扩大化。例如,如果一个谓词锁保护的查询条件是:房间123,时间段是中午至下午1点,则一种方式是通过扩大时间段来简化,即保护123房间的所有时间段;或者另一种方式是扩大房间,即保护中午至下午之间的所有房间(而不是123房间)。对于房间的预定,通常会在root_id列上创建索引,或在start_time和end_time上有索引,否则前面的查询在大型数据库上会很慢。

    无论哪种方式,查询条件的近似值都附加到某个索引上。接下来,如果另外一个事务想要插入、更新或删除一个房间或重叠时间段的预定,则肯定需要更新这些索引,一定会与共享锁冲突,因此会自动处于等待状态直到共享锁释放。

3.3 可串行化的快照隔离

    2PL虽然可以保证串行化,但性能差强人意且无法扩展,弱隔离级别虽然性能不错,但容易引发各种边界冲突。串行化隔离与性能是不是从根本上就是互相冲突而无法兼得吗?

    最近有一种称为可串行化的快照隔离(SSI)算法看起来让人眼前一亮,它提供了完整的可串行化保证,而性能相比于快照隔离损失很小。SSI是基于快照隔离,也就是说,事务中所有的读取操作都是基于数据库的一致性快照。在快照隔离的基础上,SSI新增了相关算法来检测写入之间的串行化冲突从而决定中止哪些事务。SSI和2PL的区别如下:

  • 2PL是一种典型的悲观并发机制。它基于这样的设计原则:如果某些操作可能出错,那么直接放弃,采用等待方式直到绝对安全,这和多线程编程中互斥锁是一致的。
  • SSI是一种乐观并发机制,在这种情况,如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事;而当事务提交时,数据库会检查是否确实发生了冲突,如果是的话,中止事务并接下来重试。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值