これで怖くない Datastore のトランザクション

  • このエントリーをはてなブックマークに追加

ご注意

この記事は 2016年5月13日 に書かれたものです。内容が古い可能性がありますのでご注意ください。

”トランザクション”
聞いただけで嫌厭する方も少なくないのでは?
Datastore のトランザクションに関して、

  • そもそも RDB のときからトランザクションってよく分からなかったってのにDatastoreになってますます混乱しそう
  • いきなり Datastore から始てみたけど、トランザクションがよく分からない

といったように”トランザクションうわぁ”と思ってしまってませんか?
そんなあなたの垣根を少しでも取っ払い、 Datastore を触ってもらえたらなぁという期待を胸に執筆しました。

Datastoreのコードと一緒にMySQLのクエリを並べ、さらに、開発でよく見かける6つのパターンに分けて、実装例と共に説明していきます。各パターンについて具体的な要件の例もあげておりますので、わりかし馴染みのあるパターンだと思います。

なお、”トランザクションなら大丈夫”でも、頭の中が再整理されたり、新しい気付きがあったりするかもしれませんので、一度読んでもらえると嬉しいです。

※また、当記事の内容は入念に検証したつもりですが、この記事の内容により何らか不利益を受けられたとしても筆者は責任を負いません。また、何か間違いがあればご指摘くださると幸いです。

Datastore トランザクションを理解する流れ

前提として、今回は Cross Group(以下、XG) Transaction に関しては触れず、Single Group Transaction に絞って説明します。
XG の話も含めると、トランザクション以外の部分にも関わっていて、本記事の論旨からずれてしまうためです。
そもそも XG とは?が分からない場合も、とりあえず今は気にせずに読み進んでみて下さい。
興味があれば、基本である、こちらのDataStoreの仕組みの記事で、 Entity Group から学んでみてください。
実際のコード例について、MySQLの場合と比較する形で色々なパターンで解説をして行くことでトランザクションを理解してもらうような構成にしてあります。
まずは、 Datastore を使った基本的な実装を表1のような6パターンに分け、MySQLで実装した場合と Datastore(Java の Low-level API)で実装した場合のコードを示しつつ説明していきます。
※当記事内の MySQL のトランザクション分離レベルは既定の REPEATABLE-READ を想定します。

No. タイトル Insert
有無
Update

有無

Tx

有無

処理の例
1 新規登録のみの処理 × ×   監査・アクセスログの登録処理
2 同時更新がない、または、単純な後勝ち更新の処理 × × 最新かどうかは関係なく複数人から更新されるフォーラムの投稿、ユーザのプロフィール設定
3 Txは必要ないが、既存のデータがなければ新規作成する処理 × データのレプリケーション
4 同時登録される恐れがあり、重複を許可しない新規登録の処理 × メールアドレスユニークな会員登録
5 更新直前のデータが更新ロジックに関わる処理 × 座席の予約処理
6 Txが必要で、かつ、既存のデータがなければ新規作成する処理 日にち単位のアクセスカウンター

表1.紹介する実装のパターンと具体例

 はじめはトランザクションが必要ないパターンから説明していき、段々と理解と実装が難しくなっていく順番にしています。

 No2 と 3 や No5 と 6 の違いは、既存データの有無で Insert か Update かを判別する必要があるかどうかです。一緒にしてもいいのではと思われるかもしれませんが、 Datastore では put によって Insert も Update も処理してしまうため、 RDB の実装とは少し特殊な特徴もあるので、あえて分けました。

 No4 の Tx 有無が△になっている理由は、 No4 において説明します。

No1.新規登録のみの処理

No. タイトル Insert
有無
Update
有無
Tx
有無
処理の例
1 新規登録のみの処理 × ×  監査・アクセスログの登録処理

ひたすら Insert すればよいパターンです。トランザクションがありませんので、最も簡単です。
[MySQL]

[java]
Connection conn = …; //MySQLへのコネクションを作成する
conn.setAutoCommit(true);
PreparedStatement stmt = conn.prepareStatement("UPDATE Forum SET contents = ? WHERE forumId = ?");
stmt.setString(1, "xxxxxx");
stmt.setLong(2, 1);
stmt.executeUpdate();
[/java]

[Datastore]

[java]
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
Key key = KeyFactory.createKey("Forum", 1);
Entity forum = ds.get(key);
forum.setProperty("contents", "xxxxxx");
ds.put(forum);
[/java]

INSERT クエリを投げるだけの MySQL より、 Datastore では key を確保する分、少し複雑に見えますが、やっていることは単に Key を用意して Entity を put しているだけです。

