转载请注明来源:https://itlanyan.com/yii2-transactions/

今天运行程序时发现有条数据不完整。出现问题的数据属于某个事务,按道理要么逻辑走完数据提交,要么回滚。出现预料外问题,第一个反应是ActiveRecord中内嵌事务会单独提交到数据库中?为了验证这个问题,抽空写了一个测试用例验证。

准备工作

先建立两个表 foo1foo2

CREATE TABLE `foo1` (
  `id` int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
  `data1` varchar(12) NOT NULL UNIQUE,
  `value` varchar(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `foo2` (
  `id` int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
  `data2` varchar(12) NOT NULL UNIQUE,
  `value` varchar(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

创建相应的ActiveRecord类,并定义好规则:

// file: Foo1.php
namespace console\models;

use yii\db\ActiveRecord;

class Foo1 extends ActiveRecord
{
    public function rules()
    {
        return [
            [["data1", "value"], "required", "on" => [self::SCENARIO_DEFAULT, "other]],
            ["data1", "string", "length" => [2, 8]],
        ];
    }

    public function transactions()
    {
        return [
            self::SCENARIO_DEFAULT => self::OP_ALL,
        ];
    }
}

// file: Foo2.php
namespace console\models;

use yii\db\ActiveRecord;

class Foo2 extends ActiveRecord
{
    public function rules()
    {
        return [
            [["data2", "value"], "required"],
            ["data2", "string", "length" => [2, 8]],
        ];
    }

    public function transactions()
    {
        return [
            self::SCENARIO_DEFAULT => self::OP_ALL,
        ];
    }
}

编写测试用例

为了彻底搞清楚Yii2中事务的执行情况,总共编写了六个例子。六个示例的作用分别是:

  1. 非事务保存、数据校验不通过
  2. 事务保存、数据校验不通过
  3. 校验通过、多模型数据保存
  4. 某条数据校验不通过
  5. 某条数据插入冲突
  6. 事务执行中exit/return

测试例子的代码如下:

namespace console\controllers;

use Yii;
use yii\db\Exception;
use yii\console\Controller;
use console\models\{Foo1, Foo2};

class TestController extends Controller
{
    public function beforeAction($action)
    {
        if (parent::beforeAction($action)) {
          $db = Foo1::getDb();
          $db->createCommand("truncate table " . Foo1::tableName())->execute();
          $db->createCommand("truncate table " . Foo2::tableName())->execute();
          return true;
        }
        return false;
    }

    public function actionCase1()
    {
        $transaction = Yii::$app->db->beginTransaction();
        try {
            $foo1 = new Foo1([
                "data1" => "1234567890",
                "value" => "1245677553",
            ]);
            $foo1->scenario = 'other';
            $foo1->save();
            $transaction->commit();

            echo "transaction committed";
        } catch (Exception $e) {
            $transaction->rollback();
            echo "insert data error:", $e->getMessage();
        }
    }

    public function actionCase2()
    {
        $transaction = Yii::$app->db->beginTransaction();
        try {
            $foo1 = new Foo1([
                "data1" => "1234567890",
                "value" => "1245677553",
            ]);
            $foo1->save();
            $transaction->commit();

            echo "transaction committed";
        } catch (Exception $e) {
            $transaction->rollback();
            echo "insert data error:", $e->getMessage();
        }
    }

    public function actionCase3()
    {
        $transaction = Yii::$app->db->beginTransaction();
        try {
            $foo1 = new Foo1([
                "data1" => "12345678",
                "value" => "1245677553",
            ]);
            $foo1->save();
            $foo2 = new Foo1([
                "data1" => "12345678",
                "value" => "1245677553",
            ]);
            $foo2->save();
            $transaction->commit();

            echo "transaction committed";
        } catch (Exception $e) {
            $transaction->rollback();
            echo "insert data error:", $e->getMessage();
        }
    }

    public function actionCase4()
    {
        $transaction = Yii::$app->db->beginTransaction();
        try {
            $foo1 = new Foo1([
                "data1" => "12345678",
                "value" => "1245677553",
            ]);
            $foo1->save();

            $foo2 = new Foo2([
                "data2" => "1234567890",
                "value" => "1245677553",
            ]);
            $foo2->save();
            $transaction->commit();

            echo "transaction committed";
        } catch (Exception $e) {
            $transaction->rollback();
            echo "insert data error:", $e->getMessage();
        }
    }

    public function actionCase5()
    {
        $transaction = Yii::$app->db->beginTransaction();
        try {
            $foo1 = new Foo1([
                "data1" => "12345678",
                "value" => "1245677553",
            ]);
            $foo1->save();

            $foo2 = new Foo2([
                "data2" => "12345678",
                "value" => "1245677553",
            ]);
            $foo2->save();

            $foo2 = new Foo2([
                "data2" => "12345678",
                "value" => "1245677553",
            ]);
            $foo2->save();
            $transaction->commit();

            echo "transaction committed";
        } catch (Exception $e) {
            $transaction->rollback();
            echo "insert data error:", $e->getMessage();
        }
    }

    public function actionCase6()
    {
        $transaction = Yii::$app->db->beginTransaction();
        echo "transaction level:", $transaction->level, PHP_EOL;
        try {
            $foo1 = new Foo1([
                "data1" => "12345678",
                "value" => "1245677553",
            ]);
            $foo1->save();

            echo "exit now";
            exit;
            $transaction->commit();
        } catch (Exception $e) {
            $transaction->rollback();
            echo "insert data error:", $e->getMessage();
        }
    }
}

执行结果

依次执行上述测试用例,结果如下:

  • case1: 输出”transaction committed”,数据未插入;
  • case2: 输出”transaction committed”,数据未插入;
  • case3: 输出”insert data error:SQLSTATE[23000]: Integrity constraint violation:1062 Duplicate entry ‘12345678’ for key ‘data1’ The SQL being executed was: INSERT INTO foo1 (data1, value) VALUES (‘12345678’, ‘1245677553’)”,数据未插入;
  • case4: 输出”transaction committed”,foo1中的数据成功插入;
  • case5: 输出”insert data error:SQLSTATE[23000]: Integrity constraint violation:1062 Duplicate entry ‘12345678’ for key ‘data2’ The SQL being executed was: INSERT INTO foo2 (data2, value) VALUES (‘12345678’, ‘1245677553’)”,数据未插入;
  • case6: 输出”exit now”,数据未插入。

分析

大部分示例的结果在预料之中,震惊的是case2和case4的结果。之前一直以为只要包裹在事务中,并且在transactions方法中声明了所在场景启用事务,数据保存出错就会抛异常,数据回滚。这个测试彻底颠覆了我的认知。

为了搞清楚执行机制,开始跟踪Yii2执行数据保存的源码。首先查看ActiveRecord基类BaseActiveRecord中的save方法:

public function save($runValidation = true, $attributeNames = null)
{
    if ($this->getIsNewRecord()) {
        return $this->insert($runValidation, $attributeNames);
    } else {
        return $this->update($runValidation, $attributeNames) !== false;
    }
}

save方法根据是否新数据,走插入或更新流程。继续跟踪insert方法(定义在yii\db\ActiveRecord中):

public function insert($runValidation = true, $attributes = null)
{
    if ($runValidation && !$this->validate($attributes)) {
        Yii::info('Model not inserted due to validation error.', __METHOD__);
        return false;
    }

    if (!$this->isTransactional(self::OP_INSERT)) {
        return $this->insertInternal($attributes);
    }

    $transaction = static::getDb()->beginTransaction();
    try {
        $result = $this->insertInternal($attributes);
        if ($result === false) {
            $transaction->rollBack();
        } else {
            $transaction->commit();
        }
        return $result;
    } catch (\Exception $e) {
        $transaction->rollBack();
        throw $e;
    } catch (\Throwable $e) {
        $transaction->rollBack();
        throw $e;
    }
}

insert方法的实现代码解决了我的疑问:数据的规则验证不通过,直接返回false,不会抛异常。

再看保存过程:如果当前场景未声明事务,常规保存;事务保存第一步还是尝试常规保存,如果失败,回滚并抛出异常;如果事务保存成功,提交事务。

到这一步,Yii中事务处理已经比较清晰了。剩下的问题是:嵌套事务如何处理?继续跟踪yii\db\Transaction中的commit方法:

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

    $this->_level--;
    if ($this->_level === 0) {
        Yii::trace('Commit transaction', __METHOD__);
        $this->db->pdo->commit();
        $this->db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
        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__);
    }
}

代码中出现事务的层级(level),结合begin方法,每嵌套一层事务,level加一并创建savepoint。事务提交时,如果是最外层事务,直接提交到数据库;如果是内嵌事务,释放savepoint或什么都不做。所以嵌套事务的疑问也解决了:内嵌事务不会单独提交。

总结

通过这次测试和源码跟踪阅读,对Yii的事务了解又深入一步。最大的收获是:事务开始前调用validate方法先校验数据,无错误时再通过事务中调用save(false)方法插入数据,此时出错才会抛出异常。