Yii2事务之:复杂业务场景下的跨库事务支持

业务场景描述

假如有这样一个场景:

  1. 项目说大不大,说小也不小。
  2. 操作A系统的时候,想修改B系统相关联的业务。
  3. 项目工期紧张,系统之间写接口来实现互相调用又比较耗时或者框架微服务化代价又太大。

这个时候,单纯从代码量来衡量的话,直连系统A和系统B的数据库直接进行操作是最简单也是最快速实现的。但这个时候,就暴露了一个问题:如何实现复杂业务场景下多数据库的事务的ACID?
注意:这里所说的多数据库是指不同数据解构的数据库

理解本文所需要的知识点(以mysql为例)

  1. 熟知mysql数据库的事务嵌套
  2. 熟知事务是如何创建和提交保存点的。

举个例子:

<?php

/**
 * 系统A的业务实现类,操作的是db_1数据库
 * Class SystemA
 */
class SystemA
{
    public function test1()
    {

        try {
            $tr = $db_1->beginTransaction();
            //code....
            $this->test2();
            //code....
            (new SystemB)->test1();
            //code....
            $this->test3();
            //code....
            $tr->commit();

        } catch (\Exception $e) {
            if ($tr) {
                $tr->rollBack();
            }
        }

    }

    public function test2()
    {
        try {
            $tr = $db_1->beginTransaction();
            //code....
            $tr->commit();
            return true;

        } catch (\Exception $e) {
            if ($tr) {
                $tr->rollBack();
            }
            throw $e;

        }
    }

    

}


/**
 * 系统B的业务实现类,操作的是db_2数据库
 * Class SystemA
 */
class SystemB
{
    public function test1()
    {

        try {
            $tr = $db_2->beginTransaction();
            //code....
            $this->test2();
            //code....
            $this->test3();
            //code....
            $tr->commit();

        } catch (\Exception $e) {
            if ($tr) {
                $tr->rollBack();
            }
        }


    }

    public function test2()
    {
        try {
            $tr = $db_2->beginTransaction();
            //code....
            $tr->commit();
            return true;

        } catch (\Exception $e) {
            if ($tr) {
                $tr->rollBack();
            }
            throw $e;

        }
    }

    public function test3()
    {
        try {
            $tr = $db_2->beginTransaction();
            //code....
            $tr->commit();
            return true;

        } catch (\Exception $e) {
            if ($tr) {
                $tr->rollBack();
            }
            throw $e;
        }
    }
    
}

$test=new SystemA();
$test->test1();

代码中,

  1. 调用SystemA::test1()函数首先开启了事务;
  2. SystemA::test1()中既调用了本类的SystemA::test2()和SystemA::test3();也调用了SystemB::test1();
  3. 并且,SystemA和SystemB中的所有函数都分别开启了一次事务;

以上代码模拟了一个比较复杂业务嵌套,当然,这种嵌套逻辑在实际的业务场景中并不多见,一切以学习为目的。

那么,问题来了,对于这种业务场景,mysql怎么做的呢?

实际上,数据库只是在一开始调用SystemA::test1()的时候,执行了开启A系统的数据库事务的命令:

START TRANSACTION;

在这之后,程序中再执行开启A系统数据库事务的命令的时候,只是在原来得事务上执行了一个创建事务保存点的命令:

#point_name名称可以自定义
savepoint point_name;

如果遇到子事务回滚的话,只是回滚到刚才的事务保存点

#point_name名称可以自定义
rollback to point_name;

当程序执行到(new SystemB)->test1();的时候,又会在B系统的数据库中执行一个开启事务的命令:

START TRANSACTION;

随后,在B系统内,其他的函数再开启事务的话,同A库一样,不会再重新开启事务,只是创建一个事务保存点。遇到子事务回滚的话,同样只是回滚到刚才创建的的事务保存点。

也就是说,本业务场景涉及到了两个不同得数据库,mysql会在两个不同得数据库中都会开启一次事务,并且每个数据库中不会再第二次开启事务,而是遇到子事务的时候创建一个事务保存点,回滚的时候回滚到事务保存点。当两个系统的所有的事务都执行成功了的话,执行到最后的commit的时候,会释放掉所有事务,并且,这个时候会同时提交两个系统的事务,也就是说,最后一次commit会在A和B两个库分别执行commit操作。

