# 事务

官方文档 (opens new window)

# 概述

数据库事务是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。

一般来说,数据库事务具有ACID这4个特性

  • 原子性(Atomicity): 一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性(Consistency): 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。
  • 隔离性(Isolation): 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
  • 持久性(Durability): 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

数据库事务是由数据库系统保证的,我们只需要根据业务逻辑使用它就可以了。在MySQL中只有使用了Innodb数据库引擎的数据库表才支持事务。

# 事务的实现

事务隔离性是来实现的, redo log(重做日志)用来保证事务的原子性和持久性undo log用来保证事务的一致性

或许有人认为redoundo的逆过程,其实不对的。redoundo的作用都可以看做是一种恢复操作,redo恢复提交事务修改的页,而undo回滚行记录。因此两者记录的内容不同,redo通常是物理日志,记录的是页的物理修改操作,undo是逻辑日志,根据每行记录进行记录。

# Redo Log

官方文档 (opens new window)

# 1. 基本概念

redo log包括两部分:一是内存中的日志缓冲(redo log buffer),其易失性的;二是磁盘上的重做日志文件(redo log file),其是持久的。

InnoDB是事务的存储引擎,在概念上,其通过Force Log at Commit机制实现事务的持久性,即在事务提交(COMMIT)时,必须先将该事务的所有事务日志写入到磁盘上的redo log fileundo log file中进行持久化,待事务的COMMIT操作完成才算完成。

为了确保每次日志都能写入到事务日志文件中,在每次将日志缓冲(redo log buffer)写入重做日志文件(redo log file)之后,InnoDB存储引擎都需要调用一次fsync操作(即fsync()系统调用)。 之所以要经过一层os buffer,是因为open日志文件的时候,open没有使用O_DIRECT标志位,该标志位意味着绕过操作系统层的os bufferIO直写到底层存储设备。 不使用该标志位意味着将日志进行缓冲,缓冲到了一定容量或者显式调用fsync()才会将缓冲中的日志刷到存储设备。如果使用该标志位,则意味着每次都要发起系统调用。

fsync

InnoDB存储引擎允许用户手工设置非持久性的情况发生,以此提高数据库的性能。既当事务提交时,日志不写入事务日志文件,而是等待一个时间周期后再执行fsync操作。 由于并非强制在事务提交时进行一次fsync操作,显然这可以显著提高数据库的性能,但当数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的事务。

参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略,该参数的默认值为1。可以通过命令查看此参数的设置:

select @@innodb_flush_log_at_trx_commit;
1

其中参数值含义如下:

  • 1 表示事务提交时必须调用一次fsync操作;
  • 0 表示事务提交时不进行写入redo日志操作,这个操作仅在 master thread 中完成,而在 master thread 中每1秒会进行一次redo日志文件的fsync操作;
  • 2 表示事务提交时把redo日志写入磁盘文件对应的文件系统的缓存中,不进行fsync操作; innodb_flush_log_at_trx_commit

# 2. 日志块(log block)

InnoDB存储引擎中,redo log以块为单位进行存储的,每个块占512字节,这称为redo log block

# 3. log group 和 redo log file

log group 是逻辑上的概念,并没有一个实际存储的物理文件来表示log group信息。log group表示的是redo log group,一个组内由多个大小完全相同的redo log file组成。

# 4. redo log 格式

由于InnoDB存储引擎的存储管理是基于页的,故其redo log也是基于页的。

# 5. LSN

LSN称为日志序列号(Log Sequence Number)。在InnoDB存储引擎中,LSN占用8个字节,并且单调递增。LSN表示的含义有:

  • 重做日志写入的总量,通过LSN开始号码和结束号码可以计算出写入的日志量
  • checkpoint(检查点) 的位置
  • 页的版本

