我们知道,数据库的事务存在着 ACID 四个属性,而在实际应用场景中,当面临着性能需求时,隔离性往往成为最容易妥协的一个。
为了获取更高的隔离等级,数据库系统的锁机制或者多版本并发控制机制都会影响并发;而低的隔离级别可以增加事务的并发,但同时会存在着风险。
在本文章中,详细介绍下 InnoDB 中关于隔离级别的实现。

简介

事务隔离级别 (transaction isolation levels),隔离级别就是对对事务并发控制的等级。
很多 DBMS 定义了不同的 “事务隔离等级” 来控制锁的程度,多数的数据库事务都避免高等级的隔离等级 (如可序列化) 从而减少对系统的锁的开销,高的隔离级别往往会增加死锁发生的几率。
同时,当降低事务的隔离级别时,程序员需要小心的分析数据库访问部分的代码,以保证不会造成难以发现的代码 bug。

常用命令

InnoDB 默认是可重复读的 (REPEATABLE READ),提供 SQL-92 标准所描述的所有四个事务隔离级别,可以在启动时用 —transaction-isolation 选项设置,也可以配置文件中设置。
$ cat /etc/my.cnf
[mysqld]
transaction-isolation = {READ-UNCOMMITTED | READ-COMMITTED | REPEATABLE-READ | SERIALIZABLE}
用户可以用 SET TRANSACTION 语句改变单个会话或者所有新进连接的隔离级别,语法如下:
mysql> SET autocommit=0;
mysql> SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL
{READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
如果使用 GLOBAL 关键字,当然需要 SUPER 权限,则从设置时间点开始创建的所有新连接均采用该默认事务级别,不过原有链接事务隔离级别不变。
可以用下列语句查询全局和会话事务隔离级别。
mysql> SHOW VARIABLES LIKE ‘tx_isolation’;
mysql> SELECT @@global.tx_isolation;
mysql> SELECT @@session.tx_isolation;
mysql> SELECT @@tx_isolation;

读取异常

SQL 92 规范的定义中,规定了四种隔离级别,同时对可能出现的三种现象进行了说明(不包含如下的丢失更新)。

Lost Update

丢失更新,当两个事务读取相同数据,然后都尝试更新原来的数据成新的值,此时,第二个事务可能完全覆盖掉第一个所完成的更新。
丢失更新是唯一一个用户可能在所有情况下都想避免的行为,在 SQL 92 中甚至没有提及。

Dirty Read

脏读,一个事务中读取到另一个事务未提交的数据。例如,事务 T1 读取到另一个事务 T2 未提交的数据,如果 T2 回滚,则 T1 相当于读取到了一个被认为不可能出现的值。

Non-Repeatable Read

不可重复读,在一个事务中,当重复读取同一条记录时,发现该记录的结果不同或者已经被删除了;如在事务 T1 中读取了一行,接着 T2 修改或者删除了该行 并提交,那么当 T1 尝试读取新的值时,就会发现改行的值已经修改或者被删除。

Phantom

幻读,通常是指在一个事务中,当重复查询一个结果集时,返回的两个不同的结果集,可能是由于另一个事务插入或者删除了一些记录。
例如,事务 T1 读取一个结果集,T2 修改了该结果集中的部分记录 (例如插入一条记录),T1 再次读取时发现与之前的结果不同 (多出来一条记录),就像产生幻觉一样。

其它

隔离级别与读现象。

隔离级别 脏读 不可重复读取 幻影数据行
READ UNCOMMITTED YES YES YES
READ COMMITTED NO YES YES
REPEATABLE READ NO NO YES
SERIALIZABLE NO NO NO

事务超时

与事务超时相关的变量可以参考。
——- 设置锁超时时间,单位为秒,默认50s
mysql> SHOW VARIABLES LIKE ‘innodb_lock_wait_timeout’;
+—————————————+———-+
| Variable_name | Value |
+—————————————+———-+
| innodb_lock_wait_timeout | 50 |
+—————————————+———-+
1 row in set (0.00 sec)

——- 超时后的行为,默认OFF,详见如下介绍
mysql> SHOW VARIABLES LIKE ‘innodb_rollback_on_timeout’;
+——————————————+———-+
| Variable_name | Value |
+——————————————+———-+
| innodb_rollback_on_timeout | OFF |
+——————————————+———-+
1 row in set (0.02 sec)
innodb_rollback_on_timeout 变量默认值为 OFF,如果事务因为加锁超时,会回滚上一条语句执行的操作;如果设置 ON,则整个事务都会回滚。
当上述变量为 OFF 时,也就是事务会回滚到上一个保存点,这是因为 InnoDB 在执行每条 SQL 语句之前,都会创建一个保存点,可以参见 row_insert_for_mysql() 函数中的代码。
row_insert_for_mysql()
|-row_insert_for_mysql_using_ins_graph()
|-trx_savept_take()
如果事务因为加锁超时,相当于回滚到上一条语句,但是报错后,事务还没有完成,用户可以选择是继续提交,或者回滚之前的操作,由用户选择是否进一步提交或者回滚事务。
上述参数为 ON 时,整个事务都回滚;详细的内容可以从 row_mysql_handle_errors() 中验证。

其它

如何判断当前会话已经开启了一个事务?
——- 可以直接使用在事务中会报错的语句。
mysql> SELECT @@TX_ISOLATION;
mysql> SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

——- 或者通过如下SQL查看,如果在事务中则会返回当前的事务ID,否则返回为空。
mysql> SELECT trx_id FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = connection_id();

隔离级别

先准备下环境,隔离级别在测试时,会针对不同的场景分别进行设置;另外,将 autocommit 设置为 0 ,此时 commit/rollback 后的一条语句会自动开启一个新事务。
——- 新建表并写入数据
mysql> SET GLOBAL autocommit=0;
mysql> CREATE TABLE user (id INT PRIMARY KEY, name VARCHAR(20), age INT UNSIGNED) engine=InnoDB;
mysql> INSERT INTO user VALUES (1, ‘andy’, 28);

——- 设置隔离级别
mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
一般来说,也显然,高一级的级别可以提供更强的隔离性。

READ UNCOMMITTED

也就是读未提交/未授权读,在此场景下 允许脏读,也就是允许某个事务看到其它事务尚未提交的数据行改动,这是最低的隔离等级。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
### 读到的age为28
SELECT FROM user WHERE id=1;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
### 开启一个事务,隔离级别任意,更新age
START TRANSACTION;
UPDATE user SET age=30 WHERE id=1;
### 读到未提交数据,age为30
SELECT
FROM user WHERE id=1;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
### 回滚事务
ROLLBACK;
### 读到的age为28
SELECT * FROM user WHERE id=1;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
如上所示,在事务 A 中,会读取到不同的 age 值。

READ COMMITTED

也就是读已提交/授权读,此时不允许上述的脏读,允许不可重复读,也就是 Fuzzy Read (也被称之为 Non-Repeatable Read),也指一个事务内的两次读同一行看到的数据不一样,”不可重复” 。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
START TRANSACTION;
UPDATE user SET age=30 WHERE id=1;
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
### 读到的age仍然为28,没有幻读
SELECT FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
COMMIT;
### 此时在事务B提交之后,读到的age为30
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
COMMIT;
如上,事务 A 中同一条 SQL 会读取到不同的数值,即使事务 B 中是删除操作。

REPEATABLE READ

也即可重复读,InnoDB 默认的隔离级别,此时不允许脏读、不可重复读,但是允许 幻读 。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
START TRANSACTION;
UPDATE user SET age=30 WHERE id=1;
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
### 读到的age仍然为28,没有幻读
SELECT FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
COMMIT;
### 读到的age仍然为28,没有不可重复读
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
COMMIT;

此时读到的age为30
SELECT * FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
再看一个出现幻读的情况。
### 准备数据
DELETE FROM user;
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user;
Empty set (0.00 sec)
START TRANSACTION;
INSERT INTO user VALUES (1, ‘andy’, 28);
COMMIT;
### 此时查询仍然为空
SELECT
FROM user;
Empty set (0.00 sec)
### 尝试插入数据时报错,TMD明明说没有这条记录的
INSERT INTO user VALUES (1, ‘andy’, 28);
ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘PRIMARY’
COMMIT;

START TRANSACTION;
SELECT FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
START TRANSACTION;
INSERT INTO user VALUES (2, ‘cassie’, 25);
COMMIT;
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
### 不是说有一条记录吗,怎么多出来一条啊!!!
UPDATE user SET age=10;
Query OK, 2 rows affected (0.00 sec)
Rows matched: 2 Changed: 2 Warnings: 0

Snapshot Read

另外,在 Repeatable Read 隔离级别下,如果使用 Snapshot Read 实现时,允许某些 Phantom 现象,简单来说就是第二次读到了第一次没有的行数据。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
START TRANSACTION;
UPDATE user SET age=30 WHERE id=1;
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
COMMIT;
UPDATE user SET name=’andrew’ WHERE id=1;
### 对于快照读,事务有了修改之后,就可以读到age为30
SELECT FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andrew | 30 |
+——+————+———+
1 row in set (0.00 sec)
### 不过此时读取到的仍然是andy
SELECT
FROM user;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 30 |
+——+———+———+
1 row in set (0.00 sec)
COMMIT;
### OK,终于读取到了最新的数据
SELECT * FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andrew | 30 |
+——+————+———+
1 row in set (0.00 sec)

Write Skew

Repeatable Read 允许 Write Skew,该异常主要是针对多行事务。
简单来说,就是如果事务 A 读取了行 X,并因此修改了 Y,然后提交;同时事务 B 读取了行 Y,并修改了 X ;那么此时的最终结果可能违反 X 和 Y 相关的某些约束。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28),(2, ‘cassie’, 25);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user WHERE id=1;
+——+———+———+
| id | name | age |
+——+———+———+
| 1 | andy | 28 |
+——+———+———+
1 row in set (0.00 sec)
START TRANSACTION;
SELECT
FROM user WHERE id=2;
+——+————+———+
| id | name | age |
+——+————+———+
| 2 | cassie | 25 |
+——+————+———+
1 row in set (0.00 sec)
UPDATE user SET age=15 WHERE id=2;
SELECT FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 28 |
| 2 | cassie | 15 |
+——+————+———+
2 rows in set (0.00 sec)
UPDATE user SET age=18 WHERE id=1;
SELECT
FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 18 |
| 2 | cassie | 25 |
+——+————+———+
2 rows in set (0.00 sec)
COMMIT;
COMMIT;
SELECT * FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 18 |
| 2 | cassie | 15 |
+——+————+———+
2 rows in set (0.00 sec)

