Transaction 事务
- 由一系列的读和写组成
- 这些工作要么全部执行,要么全部不执行
- 保证数据库中存储的数据语义时刻是正确的
START TRANSACTION
[SQL statements]
COMMIT or ROLLBACK
- Atomicity 原子性:事务是数据库最小执行单位
- Consistency 一致性
- Isolation 隔离性:一个事务执行不会被其他事务干扰
- Durability 持久性:一个事务一旦提交,就是永久的
数据库的特性
- Data Integrity 数据完整性:数据的语义正确,靠用户定义
- Accuracy 数据精确性
- Reliability 可靠性
- Correctness 数据语义正确性
Concurrency Control 并发控制
Schedule 调度
调度:多个事务的多个动作实际执行的顺序
- 忽略数据库的初始状态:在任何数据库上都要是好的调度
- 忽略事务的实际语义
- 事务调用的数值在内存中,而不是在磁盘上
定义
- serial schedule(串行调度):不并发,能保证数据库的一致性
- equivalent schedule(等价调度):得到相同结果的两个调度等价
- serializable schedule(可串行化调度):该调度等价为串行调度
- action conflict(动作冲突):如果交换两个动作,会改变调度的语义,则这两个动作冲突
- 同一个事务的任何动作都不能交换,都是冲突的
- 两个事务的两个动作,分别是对于同一个元素的写和读操作,这两个动作冲突
冲突可串行化调度
- 定义Th→Tk:Th与Tk冲突,且Th必须先于Tk执行
- 冲突等价调度:调度S1交换其非冲突的动作后,与S2相同,则S1和S2是冲突等价调度
- 冲突可串行化调度:调度S1与串行调度S2冲突等价,则称S1是冲突可串行化调度
优先图P(S)
- 定点:调度S中的事务T1, T2, ...
- 边:Ti→Tj
- 若S1、S2是冲突等价的,那么P(S1)=P(S2)
- 优先图中,如果不形成环,则说明该调度是冲突可串行化调度
Two phase locking 两段锁协议
- lj(A):事务Tj对A加锁
- uj(A):事务Tj对A解锁
加锁解锁规则
- well-formed transaction:一个事务在对A进行读/写操作之前,一定要对A元素加锁,完成操作后再解锁
- legal scheduler:在一个调度中,事务Ti对A加锁后,在对A解锁前,不能有另外一个事务Tj再对A加锁
- two phase locking:一个事务先加锁,一旦开始解锁,就不再加锁;即第一阶段先(对不同的元素)加锁,第二阶段(对不同的元素)解锁,解锁后不再有加锁操作
定理
- 事务Ti第一个解锁操作记为SH(Ti),若Ti→Tj,那么SH(Ti)在SH(Tj)之前
- 如果事务和调度满足两段锁协议,那么该调度是冲突可串行化调度
死锁
- 一次封锁法:每个事务必须一次将所有要使用的数据全部加锁,否则就不能继续执行
- 顺序封锁法:预先对数据对象规定一个加锁顺序,所有事务都按这个顺序实行封锁
锁的扩展
Shared Locks 共享锁
共享锁允许多个事务同时读同一个元素,记作l-Si(A),相应地,排他锁记作l-Xi(A)
下面,我们更新加锁解锁规则:
- well-formed transaction:一个事务在对A进行读操作前,对A加共享锁;对A进行写操作时,对A加排他锁;完成操作后再解锁
- legal scheduler:在一个调度中,事务Ti对A加共享锁后,在对A解锁前,不能有另外一个事务Tj再对A加排他锁,但允许加共享锁;事务Ti对A加排他锁后,在对A解锁前,不能有另外一个事务Tj再对A加任何锁
- two phase locking:一个事务先加锁,一旦开始解锁,就不再加锁;即第一阶段先(对不同的元素)加锁,包括升级锁,第二阶段(对不同的元素)解锁,解锁后不再有加锁操作
Update Locks 更新锁
如果T1对元素A先读后写,有两种加锁方法:
- 一种为直接加排他锁,但会降低并发度
- 另一种为先加共享锁,写之前再升级为排他锁,这样提高了并发度,但有一些缺陷
- 增加执行时间:T1加共享锁之后,其他事务也可以对A加共享锁,当T1需要加排他锁时,则需要等待
- 出现死锁:如果T1、T2都采用此方式,先后对A加共享锁后,双方都无法升级为排他锁
因此,我们引入更新锁的概念,记作L-Ui(A)
- 如果T1事务很可能对A元素先读后写,那么T1先对A加更新锁,写之前再升级为排他锁
- 若一个事务对A元素加了更新锁,其他事务无法再对A加任何锁
- 若一个事务对A元素加了共享锁,其他事务可以对A加更新锁
用矩阵表示各个锁之间的兼容性如下:
已经加锁\即将加锁 | S | X | U |
S | T | F | T |
X | F | F | F |
U | F | F | F |
锁表
在调度的时候,我们通过锁表来查看元素上锁的情况
我们来看一个锁表的例子:
- 对于元素A,缩表上记录其当前最高的锁(group mode),是否有事务在等待,以及每个事务对它申请加锁的情况的链表
- 链表中记录着事务,申请的锁,是否等待,指向下一个申请同一元素的事务的指针,指向该事务申请的其他元素的锁的指针
Intention Locks 意向锁
意向共享锁记作IS,意向排他锁记作IX
- 从要修改的元素上开始上锁
- 对其所有父节点上相应的意向锁
用矩阵表示各个锁之间的兼容性如下:
已经加锁\即将加锁 | IS | IX | S | X |
IS | T | T | T | F |
IX | T | T | F | F |
S | T | F | T | F |
X | F | F | F | F |
Timestamp 时间戳
根据调度随意访问数据库元素,记录优先图,检查优先图是否有环,如果有,则需要回滚事务
- 每个事务启动的时候都记录一个时间戳
- 记录最后读和写每个数据库元素的事务时间戳
- 比较上述值,以保证按照事务的时间戳排序的串行调度
算法
- 调度器赋给每个事务T一个唯一的数值TS(T)
- 每个数据库元素X上有两个时间戳和一个附加位
- RT(X):读X的事务中最高的时间戳(最晚读X的事务的时间戳)
- WT(X):写X的事务中最高的时间戳(最晚写X的事务的时间戳)
- C(X):X的提交位,当且仅当最近写X的事务已经提交时为真
- 读元素X的时候
- 如果TS(T)>=WT(X),则该请求物理上可实现
- 如果C(X)为真,同意请求,如有必要更新RT(X)
- 如果C(X)为假,推迟T直到C(X)为真或写X的事务终止
- 如果TS(T)<WT(X),该请求物理上不可实现,回滚T
- 如果TS(T)>=WT(X),则该请求物理上可实现
- 写元素X的时候
- 如果TS(T)>=RT(X)且TS(T)>=WT(X),则该请求物理上可实现,更新WT(X)并置C(X)为假
- 如果TS(T)>=RT(X)但TS(T)<WT(X),则该请求物理上可实现
- 如果TS(T)<RT(X),该请求物理上不可实现,回滚T
- 事务T执行完后,提交并置C(X)为真
实例
- 按照每个事务的时间戳,执行的顺序应该为T2→T3→T1
- 执行r1(B)时,在B元素上留下T1的时间戳,即RT=200
- 执行w1(A)时,200>=150,因此可以执行
- 执行w2(C)时,150<175,因此不能执行,回滚T2,T2终止
Validation 有效性确认
- 当事务将要提交时,检查事务的时间戳和数据库元素
- 按照每个事务申请有效性确认的时间对事务进行排序
思想
- 在每次读之前记录RS(Ti),在每次写之前记录WS(Ti)
- 在读写之前,验证按照这步读写是否能保证可串行化
- 如果验证通过,写磁盘;如果验证不通过,事务回滚
- 为了完成有效性确认,需要维护两个集合
- FIN:写盘了的事务
- VAL:通过验证但还没有写盘的事务
算法
(1) When Tj starts reading DB:
Ignore(Tj) ← FIN;
(2) At Validation of Tj:
if Validates(Tj) then
VAL ← VAL ∪ {Tj};
(3) do write to disk:
FIN ← FIN ∪ {Tj};
VAL ← VAL - {Tj};
Validates(Tj):
for each U ∈ VAL do
if (WS(U) ∩ RS(Tj) ≠ ∅ or WS(U) ∩ WS(Tj) ≠ ∅) then
return False;
end for;
for each U ∈ FIN - Ignore(Tj) do
if (WS(U) ∩ RS(Tj) ≠ ∅) then
return False;
end for;
return True;
Recovery 故障恢复
Undo Logging
Log(日志)
事务的记录
- <Ti, start>:事务开始
- <Ti, commit>:事务成功执行并提交
- <Ti, abort>:事务没有成功执行
- <Ti, X, v>:Ti事务更新了元素X,其原来的值为v
记录规则
- 在内存中将所有需要修改的数据计算完成,在内存中写入日志
- 先在磁盘的日志中记录事务更新元素的旧值,再向磁盘中更新数据
- 只有在磁盘中的相应数据全部更新,才在磁盘的日志中写入<Ti, commit>
恢复规则
- 查找磁盘日志中没有<Ti, commit>也没有<Ti, abort>标记的事务
- 从后向前扫描日志,进行回滚,把X的值恢复为v 👉 注意,日志中记录了旧值,但我们无法确定X的值是否更改了,有可能还是旧值v(不知道系统什么时候崩溃)
- 回滚结束后,在日志中写入<Ti, abort>
Redo Logging
- redo log 记录元素更新后的值
- 先把日志(包括commit)写入磁盘,再向磁盘中更新数据
- 查找磁盘日志中有<Ti, commit>标记的事务,从前向后扫描日志,把这些事务重新执行一遍
Simple Checkpointing 静止检查点机制
在日志文件中插入<CKPT>检查点,在检查点之前的记录都不用redo
- 停止开始执行新的事务
- 等所有的事务都完成(commit/ abort)
- 把所有的日志都写到磁盘日志上
- 把所有的记录都写到磁盘数据库上
- 在磁盘日志中写入<CKPT>
- 重新开始事务执行
Undo/Redo Logging
- Undo:事务的所有写盘操作后才写入commit,可能增加磁盘I/O的代价
- Redo:直到commit后才能写盘,可能导致内存缓冲迟缓
- Undo/Redo:记录旧值和新值,为命令操作提供更大的灵活性
记录规则
- 先在磁盘日志中记录<Ti, X, old, new>,再向磁盘中更新数据
- 在内存中,事务完成后,将commit写到日志中
- 磁盘更新数据可以在commit写到磁盘日志之前或之后
恢复规则
- 对没有commit记录的事务,undo 撤回
- 从后往前扫描,找到需要undo的事务,撤回
- 把X元素更新为old值,写到磁盘上
- 对有commit记录的事务,redo 重新执行一遍
- 从前往后扫描,找到需要redo的事务,重新执行
- 把X元素更新为new值,写到磁盘上
Nonquiescent Checkpointing 非静止检查点机制
- 在内存日志中写入<START CKPT T1, ... , Tk>,代表开始检查点,T1, ... , Tk为还没有完成的事务(活跃事务)
- 将内存里所有dirty buffers(内存与磁盘不一致的记录)都写出到磁盘,并执行更新
- 写入<END CKPT>,代表检查点结束
- 在开始检查点之前所有的操作全部完成,在开始检查点之后的操作需要undo/redo
- Undo:从后往前扫描,undo操作,如果扫描到开始检查点,发现该事务是活跃事务,仍要继续往前扫描到该事务开始
- Redo:从前向后扫描,redo操作