akiyoko blog

akiyoko の IT技術系ブログです

Django のトランザクションについて(初級者向け)

この投稿は 「Django Advent Calendar 2020 - Qiita」 3日目の記事です。

akiyoko です。
Django のトランザクションについては拙著『現場で使える Django の教科書《基礎編》』の第6章「モデル(Model)」でも触れていますが、この記事ではもう少し詳しく解説してみたいと思います。




検証環境

  • Windows 10 Home
  • Django 3.1
  • PostgreSQL 13.1



 

はじめに

トランザクションは連続した複数のデータベース操作をひとつにまとめたものです。

トランザクションは、データベースをある一貫した状態から別の一貫した状態へ変更するアクションを1つに束ねたものである。トランザクション処理は、既知の一貫した状態のデータベースを維持するよう設計されており、相互依存のある複数の操作が全て完了するか、全てキャンセルされることを保証する。


トランザクション処理 - Wikipedia


一連の処理がすべて完了したらレコードの登録・変更・削除をまとめてデータベースに反映(コミット)し、エラーなどで一連の処理が完了しなかった場合はその過程でおこなわれたレコードの登録・変更・削除を無かったことにする(ロールバック)必要がある場合にトランザクションが利用されます。


 

デフォルトはオートコミット

Django のデフォルトでは、データベースのレコードを登録・更新・削除するための各クエリは、モデルオブジェクトの save() や delete() が実行された時点で即座にデータベースに反映されます。これは「オートコミットモード」と呼ばれます。 *1

つまり、デフォルトではトランザクションは利用されません。


例えば、本の購入決済をするためのビューの中で、次のように連続したレコード操作があるとします。

デフォルトでは、「① 注文情報を登録」「③ 在庫数を 1 減らす」「④ 注文情報のステータスを更新」といったクエリはそれぞれの時点でコミット(データベースに反映)されます。

PostgreSQL の SQL ログは次のように出力されます。


《 PostgreSQL の SQL ログ 》

[pid-1] SET TIME ZONE 'UTC'
[pid-1] SELECT "book"."id", "book"."title", "book"."price" FROM "book" WHERE "book"."id" = 1 LIMIT 21
[pid-1] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:00:18.900883+00:00'::timestamptz) RETURNING "order"."id"
[pid-1] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21
[pid-1] UPDATE "stock" SET "book_id" = 1, "quantity" = 0, "updated_at" = '2020-12-01T03:00:18.927935+00:00'::timestamptz WHERE "stock"."id" = 1
[pid-1] UPDATE "order" SET "status" = '02', "total_amount" = 1000, "ordered_by_id" = 1, "ordered_at" = '2020-12-01T03:00:18.900883+00:00'::timestamptz WHERE "order"."id" = 1

この中の INSERT や UPDATE は逐一コミットされます。




モデル、およびビューのソースコードのイメージは次の通りです。

shop/models.py(モデル)

from django.contrib.auth import get_user_model
from django.db import models

User = get_user_model()


class Book(models.Model):
    """本モデル"""

    class Meta:
        db_table = 'book'
        verbose_name = verbose_name_plural = '本'

    title = models.CharField('タイトル', max_length=255, unique=True)
    price = models.PositiveIntegerField('価格', null=True, blank=True)

    def __str__(self):
        return self.title


