php基於dtm分散式事務管理器實現tcc模式分散式事務demo

kolin發表於2021-12-27

專案裡遇到兩個系統互相調但不能保證事務一致性的問題,湊巧這段時間跟著dtm富哥學習了一些分散式事務的知識,大概瞭解了一些分散式事務的使用場景,看了看官方的demo後,簡簡單單自己寫了個demo,程式碼已經上傳到碼雲,分散式事務模式我們使用的是tcc,即(try,confirm,cancel),呼叫方式採用的是http,關於tcc可以到這裡瞭解更多。

dtm是一個go語言開發的分散式事務管理器,在這裡我們先將dtm跑起來。

## 拉取程式碼
cd /home
git clone https://github.com/dtm-labs/dtm && cd dtm

按照官方文件,dtm依賴於mysql,安裝了docker20.04+之後,你可以通過

docker-compose -f helper/compose.mysql.yml

在docker裡面啟動mysql服務。你也可以像我一樣使用現有的mysql服務,只需要在dtm專案下:

cp conf.sample.yml conf.yml # 修改conf.yml

將配置檔案複製一份出來並修改一下相關的資訊:

Store: # specify which engine to store trans status
  Driver: 'mysql'
  Host: 'localhost'
  User: 'root'
  Password: ''
  Port: 3306

ExamplesDB:
  Driver: 'mysql'
  Host: 'localhost'
  User: 'root'
  Password: ''
  Port: 3306

這裡要注意你配置的mysql賬戶必須有建立dtm資料庫的許可權。改完了之後我們就可以啟動專案了(前提是你本地已經有了go環境,不然還是要通過docker,通過docker怎麼啟動可以檢視下dtm官方文件):

// 入口檔案是app/main.go
go run app/main.go dev

看到下圖這樣的就是啟動成功了,並且會有一個定時任務不停的列印日誌

你也可以使用資料庫工具檢視是否多了一個dtm的資料庫。根據效能測試目前dtm搭配mysql可以處理的事務大概為900多個每秒,我寫這篇文章的時候已經看到作者發了搭配redis做為儲存引擎可以達到每秒處理1W+事務的文章,如果業務上有這麼高的要求可以使用redis做為dtm的儲存引擎,具體參考(mp.weixin.qq.com/s/lmIVQ2aVksZxiCx...) OK,dtm到了這裡就不用去管它了,下面我們看具體的業務。

我們模擬的是一個下單扣庫存服務,分別有訂單服務、庫存服務。php方面使用的hyperf框架,其他框架也可以,專案結構如下:

|—— php-dtm-tcc/
    |—— api-hyperf/
    |—— order-server/
    |—— stock-server/
    |—— sql/index.sql ## 建表語句

這裡為了簡單點資料庫我就使用的同一個了,實際操作不同的系統可以使用不同的資料庫,資料庫表有商品表、商品庫存表、訂單表、訂單商品表、庫存鎖定表。

請求先到api-hyperf再呼叫兩個子服務order-serverstock-server

訂單服務生成寫入訂單、訂單商品等記錄,庫存服務扣減庫存,鎖定庫存等操作。

api-hyperf專案的composer.json里加上下面兩個包

"linxx/dtmcli-php": "*",//原本是dtm/dtmcli-php,但我覺得他那個包裡返回的資料不是我想要的,所以基於原包的情況下我將返回資料修改了一下,下面會做對比
"mix/vega": "^3.0"

然後我們分別啟動api-hyperf服務、order-server服務、stock-server服務。

在寫具體的程式碼前我們先來看看整體的流程大概是怎麼樣的,這裡引用一段官方demo的程式碼:

<?php

require __DIR__ . '/vendor/autoload.php';

