文章目录
定义
事务是指作为单个逻辑工作单元执行的一系列 SQL 操作(例如 INSERT、UPDATE、DELETE),要么全部执行,要么全部不执行。
特性
事务包含四个特性:
- A: atomicity. 原子性,事务中对数据的所有变更,要么全部执行,要么全部不执行。
- C: consistency. 一致性,当事务开始和结束时,数据处于一致状态。
- I: isolation. 隔离性,事务的中间状态对其他事务不可见,即事务之间互不影响。
- D: durability. 持久化,在事务成功完成后,对数据的更改将保持不变,即使在系统发生故障时也不会撤消。
其中原子性通过 UndoLog 实现、持久化通过 redoLog 实现、一致性和隔离性用锁和 MVCC 实现。
实现原理
原子性
事务原子性要求事务中的一系列操作要么全部完成,要么不做任何操作,不能只做一半。原子性对于原子操作很容易实现,就像HBase中行级事务的原子性实现就比较简单。而 MySQL 多条语句组成的事务则需要通过回滚到事务之前的状态,实现回滚操作完全依赖于 undo log,另外 undo log 也用来实现 MVCC,下文会介绍。
在事务开始操作任何数据之前,首先会将修改前的数据记录到 undo log 中,再进行实际修改。下图是MySQL中表示事务的基本数据结构,其中与 undo 相关的字段为 insert_undo 和 update_undo,分别指向本次事务所产生的 undo log。
事务回滚根据update_undo(或者insert_undo)找到对应的undo log,做逆向操作即可。对于已经标记删除的数据清理删除标记,对于更新数据直接回滚更新;插入操作稍微复杂一些,不仅需要删除数据,还需要删除相关的聚集索引以及二级索引记录。
隔离性
首先隔离性的作用是限定事务内外的哪些改变是可见的,哪些是不可见的。低级别的隔离级别一般支持更高的并发处理,并拥有更低的系统开销。
并发控制中读取同一个表的数据,可能出现如下问题:
脏读(Drity Read):事务T1修改了一行数据,事务T2在事务T1提交之前读到了该行数据。
不可重复读(Non-repeatable Read): 事务T1读取了一行数据。 事务T2接着修改或者删除了改行数据,当T1再次读取同一行数据的时候,读到的数据时修改之后的或者发现已经被删除。
幻读(Phantom Read): 事务T1读取了满足某条件的一个数据集,事务T2插入了一行或者多行数据满足了T1的选择条件,导致事务T1再次使用同样的选择条件读取的时候,得到了比第一次读取更多的数据集。
因此 ANSI SQL STANDARD 定义了以下4类隔离级别,通过不同的锁等机制实现。
Read Uncommitted(使用X锁实现写写并发)
Read Uncommitted只实现了写写并发控制,并没有有效的读写并发控制,导致当前事务可能读到其他事务中还未提交的修改数据,这些数据准确性并不靠谱(有可能被回滚掉),因此在此基础上作出的一切假设就都不靠谱的。在现实场景中很少有业务会选择该隔离级别。
写写并发使用两阶段锁协议(两阶段为加锁和解锁)对相应记录加锁实现多个事务在并发操作同一记录的情况下等同于串行的执行,根据行记录是否是主键索引、唯一索引、非唯一索引或者无索引等分为多种加锁情况。这里举个简单例子做下说明:
update user set userName = “libis” where id = 15
- 如果id列是主键索引,MySQL只会为聚簇索引记录加锁。
- 如果id列是唯一二级索引,MySQL会为二级索引叶子节点以及聚簇索引记录加锁。
- 如果id列是非唯一索引,MySQL会为所有满足条件(id = 15)的二级索引叶子节点以及对应的聚簇索引记录加锁。
- 如果id列是无索引的,SQL会走聚簇索引全表扫描,并将扫描结果加载到SQL Server层进行过滤,因此InnoDB会为扫描过的所有记录先加上锁,如果 SQL Server 层过滤不符合条件,InnoDB会释放该锁(违背了 2PL 约束,但提高了并发效率),保证最后只会持有满足条件记录上的锁。
Two-phase locking (2PL)
两阶段加锁(2PL)将事务锁的申请与释放拆为两步:
1. 在事务过程中统一加锁
2. 在事务提交或回滚后统一放锁
除非事务提交或者回滚,否则不会在事务的中间状态释放锁。所以在事务申请 lock 的过程中,需要判断是否与其他事务持有的 lock 冲突,对于冲突情况需要进入 waiting 队列,而在持有 lock 的事务提交或者回滚之后,都会释放持有的事务锁,从而选择等待队列里的事务进行 grant lock。
无论是 RC、RR,亦或是 Serialization,写写并发控制都使用上述加锁机制。
Read Committed(写写并发使用X锁,读写并发使用MVCC避免脏读)
RC 相比 RUC 最大的区别的通过 MVCC 解决脏读问题,以下介绍 MVCC 机制在 MySQL 中的实现。
MVCC in MySQL
Multi-Version Concurrency Control 简单来说即通过为数据库表增加版本标识来实现无锁的读写并发。
一条语句,能够看到 (快照读) 本语句开始时 (RC) /本事务开始时 (RR) 已经提交的其他事务所做的修改。
InnoDB 会为每行增加三个隐藏字段:
DB_TRX_ID
:标识插入或更新该行的最后一个事务的事务 ID。另外,删除也被当做更新处理,并会在行中将其标记为已删除。DB_ROLL_PTR
:回滚指针,指向回滚段中的 undo log(分为 insert 和 update undo logs),指向记录的上一个版本。DB_ROW_ID
:一个自动递增的ID,如果没有显式的主键,它可以用作主键,这个字段不一定会生成。
如下图,通过 undo log 以链表形式组织历史记录链表。
事务在开启后,会创建一个数据结构存储事务相关信息、锁信息、undo log 以及非常重要的read_view 信息。read_view 保存了当前事务开启时整个 MySQL 中所有活跃事务列表,其主要结构包括:
m_low_limit_id
read_view 创建时,当前数据库中应该给下一个事务的 id 值,也就是数据库全局事务中最大的事务 id 值 + 1;m_up_limit_id
read_view 创建时,当前数据库的活跃且未提交的事务中最小的事务 id;m_creator_trx_id
创建该 read_view 的事务的 id;m_ids
read_view 创建时,当前数据库的活跃且未提交的事务 id 列表。
对于快照读出来的每一行记录,都会用 read_view 来判断一下当前这行是否可以被当前事务看到,如果可以,则输出,否则就利用 undolog 来构建历史版本,再进行判断,直到记录构建玩所有的版本或者可见性条件满足。
read_view 可见性判断规则:
- 如果记录 trx_id 小于 m_up_limit_id 或者等于 m_creator_trx_id,表明 read_view 创建的时候该事务已经提交,记录可见。
- 如果记录的 trx_id 大于等于 m_low_limit_id,表明事务是在 read_view 创建后开启的,其修改,插入的记录不可见。
- 当 trx_id 在 m_up_limit_id 和 m_low_limit_id 之间的时候
- 如果 trx_id 在 m_ids 数组中,表明 read_view 创建时候,事务处于活跃状态,因此记录不可见;
- 若不在 m_ids 数组中,表示事务已经被提交,该版本记录可见。(不在活跃事务列表,且事务 id 小于全局下一个事务 id,那只能是已经提交过的事务。另外,事务提交时间与事务编号没有任何关联,有可能事务编号大的事务先提交,事务编号小的事务后提交)
Repeatable Read(写写并发使用X锁,读写并发使用MVCC避免不可重复读;当前读使用 Gap 锁避免幻读)
对于写写并发,RR 级别下最大的区别在于引入间隙锁 Gap Lock 和 临键锁 Next-Key Lock。
Next-Key Lock = Gap Lock + Record Lock,前开后闭 ( X, Y ] 区间。
加锁的对象是索引,加锁的基本单位是 Next-Key Lock,在能使用记录锁或者间隙锁就能避免幻读现象的场景下, Next-Key Lock 就会退化成记录锁或间隙锁。
以同样的一个语句来说明 RR 级别下的锁表现:
update user set userName = “libis” where id = 15
- 如果id列是主键索引
- 若记录存在,则会为聚簇索引记录加行锁( Next-Key Lock 退化为行锁);
- 若记录不存在,则通过索引找到第一条大于该查询记录的记录后,在这个记录及之前的记录加上 Gap Lock(将该记录的索引中的 Next-Key Lock 退化为Gap Lock)。
- 如果id列是唯一二级索引,与 1 的聚簇索引加锁行为一致,只是不仅需要加唯一索引的锁,记录存在的情况下也要加聚簇索引的行锁。
- 如果id列是非唯一索引
- 若记录存在,加锁需要扫索引直到扫到二级索引上第一个不符合条件的记录则停止。MySQL 会为所有满足条件(id = 15)的二级索引叶子节点加 Next-Key Lock,对第一个不符合条件的节点前面的间隙加 Gap Lock,并且对应的聚簇索引记录加行锁。
- 若记录不存在,扫描到第一条不符合条件的二级索引记录,该二级索引的 Next-Key Lock 会退化成 Gap Lock。
- 如果 id 列是无索引的或查询没有走索引,则扫描是全表扫描。那么,每一条记录的索引上都会加 Next-Key Lock,这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞。
对于读写并发,RC 和 RR 都使用 MVCC 机制实现事务之间的读写并发。只不过两者在实现细节上有一些区别:
- 针对 RR 隔离级别,在第一次创建 read_view 后,这个 read_view 就会一直持续到事务结束,也就是说在事务执行过程中,数据的可见性不会变,所以在事务内部不会出现不一致的情况。
- 针对RC隔离级别,事务中的每个查询语句都单独构建一个 read_view,所以如果两个查询之间有事务提交了,两个查询读出来的结果就不一样。
****RR 隔离级别能够避免幻读吗?****
MySQL 对于读,分为快照读(Consistent Read)和当前读(Locking Read)
当前读表示强制读取数据的最新状态,并且这个数据状态一定是已事务提交后的数据状态
- 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
- 针对当前读(select … for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
还有一种场景是事务中快照读和当前读混用会产生幻读,笔者持怀疑态度,这种用法本身没问题吗?
很多互联网公司,为了提升并发度和降低死锁发生的概率,会把 MySQL 数据库的隔离级别从默认的 RR 调整成 RC。
当然,这样做也不是完全没有问题,首先使用 RC 之后,就需要自己解决幻读的问题,这个其实还好,很多时候幻读问题其实是可以忽略的,或者可以用其他手段解决。
还有就是使用 RC 的时候,不能使用 statement 格式的 binlog,这种影响其实可以忽略不计了,因为MySQL 是在5.1.5版本开始支持 row 的、在5.1.8版本中开始支持 mixed,后面这两种可以代替 statement 格式。
Serializable
串行化,从 MVCC 并发控制退化为基于锁的并发控制。不分快照读与当前读,所有的读操作均为当前读,读加读锁 (S 锁),写加写锁 (X 锁)。
Serializable 隔离级别下,读写冲突,因此并发度急剧下降,在 MySQL/InnoDB 下不建议使用。
持久化
InnoDB 持久化需要 doublewrite buffer、redo log 以及 binlog 支持。
Doublewrite Buffer
InnoDB 对 buffer pool 数据进行持久化时,并不直接刷入磁盘文件,实际上 InnoDB 的真实数据写入分为两次写入,先写入 doublewrite buffer,写成功之后再真实写入数据所在磁盘。
为什么要写两次?这是因为 MySQL 数据页大小与磁盘一次原子操作大小不一致,有可能会出现部分写入的情况,比如默认 InnoDB 数据页大小默认为 16K,Linux 操作系统的页大小 4K,而磁盘一次原子写入大小为 512 字节(扇区大小),这样一个数据页写入需要多次 IO,无法保证原子性写入,这样一旦中间发生异常(宕机、停电等)就会出现数据页不完整(partial page write)。如下图:
此时重启后,磁盘上就是不完整的数据页,就算使用 redo log 也是无法进行恢复。
Redo Log 无法修复这类“页数据损坏”的异常,修复的前提是“页数据正确”并且Redo日志正常。
原因是 redo log 的形式实际上是 Physiological Logging,即物理逻辑日志。
如 binlog 是逻辑日志(Logical Log),无法做到重放幂等,但是优点是数据量小;而幂等则是基于 Page 的物理日志(Physical Logging)的特点,记录某页面上的实际数据,但是数据量大。所谓 Physiological Logging,就是以Page为单位,但在Page内以逻辑的方式记录。举个例子,MLOG_REC_UPDATE_IN_PLACE类型的REDO中记录了对Page中一个Record的修改,方法如下:
(Page ID,Record Offset,(Filed 1, Value 1) … (Filed i, Value i) … )
其中,PageID指定要操作的Page页,Record Offset记录了Record在Page内的偏移位置,后面的Field数组,记录了需要修改的Field以及修改后的Value。
Redo Log 这种日志形式结合了数据量小且重放幂等的优点。
针对上面出现的情况,如何解决这类“页数据损坏”的问题呢?其实就是在重做日志前,用户需要一个页的副本,当写入失效发生时,先通过页的副本来还原该页,再进行重做,这就是 double write。
Doublewrite Buffer 由两部分组成,一部分为内存中的 Doublewrite Buffer,其大小为2MB,另一部分是磁盘上共享表空间(ibdata x)中连续的128个页,即2个区(extent),大小也是2M。
crash 后,通过检查 DWB 的数据的完整性,如果不完整直接丢弃 DWB 内容,重新执行那条 redo log,如果 DWB 的数据是完整的,用 DWB 的数据更新该数据页,跳过该 redo log。
另外在性能上因为写入 Doublewrite Buffer 是顺序写入,对性能影响来说不是很大。
一般情况下,Doublewrite Buffer 默认启用。要禁用,可以设置 [innodb_doublewrite](https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_doublewrite)
为 0。(MySQL 8.0 关掉 Double Write 能有 5% 左右的性能提升)
RedoLog
redolog 是 InnoDB 的 WAL,保证数据库 crash-safe 能力,数据先写入 redolog 并落盘,再写入更新到 bufferpool。redolog 是循环写的,没有办法保留很长的周期,当数据被写满的时候,队列中的数据会被强制同步到磁盘。??
redolog 的持久化策略和 HBase 中 hlog 的持久化策略一致,默认为1;通过innodb_flush_log_at_trx_commit
控制:
- 为 1 表示每次事务提交之后log就会持久化到磁盘;(默认1)
- 为 0 表示每隔 1 秒钟左右由异步线程持久化到磁盘,这种情况下MySQL发生宕机有可能会丢失部分数据;
- 为 2 表示每次事务提交之后 log 会 flush 到操作系统缓冲区,再由操作系统异步 flush 到磁盘,这种情况下 MySQL 发生宕机不会丢失数据,但机器宕机有可能会丢失部分数据。
Binlog
binlog 作为 MySQL Server 层的日志系统,主要用于归档,以 events 的形式顺序纪录了数据库的各种操作,同时可以纪录每次操作所花费的时间。在MySQL官方文档上,主要介绍了 Binlog 的两个最基本核心作用:备份和复制,因此 binlog 的持久化会一定程度影响数据备份和复制的完整性。持久化通过 sync_binlog
控制:
- 为 0 表示写入操作系统缓冲区,操作系统异步 flush 到磁盘;(默认0)
- 为 1 表示每次提交后同步写入磁盘;
- 为 N 表示每写 N 次操作系统缓冲就执行一次刷磁盘操作。
双1配置:
通过将innodb_flush_log_at_trx_commit
和 sync_binlog
都设置为 1,最大可能保证数据不丢失。
Redolog 和 Binlog 一致性问题:
二者采用两阶段提交,如下图
可以看到,所谓两阶段提交,其实就是把 redo log 的写入拆分成了两个步骤:prepare 和 commit。
根据两阶段提交,崩溃恢复时的判断规则是这样的:
- 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交;
- 如果 redo log 里面的事务处于 prepare 状态,则判断对应的事务 binlog 是否存在并完整:
- 如果 binlog 存在并完整,则提交事务;
- 否则,回滚事务。
redo log和binlog有一个共同的数据字段叫 XID 将他们关联起来。
参考资料
ACID properties of transactions
InnoDB Repeatable Read隔离级别之大不同
Locks Set by Different SQL Statements in InnoDB