# 事务
# 概述
数据库事务是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。
一般来说,数据库事务具有ACID这4个特性:
- 原子性(Atomicity): 一个事务(
transaction
)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback
)到事务开始前的状态,就像这个事务从来没有执行过一样。 - 一致性(Consistency): 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。
- 隔离性(Isolation): 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(
Read uncommitted
)、读提交(read committed
)、可重复读(repeatable read
)和串行化(Serializable
)。 - 持久性(Durability): 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
数据库事务是由数据库系统保证的,我们只需要根据业务逻辑使用它就可以了。在MySQL
中只有使用了Innodb
数据库引擎的数据库表才支持事务。
# 事务的实现
事务隔离性是锁来实现的, redo log
(重做日志)用来保证事务的原子性和持久性,undo log
用来保证事务的一致性。
或许有人认为redo
是undo
的逆过程,其实不对的。redo
和undo
的作用都可以看做是一种恢复操作,redo
恢复提交事务修改的页,而undo
回滚行记录。因此两者记录的内容不同,redo
通常是物理日志,记录的是页的物理修改操作,undo
是逻辑日志,根据每行记录进行记录。
# Redo Log
# 1. 基本概念
redo log
包括两部分:一是内存中的日志缓冲(redo log buffer
),其易失性的;二是磁盘上的重做日志文件(redo log file
),其是持久的。
InnoDB
是事务的存储引擎,在概念上,其通过Force Log at Commit机制实现事务的持久性,即在事务提交(COMMIT)时,必须先将该事务的所有事务日志写入到磁盘上的redo log file
和undo log file
中进行持久化,待事务的COMMIT
操作完成才算完成。
为了确保每次日志都能写入到事务日志文件中,在每次将日志缓冲(redo log buffer
)写入重做日志文件(redo log file
)之后,InnoDB
存储引擎都需要调用一次fsync
操作(即fsync()系统调用)。
之所以要经过一层os buffer
,是因为open
日志文件的时候,open
没有使用O_DIRECT标志位,该标志位意味着绕过操作系统层的os buffer
,IO
直写到底层存储设备。
不使用该标志位意味着将日志进行缓冲,缓冲到了一定容量或者显式调用fsync()
才会将缓冲中的日志刷到存储设备。如果使用该标志位,则意味着每次都要发起系统调用。
InnoDB
存储引擎允许用户手工设置非持久性的情况发生,以此提高数据库的性能。既当事务提交时,日志不写入事务日志文件,而是等待一个时间周期后再执行fsync
操作。
由于并非强制在事务提交时进行一次fsync
操作,显然这可以显著提高数据库的性能,但当数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的事务。
参数innodb_flush_log_at_trx_commit
用来控制重做日志刷新到磁盘的策略,该参数的默认值为1。可以通过命令查看此参数的设置:
select @@innodb_flush_log_at_trx_commit;
其中参数值含义如下:
- 1 表示事务提交时必须调用一次
fsync
操作; - 0 表示事务提交时不进行写入
redo
日志操作,这个操作仅在master thread
中完成,而在master thread
中每1秒
会进行一次redo
日志文件的fsync
操作; - 2 表示事务提交时把
redo
日志写入磁盘文件对应的文件系统的缓存中,不进行fsync
操作;
# 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 总结 🎉
- 是 Innodb 存储引擎层生成的日志,实现了事务的持久性
- 将写操作从磁盘的「随机写」变成了「顺序写」,提升
MySQL
写入磁盘的性能。 - WAL (Write-Ahead Logging)技术,指的是 MySQL 的写操作并不是立刻更新到磁盘上,而是先记录在日志(Redo Log)上,然后在合适的时间再更新到磁盘上。
# Undo Log
# 1. 基本概念
undo log
有两个作用:提供回滚
和MVCC
(多版本并发控制)。
对数据库进行修改时,InnoDB
存储引擎不但会产生redo
,还会产生相对应的undo
,如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以借助该undo
进行回滚。
undo log
存放在数据库内部的一个特殊段(segment
)中,这个段称为undo log segment
。
undo log
和redo log
记录物理日志不一样,它是逻辑日志。InnoDB
存储引擎回滚时,它实际上做的是与先前相反的工作。对于每个INSERT
,InnoDB
存储引擎会完成一个DELETE
;
对于每个DELETE
,InnoDB
存储引擎会执行一个INSERT
;对于每个UPDATE
,InnoDB
存储引擎会执行一个相反的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(回滚日志)总结 🎉
- 是 Innodb 存储引擎层生成的日志,实现了事务中的原子性,主要用于事务回滚和MVCC;
- 一个事务在执行过程中,在还没有提交事务之前,如果MySQL 发生了崩溃,可以通过这个日志(undo log)回滚到事务之前的数据;
- MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 ReadView 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。
redo log 和 undo log 区别在哪?
- redo log 记录了此次事务**「完成后」的数据状态,记录的是更新之「后」**的值;
- undo log 记录了此次事务**「开始前」的数据状态,记录的是更新之「前」**的值;
# purge
purge
用于最终完成delete
和update
操作。这样设计是因为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;
2
3
很多公司把MySQL Innodb存储引擎
的隔离级别设置成READ-COMMITTED
,是为了防止频繁的出现死锁,所以我们平时在写代码的时候需要注意。
延伸思考
我们知道,MySQL Innodb
存储引擎默认的事务隔离级别是RR
,是会出现幻读的,那么它是如何避免幻读的呢?
# MySQL是怎么解决幻读问题的?
- 快照读: 通过
MVCC
(并发多版本控制)来解决幻读问题- MVCC 的实现依赖于:
隐藏字段
、Read View
、undo log
- 隐藏字段:事务ID、回滚指针、DB_ROW_ID(如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该 id 来生成聚簇索引)
- MVCC 的实现依赖于:
- 实时读(执行
select...for update/lock in share mode
、insert
、update
、delete
): 通过采用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锁)
2
使用 Next-Key Lock(Gap Lock
+Record Lock
,锁定一个范围,并且锁定记录本身)加锁,来解决幻读问题。
# MVCC 实现原理 🎉
MVCC 的实现依赖于:隐藏字段、Read View、undo log
- 通过事务ID(DB_TRX_ID)和 ReadView 来判断数据的可见性;
- 如可见,则返回 ReadView 规则的版本数据;
- 如不可见,则通过回滚指针(DB_ROLL_PTR)找到 undo log 中的历史版本数据;
# 隐藏字段
InnoDB 存储引擎为每行数据添加了三个 隐藏字段:
- DB_TRX_ID(6字节):表示最后一次插入或更新该行的事务ID。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除
- DB_ROLL_PTR(7字节) 回滚指针,指向该行的 undo log 。如果该行未被更新,则为空
- 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数据结构决定了不同事务隔离级别下,数据的可见性。
- ReadView的组成
- m_ids:存了当前数据库系统中正处于活跃(没有 commit)的事务的ID列表
- min_trx_id:m_ids里最小的值;
- max_trx_id:mysql下一个要生成的事务id,就是最大事务id;
- creator_trx_id:当前这个事务的id;
- 数据可见性: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 的数组中,数据可见
- 生成的时机
- RC:每个查询都单独构建ReadView
- RR:事务开始后第一条select时生成一个ReadView,一直用到事务结束
# undo log 版本链
- 假设有一个事务A(事务ID=50)插入一条数据,那么此时这条数据的隐藏字段:事务ID=50、回滚指针(DB_ROLL_PTR)指向的空的undo log;
- 当有另一个事务B(事务ID=51)更新这条数据,那么此时更新之前会生成一个 undo log 记录(事务A的值),这条数据的隐藏字段:事务ID=51、回滚指针指向刚生成的undo log;
- 当再有一个事务C更新这条数据,同步骤2,这样就形成了 undo log 版本链;
参考文档
- 《MySQL技术内幕 InnoDB存储引擎 第2版》
- MySQL 日志:undo log、redo log、binlog (opens new window)
← 锁 MySQL 高性能优化规范 →