class BookStock(models.Model):
    """在庫モデル"""

    class Meta:
        db_table = 'stock'
        verbose_name = verbose_name_plural = '在庫'

    book = models.OneToOneField(Book, verbose_name='本', on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField('在庫数', default=0)
    updated_at = models.DateTimeField('更新日時', auto_now=True)

    def __str__(self):
        return f'{self.book.title} ({self.quantity})'


class Order(models.Model):
    """注文モデル"""

    class Meta:
        db_table = 'order'
        verbose_name = verbose_name_plural = '注文'

    STATUS_PAYMENT_PROCESSING = '01'
    STATUS_PAYMENT_OK = '02'
    STATUS_PAYMENT_NG = '03'
    STATUS_PAYMENT_ERROR = '09'
    STATUS_CHOICES = (
        (STATUS_PAYMENT_PROCESSING, '決済中'),
        (STATUS_PAYMENT_OK, '決済OK'),
        (STATUS_PAYMENT_NG, '決済NG'),
        (STATUS_PAYMENT_ERROR, '決済エラー'),
    )

    status = models.CharField('ステータス', max_length=2, choices=STATUS_CHOICES)
    total_amount = models.PositiveIntegerField('金額合計')
    ordered_by = models.ForeignKey(User, verbose_name='注文者', on_delete=models.PROTECT, editable=False)
    ordered_at = models.DateTimeField('注文日時', auto_now_add=True)

    def __str__(self):
        return f'{self.get_status_display()} ({self.ordered_at:%Y-%m-%d %H:%M})'


shop/views.py(ビュー)

from django.http.response import HttpResponseRedirect
from django.shortcuts import get_object_or_404, reverse
from django.views import View

from .models import Book, BookStock, Order


class CheckoutView(View):
    ...(略)...

    def post(self, request, *args, **kwargs):
        book = get_object_or_404(Book, pk=kwargs['pk'])

        # ① 注文情報を登録
        order = Order(
            status=Order.STATUS_PAYMENT_PROCESSING,
            total_amount=book.price,
            ordered_by=request.user,
        )
        order.save()

        # ② 在庫数を確認
        book_stock = get_object_or_404(BookStock, book=book)
        # ③ 在庫数を1減らして更新
        book_stock.quantity -= 1
        book_stock.save()

        ...(決済処理)...

        # ④ 注文情報のステータスを更新
        order.status = Order.STATUS_PAYMENT_OK
        order.save()

        ...(略)...

        return HttpResponseRedirect(reverse('shop:checkout_complete'))


PostgreSQL へのデータベース接続設定は次のようになります。

config/settings.py(設定ファイル)

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'mysite',
        'USER': 'mysiteuser',
        'PASSWORD': 'mysiteuserpass',
        'HOST': 'localhost',
        'PORT': '5432',
    }
}


ここまでは OK ですよね。



次に、ビューの中で予期せぬエラーが発生した場合について考えてみましょう。


デフォルトのオートコミットモードでは、エラーが発生する前の処理はすでにコミットされてしまっているので、プログラムで明示的に後始末処理(いわゆる手動ロールバック)をしないといけません。いろんなところで予期せぬエラーが発生することを考えると、いちいち手動でロールバック処理を書くのは大変ですよね。そこで Django ORM が提供している「トランザクション」 *2 の機能を利用します。


このように一連の処理をトランザクションで囲んでおけば、エラーが発生した場合のロールバック処理を自動でおこなってくれるのです。


Django でトランザクションを利用するには、大きく次の二つの方法があります。

  • 【方法1】ATOMIC_REQUESTS を有効化する
  • 【方法2】transaction.atomic() で囲む


それぞれについて詳しく見ていきましょう。



 

【方法1】ATOMIC_REQUESTS を有効化する

トランザクションを適用するには、データベースの「ATOMIC_REQUESTS」設定を有効化するのが一番手っ取り早いです。 有効化するには、次のように設定ファイルの「DATABASES」の「ATOMIC_REQUESTS」を True(デフォルトは False)にします。*3


config/settings.py(設定ファイル)

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'mysite',
        'USER': 'mysiteuser',
        'PASSWORD': 'mysiteuserpass',
        'HOST': 'localhost',
        'PORT': '5432',
        'ATOMIC_REQUESTS': True,  # 追加
    }
}


モデルやビューには何も手を加える必要はないので簡単ですよね。これだけで、ビュー全体のデータベース操作がひとつのトランザクションで囲まれます。



なお、ミドルウェア内のデータベース操作はこのトランザクションの範囲外となるので要注意です(あくまでビューの開始から終了までが一つのトランザクションになります)。



SQLクエリログは次のように出力されます。

