關於分散式事務帶來的問題及解決方案

fireqong發表於2022-10-15

相信大家都做過微信提現的業務邏輯,扣除使用者餘額,呼叫微信付款到零錢介面進行提現。

程式碼如下:

<?php

public function withdraw(Request $request)
{
    $withdrawMoney = $request->post('money');
    $loginUser = UserService::getLoginUserInfo();
    $user = User::find($loginUser['id']);

    if ($user['money'] >= $withdrawMoney) {
        DB::beginTransaction();

        try {
            $user['money'] -= $withdrawMoney;
            $user->save();

            UserMoneyLog::record($user['id'], $withdrawMoney);
            WechatService::pay($user['openid'], $withdrawMoney);

            DB::commit();
            //返回成功響應
        } catch (\Exception $e) {
            DB::rollback();
            //返回失敗響應
        }

    }

    throw new \Exception('餘額不足');
}

這樣寫在併發提現的時候會有問題,要加上分散式鎖。具體參考我的另一篇文章分散式鎖在程式設計中的應用

加上分散式鎖後,程式碼如下:

<?php

public function withdraw(Request $request)
{
    $withdrawMoney = $request->post('money');
    $loginUser = UserService::getLoginUserInfo();

    $lock = Cache::lock('withdraw-' . $user['id'], 10);

    if (!$lock->get()) {
        throw new \Exception('提現太頻繁,請稍後重試');
    }

    $user = User::find($loginUser['id']);

    if ($user['money'] >= $withdrawMoney) {
        try {
            $user['money'] -= $withdrawMoney;
            $user->save();

            UserMoneyLog::record($user['id'], $withdrawMoney);
            WechatService::pay($user['openid'], $withdrawMoney);

            DB::commit();

            //返回成功響應
        } catch (\Exception $e) {
            DB::rollback();
            //返回失敗響應
        }

    }

    throw new \Exception('餘額不足');
}

這樣還是會帶來一個問題,如果資料庫減成功了,微信介面呼叫失敗了怎麼辦?就會導致餘額扣了,錢沒到賬。

那有朋友可能就會說了,簡單啊,微信支付的介面是同步返回的,獲取支付結果判斷一下,如果沒問題,才提交整個事務。

程式碼如下:

<?php

public function withdraw(Request $request)
{
    $withdrawMoney = $request->post('money');
    $loginUser = UserService::getLoginUserInfo();

    $lock = Cache::lock('withdraw-' . $user['id'], 10);

    if (!$lock->get()) {
        throw new \Exception('提現太頻繁,請稍後重試');
    }

    $user = User::find($loginUser['id']);

    if ($user['money'] >= $withdrawMoney) {
        try {
            $user['money'] -= $withdrawMoney;
            $user->save();

            UserMoneyLog::record($user['id'], $withdrawMoney);
            $result = WechatService::pay($user['openid'], $withdrawMoney);

            if ($result['pay_result'] == 'SUCCESS') {
                DB::commit();
            } else {
                throw new \Exception('提現失敗');
            }
        } catch (\Exception $e) {
            DB::rollback();
            throw $e;
        }
    }
}

到此,微信提現才算沒有問題,本地事務扣餘額和記日誌,還有遠端事務微信支付要麼同時成功,要麼同時失敗,保證了原子性。

難道,所謂的分散式事務這麼輕鬆就可以解決了嗎?我們再來假設一下,不一定符合實際情況,但為了更加深入地探討分散式事務,我們不妨先接受這個設定。
假設使用者資產是在JAVA端管理,而提現功能是用PHP寫。那扣除餘額和記錄資產日誌就要呼叫JAVA提供的介面,再呼叫微信介面。程式碼如下:

<?php

public function withdraw(Request $request)
{
    $withdrawMoney  =  $request->post('money');  
    $loginUser  =  UserService::getLoginUserInfo();

    try {
        UserService::withdrawMoney($loginUser['id'], $withdrawMoney); //呼叫JAVA介面的封裝
        WechatService::pay($loginUser['openid'], $withdrawMoney);
    } catch (\Exception $e) {
        throw new \Exception('提現失敗');
    }

}

這兩個都是遠端事務,如何保證這兩個事務100%執行成功?如果有一個事務失敗了,一個事務成功了怎麼辦?這可不是本地事務,沒辦法回滾。

這裡介紹一種本地訊息表的方式來解決分散式事務的問題,其核心思想就是把遠端事務化為本地事務,再透過訊息佇列和重試機制來保證事務100%執行成功

什麼叫把遠端事務化為本地事務?就是把遠端介面呼叫,封裝成一個任務。

新建一個任務表tasks:

  • id
  • name
  • handler_class
  • handler_method
  • handler_construct_params
  • handler_meethod_params
  • is_static
  • execute_result
  • create_time
  • throw_time

上面的程式碼變成了

<?php

public function withdraw(Request $request)
{
    $withdrawMoney  =  $request->post('money');  
    $loginUser  =  UserService::getLoginUserInfo();

    DB::beginTransaction();

    try {
        $task = new Task();
        $task->name = '提現任務JAVA呼叫';
        $task->handler_class = UserService::class;
        $task->handler_method = 'withdrawMoney';
        $task->is_static = 1;
        $task->handler_method_params = json_encode([
            $loginUser['id'],
            $withdrawMoney
        ]);
        $task->create_time = date('Y-m-d H:i:s');
        $task->save();

        $task = new Task();
        $task->name = '提現任務微信支付';
        $task->handler_class = WechatService::class;
        $task->handler_method = 'pay';
        $task->is_static = 1;
        $task->handler_method_params = json_encode([
            $loginUser['openid'],
            $withdrawMoney
        ]);
        $task->create_time = date('Y-m-d H:i:s');
        $task->save();

        DB::commit();
    } catch (\Exception $e) {
        DB::rollback();
    }
}

這樣我們就把兩個遠端事務化為了本地事務,由資料庫來保證兩個任務同時插入成功的原子性。

到此,只是完成了一半。還需要一個投遞程式,也叫訊息生產者程式把任務投遞到訊息佇列,由消費者程式進行消費。

但限於篇幅,要完整地展示用於生產環境的投遞程式和消費者程式程式碼難度較大。所以只是簡單地寫一下程式碼。

<?php

class MessageProducer
{
    public function onWorkerStart()
    {
        // 獲取未投遞過的任務

        //投遞到MQ
    }
}

這裡有很多要注意的細節,多程式投遞還是單程式投遞。已經投遞未處理的任務如何區分?怎麼樣避免重複投遞任務?投遞任務本質也是網路IO,如果失敗了怎麼辦?

<?php

class MessageConsumer
{
    public function onWorkerStart()
    {
        //從MQ中獲取任務

        //執行任務
    }
}

消費者程式也有非常多的細節,以RabbitMQ為例,失敗後要不要進死信佇列,失敗幾次進。重試機制是什麼樣的?重試時間間隔如何設計?進入到死信佇列後,要不要設定一個程式專門消費死信佇列的任務。

但無論投遞到什麼MQ,無論如何消費,如何投遞。本質上都是把任務先放過資料庫,再透過生產者程式投遞到訊息佇列,再透過消費者程式進行消費。就算有一個遠端事務失敗了,也可以透過重試機制去保證一定可以執行成功。

當然,處理分散式事務不止本地訊息表這一種方式,還有TCC等方式。下次有機會再一起探討。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章