blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2024.05.14 技術記事

Cloud Spannerを使う大規模アプリケーションの負荷試験におけるアプローチとチューニング

by tomohiro.katsukura

#loadtest #google-cloud #spanner

ゲームサービス事業本部の勝倉です。
この投稿では、Cloud Spanner を使う大規模アプリケーション 1 での負荷試験の方法論と、チューニング方法について記述しています。

前半は、従来的な負荷試験の手法が抱える課題を整理し、それらを解決する新しい方法論を紹介しています。
後半は、Cloud Spanner で秒間500万超のクエリ(5M+ QPS)の負荷を捌くまでに出てきたボトルネックの見つけ方と潰し方について紹介しています。

1. 負荷試験の悩みどころ

バックエンドアプリケーションの開発において、負荷試験は重要なチェックポイントになります。
これまで私が携わったプロジェクトでも、例外なく負荷試験が実施されてきました。

これまでの負荷試験の手法は、実際にアプリを動かしてみてそのプレイログから負荷試験クライアントのスクリプトを記述し、そのスクリプトを並列実行して負荷をかける、という方法です。

この方法でも一定の検証を行うことはできるのですが、次のような課題もありました。

課題1: 試験実施作業をスケールさせづらい

たとえば、

1. 認証
2. プレイヤーデータの同期
3. ログインボーナス受け取り
4. インゲームプレイ
5. 報酬受け取り
...

といったシナリオを記述したとすると、もし最初の「認証」の部分がボトルネックになっていた場合に認証部分のボトルネックを解消するまで後続のリクエストで呼び出される機能のチューニングに着手することができません。

これだと「手分けして進める」ということが難しい形です。

そのため、これまでのプロジェクトでは、1-2名が負荷試験担当としてアサインされ、アサインされたメンバーだけが試験の実施とチューニングを行うような体制で負荷試験が行われてきました。

課題2: 前提となるユーザーデータを作りづらい

負荷試験では、正常系を通す必要がありますが、正常系を通すための前提の構築が課題になります。
特にゲームサーバーの場合、何かアイテムを購入するには一定のゲーム内を保有する必要があったり、ミッションを達成するには達成可能な状態であることが必要だったりと、そのシナリオを正常に動作させるために複雑な前提状態を構築してあげる必要があります。

こうした前提状態で必要となるリソースは多岐に渡り、量も多く複雑になりやすいため、広域なロックを長い時間保持することとなります。負荷試験シナリオの中でデバッグAPIや直接SQLを発行することによってシナリオを構築しようとすると、「本番では使われない、初期状態を作るための処理」と「試験対象になっている本番での処理」とが干渉し、本当に検証したい処理のパフォーマンスを正しく計測することが難しくなります。

プレイログベースでスクリプトを作成していた背景としては、チュートリアル報酬やログインボーナスで、一定のリソースが獲得できるという意図もあったようです。

課題1に対する対策として「プレイログからのシナリオ生成をやめて、機能単位でそれぞれシナリオを記述すれば良い」と考えられますが、そのためには同時に「試験対象の処理と干渉させずに前提状態を構築する」という課題も解決する必要があります。

課題3: 機能のカバー率を高めるのが難しい

課題1,2 に起因するものでもあるのですが、作業をスケールさせづらいことから負荷試験期間中に試行できる試験の回数にも一定の上限が出てきてしまいます。
また、常に新規ユーザーとしての状態からシナリオが走るので、たとえば一定のレベルに到達しないとアンロックされない機能などは負荷試験シナリオから漏れがちになります。
機能だけではなく、ある程度データが溜まった状態(aging された状態)でのパフォーマンスを見る、ということも実施しづらくなります。


余談ですが、負荷試験の観点は、第一義的に「想定する規模のアクセス数に耐えられるか」ですが、

  • アプリケーションのサイズが柔軟に変更できるかを確認する
  • 負荷の規模に応じて各種メトリクスはどのように振る舞うのかを観察する
  • 運用中にどういったメトリクスを監視しアラートを設定すべきかを考える
  • 本番でのトラブルシュートの疑似体験もできる

など、普段の開発サイクルでは意識しづらい観点を養うことができる貴重な機会であり、可能な限り多くのメンバーに経験してほしいという思いもありました。

2. 負荷試験フレームワーク: hoe

以上から、より理想的な負荷試験の手法に求められる条件を、

  • 独立して実行可能な、小さな負荷試験シナリオに分割できること。
  • 開発メンバーがそれぞれ独立して負荷試験の実施、チューニングに取り組めること。
  • シナリオを通すために必要なユーザーデータを、負荷試験シナリオと干渉させずに作成できること。

