アーキテクチャ ·

DBとS3をまたぐ更新で、整合性の責任をどこに置くか

  • 設計
  • データ整合性
  • AWS

この記事で扱う問題

画像つきの投稿を保存する処理を考えます。

$path = Storage::disk('s3')->put('images', $file);
$post->image_path = $path;
$post->save();

DB のトランザクションは S3 を巻き戻してくれません。次のような失敗が起こり得ます。

  • S3 へのアップロードは成功したが、DB の保存に失敗した → どこからも参照されないファイルが残る
  • 画像を差し替えるときに古いファイルを先に消し、DB の更新に失敗した → DB が存在しないファイルを指す

問題は、この後始末を呼び出し側が毎回考えなければならないことです。投稿、プロフィール画像、添付ファイル……と同じパターンが増えるたびに、後始末の漏れも増えます。

判断

DB と S3 を一緒に扱う保存処理は、Repository(または専用の境界となるクラス)にまとめ、整合性を守る責任をそこに持たせます。

final class PostRepository
{
    public function save(Post $post, ?UploadedImage $newImage): void
    {
        $newPath = $newImage ? $this->storage->put($newImage) : null;   // 1. 先に新しいファイルを置く

        // 失敗しても、ここでは新しいファイルを消さない(理由は後述)
        $this->db->transaction(function () use ($post, $newPath) {
            $oldPath = $this->writeRecord($post, $newPath);             // 2. DB を更新(古いパスを返す)
            if ($newPath !== null && $oldPath !== null) {
                $this->deletionQueue->enqueue($oldPath);                // 3. 古いファイルの削除予約を、同じトランザクションで DB に記録
            }
        });
    }
}

呼び出し側は $posts->save($post, $image) を呼ぶだけで、順序や後始末を知る必要がありません。古いファイルは、削除予約のテーブルを読む別のジョブが後から消します。

DB の更新に失敗しても、新しく置いたファイルをその場で消していないのもポイントです。transaction() が例外を返しても、DB 側ではコミットが済んでいることがあります(コミットの応答を受け取る前に通信が切れた場合など)。そこで新しいファイルを消すと、コミット済みの DB が消えたファイルを指す、という一番避けたい不整合を自分で作ってしまいます。どこからも参照されていないファイルは、DB を確かめてから消す定期的な掃除に任せます。

削除予約を DB のトランザクションの中で記録しているのも、同じ考え方です。コミットした後に削除を予約する書き方だと、予約に失敗したときに「保存は終わっているのに save() が例外を返す」状態になります。呼び出し側は失敗と判断して再試行し、古いファイルの掃除も予約されません。同じトランザクションに入れておけば、DB の更新と削除予約は必ずそろって成功するか、そろって失敗します(Outbox パターンと同じ考え方です)。

なぜそう判断したか

分散した2つのリソースを、完全に同時に更新することはできません。できるのは、失敗したときに「どちらの不整合なら許容できるか」を選ぶことです。

  • DB が存在しないファイルを指す状態は、画面が壊れるので許容しにくい
  • 参照されないファイルが S3 に残る状態は、容量を使うだけで利用者には見えない

そこで、「新しいファイルを先に置く → DB を切り替える → 古いファイルは後で消す」という順序にしています。どの時点で失敗しても、DB は必ず存在するファイルを指します。取り残されたファイルは、定期的な掃除で回収できます。

この判断を呼び出し側に任せると、書く人ごとに順序が変わります。境界に閉じ込めることで、順序の判断を1か所で行えます。

どこから設計過剰になるか

  • 取り残しのファイルを回収する仕組みまで作るかは、ファイルの量と保存コスト次第です。量が少なければ、失敗時にログを残して手動で消す運用でも足ります
  • 削除予約のテーブルとジョブまで作るかは、ファイルの量と運用次第です。量が少なければ、コミット後の削除に失敗したらログを残すだけにして、定期的な掃除に任せる形でも足ります。その場合も、コミット後の失敗を save() の失敗として返さないことが大事です
  • 更新するリソースが3つ以上になり、それぞれに取り消し処理が必要なら、Saga のように状態を持った手順として設計し直すほうが適しています
  • 読み取り専用のファイル配信なら、この仕組みは不要です

まとめ

  • DB のトランザクションは S3 を巻き戻さない。失敗時にどちらの不整合を許容するかを選ぶ
  • 「新しいファイルを先に置く → DB を切り替える → 古いファイルは後で消す」順序なら、DB は常に存在するファイルを指す
  • この順序と後始末は、呼び出し側ではなく Repository などの境界に持たせる

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。