OpenCensus と Go クライアント ライブラリを使用した Cloud Spanner レイテンシのデバッグ
Google Cloud Japan Team
※この投稿は米国時間 2022 年 7 月 15 日に、Google Cloud blog に投稿されたものの抄訳です。
「クライアント側で高レイテンシが報告されているにもかかわらず、Cloud Spanner の指標には該当のレイテンシが表示されない」といった状況に直面した経験があるかもしれません。この記事では、セッション プールの枯渇が原因でクライアントサイドのレイテンシ増加が引き起こされるケーススタディと、OpenCensus の機能と Go 用 Cloud Spanner クライアント ライブラリを使用した状況診断の方法について説明します。
ケーススタディ
今回のテストの概要は次のとおりです。
Go クライアント ライブラリのバージョン: 1.33.0
SessionPoolConfig: {MaxOpened: 100, MinOpened: 40, MaxIdle: 20, WriteSessions: 0.20}
セッション プールを使用することで、us-west2 の GCE VM から us-central1 の Cloud Spanner DB に対して、複数の読み取り / 書き込みエージェントが並行してトランザクションを実行します。一般的には、パフォーマンス向上させるために、クライアントを Cloud Spanner DB により近い場所に配置することが推奨されます。このケーススタディでは、あえて遠く離れたクライアント マシンを使用して、ネットワークのレイテンシを見分けやすくし、クライアントとサーバーに負荷をかけすぎることなく、セッションを適度な時間だけ使用できるようにします。
このテストの準備として、次のサンプル リーダーボード スキーマを使用しました。1000000000~9000000000 からランダムに抜き出した PlayerId をサンプルコードで使用するのではなく、このテストでは PlayerId として 1 から 1000 までの連続した ID を使用しました。エージェントがトランザクションを実行する際には、1~1000 の範囲からランダムな ID が 1 つ選ばれました。
読み取りエージェント:
(1)1 行をランダムに読み取って反復処理を実行し、読み取り専用のトランザクションを終了。
"SELECT * FROM Players p JOIN Scores s ON p.PlayerId = s.PlayerId WHERE p.PlayerId=@Id ORDER BY s.Score DESC LIMIT 10"(2)100 ミリ秒スリーブ。
書き込みエージェント:
(1)1 つの行を挿入し、読み取り / 書き込みトランザクションを終了。
"INSERT INTO Scores(PlayerId, Score, Timestamp) VALUES (@playerID, @score, @timestamp)"(2)100 ミリ秒スリーブ。
それぞれのシナリオで異なる同時実行が発生し、各エージェントが(1)~(2)を 10 分間繰り返しました。3 と 4、6 と 7 の間にはそれぞれ 15 分の間隔があり、これにより、セッション プールの縮小を確認できます。


次のグラフはスループット(1 秒あたりのリクエスト数)を示しています。シナリオ 8 までは、スループットが一定の割合で増加しました。しかし、シナリオ 9 では、スループットは予測したとおりには増加しませんでした。また、サーバー側の指標には、サーバー側にボトルネックが発生していることが示されませんでした。


ExecuteSql リクエストと Commit リクエストに対する Cloud Spanner API リクエストのレイテンシ(spanner.googleapis.com/api/request_latencies)はこのテストを通して一定で、99 パーセンタイルと、ほとんどで 0.07 秒以下に保たれていました。


インスタンスの CPU 使用率は低く、推奨レート(優先度高 < 65%)を常に下回りました。


OpenCensus の指標とトレースを有効にするには
OpenCensus の実例とその他の指標を確認する前に、GO の Cloud Spanner ライブラリで OpenCensus を使用する方法を確認しましょう。
シンプルなアプローチとして、次のような方法があります。
GFELatency 指標を有効にします。この指標については、「Cloud Spanner のデバッグで GFE Server-Timing ヘッダーを使用する」で説明しています。
すべてのセッション プール指標を有効にします。この指標については、「OpenCensus を用いた Cloud Spanner 上のアプリケーション パフォーマンスのトラブルシューティング」で説明しています。
さらに、独自のレイテンシ指標を登録してキャプチャすることもできます。次のコードでは、「my_custom_spanner_api_call_applicaiton_latency」が独自の指標です。キャプチャされるレイテンシは、トランザクションの開始から終了までの時間差です。つまり、クライアント アプリケーションの観点から見た、エンドツーエンドの Cloud Spanner API 呼出しのレイテンシです。上述したメイン関数の下に、次のコードを追加できます。
その後、次のようにレイテンシを測定できます。
ケーススタディからの測定結果
サーバー側からクライアントへ収集した統計情報を確認してみましょう。下の図は、Cloud Spanner のエンドツーエンド レイテンシ ガイドより引用したものです。4 → 3 → 2 → 1 の順に結果を参照します。