那么,通过研究发现,Yii2 2.0版本在数据库事务的支持上只是对单数据库的嵌套事务做了支持,不支持多数据库事务嵌套操作。

改造Yii2事务机制

首先我们看下Yii2 是怎么做的,摘抄部分yii\db\Transaction的代码:

<?php
class Transaction extends \yii\base\BaseObject
{

    //code ...

    
    /**
     * @var Connection the database connection that this transaction is associated with.
     */
    public $db;

    /**
     * @var int the nesting level of the transaction. 0 means the outermost level.
     */
    private $_level = 0;

    //code ...

    /**
     * @param null $isolationLevel
     * @throws InvalidConfigException
     */
    public function begin($isolationLevel = null)
    {
        if ($this->db === null) {
            throw new InvalidConfigException('Transaction::db must be set.');
        }
        $this->db->open();

        if ($this->_level === 0) {
            if ($isolationLevel !== null) {
                $this->db->getSchema()->setTransactionIsolationLevel($isolationLevel);
            }
            Yii::debug('Begin transaction' . ($isolationLevel ? ' with isolation level ' . $isolationLevel : ''), __METHOD__);

            $this->db->trigger(Connection::EVENT_BEGIN_TRANSACTION);
            $this->db->pdo->beginTransaction();
            $this->_level = 1;

            return;
        }

        $schema = $this->db->getSchema();
        if ($schema->supportsSavepoint()) {
            Yii::debug('Set savepoint ' . $this->_level, __METHOD__);
            $schema->createSavepoint('LEVEL' . $this->_level);
        } else {
            Yii::info('Transaction not started: nested transaction not supported', __METHOD__);
        }
        $this->_level++;
    }

    /**
     * @throws Exception
     */
    public function commit()
    {
        if (!$this->getIsActive()) {
            throw new Exception('Failed to commit transaction: transaction was inactive.');
        }

        $this->_level--;
        if ($this->_level === 0) {
            Yii::debug('Commit transaction', __METHOD__);
            $this->db->pdo->commit();
            $this->db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
            return;
        }

        $schema = $this->db->getSchema();
        if ($schema->supportsSavepoint()) {
            Yii::debug('Release savepoint ' . $this->_level, __METHOD__);
            $schema->releaseSavepoint('LEVEL' . $this->_level);
        } else {
            Yii::info('Transaction not committed: nested transaction not supported', __METHOD__);
        }
    }

    /**
     * Rolls back a transaction.
     * @throws Exception if the transaction is not active
     */
    public function rollBack()
    {
        if (!$this->getIsActive()) {
            // do nothing if transaction is not active: this could be the transaction is committed
            // but the event handler to "commitTransaction" throw an exception
            return;
        }

        $this->_level--;
        if ($this->_level === 0) {
            Yii::debug('Roll back transaction', __METHOD__);
            $this->db->pdo->rollBack();
            $this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
            return;
        }

        $schema = $this->db->getSchema();
        if ($schema->supportsSavepoint()) {
            Yii::debug('Roll back to savepoint ' . $this->_level, __METHOD__);
            $schema->rollBackSavepoint('LEVEL' . $this->_level);
        } else {
            Yii::info('Transaction not rolled back: nested transaction not supported', __METHOD__);
            // throw an exception to fail the outer transaction
            throw new Exception('Roll back failed: nested transaction not supported.');
        }
    }

  
    //code ...
}

从代码中可以看到,事务类中只有一个public $db;变量和一个private $_level = 0; 变量,db记录当前的数据库连接,而_level则记录的是事务的保存点。

 /**
     * @var Connection the database connection that this transaction is associated with.
     */
    public $db;

    /**
     * @var int the nesting level of the transaction. 0 means the outermost level.
     */
    private $_level = 0;

