詳解 google-cloud-go/spanner — トランザクション編

Yuki Furuyama
May 24, 2019 · 12 min read

はじめに

前回google-cloud-go/spanner のセッション管理について見てみました。セッション管理だけでも Session Pool や Session のライフサイクルの管理など、やっていることは非常に多岐に渡っていましたね。

今回はそのセッションの上でどのように Cloud Spanner のトランザクションが動くのか、クライアントライブラリの実装を元に見ていきたいと思います。

具体的には以下のような点について述べます。

  • トランザクションとは
  • トランザクションの種類
  • トランザクションを高速化する方法
  • トランザクションのリトライ
  • トランザクションの冪等性
  • トランザクションの終了処理

前回と同じく google-cloud-go の v0.38.0 時点のものを参照して記述しています。実装に踏み込んだ話もしており、将来に渡って処理内容が変わらない保証はないため、気になる方は最新のバージョンを参照するようにしてください。

では一つずつ見ていきましょう!

トランザクションとは

トランザクションとはデータベースに対して一連の Read / Write をアトミックに実行できる手段を指します。Cloud Spanner ではどのような Read / Write であっても必ずトランザクションの上で実行されます。トランザクションの外で一つだけクエリを実行したい、というようなことはできません。

トランザクションはセッションの上で実行されますが、一つのセッションの上で同時に動かせるトランザクションは一つのみです。そのためトランザクションの並列実行数を上げたい場合は必然的にその分だけセッションが必要になります。

トランザクションが開始されるとクライアント側にはそのトランザクションを表す id が渡ってきます。基本的にはクライアントライブラリで管理している状態はその id だけであり、クライアントから発行するクエリにその id も一緒に送ってあげることで同一のトランザクションで実行されるようになります。

トランザクションの種類

Spanner を使われたことがある方は、Spanner には Read-Write Transaction と Read-Only Transaction の2種類があるのをご存知だと思います。Go クライアントライブラリでは前者は func (c *Client) ReadWriteTransaction() で、後者は func (c *Client) ReadOnlyTransaction() で実行できますが、API を見ると実は他にも2種類トランザクションを実行できるメソッドがあることがわかります。

それが func (c *Client) Apply()func (c *Client) Single() です。これらは一見クライアントのために用意されているユーティリティメソッドのように見えますが、ライブラリの実装を見てみると RPC レベルで違うことをやっていることがわかります。

それらがどのように違うのか、まず Read-Write Transaction の方から見てみましょう。Read-Write Transaction は ReadWriteTransaction()Apply() の2つのメソッドで実行することができます。

ReadWriteTransaction() で実行した場合は、最初に BeginTransaction RPC を呼びトランザクションを開始した後、ExecuteSql RPC を任意回数呼び実際の read/write をし、最後に Commit RPC でコミットして終了です。

一方 Apply() で実行した場合、RPC は Commit しか呼ばれません。これでどうトランザクションが実行されるかというと、Commit のリクエストボディに更新したい内容(Mutation)が一緒に入っており、Cloud Spanner 側で作られたテンポラリなトランザクションの上でその Mutation が適用されるという動きになります(参考)。これはいわゆる blind write と呼ばれるもので、トランザクションの中で Read を一切せずに、単一の Write のみを実行する手法です。

次に Read-Only Transaction の API を見てみます。Read-Only Transaction は ReadOnlyTransaction()Single() の2つのメソッドで実行できます。

ReadOnlyTransaction() で実行した場合は、最初に BeginTransaction RPC が呼ばれ、次に実際のクエリを実行するための ExecuteSql RPC が呼ばれます。

一方 Single() で実行した場合は、RPC は ExecuteSql しか呼ばれません。これも先程の Apply() と同様に、Spanner 側でテンポラリなトランザクションが作られ、その上でクエリが直接実行されるという動きになります。作られるトランザクションは一時的なものなので、一つのクエリしか実行できないようになっています(なのでメソッド名が Single)。