クエリのレイテンシ
クエリのレイテンシを可視化する便利なアプローチの一つとして、Query Insights を使用する方法があります。選択した時間範囲内でデータベースに最も負荷を与えているクエリとタグの概要を取得できます。このシナリオでは、読み取りリクエストの平均レイテンシ(ミリ秒)は 1.84 でした。なお、「SELECT 1」クエリはセッションを維持するためにセッション プール ライブラリから発行されます。


さらに詳しく調べるには、クエリの統計情報を使用できます。残念ながら、これらのツールではミューテーション データは利用できませんが、ミューテーションとのロックの競合が心配な場合は、ロックの統計情報を確認できます。
Cloud Spanner API リクエストのレイテンシ
このレイテンシ指標は、前述の Query Insights のクエリ レイテンシと比較して大きく乖離しています。後ほどトレースで確認できるとおり、INSERT クエリには ExecuteSql 呼び出しと Commit 呼び出しが含まれることに注意してください。また、ExecuteSql にはセッション管理リクエストによるノイズが多少含まれている点にも留意してください。クエリ レイテンシと Cloud Spanner API リクエストのレイテンシの間にギャップが生じる理由は、主に 2 つあります。まず、Query Insights では平均レイテンシが示される一方で、以下の統計データは 99 パーセンタイルのものだということです。次に、Cloud Spanner API フロントエンドが Cloud Spanner Database から遠く離れていたことです。us-west2 と us-central1 の間のネットワーク レイテンシはレイテンシの主要因です。


Google Front End のレイテンシ
GFE レイテンシ指標(OpenCensus/cloud.google.com/go/spanner/gfe_latency)には、基本的に Cloud Spanner API リクエストのレイテンシと同じ内容が示されます。
Cloud Spanner API リクエストのレイテンシと Google Front End のレイテンシを比較することで、このテストを通して Google Front End と Cloud Spanner API フロントエンドの間に大きなレイテンシの差はなかったと解釈できます。


[クライアント] カスタム指標
my_custom_spanner_api_call_applicaiton_latency は、最後のシナリオでより高いレイテンシを示しています。先ほどご覧いただいたように、Cloud Spanner API リクエストのレイテンシと Google Front End のレイテンシのサーバーサイド指標には、午後 11:00 の時点ではそれほど高いレイテンシは表示されていませんでした。

[クライアント] セッション プール指標
各指標の意味は次のとおりです。
num_in_use_sessions: 使用されているセッションの数。
num_read_sessions: 読み取り専用トランザクションに備えてアイドル状態になっているセッションの数。
num_write_prepared_sessions: 読み取り / 書き込みトランザクションに備えてアイドル状態になっているセッションの数
num_sessions_being_prepared: その時点で BeginTransaction 呼び出しを実行するために使用されているセッションの数。BeginTransaction 呼び出しが終了すると、この数は減少します(なお、この呼び出しは Java と C++ のクライアント ライブラリでは使用されません。これらのライブラリは、インライン化された BeginTransaction のメカニズムに従い、事前にトランザクションに備えることはありません)。
最後のシナリオの午後 11:00 頃には、num_in_use_sessions は常に最大値の 100 近くまで増加し、一部は num_sessions_being_prepared の状態にあったことがわかります。これは、最後のシナリオでは一貫してセッション プールにアイドル状態のセッションが存在しなかったことを意味します。
これらすべてを考慮すると、my_custom_spanner_api_call_applicaiton_latency での高レイテンシ(アプリケーション コードでキャプチャされたエンドツーエンドの呼び出しレイテンシ)は、セッション プールにアイドル セッションがなかったことが原因で発生したと推測できます。

Cloud Trace
Cloud Trace を調査することで、この推測について重ねて確認できます。前のシナリオの午後 10 時頃には、ReadWriteTransaction の開始後すぐに、「読み取り / 書き込みセッションを取得」したことがトレース イベントで確認できます。


