数据系统的事务
作者:互联网
数据密集型应用设计读书笔记第七章
事务可以解决很多数据系统半路故障导致的各种问题。
事务是一个抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。各式各样的错误被简化为一种简单情况:事务中止(transaction abort),而应用需要的仅仅是重试。
从概念上讲,事务将多个对象上的多个操作合并为一个执行单元:整个事务要么成功(提交(commit))要么失败(中止(abort),回滚(rollback))
有时候不使用事务是为了更高的性能,一些安全属性也并不必须要事务来实现。
怎样知道你是否需要事务?为了回答这个问题,首先需要确切理解事务可以提供的安全保障,以及它们的代价。
本章主要讨论单节点数据库的事务问题,这些对分布式事务也是有意义的。如果需要了解分布式事务(第九章还会在讲),看分布式事务有这一篇就够了! - 知乎 (zhihu.com)
ACID的含义
事务所提供的安全保证,即ACID
-
原子性:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。更贴切的说法是可中止性(abortability)
-
一致性:对数据的一组特定约束必须始终成立。例如事务成功执行后,一个账户上的余额仍然应该大于零。作者认为,这个属性应该由应用程序来保证,而并非只是数据库的责任。毕竟数据库无法阻止应用程序非要写入一些“脏”数据。
-
隔离性:同时执行的事务是相互隔离的:它们不能相互冒犯。例如,一个事务不能读到其它事务的中间执行结果。传统的数据库教科书将隔离性形式化为可序列化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行是一样的,尽管实际上它们可能是并发运行的。但是可序列化隔离的性能代价太大,因此很多数据库没有实现它,而是有些弱隔离级别的保证。
-
持久性:持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。无论是单节点数据库还是实现复制的数据库,都无法保证完美的持久性。毕竟磁盘可能会坏,主库可能故障。
数据库通常会用日志的崩溃恢复来保证原子性,用加锁来保证隔离性,这些显然都带来了性能开销。也就是为什么说有时候不用事务的原因。(可以看到,后面的讨论其实是集中在原子性和隔离性,这两个性质可讨论的东西多一些。)
事务分为单对象操作和多对象操作,保证了原子性和隔离性的单对象操作有时候会被称为“轻量级事务”。有一些场景中,单对象插入,更新和删除是足够的。但很多场景仍旧需要协调多个对象。这种时候,如果不支持多对象事务的话,没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
弱隔离级别
可序列化的性能代价太大了,所以有一些弱事务隔离级别。但弱意味着会有些微妙的并发错误。所以在本节中,我们将看几个在实践中使用的弱(不可串行化(nonserializable))隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便您可以决定什么级别适合您的应用程序。
-
读已提交,最基本的隔离级别,两个保证
-
从数据库读时,只能看到已提交的数据(没有脏读(dirty reads))。
-
写入数据库时,只会覆盖已经写入的数据(没有脏写(dirty writes))。
实现这个隔离级别,最常见的数据库做法是行锁(row-level lock) 来防止脏写。要写前必须获得锁,在事务结束或中止时才释放锁。对于脏读而言,加锁的效果并不太好。数据库一般会在事务写某个对象的过程中,记录这个对象旧的值。在事务未成功之前,其它事务读这个对象得到的都是旧值。
-
-
快照隔离和可重复读,在“读已提交”这个级别中,可能发生这样一种异常,爱丽丝执行转账事务,从A到B转钱。读A的余额时,转账还未成功,读B的余额时,转账已经成功。此时爱丽丝发现自己两个账户的总计余额和之前对不上了。这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew)。这个问题在某些情况不能接受,例如做备份或者扫描全库做检查的时候。最常见的解决方案就是实现快照隔离(每个事务都从数据库的一致快照(consistent snapshot) 中读取,也就是对某个事务而言,所有数据只能读到某一时刻的值,而非动态变化的)
-
防止脏写,仍旧用锁
-
防止不可重复读,使用多版本并发控制(MVCC, multi-version concurrency control)。每个对象的值,都维护了多个版本。这个技术同样可以用来实现“读已提交”的脏读,即保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。对于快照隔离级别而言,同一事务读的多个对象应该是处于同一快照下。
怎么实现MVCC?
一种办法是为每个事务赋予一个递增的ID,然后事务ID会被作为版本号,与那个版本的对象的值存储在一起。对于一个事务而言,它应该能看到满足以下条件的对象的值:
1.读事务开始时,创建该对象的事务已经提交。
2.对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
所以数据库会在事务开始时提供正在进行中但未结束的事务的ID列表,那么再结合自己当前的事务ID,就可以取一个最小值ID,大于这个ID的版本都会被忽略。
-
-
防止丢失更新。前面两个隔离级别主要保证了只读事务如果遇到了并发写入出现冲突的情况。对于两个写入的并发事务,可能存在其它问题。例如“丢失更新”。就是说,一个事务的写入,如果还涉及数据返回到应用->应用计算->写入数据库这些步骤。这样的两个事务同时发生时,其中一个更新可能被丢失(覆盖)掉。(比如,两个往账户A转钱的事务并发冲突丢失更新,效果变成了只有一个起效)那么有以下解决方法。
-
原子写。数据库自己提供了一些原子操作,如果能用这些代替返回应用计算的过程,则再好不过了。(原子操作通常通过在读取对象时,获取其上的排它锁来实现,或者强制原子操作按顺序执行)
-
显式锁定。数据库提供了锁定某个对象的操作(例如,
FOR UPDATE
子句告诉数据库应该对该查询返回的所有行加锁) -
自动检测丢失的更新。由数据库自动检查是否丢失更新并中止。一些作者【28,30】认为,数据库必须能防止丢失更新才称得上是提供了快照隔离
-
比较并设置(CAS,compare and set)。一个针对单对象的原子操作。会比较对象的值是否与之前保存的值一样,一样才会设置新值。
-
冲突解决和复制。以上方法只限于单节点数据库的情况。对于复制数据库,多个节点上存在副本,可能同时被写入,锁和CAS就不适用了。解决方法见第五章。(有些适用于多节点数据库的原子操作,例如+1操作或者向集合增加元素的操作)
-
-
写入偏差与幻读。在多个事务并行发生,读了相同的对象,并且写某些对象时(特殊的,如果写相同的对象,就发生脏读或者丢失更新(取决于时机)),则可能发生写偏差。写偏差形容这样的问题,一个事务的写是否成立,取决于读的某些条件是否达到,如果两个事务顺序执行,前一个事务成功后一个失败。但是两个同时进行,那么两者的条件可能都是成功的。想要真正自动解决这个问题需要“可序列化”的隔离级别。其他有一些比较损失性能的方法,那就是对所有的读对象加锁或者是把冲突物化出来(把条件物化成一个对象,然后查询条件就是把这个对象加锁)。
一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读【3】。快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。
可序列化
可序列化隔离级别很好,但为什么有时候不实现到这个级别呢。先了解下常见的三种实现可序列化隔离级别的技术:
-
字面意义的串行执行。这里需要了解“交互型事务”和“存储过程”的区别。存储过程与内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。(如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。)另外,如果采用分区技术,那么每个节点上的单线程之间都需要对跨分区事务进行协调以保证串行性,这样很慢且有上限。
-
2PL,两阶段锁,结合读写锁。加锁影响并发,以及死锁检测和中止。这些都是很大的性能开销。
另外有些有趣的锁机制。
1.谓词锁,它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象
如果事务A想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock)。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
如果活跃事务持有很多锁,检查匹配的锁会非常耗时。因此,大多数使用2PL的数据库实际上实现了索引范围锁(也称为间隙锁(next-key locking)),这是一个简化的近似版谓词锁
2.索引范围锁,是谓词锁的宽松情况。比如你要预定123号房间12:00到13:00的使用,你可以锁定123号的所有时段,或者锁定12:00到13:00的所有房间。更宽松的情况是锁定整个表,那样性能代价就太大了。 -
乐观并发控制技术,例如可序列化的快照隔离(serializable snapshot isolation,SSL)。 SSI是相当新的:它在2008年首次被描述【40】。
-
基于过时前提的决策:之前提到的“写入偏差与幻读”,其本质是事务执行提交时的前提发生了变化,而快照隔离无法解决这样的过时前提问题。那么想要实现序列化隔离,就要能检测到前提是不是过时了。有两种情况需要考虑:
-
检测对旧MVCC对象版本的读取(读之前存在未提交的写入)
-
检测影响先前读取的写入(读之后发生写入)
-
-
检测旧MVCC读取:数据库需要跟踪一个事务由于MVCC可见性规则而忽略的另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
-
检测影响先前读取的写入:当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务指导其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
-
因为是乐观并发控制技术,SSL最大的优点就是没有像加锁那些的性能损耗。中止率显著影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(只读长事务可能没问题)。简单点说,乐观并发控制,在最后要提交的适合才会检查是否合法,所以如果要执行很长时间的事务,更容易冲突的事务,都会影响其表现。
标签:事务,隔离,对象,数据库,写入,并发,数据系统 来源: https://www.cnblogs.com/dongjl/p/15914393.html