トピック
AWS · DynamoDB · AppSync · パフォーマンス
  1. R1画面が尋ねる

    • 20行、新しい順
  2. R2リゾルバーが答える

    • N+1。一覧が1回、そのあと項目ごとに1回
  3. R3キーかインデックスが決める

    • QueryでよいところのScan
    • この問いに対応するインデックスがない
    • 読み取りがまとまらないキー
  4. R4レスポンスが運ぶ

    • テーブルにあるすべて。ページングなし
5つのパターンが1回の読み取りのどこで費用を足すか。3つは同じ場所にあり、だから残り2つだけを直しても元に戻ります。

相談はだいたい同じ形で届きます。プロダクトは動いていた。アーキテクチャは誰も変えていない。それなのに一覧画面に9秒かかり、モバイルアプリはエクスポートでタイムアウトし、毎月悪くなっている。しかも特定の顧客の特定の画面ではなく、全員に起きている、と。

壊れたものは何もありません。ここで一度立ち止まる価値があります。どこを見るかを決めるのがこの事実だからです。

00 / 成長はデータモデルを壊さず、あらわにする

データモデルには、それが書かれた数週間の前提が埋め込まれています。レコードは何件になるのか、テナントはいくつか、どの画面が何を読むのか。こうした前提はたいてい書き残されておらず、初日には決して間違っていません。間違いだと分かるだけのデータがまだないからです。

変わるのは量です。そして量は、定数時間の誤りを線形時間の誤りに変えます。1,000件のテーブル全件読み取りは何の費用でもありません。10万件では、それがプロダクトそのものになります。

ですから問いは「何が劣化したのか」ではありません。劣化したものはないのです。問いは「どの前提が今成り立たなくなったのか、そしてそれはどこに書かれているのか」です。

01 / ドキュメントではなく、動いているものを読む

これを腰を据えてやった案件 — Webとモバイルのクライアントを持つ本番SaaS — では、データモデルのドキュメントは古く、実際にどう動いているかの共通認識がチームにありませんでした。これは普通のことです。ドキュメントは誰かが意図したモデルを説明し、コードと動いているシステムは実際にあるモデルを説明します。

情報源は3つ、この順番で見ます。

  1. コード。 すべての読み取り経路を。リゾルバーやメソッドの名前ではなく、実際に発行されているクエリを見ます。
  2. クエリ。 本物のパラメータで、本物のデータ量に対してプロファイルしたもの。シードデータで速いクエリは何も教えてくれません。
  3. CloudWatch。 時間軸でのリクエストの挙動。遅い操作と遅いパターンを区別するために使います。

コードが追いにくいところは、手を加える前にデータの流れを図に描きました。これは儀式ではありません。描けないということは、変更が何に触れるのか分かっていないということで、稼働中のプロダクトではそれこそがリスクのすべてです。

02 / 5つのパターン

いちばんよく見かける5つで、たいてい同時に現れます。単体ならどれも致命傷にはなりません。重なると効いてきます。

キーに対するQueryで足りるところでのScan

Scanはテーブル全体を読んでから絞り込みます。Queryはキーへ行き、必要な分だけ読みます。Scanのフィルタは読み取りの後に動くので、3件しか返さないフィルタ付きScanも、時間と容量の両方で支払う分はすべて読み終えています。

確かめ方: コード中のScanをすべて探し、それぞれについて代わりにどのキーでQueryするかを書き出します。キーを挙げられなければ、見つかったのはクエリの問題ではなくキー設計の問題です。

グローバルセカンダリインデックスの欠落

GSIは、よく使うアクセスパターンにプライマリキー以外の経路を与えるために存在します。これがないと、「この顧客の注文を新しい順に」に答える方法はScanか、必要以上の件数を読むかしかありません。

確かめ方: 画面が実際に投げている問いを書き出します。そのひとつひとつがキーかインデックスに対応するはずです。どちらにも対応しないものが、あなたのリストです。

読まれ方と一致しないキー

これは実際には再設計で、だからこそ前の2つが何度も戻ってきます。読み取りがそれで束ねられるからではなく、分かりやすい識別子だったからという理由で選ばれたパーティションキーは、すべての読み手に永遠の回避策を強います。

確かめ方: アクセスパターンごとに、欲しい項目が隣り合っているかを問います。答えが「散らばっていてフィルタしている」なら、キーが間違っています。

リゾルバーのN+1

AppSyncでも、ほかのGraphQL層でも、一覧のフィールドは1回だけ解決され、入れ子のフィールドは項目ごとに1回ずつ解決されます。50件の一覧を取る1リクエストが51リクエストになります。リゾルバーは単体では正しいので、コードは無害に見えます。

確かめ方: 一覧画面1つあたりの時間ではなく、リクエストの本数を数えます。その本数は一覧の長さとともに増えてはいけません。

ページングなしで全件をクライアントへ

画面は20行を表示し、リクエストはテーブルにあるすべてを返す。いちばん見つけやすく、いちばん直しやすく、だからこそ何年も生き延びます。問題が起きるその日までは遅く見えないので、問題に見えないのです。

確かめ方: 所要時間ではなく、レスポンスのサイズを見ます。

03 / 稼働中のプロダクトで変えていく

診断は小さいほうの半分です。データは今まさにそれを使っている顧客のものです。

同じようにやること:

  • マイルストーンごとに出す。 それぞれをステージングで一段ずつ検証し、並行してチームがコードレビューを進めます。最後に大きな変更を1回、ではありません。
  • 利用時間外にリリースする。 顧客が1つのタイムゾーンに集まっているときに効きます。安上がりな優位であり、誰にでもあるものではありません。
  • 終わったらデータモデルのドキュメントを更新する。 何週間もかけて復元したばかりです。書かないまま残せば、次の人が同じ費用を払うことが確定します。

違うようにやること:

  • 変更ごとに、始める前に戻り道を計画する。 dev → staging → production と上げていくことはリスクを下げますが、本番リリースの成功を保証はしません。データ移行はまさにその隙間が痛いところで、「前へ倒して直す」は計画ではありません。
  • 影響範囲を先に見積もる。 個別にデプロイされるクライアントが1つのデータ層を共有している場合、共通スキーマへの変更は、編集しているリポジトリより遠くまで届きます。私はそれを計画時ではなく検証時に学びました。

04 / やらないこと3つ

最初の一手としてのシングルテーブル全面再設計。 最終的な姿としては正しいことが多く、最初の一手としてはほぼ正しくありません。いちばん理解できていない対象に、症状がまだ説明できていないうちに、可能な限り大きな変更を加えることになるからです。

Scanの前にキャッシュを置くこと。 グラフは良く見えるようになり、費用はまったく同じ場所に残り、その上に鮮度の問題が乗ります。キャッシュはアクセスパターンを正した後であって、その代わりではありません。

測り方を決める前に数字を追うこと。 何を、どの操作で、どのデータ量で測るのかを、何かに触る前に決めてください。そうでなければ改善とノイズの区別がつかず、顧客が尋ねる唯一の問いに答えられなくなります。

05 / 「良くなった」の中身

割合は出しません。あの案件の前後の測定値は公開が承認されておらず、出典を示せない数字は手法より価値がありません。

示せることはこうです。作業のあと、検索のタイムアウトは減り、顧客からの苦情も大きく減りました。プロダクトはその間ずっと動き続けていました。

この案件そのものの長い版 — 制約、何を移行したか、どう出したか — はケーススタディにあります。