と定義し、これらを満たす方法論として、負荷試験フレームワーク hoe [発音:hóʊ] を設計しました。

Overview

課題の中心は「シナリオを成功させるためのユーザーデータの作りにくさ」にあると考えました。
ある機能を使用するためには、複雑な前提状態を構成しなければならないのですが、

  • 負荷試験シナリオの中で他の機能を使って前提状態を構築すると、シナリオの忠実度(=本番環境の振る舞いと同じである度合い)は高まるが、シナリオが依存する機能が多くなり、分割するのが難しくなる。
  • 負荷試験シナリオの中でデバッグAPIを使って前提状態を構築すると、シナリオが依存する機能が少なくなり分割することが用意になるが、デバッグAPIの処理と試験対象の処理とが干渉し、忠実度が下がる。

というジレンマを解消する必要があります。

ここに対して 「負荷試験の休止期間にユーザーを作成しておいて、負荷試験を実施するときはすでに作成済みのユーザーを使う」 というアプローチを試みるのが、hoe の基本的なコンセプトになります。
以下の図は、hoe の概念的なコンポーネントの関係性を表しています。

Backdoor API
ユーザーを作成する"裏口"APIです。
この API を叩くことで、好きな状態のユーザーがアプリケーションデータベースに作成されます。

User Queue
Backdoor API で作成されたユーザーデータをプールしておくコンポーネントです。
Queue に含まれる各エレメントは、1ユーザーに相当します。
エレメントは、UserID をはじめ、付与したリソースの情報(e.g., 仮想通貨の残高、レベルなど)も含みます。

Spawner
一定間隔で backdoor API を呼び出し、作成したデータを user queue に enqueue するコンポーネントです。daemon プロセスとして実装します。

Launcher
負荷をかけるコンポーネントです。
シナリオを成功させるために必要なデータを user queue から dequeue し、各機能担当者が作成したシナリオに渡します。

Target Server
負荷試験対象となるバックエンドサーバーです。

① 負荷試験を実施していない時間帯(業務終了後など)に、Spawner を稼働しておきます。Spawner は backdoor API を使ってユーザーデータを生み出し続けます。
② Spawner は生み出したユーザーデータを user queue にためます。
③ 負荷をかける際には、launcher が user queue から生成したい負荷の規模に応じてユーザーデータを取り出します。
④ 取り出したユーザーデータを使って、target server に対してシナリオを実行します。

システム構成

これを GoogleCloud 上に構築したものが、次のシステム構成図になります。

※ 簡単にするため、Target server は重要なコンポーネントだけ抜粋しています。

私のチームでは、バックエンドサーバーを GKE で運用していることもあり、spawner/launcher も使い慣れている GKE で構築しました。

Memorystore (for redis)は、spawner の補助的なデータベースです。
たとえば作成したユーザーのIDを記録しておいて、PubSub に publish するメッセージにここからSRANDMEMBERで数件取得して含めておくことで、「フレンド申請を送る宛先として、他のユーザーのIDが欲しい」などのケースに対応しています。

Kubernetes で構築したことによって、spawner の起動/停止もマニフェストを apply/delete するだけで良くなり、管理の手間をほとんど感じることなく運用できています。

また、launcher では kustomize を利用し、各機能の実行コマンドの違いだけを patch ファイルに記載しています。
「負荷の規模」を共通マニフェストとして「level1 はこれくらい、level2 はこれくらい…」と言った具合に負荷試験のマイルストーンするような進め方をしています。

./launcher
├── base
│   ├── level1
│   ├── level2
│   ├── level3
│   ├── level4
│   └── level5
│       ├── deployment.yaml
│       └── kustomization.yaml
└── unit
    ├── feat1
    ├── feat2
    └── feat3
        ├── kustomization.yaml
        └── patch.yaml

機能数は数十にも上るので、共通でよい設定はまとめることで全体に反映させたい設定は集約させつつ、リソース量などは個別に調整できるようになっています。
また、こうすることで、負荷の規模感が機能間である程度統一され、「この機能はある程度チューニングが進んでいる」「この機能は苦労していそう」なども把握しやすくなりました。

アプリケーションレイヤーのフレームワーク

hoe のアプリケーションレイヤーは、次のようなアーキテクチャになっています。

※ サーバーアプリケーションは Go で実装されているため、hoe framework も Go で実装しています。

Flow Controller
1プロセスでの並列度や、user queue からの取り出し頻度をなど、負荷量を制御する責務を負っています。

