MySQL事务和锁

本文主要分为几大部分:

1、事务介绍;

2、锁;

3、嵌套事务;

4、分布式事务。

1、事务介绍

学过Mysql以及事务的,肯定都知道ACID,这里重点说I,隔离性。说之前,先说下原子性和一致性是通过Undo Log实现的。

事务的隔离性指的是一个事务通常来说对其他事务是不可见的。但只是通常,而不是我们所认为的一定是。因为这还要取决于存储引擎所采用的隔离级别。隔离级别主要有四种RU、RC、RR、SERIALIZABLE等。

1、RU

全称READ UNCOMMITED,是未提交读。意思就是事务可以读取其他未提交事务中的数据。这个引起的最大问题就是脏读。在事务未提交之前读到了数据,可能因为回滚,事务未成功执行。

2、RC

全称READ COMMITED,是提交读,一个事务只能读取另外一个事务已经提交的数据,事务提交之前的数据不可读。但这种隔离级别不可重复读,即在一个事务中读取一个数据,如果在这个事务执行期间,这个数据在不断地被其他数据修改,那么该事务中每次读到的事务都是修改之后的数据,即不可重复读。

3、RR

全称 REPEATABLE READ,可重复读。这也是Mysql的默认隔离级别。该隔离级别可以保证在同一个事务中多次读取的数据一定是一致的,不论该数据是否已经发生变化,这也就解决了上述所说的脏读。

但是呢,RR同样会存在另外一个问题,即幻读。所谓幻读就是在一个事务中,多次查询,在查询条件相同的情况下所获得的结果集是不同的,那么这种现象就是幻读。在RC隔离级别下肯定是存在这种问题的。而RR级别下,可在一定程度上解决幻读的问题,但不是完全的避免。

快照读是通过MVCC解决幻读,当前读是通过next-key lock(间隙锁+记录锁)锁实现的。

先说下当前读,就是update,insert,delete等操作,我们在事务执行时,会加上next-key lock,从而保证其他事务不可插入范围内的数据。

再看一下MVCC。

MVCC基本思想就是在每行后面加上两列。

DB_TRX_ID: 记录操作该数据事务的事务ID,是一个版本号,每开启一个事务就+1;

DB_ROLL_PTR: 指向上一个版本数据在undo log 里的位置指针;

在事务开启的时候,会创建一个READ VIEW,在事务开启时,READ VIEW生成一次,然后每次查询时都会到 undo log中查找事务开启时查询的数据,就算是中间有其他事务添加数据,也不会查询到。通过这种方式,保证了在一个事务中多次查询结果都是一样的。这个READ VIEW非常重要,锁的隔离级别就是通过 READ VIEW实现的,其内置了几个重要的字段,如创建该READ VIEW的事务id,最小事务id,当前活跃事务id列表,下一个可分配的最大事务id。如下图(从小林coding摘抄的):

下面是《高性能Mysql中》的介绍:

我们来演示一下MVCC解决幻读的现象:

先开启一个事务A,进行查询:

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

mysql> select count(*) from city where id > 10;
+----------+
| count(*) |
+----------+
|      334 |
+----------+
1 row in set (0.00 sec)

查询id大于10的,一共334条,事务此时不提交。我再开启一个事务B,进行插入操作。

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

mysql> insert into city(city_id,name,province_id) values(13201,"测试",110000);
Query OK, 1 row affected (0.02 sec)

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

此时事务A再次进行查询操作,获得的结果集和之前查询的完全一样。


mysql> select count(*) from city where id > 10;
+----------+
| count(*) |
+----------+
|      334 |
+----------+

可以看到,在同一个事务内,多次查询获取到的结果集是完全一样的。尽管在这期间有其他事务插入了数据。由此可得出,MVCC解决了快照读的幻读问题。

好,那幻读已经完全解决了吗?答案是否定的。

假如事务1和事务2,事务1照旧先查询,事务2进行数据插入,由于事务1保留了快照读,事务中每次读的数据都一致。现在我们提交事务2.然后在事务1中update之前在事务2中插入的事务,是OK的。

试验:

1、开始事务1:

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

mysql> select count(1) from rate_id_counter;
+----------+
| count(1) |
+----------+
|      648 |
+----------+
1 row in set (0.01 sec)

2、开启事务2,插入数据提交


mysql> begin;
Query OK, 0 rows affected (0.01 sec)

