MySQL事务

182 阅读11分钟

事务(Transaction)是一个操作序列,这些操作要么都做,要么都不做,是一个不可分割的工作单位。

START TRANSACTION;
INSERT INTO users (id, name) values (1, "王小明");
INSERT INTO users (id, name) values (2, "李小狼");
COMMIT;

事务以BEGIN TRANSACTION开始,以COMMIT或者ROLLBACK结束,COMMIT表示提交,将未存储的SQL语句的结果写入数据库,ROLLBACK表示回滚,撤销SQL语句的执行。

在Go代码中可以用gorm来使用事务

func (dao *UserDAO) InsertUsers(c context.Context) {
	db := dao.db
	db.Transaction(func(tx *gorm.DB) error {
		if err := tx.Create(&User{ID: 1, Name: "王小明"}).Error; err != nil {
			// 返回任何错误都会回滚事务
			return err
		}
		if err := tx.Create(&User{ID: 2, Name: "李小狼"}).Error; err != nil {
			return err
		}
		// 返回nil提交事务
		return nil
	})
}

事务有4个特性,一般称为ACID特性

  • 原子性(Atomicity)

    事务在逻辑上是不可分割的操作单元,所有语句要么都执行,要么都不执行。

  • 一致性(Consistency)

    一个事务就是一系列在逻辑上相关的操作的指令的集合,用于完成一项任务,其本质是将数据库中的数据从一种一致性状态转换到另一种一致性状态,以体现现实世界中的状态变化。

  • 隔离性(Isolation)

    隔离性是针对并发事务而言的,并发执行的各个事务之间不能互相干扰(并发是指数据库服务器同时处理多个事务)。如果不采取专门的控制机制,那么并发事务之间可能会相互干扰,进而导致数据出现不一致或错误的状态。

  • 持久性(Durability)

    持久性是指一旦事务提交成功,其对数据的修改是持久性的。数据更新的结果已经从内存转存到了外部存储器上,此后即使发生了系统故障,已提交事务所做的数据更新也不会丢失。

MySQL的日志有很多种,比如二进制日志、错误日志、查询日志、慢查询日志等,此外InnoDB还提供了两种事务日志:redo log(重做日志)和 undo log(回滚日志)。其中redo log用于保证事务持久性;undo log是事务原子性和隔离性实现的基础。下文会简单地说一下回滚日志和重做日志大致是怎样来保证这些事务的特性的,注意这里是大致说明,日志背后的数据结构和实现包括刷盘方式等,我目前还没有进行深入了解。

原子性

原子性指事务在逻辑上是不可分割的操作单元,所有语句要么都执行,要么都不执行。

当事务中的SQL执行失败,或者需要回滚的时候,使用undo log(回滚日志),当事务对数据库进行修改时,InnoDB会生成对应的undo log,如果任务执行失败或者调用了ROLLBACK,可以利用undo log中的信息将数据回滚到修改之前的样子。

undo log日志中存储的是逻辑日志,包含了操作类型(insert或update)和数据信息等。根据undo log中的数据进行操作时,执行的是相反的操作,比如insert对应的是delete,delete对应的是insert,update对应的是一个相反的update。

执行下面的事务时:

START TRANSACTION;
-- 语句1
INSERT INTO users (id, name) values (1, "王小明");
-- 语句2
UPDATE users SET name="李小狼" WHERE id=1;
-- 语句3
SELECT * FROM users;
COMMIT;

执行语句1后,生成了一条undo log 1,这个log记录了语句1执行的操作(insert)。

执行语句2后,生成了一条undo log 2,这个log记录了语句2执行的操作(update),以及操作执行之前的数据("王小明")。

语句3是查询操作,没有undo log。

假如语句1和语句2都执行成功了,此时数据库中的数据是:

+----+-----------+
| id | name      |
+----+-----------+
|  1 | 李小狼     |
+----+-----------+

当执行语句3出现异常后,通过undo log日志对事务进行回滚时,可以大致理解为,执行以下语句回滚语句2:

UPDATE users SET name="王小明" WHERE id=1;

执行以下语句回滚语句1:

DELETE FROM users WHERE id=1;