SERIALIZABLE

也就是串行化/可序列化,不允许上述的异常情况,包括 Phantom 和 Write Skew 以及任何不可串行化的反常情况。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28),(2, ‘cassie’, 25);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 28 |
| 2 | cassie | 25 |
+——+————+———+
2 rows in set (0.00 sec)
START TRANSACTION;
SELECT
FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 28 |
| 2 | cassie | 25 |
+——+————+———+
2 rows in set (0.00 sec)
SELECT FROM user WHERE id=2;
+——+————+———+
| id | name | age |
+——+————+———+
| 2 | cassie | 25 |
+——+————+———+
1 row in set (0.00 sec)
### 此时尝试更新时会等待直到锁超时
UPDATE user SET age=15 WHERE id=2;
COMMIT;
### 重新尝试提交
UPDATE user SET age=15 WHERE id=2;
SELECT
FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 28 |
| 2 | cassie | 15 |
+——+————+———+
2 rows in set (0.00 sec)
COMMIT;
接下来,再看个示例。
### 准备数据
DELETE FROM user;
INSERT INTO user VALUES (1, ‘andy’, 28);
### 同时设置两个事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

——- TRANS A ——————————————————————-+——- TRANS B ————————————-
START TRANSACTION;
SELECT FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 28 |
+——+————+———+
1 rows in set (0.00 sec)
START TRANSACTION;
SELECT
FROM user;
+——+————+———+
| id | name | age |
+——+————+———+
| 1 | andy | 28 |
| 2 | cassie | 25 |
+——+————+———+
2 rows in set (0.00 sec)
### 此时尝试更新时会等待直到锁超时
UPDATE user SET age=15 WHERE id=2;
### 同上,仍然锁等待超时
INSERT INTO user VALUES (2, ‘cassie’, 25);

参考

关于数据库的事务隔离级别可以参考 WikiPedia - Isolation (database systems) 中的介绍。