Skip to the content.

..

InnoDB Transaction Model

参考:InnoDB Transaction Model 14.7 InnoDB的锁和事务模型

15.7.2 InnoDB Transaction Model

InnoDB的事务模型(transaction model), 目标是将多版本数据库(multi-versioning database)的最佳属性与传统的两阶段锁定(two-phase locking)相结合。 默认情况下, InnoDB使用行级锁, 并以非锁定一致性读(nonlocking consistent read)的方式来执行查询, 类似Oracle数据库。 InnoDB中的锁信息, 以节省空间的方式存储, 因此不需要锁升级(lock escalation)。 支持多个用户锁定InnoDB表中的每一行, 或者任意多行, 都不会让InnoDB的内存耗尽。

Transaction Isolation Levels

事务隔离(Transaction isolation)是数据库的基础特征。 隔离(Isolation)就是ACID中的I; 隔离级别是一个可配置项, 用于在多个事务进行同时并发修改和并发查询时, 调节性能、可靠性(reliability)、一致性(consistency)和可重复性(reproducibility)之间的平衡。

InnoDB支持《SQL:1992标准》中定义的四个事务隔离级别:

用户可以改变当前会话的隔离级别(控制自己会话的可见性), 也可以更改后续所有连接的隔离级别, 使用 SET TRANSACTION 语句即可。 要设置服务器的默认隔离级别, 可在命令行或配置文件中使用 --transaction-isolation 选项。

InnoDB对每个事务隔离级别使用不同的锁策略

REPEATABLE READ

InnoDB的默认隔离级别。 可重复读隔离级别, 同一事务中的一致性读, 使用第一次读取时创建的快照。 这意味着, 在同一事务中执行多个普通的 SELECT语句(nonlocking), 则这些 SELECT 语句之间彼此是能保证一致性的。

对于UPDATE语句, DELETE语句, 以及锁定读(locking read, 即 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE语句), 根据过滤条件是否使用了唯一索引, 还是使用范围条件来确定使用的锁:

READ COMMITTED

在【读已提交】隔离级别下, 即使在同一事务中, 每次一致性读都会设置和读取自己的新快照。

假如使用默认的 REPEATABLE READ 隔离级别时, 第一个 UPDATE 会先在其扫描读取到的每一行上设置X锁, 并且不会释放任何一个:

# Session A 加锁过程
x-lock(1,2); retain x-lock
x-lock(2,3); update(2,3) to (2,5); retain x-lock
x-lock(3,2); retain x-lock
x-lock(4,3); update(4,3) to (4,5); retain x-lock
x-lock(5,2); retain x-lock

因为第一个 UPDATE 在所有行上都保留了锁, 第二个 UPDATE 尝试获取任何一个锁时都会立即阻塞, 直到第一个UPDATE提交或回滚之后才能继续执行:

x-lock(1,2); block and wait for first UPDATE to commit or roll back

如果使用 READ COMMITTED 隔离级别, 则第一个 UPDATE 会在扫描读取到的每一行上获取X锁, 然后释放不需要修改行上的X锁:

# Session A 释放锁过程
x-lock(1,2); unlock(1,2)
x-lock(2,3); update(2,3) to (2,5); retain x-lock
x-lock(3,2); unlock(3,2)
x-lock(4,3); update(4,3) to (4,5); retain x-lock
x-lock(5,2); unlock(5,2)

对于第二个UPDATE, InnoDB会执行半一致读(“semi-consistent” read), 将最新的提交版本返给MySQL, 让MySQL确定该行是否符合 UPDATEWHERE条件:

# Session B 加锁过程
x-lock(1,2); update(1,2) to (1,4); retain x-lock
x-lock(2,3); unlock(2,3)
x-lock(3,2); update(3,2) to (3,4); retain x-lock
x-lock(4,3); unlock(4,3)
x-lock(5,2); update(5,2) to (5,4); retain x-lock

但是, 如果 WHERE 条件中包括了索引列, 并且 InnoDB 使用了这个索引, 则获取和保留record locks时只考虑索引列。 在下面的示例中, 第一个 UPDATE 在所有 b = 2 的行上获取并保留一个X锁。 第二个 UPDATE 尝试获取同一记录上的X锁时会阻塞, 因为也使用了 b 这列上面定义的索引。

