原文链接:http://coding-geek.com/how-databases-work/#Buffer-Replacement_strategies
紧接上一篇文章,本文翻译了如下章节:
Transaction manager(事务管理器)
一个满足ACID标准的事务,符合以下四个条件:
Consistency(一致性): 只有满足数据库约束的有效数据能写到数据库。一致性与原子性和隔离性强相关。
在同一个事务中,你可以执行多条SQL语句去查询、修改、新增、删除数据库中的数据。当多个事务同时访问一份数据时,混乱出现了。最经典的例子就是转账汇款,从A账号转账到B账户。想象一下,有这样两个事务:
对应到事务的ACID原则上来说:
现代数据库不会使用纯粹、完全的事务隔离,因为它会带来极大的性能损耗。SQL规范定义了四种隔离模式。
Repeatable Read(可重读,
MySQL默认级别):每个事务独立执行。不过,如果一个事务添加了新数据,并已提交完毕;另外一个正在执行的事务能看到新加的数据。但,如果一个事务是修改数据后提交完毕,另一个正在执行的事务是看不到这种修改的。,在新增加数据的情况下,破环的事务了的隔离性。
例如:事务A正在执行“SELECT COUNT(*) FROM
TABLE_X”,这时事务B往TABLE_X添加了数据。如果事务A再次执行COUNT(*)操作,前后两次的查询结果不同。这种情况被称之为”幻读”。
Read Committed(Oracle、PostgreSQL、SQL
Server默认支持的级别):这种隔离度是在Repeatable
Read的基础上,增加了一条打破事务隔离性的规则。如果事务A读取了数据D,同时事务B对数据做了修改(包括删除)后提交。事务A再次读取数据D,能感知事物B对数据D的修改。
也就是说,Read Committed模式下,一个事务既可以感知另一个事务添加新数据,也能感知这个事务对数据的修改。这个模式也叫
non-repeatable read。
Read uncommitted:隔离性最差的一种方式,它是在 Read
committed的基础上又增加了一条破坏事务隔离性的规则。事务A读取了数据D,同时数据D被事务B做了修改(事务B还未提交,还在执行过程中);如果事务A再次读取数据D,它将感知数据D被修改了,然后事务B回滚,A持有的数据还是被事务B修改后的。实际数据D未被修改(因为事务B回滚了)。
这种模式叫”脏读”。
大多数数据库会添加自定义的隔离性级别, 例如在Oracle、PostgreSQL、SQL Server使用的snapshot Isolation(快照隔离)。很多时间,数据库不会支持SQL规范中定义的所有隔离模式(特别是 Read uncommitted模式)。
用户在连接到数据库时,可以修改默认隔离模式。
支撑数据库实现事务隔离性、一致性、原子性的关键是解决好数据库同写的问题(含添加、删除和修改)。
1) 如果所有的事务仅仅是读取数据,他们能并行工作,相互无影响。
2) 如果有一个事务(哪怕只有一个)在修改其他事务读取的数据,数据库需要考虑如何屏蔽数据修改对其它事务的影响。并且,需要要确保修后的数据不会被其它事务覆盖。
这种技术称为“并发控制”。
解决这个问题最简单的方法是让多个事务按时间先后依次执行(串行化)。但是,这是一种非常低效的做法(在多核处理器上仅跑一个任务)。
理想的解决方式是随时允许创建事务、运行事务、删除事务。要达到这个目标,需要做到以下几点:
本质上来讲,这是一个冲突事务的调度问题。冲突事务调度的算法是非常复杂的,也非常耗时。企业级的数据库不可能花费几个小时去寻找最优调度策略,处理冲突事务。
因此,它们仅使用简易的调度策略,使得算法耗费的时间在可接受的范围内。当然这种调度策略会导致突事务更多的时间等待。
为解决事务冲突的问题,大多数数据库使用加锁和数据版本管理两种策略。这是一个大的命题,我将聚焦在锁管理部分,适当介绍一下数据版本管理。
Pessimistic lock(悲观锁)。它背后的原理是:
这种锁也叫独享锁-exclusive lock。
但是,使用独享锁将导致访问数据库代价高昂。因为,它要求其它也需要读取同一批数据的事务等待。这也是为什么存在另外一种锁—(shared lock)共享锁。
Shared lock(共享锁)。其原理是:
如果一块数据已经添加了Pessimistic lock。另外一个事务即使只是读数据(需要对数据加shared lock),也需要等待Pessimistic lock释放;否则读取的是脏数据。
Lock manager的职责就是管理锁的申请和释放。Lock manager通过哈希表管理锁资源,也管理着锁与数据的关联关系。包括:
使用锁有可能导致一个问题,即两个事务同时等待对方释放锁。
在这张图中,可以看到:
这就出现了死锁。
出现死锁时,Lock manger将选择其中一个事务回滚以解除死锁状态。选择哪一个事务回滚,这是个很复杂的问题,要考虑以下方面:
当然,在做出回滚的决策之前,先要明确是否已经出现了事务死锁。
依据lock manager的哈希表,能画出一个依赖关系图(类似上面的截图)。如果在图中出现了环路,即意味着出现了死锁。检查是否出现环路是非常耗时的,因为依赖关系图的数据量通常很庞大;所以,一般采用更简单的方法:判断是否超时。如果事务申请的锁未在指定的超时时间内分配,则认为事务进入了死锁。
Lock manager能够判断新申请的锁是否会导致死锁。同样的,要做出准确的判断,算法也是非常耗时间的。取而代之,它采用一些检查条件来判断。
为确保一个事务完全隔离,最简单方法是在事务开始时申请锁,在事务结束时释放锁。这意味着,事务必须等待申请完所有需要的锁才开始运行,在执行过程中完全占用锁,结束时才统一释放。这种方案逻辑上没问题,但是会耗费很多时间在等待锁资源上。
一种更快一些的方案是Two-Phase Locking Protocol(在DB2和SQL Server中使用)。在这种方案中,一个事务被分解为两个阶段。
其背后的原理有这样两条:
这种策略能完美运行,除非一个事务修改了数据,释放了锁,然后又回滚事务。另一个事务在前一个事务释放锁后,读取了数据;它不清楚修改后的数据后面又发生了回滚。
为了避免出现这种情况,规定所有的exclusive lock必须在事务结束时才释放。
再多说几句:
当然,一个真实的企业级数据库会使用更复杂的方案,更丰富的锁(如:意向锁),更细的锁控制粒度(基于行、分页、分区、表空间等)。但,其核心思想是一样的。
这里,我只描述了最基础的锁的原理。Data versioning(数据版本管理)是另一种解决事务冲突的方案。
数据版本管理的基本原理是:
如果多个事务修改相同的数据,只有一个事务的修改会被持久化,其它事务的修改会丢弃(事务回滚,后面也可能re-run)。
这种方式带来性能上的提升,因为:
如果没有出现两个事务同时写同一片数据,这种方式更好。但是,这种方式需要巨大的磁盘空间开销。
数据版本管理和锁管理是两种不同的思想: 乐观锁(optimistic locking)与悲观锁(pessimistic locking)。它们都同时存在支持方和反对方,使用哪种方式依赖于具体的引用场景(more reads VS more writes)。说到数据库对Data versioning的支持情况,我觉得PostgreSQL的多版本数据管理并发控制做得非常强大。
一些数据库,如DB2(9.7之前版本)和SQL Server(除了所谓的视图快照隔离)仅支持加锁的机制。其它一些数据库,如PostgreSQL、MySQL和Oracle同时支持加锁和数据版本管理两种方式。我不知道有什么数据库是仅支持Data versioning的(如果你知道,请告诉我)。
如果你已经读过了介绍隔离性不同级别的章节,就应该清楚。提升隔离性将增加锁的数量,增加事务申请锁的等待时间。这也是为什么大多数据库不将隔离性最强的串行化(Serializable),设置为默认级别的原因。
你也可以在主流的数据库(如MySQL、PostgreSQL、Oracle)指导文档中检查它的设置情况。