根据注释得知,当$_level=0的时候,说明是最外层的事务。由此我们可以猜测到,当事务真正执行提交的时候,$_level是等于0的;不急,看一下提交事务的代码(只摘抄关键部分代码)

    public function commit()
    {
        //code ...
        $this->_level--;
        if ($this->_level === 0) {
            Yii::debug('Commit transaction', __METHOD__);
            $this->db->pdo->commit();
            $this->db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
            return;
        }
        //code ...
        }
    }
?>

从代码上获知,当$this->_level=0的时候执行了提交事务的命令,但是这个$this->_level是在什么时候被累加的呢?(只摘抄关键部分代码)

  public function begin($isolationLevel = null)
    {
       //code ...
        $this->db->open();

        if ($this->_level === 0) {

           //code ....

            $this->db->trigger(Connection::EVENT_BEGIN_TRANSACTION);
            $this->db->pdo->beginTransaction();
            $this->_level = 1;

            return;
        }

        //code ...

        $this->_level++;
    }

从这个begin($isolationLevel = null)函数得知,第一次开启事务的时候,执行了$this->db->pdo->beginTransaction();代码,并且将$this->_level设置为1,从此之后,如果继续开启事务的话,只是将$this->_level的值累加1。

那么我们继续看下回滚的代码:(只摘抄关键部分代码)

public function rollBack()
    {
        //code ...

        $this->_level--;
        if ($this->_level === 0) {
            Yii::debug('Roll back transaction', __METHOD__);
            $this->db->pdo->rollBack();
            $this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
            return;
        }

      //code ...
    }

以上可以看到,当我们执行回滚的时候,会将$this->_level--,当$this->_level = 0 的时候,才会执行真正的回滚。

OK,到此为止,我们根据查看Yii2事务类的实现逻辑,确定了他的实现逻辑和数据库的事务嵌套的实现逻辑是一致的,那实行我们之前所说的跨库事务改造就集中在这几个函数就可以了。

直接上代码,重写数据库事务类\yii\db\Transaction

<?php
namespace common\db;

use yii\base\InvalidConfigException;
use Yii;

/**
 * 重写yii2框架的事务处理,以便支持跨数据库事务,适合多个数据连接的时候,层层事务嵌套
 * Class Transaction
 * @package common\library\db
 */
class Transaction extends \yii\db\Transaction
{
    /**
     * 全局数据库事务等级
     * @var int
     */
    static $_level_global = 0;

    /**
     * 数据库连接池
     * @var array
     */
    static $_db_pool = [];

    /**
     * @var int the nesting level of the transaction. 0 means the outermost level.
     */
    private $_level = 0;


    /**
     * Returns a value indicating whether this transaction is active.
     * @return bool whether this transaction is active. Only an active transaction
     * can [[commit()]] or [[rollBack()]].
     */
    public function getIsActive()
    {
        return $this->_level > 0 && $this->db && $this->db->isActive;
    }

    /**
     * 重写父类的begin方法,是为了让本类支持跨数据库事务
     * @param null $isolationLevel
     * @throws InvalidConfigException
     */
    public function begin($isolationLevel = null)
    {
        if ($this->db === null) {
            throw new InvalidConfigException('Transaction::db must be set.');
        }
        $this->db->open();

        if ($this->_level == 0 and !$this->db->pdo->inTransaction()) {
            if ($isolationLevel !== null) {
                $this->db->getSchema()->setTransactionIsolationLevel($isolationLevel);
            }
            Yii::trace('Begin transaction' . ($isolationLevel ? ' with isolation level ' . $isolationLevel : ''), __METHOD__);

            $this->db->trigger(Connection::EVENT_BEGIN_TRANSACTION);
            $this->db->pdo->beginTransaction();
            $this->_level = 1;
            static::$_level_global++;
            return;
        }

        $schema = $this->db->getSchema();
        if ($schema->supportsSavepoint()) {
            Yii::trace('Set savepoint ' . $this->_level, __METHOD__);
            $schema->createSavepoint('LEVEL' . $this->_level);
        } else {
            Yii::info('Transaction not started: nested transaction not supported', __METHOD__);
        }
        $this->_level++;
        static::$_level_global++;
    }