トランザクションを高速化する方法

上で見たように単一の Write、単一の Read を行いたいという場合であれば、それぞれ Apply(), Single() を使うことで RPC の数を減らせるので、その分トランザクション全体のレイテンシを抑えることができます。

但し Apply() の方には少し注意点があります。まず Apply() では Mutation しか実行できません。DML (INSERT / UPDATE / DELETE 文) を実行したい場合は ReadWriteTransaction() を使う必要があります。

また Apply()を使ってもあまり高速化しないことがあります。例えば ReadWriteTransaction()を使っていてMutation で更新しようとしている場合、その Mutation はクライアント側でバッファされ、Commit 時に一気に Cloud Spanner に送られます(参考)。つまり Mutation しか使っていない場合は基本的に呼ばれる RPC は BeginTransaction と Commit だけなのです。更に前回の記事にもありましたが、セッションプール側であらかじめ BeginTransaction を呼んでおくように設定できるので、トランザクション実行時に呼ばれる RPC は実質 Commit だけになります。そうなると ReadWriteTransaction()Apply() ではあまり差がなくなりますね。

もう一点注意が必要なのが、 Apply()で実行する場合は ApplyAtLeastOnce オプションを付けないと上図のように単一の RPC にはなりません。これが何なのかは後ほど冪等性の話をするときに触れたいと思います。

トランザクションのリトライ

次に Read-Write Transaction のリトライについて話します。

Cloud Spanner のクライアントライブラリでユニークなのは、Read-Write Transaction が失敗した時にライブラリレベルで自動的にリトライされるという点です(全てのクライアントライブラリではありませんが、Goではリトライされます)。普通はアプリケーション側でリトライを実装すると思うので、最初は面を食らうかもしれません。Cloud Spanner では並行制御の仕組み上トランザクションが Abort されることがよくあるので、ライブラリレベルでリトライするという思想なのだと思います。

リトライが走るともう一度 BeginTransaction からやり直して、一からトランザクションを始めることになるため、トランザクションで実行している部分が冪等性を担保できているかどうかが大切になります。

ちなみにリトライが行われるのは、以下のようなエラーが発生した場合です (カッコ内のコードは対応する RPC のエラーコードです)。

  • トランザクションが Abort された時 (ABORTED)
  • Cloud Spanner サーバの一時的なエラー (UNAVAILABLE)
  • gRPC のクローズされたコネクションを使おうとした時 (INTERNAL)
  • gRPC で RST_STREAM フレームが送られてきた時 (INTERNAL)
  • gRPC で予期しない io.EOF を受け取った時 (INTERNAL)

上記のエラーのように、トランザクションが Abort された時や、トランスポート層のエラーが起きた時にリトライされるということになります。トランザクションが Abort される理由は様々ですが、一番多いのは他のトランザクションとロック取得でぶつかってしまい、片方が優先されてもう片方が Abort されるというものです。この辺の並行制御についてはこちらの記事も参考にしてみてください。

また、リトライを何回まで行うか制御することは今の所できません。これはライブラリのドキュメントにも書かれているように「何回繰り返すか」ではなく「どのくらいの時間(タイムアウト)まで試行を繰り返すか」で制御する思想のようです。

トランザクションの冪等性

先程リトライのところで冪等性の話が出てきましたが、ここではクライアントライブラリがトランザクションの冪等性とどう関わってるか、以下の2つの観点から見てみましょう。

  1. トランザクションレベルの冪等性
  2. Write クエリレベルの冪等性

トランザクションレベルの冪等性

トランザクションは Commit が成功しない限り更新内容は実際のレコードに反映されないので、成功するまで何度最初から実行しても問題ありません。また、ここでやっているように複数回 Commit RPC がリトライされたとしても、そこには同一トランザクション ID が含まれているので、複数回更新が適用されることはありません。

ただしここで、先程説明した Apply() メソッドが厄介になります。 Apply() + ApplyAtLeastOnce オプションで実行すると中では Commit RPC が一つだけ呼ばれ、Cloud Spanner 側で作られたテンポラリなトランザクションの上で Mutation が適用されるのでした。もし Commit RPC が複数回送られてしまうと、毎回別のトランザクションだと認識されるので、同一の Mutation が何度も適用されてしまう可能性があります。それで問題ないかは Mutation の性質に依りますが、例えば Insert をするようなものだと、アプリケーション側で二度呼んだつもりがなくても ALREADY_EXISTS エラーが返ってくる可能性があるので、エラーハンドリングなど注意が必要になります。

実は Apply() メソッドを使ったとしても ApplyAtLeastOnceオプションを付けなければ、 ReadWriteTransaction() が中で呼ばれるようになり、通常のトランザクションの上で Mutation が適用されるようになります(参考)。これであれば何度 Commit がリトライされようが、同一のトランザクション ID が送られるので冪等性は担保されます。Apply Exactly Once 的な動きですね。デフォルトではこっちの挙動なので、レイテンシが非常にセンシティブであったり、何度実行されても問題ない Mutation の場合のみ、 ApplyAtLeastOnce オプションを使うのがいいと思います。

Write クエリレベルの冪等性

トランザクションレベルでは基本的にはトランザクション ID によって冪等性が担保されますが、その中の個別の Write クエリの冪等性はどのように担保されているのでしょうか。何らかの理由で同一の Write クエリが複数回 Cloud Spanner に到達してしまった状況を想定してみましょう。

Mutation を使った場合は Commit と一緒に送られるので、何度送られてしまったとしても Cloud Spanner 側で区別できそうです。

一方で DML は実行するたびに ExecuteSql の RPC が送られるので、何らかの理由で複数回 Cloud Spanner 側に到達してしまうと問題になります。これを防ぐために、クライアントライブラリから seqno というシーケンス値が送られるようになっています。同一の seqno は同一の Write クエリを表すので、Cloud Spanner 側で既に適用しているものかが判断できるという算段です。

トランザクションの終了処理

トランザクションは最後に終了処理をする必要があります。Read-Write Transaction の場合はクライアントライブラリの中で Commit 時に諸々の終了処理が行われますが、Read-Only Transaction の方はアプリケーション側で明示的に Close() メソッドを呼ぶ必要があります。

Close() を呼ばないとどうなるのでしょうか?実は Close() を呼ばないとそのトランザクションで使ってるセッションを握りっぱなしになってしまいます。そうなると前回の記事でも紹介したようにセッションプールが枯渇し、トランザクション実行のたびに新規セッションが作られるようになるので非常に効率が悪くなります。

開発時に気づきにくいポイントなので、Stackdriver Monitoring の spanner.googleapis.com/instance/session_count メトリクスでセッション数を常に確認するか、 gcpug/zagane などの静的解析ツールを使って検出するのがいいと思います。

まとめ

今回は google-cloud-go/spanner のトランザクションの挙動について見てみました。色々細かいところも述べましたが、 Apply()Single() の使い分け、リトライと冪等性について理解しておけばバッチリだと思います!

前回の記事と本記事で google-cloud-go/spanner のコアな部分 (client.go, session.go, transaction.go) は説明できたと思います。他の部分も気になる方は本記事を参考に読んでみてください。

google-cloud-jp

Google Cloud Platform…

Yuki Furuyama

Written by

Strategic Cloud Engineer @Google Cloud. Opinions are my own and not the views of my employer.

google-cloud-jp

Google Cloud Platform 製品などに関連するコミュニティが記載したテクニカル記事集。掲載された意見は著者のものであり、必ずしも Google のものを反映するものではありません。

More From Medium

More on 日本語 from google-cloud-jp

More on Google Cloud Platform from google-cloud-jp

More on Google Cloud Platform from google-cloud-jp

NEG とは何か

18

More on 日本語 from google-cloud-jp

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade