Laravel如何优雅的解决MySQL的事务嵌套
前言
1、什么是嵌套事务
2、可能产生场景:
3、savepoint来保障嵌套事务的CAID
4、Laravel框架是如何解决嵌套事务的
5、总结
前言
说这个问题之前,首先给没用过Laraverl的PHPer们简单的科普一下这个框架。Laravel是一个优秀的PHP框架,也被称为艺术家的框架,在国内外都深受PHP开发者的喜爱(在Github上的PHP框架中星星排名第一)。它的底层优雅的实现了很多开发必备的功能,使用了多种设计模式,富有表现力的接口让你一眼就看得出代码是在做什么。包括令程序员解放双手的ORM框架,定时任务以及调度任务,以及拥有丰富的开发者和社区交流平台。
英文文档:
https://laravel.com/
中文文档:
https://learnku.com/docs/laravel/8.x
感兴趣的同事可以多去阅读阅读Laravel框架的源码,能够学到很多的东西,好了废话不多说,进入正题。
1、什么是嵌套事务
顾名思义,嵌套事务是指在已经开了一层事务的前提下,又开了另一个事务,并且上一事务没有提交或回滚,这个时候,上一层的事务会被自动提交。
参考mysql官网:
https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html
官网中有一句话是这么说的:
Most of these statements also cause an implicit commit after executing. The intent is to handle each such statement in its own special transaction. Transaction-control and locking statements are exceptions: If an implicit commit occurs before execution, another does not occur after.
大概的意思是,mysql是不支持事务嵌套的,如果你嵌套执行事务的话,会隐式执行提交操作,语句也不能回滚,这意味着使用此类语句会导致事务原子性被侵犯。
举个例子,我们先创建一张测试表:
transcation_test
CREATE TABLE `transcation_test` (`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,`sex` char(2) COLLATE utf8mb4_unicode_ci NOT NULL,PRIMARY KEY (`name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
往这张表里插入一条数据,并且使用事务:
BEGIN;INSERT INTO `transcation_test` VALUES('Jasper','男');ROLLBACK;
正常的来说是这条数据不会插入,因为被我rollback了,我们执行下查询语句:
SELECT * FROM `transcation_test`;//结果符合我们的预期:Empty set (0.00 sec)
那如果我们加上多一层的事务:
BEGIN;INSERT INTO `transcation_test` VALUES('Jasper','男');BEGIN;ROLLBACK;
再执行查询,会发现,插入的这条数据并没有被rollback,而是被成功的插进表里了。
+--------+-----+| name | sex |+--------+-----+| Jasper | 男 |+--------+-----+1 row in set (0.00 sec)
这也的确说明了,Mysql是不支持事务嵌套的,在实际业务场景中,有时候我们总会避不可少的使用事务嵌套,那这个时候,应该如何避免这种事务隐性提交的发生,真正做到数据的一致性呢?
2、可能产生场景:
嵌套事务一般的使用场景是一些公用的,最小单元的业务逻辑,这些业务逻辑很多情况下都是被另外一些更加复杂,更加完整的业务逻辑调用。而嵌套事务还是会在很多地方产生的,例如您调用的方法处于某个公用包内,这个包里面包含了事务的操作,而对外面调用包的开发者也需要用到事务,相对而言,改包显然是不可能的,这个时候如果程序不做合理的处理,事务嵌套必然会产生。假设我们的程序中,有同时两个方法都涉及到多个插入或更新数据库的操作,这种例子很常见,为了数据的一致性使用了事务,并且A方法调用了B方法,那么这种情况就构成了事务嵌套。
举个简单的使用场景:在一个活动中,用户通过某些途径领取了积分,并且达到兑换要求直接用这些积分去兑换礼品,这个时候我们的逻辑涉及到两个操作:
领取积分
扣除用户积分发放礼品
我们用伪代码来看看逻辑层的这部分内容:
/*扣除用户积分发放礼品*/public function giveOutGifts(){try{DB::beginTransaction(); //开启事务FlowRecord::save(); //保存流水User::decPoint(); //用户减少积分Gift::Receive(); //领取礼品DB::commit();}catch (\Exception $e) {DB::rollBack();return $e->getMessage()}}/*领取积分@param bool $directRelease 是否直接发放礼品*/public function collectPoints($directRelease=false){try{DB::beginTransaction(); //开启事务FlowRecord::save(); //保存流水User::incPoint(); //用户添加积分if($directRelease){$this->giveOutGifts(); //发放礼品}DB::commit();}catch (\Exception $e) {DB::rollBack();return $e->getMessage()}}
那么控制器的调用应该如下:
public function receiveAndGiveOutGifts(Request $request){$this->checkParam(); //校验参数//调用服务层完成逻辑$this->service->collectPoints(true);}
在这种需要高度保持一致性的逻辑中,如果没有做事务嵌套的处理,假如中间有某些步骤失败,那么会导致回滚失败,破坏事务的原子性。
3、savepoint来保障嵌套事务的CAID
在Mysql中,支持通过 SAVEPOINT 来设置事务保存点,就像我们打单机游戏的时候的存档一样,存个档,下次复活的时候,就能回到存档的地方,savepoint只能在InnoDB中使用,与之相配套的关键字还有 ROLLBACK TO SAVEPOINT和 RELEASE SAVEPOINT,他们的语法如下:
SAVEPOINT identifierROLLBACK [WORK] TO [SAVEPOINT] identifierRELEASE SAVEPOINT identifier
SAVEPOINT:语句设置名称为identifier命名的事务保存点 。如果当前事务有同名的保存点,则删除旧的保存点并设置新的保存点。
ROLLBACK WORK TO SAVEPOINT:语句将事务回滚到指定的保存点而不终止事务。在设置保存点之后当前事务对行所做的修改在回滚中被撤消,但InnoDB不会释放保存点之后存储在内存中的行锁。(对于新插入的行,锁信息由行中存储的事务ID携带;锁不单独存储在内存中。这种情况下,行锁将在undo log中被释放。)比保存点晚删除。
RELEASE SAVEPOINT:语句从当前事务的保存点集中删除命名的保存点。不会发生提交或回滚。如果保存点不存在,则会出现错误。
再来看回刚才的测试例子,现在我们利用savepoint来模拟事务嵌套:
BEGIN;SAVEPOINT for_kiki;INSERT INTO `transcation_test` VALUES('KiKi','女');SAVEPOINT for_kevin;INSERT INTO `transcation_test` VALUES('Kevin Drunt','男');ROLLBACK TO for_kevin;COMMIT;
解读下SQL。首先开启一个事务,并且创建一个保存点叫 for_kiki,然后插入一条数据,再创建一个保存点for_kevin(注意,假如这是在您的程序逻辑中,for_kevin这个savepoint即相当于开启了一个新的事务,这也是程序中避免嵌套事务的一个重要的地方)。这个时候,再插入一条数据并且Rollback 这个查询执行结果如下:
mysql> select * from transcation_test ;+--------+-----+| name | sex |+--------+-----+| Jasper | 男 || KiKi | 女 |+--------+-----+2 rows in set (0.00 sec)
4、Laravel框架是如何解决嵌套事务的
还是回到我们刚才的transcation_test 表,来个调用示例:
DB::beginTransaction(); //主事务try{DB::beginTransaction(); //子事务DB::insert("insert into base_transcation_test values('Kevin Durant','男')");DB::rollBack(); //子事务回滚DB::insert("insert into base_transcation_test values('Taylor Swift','女')");DB::commit();}catch (\Exception $e) {DB::rollBack();return $e->getMessage();}
查看执行结果:
+--------------+-----+| name | sex |+--------------+-----+| Jasper | 男 || Taylor Swift | 女 |+--------------+-----+
会发现 Kevin Durant 并没有被插进去,说明子事务成功回滚了,我们来看看他的源码,底层是如何实现的。看源码前,我们首先需要知道看源码主要看什么,看哪里。我们知道了事务的三步操作无非就是:begin、rollback、commit,所以找源码时也是往这三个方法找。
源码路径:
vendor/laravel/framework/src/Illuminate/Database/Concerns/ManagesTransactions.php
/*** Start a new database transaction.** @return void** @throws \Throwable*/public function beginTransaction(){$this->createTransaction();$this->transactions++;optional($this->transactionsManager)->begin($this->getName(), $this->transactions);$this->fireConnectionEvent('beganTransaction');}
每开启一次事务会先调用 $this->createTransaction() 方法,假如大于两层事务,则创建一个回滚点savepoint,后$this->transactions属性+1:
protected function createTransaction(){//判断是否在事务中if ($this->transactions == 0) {$this->reconnectIfMissingConnection();try {$this->getPdo()->beginTransaction();} catch (Throwable $e) {$this->handleBeginTransactionException($e);}} elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {$this->createSavepoint();}}
这一段代码主要是判断是否在事务中,如果不在则实行开启事务操作,如果在,则调用创建保存点的操作:$this-createSavepoint():
protected function createSavepoint(){$this->getPdo()->exec($this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1)));}
接下来看看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.try {$this->performRollBack($toLevel);} catch (Throwable $e) {$this->handleRollBackException($e);}$this->transactions = $toLevel;optional($this->transactionsManager)->rollback($this->getName(), $this->transactions);$this->fireConnectionEvent('rollingBack');}
参数$toLevel是指定要回滚到哪个事务级别,如果为null,则调用$this->performRollBack()回滚上一个级别的事务,如果不为null,则会使$this->transactions属性-1。回滚完成后,将$this->transactions置为当前的回滚级别。
调用$this->performRollBack():
//执行数据库回滚protected function performRollBack($toLevel){if ($toLevel == 0) {$this->getPdo()->rollBack();} elseif ($this->queryGrammar->supportsSavepoints()) {$this->getPdo()->exec(//回滚指定级别的回滚点$this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1)));}}
最后看看commit方法:
public function commit(){if ($this->transactions == 1) {$this->getPdo()->commit();}$this->transactions = max(0, $this->transactions - 1);if ($this->transactions == 0) {optional($this->transactionsManager)->commit($this->getName());}$this->fireConnectionEvent('committed');}
实际上就是只有在最外层的事务,才会被调用提交,总结一下刚刚的例子:
//主事务 $this->transcation = 1,此时还没有savepointDB::beginTransaction();try{//子事务 $this->transcation = 2,此时savepoint = 2DB::beginTransaction();DB::insert("insert into base_transcation_test values('Kevin Durant','男')");//这时子事务回滚 $this->transcation = 2-1 = 1,调用执行回滚点的操作,回滚savepoint=2的操作DB::rollBack();DB::insert("insert into base_transcation_test values('Taylor Swift','女')");//$this->transcation=1,在最外层,可以提交DB::commit();}catch (\Exception $e) {DB::rollBack();return $e->getMessage();}
注意:savepoint保存点并不是所有的数据库都支持的,常见支持savepoint的有Mysql、PolarDB、Postgresql。像SQLite或者非关系型数据库则不支持
5、总结
其实Laravel框架实现嵌套事务的原理也很简单,利用savepoint来回滚某个事务的操作,根据事务的数量计算需要回滚或者到达哪个回滚点,并且只有在最后一个commit时才会真正提交请求。在我们的程序中,如果出现嵌套事务的情况,当然也需要在程序方面做一些处理,避免造成一些事故和损失。特别是在使用公共包的时候,要多加注意是否可能会产生嵌套事务,如果像laravel一样,统一事务的封装,即使在其他地方使用,只要调用的类是同一个,便不怕出现事务嵌套。