事务简介
事务的定义
事务(Transaction)在计算和数据库处理中是一个非常重要的概念。事务是一个被数据库管理系统(DBMS)视为一次性、逻辑上的操作单元的操作序列。这个操作序列中包含了对数据库的读、写操作。
事务是一种机制,它的主要目的是保证即使在由软件或硬件错误、系统崩溃、错误、或者其他问题导致的系统错误中,数据库仍然能够保持一致状态。事务是数据库管理系统执行过程中的一个逻辑单位,它能独立地执行一系列的数据库操作。
一个事务的操作要么全部执行,要么全都不执行,它是一个不可分割的工作单位。例如,一个银行的转账操作:从一个账户扣款和向另一个账户存款这两个操作必须要作为一个事务一起完成,不能只完成其中一个操作。这就是事务的原子性。
例如,银行转帐工作:从源帐号扣款并使目标帐号增款,这两个操作必须要么全部执行,要么都不执行,否则就会出现该笔金额平白消失或出现的情况。所以,应该把他们看成一个事务。
在现代数据库中,事务还可以实现其他一些事情,例如,确保你不能访问别人写了一半的数据;但是基本思想是相同的——事务是用来确保无论发生什么情况,你使用的数据都将处于一个合理的状态。
事务的目的
事务的主要目的是为了保证数据库的一致性,当数据库在并发环境下被多个用户或者应用同时存取时,事务能确保数据的完整性和一致性。
以下是事务所试图实现的目标:
- 保证数据库的一致性:事务的一个重要目标是保证数据的一致性。一致性是指数据库的状态应满足预定义的约束或业务规则。在事务开始和结束时,对数据库的修改都会符合这些一致性约束。
- 支持并发处理:事务可以支持多个用户或应用程序同时对数据库进行修改,而不会相互干扰。通过隔离性,每个事务都像是在独立地运行一样。
- 恢复和故障处理:如果因为系统崩溃或其他错误导致某个事务无法完成,那么事务的持久性能保证数据库能够恢复到一个一致的状态。未完成的事务对数据库所做的任何修改都会被撤销。
- 保护数据的安全性:通过事务,可以确保在并发访问和故障恢复等情况下,数据的完整性和安全性得到保护。
事务的状态
因为事务具有原子性,所以从外部看的话,事务就是密不可分的一个整体,事务的状态也只有三种:Active、Committed 和 Failed,事务要不就在执行中,要不然就是成功或者失败的状态。
进一步放大看,事物内部还有部分提交这个中间状态,其对外是不可见的。
在数据库系统中,事务的生命周期通常涵盖以下几种状态:
- 激活(Active):这是事务的初始状态,在该状态下,事务正在执行。
- 部分提交(Partially Committed):在该状态下,事务已经执行了最后的语句,但是尚未提交。
- 提交(Committed):在该状态下,事务已经执行并成功完成了提交操作。一旦事务提交,它对数据库进行的所有修改都将成为数据库的永久部分。如果系统失败,这些更改无法撤销。
- 失败(Failed):在该状态下,系统已经检测到事务无法成功继续执行或者达到预期结果,因此系统已经使事务失败。当事务处于失败状态时,其对数据库的任何更改都必须被撤销。
- 中止(Aborted):在该状态下,事务已经回滚并已释放所有的数据锁。这种状态通常发生在事务失败后,系统需要回滚事务以恢复数据库的原始状态。事务处于中止状态后,可以选择重新启动。
这些状态描述了事务从开始到结束的生命周期,同时这也是数据库管理系统事务管理的重要组成部分。
事务的ACID属性
ACID简介
ACID 是用来描述数据库事务的四个主要特性的首字母缩写,分别代表:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这四个特性是数据库事务完整性和可靠性的基础。
- 原子性(Atomicity):原子性是指事务是一个不可分割的工作单元,事务中的操作要么全都做,要么全都不做。事务的原子性确保了操作要么成功完成,要么完全不发生。
- 一致性(Consistency):一致性是指事务必须使数据库从一种一致性状态变到另一种一致性状态。在事务开始和结束时,所有的业务规则都必须为真,这就是数据库的一致性。
- 隔离性(Isolation):隔离性是指并发执行的事务之间不相互影响,一个事务的执行不应该影响其他事务。隔离性确保了事务的独立性,使得并发的事务感觉像是串行执行一样。
- 持久性(Durability):持久性是指一旦事务被提交,那么它对数据库的改变就是永久性的。即使系统崩溃,数据库也能够恢复到事务成功结束的状态。
ACID 特性是数据库事务处理的基础,它们确保了无论系统发生何种故障,数据库都能从故障中恢复过来,而且数据的完整性和一致性得到保证。
并非任意的对数据库的操作序列都是数据库事务。ACID属性是一系列操作组成事务的必要条件。总体而言,ACID属性提供了一种机制,使每个事务都作为一个单元,完成一组操作,产生一致结果,事务彼此隔离,更新永久生效,从而来确保数据库的正确性和一致性。
原子性(Atomicity)
原子性是指事务被视为一个不可分割的最小单元,这个单元中的所有操作要么全部完成,要么全部不完成。换句话说,如果事务中的所有操作都成功,则事务算作成功,所有更改都将提交到数据库中。如果事务中的任何操作失败,则事务算作失败,所有更改都将被回滚,即撤销,就像这个事务从未发生过一样。
它涉及以下两个操作:
- 中止:如果事务中止,则看不到对数据库所做的更改。
- 提交:如果事务提交,则所做的更改可见。
这种“全部或无”的性质确保了数据的一致性和完整性。例如,在一个银行转账事务中,资金的扣除和存入被视为一个整体的事务,要么两个操作都成功,要么两个操作都不成功。这就是原子性的体现。
总的来说,原子性主要解决了”部分完成”这种情况对数据完整性的破坏。
拿之前转账的例子来说,用户A给用户B转账,至少要包含两个操作,用户A钱数减少,用户B钱数增加,增加和减少的操作要么全部成功,要么全部失败,是一个原子操作。如下图,如果事务在T1 完成之后但在T2完成之前失败,将导致数据库状态不正确。
一致性(Consistency)
在数据库中,一致性指的是数据库的数据必须满足预先定义的业务规则和约束(如主键/外键约束、唯一性约束等),以保证数据的准确和可靠。这些规则和约束包括数据库在所有事务开始前和结束后都必须进行的完整性检查。
在事务处理过程中,一致性确保了事务将数据库从一种稳定状态(满足所有约束)转变为另一种稳定状态。例如,如果在一个银行转账的事务中,你从一个账户转出100美元到另一个账户,那么这个系统的一致性规则可能需要保证转账前后两个账户的总金额是相同的。
如果在事务处理过程中发生错误,或者系统因某种原因崩溃,一致性规则能确保数据库返回一个稳定的、状态一致的数据状态,即所有未完成的事务都会回滚到事务开始前的状态。只有当事务成功提交,且不违反任何一致性规则时,它对数据库所做的更改才会被永久保存。
总的来说,一致性是确保数据准确性和事务正确性的关键。
参考上面的示例,假设用户A和用户B两者的钱加起来一共是700,那么不管A和B之间如何转账,转几次账,这一约束都得成立,即事务结束后两个用户的钱相加起来还得是700,这就是事务的一致性。
如果转账过程中,仅完成A扣款或B增款两个操作中的一个,即未保证原子性,那么结果数据如上述完整性约束也就无法得到维护,一致性也就被打破。可以看出,事务的一致性和原子性是密切相关的,原子性的破坏可能导致数据库的不一致。
但数据的一致性问题并不都和原子性有关。比如转账的过程中,用户A扣款了100,而用户B只收款了50,那么该过程可以符合原子性,但是数据的一致性就出现了问题。
一致性既是事务的属性,也是事务的目的。也正如本文开篇所提到的,“事务是用来确保无论发生什么情况,你使用的数据都将处于一个合理的状态”,这里所说的合理/正确,也就是指满足完整性约束。
总的来说,一致性是事务ACID四大特性中最重要的属性,而原子性、隔离性和持久性,都是作为保障一致性的手段。事务作为这些性质的载体,实现了这种由ACID保障C的机制。
ACID和CAP中C(一致性)的区别
ACID和CAP都涉及到”一致性”这个概念,但在它们的含义和上下文环境中,”一致性”有所不同。
- ACID中的一致性(Consistency):在ACID模型中,一致性是指事务应确保数据库从一个一致状态转变到另一个一致状态。事务执行前后,数据库的完整性约束没有被破坏。这种一致性是以事务为单位的,侧重于确保单个事务对数据库状态的改变不会破坏数据库的一致性约束。又称“内部一致性”。
- CAP中的一致性(Consistency):在CAP理论(即,分布式系统设计的理论基础,表示Consistency(一致性)、Availability(可用性)和Partition Tolerance(分区容忍性))中,一致性指的是分布式系统中所有数据副本在同一时刻是否相同,即用户无论连接到哪个节点查询数据,得到的结果都是一致的。这种一致性是全局的,侧重于分布式环境下,用户无论何时读取数据,都能获得最新的数据。又称“外部一致性”。
总结一下,ACID中的一致性主要针对单个数据库系统,确保事务保持数据库状态的一致性。而CAP中的一致性则主要应用于分布式系统,确保所有节点中的数据副本保持一致。
隔离性(Isolation)
隔离性(Isolation)是数据库事务的ACID属性中的一个,它确保每个事务单独执行,而不受其他事务的干扰。
在数据库系统中,可能有多个事务同时发生并执行,它们可能尝试访问和修改同一些数据项。如果没有适当的隔离措施,那么这些并发执行的事务可能会相互影响,导致数据的不一致性。例如,一个事务正在读取一个数据项,而此时另一个事务正在修改这个数据项,如果没有适当的隔离,那么读取操作可能会看到一个不一致的数据状态。
隔离性的目标是为每个事务提供一个“私有”的工作空间,使得在这个空间中的操作对其他事务是不可见的,直到该事务提交。在隔离性的保障下,即使有多个事务并发执行,也就像它们是串行执行一样,每个事务都不会看到其他事务的中间状态。
此属性确保并发执行一系列事务的效果等同于以某种顺序串行地执行它们,也就是要达到这么一种效果:对于任意两个并发的事务T1和T2,在事务T1看来,T2要么在T1开始之前就已经结束,要么在T1结束之后才开始,这样每个事务都感觉不到有其他事务在并发地执行。这要求两件事:
- 在一个事务执行过程中,数据的中间的(可能不一致)状态不应该被暴露给所有的其他事务。
- 两个并发的事务应该不能操作同一项数据。数据库管理系统通常使用锁来实现这个特征。
还是拿转账来说,在A向B转账的整个过程中,只要事务还没有提交(commit),查询A账户和B账户的时候,两个账户里面的钱的数量都不会有变化。如果在A给B转账的同时,有另外一个事务执行了C给B转账的操作,那么当两个事务都结束的时候,B账户里面的钱必定是A转给B的钱加上C转给B的钱再加上自己原有的钱。
并行事务会引发的问题
脏读(Dirty Reads)
当一个事务读取了另一个未提交的事务修改的数据,这就是脏读。如果那个事务因为某些原因回滚,那么第一个事务读取到的数据就是错误的。
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执行更新操作,如果此时事务 A 还没有提交事务,而此时正好事务 B 也从数据库中读取小林的余额数据,那么事务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事务。
因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。
不可重复读(Non-repeatable Reads)
在一个事务内,多次读取同一数据,由于并发事务的修改导致数据一致性问题,这种现象称为不可重复读。例如,一个事务读取了一个数据项,然后另一个事务修改了该数据项,当第一个事务再次读取同一数据项时,数据已经改变。
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后继续执行代码逻辑处理,在这过程中如果事务 B 更新了这条数据,并提交了事务,那么当事务 A 再次读取该数据时,就会发现前后两次读到的数据是不一致的,这种现象就被称为不可重复读。
幻读(Phantom Reads)
一个事务在读取满足某个条件的所有行时,另一个并发事务又插入了新的满足那个条件的行,当第一个事务再次读取满足那个条件的所有行时,会发现有新的“幻影”行。
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。
接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。
然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。
事务的隔离级别
当多个事务并发执行时可能会遇到「脏读、不可重复读、幻读」的现象,这些现象会对事务的一致性产生不同程序的影响。
- 脏读:读到其他事务未提交的数据;
- 不可重复读:前后读取的数据不一致;
- 幻读:前后读取的记录数量不一致。
这三个现象的严重性排序如下:
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:
- 读未提交(Read Uncommitted):这是最低的隔离级别,一个事务可以读取另一个还未提交的事务中的数据。这可能导致各种问题,例如脏读(读取到其他未提交事务的数据)、不可重复读(在同一个事务中,多次读取同一数据返回的结果不一致)、幻读(在同一个事务中,第二次查询可以检索到第一次查询中未出现的行)。
- 读提交(Read Committed):这是大多数数据库系统的默认隔离级别(但不是所有)。它保证一个事务只能读取其他已经提交的事务的数据。这可以防止脏读,但不能防止不可重复读和幻读。
- 可重复读(Repeatable Read):这个隔离级别保证在同一个事务中多次读取同一数据的结果是一致的。它可以防止脏读和不可重复读,但不能防止幻读。请注意,这是MySQL的默认隔离级别。
- 串行化(Serializable):这是最高的隔离级别,它要求事务串行执行,即一次只能执行一个事务,不允许并发执行。这种隔离级别可以防止脏读、不可重复读和幻读,但是效率低,因为事务没有并发执行。
按隔离水平高低排序如下:
针对不同的隔离级别,并发事务时可能发生的现象也会不同。
也就是说:
- 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
- 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
- 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏读和不可重复读现象;
- 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。
不同的数据库厂商对 SQL 标准中规定的 4 种隔离级别的支持不一样,有的数据库只实现了其中几种隔离级别, MySQL 虽然支持 4 种隔离级别,但是与SQL 标准中规定的各级隔离级别允许发生的现象却有些出入。
MySQL 在「可重复读」隔离级别下,可以很大程度上避免幻读现象的发生(注意是很大程度避免,并不是彻底避免),所以 MySQL 并不会使用「串行化」隔离级别来避免幻读现象的发生,因为使用「串行化」隔离级别会影响性能。
MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象,解决的方案有两种:
- 针对快照读(普通 select 语句),是通过 MVCC(多版本并发控制) 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
- 针对当前读(select … for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
事务隔离级别越高,就越能保证数据的完整性和一致性,但同时对并发性能的影响也越大。
持久性(Durability)
持久性是指一旦事务被提交(Commit),那么此事务对数据库的所有更新将永久保存,即使发生系统崩溃、故障或重启等情况,数据也不会丢失。
持久性通常是通过数据库日志(Database Logging)来实现的。在事务执行过程中,所有对数据库的修改(例如,插入、更新和删除等操作)都会被写入到日志中。当事务成功提交后,这些日志会保持在存储系统中,即使在系统崩溃的情况下也能够保留。
如果系统在事务提交后发生崩溃,那么在系统恢复时,数据库可以通过重放日志来确保提交的事务的所有更新都被应用到数据库,从而保证了数据的持久性。
总的来说,持久性是确保数据库系统可靠性的重要特性,它保证了一旦事务提交,其对数据库的所有更改都是永久性的。
许多数据库通过引入预写式日志(Write-ahead logging,缩写 WAL)机制,来保证事务持久性和数据完整性,同时又很大程度上避免了基于事务直接刷新数据的频繁IO对性能的影响。
在使用WAL的系统中,所有的修改都先被写入到日志中,然后再被应用到系统状态中。假设一个程序在执行某些操作的过程中机器掉电了。在重新启动时,程序可能需要知道当时执行的操作是成功了还是部分成功或者是失败了。如果使用了WAL,程序就可以检查log文件,并对突然掉电时计划执行的操作内容跟实际上执行的操作内容进行比较。在这个比较的基础上,程序就可以决定是撤销已做的操作还是继续完成已做的操作,或者是保持原样。
ACID的实现
数据库系统主要是通过并发控制技术和日志恢复技术来实现ACID特性的。
- 并发控制技术:并发控制技术主要用于实现原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation)。例如,使用锁定(Locking)技术可以确保在任何给定时间只有一个事务可以访问特定的数据项,从而实现了原子性和隔离性。另外,数据库系统还可以通过在事务开始时检查数据的一致性,并在事务结束时再次检查数据的一致性,来实现一致性特性。
- 日志恢复技术:日志恢复技术主要用于实现持久性(Durability)。在每个事务执行过程中,所有对数据库的修改都会被记录在日志中。如果事务成功提交,那么这些修改就会永久保存在数据库中,即使系统崩溃也不会丢失。另外,如果系统在事务提交后发生崩溃,数据库可以通过重放日志来恢复数据,从而实现数据的持久性。
通过这两种技术,数据库系统可以在提供高并发性能的同时,确保数据的一致性和持久性,从而实现ACID特性。
常见的并发控制技术
乐观并发控制和悲观并发控制是数据库中两种不同的并发控制方法,它们主要的区别在于它们处理可能的冲突的方式不同。
- 悲观并发控制:悲观并发控制(Pessimistic Concurrency Control,PCC)假设冲突总是会发生,并采取措施防止冲突。它通常通过锁定机制实现,当事务开始访问数据库中的数据时,它会锁定这些数据,以防止其他事务同时访问和修改这些数据,从而避免冲突。这种方法在高冲突的环境中效果更好,因为它可以确保一旦一个事务开始执行,就不会有其他事务干扰它。然而,它也可能导致资源争用和锁定等待的问题。
- 乐观并发控制:相比之下,乐观并发控制(Optimistic Concurrency Control,OCC)假设冲突很少发生。它允许多个事务同时访问和修改数据,然后在事务提交时检查是否有冲突。如果检测到冲突(如两个事务试图修改同一数据),则只有一个事务可以提交,其他冲突的事务必须回滚并重新开始。这种方法在冲突较少的环境中效果更好,因为它允许更高的并发性。然而,如果冲突频繁发生,那么可能需要频繁地回滚和重启事务。
在实际情况中,选择使用乐观并发控制还是悲观并发控制,主要依赖于系统的具体需求以及预期的冲突率。
基于封锁的并发控制
基于封锁的并发控制是数据库系统中常用的一种并发控制方法,也是实现悲观并发控制的一种主要方式。它的主要思想是,当一个事务需要访问或修改一些数据时,它会对这些数据加锁,以阻止其他事务在同一时间访问和修改这些数据。
以下是锁的两种主要类型:
- 共享锁(Shared Lock):如果一个事务只是读取(而不是修改)一个数据项,那么它就可以请求一个共享锁。多个事务可以同时对同一数据项持有共享锁,因为读取操作不会引起冲突。
- 排他锁(Exclusive Lock):如果一个事务要修改一个数据项,那么就需要请求一个排他锁。一旦一个事务对一个数据项获得了排他锁,其他事务就不能再对该数据项获取共享锁或排他锁,直到该事务释放其排他锁。
基于锁的并发控制流程大致可以分为以下步骤:
- 请求锁定:当事务试图读取或修改一项数据时,它会首先请求对该数据项的锁定。如果事务只是读取数据,它会请求一个共享锁;如果事务试图修改数据,它会请求一个排他锁。
- 锁定分配:锁定管理器在收到锁定请求后,会根据当前的锁定状态决定是否分配锁定。如果请求的数据项没有被锁定,或者已经被共享锁锁定且新的请求也是共享锁,那么锁定请求就会被授予。否则,事务必须等待直到锁定被释放。
- 执行事务:一旦事务获得了必要的锁定,它就可以开始执行。如果是读取操作,事务直接读取数据;如果是写入操作,事务修改数据,并保持对数据的排他锁定。
- 释放锁定:当事务完成其操作并准备提交时,它会释放所有的锁定。这使得其他正在等待锁定的事务可以继续执行。
这个基本流程可能会引起一些问题,如死锁和资源争用。
基于锁的并发控制技术确实可能出现一些问题,主要有以下几种:
- 死锁:死锁是指两个或更多的事务互相等待对方释放资源,从而导致所有参与者都处于无法继续执行的状态。例如,事务A持有对数据项1的锁并请求对数据项2的锁,而事务B持有对数据项2的锁并请求对数据项1的锁,这就形成了一个死锁。为了解决死锁问题,数据库系统通常需要采用一些死锁检测和解决策略。
- 资源争用:当多个事务尝试同时访问同一资源时,就会发生资源争用。资源争用可能导致事务等待时间过长,从而降低系统的整体性能。
- 锁开销:锁操作(请求锁、释放锁)以及维护锁信息都需要消耗系统资源(如时间和内存)。如果有大量的锁操作,特别是在大并发系统中,锁开销可能会对系统性能有显著影响。
- 串行化顺序:基于锁的并发控制的目标是确保事务的串行化执行。然而,在保证串行化的过程中,可能会降低并发性能,特别是在高并发的场景中。
另外为了应对这些问题,数据库系统需要采取一些策略,比如两阶段锁定协议、优化锁粒度(行锁、表锁),使用乐观并发控制等。
两阶段锁定协议
在基于封锁的并发控制中,为了防止死锁(两个或更多事务互相等待对方释放资源),通常会使用一种叫做两阶段锁定协议(Two-Phase Locking Protocol)的方法。在这个协议中,每个事务的生命周期被划分为两个阶段:
- 加锁阶段(Growing Phase):在这个阶段中,事务可以获得更多的锁,但不能释放任何已经获取的锁。这就意味着事务可以逐渐锁定它需要访问的所有数据项。
- 解锁阶段(Shrinking Phase):在这个阶段中,事务可以逐渐释放它持有的锁,但不能再获得新的锁。
这种协议的名称源于这两个阶段的特点:加锁阶段像一个正在增长的锁定集合,而解锁阶段像一个正在收缩的锁定集合。
两阶段锁定协议可以保证事务的串行性,这是因为在任何时刻,对于任何数据项,都只有一个事务可以持有对其的排他锁。然而,这种协议并不能防止死锁。当两个或更多的事务互相等待对方释放锁时,就可能发生死锁。为了解决这个问题,数据库系统通常需要配备一个死锁检测和解决机制。
加锁阶段和解锁阶段。在加锁阶段中,事务可以获取更多的锁,但不能释放任何锁。在解锁阶段中,事务可以释放锁,但不能再获取新的锁。这个协议可以确保数据库的串行化,但可能会降低并发性能。
行锁、表锁
行锁和表锁是数据库中两种常见的锁定级别,它们决定了在并发控制中锁定的粒度。不同的锁定级别在并发性和数据安全性之间提供了不同的折衷方案。
- 行锁:行锁是最细粒度的锁,它将锁定应用到数据库表中的单个行。行锁允许在同一表中的不同行上执行并发事务,提高了并发性能。然而,行锁需要更多的内存和计算资源来管理和维护。大量的行锁可能会导致锁开销过大,甚至可能导致死锁。
- 表锁:表锁是较为粗粒度的锁,它将锁定应用到整个表。当一个事务持有一个表的锁时,其他事务不能修改该表的任何行。表锁的开销比行锁小,更容易管理。然而,表锁限制了对同一表的并发访问,可能导致并发性能下降。
在实际应用中,不同的场景可能需要不同的锁定级别。在并发性要求较高,冲突可能性较低的场景中,行锁可能是更好的选择。而在数据安全性要求较高,资源有限的场景中,表锁可能更适合。在某些数据库系统中,还可能提供其他级别的锁,如页面锁、数据库锁等,以满足不同的需求。
乐观并发控制
乐观并发控制(Optimistic Concurrency Control,OCC)是一种处理数据库并发的策略。与悲观并发控制(尽早获取锁,以防止冲突)不同,乐观并发控制采用一种“宽松”的策略,允许多个事务并行执行,只在事务提交的时候检查是否存在冲突。
乐观并发控制通常分为三个阶段:
- 读阶段(Read Phase):在这个阶段,事务读取数据库,并且将所有更新操作保留在本地。
- 验证阶段(Validation Phase):在这个阶段,事务检查在读阶段期间是否有其他事务修改了它读取的数据。如果有,该事务可能需要重新开始,或者采取一些其他的冲突解决策略。
- 写阶段(Write Phase):如果在验证阶段没有发现冲突,事务就会将它的更新操作应用到数据库。
乐观并发控制在冲突不频繁的情况下表现非常好,因为它在大多数时间里避免了昂贵的锁操作。然而,如果冲突频繁发生,乐观并发控制可能会导致大量的事务重启,从而影响性能。在实际的系统设计中,需要根据具体的需求和工作负载特性来选择合适的并发控制策略。
基于时间戳的并发控制
基于时间戳的并发控制(Timestamp-based Concurrency Control)是一种避免数据不一致并确保事务串行化的方法。在这种方法中,每个事务在开始时都会被分配一个唯一的时间戳,这个时间戳代表了事务的开始时间。
基于时间戳的并发控制的主要思想是:如果一个事务试图访问一个数据项,而且该数据项在该事务开始之后已经被另一个事务修改过,那么该事务就会被中止并重新开始。这可以确保事务的串行化顺序与它们的时间戳顺序一致。
基于时间戳的并发控制最大的优点是它避免了死锁,因为事务不需要等待其他事务释放锁。然而,这种方法也有一些缺点。例如,它可能会导致大量的事务终止,特别是在高并发的系统中。此外,如果事务的执行时间过长,可能会导致“饿死”,即事务长时间得不到执行。
与基于封锁的并发控制的区别
时间戳排序和基于锁的并发控制都是数据库中用于确保并发操作一致性的技术,但它们在实现方式和适用场景上有所不同。以下是它们的主要比较:
- 死锁
- 基于锁的并发控制:可能会出现死锁的情况。当两个或更多的事务相互等待对方释放资源时,就会发生死锁。为了解决死锁,需要实施死锁检测和解决策略,增加了复杂性。
- 时间戳排序:由于它不使用锁,因此不存在死锁的问题。
- 并发性
- 基于锁的并发控制:通过精细的锁定策略,可以实现较高的并发性,但可能需要更复杂的管理策略。
- 时间戳排序:在数据冲突较少的情况下,可以实现较高的并发性。但在数据冲突频繁的情况下,可能需要频繁地重启事务,这可能会降低并发性。
- 管理复杂性
- 基于锁的并发控制:需要管理与锁有关的各种信息,如锁的获取和释放,死锁的检测和解决等,增加了管理的复杂性。
- 时间戳排序:相对来说,管理复杂性较低,因为它只需要为每个事务分配并追踪一个时间戳。
在实际的数据库系统中,基于时间戳的并发控制通常与其他并发控制方法(如乐观并发控制或两阶段锁定协议)结合使用,以达到最好的效果。
基于有效性检查的并发控制
基于有效性检查的并发控制(Validation-based Concurrency Control),也被称为乐观并发控制(Optimistic Concurrency Control),是一种并发控制策略。这种策略通常假设冲突是比较少见的,因此它允许多个事务并行执行,只在事务提交时检查冲突。
基于有效性检查的并发控制主要包含三个阶段:
- 读阶段(Read Phase):事务开始执行,读取数据,并在本地做修改,此阶段不会影响到实际的数据,不会与其他事务产生冲突。
- 有效性检查阶段(Validation Phase):在这个阶段,事务需要检查它在读阶段读取的数据是否被其他事务修改过。如果没有冲突,事务可以进入写阶段,否则事务需要重新开始或者回滚。
- 写阶段(Write Phase):在这个阶段,事务将它的修改写入实际的数据。如果在有效性检查阶段检测到冲突,这个阶段可能不会执行。
基于有效性检查的并发控制对于冲突比较少见的系统来说效果很好,因为它可以减少锁的开销,提高系统的并发性能。然而,如果冲突比较频繁,这种策略可能会导致大量的事务回滚,降低系统的性能。
基于快照隔离的并发控制
快照隔离(Snapshot Isolation)也称为多版本并发控制(Multiversion Concurrency Control,MVCC),是一种并发控制机制,通常用于数据库系统中。它允许多个事务同时进行读操作,而不需要获得锁,进而提高了系统的并发性。
在快照隔离中,每个事务在开始时都会获得一个对数据库的“快照”,这个快照表示了该事务开始时数据库的状态。当事务进行读操作时,它会看到这个快照中的数据。因此,即使在事务执行期间,其他事务修改了数据库,也不会影响到这个事务的读操作。
当事务进行写操作时,会在一个新的版本中进行,而不是直接修改原始数据。只有当事务提交时,这些修改才会被应用到实际的数据库中。在这个过程中,系统会检查是否有其他事务对同样的数据进行了修改。如果有,那么这个事务可能需要进行回滚。
快照隔离的主要优点是提高了并发性,特别是在读操作占主导的系统中。然而,它也有一些缺点。例如,它需要为每个数据项保存多个版本,这可能会消耗更多的存储空间。此外,它也可能引入一种特殊类型的冲突,称为写偏(write skew),这需要特殊的机制来处理。
故障恢复技术
在数据库运行过程中,可能会出现多种类型的故障,以下是一些常见的故障类型:
- 事务故障:事务故障是最常见的故障类型,是由于事务执行过程中出现的错误导致的。比如,数据不符合完整性约束,或者运行时出现的错误,如除以零的错误,或者系统资源的不足(如内存不足)等。
- 系统故障:系统故障指的是整个数据库系统的突然停止,比如电源故障,操作系统崩溃,或者数据库管理系统(DBMS)软件的故障。这种故障会导致在系统停止时未完成的所有事务的中止。
- 媒体故障:媒体故障涉及到数据库的存储媒介,比如硬盘故障。这可能导致数据的丢失,从而需要从备份中恢复数据。
- 网络故障:在分布式数据库系统中,网络故障可能会导致数据库的一部分对其他部分不可用。
- 并发控制故障:当多个事务试图同时访问和修改同一个数据项时,可能会导致数据的不一致。
- 安全性故障:如果数据库受到攻击,或者由于错误配置导致未授权的访问,都可能导致数据的丢失或者破坏。
为了应对这些故障,数据库系统通常会采取一系列的故障恢复和故障预防措施,如日志管理,事务回滚,备份和恢复,冗余存储,安全性和访问控制等。
事务的执行过程以及可能产生的问题
事务的执行过程可以简化如下:
- 系统会为每个事务开辟一个私有工作区
- 事务读操作将从磁盘中拷贝数据项到工作区中,在执行写操作前所有的更新都作用于工作区中的拷贝.
- 事务的写操作将把数据输出到内存的缓冲区中,等到合适的时间再由缓冲区管理器将数据写入到磁盘。
由于数据库存在立即修改和延迟修改,所以在事务执行过程中可能存在以下情况:
- 在事务提交前出现故障,但是事务对数据库的部分修改已经写入磁盘数据库中。这导致了事务的原子性被破坏。
- 在系统崩溃前事务已经提交,但数据还在内存缓冲区中,没有写入磁盘。系统恢复时将丢失此次已提交的修改。这是对事务持久性的破坏。
日志的种类和格式
- <T,X,V1,V2>:描述一次数据库写操作,T是执行写操作的事务的唯一标识,X是要写的数据项,V1是数据项的旧值,V2是数据项的新值。
- <T,X,V1>:对数据库写操作的撤销操作,将事务T的X数据项恢复为旧值V1。在事务恢复阶段插入。
- <T start>: 事务T开始
- <T commit>: 事务T提交
- <T abort>: 事务T中止
关于日志,有以下两条规则
- 系统在对数据库进行修改前会在日志文件末尾追加相应的日志记录。
- 当一个事务的commit日志记录写入到磁盘成功后,称这个事务已提交,但事务所做的修改可能并未写入磁盘
日志恢复的核心思想
- 撤销事务undo:将事务更新的所有数据项恢复为日志中的旧值,事务撤销完毕时将插入一条<T abort>记录。
- 重做事务redo:将事务更新的所有数据项恢复为日志中的新值。
事务正常回滚/因事务故障中止将进行redo
系统从崩溃中恢复时将先进行redo再进行undo。
- 以下事务将进行undo:日志中只包括<T start>记录,但既不包括<T commit>记录也不包括<T abort>记录.
- 以下事务将进行redo:日志中包括<T start>记录,也包括<T commit>记录或<T abort>记录。
假设系统从崩溃中恢复时日志记录如下:
<T0 start> <T0,A,1000,950> <T0,B,2000,2050> <T0 commit> <T1 start> <T1,C,700,600>
由于T0既有start记录又有commit记录,将会对事务T0进行重做,执行相应的redo操作。
由于T1只有start记录,将会对T1进行撤销,执行相应的undo操作,撤销完毕将写入一条abort记录。
事务故障中止/正常回滚的恢复流程
- 从后往前扫描日志,对于事务T的每个形如<T,X,V1,V2>的记录,将旧值V1写入数据项X中。
- 往日志中写一个特殊的只读记录<T,X,V1>,表示将数据项恢复成旧值V1,这是一个只读的补偿记录,不需要根据它进行undo。
- 一旦发现了<T start>日志记录,就停止继续扫描,并往日志中写一个<T abort>日志记录。
系统崩溃时的恢复过程(带检查点)
检查点是形如<checkpoint L>的特殊的日志记录,L是写入检查点记录时还未提交的事务的集合,系统保证在检查点之前已经提交的事务对数据库的修改已经写入磁盘,不需要进行redo。检查点可以加快恢复的过程。
系统奔溃时的恢复过程分为两个阶段:重做阶段和撤销阶段。
重做阶段:
- 系统从最后一个检查点开始正向的扫描日志,将要重做的事务的列表undo-list设置为检查点日志记录中的L列表。
- 发现<T,X,V1,V2>的更新记录或<T,X,V>的补偿撤销记录,就重做该操作。
- 发现<T start>记录,就把T加入到undo-list中。
- 发现<T abort>或<T commit>记录,就把T从undo-list中去除。
撤销阶段:
- 系统从尾部开始反向扫描日志
- 发现属于undo-list中的事务的日志记录,就执行undo操作
- 发现undo-list中事务的T的<T start>记录,就写入一条<T abort>记录,并把T从undo-list中去除。
- undo-list为空,则撤销阶段结束
总结:先将日志记录中所有事务的更新按顺序重做一遍,在针对需要撤销的事务按相反的顺序执行其更新操作的撤销操作。
一个系统崩溃恢复的例子
恢复前的日志如下,写入最后一条日志记录后系统崩溃
<T0 start> <T0,B,2000,2050> <T2 commit> <T1 start> <checkpoint {T0,T1}> //之前T2已经commit,故不用重做 <T1,C,700,600> <T1 commit> <T2 start> <T2,A,500,400> <T0,B,2000> <T0 abort> //T0回滚完成,插入该记录后系统崩溃
总结
事务是数据库系统进行并发控制的基本单位,是数据库系统进行故障恢复的基本单位,从而也是保持数据库状态一致性的基本单位。ACID是事务的基本特性,数据库系统是通过并发控制技术和日志恢复技术来对事务的ACID进行保证的,从而可以得到如下的关于数据库事务的概念体系结构。