在多用户使用的数据库情境中,为了确保每个用户在读取或者修改数据时一致,且尽可能提高处理并发请求的能力,数据库通常需要一种机制,叫做并发控制 (Concurrency Control).
事务 (Transaction),是连续的对数据库操作的集合,对于一个事务中的所有数据库操作:要不全部成功,不要全部失败。也就是说,如果事务A中写操作a成功了,但是写操作b失败了,数据库会拒绝a操作的提交。并发控制是和事务紧密相关的概念,并发控制通常指的是对事务的并发控制。事务能保证用户操作意图的完整性。例如典型的一个情景:从小王账户转20块钱去小刘的账户。这里牵扯两个对数据库数据的修改,小王账户 – 20, 小刘账户 + 20。如果这两个修改没有在一个事务中,也就是没有一个事务约束两个操作同时成功,那么很可能出现诸如小王账户扣了钱,但是小刘没有收到钱 (更新失败)的问题。
并发控制的有两种实现方法:
基于锁
基于时间戳
基于锁的原理如下:
一个事务拥有两个阶段,增长阶段 (growing phrase) 和收缩阶段 (Shrinking).
在增长阶段,事务可以尝试获取所有涉及记录的锁,这样就能保证暂时的排他的拥有并修改数据。如果另一事务需要读或者写已经被锁住的记录,那么该事务就会被阻塞。这样持有锁的事务就保证了当前自己对记录的读取和修改,在事务的整个周期内都是一致的,也就是读到的记录不会被其它事务篡改,而写过的记录不会被其它事务重新覆盖。
在收缩阶段,事务不再获取锁,而是释放持有的所有锁。
这一版本的并发控制有死锁的问题。
例如:
txn #1 (growing) lock A lock B read A write B (shrinking) unlock A unlock B commit
txn #2 (growing) lock B lock A write A read B (shrinking) unlock B unlock A commit
基于时间戳的原理如下:
数据库和事务通过时间戳来监测不一致的读写,并相应的中止受到干扰的事务。
每个事务刚开始都会获得一个时间戳,数据库对于存储的每一个记录,都有一个上一次事务读写的时间戳的标记。例如:
读时间戳 写时间戳
记录 A 10000 10000
记录 B 10000 10000
事务1时间戳10001,事务2时间戳10005。
刚开始,记录A, B的时间戳记录都是10000. 事务1开始后,假定读了A,写了B,则此时:
读时间戳 写时间戳
记录 A 10001 10000
记录 B 10000 10001
如果这时事务1暂时在处理拿到的数据,没有进一步数据库操作,而事务2写了记录A,则:
读时间戳 写时间戳
记录 A 10001 10005
记录 B 10000 10001
记录A的写时间戳被改变了。假定事务1之后尝试写记录A,会发现记录A具有“未来”的时间戳,说明记录A被新的事务修改过了。此时事务1知道自己的操作收到了影响,因此事务1可以终止。
在理想情况下,数据库应该实现事务的串行化(Serializability)。 串行化是事务隔离的一种程度,是一种最高的程度,在这种程度下事务之间没有影响。所谓事务隔离,指的是事务之间在执行的过程中相互影响的程度。这里有两个概念,一个是事务隔离程度 (Isolation level),一种是帮助定义隔离程度的异常 (Anomaly)
首先总结一下四种事务之间影响的异常:
脏读 (Dirty Read). 事务1读了事务2没有提交的写。这个异常不好之处在于事务1将自己依赖于事务2之上。因为事务1读了事务2的写,所以事务1的成功也依赖于事务2的成功。如果事务1失败了,那么事务2所看到的修改其实是不存在的,那么如果事务2成功了,那么问题就发生了。
不可重复的读 (Unrepeatable Read). 事务1在time 1读了数据A,然后事务2在time 2修改或者删除了数据A, 然后提交成功,事务1回来在time 3读到A是一个不同的版本,也就是事务1读了不是自己修改的数据。这一个现象在并发中非常常见,例如两个线程共享一个没有锁保护的变量。显然这种异常会影响程序的正常运作。
幻象读 (Phantom Read). 幻想读是不可重复读的一种特殊例子,也就是事务1读到事务2新加入的数据。在不可重复读中,事务1对数据A的读受到了影响。在幻想读中,事务1对某一些数据的读会受到影响。比如说,如果要消除不可重复读,那么可以让事务1在读数据的时候先拿到锁,然后在提交时释放锁。但是,如果事务1在做一个范围查询,那么所以事务1能拿到已有的数据的所有锁,但是事务2如果新插入一个数据,事务1是不知道的。那么事务1第二次做同样的范围查询时,还是能看到上一次没见过的数据。解决这个异常的方法之一就是支持范围锁。
更新丢失 (Lost Update). 基于时间戳的实现版本带来的问题,略过….
然后是隔离程度:
串行化。确定异常1,2,3, 4 都不会出现。也就是很安全。
可重复读 (Repeatable Read)。幻想读可能出现。
提交的读 (Read Committed)。幻想读和不可重复读可能出现。
未提交的读 (Read Uncommited)。所有异常都可以出现。这里等于完全没有并发控制。
上面提到的四种隔离程度的定义来自于基于锁的并发控制。后来出现的基于时间戳的实现方法增加了新的可能的异常,所以学界又定义了多两种隔离程度:一个是Cursor Stability,一个是Snapshot Isolation,在此暂时不展开了。总结一下,就是串行化的隔离程度最高,但是数据库一般默认使用的隔离程度比较低,除非单独设定。
在业界常见的数据库里,没有会使用上面提到过的简单的并发控制的算法。为了提高并发处理能力,同时保证良好的隔离程度,学界提出了多个复杂的算法,例如多版本并发控制 (Multiversion Concurrency Control)。
多版本并发控制及其它高级算法留在以后的文章解释。
参考资料:
1. Isolation Level in Database Engine.
2. Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O’Neil, Patrick O’Neil, A Critique of ANSI SQL Isolation Level, ACM SIGMOD Record 24 (2), 1-10, May 1995.