《 PostgreSQL の SQL ログ 》

[pid-1] SET TIME ZONE 'UTC'
[pid-1] BEGIN
[pid-1] SELECT "book"."id", "book"."title", "book"."price" FROM "book" WHERE "book"."id" = 1 LIMIT 21
[pid-1] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:01:53.319483+00:00'::timestamptz) RETURNING "order"."id"
[pid-1] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21
[pid-1] UPDATE "stock" SET "book_id" = 1, "quantity" = 0, "updated_at" = '2020-12-01T03:01:53.334689+00:00'::timestamptz WHERE "stock"."id" = 1
[pid-1] UPDATE "order" SET "status" = '02', "total_amount" = 1000, "ordered_by_id" = 1, "ordered_at" = '2020-12-01T03:01:53.319483+00:00'::timestamptz WHERE "order"."id" = 2
[pid-1] COMMIT

すべての SQL が「BEGIN」と「COMMIT」で囲まれていて、これがひとつのトランザクションになっています。



予期せぬエラーなどで ビューから例外が漏れた場合、Django ORM によるロールバックが自動でおこなわれます。 その際の SQL ログは次のようになります。ちなみに、検査制約「CONSTRAINT stock_quantity_check CHECK (quantity >= 0)」が付いた在庫テーブルの在庫数(quantity)が「0」の場合に在庫を減らそうとしてエラーが出ちゃった例です。

《 PostgreSQL の SQL ログ 》

[pid-1] SET TIME ZONE 'UTC'
[pid-1] BEGIN
[pid-1] SELECT "book"."id", "book"."title", "book"."price" FROM "book" WHERE "book"."id" = 1 LIMIT 21
[pid-1] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:02:29.849050+00:00'::timestamptz) RETURNING "order"."id"
[pid-1] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21
[pid-1] UPDATE "stock" SET "book_id" = 1, "quantity" =  -1, "updated_at" = '2020-12-01T03:02:29.856001+00:00'::timestamptz WHERE "stock"."id" = 1
[pid-1] ERROR:  new row for relation "stock" violates check constraint "stock_quantity_check"
[pid-1] ROLLBACK

ERROR の後に、ROLLBACK がおこなわれています。



「ATOMIC_REQUESTS」の利点は導入の手軽さですが、お手軽な反面、公式ドキュメント にもあるように、同時アクセス数が多いようなシステムではスループットを下げてしまう可能性もあり、性能面での注意が必要です。

このトランザクションモデルは簡潔ではありますが、トラフィックが増加するときには非効率となります。全てのビューでトランザクションを扱うとオーバーヘッドが増加します。パフォーマンスへの影響は、アプリケーションのクエリパターンと、どれだけうまくデータベースがロッキングを扱うかに依存します。


データベースのトランザクション | Django ドキュメント | Django


 

【方法2】transaction.atomic() で囲む

「ATOMIC_REQUESTS」の代わりに、transaction.atomic() を使ってトランザクションを実現する方法もあります。「ATOMIC_REQUESTS」がビュー全体をトランザクションで囲むのに対し、transaction.atomic() は with 句を使ってトランザクションの適用範囲を自由に設定することができます。


先の購入決済のビューの例では、在庫数の更新は可能な限り迅速にデータベースに反映しておく必要があるでしょう。例えば、在庫があと「1」しか残っていない状態で、あるユーザが決済処理を実行中だと、在庫は実質「0」ですよね。このタイミングで他のユーザが在庫テーブルを参照した場合に在庫数が「1」のままにならないように、在庫数を減らす更新を決済処理の前に一旦コミットして在庫を引き当て(取り置き)しておくようにしてみます。


transaction.atomic() を適用したコード例は次のようになります。設定ファイルの「ATOMIC_REQUESTS」の設定は無効化しておきます。

shop/views.py(ビュー)

from django.db import transaction  # 追加
from django.http.response import HttpResponseRedirect
from django.shortcuts import get_object_or_404, reverse
from django.views import View

from .models import Book, BookStock, Order


