├── examples ├── tests │ └── Transaction │ │ ├── data.txt │ │ ├── QueryTest.php │ │ ├── SubNestTest.php │ │ ├── BenchmarkTest.php │ │ ├── NetworkTest.php │ │ └── ServiceTest.php ├── Models │ ├── ResetAccountModel.php │ ├── ResetStorageModel.php │ └── ResetOrderModel.php ├── Controllers │ ├── ResetStorageController.php │ ├── ResetAccountController.php │ └── ResetOrderController.php ├── routes.php └── config │ └── rt_database.php ├── src ├── Exception │ └── RtException.php ├── ExceptionCode.php ├── Facades │ ├── RTCenter.php │ ├── RT.php │ ├── TransactionCenter.php │ └── ResetTransaction.php ├── Database │ ├── MySqlProcessor.php │ ├── MySqlGrammar.php │ └── MySqlConnection.php ├── Middleware │ ├── DistributeCenter.php │ └── DistributeTransact.php ├── Console │ ├── CleanRT.php │ ├── ReleaseRT.php │ └── CreateExamples.php └── ResetTransactionServiceProvider.php ├── composer.json ├── LICENSE ├── README_zh-CN.md └── README.md /examples/tests/Transaction/data.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Exception/RtException.php: -------------------------------------------------------------------------------- 1 | put('rt_skip', 1); 23 | $id = parent::processInsertGetId($query, $sql, $values, $sequence); 24 | session()->remove('rt_skip'); 25 | 26 | RT::saveQuery($sql, $values, 0, 0, $sequence, $id); 27 | 28 | return $id; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Middleware/DistributeCenter.php: -------------------------------------------------------------------------------- 1 | exception && $response->exception instanceof RtException) { 24 | $ret = [ 25 | 'error_code' => ExceptionCode::ERROR_RT, 26 | 'message' => $response->exception->getMessage(), 27 | 'errors' => [] 28 | ]; 29 | return Response::json($ret); 30 | } 31 | 32 | 33 | 34 | return $response; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 张子彬 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Facades/RT.php: -------------------------------------------------------------------------------- 1 | get('rt_stmt'); 22 | if ($transactId && is_null($stmt)) { 23 | RT::saveQuery($sql, [], 0, 0); 24 | } 25 | 26 | return $sql; 27 | } 28 | 29 | /** 30 | * Compile the SQL statement to execute a savepoint rollback. 31 | * 32 | * @param string $name 33 | * @return string 34 | */ 35 | public function compileSavepointRollBack($name) 36 | { 37 | $sql = 'ROLLBACK TO SAVEPOINT '.$name; 38 | 39 | $transactId = RT::getTransactId(); 40 | $stmt = session()->get('rt_stmt'); 41 | if ($transactId && is_null($stmt)) { 42 | RT::saveQuery($sql, [], 0, 0); 43 | } 44 | 45 | return $sql; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/tests/Transaction/QueryTest.php: -------------------------------------------------------------------------------- 1 | 1, 42 | // 'order_no' => $orderNo, 43 | // 'stock_qty' => $stockQty, 44 | // 'amount' => $amount 45 | // ]); 46 | 47 | // var_dump($item); 48 | 49 | DB::table('reset_order')->insert([ 50 | [ 51 | 'order_no' => $orderNo, 52 | 'stock_qty' => $stockQty, 53 | 'amount' => $amount, 54 | ], 55 | [ 56 | 'order_no' => $orderNo + 1, 57 | 'stock_qty' => $stockQty + 1, 58 | 'amount' => $amount + 1, 59 | ] 60 | ]); 61 | 62 | RT::commitTest(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Console/CleanRT.php: -------------------------------------------------------------------------------- 1 | whereIn('action', $actionArr)->where('created_at', '<', $createdAt)->get(); 46 | 47 | if ($list->count()) { 48 | $rtIdArr = $list->pluck('transact_id')->toArray(); 49 | DB::table('reset_transact')->whereIn('transact_id', $rtIdArr)->delete(); 50 | DB::table('reset_transact_req')->whereIn('transact_id', $rtIdArr)->delete(); 51 | DB::table('reset_transact_sql')->whereIn('transact_id', $rtIdArr)->delete(); 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Middleware/DistributeTransact.php: -------------------------------------------------------------------------------- 1 | header('rt_request_id'); 24 | $transactId = $request->header('rt_transact_id'); 25 | 26 | if ($transactId) { 27 | if (!$requestId) { 28 | throw new RtException('rt_request_id cannot be null'); 29 | } 30 | session()->put('rt_request_id', $requestId); 31 | $item = DB::connection('rt_center')->table('reset_transact_req')->where('request_id', $requestId)->first(); 32 | if ($item) { 33 | $data = json_decode($item->response, true); 34 | return Response::json($data); 35 | } 36 | 37 | RT::middlewareBeginTransaction($transactId); 38 | } 39 | 40 | $response = $next($request); 41 | 42 | $requestId = $request->header('rt_request_id'); 43 | $transactId = $request->header('rt_transact_id'); 44 | $transactIdArr = explode('-', $transactId); 45 | if ($transactId && $response->isSuccessful()) { 46 | RT::middlewareRollback(); 47 | DB::connection('rt_center')->table('reset_transact_req')->insert([ 48 | 'transact_id' => $transactIdArr[0], 49 | 'request_id' => $requestId, 50 | 'response' => $response->getContent(), 51 | ]); 52 | } 53 | 54 | return $response; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Database/MySqlConnection.php: -------------------------------------------------------------------------------- 1 | checkResult = $checkResult; 25 | return $this; 26 | } 27 | 28 | /** 29 | * Detect the return value when committing the transaction 30 | * 31 | * @return bool 32 | */ 33 | public function getCheckResult() 34 | { 35 | return $this->checkResult; 36 | } 37 | 38 | /** 39 | * Get the default query grammar instance. 40 | * 41 | * @return \Illuminate\Database\Query\Grammars\MySqlGrammar 42 | */ 43 | protected function getDefaultQueryGrammar() 44 | { 45 | return $this->withTablePrefix(new MySqlGrammar); 46 | } 47 | 48 | /** 49 | * Get the default post processor instance. 50 | * 51 | * @return \Illuminate\Database\Query\Processors\MySqlProcessor 52 | */ 53 | protected function getDefaultPostProcessor() 54 | { 55 | return new MySqlProcessor; 56 | } 57 | 58 | /** 59 | * Run an SQL statement and get the number of rows affected. 60 | * 61 | * @param string $query 62 | * @param array $bindings 63 | * @return int 64 | */ 65 | public function affectingStatement($query, $bindings = []) 66 | { 67 | $result = parent::affectingStatement($query, $bindings); 68 | 69 | RT::saveQuery($query, $bindings, $result, $this->checkResult); 70 | $this->checkResult = false; 71 | 72 | return $result; 73 | } 74 | 75 | /** 76 | * Execute an SQL statement and return the boolean result. 77 | * 78 | * @param string $query 79 | * @param array $bindings 80 | * @return bool 81 | */ 82 | public function statement($query, $bindings = []) 83 | { 84 | $result = parent::statement($query, $bindings); 85 | 86 | RT::saveQuery($query, $bindings, $result, 0); 87 | 88 | return $result; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /examples/Controllers/ResetStorageController.php: -------------------------------------------------------------------------------- 1 | input()); 38 | } 39 | 40 | /** 41 | * Display the specified resource. 42 | * 43 | * @param int $id 44 | * @return \Illuminate\Http\Response 45 | */ 46 | public function show($id) 47 | { 48 | // 49 | $item = ResetStorageModel::find($id); 50 | return $item ?? []; 51 | } 52 | 53 | /** 54 | * Update the specified resource in storage. 55 | * 56 | * @param \Illuminate\Http\Request $request 57 | * @param int $id 58 | * @return \Illuminate\Http\Response 59 | */ 60 | public function update(Request $request, $id) 61 | { 62 | // 63 | $item = ResetStorageModel::findOrFail($id); 64 | if ($request->has('decr_stock_qty')) { 65 | $decrQty = (float) $request->input('decr_stock_qty'); 66 | $ret = $item->where('stock_qty', '>', $decrQty)->decrement('stock_qty', $decrQty); 67 | } else { 68 | $ret = $item->update($request->input()); 69 | } 70 | return ['result' => $ret]; 71 | } 72 | 73 | /** 74 | * Remove the specified resource from storage. 75 | * 76 | * @param int $id 77 | * @return \Illuminate\Http\Response 78 | */ 79 | public function destroy($id) 80 | { 81 | // 82 | $item = ResetStorageModel::findOrFail($id); 83 | $ret = $item->delete(); 84 | return ['result' => $ret]; 85 | } 86 | 87 | public function updateWithCommit(Request $request, $id) 88 | { 89 | $item = ResetStorageModel::findOrFail($id); 90 | DB::beginTransaction(); 91 | 92 | if ($request->has('decr_stock_qty')) { 93 | $decrQty = (float) $request->input('decr_stock_qty'); 94 | $ret = $item->where('stock_qty', '>', $decrQty)->decrement('stock_qty', $decrQty); 95 | } else { 96 | $ret = $item->update($request->input()); 97 | } 98 | 99 | DB::commit(); 100 | 101 | return ['result' => $ret]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/tests/Transaction/SubNestTest.php: -------------------------------------------------------------------------------- 1 | put('rt_request_id', $requestId); 36 | 37 | $this->client = new Client([ 38 | 'base_uri' => $this->baseUri, 39 | 'timeout' => 5, 40 | ]); 41 | } 42 | 43 | public function testCreateOrdersCommit() 44 | { 45 | $transactId = RT::beginTransaction(); 46 | 47 | ResetOrderModel::create([ 48 | 'order_no' => rand(1000, 9999), 49 | 'stock_qty' => 0, 50 | 'amount' => 0 51 | ]); 52 | 53 | // 请求账户服务,减金额 54 | $response = $this->client->post('/api/resetAccountTest/createOrdersRollback', [ 55 | 'headers' => [ 56 | 'rt_request_id' => session_create_id(), 57 | 'rt_transact_id' => $transactId, 58 | 59 | ] 60 | ]); 61 | $resArr = $this->responseToArray($response); 62 | 63 | $this->assertTrue($resArr['result']); 64 | 65 | RT::commit(); 66 | } 67 | 68 | public function testCreateOrdersRollback() 69 | { 70 | $transactId = RT::beginTransaction(); 71 | 72 | // 请求账户服务,减金额 73 | $response = $this->client->post('/api/resetAccountTest/createOrdersCommit', [ 74 | 'headers' => [ 75 | 'rt_request_id' => session_create_id(), 76 | 'rt_transact_id' => $transactId, 77 | 78 | ] 79 | ]); 80 | $resArr = $this->responseToArray($response); 81 | 82 | $this->assertTrue($resArr['result']); 83 | 84 | RT::rollBack(); 85 | } 86 | 87 | public function testNestTransact() 88 | { 89 | RT::beginTransaction(); 90 | RT::beginTransaction(); 91 | RT::beginTransaction(); 92 | 93 | ResetOrderModel::create([ 94 | 'order_no' => rand(1000, 9999), 95 | 'stock_qty' => 0, 96 | 'amount' => 0 97 | ]); 98 | 99 | RT::commit(); 100 | RT::commit(); 101 | RT::commit(); 102 | } 103 | 104 | private function responseToArray($response) 105 | { 106 | $contents = $response->getBody()->getContents(); 107 | return json_decode($contents, true); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/ResetTransactionServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadRoutesFrom(__DIR__ . '/../examples/routes.php'); 27 | } 28 | 29 | /** 30 | * Register the service provider. 31 | * 32 | * @return void 33 | */ 34 | public function register() 35 | { 36 | $this->app['router']->aliasMiddleware('distribute.transact', DistributeTransact::class); 37 | $this->app['router']->aliasMiddleware('distribute.center', DistributeCenter::class); 38 | 39 | $this->app->singleton( 40 | 'command.resetTransact.create-examples', 41 | function ($app) { 42 | return new CreateExamples($app['files']); 43 | } 44 | ); 45 | $this->app->singleton( 46 | 'command.resetTransact.clean-rt', 47 | function ($app) { 48 | return new CleanRT(); 49 | } 50 | ); 51 | $this->app->singleton( 52 | 'command.resetTransact.release-rt', 53 | function ($app) { 54 | return new ReleaseRT(); 55 | } 56 | ); 57 | $this->commands( 58 | 'command.resetTransact.create-examples', 59 | 'command.resetTransact.clean-rt', 60 | 'command.resetTransact.release-rt' 61 | ); 62 | 63 | $this->app->singleton('rt', function ($app) { 64 | return new ResetTransaction(); 65 | }); 66 | 67 | $this->app->singleton('rt_center', function ($app) { 68 | return new TransactionCenter(); 69 | }); 70 | 71 | 72 | 73 | Connection::resolverFor('mysql', function ($connection, $database, $prefix, $config) { 74 | // Next we can initialize the connection. 75 | $connection = new MySqlConnection($connection, $database, $prefix, $config); 76 | return $connection; 77 | }); 78 | 79 | Builder::macro('setCheckResult', function (bool $bool) { 80 | $this->getConnection()->setCheckResult($bool); 81 | 82 | return $this; 83 | }); 84 | 85 | $configList = config('rt_database.service_connections', []); 86 | $connections = $this->app['config']['database.connections']; 87 | foreach ($configList as $name => $config) { 88 | $connections[$name] = $config; 89 | } 90 | 91 | $centerConn = config('rt_database.center.connections.rt_center'); 92 | if ($centerConn) { 93 | $connections['rt_center'] = $centerConn; 94 | } 95 | $this->app['config']['database.connections'] = $connections; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /examples/routes.php: -------------------------------------------------------------------------------- 1 | middleware(['api', 'distribute.transact'])->group(function () { 9 | Route::resource('/resetOrder', \App\Http\Controllers\ResetOrderController::class); 10 | Route::resource('/resetStorage', \App\Http\Controllers\ResetStorageController::class); 11 | Route::resource('/resetAccount', \App\Http\Controllers\ResetAccountController::class); 12 | 13 | Route::post('/resetAccountTest/createOrdersCommit', [\App\Http\Controllers\ResetAccountController::class, 'createOrdersCommit']); 14 | Route::post('/resetAccountTest/createOrdersRollback', [\App\Http\Controllers\ResetAccountController::class, 'createOrdersRollback']); 15 | 16 | Route::put('/resetStorageTest/updateWithCommit/{id}', [\App\Http\Controllers\ResetStorageController::class, 'updateWithCommit']); 17 | Route::post('/resetOrderTest/createWithTimeout', [\App\Http\Controllers\ResetOrderController::class, 'createWithTimeout']); 18 | 19 | // ab test 20 | Route::put('/resetOrderTest/updateOrCreate/{id}', [\App\Http\Controllers\ResetOrderController::class, 'updateOrCreate']); 21 | Route::get('/resetOrderTest/deadlockWithLocal', [\App\Http\Controllers\ResetOrderController::class, 'deadlockWithLocal']); 22 | Route::get('/resetOrderTest/deadlockWithRt', [\App\Http\Controllers\ResetOrderController::class, 'deadlockWithRt']); 23 | 24 | Route::get('/resetOrderTest/orderWithLocal', [\App\Http\Controllers\ResetOrderController::class, 'orderWithLocal']); 25 | Route::get('/resetOrderTest/orderWithRt', [\App\Http\Controllers\ResetOrderController::class, 'orderWithRt']); 26 | Route::get('/resetOrderTest/disorderWithLocal', [\App\Http\Controllers\ResetOrderController::class, 'disorderWithLocal']); 27 | Route::get('/resetOrderTest/disorderWithRt', [\App\Http\Controllers\ResetOrderController::class, 'disorderWithRt']); 28 | }); 29 | 30 | Route::prefix('api')->middleware(['api', 'distribute.center'])->group(function () { 31 | Route::post('/resetTransaction/commit', function (Request $request) { 32 | $validator = Validator::make($request->all(), [ 33 | 'transact_id' => ['required'], 34 | 'transact_rollback' => ['array'], 35 | ]); 36 | 37 | if ($validator->fails()) { 38 | return [ 39 | 'error_code' => ExceptionCode::ERROR_VALIDATION, 40 | 'message' => 'validate fail', 41 | 'errors' => $validator->errors()->toArray(), 42 | ]; 43 | } 44 | 45 | $transactId = request('transact_id'); 46 | $transactRollback = request('transact_rollback', []); 47 | 48 | $ret = RTCenter::commit($transactId, $transactRollback); 49 | 50 | return $ret; 51 | }); 52 | 53 | Route::post('/resetTransaction/rollback', function (Request $request) { 54 | $validator = Validator::make($request->all(), [ 55 | 'transact_id' => ['required'], 56 | 'transact_rollback' => ['array'], 57 | ]); 58 | 59 | if ($validator->fails()) { 60 | return [ 61 | 'error_code' => ExceptionCode::ERROR_VALIDATION, 62 | 'message' => 'validate fail', 63 | 'errors' => $validator->errors()->toArray(), 64 | ]; 65 | } 66 | 67 | $transactId = request('transact_id'); 68 | $transactRollback = request('transact_rollback', []); 69 | 70 | $ret = RTCenter::rollback($transactId, $transactRollback); 71 | 72 | return $ret; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /examples/config/rt_database.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'commit_url' => 'http://127.0.0.1:8001/api/resetTransaction/commit', 6 | 'rollback_url' => 'http://127.0.0.1:8001/api/resetTransaction/rollback', 7 | 'connections' => [ 8 | 'rt_center' => [ 9 | 'driver' => 'mysql', 10 | 'host' => env('DB_HOST', '127.0.0.1'), 11 | 'port' => env('DB_PORT', '3306'), 12 | 'database' => 'rt_center', 13 | 'username' => env('DB_USERNAME', 'forge'), 14 | 'password' => env('DB_PASSWORD', ''), 15 | 'charset' => 'utf8mb4', 16 | 'collation' => 'utf8mb4_unicode_ci', 17 | 'prefix' => '', 18 | 'prefix_indexes' => true, 19 | 'strict' => true, 20 | 'engine' => null, 21 | 'options' => extension_loaded('pdo_mysql') ? array_filter([ 22 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), 23 | ]) : [], 24 | ], 25 | ], 26 | 'crontab' => [ 27 | 'clean_after' => 600, 28 | 'release_after' => 120, 29 | ] 30 | ], 31 | 'service_connections' => [ 32 | 'service_order' => [ 33 | 'driver' => 'mysql', 34 | 'connection_name' => 'service_order', 35 | 'host' => env('DB_HOST', '127.0.0.1'), 36 | 'port' => env('DB_PORT', '3306'), 37 | 'database' => 'service_order', 38 | 'username' => env('DB_USERNAME', 'forge'), 39 | 'password' => env('DB_PASSWORD', ''), 40 | 'charset' => 'utf8mb4', 41 | 'collation' => 'utf8mb4_unicode_ci', 42 | 'prefix' => '', 43 | 'prefix_indexes' => true, 44 | 'strict' => true, 45 | 'engine' => null, 46 | 'options' => extension_loaded('pdo_mysql') ? array_filter([ 47 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), 48 | ]) : [], 49 | ], 50 | 'service_storage' => [ 51 | 'driver' => 'mysql', 52 | 'connection_name' => 'service_storage', 53 | 'host' => env('DB_HOST', '127.0.0.1'), 54 | 'port' => env('DB_PORT', '3306'), 55 | 'database' => 'service_storage', 56 | 'username' => env('DB_USERNAME', 'forge'), 57 | 'password' => env('DB_PASSWORD', ''), 58 | 'charset' => 'utf8mb4', 59 | 'collation' => 'utf8mb4_unicode_ci', 60 | 'prefix' => '', 61 | 'prefix_indexes' => true, 62 | 'strict' => true, 63 | 'engine' => null, 64 | 'options' => extension_loaded('pdo_mysql') ? array_filter([ 65 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), 66 | ]) : [], 67 | ], 68 | 'service_account' => [ 69 | 'driver' => 'mysql', 70 | 'connection_name' => 'service_account', 71 | 'host' => env('DB_HOST', '127.0.0.1'), 72 | 'port' => env('DB_PORT', '3306'), 73 | 'database' => 'service_account', 74 | 'username' => env('DB_USERNAME', 'forge'), 75 | 'password' => env('DB_PASSWORD', ''), 76 | 'charset' => 'utf8mb4', 77 | 'collation' => 'utf8mb4_unicode_ci', 78 | 'prefix' => '', 79 | 'prefix_indexes' => true, 80 | 'strict' => true, 81 | 'engine' => null, 82 | 'options' => extension_loaded('pdo_mysql') ? array_filter([ 83 | PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), 84 | ]) : [], 85 | ], 86 | ] 87 | 88 | ]; 89 | -------------------------------------------------------------------------------- /README_zh-CN.md: -------------------------------------------------------------------------------- 1 | ## 快速预览 2 | 安装laravel5.5 - laravel8之间的版本,然后安装composer包 3 | ```shell 4 | ## 必须使用composer2版本 5 | composer require windawake/laravel-reset-transaction dev-master 6 | ``` 7 | 8 | 首先创建order,storage,account3个mysql数据库实例,3个控制器,3个model,在phpunit.xml增加testsuite Transaction,然后启动web服务器。这些操作只需要执行下面命令全部完成 9 | ```shell 10 | php artisan resetTransact:create-examples && php artisan serve --host=0.0.0.0 --port=8000 11 | ``` 12 | 打开另一个terminal,启动端口为8001的web服务器 13 | ```shell 14 | php artisan serve --host=0.0.0.0 --port=8001 15 | ``` 16 | 最后运行测试脚本 ` 17 | ./vendor/bin/phpunit --testsuite=Transaction --filter=ServiceTest 18 | `运行结果如下所示,3个例子测试通过。 19 | ```shell 20 | DESKTOP:/web/linux/php/laravel/laravel62# ./vendor/bin/phpunit --testsuite=Transaction --filter=ServiceTest 21 | Time: 219 ms, Memory: 22.00 MB 22 | 23 | OK (3 tests, 12 assertions) 24 | ``` 25 | 26 | ## 功能特性 27 | 1. 开箱即用,不需要重构原有项目的代码,与mysql事务写法一致,简单易用。 28 | 2. 两段提交的强一致性事务,高并发下,支持读已提交的事务隔离级别,数据一致性几乎100%。 29 | 3. 性能超过seata AT模式,由于事务拆分成多个,变成了几个小事务,压测发现比mysql普通事务更少发生死锁。 30 | 4. 支持分布式事务嵌套,与savepoint一致效果。 31 | 5. 支持避免不同业务代码并发造成脏数据的问题。 32 | 6. 默认支持http协议的服务化接口,想要支持其它协议则需要重写中间件。 33 | 7. [支持子服务嵌套分布式事务(突破技术)](#支持子服务嵌套分布式事务(突破技术))。 34 | 8. 支持服务,本地事务和分布式事务混合嵌套 35 | 9. 支持超时3次重试,重复请求保证幂等性 36 | 10. 几乎支持所有sql语句,可以批量插入,批量更新,批量删除(突破技术) 37 | 11. 支持检测xa prepare造成的锁,更加精准地释放xa锁 38 | 12. 支持go,java语言(开发中) 39 | 40 | 对比阿里seata AT模式,有什么优点?请阅读 https://learnku.com/articles/63797 41 | 可行性报告,请阅读 https://learnku.com/articles/64923 42 | 43 | ## 解决了哪些并发场景 44 | - [x] 一个待发货订单,用户同时操作发货和取消订单,只有一个成功 45 | - [x] 积分换取优惠券,只要出现积分不够扣减或者优惠券的库存不够扣减,就会全部失败。 46 | 47 | ## 原理解析 48 | Reset Transaction,中文名为重置型分布式事务,又命名为RT模式,与seata AT模式都是属于二段提交。跟中国电视剧的【穿越】是同一个意思。 49 | 看过《明日边缘》电影就会知道,存档和读档的操作。这个分布式事务组件仿造《明日边缘》电影的原理,每次请求基础服务一开始时读档,然后继续后面的操作,结束时所有操作全部回滚并且存档,最后一步commit把存档全部执行成功。整个过程是遵守两段提交协议,先prepare,最后commit。 50 | 51 | 以创建一个订单并且扣减一个库存的场景为例子,画了以下流程图。 52 | ![](https://cdn.learnku.com/uploads/images/202202/19/46914/9bcNTn58CH.png!large) 53 | 右图开启分布式事务RT模式后,比左图多了请求4。请求4所做的事情,都是请求1-3之前做过的东西,又回来原点重新再来,最终提交事务,结束这创建订单的流程。 54 | 55 | ## 支持子服务嵌套分布式事务(突破技术) 56 | ![](https://cdn.learnku.com/uploads/images/202112/30/46914/IzHhjfjHC1.png!large) 57 | 世界级的一个难题:A服务commit->B服务rollback->C服务commit->D服务commit sql,这种场景下,ABCD都是不同数据库,如何才能实现让A服务提交了B服务,回滚了C服务和D服务的所有操作呢? 58 | 59 | 这个问题,seata和go-dtm都没法解决。解决问题的关键点在于C服务和D服务必须要假提交,不能真提交,如果真提交就无力回天了。 60 | 61 | 实现支持子服务嵌套分布式事务后,带来什么好处呢?可以让A服务成为别人的服务,并且任意嵌套在链路里任何一层。打破了以往的束缚:A服务必须是根服务,A服务若要成为子服务,必须大改代码。用了RT模式的话,A服务不需要修改代码就能成为别人的服务。 62 | 63 | ## 如何使用 64 | 65 | 在laravel框架里,把门面DB换成RT就能实现分布式事务。 66 | ```php 67 | put('http://127.0.0.1:8000/api/resetOrder/11', [ 81 | 'json' => [ 82 | 'order_no' => 'aaa', 83 | ], 84 | 'headers' => [ 85 | 'rt_request_id' => session_create_id(), //支持幂等 86 | 'rt_transact_id' => RT::getTransactId(), //让订单服务知道,当前是在分布式事务内部 87 | ] 88 | ]); 89 | RT::commit(); 90 | ``` 91 | 具体例子可以查看composer包里面的`vendor/windawake/laravel-reset-transaction/examples/tests/Transaction/ServiceTest.php`的代码。 92 | 93 | ## 个人笔记 94 | 本人之前写了[laravel快速服务化包](https://learnku.com/articles/61638 "laravel快速服务化包"),但是它没有解决数据一致性的问题。尝试用XA,但是XA只能解决跨数据但是不能解决跨服务的问题。然后我又尝试去研究tcc和seata,难学而且难用,没办法了只能自创分布式事务解决方案。一直以来,我一直以为单单只用mysql是没法解决分布式事务的问题,现在终于明白,还是有办法滴! 95 | 96 | ![](https://cdn.learnku.com/uploads/images/202202/25/46914/heg3sLvwiG.jpg!large) 97 | 98 | 希望有更多的朋友相互学习和一起研究分布式事务的知识。 99 | ## 相关资源 100 | laravel版本: 101 | https://github.com/windawake/laravel-reset-transaction 102 | https://gitee.com/windawake/laravel-reset-transaction 103 | hyperf版本(包含压测报告): 104 | https://github.com/windawake/hyperf-reset-transaction 105 | https://gitee.com/windawake/hyperf-reset-transaction 106 | 107 | -------------------------------------------------------------------------------- /src/Console/ReleaseRT.php: -------------------------------------------------------------------------------- 1 | whereIn('action', $actionArr)->where('created_at', '<', $createdAt)->get(); 52 | 53 | $recover = $this->getXaRecover(); 54 | 55 | foreach ($list as $item) { 56 | $xidArr = json_decode($item->xids_info, true); 57 | switch ($item->action) { 58 | case RTCenter::ACTION_PREPARE: 59 | foreach ($xidArr as $name => $xid) { 60 | $arr = $recover[$name] ?? []; 61 | if (in_array($xid, $arr)) { 62 | $this->tryCatch(function () use ($name, $xid) { 63 | DB::connection($name)->getPdo()->exec("xa rollback '$xid'"); 64 | }); 65 | } 66 | } 67 | DB::table('reset_transact')->where('transact_id', $item->transact_id)->update(['action' => RTCenter::ACTION_START]); 68 | break; 69 | case RTCenter::ACTION_PREPARE_COMMIT: 70 | foreach ($xidArr as $name => $xid) { 71 | $arr = $recover[$name] ?? []; 72 | if (in_array($xid, $arr)) { 73 | $this->tryCatch(function () use ($name, $xid) { 74 | DB::connection($name)->getPdo()->exec("xa commit '$xid'"); 75 | }); 76 | } 77 | } 78 | DB::table('reset_transact')->where('transact_id', $item->transact_id)->update(['action' => RTCenter::ACTION_COMMIT]); 79 | break; 80 | case RTCenter::ACTION_PREPARE_ROLLBACK: 81 | foreach ($xidArr as $name => $xid) { 82 | $arr = $recover[$name] ?? []; 83 | if (in_array($xid, $arr)) { 84 | $this->tryCatch(function () use ($name, $xid) { 85 | DB::connection($name)->getPdo()->exec("xa rollback '$xid'"); 86 | }); 87 | } 88 | } 89 | DB::table('reset_transact')->where('transact_id', $item->transact_id)->update(['action' => RTCenter::ACTION_ROLLBACK]); 90 | // no break 91 | default: 92 | break; 93 | 94 | } 95 | } 96 | } 97 | } 98 | 99 | private function getXaRecover() 100 | { 101 | $recover = []; 102 | $configList = config('rt_database.service_connections', []); 103 | foreach ($configList as $name => $config) { 104 | $dsn="{$config['driver']}:host={$config['host']};port={$config['port']};dbname={$config['database']}"; 105 | $pdo = new PDO($dsn, $config['username'], $config['password']); 106 | $statement = $pdo->prepare("xa recover"); 107 | $statement->execute(); 108 | $list = $statement->fetchAll(); 109 | if ($list) { 110 | $recover[$name] = array_column($list, 'data'); 111 | } 112 | } 113 | 114 | return $recover; 115 | } 116 | 117 | private function tryCatch(Closure $tryCallback, Closure $catchCallback = null) 118 | { 119 | try { 120 | $tryCallback(); 121 | } catch (Exception $ex) { 122 | if (!is_null($catchCallback)) { 123 | $catchCallback($ex); 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /examples/tests/Transaction/BenchmarkTest.php: -------------------------------------------------------------------------------- 1 | urlOne}/resetOrderTest/deadlockWithLocal"; 29 | $shellTwo = "ab -n 12 -c 4 {$this->urlTwo}/resetOrderTest/deadlockWithLocal"; 30 | 31 | $shell = sprintf("%s & %s", $shellOne, $shellTwo); 32 | exec($shell, $output, $resultCode); 33 | 34 | // $sql = "SHOW ENGINE INNODB STATUS"; 35 | // $ret = $con->select($sql); 36 | // Log::info($ret); 37 | } 38 | 39 | public function testDeadlock02() 40 | { 41 | $shellOne = "ab -n 12 -c 4 {$this->urlOne}/resetOrderTest/deadlockWithRt"; 42 | $shellTwo = "ab -n 12 -c 4 {$this->urlTwo}/resetOrderTest/deadlockWithRt"; 43 | 44 | $shell = sprintf("%s & %s", $shellOne, $shellTwo); 45 | exec($shell, $output, $resultCode); 46 | } 47 | 48 | public function testBatchCreate01() 49 | { 50 | $count1 = ResetOrderModel::count(); 51 | 52 | $shellOne = "ab -n 100 -c 10 {$this->urlOne}/resetOrderTest/orderWithLocal"; 53 | $shellTwo = "ab -n 100 -c 10 {$this->urlTwo}/resetOrderTest/orderWithLocal"; 54 | $shellThree = "ab -n 100 -c 10 {$this->urlThree}/resetOrderTest/orderWithLocal"; 55 | 56 | $shell = sprintf("%s & %s & %s", $shellOne, $shellTwo, $shellThree); 57 | exec($shell, $output, $resultCode); 58 | $count2 = ResetOrderModel::count(); 59 | 60 | $this->assertTrue($count2 - $count1 == 300); 61 | } 62 | 63 | public function testBatchCreate02() 64 | { 65 | $count1 = ResetOrderModel::count(); 66 | 67 | $shellOne = "ab -n 100 -c 10 {$this->urlOne}/resetOrderTest/orderWithRt"; 68 | $shellTwo = "ab -n 100 -c 10 {$this->urlTwo}/resetOrderTest/orderWithRt"; 69 | $shellThree = "ab -n 100 -c 10 {$this->urlThree}/resetOrderTest/orderWithRt"; 70 | 71 | $shell = sprintf("%s & %s & %s", $shellOne, $shellTwo, $shellThree); 72 | exec($shell, $output, $resultCode); 73 | $count2 = ResetOrderModel::count(); 74 | 75 | $this->assertTrue($count2 - $count1 == 300); 76 | } 77 | 78 | public function testBatchCreate03() 79 | { 80 | ResetOrderModel::where('id', '<=', 10)->delete(); 81 | sleep(6); 82 | $shellOne = "ab -n 100 -c 10 {$this->urlOne}/resetOrderTest/disorderWithLocal"; 83 | $shellTwo = "ab -n 100 -c 10 {$this->urlTwo}/resetOrderTest/disorderWithLocal"; 84 | $shellThree = "ab -n 100 -c 10 {$this->urlThree}/resetOrderTest/disorderWithLocal"; 85 | 86 | $shell = sprintf("%s & %s & %s", $shellOne, $shellTwo, $shellThree); 87 | exec($shell, $output, $resultCode); 88 | } 89 | 90 | public function testBatchCreate04() 91 | { 92 | ResetOrderModel::where('id', '<=', 10)->delete(); 93 | sleep(6); 94 | $shellOne = "ab -n 100 -c 10 {$this->urlOne}/resetOrderTest/disorderWithRt"; 95 | $shellTwo = "ab -n 100 -c 10 {$this->urlTwo}/resetOrderTest/disorderWithRt"; 96 | $shellThree = "ab -n 100 -c 10 {$this->urlThree}/resetOrderTest/disorderWithRt"; 97 | 98 | $shell = sprintf("%s & %s & %s", $shellOne, $shellTwo, $shellThree); 99 | exec($shell, $output, $resultCode); 100 | } 101 | 102 | public function testBatchCreate05() 103 | { 104 | ResetOrderModel::truncate(); 105 | 106 | $amount1 = ResetAccountModel::where('id', 1)->value('amount'); 107 | $stockQty1 = ResetStorageModel::where('id', 1)->value('stock_qty'); 108 | 109 | $dataPath = __DIR__.'/data.txt'; 110 | $shellOne = "ab -n 12 -c 4 -p '{$dataPath}' {$this->urlOne}/resetAccountTest/createOrdersCommit"; 111 | $shellTwo = "ab -n 12 -c 4 -p '{$dataPath}' {$this->urlTwo}/resetAccountTest/createOrdersCommit"; 112 | $shellThree = "ab -n 12 -c 4 -p '{$dataPath}' {$this->urlThree}/resetAccountTest/createOrdersCommit"; 113 | 114 | $shell = sprintf("%s & %s & %s", $shellOne, $shellTwo, $shellThree); 115 | exec($shell, $output, $resultCode); 116 | 117 | $amount2 = ResetAccountModel::where('id', 1)->value('amount'); 118 | $stockQty2 = ResetStorageModel::where('id', 1)->value('stock_qty'); 119 | 120 | $amountSum = ResetOrderModel::sum('amount'); 121 | $stockQtySum = ResetOrderModel::sum('stock_qty'); 122 | $total = ResetOrderModel::count(); 123 | 124 | $this->assertTrue($total == 36); 125 | $this->assertTrue(abs($amount1 - $amount2 - $amountSum) < 0.001); 126 | $this->assertTrue(($stockQty1 - $stockQty2) == $stockQtySum); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /examples/Controllers/ResetAccountController.php: -------------------------------------------------------------------------------- 1 | input()); 39 | } 40 | 41 | /** 42 | * Display the specified resource. 43 | * 44 | * @param int $id 45 | * @return \Illuminate\Http\Response 46 | */ 47 | public function show($id) 48 | { 49 | // 50 | $item = ResetAccountModel::find($id); 51 | return $item ?? []; 52 | } 53 | 54 | /** 55 | * Update the specified resource in storage. 56 | * 57 | * @param \Illuminate\Http\Request $request 58 | * @param int $id 59 | * 60 | * @return \Illuminate\Http\Response 61 | */ 62 | public function update(Request $request, $id) 63 | { 64 | // 65 | $item = ResetAccountModel::findOrFail($id); 66 | if ($request->has('decr_amount')) { 67 | $decrAmount = (float) $request->input('decr_amount'); 68 | $ret = $item->where('amount', '>', $decrAmount)->decrement('amount', $decrAmount); 69 | } else { 70 | $ret = $item->update($request->input()); 71 | } 72 | 73 | return ['result' => $ret]; 74 | } 75 | 76 | /** 77 | * Remove the specified resource from storage. 78 | * 79 | * @param int $id 80 | * @return \Illuminate\Http\Response 81 | */ 82 | public function destroy($id) 83 | { 84 | // 85 | $item = ResetAccountModel::findOrFail($id); 86 | $ret = $item->delete(); 87 | return ['result' => $ret]; 88 | } 89 | 90 | /** 91 | * transaction create order then commit 92 | * 93 | * @return \Illuminate\Http\Response 94 | */ 95 | public function createOrdersCommit() 96 | { 97 | $client = new Client([ 98 | 'timeout' => 30, 99 | ]); 100 | $orderNo = session_create_id(); 101 | $stockQty = rand(1, 5); 102 | $amount = rand(1, 50)/10; 103 | $transactId = RT::beginTransaction(); 104 | 105 | $client->post('http://127.0.0.1:8003/api/resetOrder', [ 106 | 'json' => [ 107 | 'order_no' => $orderNo, 108 | 'stock_qty' => $stockQty, 109 | 'amount' => $amount 110 | ], 111 | 'headers' => [ 112 | 'rt_request_id' => session_create_id(), 113 | 'rt_transact_id' => $transactId, 114 | ] 115 | ]); 116 | 117 | $response = $client->put('http://127.0.0.1:8004/api/resetStorage/1', [ 118 | 'json' => [ 119 | 'decr_stock_qty' => $stockQty 120 | ], 121 | 'headers' => [ 122 | 'rt_request_id' => session_create_id(), 123 | 'rt_transact_id' => $transactId, 124 | ] 125 | ]); 126 | 127 | $resArr = json_decode($response->getBody()->getContents(), true); 128 | 129 | $rowCount = ResetAccountModel::setCheckResult(true)->where('id', 1)->where('amount', '>', $amount)->decrement('amount', $amount); 130 | 131 | $result = $resArr['result'] && $rowCount>0; 132 | 133 | RT::commit(); 134 | 135 | return ['result' => $result]; 136 | } 137 | 138 | /** 139 | * transaction create order then rollBack 140 | * 141 | * @return \Illuminate\Http\Response 142 | */ 143 | public function createOrdersRollback() 144 | { 145 | $client = new Client([ 146 | 'timeout' => 30, 147 | ]); 148 | $orderNo = session_create_id(); 149 | $stockQty = rand(1, 5); 150 | $amount = rand(1, 50)/10; 151 | $transactId = RT::beginTransaction(); 152 | 153 | $client->post('http://127.0.0.1:8003/api/resetOrder', [ 154 | 'json' => [ 155 | 'order_no' => $orderNo, 156 | 'stock_qty' => $stockQty, 157 | 'amount' => $amount 158 | ], 159 | 'headers' => [ 160 | 'rt_request_id' => session_create_id(), 161 | 'rt_transact_id' => $transactId, 162 | 163 | ] 164 | ]); 165 | 166 | $client->put('http://127.0.0.1:8004/api/resetStorageTest/updateWithCommit/1', [ 167 | 'json' => [ 168 | 'decr_stock_qty' => $stockQty 169 | ], 170 | 'headers' => [ 171 | 'rt_request_id' => session_create_id(), 172 | 'rt_transact_id' => $transactId, 173 | ] 174 | ]); 175 | 176 | ResetAccountModel::setCheckResult(true)->where('id', 1)->where('amount', '>', $amount)->decrement('amount', $amount); 177 | 178 | RT::rollBack(); 179 | 180 | return ['result' => true]; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /examples/tests/Transaction/NetworkTest.php: -------------------------------------------------------------------------------- 1 | put('rt_request_id', $requestId); 36 | 37 | $stack = HandlerStack::create(); 38 | $stack->push(Middleware::retry(function ($retries, Request $request, Response $response, $exception) { 39 | // 重试达到3次就报错 40 | if ($retries >= 3) { 41 | return false; 42 | } 43 | 44 | // 请求失败,继续重试 45 | if ($exception instanceof ConnectException) { 46 | return true; 47 | } 48 | 49 | if ($response) { 50 | // 如果请求有响应,但是状态码大于等于500,继续重试(这里根据自己的业务而定) 51 | if ($response->getStatusCode() >= 500) { 52 | return true; 53 | } 54 | } 55 | 56 | return false; 57 | })); 58 | 59 | $this->client = new Client([ 60 | 'base_uri' => $this->baseUri, 61 | 'timeout' => 5, 62 | 'handler' => $stack, 63 | ]); 64 | } 65 | 66 | public function testTimeout() 67 | { 68 | $orderCount1 = ResetOrderModel::count(); 69 | 70 | $requestId = session_create_id(); 71 | $transactId = RT::beginTransaction(); 72 | $orderNo = rand(1000, 9999); 73 | $stockQty = 1; 74 | $amount = 10; 75 | 76 | $startTime = microtime(true); 77 | // 创建订单 78 | $response = $this->client->post('/api/resetOrderTest/createWithTimeout', [ 79 | 'json' => [ 80 | 'order_no' => $orderNo, 81 | 'stock_qty' => $stockQty, 82 | 'amount' => $amount 83 | ], 84 | 'headers' => [ 85 | 'rt_request_id' => $requestId, 86 | 'rt_transact_id' => $transactId, 87 | 88 | ], 89 | ]); 90 | 91 | var_dump(microtime(true) - $startTime); 92 | $resArr1 = $this->responseToArray($response); 93 | $this->assertTrue($resArr1['order_no'] == $orderNo); 94 | RT::commit(); 95 | 96 | $orderCount2 = ResetOrderModel::count(); 97 | 98 | $this->assertTrue(($orderCount1 + 1) == $orderCount2); 99 | } 100 | 101 | public function testDuplicateRequest() 102 | { 103 | $orderCount1 = ResetOrderModel::count(); 104 | 105 | $requestId = session_create_id(); 106 | // $requestId = 111; 107 | $transactId = RT::beginTransaction(); 108 | $orderNo = rand(1000, 9999); 109 | $stockQty = 2; 110 | $amount = 20.55; 111 | 112 | // 创建订单 113 | $response = $this->client->post('/api/resetOrder', [ 114 | 'json' => [ 115 | 'order_no' => $orderNo, 116 | 'stock_qty' => $stockQty, 117 | 'amount' => $amount 118 | ], 119 | 'headers' => [ 120 | 'rt_request_id' => $requestId, 121 | 'rt_transact_id' => $transactId, 122 | 123 | ], 124 | ]); 125 | $resArr1 = $this->responseToArray($response); 126 | 127 | // 重复请求创建订单 128 | $response = $this->client->post('/api/resetOrder', [ 129 | 'json' => [ 130 | 'order_no' => $orderNo, 131 | 'stock_qty' => $stockQty, 132 | 'amount' => $amount 133 | ], 134 | 'headers' => [ 135 | 'rt_request_id' => $requestId, 136 | 'rt_transact_id' => $transactId, 137 | 138 | ] 139 | ]); 140 | $resArr2 = $this->responseToArray($response); 141 | 142 | $this->assertTrue($resArr1['id'] == $resArr2['id']); 143 | 144 | RT::commit(); 145 | 146 | $orderCount2 = ResetOrderModel::count(); 147 | 148 | $this->assertTrue(($orderCount1 + 1) == $orderCount2); 149 | } 150 | 151 | public function testCheckResult() 152 | { 153 | RT::beginTransaction(); 154 | DB::beginTransaction(); 155 | DB::table('reset_order')->setCheckResult(true)->where('id', 1)->update(['stock_qty' => 110]); 156 | DB::commit(); 157 | RT::commit(); 158 | 159 | $this->assertTrue(true); 160 | } 161 | 162 | public function testLogCommit() 163 | { 164 | DB::beginTransaction(); 165 | $transactId = '6abtl2inkilkvus7bftjhdi8nt'; 166 | 167 | $sqlCollects = DB::table('reset_transact')->where('transact_id', 'like', $transactId . '%')->get(); 168 | if ($sqlCollects->count() > 0) { 169 | foreach ($sqlCollects as $item) { 170 | if ($item->transact_status != RT::STATUS_ROLLBACK) { 171 | $result = DB::affectingStatement($item->sql); 172 | if ($item->check_result && $result != $item->result) { 173 | var_dump("db had been changed by anothor transact_id"); 174 | } 175 | } 176 | } 177 | } 178 | 179 | DB::commit(); 180 | 181 | $this->assertTrue(true); 182 | } 183 | 184 | private function responseToArray($response) 185 | { 186 | $contents = $response->getBody()->getContents(); 187 | return json_decode($contents, true); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Facades/TransactionCenter.php: -------------------------------------------------------------------------------- 1 | where('transact_id', $transactId)->first(); 21 | if (!$item) { 22 | throw new RtException("transact_id not found"); 23 | } 24 | 25 | if ($item->action != RTCenter::ACTION_START) { 26 | throw new RtException("transact_id has been processed"); 27 | } 28 | 29 | $this->transactId = $transactId; 30 | if ($item->transact_rollback) { 31 | $rollArr = json_decode($item->transact_rollback, true); 32 | $transactRollback = array_merge($transactRollback, $rollArr); 33 | } 34 | foreach ($transactRollback as $tid) { 35 | DB::table('reset_transact_sql')->where('transact_id', $transactId)->where('chain_id', 'like', $tid . '%')->update(['transact_status' => RT::STATUS_ROLLBACK]); 36 | } 37 | $xidMap = $this->getXidMap($transactId); 38 | if ($xidMap) { 39 | } 40 | $xidArr = []; 41 | foreach ($xidMap as $name => $item) { 42 | $xidArr[$name] = $item['xid']; 43 | } 44 | 45 | $this->xaBeginTransaction($xidArr); 46 | foreach ($xidMap as $name => $item) { 47 | $sqlCollects = $item['sql_list']; 48 | foreach ($sqlCollects as $item) { 49 | $result = DB::connection($name)->getPdo()->exec($item->sql); 50 | if ($item->check_result && $result != $item->result) { 51 | throw new RtException("db had been changed by anothor transact_id"); 52 | } 53 | } 54 | } 55 | $this->xaCommit($xidArr); 56 | 57 | return $this->result(); 58 | } 59 | 60 | public function rollback(string $transactId, array $transactRollback) 61 | { 62 | $item = DB::table('reset_transact')->where('transact_id', $transactId)->first(); 63 | if (!$item) { 64 | throw new RtException("transact_id not found"); 65 | } 66 | 67 | $this->transactId = $transactId; 68 | if (strpos('-', $transactId)) { 69 | $chainId = $transactId; 70 | $transId = explode('-', $transactId)[0]; 71 | array_push($transactRollback, $chainId); 72 | 73 | foreach ($transactRollback as $txId) { 74 | DB::table('reset_transact_sql')->where('transact_id', $transId)->where('chain_id', 'like', $txId . '%')->update(['transact_status' => RT::STATUS_ROLLBACK]); 75 | } 76 | } else { 77 | DB::table('reset_transact_sql')->where('transact_id', $transactId)->update(['transact_status' => RT::STATUS_ROLLBACK]); 78 | DB::table('reset_transact')->where('transact_id', $transactId)->update(['action' => RTCenter::ACTION_ROLLBACK]); 79 | } 80 | 81 | return $this->result(); 82 | } 83 | 84 | private function getXidMap($transactId) 85 | { 86 | $xidMap = []; 87 | $query = DB::table('reset_transact_sql')->where('transact_id', $transactId); 88 | $query->whereIn('transact_status', [RT::STATUS_START, RT::STATUS_COMMIT]); 89 | $list = $query->get(); 90 | foreach ($list as $item) { 91 | $name = $item->connection; 92 | $xidMap[$name]['sql_list'][] = $item; 93 | } 94 | 95 | foreach ($xidMap as $name => &$item) { 96 | $xid = session_create_id(); 97 | $item['xid'] = $xid; 98 | } 99 | 100 | return $xidMap; 101 | } 102 | 103 | /** 104 | * beginTransaction 105 | * 106 | */ 107 | public function xaBeginTransaction($xidArr) 108 | { 109 | $this->_XAStart($xidArr); 110 | } 111 | 112 | /** 113 | * commit 114 | * @param $xidArr 115 | */ 116 | public function xaCommit($xidArr) 117 | { 118 | $this->_XAEnd($xidArr); 119 | $this->_XAPrepare($xidArr); 120 | $this->_XACommit($xidArr); 121 | } 122 | 123 | /** 124 | * rollback 125 | * @param $xidArr 126 | */ 127 | public function xaRollBack($xidArr) 128 | { 129 | $this->_XAEnd($xidArr); 130 | $this->_XAPrepare($xidArr); 131 | $this->_XARollback($xidArr); 132 | } 133 | 134 | private function _XAStart($xidArr) 135 | { 136 | DB::table('reset_transact')->where('transact_id', $this->transactId)->update(['xids_info' => json_encode($xidArr)]); 137 | foreach ($xidArr as $name => $xid) { 138 | DB::connection($name)->getPdo()->exec("XA START '{$xid}'"); 139 | } 140 | } 141 | 142 | 143 | private function _XAEnd($xidArr) 144 | { 145 | foreach ($xidArr as $name => $xid) { 146 | DB::connection($name)->getPdo()->exec("XA END '{$xid}'"); 147 | } 148 | } 149 | 150 | 151 | private function _XAPrepare($xidArr) 152 | { 153 | DB::table('reset_transact')->where('transact_id', $this->transactId)->update(['action' => RTCenter::ACTION_PREPARE]); 154 | foreach ($xidArr as $name => $xid) { 155 | DB::connection($name)->getPdo()->exec("XA PREPARE '{$xid}'"); 156 | } 157 | } 158 | 159 | private function _XACommit($xidArr) 160 | { 161 | DB::table('reset_transact')->where('transact_id', $this->transactId)->update(['action' => RTCenter::ACTION_PREPARE_COMMIT]); 162 | foreach ($xidArr as $name => $xid) { 163 | DB::connection($name)->getPdo()->exec("XA COMMIT '{$xid}'"); 164 | } 165 | DB::table('reset_transact')->where('transact_id', $this->transactId)->update(['action' => RTCenter::ACTION_COMMIT]); 166 | } 167 | 168 | private function _XARollback($xidArr) 169 | { 170 | DB::table('reset_transact')->where('transact_id', $this->transactId)->update(['action' => RTCenter::ACTION_PREPARE_ROLLBACK]); 171 | foreach ($xidArr as $name => $xid) { 172 | DB::connection($name)->getPdo()->exec("XA ROLLBACK '{$xid}'"); 173 | } 174 | DB::table('reset_transact')->where('transact_id', $this->transactId)->update(['action' => RTCenter::ACTION_ROLLBACK]); 175 | } 176 | 177 | private function result() 178 | { 179 | return ['error_code' => 0, 'message' => 'done success', 'errors' => []]; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /examples/Controllers/ResetOrderController.php: -------------------------------------------------------------------------------- 1 | has('status')) { 29 | $query->where('status', $request->input('status')); 30 | } 31 | return $query->paginate(); 32 | } 33 | 34 | /** 35 | * Store a newly created resource in storage. 36 | * 37 | * @param \Illuminate\Http\Request $request 38 | * @return \Illuminate\Http\Response 39 | */ 40 | public function store(Request $request) 41 | { 42 | // 43 | return ResetOrderModel::create($request->input()); 44 | } 45 | 46 | /** 47 | * Display the specified resource. 48 | * 49 | * @param int $id 50 | * @return \Illuminate\Http\Response 51 | */ 52 | public function show($id) 53 | { 54 | // 55 | $item = ResetOrderModel::find($id); 56 | return $item ?? []; 57 | } 58 | 59 | /** 60 | * Update the specified resource in storage. 61 | * 62 | * @param \Illuminate\Http\Request $request 63 | * @param int $id 64 | * @return \Illuminate\Http\Response 65 | */ 66 | public function update(Request $request, $id) 67 | { 68 | // 69 | $item = ResetOrderModel::findOrFail($id); 70 | $ret = $item->update($request->input()); 71 | return ['result' => $ret]; 72 | } 73 | 74 | /** 75 | * Remove the specified resource from storage. 76 | * 77 | * @param int $id 78 | * @return \Illuminate\Http\Response 79 | */ 80 | public function destroy($id) 81 | { 82 | // 83 | $item = ResetOrderModel::findOrFail($id); 84 | $ret = $item->delete(); 85 | return ['result' => $ret]; 86 | } 87 | 88 | public function updateOrCreate(Request $request, $id) 89 | { 90 | // 91 | $attr = ['id' => $id]; 92 | $item = ResetOrderModel::updateOrCreate($attr, $request->input()); 93 | return $item; 94 | } 95 | 96 | /** 97 | * Store a newly created resource in storage. 98 | * 99 | * @param \Illuminate\Http\Request $request 100 | * @return \Illuminate\Http\Response 101 | */ 102 | public function createWithTimeout(Request $request) 103 | { 104 | $requestId = $request->header('rt_request_id'); 105 | $cache = Cache::store('file'); 106 | $cache->increment($requestId); 107 | $times = $cache->get($requestId); 108 | if ($times < 4) { 109 | sleep(13); 110 | } 111 | 112 | return ResetOrderModel::create($request->input()); 113 | } 114 | 115 | public function deadlockWithLocal(Request $request) 116 | { 117 | DB::beginTransaction(); 118 | 119 | $s = ($request->getPort()) % 2; 120 | for ($i = $s; $i < 3; $i++) { 121 | $id = $i % 2 + 1; 122 | $attrs = ['id' => $id]; 123 | $values = ['order_no' => session_create_id()]; 124 | 125 | ResetOrderModel::updateOrCreate($attrs, $values); 126 | usleep(rand(1, 200) * 1000); 127 | } 128 | DB::commit(); 129 | } 130 | 131 | public function deadlockWithRt(Request $request) 132 | { 133 | $transactId = RT::beginTransaction(); 134 | $s = ($request->getPort()) % 2; 135 | for ($i = $s; $i < 3; $i++) { 136 | $id = $i % 2 + 1; 137 | // $attrs = ['id' => $id]; 138 | // $values = ['order_no' => session_create_id()]; 139 | // ResetOrderModel::updateOrCreate($attrs, $values); 140 | 141 | $client = new Client([ 142 | 'base_uri' => 'http://127.0.0.1:8002', 143 | 'timeout' => 60, 144 | ]); 145 | $client->put('/api/resetOrderTest/updateOrCreate/'.$id, [ 146 | 'json' =>['order_no' => session_create_id()], 147 | 'headers' => [ 148 | 'rt_request_id' => session_create_id(), 149 | 'rt_transact_id' => $transactId, 150 | 151 | ] 152 | ]); 153 | 154 | usleep(rand(1, 200) * 1000); 155 | } 156 | RT::commit(); 157 | } 158 | 159 | public function orderWithLocal(Request $request) 160 | { 161 | DB::beginTransaction(); 162 | usleep(rand(1, 200) * 1000); 163 | $orderNo = session_create_id(); 164 | $stockQty = rand(1, 5); 165 | $amount = rand(1, 50)/10; 166 | 167 | $item = ResetOrderModel::create([ 168 | 'order_no' => $orderNo, 169 | 'stock_qty' => $stockQty, 170 | 'amount' => $amount 171 | ]); 172 | 173 | $item->increment('stock_qty'); 174 | DB::commit(); 175 | } 176 | 177 | public function orderWithRt(Request $request) 178 | { 179 | RT::beginTransaction(); 180 | usleep(rand(1, 200) * 1000); 181 | $orderNo = session_create_id(); 182 | $stockQty = rand(1, 5); 183 | $amount = rand(1, 50)/10; 184 | 185 | $item = ResetOrderModel::create([ 186 | 'order_no' => $orderNo, 187 | 'stock_qty' => $stockQty, 188 | 'amount' => $amount 189 | ]); 190 | 191 | $item->increment('stock_qty'); 192 | RT::commit(); 193 | } 194 | 195 | public function disorderWithLocal() 196 | { 197 | DB::beginTransaction(); 198 | usleep(rand(1, 200) * 1000); 199 | $orderNo = session_create_id(); 200 | $stockQty = rand(1, 5); 201 | $amount = rand(1, 50)/10; 202 | $status = rand(1, 3); 203 | 204 | $item = ResetOrderModel::updateOrCreate([ 205 | 'id' => rand(1, 10), 206 | ], [ 207 | 'order_no' => $orderNo, 208 | 'stock_qty' => $stockQty, 209 | 'amount' => $amount, 210 | 'status' => $status, 211 | ]); 212 | 213 | 214 | $item = ResetOrderModel::find(rand(1, 10)); 215 | if ($item) { 216 | $item->delete(); 217 | } 218 | 219 | if (rand(0, 1) == 0) { 220 | ResetOrderModel::where('status', $status)->update(['stock_qty' => rand(1, 5)]); 221 | } 222 | 223 | DB::commit(); 224 | } 225 | 226 | public function disorderWithRt() 227 | { 228 | RT::beginTransaction(); 229 | usleep(rand(1, 200) * 1000); 230 | $orderNo = session_create_id(); 231 | $stockQty = rand(1, 5); 232 | $amount = rand(1, 50)/10; 233 | $status = rand(1, 3); 234 | 235 | $item = ResetOrderModel::updateOrCreate([ 236 | 'id' => rand(1, 10), 237 | ], [ 238 | 'order_no' => $orderNo, 239 | 'stock_qty' => $stockQty, 240 | 'amount' => $amount, 241 | 'status' => $status, 242 | ]); 243 | 244 | 245 | $item = ResetOrderModel::find(rand(1, 10)); 246 | if ($item) { 247 | $item->delete(); 248 | } 249 | 250 | if (rand(0, 1) == 0) { 251 | ResetOrderModel::where('status', $status)->update(['stock_qty' => rand(1, 5)]); 252 | } 253 | 254 | RT::commit(); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /examples/tests/Transaction/ServiceTest.php: -------------------------------------------------------------------------------- 1 | client = new Client([ 32 | 'base_uri' => $this->baseUri, 33 | 'timeout' => 60, 34 | ]); 35 | 36 | $requestId = session_create_id(); 37 | session()->put('rt_request_id', $requestId); 38 | } 39 | 40 | public function testCreateOrderWithRollback() 41 | { 42 | $orderCount1 = ResetOrderModel::count(); 43 | $storageItem1 = ResetStorageModel::find(1); 44 | $accountItem1 = ResetAccountModel::find(1); 45 | 46 | $transactId = RT::beginTransaction(); 47 | $orderNo = rand(1000, 9999); // 随机订单号 48 | $stockQty = 2; // 占用2个库存数量 49 | $amount = 20.55; // 订单总金额20.55元 50 | 51 | ResetOrderModel::create([ 52 | 'order_no' => $orderNo, 53 | 'stock_qty' => $stockQty, 54 | 'amount' => $amount 55 | ]); 56 | 57 | $requestId = session_create_id(); 58 | // 请求库存服务,减库存 59 | $response = $this->client->put('/api/resetStorage/1', [ 60 | 'json' => [ 61 | 'decr_stock_qty' => $stockQty 62 | ], 63 | 'headers' => [ 64 | 'rt_request_id' => $requestId, 65 | 'rt_transact_id' => $transactId, 66 | ] 67 | ]); 68 | $resArr1 = $this->responseToArray($response); 69 | $this->assertTrue($resArr1['result'] == 1, 'lack of stock'); //返回值是1,说明操作成功 70 | 71 | $requestId = session_create_id(); 72 | // 请求账户服务,减金额 73 | $response = $this->client->put('/api/resetAccount/1', [ 74 | 'json' => [ 75 | 'decr_amount' => $amount 76 | ], 77 | 'headers' => [ 78 | 'rt_request_id' => $requestId, 79 | 'rt_transact_id' => $transactId, 80 | 81 | ] 82 | ]); 83 | $resArr2 = $this->responseToArray($response); 84 | $this->assertTrue($resArr2['result'] == 1, 'not enough money'); //返回值是1,说明操作成功 85 | 86 | RT::rollBack(); 87 | 88 | $orderCount2 = ResetOrderModel::count(); 89 | $storageItem2 = ResetStorageModel::find(1); 90 | $accountItem2 = ResetAccountModel::find(1); 91 | 92 | $this->assertTrue($orderCount1 == $orderCount2); 93 | $this->assertTrue($storageItem1->stock_qty == $storageItem2->stock_qty); 94 | $this->assertTrue($accountItem1->amount == $accountItem2->amount); 95 | } 96 | 97 | public function testCreateOrderWithCommit() 98 | { 99 | $orderCount1 = ResetOrderModel::count(); 100 | $storageItem1 = ResetStorageModel::find(1); 101 | $accountItem1 = ResetAccountModel::find(1); 102 | 103 | $transactId = RT::beginTransaction(); 104 | $orderNo = rand(1000, 9999); // 随机订单号 105 | $stockQty = 2; // 占用2个库存数量 106 | $amount = 20.55; // 订单总金额20.55元 107 | 108 | ResetOrderModel::create([ 109 | 'order_no' => $orderNo, 110 | 'stock_qty' => $stockQty, 111 | 'amount' => $amount 112 | ]); 113 | $requestId = session_create_id(); 114 | 115 | // 请求库存服务,减库存 116 | $response = $this->client->put('/api/resetStorage/1', [ 117 | 'json' => [ 118 | 'decr_stock_qty' => $stockQty 119 | ], 120 | 'headers' => [ 121 | 'rt_request_id' => $requestId, 122 | 'rt_transact_id' => $transactId, 123 | ] 124 | ]); 125 | $resArr1 = $this->responseToArray($response); 126 | $this->assertTrue($resArr1['result'] == 1, 'lack of stock'); //返回值是1,说明操作成功 127 | 128 | $requestId = session_create_id(); 129 | // 请求账户服务,减金额 130 | $response = $this->client->put('/api/resetAccount/1', [ 131 | 'json' => [ 132 | 'decr_amount' => $amount 133 | ], 134 | 'headers' => [ 135 | 'rt_request_id' => $requestId, 136 | 'rt_transact_id' => $transactId, 137 | 138 | ] 139 | ]); 140 | $resArr2 = $this->responseToArray($response); 141 | $this->assertTrue($resArr2['result'] == 1, 'not enough money'); //返回值是1,说明操作成功 142 | 143 | RT::commit(); 144 | 145 | $orderCount2 = ResetOrderModel::count(); 146 | $storageItem2 = ResetStorageModel::find(1); 147 | $accountItem2 = ResetAccountModel::find(1); 148 | 149 | $this->assertTrue(($orderCount1 + 1) == $orderCount2); 150 | $this->assertTrue($storageItem1->stock_qty - $stockQty == $storageItem2->stock_qty); 151 | $this->assertTrue(abs($accountItem1->amount - $amount - $accountItem2->amount) < 0.001); 152 | } 153 | 154 | public function testNestedTransaction() 155 | { 156 | for ($id = 11; $id <= 13; $id++) { 157 | $orderNo = rand(1000, 9999); // 随机订单号 158 | 159 | ResetOrderModel::updateOrCreate([ 160 | 'id' => $id, 161 | ], [ 162 | 'order_no' => $orderNo, 163 | ]); 164 | } 165 | 166 | $status = 11; 167 | 168 | $txId = RT::beginTransaction(); 169 | $this->client->put('api/resetOrder/11', [ 170 | 'json' => [ 171 | 'order_no' => 'aaa', 172 | 'status' => $status, 173 | ], 174 | 'headers' => [ 175 | 'rt_request_id' => session_create_id(), 176 | 'rt_transact_id' => $txId, 177 | 178 | ] 179 | ]); 180 | $txId2 = RT::beginTransaction(); 181 | $this->client->put('api/resetOrder/12', [ 182 | 'json' => [ 183 | 'order_no' => 'bbb', 184 | 'status' => $status, 185 | ], 186 | 'headers' => [ 187 | 'rt_request_id' => session_create_id(), 188 | 'rt_transact_id' => $txId2, 189 | 190 | ] 191 | ]); 192 | 193 | $txId3 = RT::beginTransaction(); 194 | $this->client->put('api/resetOrder/13', [ 195 | 'json' => [ 196 | 'order_no' => 'ccc', 197 | 'status' => $status, 198 | ], 199 | 'headers' => [ 200 | 'rt_request_id' => session_create_id(), 201 | 'rt_transact_id' => $txId3, 202 | 203 | ] 204 | ]); 205 | 206 | $response = $this->client->get('api/resetOrder', [ 207 | 'json' => [ 208 | 'status' => $status, 209 | ], 210 | 'headers' => [ 211 | 'rt_request_id' => session_create_id(), 212 | 'rt_transact_id' => $txId3, 213 | 214 | ] 215 | ]); 216 | $resArr = $this->responseToArray($response); 217 | // 3层事务内,有3个订单修改了状态 218 | $this->assertTrue($resArr['total'] == 3); 219 | 220 | RT::commit(); 221 | 222 | RT::rollBack(); 223 | 224 | RT::commit(); 225 | 226 | $response = $this->client->get('api/resetOrder', [ 227 | 'json' => [ 228 | 'status' => $status, 229 | ], 230 | 'headers' => [ 231 | 232 | ] 233 | ]); 234 | $resArr = $this->responseToArray($response); 235 | // 3层事务内,有3个订单修改了状态,但是第2层事务回滚了, 只有第1层事务成功提交 236 | $this->assertTrue($resArr['total'] == 1); 237 | } 238 | 239 | private function responseToArray($response) 240 | { 241 | $contents = $response->getBody()->getContents(); 242 | return json_decode($contents, true); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/Console/CreateExamples.php: -------------------------------------------------------------------------------- 1 | files = $files; 41 | } 42 | 43 | /** 44 | * Execute the console command. 45 | * 46 | * @return mixed 47 | */ 48 | public function handle() 49 | { 50 | $this->addFileToApp(); 51 | $this->addTableToDatabase(); 52 | $this->addTestsuitToPhpunit(); 53 | 54 | $this->info('Example created successfully!'); 55 | } 56 | 57 | /** 58 | * rewrite phpunit.xml 59 | * 60 | * @return void 61 | */ 62 | private function addTestsuitToPhpunit() 63 | { 64 | $content = file_get_contents(base_path('phpunit.xml')); 65 | $xml = new \SimpleXMLElement($content); 66 | $hasTransaction = false; 67 | 68 | foreach ($xml->testsuites->testsuite as $testsuite) { 69 | if ($testsuite->attributes()->name == 'Transaction') { 70 | $hasTransaction = true; 71 | } 72 | } 73 | 74 | if ($hasTransaction == false) { 75 | $testsuite = $xml->testsuites->addChild('testsuite'); 76 | $testsuite->addAttribute('name', 'Transaction'); 77 | $directory = $testsuite->addChild('directory', './tests/Transaction'); 78 | $directory->addAttribute('suffix', 'Test.php'); 79 | 80 | $domxml = new \DOMDocument('1.0'); 81 | $domxml->preserveWhiteSpace = false; 82 | $domxml->formatOutput = true; 83 | $domxml->loadXML($xml->asXML()); 84 | $domxml->save(base_path('phpunit.xml')); 85 | } 86 | } 87 | 88 | /** 89 | * db 90 | * 91 | * @return void 92 | */ 93 | private function addTableToDatabase() 94 | { 95 | $transactTable = 'reset_transact'; 96 | $transactSqlTable = 'reset_transact_sql'; 97 | $transactReqTable = 'reset_transact_req'; 98 | $orderTable = 'reset_order'; 99 | $storageTable = 'reset_storage'; 100 | $accountTable = 'reset_account'; 101 | 102 | $orderService = 'service_order'; 103 | $storageService = 'service_storage'; 104 | $accountService = 'service_account'; 105 | $rtCenter = 'rt_center'; 106 | 107 | $serviceMap = [ 108 | $orderService => [ 109 | $orderTable 110 | ], 111 | $storageService => [ 112 | $storageTable 113 | ], 114 | $accountService => [ 115 | $accountTable 116 | ], 117 | $rtCenter => [ 118 | $transactTable, $transactSqlTable, $transactReqTable, 119 | ] 120 | ]; 121 | 122 | $manager = DB::getDoctrineSchemaManager(); 123 | $dbList = $manager->listDatabases(); 124 | 125 | foreach ($serviceMap as $service => $tableList) { 126 | if (!in_array($service, $dbList)) { 127 | $manager->createDatabase($service); 128 | } 129 | 130 | foreach ($tableList as $table) { 131 | if ($table == $transactTable) { 132 | $fullTable = $service . '.' . $transactTable; 133 | Schema::dropIfExists($fullTable); 134 | Schema::create($fullTable, function (Blueprint $table) { 135 | $table->bigIncrements('id'); 136 | $table->string('transact_id', 32); 137 | $table->text('transact_rollback'); 138 | $table->tinyInteger('action')->default(0); 139 | $table->text('xids_info'); 140 | $table->dateTime('created_at')->useCurrent(); 141 | $table->unique('transact_id'); 142 | }); 143 | } 144 | 145 | if ($table == $transactSqlTable) { 146 | $fullTable = $service . '.' . $transactSqlTable; 147 | Schema::dropIfExists($fullTable); 148 | Schema::create($fullTable, function (Blueprint $table) { 149 | $table->bigIncrements('id'); 150 | $table->string('request_id', 32); 151 | $table->string('transact_id', 32); 152 | $table->string('chain_id', 512); 153 | $table->tinyInteger('transact_status')->default(0); 154 | $table->string('connection', 32); 155 | $table->text('sql'); 156 | $table->integer('result')->default(0); 157 | $table->tinyInteger('check_result')->default(0); 158 | $table->dateTime('created_at')->useCurrent(); 159 | $table->index('request_id'); 160 | $table->index('transact_id'); 161 | }); 162 | } 163 | 164 | if ($table == $transactReqTable) { 165 | $fullTable = $service . '.' . $transactReqTable; 166 | Schema::dropIfExists($fullTable); 167 | Schema::create($fullTable, function (Blueprint $table) { 168 | $table->bigIncrements('id'); 169 | $table->string('request_id', 32); 170 | $table->string('transact_id', 32); 171 | $table->text('response'); 172 | $table->dateTime('created_at')->useCurrent(); 173 | $table->unique('request_id'); 174 | $table->index('transact_id'); 175 | }); 176 | } 177 | 178 | if ($table == $orderTable) { 179 | $fullTable = $service . '.' . $orderTable; 180 | Schema::dropIfExists($fullTable); 181 | Schema::create($fullTable, function (Blueprint $table) { 182 | $table->increments('id'); 183 | $table->string('order_no')->default(''); 184 | $table->integer('stock_qty')->default(0); 185 | $table->decimal('amount')->default(0); 186 | $table->tinyInteger('status')->default(0); 187 | $table->unique('order_no'); 188 | }); 189 | } 190 | 191 | if ($table == $storageTable) { 192 | $fullTable = $service . '.' . $storageTable; 193 | Schema::dropIfExists($fullTable); 194 | Schema::create($fullTable, function (Blueprint $table) { 195 | $table->increments('id'); 196 | $table->integer('stock_qty')->default(0); 197 | }); 198 | DB::unprepared("insert into {$fullTable} values(1, 10000)"); 199 | } 200 | 201 | if ($table == $accountTable) { 202 | $fullTable = $service . '.' . $accountTable; 203 | Schema::dropIfExists($fullTable); 204 | Schema::create($fullTable, function (Blueprint $table) { 205 | $table->increments('id'); 206 | $table->decimal('amount')->default(0); 207 | }); 208 | DB::unprepared("insert into {$fullTable} values(1, 100000)"); 209 | } 210 | } 211 | } 212 | } 213 | 214 | private function addFileToApp() 215 | { 216 | $this->files->copyDirectory(__DIR__ . '/../../examples/Controllers', app_path('Http/Controllers')); 217 | 218 | $this->files->copyDirectory(__DIR__ . '/../../examples/Models', app_path('Models')); 219 | $this->files->copyDirectory(__DIR__ . '/../../examples/config', config_path()); 220 | $this->files->copyDirectory(__DIR__ . '/../../examples/tests', base_path('tests')); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-reset-transaction 2 | [![中文文档](https://shields.io/static/v1?label=zh-cn&message=中文文档&color=red)](https://github.com/windawake/laravel-reset-transaction/blob/master/README_zh-CN.md) 3 | 4 | RT (reset transaction) mode can be as distributed transaction for call remote api service 5 | 6 | ## Overview 7 | Install the version between laravel5.5-laravel8, and then install the composer package 8 | ```shell 9 | ## Composer2 version must be used 10 | composer require windawake/laravel-reset-transaction dev-master 11 | ``` 12 | 13 | First create order, storage, account 3 mysql database instances, 3 controllers, 3 models, add testsuite Transaction to phpunit.xml, and then start the web server. These operations only need to execute the following commands to complete all 14 | ```shell 15 | php artisan resetTransact:create-examples && php artisan serve --host=0.0.0.0 --port=8000 16 | ``` 17 | Open another terminal and start the web server with port 8001 18 | ```shell 19 | php artisan serve --host=0.0.0.0 --port=8001 20 | ``` 21 | Finally run the test script ` 22 | ./vendor/bin/phpunit --testsuite=Transaction --filter=ServiceTest 23 | `The running results are shown below, and the 3 examples have passed the test. 24 | ```shell 25 | DESKTOP:/web/linux/php/laravel/laravel62# ./vendor/bin/phpunit --testsuite=Transaction --filter=ServiceTest 26 | Time: 219 ms, Memory: 22.00 MB 27 | 28 | OK (3 tests, 12 assertions) 29 | ``` 30 | 31 | ## Feature 32 | 1. Out of the box, no need to refactor the code of the original project, consistent with the mysql transaction writing, simple and easy to use. 33 | 2. Comply with the two-stage commit protocol, which is a strong consistency transaction. Under high concurrency, it supports the isolation level of read committed transactions, and the data consistency is 100% close to mysql xa. 34 | 3. Since the transaction is split into multiple and become several small transactions, the stress test finds that there are fewer deadlocks than mysql ordinary transactions. 35 | 4. Support distributed transaction nesting, consistent with savepoint. 36 | 5. Support to avoid the problem of dirty data caused by the concurrency of different business codes. 37 | 6. The service-oriented interface of the http protocol is supported by default. If you want to support other protocols, you need to rewrite the middleware. 38 | 7. Support for nested distributed transactions of sub-services (world's first). 39 | 8. Support services, mixed nesting of local transactions and distributed transactions (the world's first) 40 | 9. Support 3 retries over time, repeated requests to ensure idempotence 41 | 10. Support go, java language (under development) 42 | 43 | ## Principle 44 | You will know the operation of archiving and reading files after watching the movie "Edge of Tomorrow". This distributed transaction component imitates the principle of the "Edge of Tomorrow" movie. Reset means to reset, that is, read the file at the beginning of each request for the basic service, and then continue the subsequent operations. At the end, all operations are rolled back and archived, and finally One step commit successfully executes all the archives. The whole process is to comply with the two-stage submission agreement, first prepare, and finally commit. 45 | 46 | Taking the scenario where user A transfers 100 yuan to user B's China Merchants Bank account with a China Merchants Bank card as an example, the following flowchart is drawn. ![](https://cdn.learnku.com/uploads/images/202111/18/46914/RRw5OHCKvK.png!large) 47 | After the reset distributed transaction is turned on in the right picture, there are 4 more requests than the left picture. What request 4 does is what was done before request 1-3, and then come back to the original point and start again, finally submit the transaction, and end the transfer process. 48 | 49 | ## Support sub-service nested distributed transaction (world's first) 50 | ![](https://cdn.learnku.com/uploads/images/202112/30/46914/IzHhjfjHC1.png!large) 51 | A world-class problem: A service commit->B service rollback->C service commit->D service commit sql. In this scenario, ABCD are all different databases. How can I make A service submit B service and roll back? What about all operations of the C service and D service? 52 | 53 | Neither seata nor go-dtm can solve this problem. The key point to solve the problem is that the C service and D service must be submitted falsely, and cannot be submitted truly. If they are submitted, they will be unable to recover. 54 | 55 | What are the benefits of implementing nested distributed transactions that support sub-services? You can make A service a service of others, and it can be nested in any layer of the link arbitrarily. Breaking the previous shackles: Service A must be a root service, and if Service A is to become a sub-service, the code must be changed. If the RT mode is used, service A can become someone else's service without modifying the code. 56 | 57 | ## How to use 58 | 59 | Take the `vendor/windawake/laravel-reset-transaction/tests/ServiceTest.php` file as an example 60 | ```php 61 | client = new Client([ 86 | 'base_uri' => $this->baseUri, 87 | 'timeout' => 60, 88 | ]); 89 | $requestId = session_create_id(); 90 | session()->put('rt_request_id', $requestId); 91 | } 92 | 93 | public function testCreateOrderWithCommit() 94 | { 95 | $orderCount1 = ResetOrderModel::count(); 96 | $storageItem1 = ResetStorageModel::find(1); 97 | $accountItem1 = ResetAccountModel::find(1); 98 | // 开启RT模式分布式事务 99 | $transactId = RT::beginTransaction(); 100 | $orderNo = rand(1000, 9999); // 随机订单号 101 | $stockQty = 2; // 占用2个库存数量 102 | $amount = 20.55; // 订单总金额20.55元 103 | 104 | ResetOrderModel::create([ 105 | 'order_no' => $orderNo, 106 | 'stock_qty' => $stockQty, 107 | 'amount' => $amount 108 | ]); 109 | // 请求库存服务,减库存 110 | $requestId = session_create_id(); 111 | $response = $this->client->put('/api/resetStorage/1', [ 112 | 'json' => [ 113 | 'decr_stock_qty' => $stockQty 114 | ], 115 | 'headers' => [ 116 | 'rt_request_id' => $requestId, 117 | 'rt_transact_id' => $transactId, 118 | ] 119 | ]); 120 | $resArr1 = $this->responseToArray($response); 121 | $this->assertTrue($resArr1['result'] == 1, 'lack of stock'); //返回值是1,说明操作成功 122 | // 请求账户服务,减金额 123 | $requestId = session_create_id(); 124 | $response = $this->client->put('/api/resetAccount/1', [ 125 | 'json' => [ 126 | 'decr_amount' => $amount 127 | ], 128 | 'headers' => [ 129 | 'rt_request_id' => $requestId, 130 | 'rt_transact_id' => $transactId, 131 | ] 132 | ]); 133 | $resArr2 = $this->responseToArray($response); 134 | $this->assertTrue($resArr2['result'] == 1, 'not enough money'); //返回值是1,说明操作成功 135 | // 提交RT模式分布式事务 136 | RT::commit(); 137 | 138 | $orderCount2 = ResetOrderModel::count(); 139 | $storageItem2 = ResetStorageModel::find(1); 140 | $accountItem2 = ResetAccountModel::find(1); 141 | 142 | $this->assertTrue(($orderCount1 + 1) == $orderCount2); //事务内创建了一个订单 143 | $this->assertTrue(($storageItem1->stock_qty - $stockQty) == $storageItem2->stock_qty); //事务内创建订单后需要扣减库存 144 | $this->assertTrue(($accountItem1->amount - $amount) == $accountItem2->amount); //事务内创建订单后需要扣减账户金额 145 | } 146 | 147 | private function responseToArray($response) 148 | { 149 | $contents = $response->getBody()->getContents(); 150 | return json_decode($contents, true); 151 | } 152 | } 153 | 154 | ``` 155 | 156 | ## Contact 157 | 158 | 159 | ![](https://cdn.learnku.com/uploads/images/202202/25/46914/heg3sLvwiG.jpg!large) 160 | 161 | Scan code into wechat group. I hope that more friends will learn from each other and study the knowledge of distributed transactions together. 162 | -------------------------------------------------------------------------------- /src/Facades/ResetTransaction.php: -------------------------------------------------------------------------------- 1 | transactIdArr, $transactId); 17 | if (count($this->transactIdArr) == 1) { 18 | $data = [ 19 | 'transact_id' => $transactId, 20 | 'transact_rollback' => '[]', 21 | 'xids_info' => '[]', 22 | ]; 23 | DB::connection('rt_center')->table('reset_transact')->insert($data); 24 | } 25 | 26 | $this->stmtBegin(); 27 | 28 | return $this->getTransactId(); 29 | } 30 | 31 | public function commit() 32 | { 33 | $this->stmtRollback(); 34 | 35 | if (count($this->transactIdArr) > 1) { 36 | array_pop($this->transactIdArr); 37 | 38 | return true; 39 | } 40 | 41 | $this->logRT(RT::STATUS_COMMIT); 42 | 43 | $commitUrl = config('rt_database.center.commit_url'); 44 | 45 | $client = new Client(); 46 | $response = $client->post($commitUrl, [ 47 | 'json' => [ 48 | 'transact_id' => $this->getTransactId(), 49 | 'transact_rollback' => $this->transactRollback, 50 | ] 51 | ]); 52 | 53 | $this->removeRT(); 54 | 55 | return $response; 56 | } 57 | 58 | public function rollBack() 59 | { 60 | $this->stmtRollback(); 61 | 62 | if (count($this->transactIdArr) > 1) { 63 | $transactId = $this->getTransactId(); 64 | foreach ($this->transactRollback as $i => $txId) { 65 | if (strpos($txId, $transactId) === 0) { 66 | unset($this->transactRollback[$i]); 67 | } 68 | } 69 | array_push($this->transactRollback, $transactId); 70 | array_pop($this->transactIdArr); 71 | return true; 72 | } 73 | 74 | $this->logRT(RT::STATUS_ROLLBACK); 75 | 76 | $rollbackUrl = config('rt_database.center.rollback_url'); 77 | 78 | $client = new Client(); 79 | $response = $client->post($rollbackUrl, [ 80 | 'json' => [ 81 | 'transact_id' => $this->getTransactId(), 82 | 'transact_rollback' => $this->transactRollback, 83 | ] 84 | ]); 85 | $this->removeRT(); 86 | 87 | return $response; 88 | } 89 | 90 | public function middlewareBeginTransaction($transactId) 91 | { 92 | $transactIdArr = explode('-', $transactId); 93 | $connection = DB::getDefaultConnection(); 94 | $sqlArr = DB::connection('rt_center') 95 | ->table('reset_transact_sql') 96 | ->where('transact_id', $transactIdArr[0]) 97 | ->where('connection', $connection) 98 | ->whereIn('transact_status', [RT::STATUS_START, RT::STATUS_COMMIT]) 99 | ->pluck('sql')->toArray(); 100 | $sql = implode(';', $sqlArr); 101 | $this->stmtBegin(); 102 | if ($sqlArr) { 103 | DB::unprepared($sql); 104 | } 105 | 106 | $this->setTransactId($transactId); 107 | } 108 | 109 | public function middlewareRollback() 110 | { 111 | $this->stmtRollback(); 112 | $this->logRT(RT::STATUS_COMMIT); 113 | 114 | if ($this->transactRollback) { 115 | $transactId = RT::getTransactId(); 116 | $transactIdArr = explode('-', $transactId); 117 | $tid = $transactIdArr[0]; 118 | 119 | $item = DB::connection('rt_center')->table('reset_transact')->where('transact_id', $tid)->first(); 120 | $arr = $item->transact_rollback ? json_decode($item->transact_rollback, true) : []; 121 | $arr = array_merge($arr, $this->transactRollback); 122 | $arr = array_unique($arr); 123 | 124 | $data = ['transact_rollback' => json_encode($arr)]; 125 | DB::connection('rt_center')->table('reset_transact')->where('transact_id', $tid)->update($data); 126 | } 127 | 128 | $this->removeRT(); 129 | } 130 | 131 | public function commitTest() 132 | { 133 | $this->stmtRollback(); 134 | 135 | if (count($this->transactIdArr) > 1) { 136 | array_pop($this->transactIdArr); 137 | 138 | return true; 139 | } 140 | 141 | $this->logRT(RT::STATUS_COMMIT); 142 | } 143 | 144 | public function rollBackTest() 145 | { 146 | $this->stmtRollback(); 147 | 148 | if (count($this->transactIdArr) > 1) { 149 | $transactId = $this->getTransactId(); 150 | foreach ($this->transactRollback as $i => $txId) { 151 | if (strpos($txId, $transactId) === 0) { 152 | unset($this->transactRollback[$i]); 153 | } 154 | } 155 | array_push($this->transactRollback, $transactId); 156 | array_pop($this->transactIdArr); 157 | return true; 158 | } 159 | 160 | $this->logRT(RT::STATUS_ROLLBACK); 161 | } 162 | 163 | public function setTransactId($transactId) 164 | { 165 | $this->transactIdArr = explode('-', $transactId); 166 | } 167 | 168 | 169 | public function getTransactId() 170 | { 171 | return implode('-', $this->transactIdArr); 172 | } 173 | 174 | public function getTransactRollback() 175 | { 176 | return $this->transactRollback; 177 | } 178 | 179 | public function logRT($status) 180 | { 181 | $sqlArr = session()->get('rt_transact_sql'); 182 | $requestId = session()->get('rt_request_id'); 183 | if (is_null($requestId)) { 184 | $requestId = $this->transactIdArr[0]; 185 | } 186 | 187 | if ($sqlArr) { 188 | foreach ($sqlArr as $item) { 189 | DB::connection('rt_center')->table('reset_transact_sql')->insert([ 190 | 'request_id' => $requestId, 191 | 'transact_id' => $this->transactIdArr[0], 192 | 'chain_id' => $item['transact_id'], 193 | 'transact_status' => $status, 194 | 'sql' => value($item['sql']), 195 | 'result' => $item['result'], 196 | 'check_result' => $item['check_result'], 197 | 'connection' => $item['connection'], 198 | ]); 199 | } 200 | } 201 | } 202 | 203 | private function removeRT() 204 | { 205 | $this->transactIdArr = []; 206 | 207 | session()->remove('rt_transact_sql'); 208 | session()->remove('rt_request_id'); 209 | } 210 | 211 | public function saveQuery($query, $bindings, $result, $checkResult, $keyName = null, $id = null) 212 | { 213 | $rtTransactId = $this->getTransactId(); 214 | $rtSkip = session()->get('rt_skip'); 215 | if (!$rtSkip && $rtTransactId && $query && !strpos($query, 'reset_transact')) { 216 | $subString = strtolower(substr(trim($query), 0, 12)); 217 | $actionArr = explode(' ', $subString); 218 | $action = $actionArr[0]; 219 | 220 | $sql = str_replace("?", "'%s'", $query); 221 | $completeSql = vsprintf($sql, $bindings); 222 | 223 | if (in_array($action, ['insert', 'update', 'delete', 'set', 'savepoint', 'rollback'])) { 224 | $backupSql = $completeSql; 225 | if ($action == 'insert') { 226 | // if only queryBuilder insert or batch insert then return false 227 | if (is_null($id)) { 228 | $id = DB::connection()->getPdo()->lastInsertId(); 229 | // extract variables from sql 230 | preg_match("/insert into (.+) \((.+)\) values \((.+)\)/", $backupSql, $match); 231 | $database = DB::connection()->getConfig('database'); 232 | $table = $match[1]; 233 | $columns = $match[2]; 234 | $parameters = $match[3]; 235 | 236 | $backupSql = function () use ($database, $table, $columns, $parameters, $id) { 237 | $columnItem = DB::selectOne('select column_name as `column_name` from information_schema.columns where table_schema = ? and table_name = ? and column_key="PRI"', [$database, trim($table, '`')]); 238 | $keyName = $columnItem->column_name; 239 | 240 | if (strpos($columns, "`{$keyName}`") === false) { 241 | $columns = "`{$keyName}`, " . $columns; 242 | $lineArr = explode('(', $parameters); 243 | foreach ($lineArr as $index => $line) { 244 | $lineArr[$index] = "'{$id}', " . $line; 245 | $id++; 246 | } 247 | $parameters = implode('(', $lineArr); 248 | } 249 | 250 | return "insert into $table ($columns) values ($parameters)"; 251 | }; 252 | } else { 253 | // extract variables from sql 254 | preg_match("/insert into (.+) \((.+)\) values \((.+)\)/", $backupSql, $match); 255 | $table = $match[1]; 256 | $columns = $match[2]; 257 | $parameters = $match[3]; 258 | 259 | if (strpos($columns, "`{$keyName}`") === false) { 260 | $columns = "`{$keyName}`, " . $columns; 261 | $parameters = "'{$id}', " . $parameters; 262 | } 263 | 264 | $backupSql = "insert into $table ($columns) values ($parameters)"; 265 | } 266 | } 267 | 268 | $connectionName = DB::connection()->getConfig('connection_name'); 269 | $sqlItem = [ 270 | 'transact_id' => $rtTransactId, 271 | 'sql' => $backupSql, 272 | 'result' => $result, 273 | 'check_result' => (int) $checkResult, 274 | 'connection' => $connectionName 275 | ]; 276 | session()->push('rt_transact_sql', $sqlItem); 277 | } 278 | } 279 | } 280 | 281 | private function stmtBegin() 282 | { 283 | session()->put('rt_stmt', 'begin'); 284 | DB::beginTransaction(); 285 | session()->remove('rt_stmt', 'begin'); 286 | } 287 | 288 | private function stmtRollback() 289 | { 290 | session()->put('rt_stmt', 'rollback'); 291 | DB::rollBack(); 292 | session()->remove('rt_stmt', 'rollback'); 293 | } 294 | } 295 | --------------------------------------------------------------------------------