转载请注明来源:https://ssrvps.org/archives/4067
今天运行程序时发现有条数据不完整。出现问题的数据属于某个事务,按道理要么逻辑走完数据提交,要么回滚。出现预料外问题,第一个反应是ActiveRecord中内嵌事务会单独提交到数据库中?为了验证这个问题,抽空写了一个测试用例验证。
准备工作
先建立两个表 foo1
和 foo2
:
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中事务的执行情况,总共编写了六个例子。六个示例的作用分别是:
- 非事务保存、数据校验不通过
- 事务保存、数据校验不通过
- 校验通过、多模型数据保存
- 某条数据校验不通过
- 某条数据插入冲突
- 事务执行中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)
方法插入数据,此时出错才会抛出异常。