App Engine におけるトランザクションの分離

最大ロス

Wikipedia によると、データベース管理システムの分離レベルにより、1 つのオペレーションによる変更が他の同時オペレーションでいつどのように表示されるかが定義されます。この記事では、App Engine で使用される Cloud Datastore のクエリトランザクションの分離について説明します。この記事を読むと、トランザクションの内外における同時読み書きの動作について理解できます。

トランザクション内部: Serializable(シリアル化可能)

最も強いものから順に、分離レベルには Serializable(シリアル化可能)、Repeatable Read(読み取り対象のデータを常に読み取る)、Read Committed(確定した最新データを常に読み取る)、Read Uncommitted(確定していないデータまで読み取る)の 4 つがあります。Datastore のトランザクションは Serializable 分離レベルを満たしています。それぞれのトランザクションはデータストアの他のトランザクションやオペレーションとは完全に分離され、特定のエンティティ グループのトランザクションは逐次直列に実行されます。

詳しくは、トランザクションのドキュメントの分離と整合性セクションと、スナップショット分離に関する Wikipedia の記事をご覧ください。

トランザクション外部: Read Committed(確定した最新データを常に読み取る)

トランザクション外部の Datastore オペレーションは、Read Committed 分離レベルに最もよく似ています。クエリまたは get によってデータストアから取り出されるエンティティには、commit 済みのデータしか含まれず、部分的に commit されたデータが含まれることはありません(つまり、一部は commit 済みで一部はまだ commit されていない状態にはなりません)。クエリとトランザクションの相互関係はさらに微妙でとらえにくいですが、これについて理解するには、commit プロセスをより深く理解する必要があります。

commit プロセス

commit が正常に返されると、トランザクションは確実に適用されますが、書き込みの結果がすぐに読み取れるわけではありません。トランザクションの適用は、次の 2 つのマイルストーンで構成されます。

  • マイルストーン A - エンティティへの変更が適用されたポイント
  • マイルストーン B - そのエンティティのインデックスへの変更が適用されたポイント

矢印は、トランザクションを commit してから、エンティティへの変更、エンティティとインデックスへの変更が可視になるまでの進行状況を示しています。

Cloud Datastore では、トランザクションは通常、commit が返されてから数百ミリ秒以内に完全に適用されます。ただし、完全に適用されていない場合でも、後続の読み取り、書き込み、祖先クエリは未処理の変更が適用されてから実行されるため、これらのオペレーションには常に commit の結果が反映されます。ただし、複数のエンティティ グループにまたがるクエリでは、実行前に未処理の変更があるかどうかを判断できないため、commit が適用される前の(stale)結果か、部分的に適用された結果が返されることがあります。

マイルストーン A の後、更新されたエンティティをそのキーでルックアップするリクエストを行うと、そのエンティティの最新バージョンが必ず表示されます。ただし、クエリを実行する同時リクエストが送られ、そのクエリの述語(SQL/GQL がそこでファンアウトする場合は WHERE 句)を満たすのが更新前ではなく更新後のエンティティである場合、そのエンティティが結果セットの一部に含まれるのは、apply オペレーションがマイルストーン B に達した後にクエリが実行された場合のみです。

言い換えると、キーによるルックアップ(参照)の結果に準じて、クエリの述語を満たすプロパティを持つエンティティが結果セットに含まれない時間が、多少なり存在することになります。また、キー参照の結果に準じて、クエリの述語を満たさないプロパティを持つエンティティが、結果セットに含まれる可能性もあります。どちらのエンティティが返されるかを決定する際に、マイルストーン A とマイルストーン B の間にある(インデックスの更新が適用されていない)トランザクションは、クエリの計算に含めることはできません。クエリは commit 前の stale データに対して実行されますが、返されたキーで get() オペレーションを行うと、常にそのエンティティの最新バージョンが得られます。つまり、対応するエンティティを取得してみると、クエリに一致する結果が見当たらない、またはクエリに一致しない結果が得られる可能性があります。

Cloud Datastore の祖先クエリなど、クエリを実行する前に未処理の変更が完全に適用されると保証されている場合、クエリの結果は常に最新で整合性があります。

同時に発生する更新とクエリの相互関係について概説しましたが、具体的な例で示したほうがわかりやすいでしょう。いくつかの例を見ていきます。簡単なものから始めて、面白い例もいくつか含まれています。

