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事务,是分两阶段提交的。具体可以参考我之前专门写的关于分布式事务的文章。里面介绍了多种分布式事务的方案。文章: 分布式事务 。
参考资料:
微信分享/微信扫码阅读