这样数据库中的数据就回滚到了事务执行之前。保证了事务的原子性,要么都执行,要么都不执行。

持久性

持久性是指一旦事务提交成功,其对数据的修改是持久性的。 数据更新的结果已经从内存转存到了外部存储器上,此后即使发生了系统故障,已提交事务所做的数据更新也不会丢失。

为了减少磁盘IO,MySQL的InnoDB存储引擎提供了缓存池(Buffer Pool):

  • 当从数据库读取数据时,首先从Buffer Pool读取,如果Buffer Pool中没有,就从磁盘读取后放入Buffer Pool;

  • 当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中(这一过程称为脏刷)。

如果在Buffer Pool中修改的数据还没写到磁盘时,MySQL服务宕机了,就会造成数据丢失,事务的持久性无法保证:事务提交成功了,但是对数据的修改丢失了。所以需要用redo log来保证持久性。

redo log存储的是物理日志,是基于磁盘的数据页的,和undo log存储的逻辑日志不同,简单来说逻辑日志会存储变化的过程,而物理日志只存储最终的结果。

执行下面的事务时:

START TRANSACTION;
-- 语句1
INSERT INTO users (id, name) values (1, "王小明");
-- 语句2
UPDATE users SET name="李小狼" WHERE id=1;
-- 语句3
SELECT * FROM users;
COMMIT;

假如这个事务提交成功,但是在Buffer Pool的数据还没有写到磁盘的时候,MySQL服务宕机了,当服务恢复之后,根据redo log日志的内容,磁盘中的数据应该是李小狼,所以会将磁盘中的数据存储为李小狼。

大致可以理解为执行以下语句重做事务:

INSERT INTO users (id, name) values (1, "李小狼");

这样保证了在事务提交成功和数据成功写入磁盘之间出现异常时,磁盘中仍然能保存下对数据的修改,保证了事务的持久性。

隔离性

隔离性是针对并发事务而言的,并发执行的各个事务之间不能互相干扰(并发是指数据库服务器同时处理多个事务)。如果不采取专门的控制机制,那么并发事务之间可能会相互干扰,进而导致数据出现不一致或错误的状态。

