orangain flavor

じっくりコトコト煮込んだみかん2。知らないことを知りたい。

DjangoからMySQLにCOMMITしたデータが別のトランザクションから見えない

はじめに

Djangoを使ったアプリケーションで、2つのバッチ処理用プロセスA、Bを同時に立ち上げたときに、Aのトランザクションでsave & commitした値をBでは読み取れないという問題に直面しました。

問題の箇所のソースコードはこのような感じです。

実行順 プロセスA プロセスB
1   with commit_on_success():
   p = Post(id=1)
   p.save()
 
2           # saveしたデータが見える
Post.objects.get(id=1)
3   with commit_on_success():
   p = Post(id=2)
   p.save()
 
4           # saveしたデータが見えず例外発生
Post.objects.get(id=2)

Djangoではデフォルトで自動コミットが有効なので、Aでコミットした値をBで読み取れるはずと思っていたのですが、これは間違いでした。(Django 1.5までは)

というわけで調べたことを残しておきます。

検証環境

トランザクション分離レベルとは

トランザクション分離レベルとは、複数のトランザクションを同時に実行したときに、あるトランザクションが別のトランザクションに与える影響の度合いです。

再発見! VB 2005快適プログラミング - 第11回 トランザクション処理に詳しくなろう:ITpro

SQL標準では、以下の4つの分離レベルが定義されています。

  1. READ UNCOMMITTED:コミット前のデータが別のトランザクションから見える
  2. READ COMMITTED:コミットしたデータが別のトランザクションから見える
  3. REPEATABLE READ:一度読み取ったデータは変わらないが、追加されたデータが見えることがある
  4. SERIALIZABLE:トランザクションは完全に別々に実行される(ように振舞う?)

READ UNCOMMITTEDが最もパフォーマンスが良く、SERIALIZABLEが最も分離レベルが高くなります。

InnoDBのデフォルト分離レベルREPEATABLE READとは

MySQL InnoDBのデフォルト分離レベルはREPEATABLE READです。Oracle DatabaseやSQL ServerPostgreSQLなどではREAD COMMITTEDであり、SQLiteではSERIALIZABLEらしいです。

MySQL :: MySQL 5.1 リファレンスマニュアル :: 13.5.10.3 InnoDB と TRANSACTION ISOLATION LEVEL

READ COMMITTEDの場合、他のトランザクションがコミットしたデータが見えます。一方、REPEATABLE READの場合、一度データを読み取るとその時点のスナップショットが作成されます。もう一度同じデータを読み取る際にはスナップショットから読み込まれるため、その間に他のトランザクションがコミットしたデータは見えません。

MySQL :: MySQL 5.5 Reference Manual :: 14.3.9.2 Consistent Nonlocking Reads

この「同じデータ」というのがどの範囲を指すのかよくわからないのですが、同じインデックスを参照する範囲なのかなと思っています。

実行順 コネクションA   コネクションB  
1               CONNECT      
2               BEGIN;        
3 CONNECT                    
4 BEGIN;                      
5 INSERT INTO posts (id) VALUES (1);
6 COMMIT;                    
7               /* INSERTしたデータが見える */
SELECT * FROM posts WHERE id = 1;
8 INSERT INTO posts (id) VALUES (2);
9 COMMIT;                    
10               /* INSERTしたデータが見えない */
SELECT * FROM posts WHERE id = 2;

なお正確には、SQL標準で規定されているREPEATABLE READでは、追加されたデータは見えてしまうはずなのですが、MySQLの実装では見えなくなるようです。

自動コミットの有効/無効による挙動の違い

トランザクションを明示的に利用している場合はこのようになりますが、MySQLには自動コミットという機能があり、自動コミットが有効かどうかによって明示的にトランザクションを開始しない場合の挙動が変わります。

自動コミットが有効の場合、更新系のクエリを実行するとすぐにコミットされます。一方自動コミットが無効の場合、明示的にCOMMITが実行されるまでコミットされません。この場合、トランザクションを明示的に開始せずにクエリが実行されると、暗黙的にトランザクションが開始されます。

コネクションBで自動コミットが無効の場合、暗黙的にトランザクションが開始されるので、AがINSERTしたデータは見えません。

実行順 コネクションA   コネクションB  
1               CONNECT      
2               /* 暗黙的にトランザクションが開始される */
SET autocommit=0;
3 CONNECT                    
4 BEGIN;                      
5 INSERT INTO posts (id) VALUES (1);
6 COMMIT;                    
7               /* INSERTしたデータが見える */
SELECT * FROM posts WHERE id = 1;
8 INSERT INTO posts (id) VALUES (2);
9 COMMIT;                    
10               /* INSERTしたデータが見えない */
SELECT * FROM posts WHERE id = 2;

コネクションBで自動コミットが有効な場合、トランザクションは開始されないので、同様の手順でINSERTしたデータを見ることができます。

