认识事务
什么是数据库事务
事务是访问数据库的程序执行单元;事务中可能包含一个或多个 SQL 语句,这些语句要么都执行,要么都不执行。事务的定义有几点需要解释下
- 数据库事务可以包含一个或多个
SQL 语句,这些操作构成一个逻辑上的整体 - 构成逻辑整体的这些数据库操作,要么全部执行成功,要么全部不执行
- 构成事务的所有操作,要么全都对数据库产生影响,要么全都不产生影响,即不管事务是否执行成功,数据库总能保持一致性状态
- 以上即使在数据库出现故障以及并发事务存在的情况下依然成立
为什么需要数据库事务
转账是生活中常见的操作,比如从 A 账户转账 100 元到 B 账号。站在用户角度而言,这是一个逻辑上的单一操作,然而在数据库系统中,至少会分成两个步骤来完成
- 将
A 账户的金额减少 100 元 - 将
B 账户的金额增加 100 元
在这个过程中可能会出现以下问题
- 转账操作的第一步执行成功,
A 账户上的钱减少了 100 元,但是第二步执行失败或者未执行便发生系统崩溃,导致 B 账户并没有相应增加 100 元 - 转账操作刚完成就发生系统崩溃,系统重启恢复时丢失了崩溃前的转账记录
- 同时又另一个用户转账给
B 账户,由于同时对 B 账户进行操作,导致 B 账户金额出现异常
为了便于解决这些问题,需要引入数据库事务的概念
数据库事务的特性
- 原子性(
Atomicity ):事务中的所有操作作为一个整体像原子一样不可分割,要么全部成功,要么全部失败 - 一致性(
Consistency ):事务的执行结果必须使数据库从一个一致性状态到另一个一致性状态 - 隔离性(
Isolation ):并发执行的事务不会相互影响,其对数据库的影响和它们串行执行时一样。比如多个用户同时往一个账户转账,最后账户的结果应该和他们按先后次序转账的结果一样 - 持久性(
Durability ):事务一旦提交,其对数据库的更新就是持久的。任何事务或系统故障都不会导致数据丢失
按照严格的标准,只有同时满足 ACID 特性才是事务;但是在各大数据库厂商的实现中,真正满足 ACID 的事务少之又少。例如 MySQL 的 NDB Cluster 事务不满足持久性和隔离性;InnoDB 默认事务隔离级别是可重复读,不满足隔离性;Oracle 默认的事务隔离级别为 READ COMMITTED ,不满足隔离性……因此与其说 ACID 是事务必须满足的条件,不如说它们是衡量事务的四个维度
数据库事务特性的实现原理
在说明原子性原理之前,首先介绍一下 MySQL 的事务日志。MySQL 的日志有很多种,如二进制日志、错误日志、查询日志、慢查询日志等,此外 InnoDB 存储引擎还提供了两种事务日志:redo log ([ri??du?] 重做日志)和 undo log ([?n?du?] 回滚日志)。其中 redo log 用于保证事务持久性;undo log 则是事务原子性和隔离性实现的基础
原子性
undo log 是实现原子性的关键,当事务回滚时能够撤销所有已经成功执行的 SQL 语句。InnoDB 实现回滚,靠的就是 undo log
- 如果事务对数据库进行修改时,
InnoDB 会生成对应的 undo log - 如果事务执行失败或调用了
rollback 导致事务需要回滚,便可以利用 undo log 中的信息将数据回滚到修改之前的样子
undo log 属于逻辑日志,它记录的是 SQL 执行的相关信息。当发生回滚时,InnoDB 会根据 undo log 的内容做与之前相反的工作
- 对于每个
insert ,回滚时会执行 delete - 对于每个
delete ,回滚时会执行 insert - 对于每个
update ,回滚时会执行一个相反的 update ,把数据改回去
以 update 操作为例:当事务执行 update 时,其生成的 undo log 中会包含被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到 update 之前的状态
持久性
redo log 存在的背景:InnoDB 作为 MySQL 的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘 IO ,效率会很低。为此 InnoDB 提供了缓存 Buffer Pool ,Buffer Pool 中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:当从数据库读取数据时,会首先从 Buffer Pool 中读取,如果 Buffer Pool 中没有,则从磁盘读取后放入 Buffer Pool ;当向数据库写入数据时,会首先写入 Buffer Pool ,Buffer Pool 中修改的数据会定期刷新到磁盘中(这一过程称为刷脏)
Buffer Pool 的使用大大提高了读写数据的效率,但是也带了新的问题:如果 MySQL 宕机,而此时 Buffer Pool 中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证
于是 redo log 被引入来解决这个问题:当数据修改时,除了修改 Buffer Pool 中的数据,还会在 redo log 记录这次操作;当事务提交时,会调用 fsync 接口对 redo log 进行刷盘。如果 MySQL 宕机,重启时可以读取 redo log 中的数据,对数据库进行恢复。redo log 采用的是 WAL (Write-ahead logging ,预写式日志),所有修改先写入日志,再更新到 Buffer Pool ,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求
既然 redo log 也需要在事务提交时将日志写入磁盘,为什么它比直接将 Buffer Pool 中修改的数据写入磁盘(即刷脏)要快呢?
- 刷脏是随机
IO ,因为每次修改的数据位置随机,但写 redo log 是追加操作,属于顺序 IO - 刷脏是以数据页为单位的,
MySQL 默认页大小是16KB ,一个数据页上一个小修改都要整页写入;而 redo log 中只包含真正需要写入的部分,无效 IO 大大减少
redo log 与 bin log
在 MySQL 中还存在 bin log (二进制日志)也可以记录写操作并用于数据的恢复,但二者是有着根本的不同的
- 作用不同:
redo log 是用于事故恢复的,保证 MySQL 宕机也不会影响持久性;bin log 是用于时间点恢复的,保证服务器可以基于时间点恢复数据,此外 bin log 还用于主从复制 - 层次不同:
redo log 是 InnoDB 存储引擎实现的,而 bin log 是 MySQL 的服务器层实现的,同时支持 InnoDB 和其他存储引擎 - 写入时机不同:
bin log 在事务提交时写入;redo log 的写入时机相对多元
隔离性
与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们主要考虑最简单的读操作和写操作(加锁读等特殊读操作会特殊说明),那么隔离性的探讨,主要可以分为两个方面
- 一个事务写操作对另一个事务写操作的影响:锁机制保证隔离性
- 一个事务写操作对另一个事务读操作的影响:
MVCC 保证隔离性
锁机制
隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB 通过锁机制来保证这一点。锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁;获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁
MySQL 锁的分类
- 按锁的粒度划分,可分为表级锁、行级锁
- 按锁级别划分,可分为共享锁、排他锁
- 按使用方式划分,可分为乐观锁、悲观锁
本文重点是 MySQL 事务的实现原理,因此对锁的介绍到此为止,详情可参考 这里
脏读、不可重复读和幻读
数据库在并发情况下,读操作可能存在的三类问题
- 脏读:当前事务
A 可以读到其他事务 B 未提交的数据(脏数据),这种现象是脏读
- 不可重复读:在事务
A 先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据
- 幻读:在事务
A 按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了
数据库事务隔离级别
一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。隔离级别与读问题的关系如下
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|
读未提交 | 会 | 会 | 会 | 读已提交 | 不会 | 会 | 会 | 可重复读 | 不会 | 不会 | 会 | 串行化 | 不会 | 不会 | 不会 |
在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。因此在大多数数据库系统中,默认的隔离级别是读已提交(如 Oracle )或可重复读(如 MySQL )
MVCC
MVCC 全称 Multi-Version Concurrency Control ,即多版本的并发控制协议。下面的例子很好的体现了 MVCC 的特点:在同一时刻,不同的事务读取到的数据可能是不同的(即多版本)——在 T5 时刻,事务 A 和事务 C 可以读取到不同版本的数据
MVCC 最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB 实现 MVCC ,多个版本的数据可以共存。下面以可重复读隔离级别为例,结合前文提到的几个问题分别说明
当事务 A 在 T3 时刻读取 zhangsan 的余额前,会生成 ReadView ,由于此时事务 B 没有提交仍然活跃,因此其事务 id 一定在 ReadView 的 rw_trx_ids 中,因此根据前面介绍的规则,事务 B 的修改对 ReadView 不可见。接下来,事务 A 根据指针指向的 undo log 查询上一版本的数据,得到 zhangsan 的余额为 100 。这样事务 A 就避免了脏读
当事务 A 在 T2 时刻读取 zhangsan 的余额前,会生成 ReadView 。此时事务 B 分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务 id 在 ReadView 的 rw_trx_ids 中;一种是事务 B 还没有开始,此时其事务 id 大于等于 ReadView 的 low_limit_id 。无论是哪种情况,根据前面介绍的规则,事务 B 的修改对 ReadView 都不可见
当事务 A 在 T5 时刻再次读取 zhangsan 的余额时,会根据 T2 时刻生成的 ReadView 对数据的可见性进行判断,从而判断出事务 B 的修改不可见;因此事务 A 根据指针指向的 undo log 查询上一版本的数据,得到 zhangsan 的余额为 100 ,从而避免了不可重复读
MVCC 避免幻读的机制与避免不可重复读非常类似
小结
InnoDB 实现的可重复读,通过锁机制、MVCC 等,实现了一定程度的隔离性,可以满足大多数场景的需要;但是,可重复读虽然避免了幻读问题,但是毕竟不是 Serializable (串行化),不能保证完全的隔离
一致性
可以说,一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障
- 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
- 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
- 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致
总结
下面总结一下 ACID 特性及其实现原理
- 原子性:语句要么全执行,要么全不执行,是事务最核心的特性,事务本身就是以原子性来定义的;实现主要基于
undo log - 持久性:保证事务提交后不会因为宕机等原因导致数据丢失;实现主要基于
redo log - 隔离性:保证事务执行尽可能不受其他事务影响;
InnoDB 默认的隔离级别是可重复读,它的实现主要基于锁机制、MVCC - 一致性:事务追求的最终目标,一致性的实现既需要数据库层面的保障,也需要应用层面的保障
|