App Engineでバージョンによる楽観的排他制御

Song of Cloudで送金のトランザクション処理パターンが紹介されていました。
http://songofcloud.gluegent.com/2009/11/blog-post_18.html
同様のpython版がこちら
Distributed Transactions on App Engine - Nick's Blog
上記のやり方で基本的には問題はないのですが、バージョン管理による楽観的排他制御を行っていないので、送金だけを考えるなら、残高を差分で更新しているので大丈夫ですが、これを一般的なパターンに拡張しようとすると、楽観的排他制御は必要になります。


楽観的排他制御とは、エンティティにバージョン番号を持たせておいて、メモリ読み込んだときのバージョン番号と書き込むときのバージョン番号が等しいことを確認する方法で、RDBMSの場合は、次のようなSQLを実行することで実現します。メモリの読み込んだときのkeyは1でバージョンは10だと思ってください


update hoge set version = 11, ... where key = 1 and version = 10
AさんもBさんもkeyが1でversionが10のエンティティを読み込んだとします。Aさんが先に更新したとするとAさんの更新は成功してversionは11になります。次にBさんが更新すると既にversionが変わっているので、更新が失敗します。
もし楽観的排他制御を行っていないとAさんの更新はBさんの更新で上書きされてしまい、なかったことになってしまいます。そんなことは防がなければなりません。
バージョンによる楽観的排他制御は、トランザクションと同様なくらい重要なもので、トランザクションとあわせて必ず理解しておく必要があります。


上記は、RDBMSの時の話で、Bigtableは条件付の更新をサポートしていないので、同じようにすることはできません。ぱっと思いつくのは、get()してバージョンを確認する方法です。


Hoge hoge = ...;
if (Datastore.get(Hoge.class, hoge.getKey()).getVersion().equals(hoge.getVersion())) {
hoge.setVersion(hoge.getVersion() + 1);
Datastore.put(hoge);
} else {
throw ...
}
このやり方は、get()からput()までの間に別の人に更新されてしまう可能性があるので、うまくいきません。synchronizedなどを使う方法もApp Engineの場合は、別のサーバーで動いているのでうまくいきません。
実は、まさにこの方法をとっているのが、AppEngineのJDOなんだけどね(笑)。


ではどうすればいいのかというと、トランザクションの中でget()してバージョンを確認します。トランザクション中でget()した場合は、commit()が成功した場合は、get()からcommit()までの間に他のプロセスが更新していないことをAppEngineが保証してくれます。
詳しくはApp Engineのユニーク制限を正しく理解しよう - yvsu pron. yas


正しい処理はこんな感じ


Transaction tx = Datastore.beginTransaction();
Hoge hoge = Datastore.get(Hoge.class, key);
if (hoge.getVersion().equals(version)) {
hoge.setVersion(hoge.getVersion() + 1);
hogeの更新
Datastore.put(hoge);
} else {
Datastore.rollback(tx);
throw ...
}
Slim3にはversionプロパティに@Attribute(version = true)とつけておくと、get()でのversionプロパティの比較とputの時に更新することを自動的にやってくれます。だからこんな感じ。

Transaction tx = Datastore.beginTransaction();
try {
Person p = Datastore.get(Person.class, key, version);
p.setSalary(newSalary);
Datastore.put(p);
Datastore.commit(tx);
} catch (ConcurrentModificationException e) {
Datastore.rollback(tx);
throw e;
}
詳しくはこちら。
Optimistic Locking with version property - Slim3


JDOもトランザクションの中で使えば、楽観的排他制御が実現できますが、putの直前にもう一度get()が呼び出されるので、パフォーマンスは悪くなります。JDOのやっていることはこんな感じ。
ただし、これは理解するための擬似的なコードで最新のデータを取ってきてversionを比較しているところはJDOが自動的にやってくれます。


PersistenceManager pm = PMF.get().getPersistenceManager();
try {
Transaction tx = pm.currentTransaction();
tx.begin();
try {
Person p = (Person) pm.getObjectById(Person.class, key);
p.setSalary(newSalary);
Person latest = (Persion) pm.getObjectById(Person.class, key);
if (latest.getVersion().equals(p.getVersion())) {
p.setVersion(p.getVersion() + 1);
pm.makePersistence(p);
tx.commit();
}
} finally {
if (tx.isActive()) {
tx.rollback();
}
}
} finally {
pm.close();
}
かなり残念な感じですが、AppEngineのJDOは残念間満載なので仕方ないですね。


ともあれ、楽観的排他制御は、必ず理解しておいたほうがいいです。特にAppEngineの楽観的排他制御はほとんどの人が理解できていないんじゃないかと心配です。公式のドキュメントにないから仕方ないかもしれないけど。Slim3だと公式のドキュメントにきちんと書かれていますよ。