一方で、午後 11:00 頃の最後のシナリオでは、読み取り / 書き込みセッションを取得できなかったためにトランザクションが低速であったことが次のトレースに示されています。「読み取り / 書き込みセッションが取得可能になるまで待機中」の状態が繰り返されています。また、トランザクションでは読み取り専用セッションしか取得できず「読み取り専用セッションを取得」のステータスになっていたため、BeginTransaction 呼び出しを行うことによって読み取り / 書き込みトランザクションを開始する必要がありました。


備考
このケーススタディと指標の確認にはいくつかの注意事項があります。
1)ExecuteSql と ExecuteStreamingSql では、レイテンシの変動がありました。これは、ExecuteSql または ExecuteStreamingSql が作成されるときに、内部で PrepareQuery が実行され、コンパイルされたクエリプランと実行プランがキャッシュに保存されることが原因です(Cloud Spanner の ExecuteQuery リクエストの裏側をご覧ください)。キャッシュは Cloud Spanner API レイヤで発生します。リクエストが別のサーバーを通過した場合は、そのサーバーがリクエストをキャッシュに保存する必要があります。PrepareQuery 呼び出しが行われることで、Cloud Spanner API フロントエンドと Cloud Spanner データベース間に新たなラウンド トリップが加わります。
2)指標、特にロングテールの指標(99 パーセンタイルなど)に注目すると、サーバーサイドのレイテンシがクライアントのレイテンシよりも長く見えるという奇妙なレイテンシ結果が表示されることもあります。このようなことは通常は起こりませんが、間隔、タイミング、測定ポイントの分布といった、さまざまなサンプリング方法の違いに起因して発生する場合があります。全体的な傾向を把握したい場合には、95 パーセンタイルや 50 パーセンタイルなどのより一般的な統計データを確認することをおすすめします。
3)Google Front End のレイテンシと比較すると、クライアントサイドの指標(たとえば my_custom_spanner_api_call_applicaiton_latency)には、プールからセッションを取得する時間だけでなく、さまざまなレイテンシ要因も含まれています。その他のレイテンシ要因には、次のものがあります。
中断されたトランザクションまたは gRPC エラーに対する自動再試行(クライアント ライブラリによって異なる)。カスタム タイムアウトおよび再試行を構成できます。
追加のセッションの作成(Cloud Spanner API リクエストのレイテンシで BatchCreateSessions レイテンシが急増します)。
読み取り / 書き込みトランザクションの BeginTransaction。これらは、プール内の読み取り / 書き込みセッション用に自動的に準備されます。BeginTransaction 呼び出しは、読み取り / 書き込みセッションが不足しているため、読み取り / 書き込みセッションに読み取りセッションを使用しなければならない場合や、読み取り / 書き込みセッションが無効、あるいは中断された場合にも発生する可能性があります。
クライアントと Google Front End 間のラウンドトリップ時間。
TCP レベル(TCP 3 ウェイ ハンドシェイク)と TLS レベル(TLS ハンドシェイク)での接続の確立。
セッション プールの指標に枯渇の問題が表れていない場合、その他の要因を確認する必要があります。
4)アプリケーション コードの外部で ExecuteSql 呼び出しが発生することがあります。これらは、セッションを維持するためにセッション プール ライブラリが送信した ping リクエストであると考えられます。
5)クライアントで読み取り / 書き込みトランザクションが非常に頻繁に実行され、読み取り / 書き込みトランザクションのリクエストが読み取り専用セッションを取得するケースがトレースで多く確認される場合には、WriteSessions の比率を高く設定すると便利です。これは、読み取り / 書き込みセッションが実際に使用される前に、事前に BeginTransaction を呼び出すためです。「書き込みセッションの割合を管理する」では、各ライブラリのデフォルト値について説明しています。一方、トラフィック量が少なく、クライアントでセッションが長時間アイドル状態になることが多い場合は、WriteSessions の比率を 0 に設定するとより高い効果が期待できます。セッションがあまり使用されていない場合、準備されたトランザクションがすでに無効になっており、中断される可能性があります。このエラーによるコストは、事前に読み取り / 書き込みトランザクションを準備する場合に比べて高くなります。
6)BatchCreateSessions リクエストはコストと時間のかかるオペレーションです。そのため、セッション プールの MinOpened と MaxOpened を同じ値に設定することをおすすめします。


まとめ
カスタム指標とトレースを使用することで、デフォルトのサーバーサイド指標には表示されないクライアントサイドでのレイテンシの原因を絞り込むことができます。この記事が、これまでよりも効率的に本当の原因を特定する一助となれば幸いです。
-テクニカル ソリューション エンジニア Tomoaki Fujii