深入理解Yii2.0 事务


事务


正文

在Yii中,使用 yii\db\Transaction 来表示数据库事务。

一般情况下,我们从数据库连接启用事务,通常采用如下的形式:

transaction = $connection->beginTransaction();
try {
    $connection->createCommand($sql1)->execute();
    $connection->createCommand($sql2)->execute();
    // ... executing other SQL statements ...
    $transaction->commit();
} catch (Exception $e) {
    $transaction->rollBack();
}

在上面的代码中,先是获取一个 yii\db\Transaction 对象,之后执行若干SQL 语句, 然后调用之前Transaction 对象的 commit() 方法。这一过程中, 如果捕获了异常,那么调用 rollBack() 进行回滚。

创建事务

在上面代码中,我们使用数据库连接的 beginTransaction() 方法, 创建了一个 yii\db\Trnasaction 对象,具体代码在 yii\db\Connection 中:

public function beginTransaction($isolationLevel = null)
{
    $this->open();
    // 尚未初始化当前连接使用的Transaction对象,则创建一个
    if (($transaction = $this->getTransaction()) === null) {
        $transaction = $this->_transaction = new Transaction(['db' => $this]);
    }

    // 获取Transaction后,就可以启用事务
    $transaction->begin($isolationLevel);
    return $transaction;
}

从创建 Transaction 对象的 new Transaction(['db' => $this]) 形式来看, 这也是Yii一贯的风格。这里简单的初始化了 yii\db\Transaction::db

这表示的是当前的 Transaction 所依赖的数据库连接。如果未对其进行初始化, 那么将无法正常使用事务。

在获取了 Transaction 之后,就可以调用他的 begin() 方法,来启用事务。 必要的情况下,还可以指定事务隔离级别。

事务隔离级别的设定,由 yii\db\Schema::setTransactionIsolationLevel() 方法来实现,而这个方法,无非就是执行了如下的SQL语句:

SET TRANSACTION ISOLATION LEVEL ...

对于隔离级别,yii\db\Transaction 也提前定义了几个常量:

const READ_UNCOMMITTED = 'READ UNCOMMITTED';
const READ_COMMITTED = 'READ COMMITTED';
const REPEATABLE_READ = 'REPEATABLE READ';
const SERIALIZABLE = 'SERIALIZABLE';

如果开发者没有给出隔离级别,那么,数据库会使用默认配置的隔离级别。 比如,对于MySQL而言,就是使用 transaction-isolation 配置项的值。

启用事务

上面的代码告诉我们,启用事务,最终是靠调用 Transaction::begin() 来实现的。 那么就让我们来看看他的代码吧:

public function begin($isolationLevel = null)
{
    // 没有初始化数据库连接的滚粗
    if ($this->db === null) {
        throw new InvalidConfigException('Transaction::db must be set.');
    }
    $this->db->open();

    // _level 为0 表示的是最外层的事务
    if ($this->_level == 0) {
        // 如果给定了隔离级别,那么就设定之
        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;
        return;
    }

    // 以下 _level>0 表示的是嵌套的事务
    $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__);
    }
    // 结合 _level == 0 分支中的 $this->_level = 1,
    // 可以得知,一旦调用这个方法, _level 就会自增1
    $this->_level++;
}

对于最外层的事务,即当 _level 为 0 时,最终落到PDO的 beginTransaction() 来启用事务。在启用前,如果开发者给定了隔离级别,那么还需要设定隔离级别。

当 _level > 0 时,表示的是嵌套的事务,并非最外层的事务。 对此,Yii使用 SQL 的 SAVEPOINT 和ROLLBACK TO SAVEPOINT 来实现设置事务保存点和回滚到保存点的操作。

嵌套事务

在开头的例子中,展现的是事务最简单的使用形式。Yii还允许把事务嵌套起来使用。 比如,可以采用如下形式来使用事务:

$outerTransaction = $db->beginTransaction();
try {
    $db->createCommand($sql1)->execute();

    $innerTransaction = $db->beginTransaction();
    try {
        $db->createCommand($sql2)->execute();
        $db->createCommand($sql3)->execute();
        $innerTransaction->commit();
    } catch (Exception $e) {
        $innerTransaction->rollBack();
    }

    $db->createCommand($sql4)->execute();
    $outerTransaction->commit();
} catch (Exception $e) {
    $outerTransaction->rollBack();
}

为了实现这一嵌套,Yii使用 yii\db\Transaction::_level 来表示嵌套的层级。 当层级为 0 时,表示的是最外层的事务。

一般情况下,整个Yii应用使用了同一个数据库连接,或者说是使用了单例。 具体可以看 服务定位器(Service Locator) 部分。

而在 yii\db\Connection 中,又对事务对象进行了缓存:

class Connection extends Component
{
    // 保存当前连接的有效Transaction对象
    private $_transaction;

    // 已经缓存有事务对象,且事务对象有效,则返回该事务对象
    // 否则返回null
    public function getTransaction()
    {
        return $this->_transaction && $this->_transaction->getIsActive() ? $this->_transaction : null;
    }

    // 看看启用事务时,是如何使用事务对象的
    public function beginTransaction($isolationLevel = null)
    {
        $this->open();
        // 缓存的事务对象有效,则使用缓存中的事务对象
        // 否则创建一个新的事务对象
        if (($transaction = $this->getTransaction()) === null) {
            $transaction = $this->_transaction = new Transaction(['db' => $this]);
        }
        $transaction->begin($isolationLevel);
        return $transaction;
    }
}

因此,可以认为整个Yii应用,使用了同一个 Transaction 对象,也就是说, Transaction::_level 在整个应用的生命周期中,是有延续性的。 这是实现事务嵌套的关键和前提。

在这个 Transaction::_level 的基础上,Yii实现了事务的嵌套:

  • 事务对象初始化时,设 _level 为0,表示如果要启用事务, 这是一个最外层的事务。
  • 每当调用 Transaction::begin() 来启用具体事务时, _level 自增1。 表示如再启用事务,将是层级为1的嵌套事务。
  • 每当调用 Transaction::commit() 或 Transaction::rollBack() 时, _level 自减1,表示当前层级的事务处理完毕,返回上一层级的事务中。
  • 当调用了一次 begin() 且还没有调用匹配的 commit() 或 rollBack() , 就再次调用 begin()时,会使事务进行更深一层级的嵌套中。

因此,就有了我们上面代码中,当 _level 为 0 时,需要设定事务隔离级别。 因为这是最外层事务。

而当 _level > 0 时,由于是“嵌套”的事务,一个大事务中的小“事务”,那么, 就使用保存点及其回滚、释放操作,来模拟事务的启用、回滚和提交操作。

要注意,在这一节的开头,我们使用2对嵌套的 try … catch 来实现事务的嵌套。 由于内层的catch 把可能抛出的异常吞了,不再继续抛出。那么, 外层的 catch ,是捕获不到内层的异常的。

也就是说,这种情况下,外层中的 $sql1 $sql4 不会由于 $sql2 或 $sql3 的失败而中止, $sql1$sql4 可以继续执行并 commit 。

这是嵌套事务的正确使用形式,即内外层之间应当是不相干的。

如果内层事务的异常,会导致外层事务需要回滚,那么我们不应该使用事务嵌套, 而是应该把内外层当成一个事务。 这个道理很浅显,但是事实开发中,一个不小心, 就会出昏招。所以,不要动不动就来个 beginTransaction() 。

当然,为了使代码功能有一定的层次感,在必要时,也可以使用嵌套的事务。 但要考虑好,子事务是否真的要吞掉异常?有没有必要继续抛出异常, 使得上一层级的事务也产生回滚?这个要根据实际的情形来确定。

提交和回滚

提交和回滚通过 Transaction::commit()Transaction::rollBack() 来实现:

public function commit()
{
    if (!$this->getIsActive()) {
        throw new Exception('Failed to commit transaction: transaction was inactive.');
    }
    // 与begin()对应,只要调用  commit(),_level 自减1
    $this->_level--;
    // 如果回到了最外层事务,那么应当使用PDO的commit
    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__);
    }
}
    
public function rollBack()
{
    if (!$this->getIsActive()) {
        return;
    }
    // 调用 rollBack() 也会使 _level 自减1
    $this->_level--;

    // 如果已经返回到最外层,那么调用  PDO 的 rollBack
    if ($this->_level == 0) {
        Yii::trace('Roll back transaction', __METHOD__);
        $this->db->pdo->rollBack();
        $this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
        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 new Exception('Roll back failed: nested transaction not supported.');
    }
}

对于提交和回滚:

  • 创建时,会使层级+1,提交、回滚时,会使层级-1
  • 对于最外层的提交和回滚,使用的是数据库事务的 commit 和 rollBack
  • 对于嵌套的内层的提交和回滚,使用的其实是事务保存点的释放和回滚
  • 释放保存点时,会释放保存点的标识符,这个标识符在下次事务嵌套达到这个层级时, 会被再次使用。

有效的事务

在上面的提交、回滚等方法的代码中,我们多次看到了一个 this->getIsActive() 。 这是用于判断当前事务是否有效的一个方法,我们通过它,来看看什么样的一个事务, 算是有效的:

public function getIsActive()
{
return $this->_level > 0 && $this->db && $this->db->isActive;
}

方法很简单明了,一个有效的事务必须同时满足3个条件:

  • _level > 0 。这是由于为0是,要么是刚刚初始化, 要么是所有的事务已经提交或回滚了。也就是说,只有调用过了 begin() 但还没有调用过匹配的 commit() 或 rollBack() 的事务对象,才是有效的。
  • 数据库连接要已经初始化。
  • 数据库连接也必须是有效的。

附图






参考资料

深入理解Yii2.0 » Yii与数据库(TBD) » 事务(Transaction) https://www.kancloud.cn/kancloud/yii-in-depth/50787


返回