事务(Transaction)

  在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还允许把事务嵌套起来使用。 比如,可以采用如下形式来使用事务:

 

18
  $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() 的事务对象,才是有效的。

  数据库连接要已经初始化。

  数据库连接也必须是有效的。