User Queue Client
Flow Controller に user queue からのメッセージの取得方法を提供します。
今回は Cloud Pub/Sub を使って user queue を実装したので、 Go の Pub/Sub SDK を使って実装しています。

Runner
負荷をかけるシナリオとして 「1人のユーザーがどのように振る舞うか」を表します。
Flow Controller は、user queue から取り出してきたユーザーデータを規定の頻度/並列度で runner に渡します。Runner は flow controller から渡された情報を使って負荷シナリオを実行します。

Runner Interface
Flow Controller と runner の間の連携は runner interface によって規定されています。
疑似コードで表すと以下のようになっています。

type RunnerInterface interface {
  Run(context.Context, func() *grpc.ClientConn, UserData) error
}

各機能担当者は runner interface を満たす runner だけ実装すればよく、接続先の管理や負荷量制御、ユーザーデータの取得部分を記述することなく、担当する機能のシナリオにだけ集中することができます。

3. hoe を使って負荷試験を実施した結果

hoe を使った所感

作業分担が可能になった
hoe を導入したことで、元々の方法論では難しかった負荷試験の分担を実現することができました。
100以上ある API のほぼ全てを網羅することができ、10名以上の開発メンバーが、お互いに干渉することなくチューニングに取り組むことができました。
負荷試験を担当していただいたメンバーからも、

  • 「通常の開発サイクルではなかなか持ちづらい負荷の観点を持つことができ、実際にボトルネックの解消にまで取り組めたのでスキルアップに繋がった。」
  • 「Lvをクリアしていくのがおもしろい。これがゲーミフィケーションというやつか。」

などといった前向きな感想をもらうことができました。

データが溜まった状態、極端な状態のユーザーで検証が可能になった
「マスターデータに登録されているミッション全てを、間髪入れずに順にクリアする」「プレゼントボックスに数百件のデータを持った状態で受け取りまくる」など、プレイログではもちろんのこと、実際にプレイしてもそうはならないような厳しいシナリオも検証することが可能になりました。
重要度が高い機能については、想定ユースケースよりも厳しいシナリオを検証することで、ローンチに向けての自信にもつながりました。

追加のコストは、負荷試験全体に対して1%未満に収まった
従来手法と hoe を比較すると、追加でかかるインフラコストは Pub/Sub の Message Delivery Basic SKU がほぼ全てになりますが、これは負荷試験全体の 1% 未満でした。

※ 注意点として、執筆している 2024-02-16 現在、 GoogleCloud から PubSub の価格改定がアナウンスされています。2024-06-30 より、これまで無料だった 「publish されたメッセージのストレージ」に対する課金が予定されています(金額は 1GB/月あたり $0.27 が課金されるとされていますが、改定されている可能性もあるため 公式情報 をご確認ください)。publish するメッセージサイズが大きかったり、queue にユーザーデータを蓄えたまましばらく負荷試験をせずにいたりするとコストが高くなる可能性があります。

Pub/Sub の throughput は問題なし
Pub/Sub のスループットは、メッセージ数ベースで 2,700 messages/sec 程度までであれば問題なく動作することが確認できました。
Pub/Sub を使うことで、1メッセージ1ユーザーとして考えられるので、これまで負荷の規模を秒間リクエスト数(RPS)ベースで論じていたところが、「単位時間あたりのユーザー数」で論じることが可能になりました。

この考え方を当てはめると、2,700 messages/sec は「1時間あたり972万人がプレイしている」状態となるので、ほとんどのアプリケーションにとって十分な負荷量であると考えられます。

実装時の注意点

同一ユーザーが同時にアクセスすることが想定されないなら、exactly once にする
Pub/Sub のサブスクリプションは、デフォルトだと at-least-once での配信になるため、配信されるメッセージに重複が生じる可能性があります。(検証時では、 2,000 msg/sec 程度の配信レートではメッセージの重複は観測されませんでした。)

我々が負荷試験を実施する中でも、user id が key に含まれるような lock range で wait が観測される事象が発生していました。Lock wait が観測されたテーブルは、そのリソースの owner であるユーザー(すなわち自分自身)しか書き込みがされないテーブルなので、at least once delivery ポリシーによる配信メッセージの重複が真っ先に疑われて、 exactly-once に切り替えました。

結果的には、この lock wait time は配信メッセージの重複以外の原因(5章で解説します)でしたが、考慮する項目を減らすためにも基本的には exactly once を有効化しておく方がベターなように思います。

