Loading

事务

事务

事务是数据库系统面向应用层服务的一个执行单元,一次事务可以包含一批SQL代码,用于执行一个操作。

比如A向B转账100这个操作可以封装成一个类似如下操作的事务,这里没有使用SQL代码,而是伪代码,后面我们还会看到很多次这样的伪代码

// 读取A的余额,减去100,写回数据库
read(A)
A := A - 100
write(A)

// 读取B的余额,加上100,写回数据库
read(B)
B := B + 100
write(B)

四大特性

  • 原子性(Atomicity):一次事务中虽然有多条SQL语句,但数据库系统应视它们为一个整体,也就是说,这些SQL语句要么都做了,要么都没做,不能做了一点。用上面的例子,如果只做了前三行操作,那么数据库中的所有用户余额总数就会少100,这可能会使数据库处于不一致的状态。如果事务在它其中的某一个操作上执行失败,那么数据库要撤销前面已经进行过的操作。
  • 隔离性(Isolation):一次事务是一个整体,尽管其中可能包含了许多次数据库访问,但它也不能被数据库中其它并发执行的操作隔开(至少看起来不能)。也就是说针对两个事务\(T_i\)\(T_j\),在\(T_i\)看来,\(T_j\)一定在\(T_i\)开始前就执行完毕或在\(T_i\)执行完毕之后才开始。事务感受不到系统中有其它事务正在并发执行。
  • 持久性(Durability):如果数据库在执行过程中因为某些原因不得不停止运行(崩溃),系统也会处于不一致的状态,这时,要求数据库仍然“记得”这些未执行完毕的事务,并在系统正常时做出处理。
  • 一致性(Consistency):其实上面三个特性中描述的内容,都显示了一些情况下数据库内容会处于不一致的状态,一致性保证当一个事务开始前,如果数据库处于一致的状态,那么事务结束后,数据库仍然处于一致的状态。

这四个特性称为事务的ACID特性。

事务原子性和持久性

事务一旦中止,它对数据库所做的变更必须撤销。一种方式是使用重做日志系统,事务中的所有操作都会先记录在日志上,包括修改的数据项、新值和旧值等,然后再执行具体的事务操作。

事务存在如下状态:

  • 活动的(active):事务执行时的状态
  • 部分提交的(partially committed):最后一条语句执行后,但还没有最终提交事务
  • 失败的(failed):发现正常的执行不能继续后
  • 中止的(aborted):事务回滚并且数据库已经恢复到事务开始前的状态
  • 已提交(committed):成功完成后

一个事务从开始到结束,大概就是这个流程

需要注意,当事务部分提交后,也就是所有语句都执行完,仍有可能出现一个硬件错误导致事务无法完成,这时事务做出的更改可能还在内存中,没有回写到数据库,从而进入失败状态。这也就是区分部分提交状态和已提交状态的原因。

接着数据库会向磁盘日志系统中写入信息,下次系统重启时,该事务做出的修改会被写回到磁盘,事务被重新创建,进入提交状态。

一个简单的事务模型

为了隐藏SQL中的一些细节,把目光聚焦在所讲内容上,我们通过之前的伪代码建立如下的事务模型。

该事务模型中有一些账户和一些允许的操作。

  • read(X):将数据库中账户X的余额读入内存中的变量X中
  • write(X):将内存中的X变量写入数据库中的账户X的余额中
  • 其余算数操作

可串行化调度

如果让不同的事务之间串行的执行,那么ACID特性很好保证。但是这样会牺牲系统并发处理的能力,所以我们还需要让事务之间并行执行。这样就需要做一些隔离操作,让ACID特性得以保持。

我们先使用刚刚的模型定义如下两个事务

\(T_1\):账户A转账50到账户B

read(A)
A := A - 50
write(A)
read(B)
B := B + 50
write(B)

\(T_2\):账户A将余额的10%过户到B

read(A)
temp := A * 0.1
A := A - temp
write(A)
read(B)
B := B + temp
write(B)

现在,如果这两个事务串行执行,先\(T_1\)\(T_2\),那么数据库的一致性不会丢失,同样,如果反过来,先执行\(T_2\)再执行\(T_1\),数据库的一致性也不会丢失。

