メインコンテンツまでスキップ

トランザクション管理 — 整合性の境界をどこに置くか

トランザクションとは

トランザクションは、複数のデータベース操作を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!');
}

// 正常終了すると自動的にコミット
});

動作の詳細:

  1. 自動BEGIN: 最外層でDB::transaction()が呼ばれた時点でBEGIN TRANSACTIONが実行される(入れ子の内側はSavepointになる。後述)
  2. 例外でロールバック: クロージャ内で例外が発生すると自動的にROLLBACK
  3. 正常終了でコミット: クロージャが正常に終了すると自動的にCOMMIT
  4. 例外の再スロー: ロールバック後、例外は呼び出し元に再スローされる
  5. デッドロック時の再試行: 第2引数で試行回数を指定できる(DB::transaction($callback, $attempts)。既定は1回で再試行しない)
// 手動でトランザクションを制御する場合(非推奨)
DB::beginTransaction();
try {
OrderModel::create([/* ... */]);
OrderLineModel::create([/* ... */]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// ↑ DB::transaction()を使えば上記の処理を自動化できる
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(最外層のみ実際にコミット)
ネストしたトランザクションの重要な性質
  1. 最外層のみがコミット: ネストされた内側のトランザクションは、最外層が成功したときのみコミットされる
  2. 内側の失敗は全体に波及: 内側のトランザクションで例外が発生すると、外側にも伝播し、全体がロールバックされる
  3. 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$fillableversionを入れるのも第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つのトランザクションだけが処理すべき場合

まとめ

場面トランザクションの場所理由
新規注文作成Repository1集約(Order)のみ更新
注文確定 + 在庫減少UseCase2集約(Order, Inventory)を更新
注文キャンセルRepository1集約(Order)のみ更新
注文キャンセル + 在庫戻しUseCase2集約を更新
同時更新の競合防止Repository楽観的ロックで検出

参考リソース

次のチャプターでは、エラーハンドリングについて詳しく見ていきます。