class CheckoutView(View):
    ...(略)...

    def post(self, request, *args, **kwargs):
        book = get_object_or_404(Book, pk=kwargs['pk'])

        with transaction.atomic():  # 追加
            # ① 注文情報を登録
            order = Order(
                status=Order.STATUS_PAYMENT_PROCESSING,
                total_amount=book.price,
                ordered_by=request.user,
            )
            order.save()

            # ② 在庫数を確認
            book_stock = get_object_or_404(BookStock, book=book)
            # ③ 在庫数を1減らして更新
            book_stock.quantity -= 1
            book_stock.save()

        ...(決済処理)...

        with transaction.atomic():  # 追加
            # ④ 注文情報のステータスを更新
            order.status = Order.STATUS_PAYMENT_OK
            order.save()

        ...(略)...

        return HttpResponseRedirect(reverse('shop:checkout_complete'))


これで、次のようにトランザクションを細かく分けることができるようになりました。*4


《 PostgreSQL の SQL ログ 》

[pid-1] SET TIME ZONE 'UTC'
[pid-1] SELECT "book"."id", "book"."title", "book"."price" FROM "book" WHERE "book"."id" = 1 LIMIT 21
[pid-1] BEGIN
[pid-1] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:03:11.428997+00:00'::timestamptz) RETURNING "order"."id"
[pid-1] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21
[pid-1] UPDATE "stock" SET "book_id" = 1, "quantity" = 0, "updated_at" = '2020-12-01T03:03:11.442959+00:00'::timestamptz WHERE "stock"."id" = 1
[pid-1] COMMIT
[pid-1] BEGIN
[pid-1] UPDATE "order" SET "status" = '02', "total_amount" = 1000, "ordered_by_id" = 1, "ordered_at" = '2020-12-01T03:03:11.428997+00:00'::timestamptz WHERE "order"."id" = 3
[pid-1] COMMIT

「BEGIN」と「COMMIT」で囲まれたトランザクションを2つ確認することができました。




 

(おまけ)ATOMIC_REQUESTS を有効化しているときに、特定のビュー内で自前でトランザクションを切る方法

ほとんどのビューではビュー全体をひとつのトランザクションで囲めばよいが、一部のビューでのみトランザクションを細かく切らないといけないといったケースがあります。そのような場合には、ATOMIC_REQUESTS を有効にした上で、対象のビューに「django.db.transaction.non_atomic_requests」を適用して ATOMIC_REQUESTS の効果を無効化 してしまえばよいです。注意点としては、Django のクラスベースビューにこれを適用するには、method_decorator を使って dispatch() メソッドに non_atomic_requests を適用してあげる必要があります。


参考
- データベースのトランザクション | Django ドキュメント | Django
- クラスベースビュー入門 | Django ドキュメント | Django


デコレータ以外のコードは先ほどのものと同じ内容です。

shop/views.py(ビュー)

from django.db import transaction
from django.http.response import HttpResponseRedirect
from django.shortcuts import get_object_or_404, reverse
from django.utils.decorators import method_decorator  # 追加
from django.views import View

from .models import Book, BookStock, Order


@method_decorator(transaction.non_atomic_requests, name='dispatch')  # 追加
class CheckoutView(View):
    ...(略)...

    def post(self, request, *args, **kwargs):
        book = get_object_or_404(Book, pk=kwargs['pk'])

        with transaction.atomic():
            # ① 注文情報を登録
            order = Order(
                status=Order.STATUS_PAYMENT_PROCESSING,
                total_amount=book.price,
                ordered_by=request.user,
            )
            order.save()

            # ② 在庫数を確認
            book_stock = get_object_or_404(BookStock, book=book)
            # ③ 在庫数を1減らして更新
            book_stock.quantity -= 1
            book_stock.save()

        ...(決済処理)...

        with transaction.atomic():
            # ④ 注文情報のステータスを更新
            order.status = Order.STATUS_PAYMENT_OK
            order.save()

        ...(略)...

        return HttpResponseRedirect(reverse('shop:checkout_complete'))


