Mar 29

浅析嵌套数据库事务 不指定

felix021 @ 2018-3-29 22:00 [IT » 数据库] 评论(0) , 引用(0) , 阅读(290) | Via 本站原创 | |

大家都知道,数据库事务提供的强一致性,让我们只需要在业务开始之前执行begin、结束后执行commit,并在异常的情况下执行rollback,就能够保证业务数据的强一致性。

## 1. 转一笔账

以一个转账操作为例,从from账户往to账户转一笔钱,涉及到两个账户的操作,我们用事务来保证数据的一致性:
function actionTransfer(Account $from, Account $to, int $amount)
{
    $this->db->begin();
    try {
        $from->withdraw($amount);
        $to->deposit($amount);
        $this->db->commit();
    } catch (Exception $e){
        $this->db->rollback();
    }   
}

class Account extends Model
{
    protected function changeBalance($amount)
    {
        $this->db->exec(
            "update account set balance = balance + :amount where id = :id",
            ['amount' => $amount, 'id' => $this->id]
        );
        AccountLog::create($this->id, $amount); #创建一条账户流水
    }
    public function withdraw($amount)
    {
        $this->changeBalance(-$amount);
    }
   
    public function deposit($amount)
    {
        $this->changeBalance($amount);
    }
}

如上面的栗子所见,我们往往会把数据库操作封装到底层模块(常常是model)里,对上层提供API,这样既保证了上层业务的可读性,也使得代码的可复用性提高了。

## 2. 充一笔钱,踩一个坑

说到复用性,那就是说,可能会有另一个业务需要调用它,比如充值,这个业务很简单,只涉及到一个账户,似乎可以简单点:
function actionDeposit(Account $account, int $amount)
{
    $account->deposit($amount);
}

这样看起来好像也没啥问题,对吧?但细心的小伙伴可能注意到了,代码里的 `changeBalance` 函数挖了个坑:在修改完账户余额以后会创建一条流水记录。如果没有开启事务的话,极端情况下会出现账户余额改好了,却没记录流水的情况,这就尴尬了。

当然这里只要在 `actionDeposit` 里开启了事务就能解决这个问题,但是基于防御性编程的习惯,`changeBalance` 这个方法本身作为一个独立的业务,应当尽最大努力确保其内业务的一致性,更直白一点说,就是也开启一个事务:
    protected function changeBalance($amount)
    {
        $this->db->begin();
        try {
            $this->db->exec("update account set balance ...", ...);
            AccountLog::create($this->id, $amount);
            $this->db->commit();
        } catch (Exception $e) {
            $this->db->rollback()
            throw $e; //确保上层业务知晓发生了异常
        }
    }

我们期望每一段代码都像这样对自己的业务负责,那么程序员不管在哪一层写代码,都能写得放心。

## 3. 嵌套事务

但到目前为止还没有到到期望的这么美好:如果只是在同一个流程里连续使用  begin ,实际上只会启动一个事务,遇到第一个 commit 就会结束事务,后续如果出现异常,rollback 无法达到预期的效果:
引用

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance = balance + 1 where id = 1;
Query OK, 1 row affected (0.01 sec)

mysql> begin;
Query OK, 0 rows affected (0.01 sec)