数据库系统需要计划如何安全的(不丢失一致性的)执行多个事务,就像刚刚这两个串行执行一样,这种执行计划就叫做调度

我们关心的是那些可以安全的让多个事务并发执行的调度,比如下面这个

在调度3中,\(T_1\)\(T_2\)并发执行,但不会影响结果的一致性,可以带两个数进去看一看。比如最初\(A=2000, B=1000, A + B = 3000\),当这个调度执行完,\(A=1755, B=1245,A + B = 3000\)仍然成立。

如下是一个没法保证一致性的并发事务调度。

假设还是\(A=2000,B=1000\),注意\(T_2\)的第一行,它通过read(A)读取到的数据还是2000,因为\(T_1\)中的减50操作并没有通过write(A)回写。这里\(T_2\)就读到了与\(T_1\)中不一致的数据,自然最终结果的一致性无法得以保存。同样的,\(T_2\)中的read(B)之后,\(T_1\)将B修改并写回,然后\(T_2\)再根据之前读到的数据进行操作,数据不一致又产生了。

按照调度4,得到的最终结果是:\(A=1950, B=1200, A+B=3150\)

所以一些并发调度并无法保证一致性,我们想要的是那些产生的最终结果与某种串行调度一致的并发调度,这样的调度可以保证一致性。这种调度称为可串行化(serializable)调度。

冲突可串行化

先看什么样的调度是可串行化的,串行调度是可串行化的。

现在我们只考虑\(read\)\(write\)操作

如果对于一个调度\(S\),里面有属于\(T_i\)\(T_j\)的两条连续指令\(I_i\)\(I_j\),并且它们引用相同的数据项,我们需要考虑如下四种情形:

  1. \(I_i=read(Q), I_j=read(Q)\)\(I_i,I_j\)的顺序无关紧要,它们读取的Q值总是相同的
  2. \(I_i=read(Q), I_j=write(Q)\),二者顺序无法调换,因为后者的写入会影响前者的读入结果
  3. \(I_i=write(Q), I_j=read(Q)\),二者顺序无法调换,因为前者的写入会影响后者读入结果
  4. \(I_i=write(Q), I_j=write(Q)\),二者顺序调换不会影响二者的写入结果,但会影响到之后的\(read(Q)\)指令,因为后面的读取只能读取到二者中较晚写入的结果

所以如果两个来自不同事务的连续指令\(I_i,I_j\)操作相同数据项,并且其中有一个是write,它们两个就无法调换顺序,称它们是冲突的

如果一个调度S可以通过一系列非冲突指令交换转换成S',称S和S'是冲突等价的

如果一个调度S可以通过一系列非冲突指令交换转换成串行调度,称S是冲突可串行化的

至此我们就知道什么样的并发事务调度是可串行化的事务调度了。

比如这个调度5

第一步的非冲突指令调换:

T1         T2
read(A)    
write(A)
read(B)
           read(A)
           write(A)
write(B)
           read(B)
           write(B)

第二步:

T1         T2
read(A)    
write(A)
read(B)
           read(A)
write(B)
           write(A)
           read(B)
           write(B)

第三步:

T1         T2
read(A)    
write(A)
read(B)
write(B)
           read(A)
           write(A)
           read(B)
           write(B)

调度5是冲突可串行化的。

调度7不是冲突可串行化的,因为无论我们调换哪两条指令的顺序,都是冲突的。

优先图

优先图给了一个简单的办法确定一个调度是否是冲突可串行化的。

一个调度S的优先图\(G=(V, E)\)\(V\)是顶点集,由所有参与调度的事务组成,\(E\)是边集,由满足下面三个条件之一的边\(T_i\to T_j\)组成

  1. \(T_i\)执行\(read(Q)\)前,\(T_j\)执行\(write(Q)\)
  2. \(T_i\)执行\(write(Q)\)前,\(T_j\)执行\(read(Q)\)
  3. \(T_i\)执行\(write(Q)\)前,\(T_j\)执行\(write(Q)\)

也就是说,只要在两个事务中分别出现操作相同数据项的操作,并且有一个事务中的是写操作,那么就在代表两个事务的顶点之间画一条边。

如果优先图中存在\(T_i\to T_j\),那么在任何等价于S的串行调度中\(T_i\)必定出现在\(T_j\)之前。好理解,因为导致边产生的两条指令都是冲突的,不可调换顺序,所以自然不可能出现\(T_j\)\(T_i\)前执行的情况。

如果优先图有环,那么该调度非冲突可串行化,如优先图无环,该调度冲突可串行化。有环就说明在调度中两个事务穿插的操作相同数据项,并且有写操作。

调度故障恢复

可恢复调度

上面一直没讨论发生故障的情况。

假设事务\(T_i\)失败了,那么必须撤销它以确保原子性,如果这时\(T_j\)读取了\(T_i\)写入的数据,那么\(T_j\)也得被撤销,所有依赖\(T_i\)的事务都要被撤销。

上图,\(T_7\)读取了\(T_6\)写入的数据并直接进行了提交,当它提交后如果\(T_6\)发生错误失败,那么要撤销\(T_7\),但\(T_7\)已经提交,这就是不可恢复调度。

可恢复调度保证对于所有的\(T_j\),若\(T_j\)依赖\(T_i\),那么\(T_j\)的提交必须在\(T_i\)提交之后。

无级联调度

如果存在非常深的依赖关系,那么挨个撤销起来很麻烦。

无级联调度保证对于所有的\(T_j\),若\(T_j\)读取\(T_i\)写入的数据,则在\(T_j\)读取前,\(T_i\)必须已经提交。

无级联调度也是可恢复调度。

事务隔离性级别

完全可串行化执行会让并发度变得很小,所以通常允许事务以不可串行化的方式执行,这样,读取到的数据未必精确,但可以提高并发度。

SQL定义了如下隔离级别:

  1. 可串行化(serializ
  2. able):通常保证可串行化调度。大部分数据库系统在该隔离级别下同样允许一定程度的非可串行化执行。
  3. 可重复读(repeatable read):只允许读取已提交数据,一个事务两次读取一个数据项期间,其他事务不可更改该数据项。
  4. 已提交读(read committed):只允许读取已提交数据,但不要求可重复读。一个事务两次读取一个数据项期间,另一个事务可以更新该数据项并提交。
  5. 未提交读(read uncommitted):允许读取未提交数据。

以上隔离级别对读逐渐放宽限制,但是对写,任何隔离级别都不允许一个数据项已经被另一个尚未提交或中止的事务写入。

隔离性级别的实现

  1. 时间戳
  2. 多版本快照

习题

串行调度指调度中的事务按串行方式执行,可串行化调度指调度可以按照一些非冲突的指令调换转换成串行调度。

a很简单,略

b:

T13           T14
read(A)
read(B)
              read(B)
              read(A)
if A=0...     
write(B)      if B=0...
              write(A)

c:
不存在,对于任意一个\(T_{13}\)\(T_{14}\)的调度\(S\),若它是并发调度,那么以下两个前提条件必须成立:

  1. 调度\(S\)\(T_{13}\)的第一行肯定在\(T_{14}\)的最后一行之前执行,否则\(S\)就不是并发调度
  2. 调度\(S\)\(T_{14}\)的第一行肯定在\(T_{13}\)的最后一行之前执行,否则\(S\)就不是并发调度

根据条件1,调度\(S\)的优先图中一定有一个从\(T_{13}\to T_{14}\)的边。
根据条件2,调度\(S\)的优先图中一定有一个从\(T_{14}\to T_{13}\)的边。

优先图存在环,调度\(S\)不是一个可串行化调度。

已提交读隔离级别中的事务可以读取到其他事务在它执行期间提交完成的数据,这正符合无级联的定义。

a.

T1             T2
read(A)
write(A)
               read(A)
               write(A)
read(A)
               commit
commit

b.

T1             T2
               read(A)
read(A)
write(A)
commit
               write(A)
               commit

c.

T1             T2
read(A)
               read(A)
read(A)
write(A)
               write(A)
commit
               commit

这应该是对的吧,如果不是请评论一下,非常感谢!!!!!

posted @ 2021-11-12 17:54  yudoge  阅读(135)  评论(0编辑  收藏  举报