事务和锁
事务就是一组 SQL,这些 SQL 要么都被提交,要么都终止,就是为了保证数据的最终 一致性
。
事务的 ACID 特性
- 原子性(Atomicity):要么都被提交,要么都终止。
- 一致性(Consistency):就是为了保证按照定义的规则(约束、级联、触发器等)执行,不满足规则就不会继续执行(有可能会损坏数据库),然后根据规则执行的情况事务就会从一种状态转换为另一种状态;但是一致性不能保证事务执行结果是正确的,执行结果正不正确和
隔离性
有关系。 - 隔离性(Isolation):就是保证事务并发执行的时候不出问题,通过
隔离级别
控制隔离强弱。 - 持久性(Durability):一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的。
持久性是通过
重做日志
来保证,原子性是通过回滚日志
来保证,隔离性是通过锁实现。
事务的状态
- 活动的(active):事务正在执行 SQL。
- 部分提交的(partially committed):事务中的 SQL 已经执行完了,准备将数据写到硬盘上。
- 提交的(committed):数据成功写到硬盘上了。
- 失败的(failed):在事务执行时候出现了某些错误或人为的停止了事务。
- 中止的(aborted):当回滚操作执行完毕(数据库恢复到了执行事务之前的状态)。

显示事务与隐式事务
通过 SHOW VARIABLES LIKE 'autocommit';
查看数据库是不是开启了隐式使用,如果开启了隐式事务那么,在没有使用 BEGIN;
语句的时候会将每一条 DML 语句都包装为单独的事务,执行完成后自动提交;而显示事务就是通过代码手动开启一个事务,下面是一个演示:
begin;
insert into dbtest values(7);
rollback; # 或者使用 commit 提交
当然还可以使用 SAVEPOINT <保存点名字>
关键字声明一个保存点,当出现异常后通过 ROLLBACK TO <保存点名字>
回滚到某个保存点,通过保存点避免多次执行相同语句。
事务的常见分类
- 扁平事务:就是上面的那个例子,常用的一种事务。
- 带有保存点的扁平事务
- 链事务:需要设置
@@completion_type
值:- 0:当执行 COMMIT 后,再执行下一个事务时,还需要使用
BEGIN;
来开启。 - 1:当执行 COMMIT 时,相当于执行了
COMMIT AND CHAIN
,也就是开启一个链事务。 - 2:当执行 COMMIT 时,相当于执行了
COMMIT AND RELEASE
,会自动断开和服务器的连接。
- 0:当执行 COMMIT 后,再执行下一个事务时,还需要使用
- 嵌套事务:事务中可以嵌套子事务,子事务中还可以继续嵌套事务。
- 分布式事务:分布式环境下运行扁平事务。
并发问题与四种隔离级别
在并发情况下读写相同数据(同一条数据或同一张表)的时候,会引发三个问题:
脏读(Dirty Read)
一个事务读到了另一个事务的未提交数据。
事务 B 更改 Alice’s balance 为 700 还没有提交就被事务 A 读取到了,因为还没有提交所以有可能这个数据会被回滚。
不可重复读(nonrepeatable read)
在同一个事务中使用了多次相同的查询,但是每次查询的查询结果不一样,这就是不可重复读。
这是因为其它事务已经 修改
了这个值,在获取这个值就是其它事务修改后的值。
幻读(Phantom Read)
幻读和不可重复读一样,都是在一个事务中使用相同查询查询多次,区别就是幻读每次查询出的数量不一样。
这三个的区别就是另一个事务有没有提交,没有提交出现不可重复读和幻读都归于脏读。
四种隔离级别
隔离级别的出现是为了解决事务在并发时出现的上面三个问题,在 MySQL 中支持 4 种隔离级别:
读未提交(READ-UNCOMMITTED)
事务 A 没有提交,事务 B 也能读取到事务 A 修改的数据;这个隔离级别一半不用,虽然是高并发但是没有解决任何一个并发问题。

读已提交(READ-COMMITTED)
解决了脏读,只保证事务 A 没有提交,事务 B 无法读取到事务 A 修改的数据。

可重复读(REPEATABLE-READ)
这是 Innodb 的默认事务隔离级别,解决了脏读的问题,保证在同一个事务中多次读取同样数据结果是一致的。

串行化(SERIALIZABLE)
最高的隔离级别,让事务强制串行执行,避免了前面说的幻读。