mysql> insert into rate_id_counter(`ctime`) values("2021-05-21 00:00:00");
Query OK, 1 row affected (0.01 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

3、在事务中再次查询。数据没有变化,这是快照读。

mysql> select count(1) from rate_id_counter;
+----------+
| count(1) |
+----------+
|      648 |
+----------+

4、接着在事务中,更新在事务2中插入的数据:

mysql> update rate_id_counter set ctime="2021-05-30 00:00:00" where id=10554;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select count(1) from rate_id_counter;
+----------+
| count(1) |
+----------+
|      649 |
+----------+
1 row in set (0.01 sec)

更新成功,查询数据也发生变化。

上述这种场景,可以通过在查询之前使用select * for update,这种就会加上了next-key lock,使得事务2插入数据会失败。

4、Serializable

串行化,这是最简单的一种级别,也是最低效的,一般情况下不会用的。它是事务串行化执行。类似将所有事务放到一个队列中,然后依次取出执行。

好了,说完Mysql的事务了,再说一下Mysql中的锁。

2、Mysql锁

1、锁的分类

基于锁的属性分类:共享锁、排他锁。

基于锁的粒度分类:表锁、行锁、记录锁、间隙锁、临键锁。

基于锁的状态分类:意向共享锁、意向排它锁。

首先从属性说,共享锁和排他锁。

加上共享锁之后,对应行数据只能读,不能修改。当然共享锁可重复加。

现在我开启一个事务并加上共享锁。

mysql> begin;select * from c_group where user_id=33 lock in share mode;
Query OK, 0 rows affected (0.01 sec)

+-------+---------+-----------+--------+
| id    | user_id | groupname | avatar |
+-------+---------+-----------+--------+
| 10016 |      33 | dwdaaa    | dwd    |
+-------+---------+-----------+--------+
1 row in set (0.00 sec)

我再开启一个事务,执行修改。

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

mysql> update c_group set groupname="dwd" where user_id=33;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

排他锁:排他锁就是我加上了,别人不可加任何的锁了,无论是共享锁还是排他锁。

我再开启一个事务:

mysql> begin;select * from c_group where user_id=33 for update;
Query OK, 0 rows affected (0.00 sec)

+-------+---------+-----------+--------+
| id    | user_id | groupname | avatar |
+-------+---------+-----------+--------+
| 10016 |      33 | dwd       | dwd    |
+-------+---------+-----------+--------+

这个开启了一个排他锁。

然后再打开一个事务:

ysql> begin;
Query OK, 0 rows affected (0.01 sec)

mysql> update c_group set groupname="dwd" where user_id=33;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql> select * from c_group where user_id=33 ;
+-------+---------+-----------+--------+
| id    | user_id | groupname | avatar |
+-------+---------+-----------+--------+
| 10016 |      33 | dwd       | dwd    |
+-------+---------+-----------+--------+
1 row in set (0.00 sec)

mysql> select * from c_group where user_id=33 lock in share mode;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

另外有一点需要说明以下,多个事务的update,会被Mysql按事务开始顺序串行化处理,当然肯定是commit之后。

如果按照锁的粒度,可以分为行锁,记录锁,间隙锁,临键锁等。

行锁的生效必须是通过索引查询的记录,否则不会生效。

记录锁是精准锁住一条记录,一般都过主键或者唯一索引锁住;

间隙锁是索引区间锁。它会把一个索引区间锁住,事务提交前,不会允许插入或删除,解决了幻读的问题。当然只在RR隔离级别有效。

上面说Mysql通过MVCC快照读解决幻读,但却没有完全避免。比如我现在开启一个事务A,然后开启一个事务B

事务B执行:

 select * from c_group where user_id>=123  and user_id<130;
+-------+---------+-----------+--------+
| id    | user_id | groupname | avatar |
+-------+---------+-----------+--------+
| 10019 |     123 | wdw       | qee    |
| 10022 |     124 | wdw       | qee    |
| 10024 |     125 | wdw       | qee    |
+-------+---------+-----------+--------+

然后事务A执行insert,commit

insert into c_group (`user_id`,`groupname`,`avatar`) values(126,"wdw","qee");

因为MVCC快照读,事务B读到的还是上述结果。

ysql> select * from c_group where user_id>=123  and user_id<130;
+-------+---------+-----------+--------+
| id    | user_id | groupname | avatar |
+-------+---------+-----------+--------+
| 10019 |     123 | wdw       | qee    |
| 10022 |     124 | wdw       | qee    |
| 10024 |     125 | wdw       | qee    |

好,事务B执行insert,插入事务A同样的数据。

mysql> insert into c_group (`user_id`,`groupname`,`avatar`) values(126,"wdw","qee");
ERROR 1062 (23000): Duplicate entry '126' for key 'user_id'

这就是幻读的一种。

一种解决方式就是使用间隙锁。下面方式就是一种间隙锁的方式。

SELECT * FROM child WHERE id > 100 FOR UPDATE;

在事务中查询直接用select for update,这样就加了间隙锁。加了它其他事务就没法修改了,就不会出现幻读了。

临键锁是记录锁和间隙锁的组合,既解决脏读,也解决幻读。

死锁

指的是两个或多个事务相互占用资源,都请求锁定对方占用的资源,这样导致恶性循环。

比如:

事务1:

start transaction;

update tablename set pub=1 where id=1;

update tablename set pub=1 where id=2;

commit

事务2:

start transaction;

update tablename set pub=1 where id=2;

update tablename set pub=1 where id=1;

commit

如果恰巧这两个事务都锁定了第一条语句,都要请求对方占用的资源,这样就陷入了死循环。

MySQL针对这种情况,制定了一种策略:将持有最小行即排它锁的事务进行回滚。

InnoDB默认是行锁的, 但行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住。

3、嵌套事务

Mysql本身是不支持事务的,感兴趣可以试验一下,就算你开了多个事务,也是按照一层处理。

mysql> select * from c_group;
+-------+---------+-----------------+--------------------------------------------+
| id    | user_id | groupname       | avatar                                     |
+-------+---------+-----------------+--------------------------------------------+
| 10008 |   10001 | PHP交流群       | uploads/avatar/20190109/5c358bcaa77e3.jpeg |
| 10009 |   10006 | 屌丝集中营      | uploads/avatar/20190109/5c358c05aa1cc.jpg  |
| 10010 |   10007 | 萌宠            | uploads/avatar/20190109/5c358c35a8043.jpg  |
| 10011 |     133 | dwdw            | dwd                                        |
| 10012 |   13113 | 海波 hehe a.a   | dwd                                        |
| 10016 |      -4 | dwd             | dwd                                        |
| 10017 |      12 | wdw             | qee                                        |
| 10019 |     123 | wdw             | qee                                        |
| 10022 |     124 | wdw             | qee                                        |
| 10024 |     125 | wdw             | qee                                        |
| 10026 |     126 | wdw             | qee                                        |
+-------+---------+-----------------+--------------------------------------------+
11 rows in set (0.01 sec)

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

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

mysql> delete from c_group where id=10016;
Query OK, 1 row affected (0.04 sec)

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

mysql> delete from c_group where id=10017;
Query OK, 1 row affected (0.01 sec)

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

mysql> select * from c_group;
+-------+---------+-----------------+--------------------------------------------+
| id    | user_id | groupname       | avatar                                     |
+-------+---------+-----------------+--------------------------------------------+
| 10008 |   10001 | PHP交流群       | uploads/avatar/20190109/5c358bcaa77e3.jpeg |
| 10009 |   10006 | 屌丝集中营      | uploads/avatar/20190109/5c358c05aa1cc.jpg  |
| 10010 |   10007 | 萌宠            | uploads/avatar/20190109/5c358c35a8043.jpg  |
| 10011 |     133 | dwdw            | dwd                                        |
| 10012 |   13113 | 海波 hehe a.a   | dwd                                        |
| 10019 |     123 | wdw             | qee                                        |
| 10022 |     124 | wdw             | qee                                        |
| 10024 |     125 | wdw             | qee                                        |
| 10026 |     126 | wdw             | qee                                        |
+-------+---------+-----------------+--------------------------------------------+
9 rows in set (0.00 sec)

看上面的结果,如果按照我们认为的嵌套事务,外层回滚了,里层的也没有。实际结果是这里就根本没有外层和里层的概念。当第一次commit之后,整个事务就结束了,没有事务了。后面的delete,如果是autocommit,是默认又开启一个事务。

一般事务的嵌套在应用层实现了,比如著名的laravel,这是一个牛逼的框架,地为堪比JAVA届的spring。

laravel主要通过Mysql的savepoint实现的,因此要详细介绍laravel的嵌套事务时,就必须了解一下savepoint。

savepoint是在事务中设置的暂存点,设置后,如果回滚,可以选择性地回滚到某个暂存点。

mysql> select * from c_group;
+-------+---------+-----------------+--------------------------------------------+
| id    | user_id | groupname       | avatar                                     |
+-------+---------+-----------------+--------------------------------------------+
| 10008 |   10001 | PHP交流群       | uploads/avatar/20190109/5c358bcaa77e3.jpeg |
| 10009 |   10006 | 屌丝集中营      | uploads/avatar/20190109/5c358c05aa1cc.jpg  |
| 10010 |   10007 | 萌宠            | uploads/avatar/20190109/5c358c35a8043.jpg  |
| 10011 |     133 | dwdw            | dwd                                        |
| 10012 |   13113 | 海波 hehe a.a   | dwd                                        |
| 10019 |     123 | wdw             | qee                                        |
| 10022 |     124 | wdw             | qee                                        |
| 10024 |     125 | wdw             | qee                                        |
| 10026 |     126 | wdw             | qee                                        |
+-------+---------+-----------------+--------------------------------------------+
9 rows in set (0.00 sec)

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

mysql> update c_group set groupname="ff" where id=10019;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0


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

mysql> update c_group set groupname="sswdwd" where id=10019;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

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

mysql> update c_group set groupname="hhtt" where id=10019;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0


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

mysql> select * from c_group;
+-------+---------+-----------------+--------------------------------------------+
| id    | user_id | groupname       | avatar                                     |
+-------+---------+-----------------+--------------------------------------------+
| 10008 |   10001 | PHP交流群       | uploads/avatar/20190109/5c358bcaa77e3.jpeg |
| 10009 |   10006 | 屌丝集中营      | uploads/avatar/20190109/5c358c05aa1cc.jpg  |
| 10010 |   10007 | 萌宠            | uploads/avatar/20190109/5c358c35a8043.jpg  |
| 10011 |     133 | dwdw            | dwd                                        |
| 10012 |   13113 | 海波 hehe a.a   | dwd                                        |
| 10019 |     123 | sswdwd          | qee                                        |
| 10022 |     124 | wdw             | qee                                        |
| 10024 |     125 | wdw             | qee                                        |
| 10026 |     126 | wdw             | qee                                        |
+-------+---------+-----------------+--------------------------------------------+
9 rows in set (0.00 sec)


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

mysql> select * from c_group;
+-------+---------+-----------------+--------------------------------------------+
| id    | user_id | groupname       | avatar                                     |
+-------+---------+-----------------+--------------------------------------------+
| 10008 |   10001 | PHP交流群       | uploads/avatar/20190109/5c358bcaa77e3.jpeg |
| 10009 |   10006 | 屌丝集中营      | uploads/avatar/20190109/5c358c05aa1cc.jpg  |
| 10010 |   10007 | 萌宠            | uploads/avatar/20190109/5c358c35a8043.jpg  |
| 10011 |     133 | dwdw            | dwd                                        |
| 10012 |   13113 | 海波 hehe a.a   | dwd                                        |
| 10019 |     123 | ff              | qee                                        |
| 10022 |     124 | wdw             | qee                                        |
| 10024 |     125 | wdw             | qee                                        |
| 10026 |     126 | wdw             | qee                                        |
+-------+---------+-----------------+--------------------------------------------+
9 rows in set (0.00 sec)

mysql> commit;

接着看看laravel是怎么实现嵌套循环的。

它的基本思想就是,遇到一个事务,就会发起begin命令;之后的事务都不会再发起begin,并计数+1。如果计数不是0,就增加一个savepoint暂存点。如果是1,就直接执行commit操作,否则不做任何操作。如果遇到任何一层的rollback,都执行rollback命令。

可以看到,真正执行begin操作和commit操作都只是在最外层,里层只是增加事务暂存点,以便回滚的时候直接回滚。

看下代码。

Laravel执行beginTransction开启事务:

 public function beginTransaction()
    {
        $this->createTransaction();
        //事务数+1 
        $this->transactions++;

        $this->fireConnectionEvent('beganTransaction');
    }


 protected function createTransaction()
    {
        //当前连接第一个事务,开启事务
        if ($this->transactions == 0) {
            try {
                $this->getPdo()->beginTransaction();
            } catch (Exception $e) {
                $this->handleBeginTransactionException($e);
            }
        //创建暂存点
        } elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
            $this->createSavepoint();
        }
    }

  protected function createSavepoint()
    {
        $this->getPdo()->exec(
            $this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1))
        );
    }