No2.同時更新がない、または、単純な後勝ち更新の処理

No. タイトル Insert

有無

Update

有無

Tx

有無

処理の例
2 同時更新がない、または、単純な後勝ち更新の処理 × × 最新かどうかは関係なく複数人から更新されるフォーラムの投稿、ユーザのプロフィール設定

同時実行による競合は気にせず、常に上書きしていればよいパターンです。一番よく見かけるパターンなのではないでしょうか。
以下のサンプルでは記事( Forum テーブル)の内容( contents カラム)を上書きます。forumId=”xxxxxx”のフォーラムは必ず存在しているという前提です。
[MySQL]

[java]
Connection conn = …; //MySQLへのコネクションを作成する
conn.setAutoCommit(true);
PreparedStatement stmt = conn.prepareStatement("UPDATE Forum SET contents = ? WHERE forumId = ?");
stmt.setString(1, "xxxxxx");
stmt.setLong(2, 1);
stmt.executeUpdate();
[/java]

[Datastore]

[java]
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
Key key = KeyFactory.createKey("Forum", 1);
Entity forum = ds.get(key);
forum.setProperty("contents", "xxxxxx");
ds.put(forum);
[/java]

※ds.get(key)はEntityNotFoundExceptionの例外を投げますが、No2ではEntityが必ず存在するということを前提としているため、エラーハンドリングしていません。
トランザクションとは関係ない話ですが、注意事項として、MySQLでは更新したいカラムにのみSET句で値をいれればよいのですが、Datastoreでは全ての値が更新対象となるので、一度getしてEntityを取得してから更新が必須です。

No3.Txは必要ないが、既存のデータがなければ新規作成する処理

No. タイトル Insert

有無

Update

有無

Tx

有無

処理の例
3 Txは必要ないが、既存のデータがなければ新規作成する処理 × データのレプリケーション

既存ならば UPDATE 、なければ INSERT するパターンです。
下のサンプルでは、レプリケーション先(CopyToテーブル)にコピー元のID(originalId)が1のデータをコピーします。わかりづらいですが、単にデータをコピーしようとして、既存だったら上書き、なければ新規作成をする処理です。
前提として、このパターンでは、同じデータに対し複数同時に実行されないものとします。複数同時に実行される場合は、トランザクションが必要なため、No5を参照してください。
[MySQL]

[java]
Connection conn = …; //MySQLへのコネクションを作成する
conn.setAutoCommit(true);
PreparedStatement stmt = conn.prepareStatement("SELECT originalId FROM CopyTo WHERE originalId = ?");
stmt.setLong(1, 1);
ResultSet rs = stmt.executeQuery();
if(rs.next()){
stmt = conn.prepareStatement("UPDATE CopyTo SET data = ? WHERE originalId = ?");
stmt.setString(1, "xxxxxx");
stmt.setLong(2, 1);
stmt.executeUpdate();
}else{
stmt = conn.prepareStatement("INSERT INTO CopyTo (originalId, data) VALUES (?, ?)");
stmt.setLong(1, 1);
stmt.setString(2, "xxxxxx");
stmt.executeUpdate();
}
[/java]

[Datastore]

[java]
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
Key key = KeyFactory.createKey("CopyTo", 1);
Entity copyTo = null;
try{
copyTo = ds.get(key);
}catch(EntityNotFoundException e){
//low-level APIでのgetは、Entityが存在していなければnullではなくEntityNotFoundExceptionが発生するのでエラーハンドリングする必要があります
copyTo = new Entity(key);
}
copyTo.setProperty("data", "xxxxxx");
ds.put(copyTo);
[/java]

MySQLもDatastoreも最初に対象となるデータを取得して、新規作成か更新かを判別しますが、Datastoreでは、EntityNotFoundExceptionが投げられたかどうかで、UPDATEかINSERTかを判別しています。

No4.同時登録される恐れがあり、重複を許可しない処理

No. タイトル Insert

有無

Update

有無

Tx

有無

処理の例
4 同時登録される恐れがあり、重複を許可しない新規登録の処理 × メールアドレスユニークな会員登録

Tx有無が△になっている理由は、RDBMSではトランザクションを利用しなくても設定によりINSERT句でが重複データの登録を許しませんが、Datastoreではトランザクションを利用しないと、同時実行された時に、データの上書きが発生してしまう恐れがある為です。
以下の実装例は、メールアドレスの重複がなく新規登録できたらtrue、既に登録されていた場合はfalseを返すものとします。
[MySQL]