下面提供一张表,更直观一些:
通过
SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '<隔离级别>'
设置隔离级别。还有就是,这四个隔离级别决定了数据库的并发能力。
InnoDB 锁
脏读、不可重复读、幻读就是通过 MVCC 和锁解决的,只是不同的隔离级别有不同的解决方案:
-
读使用 MVCC,写使用锁
- READ-COMMITTED:在一个事务中,每执行一次普通的 SELECT 操作时,都会产生一个新的 ReadView。
- REPEATABLE-READ:只会在第一次执行普通的 SELECT 操作时,产生一个 ReadView,之后的普通 SELECT 操作都会复用这个 ReadView。
-
读、写操作都采用加锁方式,这种方式其实就是 SERIALIZABLE 隔离级别,会影响性能。
MVCC(多版本控制器)就是使用 回滚日志 生成 ReadView,只不过 ReadView 中记录的是事务以提交的数据,事务没有提交的数据或者在 ReadView 生成之后提交的数据都不会被记录。
ReadView 可以认为是一个历史版本,写操作完成后数据就是一个最新版本,所以读写操作之间并不冲突,性能更高。
共享锁和排它锁
InnoDB 中行级锁分为两种:
- 共享锁(S):多个事务可以同时读取同一条数据。
- 排它锁(X):当前事务写锁没有释放,其它事务不能获取读锁或写锁。
行锁类型 | 排它锁 | 共享锁 |
---|---|---|
排它锁 | 冲突 | 冲突 |
共享锁 | 冲突 | 兼容 |
一个事务获取了共享锁,另一个事务想要获取排它锁也需要进行等待;可以直接立即为读写锁。
意向锁
意向锁就是快速判断表锁与行锁是不是冲突,假设事务 A 锁住了一行数据(不管是共享锁还是排它锁),事务 B 想要获取表的排它锁来修改这张表,但是在获取表排他锁的时候需要判断是不是已经使用了行锁,所以就可以使用意向锁进行快速判断;可以将意向锁理解为表中的一个标志位。
意向锁是表级锁,SELECT 会查询出来多行数据,意向锁就是指定事务对这些数据使用哪种类型的锁:
- 意向共享锁(IS):事务必须先获取到该表的意向共享锁,在给数据行加行级共享锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
- 意向排它锁(IX):事务必须先获取到该表的意向排它锁,在给数据行加行级排它锁。
SELECT column FROM table ... FOR UPDATE;
表锁类型 | 排它锁 | 意向排它锁 | 共享锁 | 意向共享锁 |
---|---|---|---|---|
排它锁 | 冲突 | 冲突 | 冲突 | 冲突 |
意向排它锁 | 冲突 | 兼容 | 冲突 | 兼容 |
共享锁 | 冲突 | 冲突 | 兼容 | 兼容 |
意向共享锁 | 冲突 | 兼容 | 兼容 | 兼容 |
意思就是说:如果表级别是共享锁,想添加行级排它锁/表级意向排它锁是不允许的,必须等待表级共享锁释放。
就是将查询出来的数据加上某种行级锁的时候会自动向表上添加对应的意向锁。
记录锁
记录锁锁的是一条索引记录(效果和行级排他锁一样),即使定义的表没有索引,InnoDB 创建一个隐士的聚集索引。
SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;
间隙锁
间隙锁锁定的就是一段区间,假设有下面这些数据:
下图是获取间隙锁:
因为 id = 5 的数据在表中不存在,所以会变为间隙锁,锁住的范围为(3,8)之间也就是说会锁住 4、5、6、7,这段区间不能插入数据,只要向这段区间插入数据就会等待,直到持有间隙锁的事务提交;你可以理解为只要查询查询不出来数据的就是间隙锁,查询出来的就是行锁。
如果 id > 20 锁的区间就是从 21 到无穷大,id < 1 也是无穷大。
区间、区间、区间,3 到 8 的这个区间是不能存在数据的,否则就不是间隙锁了,而是意向锁。
临键锁(Next-Key)
就是间隙锁+记录锁,间隙锁锁住的范围为(3,8)之间也就是说会锁住 4、5、6、7,Next-Key 锁是为了同时锁住 3、8。
下面演示了一个包含 15(使用记录锁) 不包含 8 的情况:
innodb 默认是有的就是这个锁。
插入意向锁
间隙锁锁住的范围为(3,8)之间也就是说会锁住 4、5、6、7,当你使用 insert 想在这个区间插入数据的时候(假设插入 4),那么这个 insert 语句就会自动添加上插入意向锁;临键锁也是一样的。
插入意向锁和意向锁完全没有关系。
自增锁
在某列上添加了 AUTO_INCREMENT
属性,当执行 insert 的时候会这个字段上添加一个自增锁。
死锁
行级锁有可能出现死锁,但是表锁肯定不会出现死锁。
出现死锁后可以通过 show engine innodb status;
查看死锁原因,MySQL 有两种死锁策略:
- 进入等待状态等待超时,这个超时参数可以通过
innodb_lock_wait_timeout = 50
来设置。 - 开启死锁监测,发现死锁后,主动回滚死锁链条中的第一个事务,通过
innodb_deadlock_detect = on
开启。
排他锁引发死锁
这个例子是一个最简单的死锁问题:
间隙锁/临键锁引发死锁
不同于写锁相互之间是互斥的原则,间隙锁之间不是互斥的,如果一个事务 A 获取到了(3,8)之间的间隙锁,另一个事务 B 也可以获取到(3,8)之间的间隙锁,这时就可能会发生 死锁
问题。
避免死锁
1. 并发情况下减少 delete-insert事务操作
可以回避这种在事务中,delete-insert 多线程操作的问题,例如我们可以先查数据是否存在,不存在不执行 delete 操作,避免不存在执行 delete 操作,触发 mysql 的行锁+间隙锁机制。如果存在我们 delete,只会用到 mysql 的行锁。这就一定程度上避免了锁竞争无法释放的问题。但是这样操作也会存在一定的风险,是否可以软删除,避免高并发情况下,出现数据已经被删除,而其他事物正在删除不存在的数据问题。
2. 单进程下可考虑在事务上加锁
sessionA和sessionB两个事务,在竞争的情况下,删除了不存在的记录,会触发mysql的行锁+间隙锁。主要出发点在于,与其在mysql竞争间隙锁的过程中报错,然后事务回滚,资源大量浪费,不如在进入事务之前进行并发控制。虽然锁的粒度有点粗,但是相对于事务一直回滚,服务端不停打印错误日志,是更能接受的。
3. 多进程高可用的情况
对于高可用多进程情况,可以通过分布式锁解决。如果不想借助非mysql的外部锁结局,那么也可以考虑对 delete-insert 事务进行排序,加入有序队列中,挨个消化。这实质上也是变相做了同步操作。
事务的日志
InnoDB 中有两种日志:
-
redo log(重做日志):保证 MySQL 实例意外停止并重启后
恢复数据
保证数据的持久性
。 -
undo log(回滚日志):用于
回滚事务
保证事务的原子性
和MVCC
。
重做日志
只有将数据写到硬盘中才能保证数据的 持久性
,但是又不能频繁的去做 IO 操作
这样会降低性能,所以会先修改缓冲池1中的数据,再将操作2放到重做日志中,等到了刷盘时间才会将重做日志中的操作同步到硬盘中。
- InnoDB 中数据是以
页
为单位来管理的,当做增删改查的时候,会从硬盘中直接读取一页数据放到缓冲池中,再从缓冲池中查找数据,如果没有找到再读取下一页数据。 - 这里的操作指的是增删改,会将增删改转换成指定数据结构,然后保存到重做日志中(保存了对数据库的所有更改)。