接着看commit:

 public function commit()
    {
       //只有最外层的事务才会执行真正的commit操作
        if ($this->transactions == 1) {
            $this->getPdo()->commit();
        }
        //里层的就是减1
        $this->transactions = max(0, $this->transactions - 1);

        $this->fireConnectionEvent('committed');
    }

再看rollback:

 public function rollBack($toLevel = null)
    {
        // We allow developers to rollback to a certain transaction level. We will verify
        // that this given transaction level is valid before attempting to rollback to
        // that level. If it's not we will just return out and not attempt anything.
        $toLevel = is_null($toLevel)
                    ? $this->transactions - 1
                    : $toLevel;

        if ($toLevel < 0 || $toLevel >= $this->transactions) {
            return;
        }

        // Next, we will actually perform this rollback within this database and fire the
        // rollback event. We will also set the current transaction level to the given
        // level that was passed into this method so it will be right from here out.
        $this->performRollBack($toLevel);

        $this->transactions = $toLevel;

        $this->fireConnectionEvent('rollingBack');
    }


  protected function performRollBack($toLevel)
    {
        if ($toLevel == 0) {
            $this->getPdo()->rollBack();
         //跳到某个暂存点
        } elseif ($this->queryGrammar->supportsSavepoints()) {
            $this->getPdo()->exec(
                $this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1))
            );
        }
    }