Person エンティティを格納するアプリケーションがあるとします。Person には次のプロパティがあります。

  • 名前
  • 身長

このアプリケーションは次のオペレーションをサポートしています。

  • updatePerson()
  • getTallPeople()。身長が 72 インチを超える人を全員返します。

データストアには次の 2 つの Person エンティティがあります。

  • Adam、身長 68 インチ
  • Bob、身長 73 インチ

例 1 - Adam の身長を高くする

アプリケーションが 2 つのリクエストをほぼ同時に受け取ったとします。最初のリクエストは、Adam の身長を 68 インチから 74 インチに更新します。急成長です。2 番目のリクエストは getTallPeople() を呼び出します。getTallPeople() は何を返すでしょうか。

答えは、リクエスト 1 によってトリガーされる 2 つの commit マイルストーンと、リクエスト 2 によって実行される getTallPeople() クエリの関係によって決まります。次の順序で処理された場合:

  • リクエスト 1、put()
  • リクエスト 2、getTallPeople()
  • リクエスト 1、put() --> commit()
  • リクエスト 1、put() -> commit() -> マイルストーン A
  • リクエスト 1、put() -> commit() -> マイルストーン B

このシナリオでは、getTallPeople() は Bob のみを返します。それはなぜでしょうか。Adam の身長を高くする更新がまだ commit されておらず、リクエスト 2 で発行したクエリはまだ変更を読み出せないためです。

では、次の順序で処理された場合:

  • リクエスト 1、put()
  • リクエスト 1、put() --> commit()
  • リクエスト 1、put() -> commit() -> マイルストーン A
  • リクエスト 2、getTallPeople()
  • リクエスト 1、put() -> commit() -> マイルストーン B

このシナリオでは、リクエスト 1 がマイルストーン B に達する前にクエリが実行されるため、Person インデックスへの更新はまだ適用されていません。そのため、getTallPeople() は Bob のみを返します。これは、エンティティのプロパティがクエリの述語を満たしているのに結果セットから除外された例です。

例 2 - Bob の身長を低くする

この例では、リクエスト 1 で違う処理を行います。Adam の身長を 68 インチから 74 インチに伸ばすのではなく、Bob の身長を 73 インチから 65 インチに縮ませます。この場合、getTallPeople() は何を

戻すでしょうか。
  • リクエスト 1、put()
  • リクエスト 2、getTallPeople()
  • リクエスト 1、put() --> commit()
  • リクエスト 1、put() -> commit() -> マイルストーン A
  • リクエスト 1、put() -> commit() -> マイルストーン B

このシナリオでは、getTallPeople() は Bob のみを返します。それはなぜでしょうか。Bob の身長を低くする更新がまだ commit されておらず、リクエスト 2 で発行したクエリはまだ変更を読み出せないためです。

では、次の順序で処理された場合:

  • リクエスト 1、put()
  • リクエスト 1、put() --> commit()
  • リクエスト 1、put() -> commit() -> マイルストーン A
  • リクエスト 1、put() -> commit() -> マイルストーン B
  • リクエスト 2、getTallPeople()

このシナリオでは、getTallPeople() は誰も返しません。それはなぜでしょうか。Bob の身長を低くする更新が、リクエスト 2 でクエリを発行するまでに commit されているためです。

では、次の順序で処理された場合:

  • リクエスト 1、put()
  • リクエスト 1、put() --> commit()
  • リクエスト 1、put() -> commit() -> マイルストーン A
  • リクエスト 2、getTallPeople()
  • リクエスト 1、put() -> commit() -> マイルストーン B

このシナリオでは、マイルストーン B の前にクエリが実行されるため、Person インデックスへの更新はまだ適用されません。その結果、getTallPeople() は Bob を返しますが、返される Person エンティティの身長プロパティは、更新された値である 65 になります。これは、エンティティのプロパティがクエリの述語を満たしていないのに、結果セットに含まれる例です。

まとめ

上の例からわかるように、Cloud Datastore のトランザクション分離レベルは Read Committed にかなり近いものです。もちろん大きな違いはありますが、それらの相違点とその理由も理解していただけたと思います。こうした理解は、アプリケーションのデータストアに関する設計についてインテリジェントな意思決定を行ううえで役に立つはずです。