Launcher のメモリ消費が激しい場合は、Pub/Sub client のフロー制御を見直す
初期の実装では、特に1プロセスあたりの負荷生成量が高い設定で launcher のメモリ消費が大きくなる事象が観測されました。
Pub/Sub の Go クラアントでは、以下のように subscription 構造体の Receive メソッドにクロージャーを渡すデザインになっています。

func(*pubsub.Subscription) Receive(context.Context, func(context.Context, pubsub.Message)) error

この Receive 関数の中では、goroutine が Pub/Sub から message を pull してきて、pull してきた message を引数のクロージャーに渡す仕組みのようです。
message を pull してくる件数や並列度は Subscription.ReceiveSettings によって制御されます。
ReceiveSettings の中でも重要だったのが MaxOutstandingMessages の設定で、これは同時に取り出される message の数を制御します( v1.36.1だと、デフォルトは1,000になります )。

message を受け取ったら直ちに処理を行えるような通常のユースケースであれば、この値が大きくても問題ないのですが、
負荷試験クライアントのようにシナリオの実行間隔が制御されていると、(単位時間あたりに pull される message) >> (単位時間あたりに消費される message) となった場合に、処理されずにたまる message によってメモリが圧迫されてしまいます。

そのため MaxOutstandingMessages の値を flow controller に設定されている runner の並列数と揃える必要がありました。

Pub/Sub の Admin Operation Per minute クオータにも注意
User Queue Client で Pub/Sub から message を pull してくる部分は、はじめこのように実装されていたのですが、

// NG: time.Tick のループの中で、毎回 Receive を呼び出す
func (l *Launcher) RunPeriodic(interval time.Duration) error {
	for range time.Tick(interval) {
		// 簡単にするために、mutex.Lock や atomic などでスレッドセーフにする部分は割愛しています。
		runPerTick := l.Parallelism
		l.subscription.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
			if runPerTick < 1 {
				cancel() // Receive の処理を抜ける
				return
			}
			runPerTick--
			// ack with result
			l.Runner.Run(...)
		})
	}
}

これだと負荷を高くしようとした時に AdminOperationPerMinute のクオータ上限にかかってしまいました。
調べるとどうも Receive の中で subscription の存在チェックをしているようで、頻繁に Receive を呼び出すとこのクオータにかかってしまうようです。
(そもそも Receive は、何度も呼び出すことを想定した作りになっていなさそうです。)

以下のように修正すると意図通りの挙動となりました。

// OK: Receive は1度だけ呼び出し、クロージャーの中で間隔を制御する
func (l *Launcher) RunPeriodic(interval time.Duration) error {
	// 簡単にするために、mutex.Lock や atomic などでスレッドセーフにする部分は割愛しています。
	var lastCalled = time.Time{}
	l.subscription.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
		time.Sleep(time.Until(lastCalled.Add(period)))
		lastCalled = time.Now()
		// ack with result
		go l.Runner.Run(...)
	})
}

4. Cloud Spanner での負荷試験でみておきたい指標

ここまでは、既存の負荷試験の課題を解消すべく導入した hoe について紹介してきました。
ここからは、実際の負荷試験でどのようにチューニングを行ってきたかを紹介します。

実際の負荷試験で見るべき観点は多岐に渡りますが、今回は Cloud Spanner に焦点を絞って紹介していきます。
基本的に負荷試験は、

  • 負荷をかけ、システムメトリクスやトレースなどの指標から、ボトルネックを見つける。
  • 要件を満たしたまま、アルゴリズムやクエリパターン、DBスキーマ、ネットワーク設定などの最適化を行う。
  • 再度負荷をかけ、ボトルネックが解消しているかを確認する。

という作業を目標の負荷が問題なく捌けるようになるまで繰り返す作業になります。
まずはどういった指標を見ているのかを紹介します。

QPS

まずは秒間クエリ数(QPS)です。
意図通りの負荷がかかっているのか、どのくらいまで捌けているのかなど、負荷の規模感を論じる上で重要な指標となります。
Cloud Monitoring の metrics だと、

  • spanner.googleapis.com/query_count
  • spanner.googleapis.com/api/api_request_count

になります。
query_count は読み取りのみの QPS、api_request_count は書き込みや Begin Commit などの API コールも含むカウントになります。

Request Latency

次に Spanner API リクエストのレイテンシです。
これは Spanner API フロントエンドが受信したリクエストの最初のバイトから Spanner API フロントエンドが送信したレスポンスの最後のバイトまでの時間 (秒単位) で、最初に見ておくべき指標となります。
Cloud Monitoring の metrics だと、

  • spanner.googleapis.com/api/request_latencies