# 6. Redo Log 总结 🎉

  1. 是 Innodb 存储引擎层生成的日志,实现了事务的持久性
  2. 将写操作从磁盘的「随机写」变成了「顺序写」,提升MySQL写入磁盘的性能。
  3. WAL (Write-Ahead Logging)技术,指的是 MySQL 的写操作并不是立刻更新到磁盘上,而是先记录在日志(Redo Log)上,然后在合适的时间再更新到磁盘上。

# Undo Log

官方文档 (opens new window)

# 1. 基本概念

undo log有两个作用:提供回滚MVCC(多版本并发控制)

对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生相对应的undo,如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以借助该undo进行回滚

undo log 存放在数据库内部的一个特殊段(segment)中,这个段称为undo log segment

undo logredo log记录物理日志不一样,它是逻辑日志InnoDB存储引擎回滚时,它实际上做的是与先前相反的工作。对于每个INSERTInnoDB存储引擎会完成一个DELETE; 对于每个DELETEInnoDB存储引擎会执行一个INSERT;对于每个UPDATEInnoDB存储引擎会执行一个相反的UPDATE

除了回滚操作,undo的两一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成的。当用户读取一行记录时,若该记录已经被其他的事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取

最后也是最重要的一点是,undo log也会产生redo log,因为undo log也要实现持久性保护

# 2. undo log 的存储方式

InnoDB存储引擎对undo的管理采用段的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log segment

# 3. undo log(回滚日志)总结 🎉

  1. 是 Innodb 存储引擎层生成的日志,实现了事务中的原子性,主要用于事务回滚和MVCC
  2. 一个事务在执行过程中,在还没有提交事务之前,如果MySQL 发生了崩溃,可以通过这个日志(undo log)回滚到事务之前的数据;
  3. MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 ReadView 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。

redo log 和 undo log 区别在哪?

  • redo log 记录了此次事务**「完成后」的数据状态,记录的是更新之「」**的值;
  • undo log 记录了此次事务**「开始前」的数据状态,记录的是更新之「」**的值;

# purge

purge 用于最终完成deleteupdate操作。这样设计是因为InnoDB存储引擎支持MVCC,所以记录不能再事务提交时立即进行处理。这时其他事务可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。 而是否可以删除该条记录通过purge来进行判断。若该行记录已不被任何事务引用,那么就可以进行真正的delete操作。

官方解释

In the InnoDB multi-versioning scheme, a row is not physically removed from the database immediately when you delete it with an SQL statement. InnoDB only physically removes the corresponding row and its index records when it discards the update undo log record written for the deletion. This removal operation is called a purge, and it is quite fast, usually taking the same order of time as the SQL statement that did the deletion.

# group commit

组提交(group commit)即一次fsync可以刷新确保多个事务日志被写入文件。

# 事务隔离级别

序号 英文名 中文名 脏读 不可重复读 幻读
1 read_uncommited 读未提交
2 read_commited 读已提交
3 repeatable_read 可重复读
4 serilizable 序列化读
  • ❌ 表示当前事务级别未解决了此问题
  • ✅ 表示当前事务级别已经解决了此问题

# 脏读

脏读重点在于事务A读取事务B尚未提交的更改数据(UPDATE)并在这个数据的基础上进行操作,这时候如果事务B回滚,那么事务A读到的数据是不被承认的。产生的流程如下:

序号 事务A 事务B
1 start transaction;
2 start transaction;
3 select balance from account where id=1; (结果为100)
4 update account set balance = 200 where id=1;
5 select balance from account where id=1; (结果为200)

# 不可重复读

不可重复读是指在同一个事务内,两次相同的查询返回了不同的结果。事务A第一次读取某一条数据后,事务B更新该数据并提交了事务事务A再次读取该数据,两次读取便得到了不同的结果。

不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了数据库一致性的要求。

产生的流程如下:

序号 事务A 事务B
1 start transaction;
2 start transaction;
3 select balance from account where id=1; (结果为100)
4 update account set balance = 200 where id=1;
5 commit;
6 select balance from account where id=1; (结果为200)

