MySQL InnoDB 如何保证事务特性

本贴最后更新于 1796 天前,其中的信息可能已经渤澥桑田

如果有人问你“数据库事务有哪些特性”?你可能会很快回答出原子性、一致性、隔离性、持久性即 ACID 特性。那么你知道 InnoDB 如何保证这些事务特性的吗?如果知道的话这篇文章就可以直接跳过不看啦(#^.^#)

先说结论:

  • redo log 重做日志用来保证事务的持久性
  • undo log 回滚日志保证事务的原子性
  • undo log+redo log 保证事务的一致性
  • 锁(共享、排他)用来保证事务的隔离性

重做日志 redo log

重做日志 redo log 分为两部分:一部分是内存中的重做日志缓冲(redo log buffer),是易丢失的;二部分是重做日志文件(redo log file),是持久的。InnoDB 通过 Force Log at Commit 机制来实现持久性,当 commit 时,必须先将事务的所有日志写到重做日志文件进行持久化,待 commit 操作完成才算完成。
InnoDB 在下面情况下会将重做日志缓冲的内容写入重做日志文件:

  • master thread 每一秒将重做日志缓冲刷新到重做日志文件;
  • 每个事务提交时
  • 当重做日志缓冲池剩余空间小于 1/2 时

为了确保每次日志都写入重做日志文件,在每次将日志缓冲写入重做日志文件后,InnoDB 存储引擎都需要调用一次 fsync(刷盘)操作。但这也不是绝对的。用户可以通过修改 innodb_flush_log_at_trx_commoit 参数来控制重做日志刷新到磁盘的策略,这个可以作为大量事务提交时的优化点。

  • 1 参数默认值,表示事务提交时必须调用一次 fsync 操作。
  • 0 表示事务提交时,重做日志缓存并不立即写入重做日志文件,而是随着 Master Thread 的间隔进行 fsync 操作。
  • 2 表示事务提交时将重做日志写入重做日志文件,但仅写入文件系统的缓存中,不进行 fsync 操作。
    fsync 的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。所以如果有人问你如何优化 Mysql 数据库的时候别忘了有硬件这一条,让他们提升硬盘配置,换 SSD 固态硬盘
    重做日志都是以 512 字节进行存储的,称之为重做日志块,与磁盘扇区大小一致,这意味着重做日志的写入可以保证原子性,不需要 doublewrite 技术。它有以下 3 个特性:
  • 重做日志是在 InnoDB 层产生的
  • 重做日志是物理格式日志,记录的是对每个页的修改
  • 重做日志在事务进行中不断被写入,而且是顺序写入

回滚日志 undo log

为了满足事务的原子性,在操作任何数据之前,首先将数据备份到一个地方(这个存储数据备份的地方称为 Undo Log),然后进行数据的修改。如果出现了错误或者用户执行了 ROLLBACK 语句,系统可以利用 Undo Log 中的备份将数据恢复到事务开始之前的状态。
undo log 实现多版本并发控制(MVCC)来辅助保证事务的隔离性。

回滚日志不同于重做日志,它是逻辑日志,对数据库的修改都逻辑的取消了。当事务回滚时,它实际上做的是与先前相反的工作。对于每个 INSERT,InnoDB 存储引擎都会完成一个 DELETE;对于每个 UPDATE,InnoDB 存储引擎都会执行一个相反的 UPDATE。

事务提交后并不能马上删除 undo log,这是因为可能还有其他事务需要通过 undo log 来得到行记录之前的版本。故事务提交时将 undo log 放入一个链表中,是否可以删除 undo log 根据操作不同分以下 2 种情况:

  • Insert undo log: insert 操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作。
  • update undo log:记录的是对 delete 和 update 操作产生的 undo log。该 undo log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge 线程进行最后的删除。

事务的隔离性的实现原理就是锁,因而隔离性也可以称为并发控制、锁等。事务的隔离性要求每个读写事务的对象对其他事务的操作对象能互相分离。再者,比如操作缓冲池中的 LRU 列表,删除,添加、移动 LRU 列表中的元素,为了保证一致性那么就要锁的介入。

锁的类型

InnoDB 主要有 2 种锁:行级锁,意向锁

行级锁:

  • 共享锁(读锁 S),允许事务读一行数据。事务拿到某一行记录的共享 S 锁,才可以读取这一行,并阻止别的事务对其添加 X 锁。共享锁的目的是提高读读并发。
  • 排它锁(写锁 X),允许事务删除一行数据或者更新一行数据。事务拿到某一行记录的排它 X 锁,才可以修改或者删除这一行。排他锁的目的是为了保证数据的一致性。

行级锁中,除了 S 和 S 兼容,其他都不兼容。

意向锁:

  • 意向共享锁(读锁 IS ),事务想要获取一张表的几行数据的共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
  • 意向排他锁(写锁 IX),事务想要获取一张表中几行数据的排它锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。
    解释一下意向锁
The main purpose of IX and IS locks is to show that someone is locking a row, or going to lock a row in the table.

意向锁的主要用途是为了表达某个事务正在锁定一行或者将要锁定一行数据。e.g:事务 A 要对一行记录 r 进行上 X 锁,那么 InnoDB 会先申请表的 IX 锁,再锁定记录 r 的 X 锁。在事务 A 完成之前,事务 B 想要来个全表操作,此时直接在表级别的 IX 就告诉事务 B 需要等待而不需要在表上判断每一行是否有锁。意向排它锁存在的价值在于节约 InnoDB 对于锁的定位和处理性能。另外注意了,除了全表扫描以外意向锁都不会阻塞。

锁的算法

InnoDB 有三种行锁的算法:

  • Record Lock:单个行记录上的锁
  • Gap Lock:间隙锁,锁定一个范围,而非记录本身
  • Next-Key Lock:结合 Gap Lock 和 Record Lock,锁定一个范围,并且锁定记录本身。主要解决的问题是 REPEATABLE READ 隔离级别下的幻读。可以参考文章了解事务隔离级别的相关知识点。

这里主要讲一下 Next-Key Lock,利用 Next-key Lock 锁定的不是单个值而是一个范围,他的目的就是为了阻止多个事务将记录插入到同一范围内从而导致幻读。

注意了,如果走唯一索引,那么 Next-Key Lock 会降级为 Record Lock,即仅锁住索引本身,而不是范围。也就是说 Next-Key Lock 前置条件为事务隔离级别为 RR 且查询的索引走的非唯一索引、主键索引。

下面我们用个例子详细说一下。
首先建立一张表:

CREATE TABLE T (id int ,f_id int,PRIMARY KEY (id), KEY(f_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8
insert into T SELECT 1,1;
insert into T SELECT 3,1;
insert into T SELECT 5,3;
insert into T SELECT 7,6;
insert into T SELECT 10,8;

事务 A 执行如下语句:

SELECT * FROM T WHERE f_id = 3 FOR UPDATE

这时 SQL 语句走非唯一索引,因此使用 Next-Key Locking 加锁,并且有 2 个索引,其需要分别进行锁定。
对于聚集索引,其仅对 id 等于 5 的索引加上 Record Lock。而对于辅助索引,其加上 Next-Key Lock,锁定了范围(1,3),特别需要注意的是,InnoDB 存储引擎还会对辅助索引下一个键值加上 Gap Lock,即范围(3.6)的锁。
所以如果在新 session 中执行如下语句都会报错 [Err] 1205 - Lock wait timeout exceeded; try restarting transaction

select * from T where id = 5 lock in share MODE -- 不能执行,因为事务A已经给id=5的值加上了X锁,执行会被阻塞
INSERT INTO T SELECT 4,2  -- 不能执行,辅助索引的值为2,在(1,3)的范围内,执行阻塞
INSERT INTO T SELECT 6,5  -- 不能执行,gap锁会锁住(3,6)的范围,执行阻塞

此时想象一下,事务 A 锁定了 f_id =5 的记录, 正常会有个 gap lock,锁住(5,6),那么如果没有(5,6)的 gap 锁,那么用户可以插入索引 f_id 为 5 的记录,这样事务 A 再次查询就会返回一个不同的记录,也就导致了幻读的产生。

同理,如果我们事务 A 执行的是 select * from T where f_id = 10 FOR UPDATE,在表里查不到数据,但是基于 Next-Key Lock 会锁住(8,+∞),我们执行 INSERT INTO T SELECT 6,11 是无法插入成功的,这就从根本上解决了幻读问题。

  • 数据库

    据说 99% 的性能瓶颈都在数据库。

    340 引用 • 708 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...