美文网首页
三: 事务

三: 事务

作者: wangshanshi | 来源:发表于2020-05-12 13:51 被阅读0次

    事务不是一个天然存在的东西,它是被人为创造出来的,目的是简化应用层的编程模型。有了事务,应用程序不用考虑并发或各种错误情况(进程崩溃、网络中断、停电、磁盘问题)导致的各种不一致。
    然而并非每个应用程序都需要事务机制,有时可以弱化事务处理或完全放弃事务,一些安全相关的属性也可能会避免引入事务。

    我们需要判断的是: 是否需要事务?需要什么样的事务?

    1. 什么是ACID

    ACID代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)
    BASE它代表基本可用性(Basically Available),软状态(Soft State)和最终一致性(Eventual consistency),BASE唯一可以确定的是: "它不是ACID",此外它几乎没有承诺任何东西。

    什么是原子性?

    一般来说,原子是指不能分解成小部分的东西。这个词在计算的不同分支中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。相比之下,ACID的原子性并是关于并发(concurrent)的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在缩写I 中,即隔离性(Isolation)
    ACID原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 或许 可中止性(abortability) 是更好的术语,但本书将继续使用原子性,因为这是惯用词。

    什么是一致性?

    • 在主从复制中,我们讨论了副本一致性,以及异步复制系统中的最终一致性问题。
    • 一致性哈希(Consistency Hash)是某些系统用于重新分区的一种分区方法。
    • 在CAP定理中,一致性一词用于表示可线性化
    • 在ACID的上下文中,一致性是指数据库处于应用程序所期待的“预期状态”

    可以看出ACID中的一致性本质上要求应用层来维持状态一致,应用程序有责任通过正确的定义事务来保持一致性。原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID。
    (乔·海勒斯坦(Joe Hellerstein)指出,“ACID中的C”是被“扔进去凑缩写单词的”,而且那时候大家都不怎么在乎一致性)

    什么是隔离性?

    ACID语义中的隔离性意味着,并发执行的多个事务相互隔离,它们不能相互交叉。传统的数据库教科书将隔离性定义为可串行化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)完全相同。

    然而实践中,由于性能问题,很少会使用串行化隔离。一些流行的数据库如Oracle 11g,甚至没有实现它。在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做快照隔离(snapshot isolation) 的功能,这是一种比可序列化更弱的保证

    什么是持久性?
    持久性是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。

    数据库实现ACID主要是实现: 1. 事务; 2. 隔离性

    2. 事务: 实现原子性和持久性

    (以InnoDB为例)

    2.1 redo log

    2.1.1 WAL机制保证持久

    InnoDB使用redo log和undo log来实现事务,其中redo log用于保证事务的持久性,undo log用于帮助事务回滚。redo log是顺序读写,undo log需要随机读写。

    image.png

    采用WAL机制,将日志写入Redo Log Buffer,然后根据如下规则,将Buffer刷到磁盘:

    • 事务提交时
    • 当log buffer中一半的内存空间已经被使用时
    • log checkpoint时

    InnoDB Redo Log 是基于页的:


    image.png
    image.png

    2.1.2 LSN用于崩溃恢复

    LSN(log sequence number),意思是日志序列号,在InnoDB中,LSN占8个字节,并且单调递增,表示重做日志的字节总量。

    LSN有三个含义:

    • 重做日志写入的总量
    • checkpoint的位置,checkpoint表示刷新到磁盘的LSN
    • 页的版本,在每个页的头部,有一个FIL_PAGE_LSN,记录该页的LSN——该页最后刷新时LSN大小,可用于崩溃恢复
    image.png

    2.2 undo log: 实现原子性

    事务的原子性,需要依赖于回滚。这就是undo的作用——实现回滚(其实在InnoDB中还有实现MVCC的功能)
    undo是一种逻辑日志,取消之前的修改逻辑。

    那么undo log怎么实现回滚呢?
    有两种类型的undo log:

    • insert undo log
    • update undo log
    image.png
    • next: 下一个undo log位置
    • type_cmpl: undo的类型
    • undo_no: 事务ID
    • table_id: 记录undo log对应的表对象

    2.2.1 insert undo log

    其中insert undo log是指在insert操作中产生的undo log,在业务上比较简单。因为insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log可以在事务提交后直接删除,不需要purge操作。

    insert undo log记录了所有主键的列和值,在进行rollback操作时,根据这些值可以定位到具体的记录,然后进行删除即可。

    2.2.2 update undo log

    其复杂性来源于:


    image.png
    image.png

    2.3 分布式事务:两阶段提交

    ​ 两阶段提交(two-phase commit)是一种用于实现多节点之间的原子事务提交的算法,即确保所有节点要么全部提交,要么全部中止。 它是分布式数据库中的经典算法。


    image.png

    2PC使用单节点事务所没有的新组件:协调者(coordinator)(也称为事务管理器(transaction manager))。协调者通常在共享库函数,但也可以是单独的进程或服务。
    ​正常情况下,2PC事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为参与者(participants)。当应用准备提交时,协调者开始
    阶段1: 它发送一个准备(prepare)请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应:

    • 如果所有参与者都回答“是”,表示它们已经准备好提交,那么协调者在阶段 2 发出提交(commit)请求,然后提交真正发生。
    • 如果任意一个参与者回复了“否”,则协调者在阶段2 中向所有节点发送中止(abort)请求。

    2.3.1 2PC原理

    1. 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
    2. 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以安全中止。
    3. 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
    4. 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。节点承诺,只要向协调者回答“是”,这个事务一定可以不出差错地提交。
    5. 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志中,以防止系统崩溃。这个时刻称为提交点(commit point)。
    6. 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交——由于参与者投了赞成,因此恢复后它不能拒绝提交。

    2.3.2 InnoDB的内部XA事务

    image.png

    2.3.3 2PC vs 3PC

    协调者可能发生故障。


    image.png

    (已经对db2发送commit, 但是没有对db2发送commit,此时协调者crash)

    3PC解决了:

    • 同步阻塞问题
    • 单点故障问题,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。

    原文: https://zhuanlan.zhihu.com/p/35616810



    • 二阶段提交的缺点

    二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的:

    image

    1、同步阻塞问题
    执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。也就是说从投票阶段到提交阶段完成这段时间,资源是被锁住的。

    2、单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。
    尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。
    【协调者发出Commmit消息之前宕机的情况】
    (如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题

    3、数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。

    4、二阶段无法解决的问题:------ 极限情况下,对某一事务的不确定性!
    【协调者发出Commmit消息之后宕机的情况】
    协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

    由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。

    3PC-三阶段提交

    三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。

    image

    与两阶段提交不同的是,三阶段提交有两个改动点。

    1、引入超时机制。同时在协调者和参与者中都引入超时机制。
    2、在第一阶段和第二阶段中插入一个准备阶段,保证了在最后提交阶段之前各参与节点状态的一致。

    也就是说,除了引入超时机制之外,3PC把2PC的投票阶段再次一分为二,这样三阶段提交就有CanCommitPreCommitDoCommit三个阶段。

    为什么要把投票阶段一分为二?

    假设有1个协调者,9个参与者。其中有一个参与者不具备执行该事务的能力。
    协调者发出prepare消息之后,其余参与者都将资源锁住,执行事务,写入undo和redo日志。
    协调者收到相应之后,发现有一个参与者不能参与。所以,又出一个roolback消息。其余8个参与者,又对消息进行回滚。这样子,是不是做了很多无用功?
    所以引入can-Commit阶段,主要是为了在预执行之前,保证所有参与者都具备可执行条件,从而减少资源浪费。

    image
    • CanCommit阶段

    3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。

    1.事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
    2.响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

    • PreCommit阶段

    本阶段协调者会根据第一阶段的询盘结果采取相应操作,询盘结果主要有两种:

    情况1-假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行:

    1.发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
    2.事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
    3.响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

    情况2-假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。具体步骤如下:

    1.发送中断请求 协调者向所有参与者发送abort请求。
    2.中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。

    image
    • doCommit阶段

    该阶段进行真正的事务提交,也可以分为以下两种情况。

    情况1-执行提交

    针对第一种情况,协调者向各个参与者发起事务提交请求,具体步骤如下:

    1. 协调者向所有参与者发送事务commit通知
    2. 所有参与者在收到通知之后执行commit操作,并释放占有的资源
    3. 参与者向协调者反馈事务提交结果

    image

    情况2-中断事务

    协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。具体步骤如下:

    1. 发送中断请求 协调者向所有参与者发送事务rollback通知。
    2. **事务回滚 **所有参与者在收到通知之后执行rollback操作,并释放占有的资源。
    3. **反馈结果 **参与者向协调者反馈事务提交结果。
    4. 中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
    image

    2PC与3PC的区别

    相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。

    【在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。
    其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么Coordinator产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了。
    所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。

    但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

    【如果进入PreCommit后,协调者发出的是abort请求,如果只有一个参与者收到并进行了abort操作,而其他对于系统状态未知的参与者会根据3PC选择继续Commit,那么系统的不一致性就存在了。所以无论是2PC还是3PC都存在问题,后面会继续了解传说中唯一的一致性算法Paxos。】



    3. MVCC&锁: 隔离性

    实现隔离并没有想象中那么简单,可串行化的隔离会严重影响性能,而许多数据库却不愿意牺牲性能,因此,系统通常使用较弱的隔离级别,它可以防止某些单并不是全部的并发问题。这些隔离级别难以理解,并且会带来难以捉摸的隐患,但是它们仍然在实践中被使用。

    3.1 sql隔离级别

    SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable ):

    • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
    • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
    • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
    • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
    image.png

    不同隔离级别带来的问题

    • 更新丢失(Lost Update):当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题--最后的更新覆盖了由其他事务所做的更新。例如,两个编辑人员制作了同一文档的电子副本。每个编辑人员独立地更改其副本,然后保存更改后的副本,这样就覆盖了原始文档。最后保存其更改副本的编辑人员覆盖另一个编辑人员所做的更改。如果在一个编辑人员完成并提交事务之前,另一个编辑人员不能访问同一文件,则可避免此问题。
    • 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做”脏读”。
    • 脏写: 当两个事务同时尝试去更新某一条数据记录时,就肯定会存在一个先一个后。而当事务A更新时,事务A还没提交,事务B就也过来进行更新,覆盖了事务A提交的更新数据,这就是脏写。脏写是更新丢失的一种。
    • 不可重复读(Non-Repeatable Reads):一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。
    • 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。

    3.2 MVCC

    3.2.1 实现读已提交

    最常见的情况是,数据库通过使用行锁(row-level lock) 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。给定时刻,只有一个事务可持有给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。这种锁定是处于读已提交模式(或更强的隔离级别)的数据库自动完成的。

    如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)。

    但是读锁的方式在实际中并不可行。因为运行时间较长的写事务会迫使许多只读事务等待太长时间,这会严重影响只读事务的响应延迟。并且于可操作性差:因为等待读锁,应用某个部分的迟缓导致其他部分出现问题,产生连锁效应。

    出于这个原因,大多数数据库如下方式防止脏读:对于每个待更新的对象,数据库会维护其旧值和当前持有锁事务将要设置的新值两个版本。事务提交之前,任何其他读取对象的事务都会拿到旧值。 只有当写事务提交后,才能切换到读取新值。

    3.2.2 解决读倾斜

    不可重复读(nonrepeatable read)又称为读倾斜(read skew),此处倾斜的意思是:时间异常。

    快照级别隔离(snapshot isolation)是这个问题最常见的解决方案。想法是,每个事务都从数据库的一致快照(consistent snapshot) 中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。

    快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。

    3.2.3 读已提交 vs 可重复读

    快照级别隔离的实现是采用了比读已提交更加通用的机制。考虑到多个正在进行的事务可能在不同的时间点查看数据库状态,数据库必须可能保留一个对象的多个不同的提交版本,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)

    如果一个数据库只需要提供读已提交的隔离级别,而不提供快级别照隔离,那么保留一个对象的两个版本就足够了:提交的版本和尚未提交的版本。支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。一种典型的方法读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。

    3.2.4 MVCC实现——解决只读事务

    image.png

    重点:

    1. 当一个事务开始时,它被赋予一个唯一的,永远增长的事务ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。
    2. 表中的每一行都有一个 created_by 字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个 deleted_by 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。
    3. UPDATE 操作在内部翻译为 DELETEINSERT 。例如,上图中,事务13 从账户2 中扣除100美元,将余额从500美元改为400美元。实际上包含两条账户2 的记录:余额为500 的行被标记为被事务13删除,余额为 400 的行由事务13创建

    事务读取数据库时的可见性规则:

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

    这些规则适用于创建和删除对象。在上图中,当事务12 从账户2 读取时,它会看到 500 的余额,因为 500 余额的删除是由事务13 完成的(根据规则3,事务12 看不到事务13 执行的删除),且400美元记录的创建也是不可见的(按照相同的规则)。

    换句话说,如果以下两个条件都成立,则可见一个对象:

    • 读事务开始时,创建该对象的事务已经提交。
    • 对象未被标记为删除,或即使被标记为删除,删除事务在当前事务开始时尚未提交。

    MVCC with索引

    索引如何在多版本数据库中工作?

    • 一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。在实践中,许多实现细节决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个页面中,PostgreSQL就是采用这种优化措施优化避免更新索引。
    • 在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用B树,但它们使用的是一种仅追加/写时拷贝(append-only/copy-on-write) 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变。使用仅追加的B树,每个写入事务(或一批事务)都会创建一颗新的B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。

    3.2.5 mysql中的实现

    image.png

    InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。


    image.png

    这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

    如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
    如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
    如果落在黄色部分,那就包括两种情况a.
    若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;b.
    若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

    3.3 并发写问题:更新丢失、幻读、写倾斜

    • 更新丢失: 两个客户端同时执行读取-修改-写入序列。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(SELECT FOR UPDATE)。

    • 写倾斜: 事务首先查询数据,根据它所看到的值作出决定,并将决定写入数据库。但是,当写的时候,支持决定的前提已经不再存在了。只有可序列化的隔离才能防止这种异常。

    • 幻读:事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止简单的幻像,但是写倾斜环境中的幻读需要特殊处理,例如索引范围锁定。

    3.3.1 防止更新丢失

    • 原子写操作, 如: UPDATE counters SET value = value + 1 WHERE key = 'foo';
    • 显式加锁, 如果数据库不支持内置原子操作,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成。
    • CAS
    • 之前提到的冲突解决

    3.3.2 可串行化隔离

    可串行化(Serializability)隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止所有可能的竞争条件。

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

    • 字面意义上地串行执行事务
    • 两阶段锁定(2PL, two-phase locking),几十年来唯一可行的选择。
    • 乐观并发控制技术,例如可序列化的快照隔离(serializable snapshot isolation)

    1. 串行执行事务

    避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。

    尽管这似乎是一个直白的想法,但数据库设计人员在2007年左右才确定,单线程循环执行事务是可行的。如果多线程并发在过去的30年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执行变为可能呢?

    两个进展引发了这个反思:

    • RAM足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。(当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
    • 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。

    串行执行事务的方法在VoltDB/H-Store,Redis和Datomic中实现。设计用于单线程执行的系统有时可以比支持并发的系统更好,因为它可以避免锁的协调开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单一线程,需要与传统形式不同的结构的事务。

    image.png

    使用存储过程+内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。

    分区
    顺序执行所有事务使并发控制简单多了,但数据库的事务吞吐量被限制为单机单核的速度。只读事务可以使用快照隔离在其它地方执行,但对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。

    为了扩展到多个CPU核心和多个节点,可以对数据进行分区,在VoltDB中支持这样做。如果你可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的CPU核,事务吞吐量就可以与CPU核数保持线性扩展。

    但是,对于需要访问多个分区的任何事务,数据库必须在相关的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。由于跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多。 VoltDB报告的吞吐量大约是每秒1000个跨分区写入,比单分区吞吐量低几个数量级,并且不能通过增加更多的机器来增加。事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个二级索引的数据可能需要大量的跨分区协调,就不太合适了。

    串行执行小结

    在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法。

    • 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
    • 仅限于活跃数据集可以全部加载进内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。
    • 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
    • 跨分区事务是可能的,但是占比必须很小。

    2. 两阶段加锁

    什么是两阶段加锁?

    之前我们看到锁通常用于防止脏写(参阅“没有脏写”一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。

    两阶段锁定定类似,但使锁的要求更强。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要独占访问(exclusive access) 权限:

    • 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A底下意外地改变对象。)
    • 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。 (像图7-1那样读取旧版本的对象在2PL下是不可接受的。)

    2PL不仅在并发写操作之前互斥,读取也会和修改互斥。快照级别隔离的口号是”读写互不干扰“,这是2PL和快照隔离之间的关键区别。另一方面,因为2PL提供了可序列化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写倾斜。

    如何实现两阶段加锁?

    2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离。

    读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)独占模式(exclusive mode)。锁使用如下:

    • 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
    • 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
    • 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
    • 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁

    由于使用了这么多的锁,因此很可能会发生:事务A等待事务B释放它的锁,反之亦然。这种情况叫做死锁(Deadlock)。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。

    两阶段锁定的性能

    两阶段锁定的巨大缺点,以及70年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。

    这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。

    传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。

    因此,运行2PL的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。

    基于锁实现的读已提交隔离级别可能发生死锁,但在基于2PL实现的可序列化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。

    3. 谓词锁和索引区间锁

    谓词锁

    从概念上讲,我们需要一个谓词锁(predicate lock)。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象。
    谓词锁限制访问,如下所示:

    1. 如果事务A想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock)。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
    2. 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。

    这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果使用两阶段锁定+谓词锁,则数据库将阻止所有形式的写倾斜和其他竞争条件,因此其隔离实现了可串行化。

    区间索引锁
    mysql使用Next-Key Lock来实现对谓词锁的近似。

    3.4 Mysql中的锁

    https://i6448038.github.io/2019/02/23/mysql-lock/

    全局读锁

    MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。全局锁的典型使用场景是,做全库逻辑备份。

    表级锁

    MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

    表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

    在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。

    另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

    MDL用于防止DDL和DML并发的冲突

    意向锁

    InnoDB支持多粒度的锁,即:允许表锁和行锁同时存在。
    但是,假如表锁覆盖了行锁的数据,所以表锁和行锁也会产生冲突。如何判断表锁与行锁的冲突性?

    加行锁之前加意向锁,意向锁即为表级别的锁,意思就是:这种表中有几行数据被加锁,意向锁不会阻塞除全表扫描以外的任何请求

    IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。

    也许有人会问:“意向锁存在的意义是什么呢?没有意向锁,行锁和表锁照样可以共存啊?”
    试问如何共存?
    “查看表中某一行存在X锁”
    “如何查看呢?”
    唯有全表扫描…
    意向锁的存在就是解决了“全表扫描”的性能问题,所以,意向锁一定是“表级”锁,告诉整张表XXX行存在X锁。此时假如进行表操作就会被阻塞。

    行锁

    《Mysql实战45讲》的第20章和第21章把这个问题讲得很明白.

    行锁的实现: https://lanjingling.github.io/2015/10/10/mysql-hangsuo/

    InnoDB行锁是通过给索引上的索引项加锁来实现的,这一点MySQL与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。 InnoDB这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!

    • 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
    • 原则 2:查找过程中访问到的对象才会加锁。
    • 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
    • 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
    • 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

    详细加锁按理可以参见: https://time.geekbang.org/column/article/75659

    在可重复读隔离级别下, 采用Next-Key Locking的方式来加锁, 而在读已提交隔离级别下, 仅采用Record Lock

    跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。

    间隙锁和 next-key lock 的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。



    CREATE TABLE `t` (
      `id` int(11) NOT NULL,
      `c` int(11) DEFAULT NULL,
      `d` int(11) DEFAULT NULL,
      PRIMARY KEY (`id`),
      KEY `c` (`c`)
    ) ENGINE=InnoDB;
    
    insert into t values(0,0,0),(5,5,5),
    (10,10,10),(15,15,15),(20,20,20),(25,25,25);
    
    image.png
    image.png

    image.png


    相关文章

      网友评论

          本文标题:三: 事务

          本文链接:https://www.haomeiwen.com/subject/zdeaghtx.html