專案裡遇到兩個系統互相調但不能保證事務一致性的問題,湊巧這段時間跟著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-server
和stock-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-php
的tccGlobalTransaction
函式返回值是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-server
和stock-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 協議》,轉載必須註明作者和本文連結