トランザクション管理 — 整合性の境界をどこに置くか
トランザクションとは
トランザクションは、複数のデータベース操作を1つの論理的な単位としてまとめる仕組みです。すべての操作が成功すればコミット(確定)され、1つでも失敗すればロールバック(取り消し)されます。
ACID特性
トランザクションは以下の4つの特性(ACID)を保証します。
| 特性 | 説明 | 例 |
|---|---|---|
| Atomicity(原子性) | すべて成功するか、すべて失敗するか | 注文作成と注文明細の保存は両方成功、または両方失敗 |
| Consistency(一貫性) | データベースが常に整合性のある状態に保たれる | 注文の合計金額が常に明細の合計と一致 |
| Isolation(分離性) | 同時実行される他のトランザクションの影響を受けない | ユーザーAとBが同時に在庫を更新しても整合性が保たれる |
| Durability(永続性) | コミット後は障害があってもデータが保持される | コミット完了後のデータはシステムクラッシュでも失われない |
[Without Transaction]
1. Insert order ✓
2. Insert order_lines ✗ (ERROR!)
→ Result: 注文だけが残り、明細がない不整合な状態
[With Transaction]
1. BEGIN TRANSACTION
2. Insert order ✓
3. Insert order_lines ✗ (ERROR!)
4. ROLLBACK
→ Result: すべての操作が取り消され、整合性が保たれる
Laravelのトランザクション管理
DB::transaction()の動作
LaravelのDB::transaction()は以下のように動作します。
DB::transaction(function () {
// この中の操作がトランザクション内で実行される
OrderModel::create([/* ... */]);
OrderLineModel::create([/* ... */]);
// 例外が発生すると自動的にロールバック
if ($error) {
throw new Exception('Error!');
}
// 正常終了すると自動的にコミット
});
動作の詳細:
- 自動BEGIN: 最外層で
DB::transaction()が呼ばれた時点でBEGIN TRANSACTIONが実行される(入れ子の内側はSavepointになる。後述) - 例外でロールバック: クロージャ内で例外が発生すると自動的に
ROLLBACK - 正常終了でコミット: クロージャが正常に終了すると自動的に
COMMIT - 例外の再スロー: ロールバック後、例外は呼び出し元に再スローされる
- デッドロック時の再試行: 第2引数で試行回数を指定できる(
DB::transaction($callback, $attempts)。既定は1回で再試行しない)
// 手動でトランザクションを制御する場合(非推奨)
DB::beginTransaction();
try {
OrderModel::create([/* ... */]);
OrderLineModel::create([/* ... */]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// ↑ DB::transaction()を使えば上記の処理を自動化できる
手動でbeginTransaction()、commit()、rollBack()を管理すると、例外ハンドリングを忘れたり、ネストしたトランザクションの扱いが複雑になります。DB::transaction()を使うことで、これらの問題を回避できます。
2つのアプローチ
トランザクションを管理する場所には2つのアプローチがあります。
トランザクションをどこに置くか
| ケース | トランザクションの場所 | 理由 |
|---|---|---|
| 1つの集約のみ更新 | Repository内(アプローチA) | 集約の整合性はリポジトリが保証 |
| 複数の集約を更新 | UseCase内(アプローチB) | 複数集約にまたがる整合性はUseCaseが保証 |
1集約の更新:Repository内でトランザクション
// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher,
) {}
public function save(Order $order): void
{
// Repository内でトランザクション
DB::transaction(function () use ($order) {
$orderModel = OrderModel::findOrNew($order->id()->value());
// 識別子は$fillableに含めず直接代入する(第13章)
$orderModel->id = $order->id()->value();
$orderModel->fill([
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
])->save();
$this->saveOrderLines($orderModel, $order->orderLines());
});
// 外側にトランザクションがあれば最外のコミットを待ち、無ければ即時実行される
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
}
versionはここでは書きません。バージョンの読み書きは後半の楽観的ロックでまとめて扱います。イベント配信の2フェーズについては第13章「リポジトリパターン」の実装と、第9章「ドメインイベント」がこの章を名指しで注意している箇所を参照してください。
// app/Application/UseCase/Order/CreateOrderUseCase.php
final class CreateOrderUseCase
{
public function __construct(private readonly OrderRepositoryInterface $orderRepository) {}
public function execute(CreateOrderCommand $command): OrderId
{
$orderId = $this->orderRepository->nextIdentity();
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
$order = Order::create($orderId, $shippingAddress);
foreach ($command->items as $item) {
$order->addItem(
$this->orderRepository->nextLineIdentity(),
new ProductId($item['productId']),
$item['quantity'],
new Money($item['unitPrice'], 'JPY'),
);
}
// UseCaseはトランザクションを意識しない
// 新規作成なのでcreate()。所有者は引数で渡す(第13章)
$this->orderRepository->create($order, new UserId($command->userId));
return $orderId;
}
}
複数集約の更新:UseCase内でトランザクション
注文確定時に在庫を減らすケースを考えます。
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
use App\Domain\Inventory\Exception\InventoryNotFoundException;
use App\Domain\Order\Exception\OrderNotFoundException;
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}
public function execute(ConfirmOrderCommand $command): void
{
// 複数集約を更新するため、UseCase内でトランザクション管理
DB::transaction(function () use ($command) {
// 1. Order集約を取得・更新
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
$order->confirm();
$this->orderRepository->save($order);
// 2. Inventory集約を更新(在庫を減らす)
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new InventoryNotFoundException($line->productId());
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory);
}
});
}
}
ネストしたトランザクションの扱い
Laravelでは、DB::transaction()がネストした場合、Savepoint機能が使われます。
Savepointとは
Savepointは、トランザクション内の特定地点をマークし、その地点までロールバックできる機能です。
[Nested Transaction Flow]
DB::transaction(function () { ← Transaction Level 1: BEGIN
// 操作1
DB::transaction(function () { ← Transaction Level 2: SAVEPOINT trans2
// 操作2
}); ← 正常終了(後述のとおりRELEASEは発行されない)
DB::transaction(function () { ← Transaction Level 2: SAVEPOINT trans2(兄弟なので同名)
// 操作3
throw new Exception(); ← ROLLBACK TO SAVEPOINT trans2(例外)
});
}); ← COMMIT(最外層)
セーブポイント名は入れ子の深さで決まるので、同じ深さに並ぶ2つはどちらもtrans2です。また、内側が正常終了してもRELEASE SAVEPOINTは発行されません。Laravelは内部のトランザクションカウンタを1つ戻すだけで、実際にコミットするのは最外層だけです。
実際の動作:
// UseCase内でトランザクション開始
DB::transaction(function () { // BEGIN TRANSACTION
// Repository内でもDB::transaction()を呼んでいる
$this->orderRepository->save($order); // SAVEPOINT trans2 → ... → 正常終了
$this->inventoryRepository->save($inventory); // SAVEPOINT trans2 → ... → 正常終了
}); // COMMIT(最外層のみ実際にコミット)
- 最外層のみがコミット: ネストされた内側のトランザクションは、最外層が成功したときのみコミットされる
- 内側の失敗は全体に波及: 内側のトランザクションで例外が発生すると、外側にも伝播し、全体がロールバックされる
- Repository内でトランザクションを張っていても安全: UseCase内でトランザクションを張れば、全体が1つのトランザクションとして扱われる
この性質があるので、先ほどの ConfirmOrderUseCase のように Repository 内でトランザクションを張っていても、UseCase 側で外側のトランザクションを張れば全体が1つのトランザクションとして扱われます。Repository の DB::transaction() はSavepointになり、実際にコミットするのは最外層だけです。
DB::transaction()をUseCaseで直接使うことについて厳密なクリーンアーキテクチャでは、DB::transaction()はインフラ層の詳細であり、UseCase層で直接使うべきではないという考え方もあります。
// より厳密な実装(オプション)
use Closure;
interface TransactionManagerInterface
{
// DB::transaction()がClosureしか受け取らないので、ここもClosureで揃える
public function execute(Closure $callback): mixed;
}
final class LaravelTransactionManager implements TransactionManagerInterface
{
public function execute(Closure $callback): mixed
{
return DB::transaction($callback);
}
}
しかし、本書ではLaravelの実用性を重視し、DB::transaction()を直接使用しています。統合テストではRefreshDatabaseトレイトにより動作します。モックだけのユニットテストにしたい場合は、上のTransactionManagerInterfaceを挟んでください。
プロジェクトの要件に応じて、抽象化レベルを選択してください。
楽観的ロック(Optimistic Locking)
同時更新による競合を防ぐには、楽観的ロックが有効です。
[Problem: Lost Update]
User A: read order (version 1)
User B: read order (version 1)
User A: update order → save (version 2)
User B: update order → save (overwrites A's changes!)
[Solution: Optimistic Locking]
User A: read order (version 1)
User B: read order (version 1)
User A: update order → save (version 1 → 2) ✓
User B: update order → save (version 1 → ?) ✗ Conflict!
Laravelでの実装
versionカラムは第14章「ドメインモデルとテーブル設計」のordersマイグレーションでdefault(1)付きで定義します。OrderModelの$fillableにversionを入れるのも第13章「リポジトリパターン」です。本章はその上で競合を検出する側を扱います。
ドメインエンティティでのバージョン管理
// app/Domain/Order/Order.php
final class Order
{
private function __construct(
private readonly OrderId $id,
private OrderStatus $status,
private int $version, // バージョン
// ...
) {}
public function version(): int
{
return $this->version;
}
public function incrementVersion(): void
{
$this->version++;
}
}
リポジトリでの競合検出
// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher,
) {}
public function save(Order $order): void
{
DB::transaction(function () use ($order) {
$currentVersion = $order->version();
$affected = OrderModel::where('id', $order->id()->value())
->where('version', $currentVersion) // 現在のバージョンを条件に
->update([
'status' => $order->status()->value,
'version' => $currentVersion + 1,
// ...
]);
if ($affected === 0) {
// 更新対象がない = バージョンが変わっている = 競合
throw new OptimisticLockException(
'他のユーザーによって更新されました。再度読み込んでください。'
);
}
// 成功してから進める。UPDATEの前に進めると、デッドロック再試行
// ($attempts > 1)で同じインスタンスを使い回したときに二重に進む
$order->incrementVersion();
// update()は件数を返すだけなので、明細の保存にはモデルを取り直す
$orderModel = OrderModel::findOrFail($order->id()->value());
$this->saveOrderLines($orderModel, $order->orderLines());
});
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
}
新規作成時のversionは、第14章のマイグレーションがdefault(1)を持つので指定しなくても1から始まります。
例外の定義
// app/Domain/Shared/Exception/OptimisticLockException.php
// namespaceを省くとグローバルのクラスになり、第16章のmatchが捕まえる
// App\Domain\Shared\Exception\OptimisticLockException とは別物になる
namespace App\Domain\Shared\Exception;
final class OptimisticLockException extends DomainException
{
public function __construct(string $message = '同時更新の競合が発生しました')
{
parent::__construct(
message: $message,
errorCode: 'OPTIMISTIC_LOCK_CONFLICT',
);
}
}
同時更新の競合を防ぐ方法はもう1つあります。悲観的ロックです。
| 方式 | 特徴 | 適したケース | パフォーマンス |
|---|---|---|---|
| 楽観的ロック | 更新時に競合を検出 | 競合が稀な場合(Webアプリの多く) | 高速(ロック待ちなし) |
| 悲観的ロック | 読み取り時にロック取得 | 競合が頻繁な場合(在庫の同時更新など) | 低速(ロック待ちが発生) |
楽観的ロックの仕組み
ユーザーA: read (version 1) → 処理中...
ユーザーB: read (version 1) → 処理中...
ユーザーA: update (version 1 → 2) ✓ 成功
ユーザーB: update (version 1 → ?) ✗ 競合エラー(version が既に2になっている)
利点: 読み取り時にロックしないため、パフォーマンスが高くなります。
欠点: 競合発生時はエラーになり、ユーザーは再試行が必要になります。
悲観的ロック(Pessimistic Locking)
悲観的ロックの仕組み
ユーザーA: read with lock → 処理中...(他のユーザーは待機)
ユーザーB: read with lock → 待機中...
ユーザーA: update → commit → ロック解放
ユーザーB: read with lock → 処理開始
利点: 確実に競合を防げます。
欠点: ロック待ちが発生し、パフォーマンスが低下する可能性があります。
Laravelでの悲観的ロックの実装
// app/Infrastructure/Repository/EloquentInventoryRepository.php
final class EloquentInventoryRepository implements InventoryRepositoryInterface
{
// 他のメソッドは第8章のインターフェース定義を参照
public function findByProductIdForUpdate(ProductId $productId): ?Inventory
{
$model = InventoryModel::where('product_id', $productId->value())
->lockForUpdate() // SELECT ... FOR UPDATE(行ロック)
->first();
return $model ? $this->toEntity($model) : null;
}
}
// UseCase内での使用
DB::transaction(function () {
// 悲観的ロックで在庫を取得(他のトランザクションはここで待機)
$inventory = $this->inventoryRepository->findByProductIdForUpdate($productId);
$inventory->decrease(5);
$this->inventoryRepository->save($inventory);
// コミット後、他のトランザクションがロック解放を待って進行
});
ロック方式の選び方
多くのWebアプリケーションでは楽観的ロックが適しています。
楽観的ロックを選ぶ場合:
- 注文の更新、ユーザープロフィールの編集など、同時更新が稀なケース
- レスポンス速度を重視したい場合
悲観的ロックを選ぶ場合:
- セール期間中の在庫更新など、同時更新が頻繁に発生するケース
- 競合エラーでの再試行をユーザーに求めたくない場合
- 座席予約システムなど、確実に1つのトランザクションだけが処理すべき場合
まとめ
| 場面 | トランザクションの場所 | 理由 |
|---|---|---|
| 新規注文作成 | Repository | 1集約(Order)のみ更新 |
| 注文確定 + 在庫減少 | UseCase | 2集約(Order, Inventory)を更新 |
| 注文キャンセル | Repository | 1集約(Order)のみ更新 |
| 注文キャンセル + 在庫戻し | UseCase | 2集約を更新 |
| 同時更新の競合防止 | Repository | 楽観的ロックで検出 |
参考リソース
- Laravel Database Transactions - Laravel公式ドキュメント
次のチャプターでは、エラーハンドリングについて詳しく見ていきます。