この投稿は 「Django Advent Calendar 2020 - Qiita」 4日目の記事です。
akiyoko です。
この記事では、Django で簡単に楽観的排他制御を実装する方法について説明します。Django の楽観的排他制御については拙著『現場で使える Django REST Framework の教科書』の第11章「現場で使える Tips 集」の中でも触れていますが、もう少し詳しく解説してみたいと思います。
検証環境
- Windows 10 Home
- Django 3.1
- PostgreSQL 13.1
はじめに
複数ユーザからほぼ同じタイミングで同じレコードに対して更新をおこなうと、タイミングによってはレースコンディション(競合状態)が発生してしまい、更新内容が上書きされてしまうことがあります。
次の図を見てください。
一例として、決済処理の中で在庫テーブルを 1 ずつ減らして更新する処理を抜き出したものです。Aさんが在庫テーブルを参照(①)して更新(③)するまでのわずかな時間の中に、Bさんからの在庫テーブルの参照(②)が挟まっている状況を示しています。AさんとBさんが在庫テーブルを参照した時点(① および ②)では双方とも在庫数が「1」となっているのですが、Aさんが在庫テーブルを更新した③のタイミングで在庫数が「0」になるのにも関わらず、Bさんがレコードを「0」に更新(すなわち上書き)できてしまっています。なんと、在庫が「1」だったにも関わらず、二回も購入ができてしまっているのです。
これがレースコンディション(競合状態)です。解決方法としては「排他制御」と呼ばれる対策が採られることが多いです。中でも、更新時にデータが最新のものかどうかをチェックして、データが最新でない場合にエラーを出してレコードを更新しないようにする方式のものを特に「楽観的排他制御」と呼びます。
楽観的排他制御と悲観的排他制御
排他制御には、「楽観的排他制御」(楽観ロック)と「悲観的排他制御」(悲観ロック)があります。特徴や違いについては参考記事に譲ります。
参考
- 排他制御(楽観ロック・悲観ロック)の基礎 - Qiita
- アプリにおける排他制御 -楽観的/悲観的ロックの違いと使い所- - Qiita
- How to manage concurrency in Django models | by Haki Benita | Medium
- Django: How can I protect against concurrent modification of database entries - Stack Overflow
引用してまとめるとこうなります。
楽観的排他制御
- 更新対象のデータがデータ取得時と同じ状態であることを確認してから更新することで、データの整合性を保証する方式
- データ取得時の最終更新日時またはバージョン番号を条件に含めて更新する
- 同時更新されることがあまりない場合に使う
悲観的排他制御
- 更新対象のデータを取得する際にロックをかけることで、他のトランザクションから更新されないようにする方式
- 「SELECT FOR UPDATE」を使う
- 同時更新されることが多く、トランザクションが短い場合に使う
Django でも「SELECT ... FOR UPDATE」を使うことができますが、本記事では比較的実装が簡単な「楽観的排他制御」の具体的な実装方法について説明します。
次に、データベースのトランザクション分離レベル(isolation level)の話をします。
READ COMMITTED の挙動
Django においては、データベースのトランザクション分離レベルは「READ COMMITTED」がベストとされていて、データベース接続設定のデフォルトのトランザクション分離レベルも「READ COMMITTED」となっています。 *1
ちなみに、Django 2.0 以降、MySQL をバックエンドにした場合の分離レベルのデフォルト設定も「READ COMMITTED」に変更になりました。MySQL(InnoDB)自体のデフォルトのトランザクション分離レベルは「REPEATABLE READ」ですが、もしそれに合わせたい場合は、Django 側のデータベースオプションで明示的に
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', ... 'OPTIONS': { ... 'isolation_level': 'REPEATABLE READ', } } }
として、「isolation_level」の指定をしないといけません。
「READ COMMITTED」のトランザクション内では、「REPEATABLE READ」とは異なり、他のトランザクションのコミットによる変更が参照できます。例えば、次の図のように、他のユーザのトランザクションが終了した瞬間から最新の値が参照できるようになっています。
つまり、「READ COMMITTED」では、更新するギリギリのタイミングで最新のデータを参照することでレースコンディション(競合状態)を起こしにくくすることができると言えるでしょう。
これを踏まえた上で、楽観的排他制御の具体的な実装方法としては、例えばモデルにバージョンを保持するフィールドを追加し、データが更新されるたびに値をインクリメントして更新するようにします。そして更新の際にバージョンの値を検索条件に含め、更新件数が0件となった場合に排他エラーを発生させます。このような仕組みを自前で実装してもよいのですが、django-concurrency パッケージを利用すると簡単に実装することができます。
django-concurrency パッケージを利用する
django-concurrency パッケージを利用すると、楽観的排他制御が簡単に実現できます。
まず、pip で django-concurrency をインストールします。
(venv) > pip install django-concurrency==2.2.*
あとは、排他制御したいモデルに次のように concurrency.fields.AutoIncVersionField を使って「version」フィールドを追加するだけです。
shop/models.py(モデル)
from concurrency.fields import AutoIncVersionField # 追加 from django.db import models 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) version = AutoIncVersionField(verbose_name='バージョン') # 追加 def __str__(self): return f'{self.book.title} ({self.quantity})'
AutoIncVersionField は初回登録時に初期値「1」で自動保存され、更新するたびに自動でインクリメントされていきます。以下は Django シェル環境での実行例です。
>>> s1 = BookStock.objects.create(book_id=1, quantity=10) >>> s1.version 1 >>> s1.save() >>> s1.version 2
レコードの更新時にはバージョンの値が更新条件に自動的に加えられ、条件に合致しない場合は更新件数が0件になり、RecordModifiedError が発生します。
>>> s2 = BookStock.objects.get(book_id=1) >>> s2.version 2 >>> s1.save() >>> s1.version 3 >>> s2.save() Traceback (most recent call last): ...(略)... concurrency.exceptions.RecordModifiedError: Record has been modified
先ほどの決済処理の例では次のようにBさんの処理がエラーになり、更新ができなくなります。
「ATOMIC_REQUESTS」や「transaction.atomic() 」でトランザクションを利用していて、RecordModifiedError がトランザクション内でキャッチされなかった場合はトランザクションがロールバックされます。
PostgreSQL の SQL ログの例は次のようになります(「pid-1」および「pid2」はプロセス番号を分かりやすいように書き換えたものです)。
《 PostgreSQL の SQL ログ 》
12:00:11.976 JST [pid-1] SET TIME ZONE 'UTC' 12:00:11.980 JST [pid-1] SELECT "book"."id", "book"."title", "book"."price" FROM "book" WHERE "book"."id" = 1 LIMIT 21 12:00:11.984 JST [pid-1] BEGIN 12:00:11.986 JST [pid-1] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:00:11.983496+00:00'::timestamptz) RETURNING "order"."id" 12:00:11.992 JST [pid-1] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at", "stock"."version" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21 12:00:12.011 JST [pid-2] SET TIME ZONE 'UTC' 12:00:12.013 JST [pid-2] SELECT "book"."id", "book"."title", "book"."price" FROM "book" WHERE "book"."id" = 1 LIMIT 21 12:00:12.019 JST [pid-2] BEGIN 12:00:12.020 JST [pid-2] INSERT INTO "order" ("status", "total_amount", "ordered_by_id", "ordered_at") VALUES ('01', 1000, 1, '2020-12-01T03:00:12.016340+00:00'::timestamptz) RETURNING "order"."id" 12:00:12.028 JST [pid-2] SELECT "stock"."id", "stock"."book_id", "stock"."quantity", "stock"."updated_at", "stock"."version" FROM "stock" WHERE "stock"."book_id" = 1 LIMIT 21 12:00:13.109 JST [pid-1] SELECT (1) AS "a" FROM "stock" WHERE "stock"."id" = 1 LIMIT 1 12:00:13.112 JST [pid-1] UPDATE "stock" SET "book_id" = 1, "quantity" = 0, "updated_at" = '2020-12-01T03:00:13.106776+00:00'::timestamptz, "version" = 2 WHERE ("stock"."id" = 1 AND "stock"."version" = 1) 12:00:13.115 JST [pid-1] COMMIT 12:00:13.124 JST [pid-1] BEGIN 12:00:13.126 JST [pid-1] UPDATE "order" SET "status" = '02', "total_amount" = 1000, "ordered_by_id" = 1, "ordered_at" = '2020-12-01T03:00:11.983496+00:00'::timestamptz WHERE "order"."id" = 55 12:00:13.128 JST [pid-1] COMMIT 12:00:14.412 JST [pid-2] SELECT (1) AS "a" FROM "stock" WHERE "stock"."id" = 1 LIMIT 1 12:00:14.417 JST [pid-2] UPDATE "stock" SET "book_id" = 1, "quantity" = 0, "updated_at" = '2020-12-01T03:00:14.409133+00:00'::timestamptz, "version" = 2 WHERE ("stock"."id" = 1 AND "stock"."version" = 1) 12:00:14.419 JST [pid-2] ROLLBACK
UPDATE 時に version の値が検索条件として加えられているのが確認できます。「pid-2」の UPDATE 時にRecordModifiedError が発生し、トランザクションがロールバックされています。
django-concurrency の「concurrency.middleware.ConcurrencyMiddleware」というミドルウェアを使うことで、独自の排他エラー画面に遷移させることも可能です。
config/settings.py(設定ファイル)
MIDDLEWARE = [ 'concurrency.middleware.ConcurrencyMiddleware', # 追加 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ]
ConcurrencyMiddleware には ビューから例外が漏れたとき実行される process_exception が実装されていて、例外クラスが RecordModifiedError の場合に「concurrency.views.conflict」というコールバックが呼ばれ、最終的に「409.html」という名前のテンプレートがレンダリングされるような作りになっています。
そこで、設定ファイルの「TEMPLATES」の設定を次のように修正した上で、
config/settings.py(設定ファイル)
TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [BASE_DIR / 'templates'], # 修正 'APP_DIRS': True, ...(略)... }, ]
BASE_DIR の直下の「templates」ディレクトリの下に 409.html を配置しておくと、排他エラー発生時にこの画面に遷移させることが可能です(templates/base.html のコードは省略)。
templates/409.html
{% extends "base.html" %} {% block title %}排他エラー{% endblock %} {% block content %} <h3>排他エラー</h3> <hr> <p>排他エラーが発生しました。</p> {% endblock %}
排他制御エラー発生時のコールバック関数をカスタマイズすることも可能です。公式ドキュメント に書かれている通り、設定ファイルに「CONCURRENCY_HANDLER409」を定義します。
config/settings.py(設定ファイル)
MIDDLEWARE = [ 'concurrency.middleware.ConcurrencyMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] CONCURRENCY_HANDLER409 = 'common.handlers.conflict' # 追加
common/handlers.py
from django.shortcuts import render from shop.models import BookStock def conflict(request, target=None, template_name='409.html'): if isinstance(target, BookStock): template_name = 'shop/book_stock_conflict.html' try: saved = target.__class__._default_manager.get(pk=target.pk) except target.__class__.DoesNotExists: saved = None context = { 'target': target, # 更新しようとしたモデルオブジェクト 'saved': saved, # 最新状態のモデルオブジェクト } return render(request, template_name, context)
templates/shop/book_stock_conflict.html
{% extends "base.html" %} {% block title %}排他エラー{% endblock %} {% block content %} <h3>排他エラー</h3> <hr> <p>在庫レコードの更新時に排他エラーが発生しました。</p> {% endblock %}
なお、disable_concurrency をデコレータや with 句で利用することで、特定のビューやモデル操作に対して排他制御をしないようにすることもできます。またこの django-concurrency は、管理サイトや Django REST Framework(DRF)でも利用できるので大変便利です。
まとめ
複数ユーザからほぼ同じタイミングで同じレコードに対して更新をおこなうと、タイミングによってはレースコンディション(競合状態)が発生し、更新内容が上書きされてしまうことがあります。レースコンディションが業務にクリティカルな影響を与えてしまう場合は排他制御が必要になります。
Django で楽観的排他制御を実現するには、django-concurrency パッケージを利用すれば非常に簡単に実装することができます。
宣伝
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(紙の本)