不可重复读有一种特殊情况,两个事务更新同一条数据资源,后完成的事务会造成先完成的事务更新丢失,这种情况就是第二类丢失更新。产生的流程如下:

序号 事务A 事务B
1 start transaction;
2 start transaction;
3 select balance from account where id=1; (结果为100)
4 select balance from account where id=1; (结果为100)
5 update account set balance = 0 where id=1; (取出100元)
6 commit;
5 update account set balance = 200 where id=1; (存入100元)
6 commit;
6 select balance from account where id=1; (结果为200,丢失更新)

我们在平时写代码的时候,需要特别注意第二类丢失更新(覆盖丢失/两次更新问题,Second lost update) 的问题,可以使用乐观锁的解决这个问题。

第一类丢失更新(回滚丢失,Lost update)

事务A期间,事务B对数据进行了更新并提交;在事务A回滚之后,覆盖了事务B已经提交的数据。SQL92没有定义这种现象,标准定义的所有隔离级别都不允许第一类丢失更新发生。

# 幻读

幻读重点在于A事务读取B事务提交的新增数据(INSERT),会引发幻读问题。幻读一般发生在计算统计数据的事务中。产生的流程如下:

序号 事务A 事务B
1 start transaction;
2 start transaction;
3 select count(*) from account_transfer_record where account_id=1; (结果为0)
4 insert into account_transfer_record(id,account_id,amount) values(1,1,100);
5 commit;
6 select count(*) from account_transfer_record where account_id=1; (结果为1)

MySQL Innodb存储引擎的默认支持的隔离级别是REPEATABLE-READ(可重读)。可以通过命令查看数据库事务数据库隔离级别:

-- `MySQL 8.0` 该命令改为`SELECT @@transaction_isolation;`

SELECT @@tx_isolation;
1
2
3

很多公司把MySQL Innodb存储引擎的隔离级别设置成READ-COMMITTED,是为了防止频繁的出现死锁,所以我们平时在写代码的时候需要注意。

延伸思考

我们知道,MySQL Innodb存储引擎默认的事务隔离级别是RR,是会出现幻读的,那么它是如何避免幻读的呢?

# MySQL是怎么解决幻读问题的?

  • 快照读: 通过MVCC(并发多版本控制)来解决幻读问题
    • MVCC 的实现依赖于:隐藏字段Read Viewundo log
    • 隐藏字段:事务ID、回滚指针、DB_ROW_ID(如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该 id 来生成聚簇索引)
  • 实时读(执行 select...for update/lock in share modeinsertupdatedelete): 通过采用Next-Key Locking机制来解决幻读问题

# 快照读

MySQL默认的隔离级别是可重复读(RR),这种隔离级别下,普通的SELECT语句都是快照读,也就是在一个事务内,多次执行SELECT语句,查询到的数据都是事务开始时那个状态的数据。

快照读就是在每一行数据中额外保存两个隐藏的列,分别是:插入这个数据行时的版本号/删除这个数据行时的版本号(事务的ID | 可能为空),滚动指针(指向undo log中用于事务回滚的日志记录)。

事务在对数据修改后,进行保存时,如果数据行的当前版本号与事务开始取得数据的版本号一致就保存成功,否则保存失败。

  • insert

插入一行数据时,将事务的ID作为数据行的创建版本号。

  • delete

执行删除操作时,会将原数据行的删除版本号设置为当前事务的ID,然后根据原数据行生成一条INSERT语句,写入undo log,用于事务执行失败时回滚。 delete操作实际上不会直接删除,而是将delete对象打上delete flag,标记为删除,最终的删除操作是purge线程完成的。 但是会将数据行的删除版本号设置为当前的事务的ID,这样后面的事务B即便查到这行数据由于事务B的ID > 删除版本号,也会忽略这条数据。

  • update

更新时可以简单的认为是先将旧数据删除,然后插入一条新数据。