上面的回滚操作还可以选择回滚到哪个事务,如果不选择,默认向前回滚。上面的toLevel就是表示层级。

其实spring实现嵌套事务的基本思想也是一致的。当然,spring的事务管理更加复杂,实现的功能也更多。spring的事务管理是通过AOP代理实现的。它通过事务传播的方式,来实现不同场景的事务要求。多个子事务可以保持在一个事务中,也可以新建事务。最终会生成我们希望的sql来去执行。

在注解上,可以定义传播方式。@Transactional(propagation = Propagation.XXXX)

事务传播行为类型 说明
PROPAGATION_REQUIRED 如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。这是最常见的选择。
PROPAGATION_SUPPORTS 支持当前事务,如果当前没有事务,就以非事务方式执行。
PROPAGATION_MANDATORY 使用当前的事务,如果当前没有事务,就抛出异常。
PROPAGATION_REQUIRES_NEW 新建事务,如果当前存在事务,把当前事务挂起。
PROPAGATION_NOT_SUPPORTED 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
PROPAGATION_NEVER 以非事务方式执行,如果当前存在事务,则抛出异常。
PROPAGATION_NESTED 如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。

其实最重要的用的最多的就是第一个REQUIRED,NESTED,NEW。

第一个很好理解,就是所有的都在一个事务中进行。

