37DATA

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方法,那么这种情况就构成了事务嵌套。

       举个简单的使用场景:在一个活动中,用户通过某些途径领取了积分,并且达到兑换要求直接用这些积分去兑换礼品,这个时候我们的逻辑涉及到两个操作:

  1. 领取积分

  2. 扣除用户积分发放礼品

       我们用伪代码来看看逻辑层的这部分内容:

/*扣除用户积分发放礼品*/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,此时还没有savepoint
DB::beginTransaction();
try{
//子事务 $this->transcation = 2,此时savepoint = 2
DB::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一样,统一事务的封装,即使在其他地方使用,只要调用的类是同一个,便不怕出现事务嵌套。