トランザクション管理 — 整合性の境界をどこに置くか
トランザクションとは
トランザクションは、複数のデータベース操作を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が実行される - 例外でロールバック: クロージャ内で例外が発生すると自動的に
ROLLBACK - 正常終了でコミット: クロージャが正常に終了すると自動的に
COMMIT - 例外の再スロー: ロールバック後、例外は呼び出し元に再スローされる
// 手動でトランザクションを制御する場合(非推奨)
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 save(Order $order): void
{
// Repository内でトランザクション
DB::transaction(function () use ($order) {
$orderModel = OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'status' => $order->status()->value,
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
]
);
$this->saveOrderLines($orderModel, $order->orderLines());
});
}
}
// 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はトランザクションを意識しない
$this->orderRepository->save($order);
return $orderId;
}
}
複数集約の更新:UseCase内でトランザクション
注文確定時に在庫を減らすケースを考えます。
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}
public function execute(OrderId $orderId): void
{
// 複数集約を更新するため、UseCase内でトランザクション管理
DB::transaction(function () use ($orderId) {
// 1. Order集約を取得・更新
$order = $this->orderRepository->findById($orderId)
?? throw new DomainException('注文が見つかりません');
$order->confirm();
$this->orderRepository->save($order);
// 2. Inventory集約を更新(在庫を減らす)
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new DomainException('在庫情報が見つかりません');
$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 SAVEPOINT trans2(正常終了)
DB::transaction(function () { ← Transaction Level 3: SAVEPOINT trans3
// 操作3
throw new Exception(); ← ROLLBACK TO SAVEPOINT trans3(例外)
});
}); ← COMMIT(最外層)
実際の動作:
// UseCase内でトランザクション開始
DB::transaction(function () { // BEGIN TRANSACTION
// Repository内でもDB::transaction()を呼んでいる
$this->orderRepository->save($order); // SAVEPOINT sp1 → ... → RELEASE sp1
$this->inventoryRepository->save($inventory); // SAVEPOINT sp2 → ... → RELEASE sp2
}); // COMMIT(最外層のみ実際にコミット)
- 最外層のみがコミット: ネストされた内側のトランザクションは、最外層が成功したときのみコミットされる
- 内側の失敗は全体に波及: 内側のトランザクションで例外が発生すると、外側にも伝播し、全体がロールバックされる
- Repository内でトランザクションを張っていても安全: UseCase内でトランザクションを張れば、全体が1つのトランザクションとして扱われる
ネストしたトランザクションの実例
// UseCase: 複数集約を更新(トランザクション管理)
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
// 最外層トランザクション
DB::transaction(function () use ($orderId) {
// Repository内でもトランザクションを使用(Savepointになる)
$order = $this->orderRepository->findById($orderId);
$order->confirm();
$this->orderRepository->save($order); // 内部でDB::transaction()
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId());
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory); // 内部でDB::transaction()
}
// すべて成功 → COMMIT
});
}
}
そのため、Repository内でトランザクションを張っていても、UseCase内でトランザクションを張れば、全体が1つのトランザクションとして扱われます。
DB::transaction()をUseCaseで直接使うことについて厳密なクリーンアーキテクチャでは、DB::transaction()はインフラ層の詳細であり、UseCase層で直接使うべきではないという考え方もあります。
// より厳密な実装(オプション)
interface TransactionManagerInterface
{
public function execute(callable $callback): mixed;
}
final class LaravelTransactionManager implements TransactionManagerInterface
{
public function execute(callable $callback): mixed
{
return DB::transaction($callback);
}
}
しかし、本書ではLaravelの実用性を重視し、DB::transaction()を直接使用しています。これはLaravelプロジェクトでの一般的なプラクティスであり、テストでもRefreshDatabaseトレイトにより問題なく動作します。
プロジェクトの要件に応じて、抽象化レベルを選択してください。
楽観的ロック(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での実装
// マイグレーション
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status');
// ...
$table->unsignedBigInteger('version')->default(1); // バージョンカラム
$table->timestamps();
});
// Eloquent Model
final class OrderModel extends Model
{
protected $fillable = ['status', 'version', /* ... */];
}
ドメインエンティティでのバージョン管理
// 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 save(Order $order): void
{
DB::transaction(function () use ($order) {
$currentVersion = $order->version();
$order->incrementVersion();
$affected = OrderModel::where('id', $order->id()->value())
->where('version', $currentVersion) // 現在のバージョンを条件に
->update([
'status' => $order->status()->value,
'version' => $order->version(),
// ...
]);
if ($affected === 0) {
// 更新対象がない = バージョンが変わっている = 競合
throw new OptimisticLockException(
'他のユーザーによって更新されました。再度読み込んでください。'
);
}
});
}
}
新規作成時の処理
public function save(Order $order): void
{
DB::transaction(function () use ($order) {
$exists = OrderModel::where('id', $order->id()->value())->exists();
if ($exists) {
// 更新:楽観的ロックを適用
$this->updateWithOptimisticLock($order);
} else {
// 新規作成:バージョン1で保存
$this->create($order);
}
});
}
例外の定義
// app/Domain/Shared/Exception/OptimisticLockException.php
final class OptimisticLockException extends DomainException
{
public function __construct(string $message = '同時更新の競合が発生しました')
{
parent::__construct(
message: $message,
errorCode: 'OPTIMISTIC_LOCK_CONFLICT',
);
}
}
DDDアプリケーションでは、同時更新の競合を防ぐために2つのアプローチがあります。
| 方式 | 特徴 | 適したケース | パフォーマンス |
|---|---|---|---|
| 楽観的ロック | 更新時に競合を検出 | 競合が稀な場合(Webアプリの多く) | 高速(ロック待ちなし) |
| 悲観的ロック | 読み取り時にロック取得 | 競合が頻繁な場合(在庫の同時更新など) | 低速(ロック待ちが発生) |
楽観的ロックの仕組み
ユーザーA: read (version 1) → 処理中...
ユーザーB: read (version 1) → 処理中...
ユーザーA: update (version 1 → 2) ✓ 成功
ユーザーB: update (version 1 → ?) ✗ 競合エラー(version が既に2になっている)
利点: 読み取り時にロックしないため、パフォーマンスが高くなります。
欠点: 競合発生時はエラーになり、ユーザーは再試行が必要になります。
悲観的ロックの仕組み
ユーザーA: read with lock → 処理中...(他のユーザーは待機)
ユーザーB: read with lock → 待機中...
ユーザーA: update → commit → ロック解放
ユーザーB: read with lock → 処理開始
利点: 確実に競合を防げます。
欠点: ロック待ちが発生し、パフォーマンスが低下する可能性があります。
Laravelでの悲観的ロックの実装
// app/Infrastructure/Repository/EloquentInventoryRepository.php
public function findByProductIdForUpdate(ProductId $productId): ?Inventory
{
$model = InventoryModel::where('product_id', $productId->value())
->lockForUpdate() // SELECT ... FOR UPDATE(行ロック)
->first();
return $model ? $this->toDomain($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公式ドキュメント
次のチャプターでは、エラーハンドリングについて詳しく見ていきます。