になります。
こちらの良いところは、Read Latency (Query Latency にほぼ同義) と Write Latency (実際には transaction の commit) の両方が見えるところで、まず俯瞰してレイテンシの状況を見てみることに向いてます。

Query Latency

Query Latency も外せない指標です。
「負荷を捌けてるかどうか」を判断する上で重要な指標となります。

Cloud Monitoring の metrics だと、

  • spanner.googleapis.com/query_stat/total/query_latencies

になります。

CPU utilization

Latency が悪いな、と思ったら、単純にCPU使用率が高い(PUが足りない)だけだった、というケースもままあります。
(Cloud Spanner は、シングルリージョン インスタンスでは CPU 使用率が 65% を下回る程度で運用することが推奨されています。)

Cloud Monitoring の metrics だと、

  • spanner.googleapis.com/instance/cpu/utilization

になります。

実験記録の観点だと、Processing Unit (spanner.googleapis.com/instance/processing_units) も合わせて残しておくと良いかと思います。

Lock Wait Time & Commit Retry

Cloud Spanner は dead lock prevention として wound-wait アルゴリズムを採用しており、「トランザクション内で最初に発行されたクエリが早い順」に優先度が高く扱いとなり、ロックを取り合っている他の優先度が低いトランザクションを abort させます(abort された側は、再試行ポリシーの範囲内で retry します)。
(Cloud Spanner の lock model は こちらの公式ドキュメント に詳しく記述されています。)

「フレンド」や「いいね」などのソーシャル機能では、他のユーザーと同時に同じリソースに更新をかけに行くようなアクセスパターンになるため、ロック競合が起きやすくなります。
そのようなケースで request latency 及び query latency が遅い場合には、lock wait time や commit retry を見ておくことでロック競合の可能性に気づくことができます。
lock wait time と commit retry だけでボトルネックを特定することは難しいですが、後述の Lock Stats の統計データと見に行くきっかけとして重宝します。

Cloud Monitoring の metrics だと、

  • spanner.googleapis.com/lock_stat/total/lock_wait_time
  • spanner.googleapis.com/transaction_stat/total/commit_retry_count

になります。




重要なのは、「lock wait time が観測される = ロック競合が発生している」 とは限らない と認識しておくことです。
特に高負荷環境下でホットスポットが生じている場合は、ホットスポットが原因で lock wait time の数値が大きく見えてしまうこともあるようなので、「ロック競合が原因」と追いかけ続けるのではなく「ホットスポットが原因で、その結果として lock wait time が見えてるのかも」と他の事象を疑うのも手です。

たとえば上の図では lock wait time が発生しているように見えますが、実際にはボトルネックになるようなロック競合は、アプリケーションロジック上発生し得ないことまでは判明していました。
この点については Google Cloud に問い合わせたところ、同じように「特に高負荷下 (極度なホットスポットが生じている場合) で、ロック競合が起きていない場合に lock wait time が観測される事象が報告されている」という回答がありました。

以下、個人的な推測を含むのですが、

たとえば負荷のかけはじめや、Processing Unit を拡張したタイミング、負荷の規模を上げたタイミング(上図の 5:18PM あたりが該当)などで突発的な lock wait time と commit retry が観測されることが経験則的にわかっています。

このとき、内部的なスプリットのリバランシングが走ることで、スプリット分割やシャードの移動のためのシステムオペレーションによってアプリケーションのトランザクションが abort され、それがロック競合による abort と区別されずに集計されている可能性があるのではないかと予想しています。

統計データ

Cloud Spanner は、内部的にクエリやロック、トランザクションの統計情報を保存しており、ユーザーがアクセス可能になっています。
これらの情報は、常に注視するというより、他の指標からボトルネックの存在が示唆された後でより詳細にボトルネックを探していく時に役立ちます。

ボトルネックが発生している時間帯が明確な場合は、1min や 10min などの比較的短いサンプリング間隔のデータを見るとボトルネックを見つけやすいです。
また、コンソールの Cloud Spanner 画面から、Query Insights、Lock Insights、Transaction Insights を開くことで見やすくビジュアライズされたデータを参照することもできます。

これらのデータから lock wait time が長い key range や、平均処理時間が長いクエリなどの情報をヒントに実装箇所を確認することでボトルネックを特定することができます。

実行計画

実行計画も、ボトルネックの特定のための有用なヒントを与えてくれます。
私の場合、コンソールの Spanner Studio が出してくれる図が見やすいので、Query Insights で処理時間の長いクエリを特定し、Spanner Studio で実行計画を確認することが多いです。
(レポートとして残したい場合には、spanner-cli で EXPLAIN したものを貼り付けたりもしています。)

フルスキャンしているようなクエリはわかりやすいのですが(というより、コードレビューで弾かれるのでそもそもそんなに存在しないですが)、 Distributed Cross Apply が発生している場合などもチューニングポイントになってきます。

インデックスに乗ってない値を SELECT しているような場合に、ベーステーブルへの back join が発生しているようなクエリは、意外と頻出パターンになっていたりしました。

実行計画のもう一つのポイントとしては、スプリットの割れ具合を確認することができる点です。
Spanner Studio で、スプリットの割れ具合を確認したいテーブルに対して、たとえば SELECT COUNT(*) FROM ... のようなフルスキャンをするようなクエリを発行し、EXPLANATION タブで適当な演算子ノードを選択すると、右側に説明が表示されるのですが、その中の Number of executions をスプリットの割れ具合とみなすことができます。
( こちらの公式ブログ に記載されています。)


(インデックスとベーステーブルでそれぞれ割れ方は異なりうるので、@{FORCE_INDEX}ヒントなどで確認したいインデックス名 or _BASE_TABLEを明示するとベターです。)

Number of executions が完全にスプリット数と合致しているわけではないようですが、おおまかな傾向を把握するのに役立ちます。

Key Visualizer

Key Visualizer は、ホットスポットが発生しているテーブル/インデックスを見つけるのに重宝します。
https://cloud.google.com/spanner/docs/key-visualizer

縦方向の幅はレコード数が増えるほど太くなるので、マスターデータのように、「データ数は比較的少なく、全ユーザーが同じキーを参照しにいく」ようなホットスポットを見つけるのには若干コツがいります。


公式ドキュメントにて、対応すべき ヒートマップパターン が公開されています。
ただ、機能単位の負荷試験だとどうしても試験対象の機能に関連するテーブルに処理が偏るので、「綺麗に分散されているヒートマップをめざす」という使い方はしていません。

Cloud Trace

Cloud Trace は、厳密には Spanner のメトリクスではないのですが、ボトルネックを特定するための強力なツールです。
https://cloud.google.com/blog/topics/developers-practitioners/debugging-cloud-spanner-latency-using-opencensus-and-go-client-library

こちらの Google Cloud の投稿でも紹介されているように、Cloud Logging と Cloud Trace を統合することで、パフォーマンスチューニングの大きな助けとなります。
https://cloud.google.com/blog/products/management-tools/cloud-monitoring-with-full-stack-observability?hl=en

我々のチームでは、SQL と span を紐づけることで処理時間の長い span から、すぐに重たいクエリを確認することができるような形になってます。



ここまで、いくつか Spanner のチューニングに役立つ指標を紹介してきました。
「これさえみておけば大丈夫」というものはなく、それぞれの指標はどこかがボトルネックになってることを示唆するシグナルと捉え、「それらのシグナルを最も合理的に説明するボトルネックは何か」という捉え方をすることが重要であると考えています。

5. ボトルネックの傾向と対策

最後に、負荷試験で遭遇したボトルネックの中から印象深かったものを2つ紹介したいと思います 2

書き込みの hot spot と lock wait time

事象
ある試験で、いつもどおり負荷をかけて行ったところ、どうもパフォーマンスがよくありません。

query latency (左上)は 99%ile で 200ms を超えているし、負荷試験クライアントもタイムアウトエラーを受け取っていました。
まっさきに怪しまれたのは lock の競合でした。これだけ lock wait time (左下)が見えている状態なので、きっとロック競合しているのだろうと考えたわけです。

ところが lock stats をみると、衝突しているとされるキーは、ユーザーが自分しか触らないテーブルを PK で引いてきているクエリによるものでした。
つまり、想定するシナリオ上、どう考えてもロックを取り合うことがないはずのキーだったのです。

対策の初動としては、ロック競合が起きていることを前提として、

  • hoe で使っている Pub/Sub が at-least-once なので配信メッセージが重複してる可能性を疑って pubsub の配信ポリシーを exactly-once に変更
  • アクセスログのタイムスタンプを調べても並列にアクセスされていることを確認

したものの、並列アクセスしている痕跡が確認できず難航していました。

その後幸いにも Google Cloud のエンジニアの方が、Spanner ユーザーには公開されていない内部指標をみながら、負荷試験を伴走していただける機会に恵まれ、

内部的な指標をみると特定の split がスプリットの分割を繰り返しているものの、解消していかない様子がみえている。
hot spot になっているテーブル/インデックスが存在している可能性が高い。
ただ、問題になっているのは lock wait time なので、これが直接的な問題となっている可能性は低いかもしれないが、試してみてはどうか。

というアドバイスをいただきました。

原因
問題となっていたのは、ユーザーのアイテムを一時的に保管しておく機能、いわゆる"プレボ"(プレゼントボックス)のインデックスでした。
ユーザー間でのアイテムのやりとりにプレゼントボックスが使われる仕様があり、レコードに「贈り主」のユーザーIDが含まれています。

このテーブルに対して「贈り主」とソートのための「贈られた時刻」で検索する意図でインデックスが作られたのですが、システムからの付与によってプレゼントが贈られる際に空文字列が付与されることによって hot spot 化していたようです。

このインデックスを削除すると、なんとロック競合がみられなくなりパフォーマンスも大幅に改善されました。

余談ですが、このインデックスは仕様変更によって使われなくなっていたにもかかわらず、消されずに残ってしまっていたものだったらしく、結果的に消すだけで対処は済みました。
もし仕様が生きていたとすると、送った履歴が見られればいいだけなら別途履歴テーブル化するとか、あるいは SenderID を nullable にして NullFilteredIndex として作成しても実現できるのかもしれないです(意外と NullFilteredIndex を使う場面がなかったので、ちょっと試してみたかった…)。

教訓
あとになって考えると、ロックがぶつかっているわけではないことを示唆するシグナルはいくつか出ていました。

たとえば、もし本当にロックがぶつかっているのであれば、lock wait time は負荷に応じてほぼ線形に発生するはずです。
一方で、調査のために段階的に負荷を上げるようなかけ方を試してみたところ、ある一定の負荷を超えたところから急激に lock wait time が観測されるような結果となりました。


もし仮に、4章で記載した、

内部的なスプリットのリバランシングが走ることで、スプリット分割やシャードの移動のためのシステムオペレーションによってアプリケーションのトランザクションが abort され、それがロック競合による abort と区別されずに集計されている可能性があるのではないか

という仮説が正しいとすると、この観測結果にも説明がつきます。

つまり、一定の負荷に達すると hot spot になってるスプリットの負荷を分散しようと、システムがスプリットの分割を開始します。
スプリット分割によるシステムオペレーションによって、アプリケーションのトランザクションは abort されます。これが lock wait time として集計されることになります。
一方で、タイムスタンプのような単調増加キーではスプリットを分割したとしても書き込まれる場所が移動するだけで hot spot は解消しません。
これを繰り返すことで、パフォーマンスは改善しないが、lock wait time だけが増大するような見え方になっているのではないか、という考察になります。

また、Spanner は内部的には自動シャーディングされたデータベースです。
Spanner における hot spot とは、特定のシャードにアクセスが集中している状態です。
いわばそのシャードだけ CPU 使用率が 100% に張り付き、処理の待ち行列ができているようなものです。
ホットスポットにアクセスをしなければならないトランザクションは待たされてしまうため、トランザクションが取得するロックで、lock wait time が伸びても不思議ではなさそうです。
また今回の原因は単調増加するインデックスの hot spot でした。
つまりこのインデックスに更新を行うすべてのトランザクションが、超高負荷となってるシャードにアクセスに行っていたはずで、すべてのトランザクションが待たされていたのだろうと想像できます。

他にも、Cloud Trace をみると、時間がかかっていたのは Sent.google.spanner.v1.Spanner.Commit でしたが、ロック競合しているキーを取得するクエリには @{LOCK_SCANNED_RANGES=exclusive} で初めから排他ロックを取るようにヒントを与えていたため、もしロックを待つのであれば Commit ではなくクエリを発行したタイミングになるはずです。

それから、時間軸を広くとってみると、特徴的な hot spot のパターンも見えていました。


Cloud Spanner のスキーマ設計において、タイムスタンプや auto increment のような 単調増加するキーはホットスポットになるので作成してはならない 、というのは Spanner ユーザーにとってもはや常識かとは思います。
「そんな初歩的なミスはしないだろう」という慢心もどこかにあったのかもしれません。

1つの指標に引きずられすぎるのではなく、広くさまざまな指標をみることの重要性を改めて認識させられることとなりました。

読み取りの hot spot (マスターデータ)

弊社の場合、商品や報酬、イベントの設定のようなマスターデータが、git に近い形でバージョン管理され、各コンポーネントにはバージョン単位でデータセットがデリバリーされてきます。
(ちょうど Github で、特定のタグが打たれた時に Github Actions でビルドジョブがキックされ、アーティファクトがデリバリーされるのと同じようなイメージです。)

サーバーへのインポートは専用のパイプラインによって実行されますが、実行されたタイミングでは disabled な状態のまま保持されます。
これを管理コンソールから「有効化(Enable)」にすることによって、アプリケーションが参照するマスターデータセットが切り替わるような作りになっています。

マスターデータや有効なバージョン情報は、すべてのユーザーが同じキーで読み取る性質のデータになるため、どうしても読み取りの hot spot になってしまいます。
こうした性質のデータをどう扱っているかを紹介します。

cache によるアプローチ
同じバージョンのマスターデータであれば、一度 cache に載せた後にオリジンの状態が変化することはないと考えられるので、プロセスのメモリ上にキャッシュする(local cache)と効果的です。

Go だと、 bigcache freecache go-cache ristretto あたりの OSS が選択肢になるかと思います。
それぞれコンセプトやサポートしてるキャッシュアルゴリズム、メンテナーの活気などに違いがあるので、自身のプロジェクト要件に合致するものを選ぶことになります。

また、オリジンアクセスは singleflight などを使って不要なクエリが発行されないように工夫することも忘れてはいけません。

余談ですが過去のプロジェクトでは、

[local cache]--[remote cache (memcached)]--[origin (Spanner)]

というマルチレイヤーキャッシュを採用してたこともあったのですが、データソースが増えると考慮事項が大幅に増えるので「計測して、必要性が生じてから導入しよう」というポリシーのもと、導入されることなく今日まで至ったことは良いことだったのだと思います。

重複レコードと shard によるアプローチ
一方で「有効なバージョン情報」のような、キャッシュにのせても切り替え操作によって状態が変更されうる性質のデータは、永続的にキャッシュすることができません。
TTL を設定し、短い時間で揮発させるようなアプローチもあり得ますが、

  • 揮発のタイミングやラグ(特にバージョンの巻き戻り)などを考慮する必要がある。
  • もしうまくいったとしても、スケールさせる方法として「cache TTL を長くする」という方法しかない。

など、あまり見通しが良くないと考えていました。
あるいは memcached などリモートキャッシュに乗せる案もありますが、障害点と複雑さの増加を受け入れる必要が出てきます。

そこで我々は、 書き込み hot spot 回避の論理シャーディング のプラクティスを応用して、この性質のデータも複数シャードに分散させるアプローチを採用しました。


親テーブルとして ShardID だけを持つテーブルをあえて作っているのは、データが存在しない場合の対策です。

このとき、1秒未満程度の短い時間でも staleness を設定しておくことで、クエリを完全にリードレプリカだけで処理できる可能性が高まるので効果的です。

実際このアプローチで、キャッシュを使わずとも現在までにパフォーマンスの問題は確認されておりません。


まとめ

記事の前半では、従来の負荷試験の課題を解決するべく設計した負荷試験フレームワーク hoe について紹介しました。
これによって、負荷試験の分担が可能になり、柔軟なシナリオの記述ができることで網羅率を高めることが可能になり、一定の有用性が確認できたと考えています。

後半では、実際に負荷試験を行う際、特に Cloud Spanner のパフォーマンス改善についての実践的なポイントを紹介しました。


軽い気持ちで書き始めたところ、気がついたら結構な力作となってしまいました。
最後までお読みいただき、ありがとうございました。

注釈

1. ここでの「大規模」とは、

  • 1時間あたり数百万人が利用するような、負荷の規模
  • 公開している API が 100 以上存在するような、機能ボリュームの規模
  • バックエンドエンジニアが 10 名以上のいるような、開発チームの規模

程度の規模の大きさを想定しています。

2. ここで「クエリの最適化」を取り上げなかったことに言及しておきます。
我々のチームでは「継続的ベンチマーク」という仕組みを導入することで、単体で非効率なクエリは普段の開発サイクルの中で排除されるようなプロセスを構築してきました。
そのため、大規模負荷試験の段階においては高負荷環境特有の問題にフォーカスすることができました。
継続的ベンチマークの立役者である terunori.togo さんと、プロジェクトへの導入を推進してくれた keisuke.shibata さんには感謝申し上げます。

Tomohiro Katsukura
ゲームサービス事業本部
開発事業部
第二技術部
サーバー第一グループ
リードエンンジニア
サーバーサイドエンジニア

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。