事务的基本要素
事务的四个基本要素:ACID
原子性(A):整个事务中的操作,要么全部完成,要么全部不完成(全部撤销)
一致性(C):事务开始之前和结束之后,数据库的完整性没有遭到破坏
隔离性(I):在同一时间,只允许一个事务请求同一数据
持久性(D):事务完成以后,该事务对数据库所做的操作持久化在数据库中,并不会被回滚
原子性与一致性
在数据库实现的场景中,一致性可以分为数据库外部的一致性和数据库内部的一致性。前者由外部应用的编码来保证,即某个应用在执行转帐的数据库操作时,必须在同一个事务内部调用对帐户A和帐户B的操作。如果在这个层次出现错误,这不是数据库本身能够解决的,也不属于我们需要讨论的范围。后者由数据库来保证,即在同一个事务内部的一组操作必须全部执行成功(或者全部失败)。这就是事务处理的原子性。
为了实现原子性,需要通过日志:将所有对数据的更新操作都写入日志,如果一个事务中的一部分操作已经成功,但以后的操作,由于断电/系统崩溃/其它的软硬件错误而无法继续,则通过回溯日志,将已经执行成功的操作撤销,从而达到“全部操作失败”的目的。最常见的场景是,数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个crash
recovery的过程:读取日志进行REDO(重演将所有已经执行成功但尚未写入到磁盘的操作,保证持久性),再对所有到崩溃时尚未成功提交的事务进行UNDO(撤销所有执行了一部分但尚未提交的操作,保证原子性)。crash
recovery结束后,数据库恢复到一致性状态,可以继续被使用。
其中一致性还可以分为:
- 强一致性:读操作可以立即读到提交的更新操作。
- 弱一致性:提交的更新操作,不一定立即会被读操作读到,此种情况会存在一个不一致窗口,指的是读操作可以读到最新值的一段时间。
- 最终一致性:是弱一致性的特例。事务更新一份数据,最终一致性保证在没有其他事务更新同样的值的话,最终所有的事务都会读到之前事务更新的最新值。如果没有错误发生,不一致窗口的大小依赖于:通信延迟,系统负载等。
隔离性
本文重点讨论事务的隔离性,隔离性是为了保证并发情况下数据库的强一致性,下面探讨一下并发情况下带来的问题。
事务中经常出现的并发问题
脏读: 一个事务读取了另一个事务操作但未提交的数据
比如A、B两个事务,都操作同一张表,A刚刚对数据进行了操作(插入、修改等)但还没有提交,这时B读取到了A刚刚操作的数据,因为A有可能回滚,所以这部分数据有可能只是临时的、无效的,即脏数据。
不可重复读:一个事务中的多个相同的查询返回了不同数据
比如A、B两个事务,A中先后有两次查询相同数据的操作,第一次查询完之后,B对相关数据进行了修改,造成A事务第二次查询出的数据与第一次不一致。
幻读:事务并发执行时,其中一个事务对另一个事务中操作的结果集的影响
比如A、B两个事务,事务A操作表中符合条件的若干行。事务B插入符合A操作条件的数据行,然后再提交。后来发现事务A并没有如愿对“所有”符合条件的数据行做了修改。
请注意不可重复读和幻读的区别:不可重复读和幻读都是在另一个事务提交之后才操作,但不可重复读只针对某一条特定数据,而幻读指的是整个操作结果集;下面提到的Repeatable read隔离级别可以防止脏读、不可重复读,但是不能防止幻读,是因为该隔离级别只应用了行锁锁住了某一行特定数据,而没有用表锁把整个表锁住,而Serializable隔离级别就可以锁住整个表,从而防止幻读。
SQL规范定义的四个事务隔离级别
以上都是事务中经常发生的问题,所以为了兼顾并发效率和异常控制,SQL规范定义了四个事务隔离级别:
Read uncommitted(读未提交):如果设置了该隔离级别,则当前事务可以读取到其他事务已经修改但还没有提交的数据。这种隔离级别是最低的,会导致上面所说的脏读
Read committed(读已提交):如果设置了该隔离级别,当前事务只可以读取到其他事务已经提交后的数据,这种隔离级别可以防止脏读,但是会导致不可重复读和幻读。这种隔离级别最效率较高,并且不可重复读和幻读在一般情况下是可以接受的,所以这种隔离级别最为常用
<span id="repeatableRead">Repeatable read(可重复读)</span>:如果设置了该隔离级别,可以保证当前事务中多次读取特定记录的结果相同。可以防止脏读、不可重复读,但是会导致幻读
<span id="serializable">Serializable(串行化)</span>:如果设置了该隔离级别,所有的事务会放在一个队列中执行,当前事务开启后,其他事务将不能执行,即同一个时间点只能有一个事务操作数据库对象。这种隔离级别对于保证数据完整性的能力是最高的,但因为同一时刻只允许一个事务操作数据库,所以大大降低了系统的并发能力
引用一张很经典的表格:
事务隔离级别 | 是否存在脏读 | 是否存在不可重复读 | 是否存在幻读 |
---|---|---|---|
Read uncommitted | √ | √ | √ |
Read committed | × | √ | √ |
Repeatable read | × | × | √ |
Serializable | × | × | × |
并且隔离级别越高,并发性能越弱:
事务隔离级别测试
read uncommitted测试
打开一个客户端A,并设置当前事务模式为read uncommitted(未提交读),查询表account的初始值:
mysql> set session transaction isolation level read uncommitted;
mysql> select @@tx_isolation;
+--------------------+
| @@tx_isolation |
+--------------------+
| READ-UNCOMMITTED |
+--------------------+
1 row in set, 1 warning (0.01 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
在客户端A的事务提交之前,打开另一个客户端B,更新表account:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance=balance+1000 where id=1;
Query OK, 1 rows affected (0.01 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 2000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
这时,虽然客户端B的事务还没提交,打开客户端A查询数据,发现已经可以查询到客户端B更新的数据:
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 2000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
客户端B回滚,所有的操作都将会被撤销:
mysql> rollback;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
客户端A再次查询:
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
所以一旦客户端B的事务因为某种原因回滚,所有的操作都将会被撤销,那客户端A查询到的数据其实就是脏数据
read committed测试
针对上面的问题,把客户端A的事务隔离级别设置为read committed,再重复上面的步骤,会发现B在事务结束之前,A并不能查询到B所做的操作。
客户端A设置事务隔离级别为read committed,然后开启事务:
mysql> set session transaction isolation level read committed;
mysql> select @@tx_isolation;
+------------------+
| @@tx_isolation |
+------------------+
| READ-COMMITTED |
+------------------+
1 row in set, 1 warning (0.01 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
客户端B开启事务并更新数据:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
mysql> update account set balance=balance+1000 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 2000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
客户端A在客户端B未提交事务前读取数据:
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
正如上文所说,这种隔离级别下可能导致前事务中多次读取特定记录的结果不相同,比如客户端A事务隔离级别为read committed,在A的一个事务中,执行两次相同的查询,在这两次查询的中间,客户端B对数据进行更改并提交事务,那么会导致客户端A的两次查询结果不一致,导致“不可重复读”的麻烦。
客户端B提交事务:
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
客户端A再次读取数据:
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 2000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
repeatable read测试
同理,如果设置了repeatable read隔离级别,就可以保证在当前事务中多次执行相同查询的结果集相同,实现“可重复读”。
客户端A设置事务隔离级别为repeatable read并开启事务:
mysql> set session transaction isolation level REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
客户端B开启事务,修改数据并提交事务:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance=5000 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 5000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
客户端A查询,数据没有发生变化:
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
可以发现,在同一个事务A里,无论事务B是否提交,读到的数据都是相同的。
但是,这种隔离级别会导致“幻读”,比如客户端A中事务操作表中符合条件的若干行,同时客户端B中事务插入符合A操作条件的数据行,然后再提交。
客户端A设置事务隔离级别为repeatable read并开启事务:
mysql> set session transaction isolation level REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
+----+------+---------+
3 rows in set (0.00 sec)
客户端B开启事务,插入数据并提交事务:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values(4, 'd', 8888);
Query OK, 1 row affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
| 4 | d | 8888 |
+----+------+---------+
4 rows in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
客户端A更新全表数据:
mysql> update account set balance=9999;
Query OK, 4 rows affected (0.00 sec)
Rows matched: 4 Changed: 4 Warnings: 0
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 9999 |
| 2 | b | 9999 |
| 3 | c | 9999 |
| 4 | d | 9999 |
+----+------+---------+
4 rows in set (0.00 sec)
奇怪,这样操作并不会发生幻读,那如果事务B在事务A更新之后插入数据,会不会产生幻读呢,我们再来做一个测试:
客户端A设置事务隔离级别为repeatable read并开启事务,并在开启事务后更新全表数据:
mysql> set session transaction isolation level REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
| 4 | d | 8888 |
+----+------+---------+
3 rows in set (0.00 sec)
mysql> update account set balance=9999;
Query OK, 4 rows affected (0.00 sec)
Rows matched: 4 Changed: 4 Warnings: 0
客户端B开启事务,插入数据:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
| 4 | d | 8888 |
+----+------+---------+
4 rows in set (0.00 sec)
mysql> insert into account values(5, 'e', 5555);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction //阻塞超时
这个时候客户端B阻塞超时,可以发现,事务A正在执行写操作时,事务B是阻塞的(第一次测试事务B没有阻塞是因为事务A没有进行写操作)
为了不阻塞,在客户端A提交事务:
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
客户端A提交事务后,客户端B再次插入数据:
mysql> insert into account values(5, 'e', 5555); //事务A已提交,没有阻塞
Query OK, 1 row affected (2.92 sec)
mysql> select * from account; //因为事务B的默认隔离级别是REPEATABLE-READ,即使事务A提交后也读不到更新后的数据
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 1000 |
| 2 | b | 1200 |
| 3 | c | 6666 |
| 4 | d | 8888 |
| 5 | e | 5555 |
+----+------+---------+
5 rows in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account; //事务B提交后,就能读到事务A更新的数据了
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 9999 |
| 2 | b | 9999 |
| 3 | c | 9999 |
| 4 | d | 9999 |
| 5 | e | 5555 |
+----+------+---------+
5 rows in set (0.00 sec)
客户端A再次读取数据:
mysql> select * from account; //两个事务都提交后客户端A读到的数据,但这不算是幻读,因为事务A已经提交
+----+------+---------+
| id | name | balance |
+----+------+---------+
| 1 | a | 9999 |
| 2 | b | 9999 |
| 3 | c | 9999 |
| 4 | d | 9999 |
| 5 | e | 5555 |
+----+------+---------+
5 rows in set (0.00 sec)
可以发现,事务A正在执行写操作时,事务B是阻塞的(第一次测试事务B没有阻塞是因为事务A没有进行写操作),可见mysql的repeatable read隔离级别和Serializable是一样的,不会产生幻读!
serializable测试
笔者再次把事务隔离级别设置为serializable,实验结果与repeatable read的第二测试一样。
再次证明,mysql的repeatable read隔离级别和Serializable是一样的,不会产生幻读!
参考文献: