事务(Transaction)
在Yii中,使用 yii\db\Transaction 来表示数据库事务。
一般情况下,我们从数据库连接启用事务,通常采用如下的形式:
$db = Yii::$app->db;
$transaction = $db->beginTransaction();
try {
$db->createCommand($sql1)->execute();
$db->createCommand($sql2)->execute();
// ... executing other SQL statements ...
$transaction->commit();
} catch(\Exception $e) {
$transaction->rollBack();
throw $e;
} catch(\Throwable $e) {
$transaction->rollBack();
throw $e;
}
在上面的代码中,先是获取一个 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() 的事务对象,才是有效的。
- 数据库连接要已经初始化。
- 数据库连接也必须是有效的。