事务的四大特性#
- A 原子性:同一个事务中的所有操作要么全部成功,要么全部失败,靠 undo log 实现
- C 一致性:事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态,是通过持久性+原子性+隔离性来保证
- I 隔离性:事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态,依靠 MCVV 和 锁机制实现
- D 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失,通过 redo log (重做日志)来保证的
undo log 保证事务的原子性#
undo log 是一种用于回滚数据的日志,在事务未提交之前,MySQL 会先记录更新前的数据到 undo log 日志文件中,当需要回滚事务的时候,可以利用 undo log 回滚,从而保障了事务的原子性。
针对不同的操作类型,undo log 记录的内容也不相同,例如:
- 插入一条数据时,只需要记录主键值,回滚的时候把这个主键对应的数据删掉即可;
- 删除一条数据时,把这条数据中的内容记录下来,回滚的时候重新插入即可,
delete操作实际上不会立即直接删除数据,而是打上一个delete flag,然后由purge线程完成删除操作; - 更新一条数据时,
update的列如果是主键列,则先删除该条数据,再插入;如果不是主键列,则在 undo log 中直接反向记录是如何update的
但是每一次操作产生的 undo log 都包含:
roll_pointer指针,将 undo log 串成一个链表,称为版本链;trx_id记录该数据是被哪个事务所修改的。
这两个字段是实现 MCVV 的关键
并发事务引发的问题#
脏读#
一个事务读到了另一个未提交事务修改过的数据
不可重复读#
同一个事务中两次执行同样的查询语句,得到的结果不一样
幻读#
同一个事务中两次执行同样的范围查询,得到的记录数量不一样
事务的隔离级别#
读未提交#
最低的隔离级别,可以读取其他事务未提交的数据,并发度最高,但是可能出现脏读、不可重复读和幻读问题。
读已提交#
只能读取其他事务已经提交的数据,可能出现不可重复读和幻读问题。
可重复读#
一个事务执行过程中看到的数据一直跟这个事务启动时看到的数据是一致的,InnoDB 存储引擎默认的事务隔离级别。
可以解决脏读和不可重复读问题,InnoDB 存储引擎利用 MVCC + 临键锁可以很大程度上避免幻读问题
串行化#
会对记录加上读写锁,所有事务按顺序执行,完全解决并发事务引发的问题,但是并发度极低。
MVCC + 锁保证事务的隔离性#
- 对于「读未提交」隔离级别来说,只要每次读取最新的数据即可;
- 对于「串行化」隔离级别来说,在事务启动时候对记录加上读写锁,提交事务的时候释放锁,即可实现;
- 对于「读已提交」和「可重复读」,是通过 Read View 来实现的,只不过创建 Read View 的时机不同
Read View 和 MVCC 是如何工作的#
Read View 有四个重要的字段:
m_ids:创建该 Read View 的时候数据库中「已经启动但是还未提交的」事务 id 列表;min_trx_id:m_ids的最小值;max_trx_id:创建该 Read View 的时候数据库应该给下一个事务分配的 id 值;creator_trx_id:创建该 Read View 的事务 id
每一条聚簇索引中,会有两个隐藏字段:
trx_id:当一个事务修改了这条记录后,把该事务的事务 id 记录在trx_id中;roll_pointer:每次修改记录时,会把旧版本的记录写入到 undo log 中,roll_pointer是指向旧版本的指针,多个旧版本记录形成一条版本链。
当一个事务去访问数据库中的数据的时候,会根据 Read View 和聚簇索引中的这些字段,来决定数据的可见性:
-
如果
trx_id等于creator_trx_id,说明这个版本数据的创建者/修改者就是这个事务,自然是可见的 -
如果
trx_id小于min_trx_id,说明创建/修改这个版本数据的事务在这个 Read View 创建之前就已经提交了,可见; -
如果
trx_id大于max_trx_id,说明创建/修改这个版本数据的事务在这个 Read View 创建后才启动,不可见; -
如果
trx_id在min_trx_id和max_trx_id之间:- 如果
trx_id在m_ids中,说明创建/修改这个版本数据的事务还未提交,不可见; - 如果
trx_id不在m_ids中,说明创建/修改这个版本数据的事务已经提交,可见。
- 如果
这样事务只能看见自己可见的版本,这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)
可重复读是如何实现的#
可重复读是在每次启动事务的时候创建一个 Read View,然后整个事务期间都在使用这个 Read View
这样保证了即使在事务执行过程中有新的事务修改了数据并提交了,也读不到
读已提交是如何实现的#
读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。
事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。
可重复读是如何避免幻读问题的#
对于快照读,即普通 select 语句,是通过 MVCC 的方式解决幻读的,在可重复读隔离级别下,整个事务期间都在使用同一个 Read View,是看不到其他事务提交的新数据的
对于当前读,即 select ... for update 语句,是通过临键锁的方式解决幻读问题
redo log 保证事务的持久性#
InnoDB修改数据时不会直接写磁盘上的数据页,那样随机 IO太多,性能扛不住。它用的是WAL策略:先把修改操作顺序写到redo log,再找机会把数据页刷到磁盘。顺序写比随机写快几个数量级。
redo log 也是循环写的方式,有两个指针:
writepos表示当前写到哪了checkpoint表示已经刷盘的位置。
两个指针之间就是待刷盘的脏数据
redo log 什么时候写入到磁盘#
redo log 也有自己的缓存 redo log buffer,每当产生一条 redo log 时,会先写
入到 redo log buffer,后续在持久化到磁盘,主要有几个刷盘的时机:
-
MySQL 正常关闭的时候;
-
当 redo log buffer 中的写入量大于缓冲区的一般时
-
InnoDB 的后台线程每隔 1 秒刷盘;
-
每次事务提交的时候(由
innodb_flush_log_at_trx_commit)参数控制:- 设置该参数为 0,每次事务提交的时候不会刷盘;只靠后台线程每隔 1s 执行
write()写操作写到 Page Cache,然后调用fsync()持久化到磁盘。MySQL 崩溃会导致丢失 1s 数据; - 设置该参数为 1,每次事务提交的时候都刷盘;
- 设置该参数为 2,只写入到内核缓冲区 Page Cache,靠后台线程每隔 1s 执行
fsync()持久化到磁盘。只有操作系统崩溃才会丢失数据。
- 设置该参数为 0,每次事务提交的时候不会刷盘;只靠后台线程每隔 1s 执行
binlog#
MySQL 在完成一条更新操作后,Server 层还会生成一条 binlog,记录了所有数据库表变更和表数据修改,有 3 种格式类型:
- STATEMENT(默认) :记录每条修改数据的 SQL;
- ROW:记录行数据最终被修改成什么样子;
- MIXED:根据不同情况自动使用 ROW 模式和 STATEMENT 模式。
binlog 主要用于备份恢复和主从复制,保存的是全量的日志,写满一个文件,就会创建一个新的文件继续写。
binlog 也会先写到 binlog cache 中,每个线程都会有一个单独的 binlog cache,参数 sync_binlog 控制数据库的 binlog 刷到磁盘上的频率:
- 设置为 0,每次提交事务的时候都会
write但不fsync,交由操作系统决定何时持久化到磁盘。 - 设置为 1,每次提交事务的时候都会
write并马上fsync。 - 设置为 N,每次提交事务的时候都会
write,累积 N 个事务后才fsync。
两阶段提交#
事务提交后,redo log 和 binlog 都要持久化到磁盘,但是可能出现以下半成功状态:
- redo log 刷盘后,MySQL 宕机,binlog 还没来得及写入,MySQL 重启后会从 redo log 中恢复数据,但是从库由于 binlog 还没记录,会丢失这条数据,导致主从数据不一致;
- binlog 刷盘后,MySQL 宕机,redo log 还没来得及写入,会导致主库丢失这条数据,从库以为有 binlog 会同步这条数据,导致主从数据不一致。
为了避免这种情况,使用了「两阶段提交」来解决,把单个事务的提交过程分为准备阶段和提交阶段
MySQL 使用内部 XA 事务:
- 准备阶段:将 XID 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘;
- 提交阶段:将 XID 写入到 binlog,将 binlog 持久化到磁盘,然后将 redo log 的状态设置为 commit
对于处于 prepare 阶段的 redo log,即可以提交事务,也可以回滚事务,这取
决于是否能在 binlog 中查找到与 redo log 相同的XID,如果有就提交事务,如果没有就回滚事务。这样就可以保证 redo log 和 binlog 这两份日志的一致性了。