我们知道,数据库的事务存在着 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) 中的介绍。