所以执行更新操作时,其实是会将原数据行的删除版本号设置为当前事务的ID,生成一条INSERT语句,写入undo log,用于事务执行失败时回滚。 插入一条新的数据,将事务的ID作为数据行的的创建版本号。

  • select

首先读取数据的前提条件是:数据行的创建版本号<=当前事务版本号 (否则数据是后面的事务创建出来的)、数据行删除版本号为空或者>当前事务版本号的数据(否则数据已经被标记删除了)

如果该行数据没有被加行锁中的X锁(也就是没有其他事务对这行数据进行修改),那么直接读取数据。

如果该行数据被加了行锁X锁(也就是现在有其他事务对这行数据进行修改),那么读数据的事务不会进行等待,而是回去undo log端里面读之前版本的数据(这里存储的数据本身是用于回滚的), 在可重复读的隔离级别下,从undo log中读取的数据总是事务开始时的快照数据,在提交读的隔离级别下,从undo log中读取的总是最新的快照数据。

# 实时读

使用一致性读定锁进行查询时就是实时读,读(locking read)操作:

- SELECT...FOR UPDATE (X锁)
- SELECT...LOCK IN SHARE MODE (S锁)
1
2

使用 Next-Key LockGap Lock+Record Lock ,锁定一个范围,并且锁定记录本身)加锁,来解决幻读问题。


# MVCC 实现原理 🎉

MVCC 的实现依赖于:隐藏字段、Read View、undo log

  1. 通过事务ID(DB_TRX_ID)和 ReadView 来判断数据的可见性
  2. 如可见,则返回 ReadView 规则的版本数据;
  3. 如不可见,则通过回滚指针(DB_ROLL_PTR)找到 undo log 中的历史版本数据;

# 隐藏字段

InnoDB 存储引擎为每行数据添加了三个 隐藏字段:

  1. DB_TRX_ID(6字节):表示最后一次插入或更新该行的事务ID。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除
  2. DB_ROLL_PTR(7字节) 回滚指针,指向该行的 undo log 。如果该行未被更新,则为空
  3. DB_ROW_ID(6字节):如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该 id 来生成聚簇索引

# ReadView

Read view lists the trx ids of those transactions for which a consistent read should not see the modifications to the database.

ReadView是事务开启时,当前所有活跃事务(还未提交的事务)的一个集合,ReadView数据结构决定了不同事务隔离级别下,数据的可见性。

  1. ReadView的组成
    • m_ids:存了当前数据库系统中正处于活跃(没有 commit)的事务的ID列表
    • min_trx_id:m_ids里最小的值;
    • max_trx_id:mysql下一个要生成的事务id,就是最大事务id;
    • creator_trx_id:当前这个事务的id;
  2. 数据可见性:InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较
    • 当 DB_TRX_ID < min_trx_id 表示此版本是已经提交的事务生成的,数据可见
    • 当 DB_TRX_ID > max_trx_id 表示此版本是由将来启动的事务生成的,数据不可见
    • 当 DB_TRX_ID >= min_trx_id & DB_TRX_ID <= max_trx_id
      • 如果 DB_TRX_ID 在 m_ids 的数组中,数据不可见,但对当前自己的事务是可见的
      • 如果 DB_TRX_ID 不在 m_ids 的数组中,数据可见
  3. 生成的时机
    • RC:每个查询都单独构建ReadView
    • RR:事务开始后第一条select时生成一个ReadView,一直用到事务结束

# undo log 版本链

undo-log-link.png

  1. 假设有一个事务A(事务ID=50)插入一条数据,那么此时这条数据的隐藏字段:事务ID=50、回滚指针(DB_ROLL_PTR)指向的空的undo log;
  2. 当有另一个事务B(事务ID=51)更新这条数据,那么此时更新之前会生成一个 undo log 记录(事务A的值),这条数据的隐藏字段:事务ID=51、回滚指针指向刚生成的undo log;
  3. 当再有一个事务C更新这条数据,同步骤2,这样就形成了 undo log 版本链;

参考文档

Last Updated: 2 years ago