NESTED的就是嵌套事务,当然和laravel一样,通过savepoint实现。它会保证内层事务回滚到savepoint点。外层事务正常提交。

NEW的意思是重新开启一个事务。

现在,我们实验一下,他的嵌套事务。

@Override
    @Transactional()
    public void testTrans1(){
        HbnnUser hbnnUser = new HbnnUser();
        hbnnUser.setPassword("dwd");
        hbnnUser.setUid(2421L);
        hbnnUser.setOpenId(22);
        hbnnUser.setPhone("333");
        hbnnUser.setUserName("dwdwdd");
        userMapper.insert(hbnnUser);
        accountService.testTrans2();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void testTrans2(){
        HbnnUser hbnnUser = new HbnnUser();
        hbnnUser.setPassword("dwd");
        hbnnUser.setUid(242L);
        try {
            userMapper.insert(hbnnUser);
        }catch (Exception e){
           //主动回滚
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
    }

上面的是默认REQUIRED传播行为,两个都在一个事务内。所以testTrans2 rollback后,testtrans1不会正常提交。

现在我们把testTrans2改成Nested传播模式。

 @Override
    @Transactional(rollbackFor = Exception.class,propagation = Propagation.NESTED)
    public void testTrans2(){
        HbnnUser hbnnUser = new HbnnUser();
        hbnnUser.setPassword("dwd");
        hbnnUser.setUid(242L);
        try {
            userMapper.insert(hbnnUser);
        }catch (Exception e){
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
    }

testTrans2回滚,并不会影响第一个事务的正常提交。

注意:上面需要try catch,如果不try catch ,那么抛出Exceptio你,任何一个事务都会回滚。

之前也写过一篇文章,但都是简单的使用: springboot事务

嵌套事务还是非常实用的,在很多场景下,我们都需要实用嵌套事务来实现业务数据的一致性。

4、分布式事务

目前所说的Mysql事务都是在一个存储引擎下的。当我们需要跨引擎或者跨库的事务时,就需要分布式事务了。Mysql内部有一个叫XA事务,是分两阶段提交的。具体可以参考我之前专门写的关于分布式事务的文章。里面介绍了多种分布式事务的方案。文章: 分布式事务

参考资料:

Mysql InnoDB锁官网

Mysql事务隔离级别

InnoDB的MVCC实现原理

一张图彻底搞懂Mysql的锁

彻底搞懂Mysql事务的隔离级别

详细分析Mysql的undo log

--------EOF---------
微信分享/微信扫码阅读