隔离性可以分为两个方面:

  • (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性
  • (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性

锁机制

锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,行锁只锁定需要操作的行,间隙锁锁定行与行之间的某个间隙。

共享锁(Shared Lock),又称为读锁或S锁,它允许多个事务同时获取锁并读取同一份数据;排他锁(Exclusive Lock),又称为写锁,独占锁或者X锁,它只允许一个事务获取并持有该锁。

当事务想要获取一张表中的某几行的行级共享锁(S锁)时,MySQL会先自动获取该表的意向共享锁(IS锁);当事务想要获取一张表中某几行的行级排他锁(X锁)时,MySQL会自动获取该表的意向排他锁(IX锁)。

执行以下两个事务,但是两个事务都不提交,相当于两个事务都在执行中:

START TRANSACTION;
UPDATE users SET name = "王小明-1" WHERE id = 1;
-- COMMIT;

START TRANSACTION;
UPDATE users SET name = "李小狼-1" WHERE id = 2;
-- COMMIT;

然后执行以下语句查看加锁的情况:

SELECT * FROM performance_schema.data_locks;

image-20231120163355230.png

图中的内容说明,加上了表级别的意向排他锁(因为需要对表中对两行数据加上排他锁),加上了行级别的X锁,锁覆盖的范围是2行数据。

LOCK_TYPE:TABLE表示是表级锁,RECORD表示是行级锁。

LOCK_MODE:IS、IX、S、X、S,GAP、X,GAP、S,REC_NOT_GAP、X,REC_NOT_GAP

LOCK_STATUS: GRANTED表示已添加,WAITING表示等待中。

LOCK_DATA:锁覆盖的范围。

当在事务中修改某行数据A时,是会对要修改的行进行加锁处理的,其他事务无法在事务执行期间读写数据A,保证了事务间对数据A的处理不会相互干扰。

MVCC

并发情况下,读操作可能存在脏读、不可重复读和幻读的问题。可以通过MVCC(Multi-Version Concurrency Control, 多版本并发控制)机制解决这些问题。

一、脏读(Dirty Read)

一个事务读取了已被另一个事务修改,但尚未提交的数据(脏数据)。

MySQL事务.drawio.svg

二、不可重复读(Nonrepeate Read)

在同一个事务中,同一个查询在时刻1读取某一行,在时刻2重读取这一行数据的时候,发现这一行的数据已经发生修改,可能被更新了,也可能被删除了。

MySQL事务-第 2 页.drawio.svg

三、幻读(Phantom Read)

在同一事务中,当同一查询多次执行的时候,由于其他插入操作的事务提交,会导致每次返回不同的结果集

MySQL事务-第 3 页.drawio.svg SQL标准定义了四个隔离级别:

隔离级别描述脏读不可重复读幻读
Read Uncommitted 读未提交所有事务都可以看到其他未提交事务的执行结果,事务中的修改,即使没有提交,对其他事务也是可见的。可能可能可能
Read Committed 读已提交一个事务从开始直到提交之前,所做的任何修改对其他事务都是不可见的。不可能可能可能
Repeatable Read 可重复读同一个事务,在多次读取同一行数据的时候,得到的是同样的结果。不可能不可能可能
Serializable 可串行化强制事务排序,强制事务串行执行,使之不可能相互冲突。不可能不可能不可能

InnoDB存储引擎默认的隔离级别是可重复读。

-- 查询当前会话的事务隔离级别
SELECT @@transaction_isolation;

-- 查询系统的事务隔离级别
SELECT @@global.transaction_isolation;
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
1 row in set (0.00 sec)

mysql> SELECT @@global.transaction_isolation;
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| REPEATABLE-READ                |
+--------------------------------+
1 row in set (0.00 sec)

接下来看一下MVCC是如何解决脏读、不可重复读、幻读的问题的。MVCC多版本并发控制,能够实现在同一时刻,不同事务中读取到的数据是不同的(即多版本)。

MVCC通过隐藏列和undo log实现ReadView,通过ReadView来判断不同事务要使用哪个版本的数据。

一、解决脏读问题

事务A在查询余额前会生成ReadView,事务B此时还未提交,对数据的修改对ReadView不可见,所以事务A中读取到的是修改前的数据。

MySQL事务-第 4 页.drawio.svg

二、解决不可重复读问题

事务A在第1次查询余额前会生成ReadView,事务B的修改对ReadView是不可见的,再次查询时通过判断事务B已经提交的修改对事务A仍然是不可见的,就通过undo log查询修改前的版本的数据。

MySQL事务-第 5 页.drawio.svg

二、解决幻读问题

解决幻读的问题和解决不可重复读的问题类似。

MySQL事务-第 6 页.drawio.svg

使用MVCC避免脏读、不可重复读、幻读,保证了隔离性,MVCC中使用的是非加锁读,也称作快照读。

另外还可以使用加锁读:

START TRANSACTION;
-- 共享锁读取
SELECT * FROM users WHERE id=1 LOCK IN SHARE MODE;

-- 排他锁读取
SELECT * FROM users WHERE id=1 FOR UPDATE;

UPDATE users SET name="王小明" WHERE id=1;
COMMIT;

SELECT ... LOCK IN SHARE MODE;用于在查询数据时获取共享锁,防止其他事务对数据进行写操作,但允许事务对数据进行读操作。

SELECT ... FOR UPDATE;用于在查询数据时获取排他锁,防止其他事务对数据进行读写操作。

一致性

一个事务就是一系列在逻辑上相关的操作的指令的集合,用于完成一项任务,其本质是将数据库中的数据从一种一致性状态转换到另一种一致性状态,以体现现实世界中的状态变化。

一致性是事务追求的最终目标。

比如商场中一共有100件商品,总售价5000元,这就是一个一致性状态,在售卖或者用户退款退货的过程中,无论处于什么状态,商场能卖出的商品总计一定是100件,不能多卖也不能少卖,能卖出的总售价是5000元,不能多也不能少。

学习地址

深入学习MySQL事务:ACID特性的实现原理:www.cnblogs.com/kismetv/p/1…