CREATE TABLE t (a INT NOT NULL, b INT, c INT, INDEX (b)) ENGINE = InnoDB;
INSERT INTO t VALUES (1,2,3),(2,2,4);
COMMIT;

# Session A
START TRANSACTION;
UPDATE t SET b = 3 WHERE b = 2 AND c = 3;

# Session B
UPDATE t SET b = 4 WHERE b = 2 AND c = 4;

使用 READ COMMITTED 隔离级别, 与设置 innodb_locks_unsafe_for_binlog 选项的效果基本一样【该选项已废弃】, 但也有一些不同:

READ UNCOMMITTED

【读未提交】隔离级别下, SELECT语句以非锁定的方式执行, 但可能会用到某一行的早期版本。 所以使用此隔离级别时, 不能保证读取的一致性, 这种现象称为脏读(dirty read)。 其他情况下, 此隔离级别类似于 READ COMMITTED

SERIALIZABLE

【串行化】这个隔离级别类似于 REPEATABLE READ;

如果要强制普通的 SELECT 语句在其他事务修改选定行时进行阻塞等待, 请禁用 autocommit

隔离级别 脏读(Dirty Read) 不可重复读(NonRepeatable Read) 幻读(Phantom Read)
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ 不可能 不可能 可能
SERIALIZABLE 不可能 不可能 不可能

autocommit, Commit, and Rollback

在InnoDB中, 所有用户活动都在事务中执行。 如果启用了自动提交模式(autocommit), 则每条SQL语句都会自己形成一个事务。

Grouping DML Operations with Transactions

DML(Data Manipulation Language,S、I、U、D、M等)操作分组和事务;

MySQL数据库的客户端连接,默认开启自动提交模式,每个SQL语句执行完都会自动提交。 用过其他数据库系统的用户,可能对这种操作模式不太习惯,因为他们更常用的方式,是执行一连串的DML语句, 然后再一起提交, 或者一起回滚。

想要使用多语句事务:

  1. 以通过 SET autocommit = 0 语句关闭自动提交模式,并在适当的时机以 COMMIT 或者 ROLLBACK 结束事务。
  2. 处于自动提交状态, 可以通过 START TRANSACTION 开启一个事务,并以 COMMIT 或者 ROLLBACK 结束。
Transactions in Client-Side Languages

在MySQL客户端API中, 例如 PHP, Perl DBI, JDBC, ODBC, 或者标准C调用接口, 可以将事务控制语句(如COMMIT)当做字符串发送给MySQL服务器, 就像普通的SQL语句(SELECTINSERT)一样。 某些API还单独提供了提交事务和回滚的函数/方法。

Consistent Nonlocking Reads(非锁定一致性读)

一致性读(consistent read), 意味着 InnoDB 通过多版本技术, 为一个查询呈现出某个时间点上的数据库快照。

查询能看到这个时间点之前所有已提交事务的更改, 而看不到这个时间点之后新开的事务、或者未提交的事务所做的更改。

例外是查询可以看到同一事务中前面执行的语句所做的更改。 这种例外会引起一些异常: 如果更新了表中的某些行, 则 SELECT 将看到该行被更新之后的最新版本, 但其他的行可能看到的还是旧版本。 如果其他会话也更新了这张表, 则这种异常意味着我们可能会看到某种并不存在的状态。

Note

数据库状态的快照适用于事务中的 SELECT 语句, 而不一定适用于DML语句(增删改)。 如果插入或修改一些行, 稍后再提交事务(T0), 则另一个并发的 REPEATABLE READ 事务(T1)中的 DELETE 或者 UPDATE 语句可能会影响准备提交(just-committed)的这些行, 即使(T1)会话可能无法读取看到它们。 如果某个事务确实更新或删除已经被另一个事务提交的行, 则这些更改对于当前事务而言来说会变得可见。

# 场景1
SELECT COUNT(c1) FROM t1 WHERE c1 = 'xyz';
-- Returns 0: 没有匹配的行. (查不到)
DELETE FROM t1 WHERE c1 = 'xyz';
-- 删除了被另一个事务提交的某些行... (但确实会删除)

# 场景2
SELECT COUNT(c2) FROM t1 WHERE c2 = 'abc';
-- Returns 0: 没有匹配的行. (查不到)
UPDATE t1 SET c2 = 'cba' WHERE c2 = 'abc';
-- Affects 10 rows: 比如, 另一个事务 txn 刚刚提交了 10 行 'abc' 值. (但确实更新了...)
SELECT COUNT(c2) FROM t1 WHERE c2 = 'cba';
-- Returns 10: 本事务 txn 这时候可以看到刚刚更新的行.
             Session A              Session B

           SET autocommit=0;      SET autocommit=0;
