欧阳亮的博客

编程不止是一份工作,还是一种乐趣!!!

MySQL InnoDB事务并发控制

1. 什么是事务

所谓事务,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的原子单位。例如,银行转帐工作:从一个帐号扣款并使另一个帐号增款,这两个操作要么都执行,要么都不执行。

事务的四个特性:

  • 原子性

    一个事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做。

  • 一致性

    事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。

  • 隔离性

    一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

  • 持久性

    持续性也称永久性,指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。


2. 事务的并发

事务并发通俗点讲就是多个事务同时执行,并发的访问或更新数据库中相同的数据。事务并发一般需要相应的隔离措施,否则就会出现各种问题。

事务的并发会造成哪些问题?

  • 丢失更新

    分两种情况:一,两个事务同时更新一条记录,那其中一个事务所做的修改将被另一个事务所覆盖;二,两个事务同时更新一条记录,如果事务一提交了,而事务二却回滚了,那事务一的提交将因为事务二的回滚而失效。

    值得注意的是,丢失更新在任何现有的数据库上都不会发生,因为目前所有的数据库(笔者所知)在更新记录时都必须先对记录加排它锁,因而多个事务不可能同时更新同一条记录。

  • 脏读

    事务读取了别的事务修改后并撤消的数据。笔者之前见过一个项目将事务默认的隔离级别设定为读未提交,询问项目负责人后得知是为了最大化的提升查询效率。其实这是完全没有必要的,读已提交的性能比起读未提交并不会差多少,因为两者都不会涉及的任何的锁,而且大部分情况下读已提交可以避免很多问题的发生,总是读取已经生效的数据一定是好的。

  • 不可重复读

    在同一个事务范围内,多次查询同一条记录,返回的结果不同,即数据在多次查询之间被别的事务所修改。

    那么在什么样的场景下,需要避免不可重复读发生呢?即在事务查询记录之后,在提交或回滚之前,别的事务不能对同一记录进行修改。

    假设系统用一张表来维护客户的账户,

    姓名账户余额
    张三招行100.00
    李四招行210.00
    张三建行170.00

    现在有个新需求,需要统计每位客户的总余额,并将总余额写入到另一张新创建的表中,之后客户账户余额发生变化时同步更新两张表的数据,如:

    姓名余额
    张三270.00
    李四210.00

    在初始化第二张表的数据时,首先需要查询第一张表的数据,累加计算,将计算结果写入第二张表。这里的关键是查询第一张表的记录后,在计算和写入前必须保证别的事务不能修改已经查询出来的记录,否则两边的数据就会处于不一致的状态。所以在类似这样的场景中,我们需要避免不可重复读的发生。用通俗点的话讲就是,我不需要修改这些数据本身,但是在我操作的时候,别人只可以读,但是不允许修改它们。

  • 幻读

    在同一个事务范围内,多次以相同的条件查询,返回的记录行数不一样。如:事务S以条件X查询T表得到10行记录之后,别的事务写入或删除一些数据,事务S再次以条件X查询T表返回的行数不是10。

    那么在什么样的场景下,需要避免幻读的发生呢?

    还是继续以上面账户的例子来说明,避免了不可重复读的发生,上面的场景就一定是正确的吗?未必。

    假入以“张三”为条件查询每一张表得到两条记录,但是在写入第二张表前,如果别的事务又创建了一个新的记录,如:

    姓名账户余额
    张三交行500.00

    那最终两张表的数据还是不一致的,对于这样的场景,还需要避免幻读的发生。关于如何避免幻读发生,请继续阅读本文后续章节。

  • 覆盖更新

    这里指的与前面的丢失更新中的第一种情况并不是一回事,特定的实现方式与不合理的并发控制可能会导致覆盖更新的发生,从而产生数据不一致。 我们看一个例子,扣减客户的账户余额,最简单的实现过程可能如下:

      cur_balance = service.queryBalance(...);
      if (cur_balance < subtract) {
          return;
    
      new_balance = cur_balance - subtract;
      service.updateBalance(new_balance);
    

    如果两个多事并发的扣减同一账户,就有可能出现问题,比如两个事务在更新前都查询出账户余额,那其中一次扣减将被覆盖。像类似这种以更新为目的的查询,需要在查询的时候对数据加上排它锁,以保证在事务并发的时候不会出现数据一致性问题。

    当然,以排它锁的方式可以避免覆盖更新的发生,但是这种处理方式对性能是有一定的影响的;另一种可行的办法是通过乐观锁来实现,乐观锁并不会真正锁定记录,而是在更新的时候检查数据在本次事务过程中是否有被其它事务修改过,如果是的话则放弃本次事务所做的修改。

    悲观锁(加排它锁)与乐观锁都可以解决覆盖更新的问题,一种是通过对数据行加锁的方式,在并发发生的时候会出现等待导致响应过慢的问题;另一种是通过在更新时检查数据是否有变动的方式,响应性较高,但在并发发生时需要提供额外的失败重试机制。

知道了事务并发会产生哪些问题,那如何避免这些问题的发生呢?


3. 封锁协议

解决事务并发问题,是通过指定隔离级别来实现的,为了更好的理解隔离级别,我们先了解一下封锁协议。

数据库中有两种最基本的锁:共享锁(读锁S)与排它锁(写锁X)。

  • 读锁:若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放S锁。

  • 写锁:若事务T对数据对象A加上X锁,则只允许T读取和修改A,其他事务不能再对A加任何类型的锁,直到T释放A上的X锁。

封锁协议一共分三级:

  • 一级封锁协议

    事务T再修改数据R之前,必须先对其加X锁,直到事务结束才释放。

    由定义可知,一级封锁协议可以防止丢失更新。

  • 二级封锁协议

    在一级封锁协议的基础上,事务T在读取数据R之前,必须先对其加上S锁,读完之后立即释放S锁。

    由定义可知,二级封锁协议除了能防止丢失更新,还可以防止脏读。

  • 三级封锁协议

    在一级封锁协议的基础上,事务T在读取数据R之前,必须先对其加上S锁,直到事务结束后才释放。

    由定义可知,三级封锁协议可以防止丢失更新、脏读、不可重复读。

封锁协议并不能解决幻读的问题,后续会介绍如何避免幻读的发生。 另外,封锁协议只是数据库内部实现事务并发控制的一种理论机制,我们无法在程序中使用。在程序开发中,是通过指定事务的隔离级别来解决并发的各种问题的。


4. 隔离级别

有四种隔离级别,无论哪一种都不会出现丢失更新,因为四种隔离级别都要求在更新数据对象前先要对数据加X锁,直到事务结束后才释放,即一级封锁协议。

  • Read Uncommitted读未提交

    相当于一级封锁协议,脏读、不可重复读、幻读等并发问题都会发生,但并发性能最好。

  • Read Committed读已提交

    相当于二级封锁协议,能避免脏读,但不可重复读、幻读发生,并发性能很好。大部分数据库的默认隔离级别。

  • Repeatable Read可重复读

    相当于三级封锁协议,能避免脏读和不可重复读,但幻读会发生,并发性能会受到小幅影响。

    MySQL InnoDB的默认隔离级别,并且InnoDB在该隔离级别下不会发生幻读(原因后续详细说明)。

  • Serializable序列化

    能避免脏读、不可重复读、幻读,并发性能很差,生产环境几乎不可能使用该级别。


5. MySQL InnoDB的隔离级别

5.1 MySQL InnoDB的可重复读级别

由于Mysql InnoDB对隔离级别的实现方式比较特殊,我们先以可重复读来举例说明一下。

首先,我们创建一张employee员工表,如下:

create table employee (
    `id`        int         not null,
    num         int         not null,
    depart      int         not null,
    name        varchar(20) not null,
    primary key (id),
    unique key (num),
    key (depart)
)engine=innodb;

其中,id表示记录主键;num表示员工工号,唯一索引;depart表示员工所在的部门编号,普通索引;name表示员工姓名。测试前插入一些测试数据。

+----+------+--------+--------+
| id | num  | depart | name   |
+----+------+--------+--------+
| 10 | 1010 |   5100 | 张三   |
| 20 | 1020 |   5200 | 李四   |
| 30 | 1030 |   5300 | 王五   |
| 40 | 1040 |   5100 | 刘大   |
+----+------+--------+--------+

首先事务S1在可重复读级别下,以条件id = 10查询员工表,如下:

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from employee where id = 10;
+----+------+--------+--------+
| id | num  | depart | name   |
+----+------+--------+--------+
| 10 | 1010 |   5100 | 张三   |
+----+------+--------+--------+
1 row in set (0.00 sec)

接着事务S2尝试更新id = 10的员工记录,如下:

mysql> set autocommit = false;
Query OK, 0 rows affected (0.00 sec)

mysql> update employee set name = '张三2' where id = 10;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

可以看到事务S2成功的更新了id = 10的记录,现在回到事务S1中再次以条件id = 10查询员工表,

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from employee where id = 10;
+----+------+--------+--------+
| id | num  | depart | name   |
+----+------+--------+--------+
| 10 | 1010 |   5100 | 张三   |
+----+------+--------+--------+
1 row in set (0.00 sec)

mysql> select * from employee where id = 10;
+----+------+--------+--------+
| id | num  | depart | name   |
+----+------+--------+--------+
| 10 | 1010 |   5100 | 张三   |
+----+------+--------+--------+
1 row in set (0.00 sec)

可以到事务S1第二次查询返回的结果与第一次完全相同,并没有发生不可重复读的问题,但是在事务S1的两次查询之间,事务S2却成功的修改了同一条记录,这个现象与三级封锁协议的定义是不一致的,我们回顾一下三级封锁协议的定义: 在一级封锁协议的基础上,事务T在读取数据R之前,必须先对其加上S锁,直到事务结束后才释放,换句话说,三级封锁协议避免不可重复读是一种阻塞式的方式,即当前事务读取数据后即加上S锁,别的事务要更新同一记录必须等当前事务提交或回滚。通过上面的示例看到,MySQL InnoDB显然不是这样的方式实现的。

MySQL InnoDB使用MVCC(Multi-Version Concurrent Controll多版本并发控制)的技术来实现非阻塞式读,在这个模式下,数据库会为每个数据记录维护多个版本。在可重复读隔离级别下,事务第一次查询记录的时候,会记录下一个时间点,在该事务内如果再次(可是以不同的SELECT)查询相同的数据的话,事务只会取上一次查询的记录版本,这样在不需要对数据加锁的情况下就实现了可重复读的隔离级别了,而且并发性能更好。在同一事务内多次查询同一数据,也不是就返回一个固定的记录版本,如果事务先查询了某个记录,随后自己又更新了这个数据,等再查询该数据的话,返回的就是自己更新过后的数据版本了。

虽然MySQL InnoDB使用MVCC的方式巧秒的实现了可重复读,但是在笔者看来这种方式并没有实际的意义,因为它无法解决真正需要避免不可重复读的业务场景,比如本文中介绍事务并发问题时关于客户账户的问题。要想真正避免不可重复读的并发问题,必须在查询的时候以lock in share mode的方式显示的加锁。

MySQL InnoDB是特殊的,在可重复读级别下,还能避免幻读的发生。但是存在与不可重复读类似的问题,它无法解决实际的业务问题。

我们接着之前的示例看看幻读的情况。首先事务S3以条件depart = 5100查询员工表,

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from employee where depart = 5100;
+----+------+--------+---------+
| id | num  | depart | name    |
+----+------+--------+---------+
| 10 | 1010 |   5100 | 张三2   |
| 40 | 1040 |   5100 | 刘大    |
+----+------+--------+---------+
2 rows in set (0.00 sec)

接着事务4尝试向depart = 5100这个部门添加一名新的员工,

mysql> set autocommit = false;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into employee values (50, 1050, 5100, '赵小');
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

回到事务S3中再次以条件depart = 5100查询员工表,

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from employee where depart = 5100;
+----+------+--------+---------+
| id | num  | depart | name    |
+----+------+--------+---------+
| 10 | 1010 |   5100 | 张三2   |
| 40 | 1040 |   5100 | 刘大    |
+----+------+--------+---------+
2 rows in set (0.00 sec)

mysql> select * from employee where depart = 5100;
+----+------+--------+---------+
| id | num  | depart | name    |
+----+------+--------+---------+
| 10 | 1010 |   5100 | 张三2   |
| 40 | 1040 |   5100 | 刘大    |
+----+------+--------+---------+
2 rows in set (0.00 sec)

mysql> rollback;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from employee where depart = 5100;
+----+------+--------+---------+
| id | num  | depart | name    |
+----+------+--------+---------+
| 10 | 1010 |   5100 | 张三2   |
| 40 | 1040 |   5100 | 刘大    |
| 50 | 1050 |   5100 | 赵小    |
+----+------+--------+---------+
3 rows in set (0.00 sec)

可以到事务S3第二次查询返回的结果与第一次完全相同,并没有发生幻读的问题,但是在事务S3的两次查询之间,事务S4却成功的创建了一条depart = 5100的记录(事务S3退出后再次查询可以看到id = 50的记录确实被S4创建成功)。

所以,在默认情况下,MySQL InnoDB在可重复读级别下以非阻塞式的方式,(仅仅从查询结果看)避免了不可重复读幻读的问题,但是却不能真正解决实际的业务发并问题。要想真正避免并发问题,必须在查询的时候以lock in share mode的方式显示的加锁。

5.2 MySQL InnoDB的Serializable级别

既然在可重复读级别下,InnoDB已经可以避免脏读、不可重复读与幻读了,那Serializable级别存在的意义何在呢?在上面的例子中,事务S4插入id = 50这条记录后,事务S3并不会发现,而当它尝试插入相同的记录时,却会发生主键重复的错误。而在Serializable级别下,就不会发生这样的情况:

Serializable级别下重试之前的示例,事务S3以条件depart = 5100查询员工表,

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from employee where depart = 5100;
+----+------+--------+---------+
| id | num  | depart | name    |
+----+------+--------+---------+
| 10 | 1010 |   5100 | 张三2   |
| 40 | 1040 |   5100 | 刘大    |
+----+------+--------+---------+
2 rows in set (0.00 sec)

接着事务S4尝试向depart = 5100这个部门添加一名新的员工,

mysql> set autocommit = false;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into employee values (50, 1050, 5100, '赵小');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

可以看到,事务S4并不能成功的插入id = 50这条记录(事实上在该例中,depart字段值小于5200的记录都无法插入),事务S3也不会发生可重复读级别下主键重复那样的问题了,而具体的原因是因为在Serializable级别下,depart索引被加了间隙锁。

关于InnoDB的锁机制,请参考笔者的另一篇文章MySQL InnoDB锁机制