[java]
Connection conn = …; //MySQLへのコネクションを作成する
conn.setAutoCommit(true);
try{
PreparedStatement stmt = conn.prepareStatement("INSERT INTO User (mailAddress) VALUES (?)");
stmt.setString(1, "xxxxxx@xxxxxx");
stmt.executeUpdate();
return true;
}catch(SQLException e){
return false;
}
[/java]

[Datastore]

[java]
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
Transaction tx = ds.beginTransaction();
try{
Key key = KeyFactory.createKey("User", "xxxxxx@xxxxxx");
try{
//引数にtxを渡すこともできますが、Javaのlow-level APIの仕様で、明示的に渡されない場合は最新のActiveなTransactionを利用する仕様になっているので、省くことができます。
ds.get(key);
return false;
}catch(EntityNotFoundException e){
}
Entity user = new Entity(key);
ds.put(user);
tx.commit();
return true;
}catch(ConcurrentModificationException e){
//同時実行により、重複データが既に登録されてしまっている場合に発生
//対処の例としては、入力したメールアドレスは既に存在していることをユーザに通知するなど。
return false;
}finally{
if(tx.isActive()){
tx.rollback();
}
}
[/java]

2つのトランザクションが同時実行されない場合とされた場合について、時系列を示しつつ説明します。なお、 R, W, Cはそれぞれ READ(読み取り、MySQLでは select、 Datastoreでは get)、 WRITE(書き込み、MySQLでは insertまたはupdate、Datastoreでは put)、COMMIT(コミット)を意味します。

[MySQL]
ds_transaction_03
図1.MySQLにおけるInsertでの重複エラー
図1でのWはinsertしかありませんので、2回目以降のinsertにおいて重複エラーが発生します。MySQLの場合、ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘PRIMARY’のようなエラーメッセージが表示されます。
[Datastore]
同時実行されたかどうかに分けて説明します。
まずは、図2の同時実行ではない場合の重複データの検知です。
ds_transaction_04
図2.同時実行でない場合の、Datastoreにおける重複チェック
同時実行でなければ、後発のtx2のgetでEntityが取得できるため、アプリケーション側で既存のデータがあると検知できます。
ds_transaction_02
図3.同時実行された場合の、Datastoreにおける競合検知
同時実行時は、tx2のgetの時点ではEntityを取得できないため処理が続きます。最終的に、後発のコミットのタイミングでConcurrentModificationExceptionが発生します。

まとめると、Read?Commitの間に、他のトランザクションによって同一EntityへのCommitされると、当例外が発生します。ただし、他トランザクションがWriteのないCommitの場合は、競合検知はされません。図4のように先に、tx2がReadは後発でもCommitを先に行った場合は、tx1側のCommitでConcurrentModificationExceptionが発生します。
ds_transaction_01
図4.コミットのタイミングがずれた場合

No5.更新直前のデータが更新ロジックに関わる処理

No. タイトル Insert

有無

Update

有無

Tx

有無

処理の例
5 更新直前のデータが更新ロジックに関わる処理 × 座席の予約処理

SELECTしたデータがUPDATEされるまでの間に他のトランザクションによって書き換えられてはいけないパターン。同時実行時は、先発のトランザクションを反映し、後発のトランザクションはロールバックさせ適切に対処しないといけません。RDBMS と Datastoreにおいては、このパターンから同時実行時の競合への対処方法が変わります。
以下のサンプルでは、座席の確保に成功したらtrue、既に予約済みとなっていたらfalseを返すようにしています。なお、座席自体は必ず存在しているという前提です。
[MySQL]

[java]
Connection conn = …; //MySQLへのコネクションを作成する
conn.setAutoCommit(false);
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM Sheet WHERE sheetId = ? FOR UPDATE");
stmt.setString(1, "xxx");
ResultSet rs = stmt.executeQuery();
if(rs.getBoolean("isResearved")){
conn.rollback();
return false;
}
stmt = conn.prepareStatement("UPDATE Sheet SET isResearved = true WHERE sheetId = ?;");
stmt.setString(1, "xxx");
stmt.executeUpdate();
conn.commit();
return true;
[/java]

[Datastore]

[java]
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
Transaction tx = ds.beginTransaction();
try{
Key key = KeyFactory.createKey("Sheet", "xxx");
Entity sheet = ds.get(key);
if((Boolean)sheet.getProperty("isResearved")){
return false;
}
sheet.setProperty("isResearved", Boolean.TRUE);
ds.put(sheet);
tx.commit();
return true;
}catch(ConcurrentModificationException e){
//同時実行により、重複データが既に登録されてしまっている場合に発生
//対処の例としては、ユーザ側に他のユーザに先に登録されてしまったことを通知するなど。
return false;
}finally{
if(tx.isActive()){
tx.rollback();
}
}
[/java]

