构成单一逻辑工作单元的操作集合称为事务(transaction)
1. 事务的概念
事务是访问并可能更新各种数据项的一个程序执行单元(unit)
。
通常用形如begin transaction
和end transaction
语句或者函数调用来界定
这些步骤集合必须作为一个单一的、不可分割的单元出现,因为事务是不可分割的,所以要么执行其全部的内容,要么就根本不执行。
2. 事务的特性(ACID)
原子性(atomicity)
:事务的所有操作在数据库中要么全部正确地反映出来,要么就完全不反映
一致性(consistency)
:隔离执行事务的时候(在没有其他事务并发执行的情况下)
保持数据库的一致性,如果一个事务作为原子,从一个一致的数据库状态开始独立地运行,那么在事务结束的时候数据库也必须再次是一致的
隔离性(isolation)
:尽管多个事务可能并发执行但是系统保证,对于任何一对事务Ti
和Tj
,在Ti
看来,Tj
也许是在Ti
开始之前完成执行,也可能是在Ti
结束之后开始执行的,对于系统中的每个事务,它们都感受不到系统中有其他事务在并发地执行
持久性(Durability)
:一个事务成功之后,它对数据库的改变必须是永久的,即使后面出现了系统故障
3. 简单事务模型
事务运用以下两个操作来访问数据
read(X)
:从数据库把数据项X传送到执行read
操作的事务的主存缓冲区,为主存缓冲区中,同样存在的这个X变量附上值write(X)
:从执行write
的事务的主存缓冲区的变量X中把这个X写回到数据库中
一个数据项的变化有可能是出现在主存中的,也有可能是已经写回了磁盘中,在实际的数据库系统中,
write
操作不一定立即更新磁盘上的数据,write
操作的结果可以临时存储在某处,以后再写入到磁盘上。但是在本章中我们假设
write
会马上更新数据库
不一致状态(inconsistent state)
:系统的状态不再反映数据库本应该描述的显示世界的真实状态。
但是需要注意的是,系统必然会在某一时刻存在一个不一致的状态但是这一个状态会最终被一个一致性的状态所替代。
一种避免事务并发执行而产生问题的途径是串行地
执行事务.
4. 存储结构(了解)
- 易失性存储器(volatile storage):其中的信息通常在系统崩溃后不会幸存,主要有主存储器和高速缓存存储器,其访问速度很快,一方面是因为其访问自身的速度,另一方面是因为可以直接访问易失性存储器中的任何数据项
- 非易失性存储器(nonvolatile storage):非易失性存储器中的信息会在系统崩溃后幸存,包括有磁盘和闪存,光介质和磁带
- 稳定性存储器(stable storage):稳定性存储器中的信息永远不会丢失。
5.事务的状态(重点)
事务必须处于以下的状态之一:
- 活动的
active
:初始状态,事务执行的时候处于这个状态 - 部分提交的
partially committed
:最后一条语句执行之后 - 失败的
failed
:发现正常的执行不能继续后 - 中止的
aborted
:事务回滚并且数据库已经恢复到事务开始执行前的状态 - 提交的
committed
:成功完成后
只有在事务进入提交状态后,我们才说已经提交
只有当事务进入了中止状态,我们才说事务已经中止,如果事务已经是提交的或者中止的,它就被称为已经结束的terminated
关于部分提交的理解
事务从活动状态开始,当事务完成了它的最后一条语句之后就进入了部分提交的状态,这时候事务语句完成了执行,但是由于实际输出可能仍然临时驻留在主存中而没有写入到持久化设备中去
,因此一个硬件故障可能会导致其不能够成功完成,所以这时候事务依然有可能不得不中止。
接着数据库系统往磁盘上写入足够的信息,确保出现故障的时候,事务所做的更新也能在系统重启之后重新创建,当最后一条这样的信息写完之后,事务才会进入提交状态
关于事务中止状态的理解
如果系统判定一个事务不能够继续正常执行之后可能是逻辑错误,也有可能是硬件错误
,这个事务就宣告失败(进入失败状态),事务必须回滚,这样事务就进入了中止状态,此时系统有两种选择:
重启(restart)
:仅当引发事务中止的是硬件错误或者不是由事务的内部逻辑所产生的软件错误的时候会执行重启。重启的事务会被看作是一个重新启动的事务杀死(kill)
:这样做通常是由于事务的内部逻辑造成的错误,只有重写应用程序才能改正,发生这种情况还有输入错误,亦或者数据在数据库中找不到。
请你谈谈的事务有几种状态,以及这几种状态是如何转换的?
当数据库系统的编译系统检测到我们编写的应用程序中包含一个事务的起始边界的时候,这时候就看作为一个事务的开始,在这个时候,事务就进入了第一个状态,
活动状态
在进入活动状态之后,就会开始执行我们的事务语句,我们先假设系统的执行非常顺利,在没有外界硬件错误的发生和程序逻辑错误的发生的情况下,这时候就会将我们的事务语句全部执行完毕,这时候就会进入到一个
部分提交状态
。注意这时候事务并没有提交完成。这时候为了保证事务的
ACID
,这时候采用了一种日志
的技术策略,数据库系统维护一个日志
系统,在日志系统的作用下,一旦一个事务成功地执行,即使系统出现了故障,这时候通过日志的记录手段,也能够将故障的系统恢复到故障之前的状态,这也就是保证了事务的持久性
,因此在这个阶段,部分提交状态到提交状态的过程还要执行一些写日志之类的操作,只有当这些日志全部写入完毕后,才会进入提交状态
但是如果写日志的过程中,硬件系统发生故障,这时候就认为提交失败,事务进入了
失败状态
,然后执行回滚,进入了中止状态
,那么这个过程也有我们的日志系统的参与,如果一个事务需要回滚,那么数据库系统将会从日志中恢复旧值,看上去事务从来没有执行过一样,这也就是保证了事务的原子性
接下来,让我们讨论在执行事务语句的过程发生逻辑错误或者硬件错误的情况,这时候事务也被认为是无法再执行下去的,这时候事务就会由
活动状态
转为失败状态
,在失败的情况下,处理完数据库内部数据的相关逻辑后,就进入了中止状态
6. 事务的隔离性
事务的处理系统通常是允许多个事务并发地执行的,如果允许多个事务并发地更新数据会引起许多数据一致性的复杂性问题,当存在事务并发执行的情况下保证一致性需要进行额外的工作。
如果我们强制事务串行地(serially)
执行将简单很多,一次只执行一个事务,每个事务仅当前一个事务执行完毕后才会开始执行。
然而并发带来的诸如提高吞吐量和资源利用率、提高磁盘利用率、减少平均响应时间这些效果是很显著的。
这个就像为什么OS里面要搞多道程序设计一样
数据库系统有两种方法利用多处理器和多核的优势,一种是发现单个事务或者查询内的并行性,一种是支持大量并发的事务。
调度
:不同事务之间的执行顺序,表示指令在系统中执行的时间顺序,一组事务的一个调度必须包含这一组事务的全部指令
,并且必须保持指令在各个事务中出现的顺序。
一个基本的银行转账调度1:
第一种执行顺序的调度的事务组任务在完成之后,此时的金额总数加起来和转账前的金额总数相等
接下来看一个串行调度和并发调度
串行调度
:每个串行调度由来自各事务的指令序列组成,其中属于同一个事务的指令在调度中紧挨在一起。
像调度3这样的调度就不是串行的,操作系统可能先选择其中的一个事务执行一小段时间,然后切换上下文,执行第二个事务一段时间又切回去。在这种情况下,多种执行顺序是有可能的,因为来自于两个事务的各条指令可能是较差执行的。
并发控制(concurrency-control)
:保证所执行的任何调度都能够使得数据库处于一致的状态
可串行化(serializable)
:在并发执行中,通过保证所执行的任何调度的效果与没有并发执行的调度效果一样,可以确保数据库的一致性,这个调度在某种意义上是等价于一个串行调度的,这种调度称为可串行化
7. 可串行化
在以下的讨论中,我们将操作统一划分为两个read
和write
,在数据项Q
上的read(Q)
和write(Q)
指令之间,事务可以对驻留在事务局部缓冲区中的Q
的拷贝执行任意操作序列。
- 对于调度S,然后其中含有分别属于
I(事务)
和J(事务)
的两条连续指令Ii
和Ij
,如果I
和J
事务引用的是不同的数据项,那么交换I
和J
不会影响调度中任何指令的结果 - 但
I
和J
如果操作的是相同的数据项,那么Ii
和Ij
的顺序就值得考究了
由于我们只考虑read
和write
操作,因此I
和J
的操作组合一共是有4种:
I=read(Q),J=read(Q)
,I和J
的次序是无关紧要的,因为没有修改到I
和J
的值,在这种情况下谁先读都一样I=read(Q),J=write(Q)
,这种情况下,如果I先执行,那么I
不会读到J
写入的值,但如果J
先执行,那么I
会读到J写入的值,这时候这个次序是重要的I=write(Q),J=read(Q)
,同(2),这个操作的次序是重要的I=write(Q),J=write(Q)
,这时候两条指令都是write
指令,因此指令的顺序在这个局部来看是没有影响的,但是如果下一条指令是read(Q)
,那么这个顺序就值得考究了。同时,如果这个事务执行的最后两条语句恰好是它们,那么谁先写入,也会导致数据库中存储的最终Q值的不同。
总结一下
- 只有当两条指令均为read指令或者两条指令操作的是不同的数据项的时候,这时候这两条指令的顺序才是无关紧要的
只有当I和J操作同一个数据项,而且有一个为写操作的时候,这时候我们就说事务I和事务J是冲突的(conflict)
- 如果交换那些不冲突的指令,那么就会得到一个等价的调度
冲突等价过程与冲突可串行化
我们观察这个图,如果想要观察到冲突的语句,我们可以在事务的交替执行的边界观察
- 冲突点1:
T1:write(A)
和T2:read(A)
,这时候会产生冲突 - 冲突点2:
T1:write(B)
和T2:read(B)
,这时候会产生冲突 - 不冲突点1:
T2:write(A)
和T1:read(B)
,由于操作的是不同的数据项,因此不冲突
冲突可串行化的做法,可以从不冲突点开始探究,由于如果交换那些不冲突的指令,那么就会得到一个等价的调度
为什么要这样做呢?这是因为这样做了之后,不但可以保证执行结果的一致,还可以尽量地将操作指令序列还原到串行化的程度
交换不冲突点的指令序列,得到:
T1 | T2 |
---|---|
read(A) | |
write(A) | |
read(A) | |
read(B) | |
write(A) | |
write(B) | |
read(B) | |
write(B) |
继续找出不冲突点
T2:read(A)
,T1:read(B)
T1 | T2 |
---|---|
read(A) | |
write(A) | |
read(B) | |
read(A) | |
write(A) | |
write(B) | |
read(B) | |
write(B) |
T2:write(A)
和T2:write(A)
T1 | T2 |
---|---|
read(A) | |
write(A) | |
read(B) | |
read(A) | |
write(B) | |
write(A) | |
read(B) | |
write(B) |
T1:write(B)
、T2:read(A)
T1 | T2 |
---|---|
read(A) | |
write(A) | |
read(B) | |
write(B) | |
read(A) | |
write(A) | |
read(B) | |
write(B) |
做到这一步,我们发现T1和T2经过上面的一顿操作变成了串行化的两个事务,也就是变成了串行调度。
这个等价于意味着不管初始系统状态如何,调度3将与某个串行调度产生相同的最终状态
- 如果调度S可以经过一系列调的非冲突指令的交换转换为
S'
,那么我们就说S
和S'
是冲突等价的(conflict equivalent)
- 如果一个调度S和一个串行调度的冲突等价,那么就说调度
S
是冲突可串行化的(conflict serializable)
冲突可串行化的判断
为了确定一个调度是否冲突可串行化,我们假设S是一个调度,由S构造一个有向图
,称为优先图(precedence graph)
,这个中的顶点集是由所有参与调度的事务组成
,边集则是由满足下列三个条件之一的边Ti->Tj
组成的
- 在
Tj
执行read(Q)
之前,Ti
执行write(Q)
- 在
Tj
执行write(Q)
之前,Ti
执行read(Q)
- 在
Tj
执行write(Q)
之前,Ti
执行write(Q)
如果优先图中存在边Ti->Tj
,那么就在任何等价于S的串行调度S'
中,Ti
一定会出现在Tj
之前
这个简单理解,可以看到给出的边集的情况主要是针对于是出现冲突的情况的,那么对于出现冲突的情况,在这里规定了两个事务的执行顺序,在这样的情况下,就能够保证最终冲突可串行化
来看一下调度4的优先图表示
在这个情况下T1执行read(A)
先于T2执行write(A)
,所以有T1->T2
的边
T2
执行read(B)
先于T1执行write(B)
,所以有T2->T1
,在这种情况下优先图就含有环,那么调度S
就不是冲突可串行化的,如果优先图是无环的,那么调度S就是冲突可串行化的
总结一下方法:
对于事务T,我们考察其中的数据项,先把第一个对这个数据项的
read
和write
操作标出来然后对于每一个数据项的第一个这个
read
和write
操作,比较另外一个事务对这个同样的数据项的read
和write
操作,然后根据边集的判定条件,判定这个边要怎么画依次考察各个事务和各个数据项,直到画出来图为止
8. 事务隔离性和原子性
如果事务Ti
失败了,那么我们必须撤销该事务的影响以保证其原子性,在允许并发执行的系统中,原子性要求依赖于Ti的任何事务Tj(也就是说Tj读取了Ti写的数据)也终止,为了确保这一点,我们需要对系统所允许的调度做一些限制。
8.1 可恢复的调度
T6 | T7 |
---|---|
read(A) | |
write(A) | |
read(A) | |
commit | |
read(B) |
如图所示,其中事务T7
只执行一条指令read(A)
,我们称这个调度为部分调度(partial schedule)
,这是因为在T6
中没有包括commit
或者abort
操作。
因此在T7
提交的时候,T6
依然是活跃的,现在假定T6
在提交前发生了故障,T7
已经读取了由T6
写入的数据项A
的值(在这个情况下,我们说T7
依赖于T6
),因此我们必须中止T7
以保证事务的原子性但是T7
已经提交了,不能够再中止,这样就出现了T6
发生故障后而无法正确恢复的情形
可恢复的调度(recoverable schedule)应当满足:对于事务Ti
和Tj
,如果Tj
读取了Ti
所写的数据项(这时候称Tj
依赖于Ti
),那么应该要Ti
先提交而Tj
后提交
8.2 无级联的调度
即使一个调度是可恢复的,但是如果要从事务Ti
的故障中正确恢复,可能需要回滚若干个事务,当其他事务读取了由事务Ti
所写的数据项的时候就会发生这样的情况。
级联回滚(cascading rollback)的例子
:T8写入了A的值,T9读取了A的值后又写入了A的值,T10读取了A的值,如果这时候T8失败回滚了,那么由于T9依赖T8,那么T9也必须回滚,而T10又依赖于T9,那么T9也必须回滚。这样就产生了连锁回滚的现象。
无级联调度(cascadeless schedule):对于每对事务Ti
和Tj
,如果Tj
读取了先前由Ti
所写的数据项,那么Ti
必须在Tj
这一读操作之前提交。
(这样就能保证Tj读取的值是持久化的值,从而保证不会连锁回滚)
无级联调度也是可恢复的调度。
这是因为无级联调度甚至不满足可恢复的调度的前提条件,当然是成立的了。
9. 事务隔离级别(面试考察)
事务在独立执行的时候保证数据库的一致性,那么可串行性就能确保并发执行的时候也具有一致性。
SQL标准允许一个事务这样规定:它可以以一种与其他事务不可串行化的方式执行。比如说在未提交读
级别上操作,这种情况下允许事务读取甚至还未提交的记录,SQL为那些不要求精确结果的长事务提供这种特征,如果这些事务要在可串行化的方式下执行,它们就会干扰其他事务,造成其他事务执行的延迟
SQL标准规定的隔离性级别
可串行化(serializable)
:通常保证可串行化调度。一些数据库系统对该隔离性级别的实现在某些情况下允许非可串行化执行可重复读(repeatable read)
:只允许读取已提交的数据,而且在一个事务两次读取一个数据项期间,其他事务不得更新该数据
,但是该事务不要求与其他事务可串行化,例如当一个事务在查找满足某些条件的数据的时候,它可能找到一个已提交事务插入的一些数据,但是可能找不到该事务插入的其他数据
这个的话举个例子好理解一点,比如说事务A插入了1-10条数据并且成功提交,然后事务B去读取这1-10条数据,然后这时候事务C进入活跃状态,往这个表里面插入了11-20条数据,然后提交数据,这时候事务A再执行查询,就会发现多了这几条数据进去。
这样做破坏了事务的隔离性,因为我们说事务隔离应该是一个事务不会被另外一个事务的操作所影响,也就是说无论外部的事务怎么操作,本事务的操作结果都不会因为外部的事务的操作而改变,但是在这种情况下,就产生了影响,这个影响有一个专业的术语,也就是产生了
幻读
已提交读(read committed)
:只允许读取已提交的数据,但是不要求可重复读,这是对可重复读的限制的降低。比如说在事务两次读取一个数据项期间,另一个事务允许更新这个数据并且提交。未提交读(read uncommitted)
:允许读取未提交的数据,最低的一致性级别
以上的四种隔离级别不允许脏写(dirty write):如果一个数据项已经被另外一个尚未提交或者中止的事务写入了,这时候就不允许对这个数据项执行写操作。
10. 隔离性级别的实现(面试考察)
可以使用并发控制机制(concurrency-control scheme)
来保证数据库的一致性,即使有多个事务并发的时候,不管操作系统在事务之间如何分时地共享资源,都只产生可接受的调度,要保证产生的调度是冲突可串行化或者视图可串行化的、可恢复的并且是无级联的
10.1 锁机制
一个事务可以封锁其访问的数据项而不用封锁整个数据库,在这种策略下,事务必须在足够长的时间内持有锁来保证可串行化,同时还需要注意足够端而不会导致性能的严重下降
- 共享锁:用于事务读
- 排它锁:用于事务写
许多事务可能同时持有一个数据项上的共享锁,但是只有当 其他事务在一个数据项上不持有任何的锁的时候才能上排它锁
10.2 时间戳
为每个事务分配一个时间戳(timestamp)
,系统将会维护两个时间戳
- 数据项的读时间戳记录该数据项的事务的最近时间戳。
- 数据项的写时间戳记录的是该事务写入该数据项当前值的事务的时间戳
10.3 多版本和快照隔离
通过维护数据项的多个版本,一个事务允许读取一个旧版本的数据项,而不是被另一个未提交或者在串行化序列中应该排在后面的事务写入的新版本的数据项
快照隔离(snapshot islation)技术:我们可以想象每个事务在开始的时候有其自身数据库版本或者快照,它从这个私有版本中读取数据,因此和其他事务所做的更新隔离开,如果事务更新数据库,更新只出现在其私有版本中,而不是实际的数据库本身中。当事务提交的时候,和更新相关的信息将会被保存,写入到真正的数据库中。
当一个事务T进入部分提交状态之后,只有在没有其他并发事务想要更新的数据项的情况下,事务才会进入提交状态。
快照隔离的优点
它可以保证读数据的尝试永远无无需等待(不像封锁),只读事务不会中止,只有修改数据的事务有微小的风险。由于每个事务读取它自己的数据库版本或者快照,因此读取数据不会导致此后的所有事务的更新都被迫等待。
因为大部分事务都是只读的。
快照隔离的缺点
快照隔离带来了太多的隔离,在快照隔离的情况下,任何事务都不能看到对方的更新,但是在大多数情况下两个事务的数据访问都不会冲突。