业务场景描述
假如有这样一个场景:
- 项目说大不大,说小也不小。
- 操作A系统的时候,想修改B系统相关联的业务。
- 项目工期紧张,系统之间写接口来实现互相调用又比较耗时或者框架微服务化代价又太大。
这个时候,单纯从代码量来衡量的话,直连系统A和系统B的数据库直接进行操作是最简单也是最快速实现的。但这个时候,就暴露了一个问题:如何实现复杂业务场景下多数据库的事务的ACID?
注意:这里所说的多数据库是指不同数据解构的数据库
理解本文所需要的知识点(以mysql为例)
- 熟知mysql数据库的事务嵌套
- 熟知事务是如何创建和提交保存点的。
举个例子:
<?php
/**
* 系统A的业务实现类,操作的是db_1数据库
* Class SystemA
*/
class SystemA
{
public function test1()
{
try {
$tr = $db_1->beginTransaction();
//code....
$this->test2();
//code....
(new SystemB)->test1();
//code....
$this->test3();
//code....
$tr->commit();
} catch (\Exception $e) {
if ($tr) {
$tr->rollBack();
}
}
}
public function test2()
{
try {
$tr = $db_1->beginTransaction();
//code....
$tr->commit();
return true;
} catch (\Exception $e) {
if ($tr) {
$tr->rollBack();
}
throw $e;
}
}
}
/**
* 系统B的业务实现类,操作的是db_2数据库
* Class SystemA
*/
class SystemB
{
public function test1()
{
try {
$tr = $db_2->beginTransaction();
//code....
$this->test2();
//code....
$this->test3();
//code....
$tr->commit();
} catch (\Exception $e) {
if ($tr) {
$tr->rollBack();
}
}
}
public function test2()
{
try {
$tr = $db_2->beginTransaction();
//code....
$tr->commit();
return true;
} catch (\Exception $e) {
if ($tr) {
$tr->rollBack();
}
throw $e;
}
}
public function test3()
{
try {
$tr = $db_2->beginTransaction();
//code....
$tr->commit();
return true;
} catch (\Exception $e) {
if ($tr) {
$tr->rollBack();
}
throw $e;
}
}
}
$test=new SystemA();
$test->test1();
代码中,
- 调用SystemA::test1()函数首先开启了事务;
- SystemA::test1()中既调用了本类的SystemA::test2()和SystemA::test3();也调用了SystemB::test1();
- 并且,SystemA和SystemB中的所有函数都分别开启了一次事务;
以上代码模拟了一个比较复杂业务嵌套,当然,这种嵌套逻辑在实际的业务场景中并不多见,一切以学习为目的。
那么,问题来了,对于这种业务场景,mysql怎么做的呢?
实际上,数据库只是在一开始调用SystemA::test1()的时候,执行了开启A系统的数据库事务的命令:
START TRANSACTION;
在这之后,程序中再执行开启A系统数据库事务的命令的时候,只是在原来得事务上执行了一个创建事务保存点的命令:
#point_name名称可以自定义
savepoint point_name;
如果遇到子事务回滚的话,只是回滚到刚才的事务保存点
#point_name名称可以自定义
rollback to point_name;
当程序执行到(new SystemB)->test1();
的时候,又会在B系统的数据库中执行一个开启事务的命令:
START TRANSACTION;
随后,在B系统内,其他的函数再开启事务的话,同A库一样,不会再重新开启事务,只是创建一个事务保存点。遇到子事务回滚的话,同样只是回滚到刚才创建的的事务保存点。
也就是说,本业务场景涉及到了两个不同得数据库,mysql会在两个不同得数据库中都会开启一次事务,并且每个数据库中不会再第二次开启事务,而是遇到子事务的时候创建一个事务保存点,回滚的时候回滚到事务保存点。当两个系统的所有的事务都执行成功了的话,执行到最后的commit的时候,会释放掉所有事务,并且,这个时候会同时提交两个系统的事务,也就是说,最后一次commit会在A和B两个库分别执行commit操作。
那么,通过研究发现,Yii2 2.0版本在数据库事务的支持上只是对单数据库的嵌套事务做了支持,不支持多数据库事务嵌套操作。
改造Yii2事务机制
首先我们看下Yii2 是怎么做的,摘抄部分yii\db\Transaction
的代码:
<?php
class Transaction extends \yii\base\BaseObject
{
//code ...
/**
* @var Connection the database connection that this transaction is associated with.
*/
public $db;
/**
* @var int the nesting level of the transaction. 0 means the outermost level.
*/
private $_level = 0;
//code ...
/**
* @param null $isolationLevel
* @throws InvalidConfigException
*/
public function begin($isolationLevel = null)
{
if ($this->db === null) {
throw new InvalidConfigException('Transaction::db must be set.');
}
$this->db->open();
if ($this->_level === 0) {
if ($isolationLevel !== null) {
$this->db->getSchema()->setTransactionIsolationLevel($isolationLevel);
}
Yii::debug('Begin transaction' . ($isolationLevel ? ' with isolation level ' . $isolationLevel : ''), __METHOD__);
$this->db->trigger(Connection::EVENT_BEGIN_TRANSACTION);
$this->db->pdo->beginTransaction();
$this->_level = 1;
return;
}
$schema = $this->db->getSchema();
if ($schema->supportsSavepoint()) {
Yii::debug('Set savepoint ' . $this->_level, __METHOD__);
$schema->createSavepoint('LEVEL' . $this->_level);
} else {
Yii::info('Transaction not started: nested transaction not supported', __METHOD__);
}
$this->_level++;
}
/**
* @throws Exception
*/
public function commit()
{
if (!$this->getIsActive()) {
throw new Exception('Failed to commit transaction: transaction was inactive.');
}
$this->_level--;
if ($this->_level === 0) {
Yii::debug('Commit transaction', __METHOD__);
$this->db->pdo->commit();
$this->db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
return;
}
$schema = $this->db->getSchema();
if ($schema->supportsSavepoint()) {
Yii::debug('Release savepoint ' . $this->_level, __METHOD__);
$schema->releaseSavepoint('LEVEL' . $this->_level);
} else {
Yii::info('Transaction not committed: nested transaction not supported', __METHOD__);
}
}
/**
* Rolls back a transaction.
* @throws Exception if the transaction is not active
*/
public function rollBack()
{
if (!$this->getIsActive()) {
// do nothing if transaction is not active: this could be the transaction is committed
// but the event handler to "commitTransaction" throw an exception
return;
}
$this->_level--;
if ($this->_level === 0) {
Yii::debug('Roll back transaction', __METHOD__);
$this->db->pdo->rollBack();
$this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
return;
}
$schema = $this->db->getSchema();
if ($schema->supportsSavepoint()) {
Yii::debug('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 an exception to fail the outer transaction
throw new Exception('Roll back failed: nested transaction not supported.');
}
}
//code ...
}
从代码中可以看到,事务类中只有一个public $db;
变量和一个private $_level = 0;
变量,_level则记录的是事务的保存点。
/**
* @var Connection the database connection that this transaction is associated with.
*/
public $db;
/**
* @var int the nesting level of the transaction. 0 means the outermost level.
*/
private $_level = 0;
根据注释得知,当$_level=0的时候,说明是最外层的事务。由此我们可以猜测到,当事务真正执行提交的时候,$_level是等于0的;不急,看一下提交事务的代码(只摘抄关键部分代码)
public function commit()
{
//code ...
$this->_level--;
if ($this->_level === 0) {
Yii::debug('Commit transaction', __METHOD__);
$this->db->pdo->commit();
$this->db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
return;
}
//code ...
}
}
?>
从代码上获知,当$this->_level=0的时候执行了提交事务的命令,但是这个$this->_level是在什么时候被累加的呢?(只摘抄关键部分代码)
public function begin($isolationLevel = null)
{
//code ...
$this->db->open();
if ($this->_level === 0) {
//code ....
$this->db->trigger(Connection::EVENT_BEGIN_TRANSACTION);
$this->db->pdo->beginTransaction();
$this->_level = 1;
return;
}
//code ...
$this->_level++;
}
从这个begin($isolationLevel = null)
函数得知,第一次开启事务的时候,执行了$this->db->pdo->beginTransaction();
代码,并且将$this->_level设置为1,从此之后,如果继续开启事务的话,只是将$this->_level的值累加1。
那么我们继续看下回滚的代码:(只摘抄关键部分代码)
public function rollBack()
{
//code ...
$this->_level--;
if ($this->_level === 0) {
Yii::debug('Roll back transaction', __METHOD__);
$this->db->pdo->rollBack();
$this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
return;
}
//code ...
}
以上可以看到,当我们执行回滚的时候,会将$this->_level--
,当$this->_level = 0
的时候,才会执行真正的回滚。
OK,到此为止,我们根据查看Yii2事务类的实现逻辑,确定了他的实现逻辑和数据库的事务嵌套的实现逻辑是一致的,那实行我们之前所说的跨库事务改造就集中在这几个函数就可以了。
直接上代码,重写数据库事务类\yii\db\Transaction
:
<?php
namespace common\db;
use yii\base\InvalidConfigException;
use Yii;
/**
* 重写yii2框架的事务处理,以便支持跨数据库事务,适合多个数据连接的时候,层层事务嵌套
* Class Transaction
* @package common\library\db
*/
class Transaction extends \yii\db\Transaction
{
/**
* 全局数据库事务等级
* @var int
*/
static $_level_global = 0;
/**
* 数据库连接池
* @var array
*/
static $_db_pool = [];
/**
* @var int the nesting level of the transaction. 0 means the outermost level.
*/
private $_level = 0;
/**
* Returns a value indicating whether this transaction is active.
* @return bool whether this transaction is active. Only an active transaction
* can [[commit()]] or [[rollBack()]].
*/
public function getIsActive()
{
return $this->_level > 0 && $this->db && $this->db->isActive;
}
/**
* 重写父类的begin方法,是为了让本类支持跨数据库事务
* @param null $isolationLevel
* @throws InvalidConfigException
*/
public function begin($isolationLevel = null)
{
if ($this->db === null) {
throw new InvalidConfigException('Transaction::db must be set.');
}
$this->db->open();
if ($this->_level == 0 and !$this->db->pdo->inTransaction()) {
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;
static::$_level_global++;
return;
}
$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__);
}
$this->_level++;
static::$_level_global++;
}
/**
* 事务提交
*/
public function commit()
{
if (!$this->getIsActive()) {
//$this->db && ;
throw new \yii\db\Exception('Failed to commit transaction: transaction was inactive.');
}
$this->_level--;
if ($this->_level == 0) {
if (!in_array($this->db, static::$_db_pool)) {
static::$_db_pool[] = $this->db;
}
}
static::$_level_global--;
if (static::$_level_global == 0) {
foreach (static::$_db_pool as $db) {
Yii::trace('Commit transaction', __METHOD__);
$db->pdo->commit();
$db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
}
static::$_db_pool = [];
return;
}
if ($this->_level == 0) {
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()) {
// do nothing if transaction is not active: this could be the transaction is committed
// but the event handler to "commitTransaction" throw an exception
return;
}
$this->_level--;
static::$_level_global--;
if ($this->_level == 0) {
Yii::trace('Roll back transaction', __METHOD__);
$this->db->pdo->rollBack();
$this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
static::$_db_pool = [];//清空数据库对象
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 an exception to fail the outer transaction
throw new \yii\db\Exception('Roll back failed: nested transaction not supported.');
}
}
}
要想让自定义事务类起作用,还需要将\yii\db\Connection
类在同样的命名空间下重写:
<?php
namespace common\db;
/**
* 为了让数据库支持跨库事务连接,重写开启事务方法
* Class Connection
* @package common\library\db
*/
class Connection extends \yii\db\Connection
{
/**
* 事务类
* @var
*/
private $_transaction;
/**
* Returns the currently active transaction.
* @return Transaction|null the currently active transaction. Null if no active transaction.
*/
public function getTransaction()
{
return $this->_transaction && $this->_transaction->getIsActive() ? $this->_transaction : null;
}
/**
* 开启事务管理
* @param null $isolationLevel
* @return \yii\db\Transaction
*/
public function beginTransaction($isolationLevel = null)
{
$this->open();
if (($transaction = $this->getTransaction()) === null) {
$transaction = $this->_transaction = new Transaction(['db' => $this]);
}
$transaction->begin($isolationLevel);
return $transaction;
}
/**
* Closes the currently active DB connection.
* It does nothing if the connection is already closed.
*/
public function close()
{
if ($this->pdo !== null) {
$this->_transaction = null;
}
parent::close();
}
/**
* Reset the connection after cloning.
*/
public function __clone()
{
parent::__clone();
$this->_transaction = null;
}
}
我们将两个重写的两个关键类都放在了namespace common\db;
的命名空间下,如果想让他们生效,还需要修改一下数据库连接的配置文件:
<?php
return [
'components' => [
//code ...
'db' => [
'class' => 'common\db\Connection',//使用自定的数据库连接。
'dsn' => 'mysql:host=127.0.0.1;dbname=db_name',
'username' => 'root',
'password' => '',
'charset' => 'utf8',
'tablePrefix' => '',
// 是否开启schema缓存
'enableSchemaCache' => true,
],
//code ...
],
];
以上便是我们对YII2数据库事务的改造,虽说改造起来并不复杂,但本人并不建议这么做。这样的操作只是不得已为之,系统之间的调用应该在接口层面去实现,甚至,客观条件允许的时候,采用更高级的2PL或者3PL来实现不同数据库之间的事务ACID操作。