时间序
|          SELECT * FROM t;
|          empty set
|                                 INSERT INTO t VALUES (1, 2);
|
v          SELECT * FROM t;
           empty set
                                  COMMIT;

           SELECT * FROM t;
           empty set

           COMMIT;

           SELECT * FROM t;
           ---------------------
           |    1    |    2    |

如果要查看数据库的最新状态(freshest), 可以使用 READ COMMITTED隔离级别, 或者使用锁定读取:

SELECT * FROM t LOCK IN SHARE MODE;

一致性读不支持某些DDL(drop、alter)语句

  1. 一致性读不能在 DROP TABLE 时生效, 因为MySQL无法使用已删除的表, 而且 InnoDB 已经销毁了这张表。
  2. 一致性读不能在 ALTER TABLE 操作时生效, 因为这个操作会创建原始表的临时副本,并在构建临时副本之后删除原始表。 在事务中重新执行一致性读时, 新表中的数据行是不可见的, 因为事务在获取快照时这些行还不存在。 这种情况下, 会返回错误信息: ER_TABLE_DEF_CHANGED, “Table definition has changed, please retry transaction”.

对于没有指定 FOR UPDATE 或者 LOCK IN SHARE MODE 的各种查询, 其行为有所不同, 如 INSERT INTO ... SELECT, UPDATE ... (SELECT), 以及 CREATE TABLE ... SELECT:

Locking Reads

如果在一个事务中, 先查询数据, 然后再insert或update相关数据, 则常规的 SELECT 语句并不能提供足够的保护。其他事务可以更新或删除我们刚刚查到的这些行。 InnoDB 支持两种类型的锁定读(Locking Read), 可以提供额外的安全性:

SELECT ... LOCK IN SHARE MODE

在读取到的所有行上设置共享锁。 其他会话可以读取这些行, 但在当前事务结束之前都不能修改。 在查询时, 如果某些行被其他尚未提交的事务修改了, 那么当前查询会被一直阻塞到那些事务结束, 然后使用最新的值。

SELECT ... FOR UPDATE

对于搜索到的索引记录, 锁定数据行以及所有关联的索引条目, 就如同对这些行执行了 UPDATE 语句一样。 其他事务会被阻塞, 包括修改、 使用 SELECT ... LOCK IN SHARE MODE来读取, 甚至在某些隔离级别执行读操作时, 都会阻塞。 一致性读会忽略读取视图中的记录上设置的任何锁。 (因为数据行的旧版本无法被锁定, 是通过记录的内存副本加上 undo logs 来重建的)。

这类子句在处理树结构(tree-structured)或图结构(graph-structured)的数据时非常有用, 不管是单张表还是多张表。 我们可以先遍历一遍, 然后再去修改其中的某些记录。

当事务被提交或者回滚时, 由 LOCK IN SHARE MODEFOR UPDATE 设置的锁都会被释放。

Note

只有禁用自动提交, 才可能执行锁定读。(一般使用 START TRANSACTION 语句或者设置 autocommit=0来禁用自动提交)。

执行嵌套语句查询时, 外部查询中的锁定读, 不会对子查询的数据行加锁, 除非子查询也指定了锁定读。 例如, 下面的语句不会锁定 t2 表中的行。

SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2) FOR UPDATE;

要锁定 t2 表中的行, 需要在子查询中也进行锁定读:

SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2 FOR UPDATE) FOR UPDATE;

Locking Read Examples

示例1

假设需要在 child 表中插入新行, 但要确保 parent 表中具有对应的记录。 在应用程序代码中, 可以通过以下顺序的操作, 来确保引用完整性。

首先, 使用一致性读来查询 parent 表以检查父记录是否存在。 这样可以保证将数据安全地插入到 child 表吗? 不能, 因为其他会话可能在我们不知道的情况下, 在 SELECTINSERT 之间, 恰好把 parent 表中的那行数据给删了。

要避免这个潜在的BUG, 可以通过 LOCK IN SHARE MODE 来执行 SELECT :

SELECT * FROM parent WHERE NAME = 'Jones' LOCK IN SHARE MODE;