mysql> update account set balance = balance - 1 where id = 2;
Query OK, 1 row affected (0.01 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

mysql> rollback;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from account;
+----+---------+
| id | balance |
+----+---------+
|  1 |    1001 |
|  2 |    999 |
+----+---------+

还好的是,MySQL提供了嵌套事务的支持,可以使用其 SavePoint 特性来达到这个效果:
引用

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance = balance + 1 where id = 1;
Query OK, 1 row affected (0.00 sec)

mysql> savepoint level1;
Query OK, 0 rows affected (0.00 sec)

mysql> update account set balance = balance - 1 where id = 2;
Query OK, 1 row affected (0.00 sec)

mysql> release savepoint level1;
Query OK, 0 rows affected (0.00 sec)

mysql> rollback;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from account;
+----+---------+
| id | balance |
+----+---------+
|  1 |    1000 |
|  2 |    1000 |
+----+---------+

利用这一点,我们就可以写出一个支持嵌套事务的数据库模块:
class Database
{
    protected $level = 0;
    public function begin()
    {
        if ($this->level == 0) {
            $this->exec("begin");
        } else {
            $this->exec("savepoint level{$this->level}");
        }
        $this->level += 1;
    }

    public function commit()
    {
        $this->level -= 1;
        if ($this->level == 0) {
            $this->exec("commit");
        } else {
            $this->exec("release savepoint level{$this->level}");
        }
    }

    public function rollback()
    {
        $this->level -= 1;
        if ($this->level == 0) {
            $this->exec("rollback");
        } else {
            $this->exec("rollback to savepoint level{$this->level}");
        }
    }
}

当然,对于不支持 savepoint 特性的DBMS,也不是不能解决这个问题,只要在 `level != 0` 的时候不执行 commit 或者 rollback 就行,只是无法达到 savepoint 的效果。

加上这个改动的具体实现这里就不贴了,感兴趣的小伙伴建议自己动手写写看,实在太懒也可以查看 Yii2.0 中`yii\db\Transaction` 的实现 —— 其实上面这段代码就是对 Yii2.0 实现的一个简化版本。喜欢 Laravel 的小伙伴,在 `Illuminate\Database\Concerns\ManagesTransactions` 也能找到类似的应用。

## 4. 找坑和填坑

我们实现的这个嵌套事务方案,使得每一部分代码都能够健壮地实现其业务。但是,在具体编码过程中会常常还会遇到两个大坑:坑一是 begin 和 commit/rollback 不配对,坑二是出了错忘记反馈给上层。在上面的实现里,要避开这两个坑,完全依赖程序员的编码习惯。但墨菲定律告诉我们……说多了都是泪,总之就是“依赖人来保证正确性”这件事情永远是不靠谱的。

那怎么改变这一点呢?Laravel 的解决方案是由代码本身来填坑,所以实现了 `ManagesTransactions->transaction` 这个方法(以下略作简化,省略了失败重试机制及相应的死锁处理,这一部分建议详读 laravel 的相关代码):
    public function transaction(Closure $callback)
    {
        $this->begin();
        try {
            $value = $callback();
            $this->commit();
            return $value;
        } catch (Exception $e) {
            $this->rollback();
            throw $e;
        }
    }

于是程序员们就可以这么干了:
public function actionTransfer($from, $to, $amount)
{
    DB::transaction(function () use ($from, $to, $amount) {
        $from->withdraw($amount);
        $to->deposit($amount);   
    })
}

当然,这个方案也不是完全没代价的,其中最重要的是,程序员们要严格遵守一个基本约定:**遇到错误的时候抛出异常**,以便封装层自动执行rollback。虽然也是约定,但是不像前面那两个约定那么晦涩,应该更容易遵守吧。

另外,如果在rollback之前还希望做一些清理操作(包括打log之类的),需要在callback里面再包一层 try-catch ;但如果遇到 commit 失败这种极端情况,清理操作仍无法被执行。这可以通过一个更复杂的约定(比如返回一个包含清理操作的 $clean_callback),但鉴于实际上不太有这种需要,得不偿失,所以大概就这样吧。

这种方案,在 C++ 里面有很多类似的例子,甚至还有一个专有名词 RAII (Resource Acquisition Is Initialization),“资源获取即初始化”,念起来有点拗口,其逻辑是在获取一个资源的时候立即初始化,并且在资源生命周期结束的时候自动销毁,避免因为程序员遗忘导致非预期的异常。常见的应用场景,包括打开文件(自动关闭)、多线程并发获取互斥锁(自动解锁)、申请内存资源(自动释放)等等。

如果你觉得看明白了,不妨试着写一个文件锁(flock)的代码练练手?

转载请注明出自 ,如是转载文则注明原出处,谢谢:)
RSS订阅地址: http://www.felix021.com/blog/feed.php
发表评论
表情
emotemotemotemotemot
emotemotemotemotemot
emotemotemotemotemot
emotemotemotemotemot
emotemotemotemotemot
打开HTML
打开UBB
打开表情
隐藏
记住我
昵称   密码   *非必须
网址   电邮   [注册]