事务并发机制之两阶段锁篇
author:何绍泽
前言
正如《快速理解什么是事务》篇幅里讲的,多个事务一起时,为了实现隔离性,需要并发机制。(如果还不了解事务的建议查阅上一篇文档。)因为数据库中的对象是共享的,假如不同的用户同时修改某个对象,就会出现数据错乱,从而破坏数据库的数据一致性,违反事务的隔离性原则。为了满足隔离性的要求,数据库需要实现并发控制机制。
并发控制机制可以采用不同的方法实现,概括地说,可以分成基于封锁的并发控制和基于时间戳的并发控制,不同的数据库在实现并发控制时会根据自身的特点对这两种技术进行改进。unvdb数据库采用两阶段锁(Two Phase Lock,2PL)和MVCC相结合的方法来满足事务的隔离性要求。
如果事务调度系统能够保证事务逐个执行而不交叉,那么就说事务符合可串行化的要求。所谓的事务可串行化,指事务虽然是并发交叉执行的,但执行结果和串行执行的结果一致。串行执行的效率比较低,为了提高系统的吞吐量,事务需要并发执行,因此在数据库中就需要实现并发控制机制,这样既能满足事务隔离性的要求,又能提高数据库的并发性能。
本篇主要介绍两阶段锁的基本概念。
基本概念
虽然我们用最通俗易懂的方式介绍,但还是需要了解一些基本的术语定义。
并发中的异常现象有哪些:
脏读,在访问同一个元组对象的时候,事务T1写(修改)了这个元组,但还未提交,事务T2读取了事务T1修改的这个元组,此时就为脏读。
不可重复读,假设两个事务在访问同一元组,事务T1首先读取了元组数据,紧接着事务T2修改或删除了元组,当事务T1再次来读该元组的时候 发现数据与第一次读的不一致。此时就是不可重复读异常。
幻读,同样多个元组再操作同一个对象,本次是元组集,事务T1 通过谓词条件(谓词条件即where之类的)读取了一组元组,此时事务T2插入了一个元组正好符合我谓词条件的内容,当事务T1再次读的时候多了一个元组和第一次元组集不一样,这就是幻读。
调度:避免事务之间冲突的最暴力的解决方法就是串行化执行,这就是第一个概念事务的串行调度 ,但这是不现实的,那么事务之间同时作业就一定会冲突吗?不,如事务T1和事务T2同时读同一个元组,这是没关系的,那么我们就将两个事务交替执行,但不影响执行结果的这种称作可串行化调度。
冲突 :如果两个分属于不同事务的操作中有一个是写操作,且两个操作针对的是同一个数据库对象,那么这两个操作是冲突 的。反之则是不冲突。
冲突的操作不能交换执行顺序,不冲突的操作可以交换执行顺序。
两阶段锁
要实现冲突可串行化的调度,可以采用对数据库对象封锁的方式。当读取一个数据库对象时,可以对这个对象加共享锁(S锁);当修改一个数据库对象时,可以对这个对象加排他锁(X锁)。
加锁操作需要在访问数据库对象之前执行,而放锁的时机就比较微妙了。如果要实现一个真正的可串行化调度,那么需要借助两阶段锁机制。两阶段锁协议将事务的封锁过程分成了以下两个阶段。
• 增长阶段(Growing Phase):事务可以尝试申请任何类型的锁,但是这个阶段不允许
释放锁。
• 收缩阶段(Shrinking Phase):事务可以放锁,但是禁止再申请新的锁。
所谓的两阶段即是上面两个阶段。
两阶段锁为什么能保证可串行化调度?光看上面的介绍感觉我用普通锁也行啊?
1假设我们使用普通锁,就会有如下:
可能的调度序列
| 时间 | 事务 T1 | 事务 T2 |
|---|---|---|
| 1 | 锁 A → 读 A (100) → 解锁 A | |
| 2 | 锁 B → 读 B (100) → 解锁 B | |
| 3 | 锁 B → 读 B (100) → 解锁 B | |
| 4 | 锁 A → 读 A (100) → 解锁 A | |
| 5 | 写 A (A = 50) → 锁 A → 写 A → 解锁 A | |
| 6 | 写 B (B = 70) → 锁 B → 写 B → 解锁 B | |
| 7 | 写 B (B = 150) → 锁 B → 写 B → 解锁 B | |
| 8 | 写 A (A = 130) → 锁 A → 写 A → 解锁 A |
最终结果
-
A = 130(被 T1 减为 50,又被 T2 加为 130)
-
B = 150(被 T2 减为 70,又被 T1 加为 150)
总余额**:130 + 150 = 280,凭空多了 80 元!
即当前事务在使用完后,会立即释放,另一个事务就会访问到该对象,而当前事务是一系列sql可能操作还没做完,故会出现上述问题。
2 两阶段锁:
调度序列(假设 T1 先获得所需锁)
| 时间 | 事务 T1 | 事务 T2 |
|---|---|---|
| 1 | 锁 A → 读 A (100) | |
| 2 | 锁 B → 读 B (100) | |
| 3 | 写 A (50) | |
| 4 | 请求锁 B → 等待(因为 B 被 T1 锁住) | |
| 5 | 写 B (150) | 等待 |
| 6 | 提交,释放所有锁 | |
| 7 | 获得锁 B → 读 B (150) | |
| 8 | 请求锁 A → 等待(A 已释放,立即获得) | |
| 9 | 写 B (120) | |
| 10 | 写 A (80) | |
| 11 | 提交 |
最终结果
- A = 80
- B = 120
总余额 200,数据一致。T2 看到的是 T1 提交后的数据,执行顺序等价于 T1 先完成,T2 后完成。
结论:基于上述我们不难看出,普通锁,是用完即释放了,而当事务是一系列操作的时候,在整个事务未提交就释放了锁,别的事务进来就有可能造成错误的修改。而两阶段锁 ,是在整个事务未提交之前,都不释放锁,直到整个事务回滚或者提交才释放锁。即所谓的两阶段是对于当前要获取锁的事务 而言,获取锁第一阶段 只准获取锁,不准释放。第二阶段,你事务要提交了或者回滚了,那么允许你进入第二阶段,该阶段只能释放锁,不能获取锁了。
总结
- 两阶段锁是控制事务并发的机制之一。
- 两阶段锁定义了事务获取锁的规则,普通获取锁是用完即释放,而两阶段锁是,事务用完了也不允许释放,直到你事务结束(即回滚或者提交)才能释放。
- 现实中两阶段锁并非独立存在,通常会配合MVCC机制使用。