    /**
     * 事务提交
     */
    public function commit()
    {
        if (!$this->getIsActive()) {

            //$this->db && ;
            throw new \yii\db\Exception('Failed to commit transaction: transaction was inactive.');
        }

        $this->_level--;
        if ($this->_level == 0) {

            if (!in_array($this->db, static::$_db_pool)) {
                static::$_db_pool[] = $this->db;
            }
        }

        static::$_level_global--;
        if (static::$_level_global == 0) {
            foreach (static::$_db_pool as $db) {
                Yii::trace('Commit transaction', __METHOD__);
                $db->pdo->commit();
                $db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
            }

            static::$_db_pool = [];
            return;
        }

        if ($this->_level == 0) {
            return;
        }

        $schema = $this->db->getSchema();
        if ($schema->supportsSavepoint()) {
            Yii::trace('Release savepoint ' . $this->_level, __METHOD__);
            $schema->releaseSavepoint('LEVEL' . $this->_level);
        } else {
            Yii::info('Transaction not committed: nested transaction not supported', __METHOD__);
        }
    }


    /**
     * 事务回滚
     */
    public function rollBack()
    {
        if (!$this->getIsActive()) {
            // do nothing if transaction is not active: this could be the transaction is committed
            // but the event handler to "commitTransaction" throw an exception
            return;
        }

        $this->_level--;
        static::$_level_global--;
        if ($this->_level == 0) {
            Yii::trace('Roll back transaction', __METHOD__);
            $this->db->pdo->rollBack();
            $this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);

            static::$_db_pool = [];//清空数据库对象
            return;
        }

        $schema = $this->db->getSchema();
        if ($schema->supportsSavepoint()) {
            Yii::trace('Roll back to savepoint ' . $this->_level, __METHOD__);
            $schema->rollBackSavepoint('LEVEL' . $this->_level);
        } else {
            Yii::info('Transaction not rolled back: nested transaction not supported', __METHOD__);
            // throw an exception to fail the outer transaction
            throw new \yii\db\Exception('Roll back failed: nested transaction not supported.');
        }
    }
}

要想让自定义事务类起作用,还需要将\yii\db\Connection类在同样的命名空间下重写:

<?php

namespace common\db;

/**
 * 为了让数据库支持跨库事务连接,重写开启事务方法
 * Class Connection
 * @package common\library\db
 */
class Connection extends \yii\db\Connection
{
    /**
     * 事务类
     * @var
     */
    private $_transaction;

    /**
     * Returns the currently active transaction.
     * @return Transaction|null the currently active transaction. Null if no active transaction.
     */
    public function getTransaction()
    {
        return $this->_transaction && $this->_transaction->getIsActive() ? $this->_transaction : null;
    }


    /**
     * 开启事务管理
     * @param null $isolationLevel
     * @return \yii\db\Transaction
     */
    public function beginTransaction($isolationLevel = null)
    {
        $this->open();

        if (($transaction = $this->getTransaction()) === null) {
            $transaction = $this->_transaction = new Transaction(['db' => $this]);
        }
        $transaction->begin($isolationLevel);

        return $transaction;
    }


    /**
     * Closes the currently active DB connection.
     * It does nothing if the connection is already closed.
     */
    public function close()
    {

        if ($this->pdo !== null) {
            $this->_transaction = null;
        }

        parent::close();
    }


    /**
     * Reset the connection after cloning.
     */
    public function __clone()
    {
        parent::__clone();

        $this->_transaction = null;
    }
}

我们将两个重写的两个关键类都放在了namespace common\db;的命名空间下,如果想让他们生效,还需要修改一下数据库连接的配置文件:

<?php
return [
    'components' => [
        //code ...
        'db' => [
            'class' => 'common\db\Connection',//使用自定的数据库连接。
            'dsn' => 'mysql:host=127.0.0.1;dbname=db_name',
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8',
            'tablePrefix' => '',
            // 是否开启schema缓存
            'enableSchemaCache' => true,
        ],
      //code ...  
    ],  
];

以上便是我们对YII2数据库事务的改造,虽说改造起来并不复杂,但本人并不建议这么做。这样的操作只是不得已为之,系统之间的调用应该在接口层面去实现,甚至,客观条件允许的时候,采用更高级的2PL或者3PL来实现不同数据库之间的事务ACID操作。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容