LOCK IN SHARE MODE 查询返回 parent 记录 'Jones' 之后, 可以安全地将记录添加到child表中, 然后提交事务。 其他事务如果试图获取 parent 表中对应数据行上的排他锁, 会被阻塞并需要等待我们完成操作之后才能继续, 也就是需要先等待这两张表中的数据处于一致状态。

示例2

又比如, CHILD_CODES 表中有一个整型的 counter_field 字段, 用来为 child 表中的每条记录分配唯一ID。 我们不能使用一致性读或者共享模式来读取当前计数器的值, 因为这样会有多个客户端看到相同的值, 如果两个事务尝试使用相同的id来添加数据, 则会发生重复键错误(duplicate-key error)。

在这个场景下, LOCK IN SHARE MODE 不是一种好方案, 如果有多个用户同时读取计数器, 则其中至少有一个会在更新计数器时陷入死锁状态。

要读取计数器并实现递增, 需要先执行 FOR UPDATE 对计数器的锁定读, 然后再递增计数。例如:

SELECT counter_field FROM child_codes FOR UPDATE;
UPDATE child_codes SET counter_field = counter_field + 1;

SELECT ... FOR UPDATE 会读取最新的可用数据, 并在读到的每一行上设置排他锁。 因此, 它设置的锁与 UPDATE 语句设置的锁相同。

这个示例仅仅是为了演示 SELECT ... FOR UPDATE 的工作原理。 在MySQL中, 生成唯一标识的任务, 实际上可以通过一次查询就能完成:

UPDATE child_codes SET counter_field = LAST_INSERT_ID(counter_field + 1);
SELECT LAST_INSERT_ID();

SELECT 语句只会基于当前会话来查询id信息。而且不读任何一张表。

Locking Read Concurrency with NOWAIT and SKIP LOCKED

使用NOWAIT和SKIP LOCKED 锁定读取并发;

如果某行被事务锁定,则请求同一锁定行的SELECT ... FOR UPDATESELECT ... FOR SHARE事务必须等待,直到阻塞事务释放行锁定为止。此行为可以防止事务更新或删除其他事务为更新而查询的行。 但是,如果希望查询被锁定请求的行立即返回,或者可以接受从结果集中排除锁定的行,则不必等待释放行锁。

为了避免等待其他事务释放行锁,可以将NOWAITSKIP LOCKED选项与SELECT ... FOR UPDATESELECT ... FOR SHARE锁定读取语句一起使用。

NOWAIT

使用NOWAIT的锁定读取永远不会等待获取行锁定。 查询将立即执行,如果请求的行被锁定,则会失败并显示错误。

SKIP LOCKED

使用SKIP LOCKED的锁定读取永远不会等待获取行锁。 查询将立即执行,从结果集中删除锁定的行

Note

SKIP LOCKED的查询将返回不一致的数据视图。 因此,SKIP LOCKED不适用于一般事务处理。 但是,当多个会话访问相同的类似队列的表时,可以使用它来避免锁争用。

示例

NOWAITSKIP LOCKED仅适用于行级锁。

对于基于语句(statement )的复制,使用NOWAIT或SKIP LOCKED的语句是不安全的。

下面的示例演示了NOWAITSKIP LOCKED。 会话1启动一个事务,该事务对单个记录进行行锁定。 会话2使用NOWAIT选项尝试对同一记录进行锁定读取。 由于请求的行已被会话1锁定,因此锁定读取立即返回错误。 在会话3中,使用SKIP LOCKED进行的锁定读取将返回请求的行,但会话1锁定的行除外。

# Session 1:

mysql> CREATE TABLE t (i INT, PRIMARY KEY (i)) ENGINE = InnoDB;

mysql> INSERT INTO t (i) VALUES(1),(2),(3);

mysql> START TRANSACTION;

mysql> SELECT * FROM t WHERE i = 2 FOR UPDATE;
+---+
| i |
+---+
| 2 |
+---+

# Session 2:

mysql> START TRANSACTION;

mysql> SELECT * FROM t WHERE i = 2 FOR UPDATE NOWAIT;
ERROR 3572 (HY000): Do not wait for lock.

# Session 3:

mysql> START TRANSACTION;

mysql> SELECT * FROM t FOR UPDATE SKIP LOCKED;
+---+
| i |
+---+
| 1 |
| 3 |
+---+