紡Book の勉強ノートに戻る
コードレビュー学習2026.05.11

2ステップ保存で空ページが残った — transaction.atomic() を「いつ使い、いつ使わないか」

Djangotransactionデータ整合性

コードレビューで見つかった「空ページが残る可能性がある」指摘を、修正と一緒にまとめたものです(修正済み)。

この記事でわかること

  • DB に 2 つ以上の書き込みをするとき「片方だけ成功する」とどうなるか
  • Django の transaction.atomic() がやってくれること
  • ビューの create で transaction を使うべき場面と、使う必要がない場面の見分け方

起きていたコード

テンプレートページ(写真とコメントの組み合わせ)を作成する DRF ビュー。元のコードはこんな構造だった:

python
def create(self, request, *args, **kwargs):
    ser = TemplatePageCreateSerializer(data=request.data)
    ser.is_valid(raise_exception=True)
 
    # 1. ページ本体を作る
    page = TemplatePage.objects.create(
        owner=request.user,
        title=ser.validated_data["title"],
        template_type=ser.validated_data["template_type"],
    )
    # 2. ページに紐づく entries を bulk_create する
    TemplatePageEntry.objects.bulk_create([
        TemplatePageEntry(
            page=page,
            meaning=meanings[e["meaning_id"]],
            ...
        )
        for e in entries_data
    ])
    return Response(TemplatePageDetailSerializer(page, ...).data, ...)

普通に動く。普通に動くんだけど、レビューで指摘されたのは:

bulk_create が途中で失敗すると、page だけ作られて entries 0 件のレコードが DB に残る

置き場所。閉じても残る正は PostgreSQL の2表。失敗表示は HTTP の返事で保存しない。画面「保存に失敗しました」HTTP の返事。保存しないphotos_templatepage空のページ 1行が残るステップ1 の create が既に確定PostgreSQL・閉じても残るphotos_templatepageentry行なしステップ2 の bulk_create が失敗PostgreSQL・閉じても残る画面は失敗。残る正は左の空ページ1行。

なぜ「空ページ残留」が問題か

  • ユーザは失敗と見て再度作り直します
  • 後で一覧を表示すると、空ページが混ざる → 操作不能 or 不思議な UI 挙動
  • 再現性のあるバグの温床になります

セキュリティ問題ではないが、データ整合性の問題。

なぜ bulk_create が失敗しうるのか

  • 接続切断(DB との TCP が切れる)
  • ロックタイムアウト(他のトランザクションと競合)
  • 制約違反(外部キーが指す先が削除された 等)
  • メモリ不足・タイムアウト(巨大な bulk)

「100% 落ちる」とは限らないが、運用していれば必ず数回は遭遇するライン。

直し方: transaction.atomic()

Django の transaction.atomic() を使うと、ブロック内の DB 書き込みが全部成功するか / 全部ロールバックされるかの二択になります:

python
from django.db import transaction
 
def create(self, request, *args, **kwargs):
    ser = TemplatePageCreateSerializer(data=request.data)
    ser.is_valid(raise_exception=True)
 
    with transaction.atomic():  # ← ここから下を「全部成功 or 全部なし」の1かたまりにする
        page = TemplatePage.objects.create(...)
        TemplatePageEntry.objects.bulk_create([...])  # 途中でコケたら page 作成ごと巻き戻る
 
    return Response(...)
置き場所。atomic は交差で、残る正は PostgreSQL の2表。例外で止まる塊ごと開始時点へ戻るphotos_templatepage行なしphotos_templatepageentry行なし最後まで完走両表が一緒に確定するphotos_templatepageページ 1行photos_templatepageentryentries が並ぶatomic は交差。閉じても残る正は2表。

仕組み(ざっくり)

with transaction.atomic(): は内部で「ブロック開始時に SAVEPOINT を張り、最後まで通れば確定(コミット相当)、途中で例外が出れば開始時点まで巻き戻す(ROLLBACK)」という動きをします。

SAVEPOINT のステップごとの中身やネストの挙動は、原典の transaction.atomic() の「全部か無か」はどう効くのか で詳しく書いた。この記事では「で、結局いつ使うのか」に絞ります。

いつ使う?

毎回 transaction.atomic() で囲めばいいのかというと、そうでもありません。コストもあります(SAVEPOINT 発行、競合発生時のロック保持時間が伸びる)。

使うべき場面の目安:

  • 1リクエストで複数テーブルに書き込みを横断します(今回のケース)
  • 中間状態に意味がありません(page 単独 / entries 単独で残しても意味がありません)
  • 整合性が崩れると後続のユーザ操作が壊れます

使わなくていい場面:

  • 単一テーブルの単一 INSERT/UPDATE
  • 失敗してもユーザが再試行すれば良い独立した処理(例: ログ書き込み)
  • 並列性を優先したい(ロック保持時間を短くしたい)

学びポイント

  • 「1リクエスト内で書く先が複数ある」コードを見たら、原子性を考える癖をつけます
  • Django なら with transaction.atomic(): で原子性は1行で確保できます
  • 「動いてるから OK」と「データ整合性 OK」は別。運用していれば必ず障害は起きる前提で書きます
  • bulk_create は「複数 INSERT を 1 ラウンドトリップで送る」最適化であって、原子性は保証しません(atomic で囲んで初めてセット)

おわりに

「正常系で動く」コードと「障害時にも壊れない」コードは別物で、後者を書くには 「これ落ちたらどうなる?」を全 step ごとに想像する癖が要ります。

transaction.atomic() はその想像に応える Django の道具。今回みたいに 2 ステップに分かれてる時こそ、囲むことで「中間状態は許さない」をコードで表現できます。


この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。