重做日志的好处:
- 降低了硬盘刷新频率
- 日志占用空间非常小(将增删改转换为特定结构)
- 按顺序写入硬盘
- 事务执行过程中,不断记录
重做日志的通用数据结构:
- type:日志的类型
- space ID:表空间 ID
- page number:页号
- data:具体内容
刷盘策略
InnoDB 存储引擎为 redo log 的刷盘策略提供了 innodb_flush_log_at_trx_commi
参数,它支持三种策略:
- 0:设置为 0 的时候,表示每次事务提交时不进行刷盘操作
- 1:设置为 1 的时候,表示每次事务提交时都将进行刷盘操作(默认值)
- 2:设置为 2 的时候,表示每次事务提交时都只把 redo log buffer 内容写入 page cache
回滚日志
回滚日志里面记录的是每个事务关联的回滚记录,回滚记录是在执行增删改前保存到回滚日志中的:
- 插入一条记录:回滚日志中会记录一条删除记录
- 删除一条记录:回滚日志中会记录一条插入记录
- 修改一条记录:回滚日志中会记录一条修改为原值的记录
回滚记录就是记录,和真实的操作相反的操作,这样当事务进行回滚的时候直接执行这些相反的操作;当事务 COMMIT 时候,会将事务中的操作都保存到重做日志中。
只有调用
ROLLBACK;
才会通过回滚日志回滚事务;当 MySQL 实例意外停止并重启后,不会通过回滚日志回滚事务,因为在事务中的操作只是一个临时操作,只有COMMIT;
的时候才是一个真正的操作。
回滚日志的删除
- insert:不需要进行 purge 操作,会直接删除回滚日志
- update:需要进行 purge 操作,需要等待 purge 线程最后删除
参考资料
Transaction Isolation Levels In MySql (InnoDB)