それぞれ時系列で処理の様子を図示すると以下のようになります。
[MySQL]
ds_transaction_05
図5.MySQLにおける複数同時更新時の競合検知
MySQLでは、SELECT句にFOR UPDATEを付与することで、SELECTからUPDATEまでの間もレコードに対しロックをかけます。ロック中は、後発のトランザクションにおける当レコードへのSELECTが待機状態になります。
[Datastore]
ds_transaction_02
図6. Datastore における複数同時更新時の競合検知(図3と同じ画像)
一方、 Datastore では、楽観的な排他処理をおこないます。後発のトランザクションもgetでEntityを取得できますが、後発の commit のタイミングで ConcurrentModificationException が発生する為、同時更新を防ぐことが出来ます。図7 のようにコミットのタイミングがずれた場合でも同様に、先発のトランザクションが ConcurrentModificationException が発生して排他処理を行います。
ds_transaction_02
図7.(図4 と同様)コミットのタイミングがずれた場合

図5の MySQL側は悲観排他、図6の Datastore側は楽観排他と呼ばれ区別されています。それぞれの特徴を簡単にまとめたものが下の表になります。

悲観排他 ・ロックして他Txを待機させることで、複数同時更新による処理の不整合を防ぐ。

・設計次第では、デッドロックの恐れがある。

楽観排他 ・Tx開始からコミットまでの間に、他のTxによって更新されたリソースがないかを確認することで、処理の不整合を防ぐ。

・競合検出された時に、リトライ処理が必要。

No6.Txが必要で、かつ、既存のデータがなければ新規作成する処理

No. タイトル Insert

有無

Update

有無

Tx

有無

処理の例
6 Txが必要で、かつ、既存のデータがなければ新規作成する処理 日にち単位のアクセスカウンター

[MySQL]

[java]
Connection conn = …; //MySQLへのコネクションを作成する
conn.setAutoCommit(false);
try{
String today = "2016-02-23";
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM DailyCounter WHERE date = ? FOR UPDATE");
stmt.setString(1, today);
ResultSet rs = stmt.executeQuery();
if(rs.next()){
stmt = conn.prepareStatement("UPDATE DailyCounter SET count = ? WHERE date = ?");
stmt.setInt(1, rs.getInt("count") + 1);
stmt.setString(2, today);
stmt.executeUpdate();
}else{
stmt = conn.prepareStatement("INSERT INTO DailyCounter (date, count) VALUES (?, 1);");
stmt.setString(1, today);
stmt.executeUpdate();
}
conn.commit();
return true;
}catch(SQLException e){
conn.rollback();
return false;
}
[/java]

[Datastore]

[java]
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
Transaction tx = ds.beginTransaction();
try{
Key key = KeyFactory.createKey("DailyCounter", "2015-12-23");
Entity counter = null;
try{
counter = ds.get(key);
counter.setProperty("count", (Integer)counter.getProperty("count") + 1);
}catch(EntityNotFoundException e){
counter = new Entity(key);
counter.setProperty("count", 1);
}
ds.put(counter);
tx.commit();
return true;
}catch(ConcurrentModificationException e){
//同時実行により、重複データが既に登録されてしまっている場合に発生
//対処の例としては、トランザクション内の処理全体を再実行するなど。
return false;
}finally{
if(tx.isActive()){
tx.rollback();
}
}
[/java]

同時実行時の競合検出は No5と同じです。つまるところ、 No6 のパターンは、 No3 の新規登録か更新かの判別処理が No5 に組み合わさったパターンです。No5が理解できれば、こちらは理解しやすいのではと思います。

まとめ

トランザクション周りは、頭の中で整理できていないと、いざトラブルが起きた時は余計に混乱するものです。苦手意識をもっている人は、この機会に是非一度整理してみると、ワンランク上の力がつくのではと思います。これをきっかけに、 Datastore を触ってみようかなと思う足がかりになってもらえたら、著者としてこれ以上嬉しいことはありません。
今回紹介できませんでしたが次回は弊社のこれまでの事例から、Datastore のトランザクション利用時でよくある失敗例とトラブルシュートを紹介します。ぜひそちらも読んでもらえればと思います。

  • このエントリーをはてなブックマークに追加

Google のクラウドサービスについてもっと詳しく知りたい、直接話が聞いてみたいという方のために、クラウドエースでは無料相談会を実施しております。お申し込みは下記ボタンより承っておりますので、この機会にぜひ弊社をご利用いただければと思います。

無料相談会のお申込みはこちら