こうすることで、特定のビュー内で ATOMIC_REQUESTS の効果を無効にして、自前でトランザクションを切ることができます。

[pid-1] SET TIME ZONE 'UTC'
[pid-1] SELECT "book"."id", "book"."title", "book"."price", "book"."updated_at" FROM "book" WHERE "book"."id" = 1 LIMIT 21
[pid-1] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21
[pid-1] BEGIN
[pid-1] UPDATE "stock" SET "book_id" = 1, "quantity" = 0, "updated_at" = '2020-12-01T03:06:00.940420+00:00'::timestamptz WHERE "stock"."id" = 1
[pid-1] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:06:00.945405+00:00'::timestamptz) RETURNING "order"."id"
[pid-1] COMMIT
[pid-1] BEGIN
[pid-1] UPDATE "order" SET "status" = '02', "total_amount" = 1000, "ordered_by_id" = 1, "ordered_at" = '2020-12-01T03:06:00.945405+00:00'::timestamptz WHERE "order"."id" = 4
[pid-1] COMMIT

 

まとめ

Django はデフォルトではトランザクションを利用しておらず、すべてのクエリ操作が逐一コミットされます。Django でトランザクションを利用するには、大きく「ATOMIC_REQUESTS を有効化する」方法と「transaction.atomic() で囲む」方法の二つがあります。前者は導入が超簡単ですが、性能面での影響を考慮する必要があります。


ちなみに Django が提供しているトランザクションの API は今回紹介したものだけではありません。詳しい説明については 公式ドキュメント の後半部分をお読みください。なお、DjangoCongress JP 2019 の Denzow さんの「どうなってるの?Djangoのトランザクション」の資料が分かりやすいのでぜひ合わせて参照ください。


speakerdeck.com



 

宣伝

Django の技術同人誌をこれまでに4冊出しました。開発のお供にどうぞ。



現場で使える Django の教科書《基礎編》

「現場で使える Django の教科書」シリーズ第1弾となる Django の技術同人誌。Django を現場で使うための基礎知識やベストプラクティスについて、初心者・初級者向けに解説した本です。B5・本文192ページ。Django 3.2 LTS に対応。


★ Amazon(電子版/ペーパーバック)


★ BOOTH(紙の本)



現場で使える Django の教科書《実践編》

《基礎編》の続編にあたる「現場で使える Django の教科書」シリーズの第2弾。認証まわり、ファイルアップロード、ユニットテスト、デプロイ、セキュリティ、高速化など、さらに実践的な内容に踏み込んでいます。現場で Django を本格的に活用したい、あるいはすでに活用している方にピッタリの一冊。B5・本文180ページ。


★ Amazon(電子版/ペーパーバック)


★ BOOTH(紙の本)※在庫なし



現場で使える Django REST Framework の教科書

Django で REST API を構築する際の鉄板ライブラリである「Django REST Framework」(通称「DRF」)にフォーカスした、「現場で使える Django の教科書」シリーズの第3弾。B5・本文228ページ。Django 3.2 LTS に対応。


★ Amazon(電子版/ペーパーバック)


★ BOOTH(紙の本)※在庫なし



現場で使える Django 管理サイトのつくり方

Django の管理サイト(Django Admin)だけに特化した、ニッチでオンリーワンな一冊。管理サイトをカスタマイズする前に絶対に読んでほしい本です。B5・本文152ページ。


★ Amazon(電子版)


★ BOOTH(紙の本)

*1:https://docs.djangoproject.com/ja/3.1/topics/db/transactions/#django-s-default-transaction-behavior

*2:https://docs.djangoproject.com/ja/3.1/topics/db/transactions/#managing-database-transactions

*3:https://docs.djangoproject.com/ja/3.1/topics/db/transactions/#tying-transactions-to-http-requests

*4:なお、決済処理以降でエラーが出た場合に1つ目のトランザクションの処理を手動でロールバックする必要がありますが、それらについては割愛します。