実行順 コネクションA   コネクションB  
1               CONNECT      
2               /* トランザクションは開始されない */
SET autocommit=1;
3 CONNECT                    
4 BEGIN;                      
5 INSERT INTO posts (id) VALUES (1);
6 COMMIT;                    
7               /* INSERTしたデータが見える */
SELECT * FROM posts WHERE id = 1;
8 INSERT INTO posts (id) VALUES (2);
9 COMMIT;                    
10               /* INSERTしたデータが見える */
SELECT * FROM posts WHERE id = 2;

なお、MySQLではデフォルトで自動コミットが有効になります。

Python Database API 2.0での自動コミット

Python Database API 2.0では、データベースが自動コミットをサポートしている場合でも、デフォルトでは無効にしなければならないと規定されています。

実際DjangoでMySQLへの接続に使われるMySQL-pythonでも、以下のように接続時に無効に設定されます。

class Connection(_mysql.connection):

    """MySQL Database Connection Object"""

    default_cursor = cursors.Cursor

    def __init__(self, *args, **kwargs):
        #(中略)
        if self._transactional:
            # PEP-249 requires autocommit to be initially off
            self.autocommit(False)

MySQLdb1/MySQLdb/connections.py at master · farcepest/MySQLdb1 · GitHub

Django 1.5までの自動コミットの挙動

Djangoではデフォルトではmodel.save()したデータは、明示的にコミットしなくとも、その時点でコミットされます。

Webアプリケーションにおいて、明示的にコミットしなくてはならないのでは不便なので、Djangoが面倒を見てくれるのです。

このため、てっきり自動コミットが有効になっていると思っていたのですが、違っていました。

執筆時点で最新の安定版であるDjango 1.5系列のドキュメントを読むと、次のように書いてあります。

Django’s default transaction behavior

Django’s default behavior is to run with an open transaction which it commits automatically when any built-in, data-altering model function is called. For example, if you call model.save() or model.delete(), the change will be committed immediately.

This is much like the auto-commit setting for most databases. As soon as you perform an action that needs to write to the database, Django produces the INSERT/UPDATE/DELETE statements and then does the COMMIT. There’s no implicit ROLLBACK.

更新が必要なときに自動的にCOMMITを発行してくれるため、自動コミットが有効になっているのと同じような動きをするというだけで、実際には自動コミットは無効になっています。

この違いにより、上で述べたような挙動の違いが発生してしまいます。

Django 1.6からの自動コミットの挙動

経緯はよく知りませんが、ちょうど現在開発中のDjango 1.6からはトランザクションの仕組みが変わります。

開発版のDjangoのドキュメントには次のように書いてあります。

Django’s default transaction behavior

Django’s default behavior is to run in autocommit mode. Each query is immediately committed to the database. See below for details.

Django uses transactions or savepoints automatically to guarantee the integrity of ORM operations that require multiple queries, especially delete() and update() queries.

Changed in Django Development version: Previous version of Django featured a more complicated default behavior.

Django 1.6からは、データベースの自動コミットがデフォルトで有効になるとのことです。

ドキュメントのページ下部で、後方互換性について触れられています。1.5までで、暗黙的なトランザクションの開始によるREPEATABLE READ以上の分離に依存していた場合、非互換になってしまいます。

ちなみに、1.6からはトランザクション周りのAPIが大きく変更になっています。1.5までで使われていたTransactionMiddlewareやデコレータcommit_on_successなどは、1.6でDeprecatedとなり、1.8で削除される予定になっています。

結局どうすれば良いのか

Django 1.6からは冒頭のコードで問題なくsave()したデータを見られるようになるはずなので、公開されたらアップデートすると良いでしょう。

Django 1.5まででは、Django 1.6からの挙動に合わせて明示的に自動コミットを有効にするのが良いでしょう。トランザクション分離レベルを変更する方法も考えられますが、暗黙のトランザクションという仕組みはあまり良いものに思えません。

通常は自動コミットが有効になっており、トランザクションが必要な場合に明示的に利用するのが直感的だと思います。

Explicit is better than implicit.

Django 1.5では以下のように自動コミットを有効にできます。

from django.db import connections

connections['default'].cursor().execute('SET autocommit=1')

これにより、冒頭のコードは次のようになります。

実行順 プロセスA プロセスB
1           from django.db import connections
connections['default'].cursor().execute('SET autocommit=1')
2   with commit_on_success():
   p = Post(id=1)
   p.save()
 
3           # saveしたデータが見える
Post.objects.get(id=1)
4   with commit_on_success():
   p = Post(id=2)
   p.save()
 
5           # saveしたデータが見える
Post.objects.get(id=2)

2013年6月10日 追記

サーバーとして起動するなど、DBとのコネクションが切断される可能性がある場合は、以下のようにSignalを使ってコネクション確立時に確実に自動コミットを有効にするほうが良いです。

from django.db.backends.signals import connection_created

def enable_autocommit(sender, **kwargs):
    connection = kwargs['connection']
    connection.cursor().execute('SET autocommit=1')

connection_created.connect(enable_autocommit)

追記終わり

まとめ

Django 1.5まででは自動コミットが有効になっていないので、(特にMySQLでは)暗黙のトランザクションを意識しましょう。