function FireTcc () {
    $dtm = 'http://localhost:36789/api/dtmsvr';
    $svc = 'http://localhost:4005/api';

    Dtmcli\tccGlobalTransaction($dtm, function ($tcc) use ($svc) {
        /** @var Dtmcli\Tcc $tcc */

        $req = ['amount' => 30];
        echo 'calling trans out' . PHP_EOL;
        $tcc->callBranch($req, $svc . '/TransOutTry', $svc . '/TransOutConfirm', $svc . '/TransOutCancel');
        echo 'calling trans in' . PHP_EOL;
        $tcc->callBranch($req, $svc . '/TransInTry', $svc . '/TransInConfirm', $svc . '/TransInCancel');
    });
}

$vega = new Mix\Vega\Engine();

//轉出try
$vega->handleFunc('/api/TransOutTry', function (Mix\Vega\Context $ctx) {
    var_dump('TransOutTry', $ctx->request->getQueryParams(), $ctx->request->getParsedBody());
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

//轉出confirm
$vega->handleFunc('/api/TransOutConfirm', function (Mix\Vega\Context $ctx) {
    var_dump('TransOutConfirm', $ctx->request->getQueryParams(), $ctx->request->getParsedBody());
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

//轉出commit
$vega->handleFunc('/api/TransOutCancel', function (Mix\Vega\Context $ctx) {
    var_dump('TransOutCancel', $ctx->request->getQueryParams(), $ctx->request->getParsedBody());
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

//轉入try
$vega->handleFunc('/api/TransInTry', function (Mix\Vega\Context $ctx) {
    var_dump('TransInTry', $ctx->request->getQueryParams(), $ctx->request->getParsedBody());
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

//轉入confirm
$vega->handleFunc('/api/TransInConfirm', function (Mix\Vega\Context $ctx) {
    var_dump('TransInConfirm', $ctx->request->getQueryParams(), $ctx->request->getParsedBody());
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

//轉入commit
$vega->handleFunc('/api/TransInCancel', function (Mix\Vega\Context $ctx) {
    var_dump('TransInCancel', $ctx->request->getQueryParams(), $ctx->request->getParsedBody());
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

$vega->handleFunc('/api/FireTcc', function (Mix\Vega\Context $ctx) {
    FireTcc();
    $ctx->JSON(200, ['result' => 'SUCCESS']);
})->methods('POST');

$http_worker = new Workerman\Worker("http://0.0.0.0:4005");
$http_worker->onMessage = $vega->handler();
$http_worker->count = 4;
Workerman\Worker::runAll();

由上面這個例子我們看到,這是一個仿照轉賬業務的操作,首先定義了轉出、出入的try、confirm、cancel操作,在這個workerman服務啟動起來後請求http:127.0.0.1:4006/api/FireTcc地址,會呼叫FireTcc()函式,這個函式裡有一個tccGlobalTransaction函式,這個函式裡就是dtm管理事務的相關操作,官方這個包dtm/dtmcli-phptccGlobalTransaction函式返回值是gid(分散式事務id),在整個事務操作成功的時候返回分散式事務id,事務失敗時返回空。

function tccGlobalTransaction(string $dtmUrl, callable $cb): string
    {
        $tcc = new Tcc($dtmUrl, genGid($dtmUrl));
        $tbody = [
            'gid' => $tcc->gid,
            'trans_type' => 'tcc',
        ];
        $client = new \GuzzleHttp\Client();
        try {
            $response = $client->post($tcc->dtm . '/prepare', ['json' => $tbody]);
            checkStatus($response->getStatusCode());
            $cb($tcc);
            $client->post($tcc->dtm . '/submit', ['json' => $tbody]);
        } catch (\Throwable $e) {
            $client->post($tcc->dtm . '/abort', ['json' => $tbody]);
            return '';
        }
        return $tcc->gid;
    }

linxx/dtmcli-php這個包返回的是一個陣列:

function tccGlobalTransaction(string $dtmUrl, callable $cb): array
    {
        $tcc = new Tcc($dtmUrl, genGid($dtmUrl));
        $tbody = [
            'gid' => $tcc->gid,
            'trans_type' => 'tcc',
        ];
        $client = new \GuzzleHttp\Client();
        $message = '操作成功';
        $gid = $tcc->gid;
        try {
            $response = $client->post($tcc->dtm . '/prepare', ['json' => $tbody]);
            checkStatus($response->getStatusCode());
            $cb($tcc);
            $client->post($tcc->dtm . '/submit', ['json' => $tbody]);
        } catch (\Throwable $e) {
            $client->post($tcc->dtm . '/abort', ['json' => $tbody]);
            $message = $e->getMessage();
            $gid = '';
        }
        return [
            'gid'   => $gid,
            'message'   => $message
        ];
    }

除了gid之外還返回了一個message欄位,這樣可以將子服務失敗的原因也返回,方便api-hyperf服務提示錯誤資訊。

下面我們就開始寫具體的業務程式碼了,我們先到order-serverstock-server寫上相應的try、confirm、cancel操作。

訂單服務:

<?php

declare(strict_types=1);
/**
 * This file is part of Hyperf.
 *
 * @link     https://www.hyperf.io
 * @document https://hyperf.wiki
 * @contact  group@hyperf.io
 * @license  https://github.com/hyperf/hyperf/blob/master/LICENSE
 */
namespace App\Controller;

use Hyperf\DbConnection\Db;

class Order extends AbstractController
{
    public function addOrderTry()
    {
        Db::beginTransaction();
        try {
            $postData = $this->request->post();
            $totalAmount = 0;
            $totalNumber = 0;
            foreach ($postData['goods'] as &$value) {
                $totalNumber = $value['number'] + $totalNumber;
                $goodInfo = Db::table('good')->where('id', $value['good_id'])->first();
                $amount = $value['number'] * $goodInfo->price;
                $value['amount'] = $amount;
                $value['price'] = $goodInfo->price;
                $totalAmount = $totalAmount + $amount;
            }
            unset($value);
            if (round($totalAmount, 2) != round($postData['pay_amount'], 2)) {
                throw new \Exception('商品金額計算錯誤', 10010);
            }
            $orderId = Db::table('order')->insertGetId([
                'user_id' => $postData['user_id'],
                'order_no' => $postData['order_no'],
                'total_amount' => $totalAmount,
                'total_number' => $totalNumber,
                'create_time' => time(),
                'update_time' => time(),
            ]);
            foreach ($postData['goods'] as $value) {
                Db::table('order_goods')->insert([
                    'order_id' => $orderId,
                    'good_id' => $value['good_id'],
                    'number' => $value['number'],
                    'amount' => $value['amount'],
                    'price' => $value['price'],
                    'create_time' => time(),
                    'update_time' => time(),
                ]);
            }
            Db::commit();
            return [
                'code' => 0,
                'data' => 'SUCCESS',
                'msg' => '成功',
            ];
        } catch (\Exception $e) {
            Db::rollBack();
            return [
                'code' => 10010,
                'data' => 'FAILURE',
                'msg' => $e->getMessage(),
            ];
        }
    }

    public function addOrderConfirm()
    {
        Db::beginTransaction();
        try {
            $postData = $this->request->post();
            $orderInfo = Db::table('order')->where('order_no', $postData['order_no'])->first();
            Db::table('order')->where('id', $orderInfo->id)->update([
                'is_ok' => 1,
                'update_time' => time(),
            ]);
            Db::table('order_goods')->where('order_id', $orderInfo->id)->update([
                'is_ok' => 1,
                'update_time' => time(),
            ]);
            Db::commit();
            return [
                'code' => 0,
                'data' => 'SUCCESS',
                'msg' => '成功',
            ];
        } catch (\Exception $e) {
            Db::rollBack();
            return [
                'code' => 10010,
                'data' => 'FAILURE',
                $e->getMessage(),
            ];
        }
    }

    public function addOrderCancel()
    {
        Db::beginTransaction();
        try {
            $postData = $this->request->post();
            $orderInfo = Db::table('order')->where('order_no', $postData['order_no'])->first();
            Db::table('order')->where('id', $orderInfo->id)->update([
                'delete_time' => time(),
            ]);
            Db::table('order_goods')->where('order_id', $orderInfo->id)->update([
                'delete_time' => time(),
            ]);
            Db::commit();
            return [
                'code' => 0,
                'data' => 'SUCCESS',
                'msg' => '成功',
            ];
        } catch (\Exception $e) {
            Db::rollBack();
            return [
                'code' => 10010,
                'data' => 'FAILURE',
                'msg' => $e->getMessage(),
            ];
        }
    }
}

庫存服務:

<?php

declare(strict_types=1);
/**
 * This file is part of Hyperf.
 *
 * @link     https://www.hyperf.io
 * @document https://hyperf.wiki
 * @contact  group@hyperf.io
 * @license  https://github.com/hyperf/hyperf/blob/master/LICENSE
 */
namespace App\Controller;

use Hyperf\DbConnection\Db;

class Stock extends AbstractController
{
    public function decGoodStockTry()
    {
        Db::beginTransaction();
        try {
            $postData = $this->request->post();
            foreach ($postData['goods'] as $value) {
                $goodInfo = Db::table('good')->where('id', $value['good_id'])->first();
                if (empty($goodInfo)) {
                    throw new \Exception('商品不存在', 10010);
                }
                //查尋庫存
                $stockInfo = Db::table('good_stock')->where('good_id', $value['good_id'])->first();
                if (empty($goodInfo)) {
                    throw new \Exception('商品庫存為空', 10010);
                }
                if (round($value['number'], 2) > round($stockInfo->total_number, 2)) {
                    throw new \Exception('商品庫存不足', 10010);
                }
                Db::table('good_stock')->where('id', $stockInfo->id)->decrement('total_number', $value['number']);
                //鎖定庫存
                Db::table('good_stock_lock')->insert([
                    'order_no' => $postData['order_no'],
                    'good_id' => $value['good_id'],
                    'number' => $value['number'],
                    'create_time' => time(),
                    'update_time' => time(),
                ]);
                //@todo 庫存記錄
            }
            Db::commit();
            return [
                'code' => 0,
                'data' => 'SUCCESS',
                'msg' => '成功',
            ];
        } catch (\Exception $e) {
            Db::rollBack();
            return [
                'code' => 10010,
                'data' => 'FAILURE',
                'msg' => $e->getMessage(),
            ];
        }
    }

    public function decGoodStockConfirm()
    {
        Db::beginTransaction();
        try {
            $postData = $this->request->post();
            foreach ($postData['goods'] as $value) {
                $info = Db::table('good_stock_lock')->where('good_id', $value['good_id'])
                    ->where('order_no', $postData['order_no'])
                    ->first();
                if (! empty($info) && $info->status == 0) {
                    Db::table('good_stock_lock')->where('id', $info->id)->update([
                        'status' => '1',
                        'update_time' => time(),
                    ]);
                }
            }
            Db::commit();
            return [
                'code' => 0,
                'data' => 'SUCCESS',
                'msg' => '成功',
            ];
        } catch (\Exception $e) {
            Db::rollBack();
            return [
                'code' => 10010,
                'data' => 'FAILURE',
                'msg' => $e->getMessage(),
            ];
        }
    }

    public function decGoodStockCancel()
    {
        Db::beginTransaction();
        try {
            $postData = $this->request->post();
            foreach ($postData['goods'] as $value) {
                $info = Db::table('good_stock_lock')->where('good_id', $value['good_id'])
                    ->where('order_no', $postData['order_no'])
                    ->first();
                if (! empty($info) && $info->status == 0) {
                    Db::table('good_stock_lock')->where('id', $info->id)->update([
                        'status' => '2',
                        'update_time' => time(),
                    ]);
                    Db::table('good_stock')->where('good_id', $value['good_id'])->increment('total_number', $value['number']);
                }
            }
            Db::commit();
            return [
                'code' => 0,
                'data' => 'SUCCESS',
                'msg' => '成功',
            ];
        } catch (\Exception $e) {
            Db::rollBack();
            return [
                'code' => 10010,
                'data' => 'FAILURE',
                'msg' => $e->getMessage(),
            ];
        }
    }
}

再到api-hyperf定義呼叫相關的操作:

<?php

declare(strict_types=1);
/**
 * This file is part of Hyperf.
 *
 * @link     https://www.hyperf.io
 * @document https://hyperf.wiki
 * @contact  group@hyperf.io
 * @license  https://github.com/hyperf/hyperf/blob/master/LICENSE
 */
namespace App\Controller;

use App\Constants\ErrorCode;
use App\Utils\DtmCli;

class Order extends AbstractController
{
    public function addOrder()
    {
        $orderNo = get_order_no();
        $req = [
            'user_id' => 1,
            'pay_amount' => 100,
            'order_no' => $orderNo,
            'goods' => [
                [
                    'good_id' => 10000,
                    'number' => 10,
                ],
            ],
        ];
        $serverList = [
            [
                'server' => 'http://localhost:9551', //可使用etcd等服務註冊發現中介軟體
                'try' => 'addOrderTry',
                'confirm' => 'addOrderConfirm',
                'cancel' => 'addOrderCancel',
            ],
            [
                'server' => 'http://localhost:9552',
                'try' => 'decGoodStockTry',
                'confirm' => 'decGoodStockConfirm',
                'cancel' => 'decGoodStockCancel',
            ],
        ];
        $ret = DtmCli::handleDtmTransaction($serverList, $req);
        if ($ret['is_ok']) {
            return $this->success([]);
        }
        return $this->success([], ErrorCode::CODE_ERROR, $ret['message']);
    }
}

DtmCli::handleDtmTransaction是我封裝的一個操作:

<?php

declare(strict_types=1);
/**
 * This file is part of Hyperf.
 *
 * @link     https://www.hyperf.io
 * @document https://hyperf.wiki
 * @contact  group@hyperf.io
 * @license  https://github.com/hyperf/hyperf/blob/master/LICENSE
 */
namespace App\Utils;

use App\Constants\ErrorCode;
use Dtmcli\Tcc;
use function Dtmcli\tccGlobalTransaction;

class DtmCli
{
    /**
     * @param $serverList
     * @param $req
     */
    public static function handleDtmTransaction($serverList, $req): array
    {
        $isOk = false;
        try {
            $dtm = 'http://localhost:36789/api/dtmsvr';//dtm服務地址
            if (empty($serverList)) {
                throw new \Exception('子服務不能為空', ErrorCode::CODE_ERROR);
            }
            $ret = tccGlobalTransaction($dtm, function ($tcc) use ($req, $serverList) {
                /*
                 * @var Tcc $tcc
                 */
                foreach ($serverList as $value) {
                    if (empty($value['server']) || empty($value['try']) || empty($value['confirm']) || empty($value['cancel'])) {
                        throw new \Exception('子服務錯誤', ErrorCode::CODE_ERROR);
                    }
                    $tryUrl = $value['server'] . '/' . $value['try'];
                    $confirmUrl = $value['server'] . '/' . $value['confirm'];
                    $cancelUrl = $value['server'] . '/' . $value['cancel'];
                    $tcc->callBranch($req, $tryUrl, $confirmUrl, $cancelUrl);
                }
            });
            if (! empty($ret['gid'])) {
                $isOk = true;
            }
            $message = $ret['message'];
        } catch (\Exception $e) {
            $message = $e->getMessage();
        }
        return [
            'is_ok' => $isOk,
            'message' => $message,
        ];
    }
}

都寫好了之後我們來請求一下api-hyperf下的addOrder介面:

可以看到返回操作成功了,我們去表裡看下資料。

dtm表中的資料也是顯示該事務是操作成功的

下面我們再請求一次:

可以看到返回商品庫存不足,因為我們庫存只有10件,上面第一次請求已經賣了10件沒庫存了,所以庫存服務返回庫存不足,我們這裡也提示出來了。

訂單表的相關資料進行了回滾。dtm資料表標識該事務失敗

常見問題

  • hyperf框架修改程式碼後不生效,根目錄下執行composer dump-autoload -o
  • tcc模式下Confirm真正執行業務,不作任何業務檢查,只使用 Try 階段預留的業務資源,所以前面try階段要鎖定好confirm需要的資源。
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章