[python][django] transaction.atomicの動きを研究する

transaction.atomicとは

 djangoの transaction.atomic は、RDBにおけるatomicityを実現するために非常に便利なモジュールです。

 これを利用することで、煩雑なトランザクション管理から解放されることとなります。

 例えば、銀行における口座間の送金処理は、一般的には2つの口座のお金を操作することになります。このとき片方が成功して片方が失敗するようなことは許されません。

 以下のコードは、送金処理の簡易的な例です。

def remit(from_account_id, to_account_id, amount):
  from_account = Account.objects.get(id=from_account_id)
  from_account.balance -= amount
  from_account.save()
  to_account = Account.objects.get(id=to_account_id)
  to_account.balance += amount
  to_account.save()

 この場合、 from_account.save() と、 to_account.save() の間に例外や、障害などが起こってしまうと、送金元口座の残高が減っているのに、送金先口座の残高が増えていないという状態になります。これは看過できません。

 これを防ぐために、 transaction.atomicを追加してみましょう。

from django.db import transaction

@transaction.atomic()
def remit(from_account_id, to_account_id, amount):
  from_account = Account.objects.get(id=from_account_id)
  from_account.balance -= amount
  from_account.save()
  to_account = Account.objects.get(id=to_account_id)
  to_account.balance += amount
  to_account.save()

 これだけで、処理の途中で問題が発生した場合は、処理全体が無かったことに(ロールバック)されます。

Nestedなtransactionの場合

 例えば、複数の同じtransactionを指定している場合、外側のtransactionが有効になります。

from django.db import transaction

@transaction.atomic()
def remit(from_account_id, to_account_id, amount):
  from_account = Account.objects.get(id=from_account_id)
  from_account.balance -= amount
  from_account.save()
  to_account = Account.objects.get(id=to_account_id)
  to_account.balance += amount
  to_account.save()

@transaction.atomic()
def remit_with_logging(from_account_id, to_account_id, amount):
  remit(from_account_id, to_account_id, amount):
  RemitLog.objects.create(from_account_id=from_account_id, to_account_id=to_account_id, amount=amount)

 このコードで、 remit_with_logging を呼び出した場合、RemitLogへの書き込みが失敗した場合、remit側の処理も全てロールバックされます。

call_command

 management commandをアプリケーション内で実行する、call_commandという便利な関数がありますが、これらを跨いだ場合であっても、transactionは有効です。この場合でも、最も外側のtransactionが有効になります。

 例えば、以下の場合、call_command内で処理されたものは全てロールバックされます。

@transaction.atomic()
def call():
  call_command("hogehoge") 
  raise RuntimeException()

transaction.set_rollback()

 意図的にロールバックしたい場合、Exceptionを上げるのが最も簡単な方法ですが、異常でもないのにExceptionをraiseするのは違和感があります。その場合、set_rollback()を利用するのがよさそうです。

 公式ドキュメントによれば、

This may be useful to trigger a rollback without raising an exception.

 とのことですから、まさに例外を発生させずにロールバックする良い手段だということになります。

 ただし、本機能はtransaction.atomicブロックの最も内側のものをロールバックするということに注意してください。冒頭のコードを少し変更してみましょう。

from django.db import transaction

@transaction.atomic()
def remit(from_account_id, to_account_id, amount):
  from_account = Account.objects.get(id=from_account_id)
  from_account.balance -= amount
  from_account.save()
  to_account = Account.objects.get(id=to_account_id)
  to_account.balance += amount
  to_account.save()
  transaction.set_rollback(True) # この行を追加

@transaction.atomic()
def remit_with_logging(from_account_id, to_account_id, amount):
  remit(from_account_id, to_account_id, amount):
  transaction.set_rollback(False) # この行を追加
  RemitLog.objects.create(from_account_id=from_account_id, to_account_id=to_account_id, amount=amount)

 この場合、remit()内のコードがロールバックされますが、RemitLogは書き込まれてしまいます。これは意図した動作にならないでしょう。

 このように、かなり注意して使う必要がある上に、そのような使い方をするケースは少なく、他の手段で代用できるものが多いため、こちらの機能は頭の片隅に置いておき、できるだけ使わないほうがよいでしょう。

 このような機能は、テストコードで一時的にある状態のDBを作り出したいときに使う、などが想定されます。

まとめ

 ライブラリによっては、細かい動作や仕様までdocumentに記載されているわけではないため、ある程度実験したりコードを読みながら使っていくことになります。

 ただし、仕様として明確に定義されていない場合は、アップデートで突然振る舞いが変わることもあります。注意しながら使っていきたいものですね。