定义
DBMS 提供了事务(Transactions)支持。事务是作为 DBMS 中的逻辑单元分组执行的一系列数据库操作。与在DBMS之外执行程序(例如,C程序)在许多方面都不同!
数据库应用程序通常通过事务而不是单个操作访问数据库。例如,大型数据库和数百个并发用户:银行、超市收银台、机票预订、在线购买等。
之所以使用事务是因为它们可以在以下情况下实施数据完整性:多个用户可以同时修改和共享数据;事务、系统和媒体故障可能不时发生。
- 从高级语言的角度(如 SQL)
- 插入(INSERT)、选择(SELECT)、更新(UPDATE)、删除(DELETE)
- 开始(BEGIN)、提交(COMMIT)、中止(ABORT)/ 回滚(ROLLBACK)等;
BEGIN TRANSACTION
SELECT balance FROM Account WHERE name = `Steve';
UPDATE Account
SET balance = balance-500 WHERE name=`Steve';
SELECT balance FROM Account WHERE name = `Bob';
UPDATE Account
SET balance = balance+500 WHERE name = `Bob';
COMMIT
- 内部进程级别:操作对象为数据表、行、单元或者内存页。
- 读(read)、写(write)
- 开始(begin)、提交(commit)
- 中止(abort):表示事务未成功结束,撤消事务的所有操作
步骤 | Transactions |
---|---|
1 | read(A) |
2 | write(A) (A := A - 500) |
3 | read(B) |
4 | write(B) (B:= B + 500) |
5 | commit |
其中,A 是 Steve 的账户余额;B 是 Bob 的账户余额。
ACID 属性
DBMS 确保了事务的以下属性:
- 原子性(Atomicity)
- 每个事务的执行都是原子性的,即要么所有的操作都完成了(ALL),要么根本没有完成(NONE)。
- 如果一个事务因为某些原因而不能完成,那么 DBMS 必须消除部分事务的影响,以确保原子性。
- 一致性(Consistency)
- 数据库的状态在每个事务之前和之后是一致的。
- 中间状态可能是不一致的。
- 隔离(Isolation)
- 每个事务的执行结果应该不受其他并发执行事务的影响。
- 其他事务不会看到目前事务下所有对象的信息,直至该事务完成。
- 耐用性(Dutability)
- 一旦事务成功完成,它的效果应该会保存在数据库中。- 一旦提交,事务就不能恢复为 abort,这种改变是持久的。
需要注意的是,这些属性不是相互独立的,但原子性是中心属性。
一致性(Consistency)由应用开发者实现,因为这要求开发者理解应用程序的数据模型,并且确保每个事务都以一种与现实世界中的合法更改相一致的方式更改数据。
而其他三种属性则由事务管理器实现,最常用的技术有两种:
- 锁(Locking)—— 用于并发控制,如两阶段锁(2PL:Two-phase Locking)
- 日志(Logging)—— 用于恢复,如预写式日志(WAL:Write-Ahead Log)
锁(Locking)
锁是一种用于并发控制的技术,可保证事务的隔离性。锁在数据库中一般作用在对象上,如文件、表、记录、页等。
锁的用法分成两类:
- 共享锁:多个事务可以同时获取它。
- 互斥锁:只有一个事务可以获得它,导致其他试图获取它的事务等待。持有锁的事务完成后,它释放锁,允许一个等待的事务获取锁。
两阶段锁(2PL:Two-phase Locking)
锁的使用和移除分为两个阶段:
- 展开:获取锁,不释放锁。
- 收缩:释放锁,不获取锁。
2PL 基本协议使用两种类型的锁:
- 共享锁(read-lock):在读取对象之前通过事务与对象关联。
- 互斥锁(write-lock):在写对象之前通过事务与对象关联。
当读写锁同时出现在一个对象身上的时候,锁之间的兼容满足以下关系:
锁的类型 | 读锁(read-lock) | 写锁(write-lock) |
---|---|---|
读锁(read-lock) | 兼容 | 不兼容 |
写锁(write-lock) | 不兼容 | 不兼容 |
缺点:在某些情况下,2PL可以从根本上限制事务间的交叉
优点:2PL使交叉变得安全,比如保证事务的可串行性。
可串行化(Serializability)意味着产生的数据库状态等于以串行方式运行事务的数据库状态。
可串行性是并发事务的主要正确性标准。
但是,2PL可能会出现死锁,即,两个或多个事务的相互阻塞,比如一下的情况:
T1 | T2 | 解释 |
---|---|---|
lock-r(A) | T1获取了A的读锁 | |
read(A) | ||
lock-r(B) | T2获取了B的读锁,与A的读锁相互不影响 | |
read(B) | ||
lock-w(B) | T1想获取B的写锁,但是需要等待T2释放B的读锁才能获取到 | |
write(B) | ||
lock-w(A) | ||
write(A) | T2想获取A的写锁,但是需要等待T1释放A的读锁才能获取到 |
如何使用锁定技术提高并发性?
- 细化锁类型
- 区分读锁和写锁。
- 读操作通常比写操作更频繁,而且读同一个对象的多个事务不会相互干扰
- 锁定粒度
- 数据库 > 表 > 记录 > 页面 > 数据库表页记录
- Table-level locks > Record-level locks
- 放松隔离(Isolation)的概念
日志(Logging)
日志是一种用于恢复的技术,可保证事务的原子性和持续性。日志是一个仅追加(append-only)的文件,它记录对对象的建议更改。当多个事务并发运行时,日志记录是交错的。
恢复相当于撤销或重做日志中的更改:
- 撤销(Undo)尚未提交的操作。
- 重新执行(Redo)已提交但尚未写入磁盘的操作。
原子性(Atomicity)是通过定义当且仅当日志中记录了更改时才提交事务来实现的。
持久性(Durability)是通过在系统启动时读取日志并确保每个提交的事务都在数据库中应用了其更改来实现的。
预写式日志(WAL:Write-Ahead Log)
预写式日志(WAL)是最基本的规则,它确保在尝试从崩溃中恢复时,数据库的每个更改记录都是可用的。
主要思想:
- 对对象的任何更改都首先记录在日志中,即,包含该对象的旧值和新值的记录。
- 在提交事务之前,必须将日志中的记录写入持久存储。
因此,提交事务的定义为:“日志记录(包括提交记录)已写入持久存储的事务”。
对于一个日志记录来说,其典型的字段为:
- LSN:每一个日志记录都有一个独一无二的日志序列码(Log Sequence Number)
- prevLSN:同一个事物中上一个日志记录的序列码
- transID
- type:日志内容的类型,包括 update,commit,abort,end 等等
- 对于更新的日志记录,可能还会有 pageID,length,offset,before-image,after-image
WAL 对性能的提升体现在:
- 通常会显著减少磁盘写的数量
- 支持对日志文件进行一次同步,而不是对数据文件进行多次同步
- 支持在线备份和时间点恢复
并发事务(Concurrent Transactions)
交错处理:事务交错在单个CPU中。
并行处理:事务在多个cpu中并行执行。
使用并发同时执行事务将提高数据库性能,具体包括:
- 增加吞吐量(已完成事务的平均数量)
- 例如,当一个事务正在等待从磁盘读取一个对象时,CPU可以处理另一个事务
- (因为 I/O 活动可以与 CPU 活动并行执行)
- 减少延迟(完成事务的平均时间)
- 例如,短事务与长事务的交错执行通常允许短事务更快地完成。
但是 DBMS 必须保证事务的交错不会导致数据库的不一致,也就是说要实现并发控制。
并发控制的实现主要是为了要防止以下的问题:
- 丢失更新问题(The lost update problem)
- 写入冲突(write-write conflicts)
- 当两个事务更新同一对象时发生,且一个事务可能会覆盖已由另一个事务更新的对象的值
- 肮脏读取问题(The dirty read problem)
- 写读冲突(write-read conflicts)
- 当一个事务可以读取另一个事务更新但尚未提交的对象的值时发生
- 不可重复读取问题(The unrepeated read problem)
- 读写冲突(read-write conflicts)
- 一个事务可以更改一个对象的值,该对象已被另一个事务读取,但仍在进行中(可以为该对象发出两个read,或在读取该对象后发出一个write)
- 幽灵读取问题(The phantom read problem)
- 当一个事务 T1 更新的元组满足另一个事务的搜索条件时发生,因此通过相同的搜索条件,事务在不同的时间获得不同的结果。
如果使用串行执行事务的话,就不会出现 肮脏读取、不可重复读取问题 和 幽灵读取问题 这几种情况了。
未重复读取与幽灵读取的差别
不可重复读取 | 幽灵读取 |
---|---|
执行相同的选择(SELECT)两次会产生相同的元组集合,但是属性值可能不同 | 执行相同的选择(SELECT)两次会产生两组不同的元组 |
读取受另一个事务更新(UPDATE)影响的对象时可能发生 | 当查询一组从另一个事务中插入(INSERT)、删除(DELETE)、更新(UPDATE)中受影响的元组时可能发生 |
可以使用记录级锁定(record-level lock)来防止 | 可以使用表格级锁定(table-level lock)来防止 |
也就是说,对于由更新(UPDATE)造成的读取不一致的情况,可以分别通过记录级锁定(record-level lock)和表格级锁定(table-level lock)两种类型的锁来防止相应的问题。
SQL 对事务的支持
- SQL标准没有强制实施特定的锁定方案或强制执行特定的行为
- 显式事务可能没有 BEGIN TRANSACTION 语句,但必须以 COMMIT 或 ABORT (ROLLBACK) 语句结束。
- 当没有给出显式的事务语句时,每个SQL语句都被认为是一个事务。
- 为了让程序员对事务开销有更多的控制,SQL允许他们指定隔离级别,即。事务对并发事务准备容忍的干扰程度。
核心思想就是,权衡性能(更好的并发访问)与一致性(数据库的完整性)
SQL-92 定义了事务隔离的4个级别:
- 未提交读(Read Uncommitted)
- 已提交读(Read Committed)
- 可重复读(Repeatable Read)
- 串行(Serializable)
通过以下命令可以指定事务隔离的级别:
SET TRANSACTION ISOLATION LEVEL serializable;
不同的隔离级别排除了不同的问题:(限制由弱到强)
隔离级别 | 含义 | 肮脏读取 | 不可重复读取 | 幽灵读取 |
---|---|---|---|---|
未提交读(Read Uncommitted) | 一个事务可以看到尚未提交的其他事务所做的更改。这可能相当危险。在对只读数据执行查询时使用它,或者在查询是否返回未提交数据无关紧要的情况下使用它。 | 出现 | 出现 | 出现 |
已提交读(Read Committed) | 一个事务只看到其他事务提交的更改。它是数据库应用程序中最常用的隔离级别。当希望最大化应用程序之间的并发性,但不希望查询看到未提交的数据时,可以使用它。 | 不出现 | 出现 | 出现 |
可重复读(Repeatable Read) | 事务所触及的对象被锁定,并且不能被并发事务更新或删除。当希望应用程序之间具有某种程度的并发性,但不希望在事务期间更改单个对象时,可以使用它 | 不出现 | 不出现 | 出现 |
串行(Serializable) | 所有事务都与其他事务完全隔离。它是安全的,但可能会导致显著的性能下降。当希望应用程序之间具有某种程度的并发性,但不希望在不同的时间运行查询时返回不同的结果集时,可以使用它。 | 不出现 | 不出现 | 不出现 |
更高的隔离级别减少了用户可能遇到的并发问题的类型,但是需要更多的系统资源,并且增加了一个事务阻塞其他事务的机会。
不同的 DBMS 实现隔离级别的方式非常不同。丢失更新所需的隔离级别取决于数据库管理系统的实现。但一般来说,它可能需要最高的水平序列化来防止它。
较低的隔离级别增加了许多用户同时访问数据的能力,但也增加了用户可能会遇到并发性影响的数量
相反,更高的隔离级别减少了用户可能遇到的并发影响的类型,但是需要更多的系统资源,并增加了一个事务阻塞另一个事务的机会。
因此,选择适当的隔离级别取决于平衡应用程序的数据完整性需求和每个隔离级别的开销。