迷えるあなたへまず結論を
Laravelでうまくrollbackできない全ての根源はconnectionが違うから。
connectionについて
Laravel 5.7では、config/database.phpに記述するだけで、自動でReaderとWriterコネクションを選択してくれます。Laravel Document
この機能、実装者にコネクションを意識させなくて良いとても便利な機能なので、おそらくみなさんの現場でもアーキテクトの方がよしなに設定してくれてると思います。
しかし、これがrollbackされない問題の原因かな、と個人的には考えています。__「意識させない」が「意識されない」__になってしまっているのです。
自動コネクション設定例
'mysql' => [
'reader' => [
'host' => [env('DB_READER_HOST', 'localhost')],
],
'writer' => [
'host' => [env('DB_WRITER_HOST', 'localhost')],
],
'driver' => 'mysql',
'port' => env('DB_PORT', 3306),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
],
writerを明示的に指定
この便利な自動コネクションでは厳しい場面があります。
例えばECサイトを作っているときです。
商品の購入をするとき、購入処理中に「在庫切れ」が起きないようにしなければならないでしょう。
5個の在庫がある商品を、自分のプロセスでは4個購入したい。しかし、処理中にもし他のユーザーの購入処理とバッティングし、先に2個引き落とされてしまったら、自分のプロセス終了後、在庫が−1個になってしまうような現象を防がなければなりません。
このため実装者は、対象商品をロックします。これで自分が購入処理を終えるまで他のプロセスはその商品を読み込むことができません。その際、最終的に商品在庫を更新したいので、Reader側ではなくWriter側へロックをかける必要があります。
コネクションの追加
database.phpにコネクション情報はいくつでも設定できます。
自動コネクション設定の下あたりに、writerコネクション専用の定義を書くだけです。
writerコネクション設定例
'mysql' => [
'reader' => [
'host' => [env('DB_READER_HOST', 'localhost')],
],
'writer' => [
'host' => [env('DB_WRITER_HOST', 'localhost')],
],
'driver' => 'mysql',
'port' => env('DB_PORT', 3306),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
],
// 追加で名前をつけて書くだけ
'force_writer' => [
'driver' => 'mysql',
'host' => env('DB_WRITER_HOST', 'localhost'),
'port' => env('DB_PORT', 3306),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
],
明示的なコネクションを指定したmodel部
それでは、購入処理を実装してみます。
まずは、writerコネクションを明示的に指定したSELECT FOR UPDATEメソッドと、UPDATEメソッドです。
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Products extends Model
{
protected $fillable = [
'id',
'stock',
];
/**
* writerコネクションでロックしながら1件取得
* @param int $id
* @return Products|null
*/
public function writerFetchById(int $id): ?Products
{
return $this->setConnection('force_writer')
->where('id', $id)
->lockForUpdate()
->first();
}
/**
* 在庫数の更新
* @param Products $products
*/
public function updateStock(Products $products)
{
$products->save();
}
}
上記のmodelを使った購入処理部
購入実処理です。
本当は、UPDATEする前に在庫数をチェックすればいいだけなんですが、rollbackできるかのチェックのためにわざとDB更新後に在庫数チェックをしています。
<?php
declare(strict_types=1);
namespace App\Services;
use App\Exceptions\OutOfStockException;
use App\Models\Products;
use Illuminate\Support\Facades\DB;
use Exception;
class ProductService
{
/**
* @var Products
*/
private $products;
/**
* ProductService constructor.
* @param Products $products
*/
public function __construct(Products $products)
{
$this->products = $products;
}
/**
* 購入処理
* @param int $id
* @param int $piecesNumber
* @throws Exception
*/
public function purchase(int $id, int $piecesNumber): void
{
$product = $this->products->writerFetchById($id);
DB::beginTransaction();
try {
$product->stock -= $piecesNumber;
$this->products->updateStock($product);
// 在庫判定をupdateする前にすればいいだけですが、rollbackのテストのためにこんな風にしてます
if ($product->stock <= 0) {
$message = vsprintf('ID:%d / 購入数:%d / 在庫数:%d', [$id, $piecesNumber, $product->stock]);
throw new OutOfStockException($message);
}
DB::commit();
} catch (Exception $exception) {
DB::rollBack();
throw $exception;
}
}
}
テスト
それではテストをしてみます。
正常系は、購入後、在庫が減っていること。異常系として、在庫が不足している場合はrollbackしていることをテストします。(本来ならばModel部分はMockにしてテストすべきですが、ここは説明のためにMockにしていません)
<?php
declare(strict_types=1);
namespace Tests\Models;
use App\Models\Products;
use App\Services\ProductService;
use Laravel\Lumen\Testing\DatabaseTransactions;
use Tests\TestCase;
use Exception;
class PurchaseTest extends TestCase
{
use DatabaseTransactions;
protected $connectionsToTransact = ['mysql', 'force_writer'];
/**
* @var Products
*/
private $products;
/**
* @var ProductService
*/
private $service;
public function setUp()
{
parent::setUp();
$this->products = app()->make(Products::class);
$this->service = app()->make(ProductService::class);
$this->products->setConnection('force_writer')->fill(['id' => 1, 'stock' => 2])->save();
}
/**
* 購入できること
*/
public function testPurchase()
{
$actual = $this->products->find(1);
$this->assertSame(2, $actual->stock);
$this->service->purchase(1, 1);
$boughtActual = $this->products->find(1);
$this->assertSame(1, $boughtActual->stock);
}
/**
* 在庫数が足りない場合はロールバックして例外が発報されること
*/
public function testRollback()
{
$actual = $this->products->find(1);
$this->assertSame(2, $actual->stock);
try {
$this->service->purchase(1, 3);
} catch (Exception $exception) {
$rollbackActual = $this->products->find(1);
$this->assertSame(2, $rollbackActual->stock);
}
}
}
結果
Failed asserting that -1 is identical to 2.
/data/web/tests/Models/PurchaseTest.php:61
Time: 960 ms, Memory: 12.00MB
FAILURES!
Tests: 2, Assertions: 4, Failures: 1.
テストは失敗します。rollbackされずに在庫数は-1になってしまっています。
なにが問題なのか
冒頭にも書きましたがコネクションが別だと、Laravelではrollbackされません。
でも、明示的にforce_writerコネクションを指定し、updateもそのインスタンスを使っています。どこのコネクションが違うというのでしょう。
もう一度、ソースを見てみます。
public function purchase(int $id, int $piecesNumber): void
{
$product = $this->products->writerFetchById($id);
DB::beginTransaction();
try {
$product->stock -= $piecesNumber;
$this->products->updateStock($product);
// 在庫判定をupdateする前にすればいいだけですが、rollbackのテストのためにこんな風にしてます
if ($product->stock <= 0) {
$message = vsprintf('ID:%d / 購入数:%d / 在庫数:%d', [$id, $piecesNumber, $product->stock]);
throw new OutOfStockException($message);
}
DB::commit();
} catch (Exception $exception) {
DB::rollBack();
throw $exception;
}
}
たしかに、$product
にはwriterコネクションのモデルが入ります。そして、updateStock
メソッドにその$product
インスタンスを代入しています。
…でも、このソースにはもう1つ、コネクションが隠されています。
そう、それは、__DBファサード__です。
DBファサードも当然コネクションの概念があります。
コネクションを指定しなければデフォルトの「自動で選択してくれるコネクション」の方が選択されます。このソースでもコネクションの指定をしていないため、デフォルトコネクションでトランザクションをはっているのです。無論、rollbackしてもデフォルトコネクションに対してのrollbackということになります。明示的に指定したforce_writerコネクションにはなんの影響も与えてないのです。
もうおわかりでしょう。このソースを正しく動くようにするには、DBファサードにも明示的にコネクションを指定する必要があります。
public function purchase(int $id, int $piecesNumber): void
{
$product = $this->products->writerFetchById($id);
// 明示的にコネクションを指定
DB::connection('force_writer')->beginTransaction();
try {
$product->stock -= $piecesNumber;
$this->products->updateStock($product);
// 在庫判定をupdateする前にすればいいだけですが、rollbackのテストのためにこんな風にしてます
if ($product->stock <= 0) {
$message = vsprintf('ID:%d / 購入数:%d / 在庫数:%d', [$id, $piecesNumber, $product->stock]);
throw new OutOfStockException($message);
}
DB::connection('force_writer')->commit();
} catch (Exception $exception) {
DB::connection('force_writer')->rollBack();
throw $exception;
}
}
結果
PHPUnit 7.4.4 by Sebastian Bergmann and contributors.
Time: 1.21 seconds, Memory: 12.00MB
OK (2 tests, 4 assertions)
無事rollbackできました。
まとめ
説明した以外にも、トランザクション内で複数のテーブルを扱っていて、Aテーブルは参照だけなのでデフォルトコネクション、Bテーブルはロックするので明示的なコネクション指定してた、なんてときもこの罠にハマりやすいです。
自分の環境ではそもそも別の理由からファサードを禁止しているかねあいもあって、begin~commit~rollbackのDBファサードファサードをラップし、明示的にwriterを向かせたライブラリでトランザクションは実装するように、といった形で運用しています。