🧐

バックエンドの性能改善で見直したことの備忘録

に公開

はじめに

最近、Go のバックエンド実装で性能改善について考える機会がありました。

最初は必要なデータを取得してから Go のループで突き合わせる形で書いていたのですが、件数が増えるにつれて DB への問い合わせ回数が増え、想定以上に時間がかかるようになりました。その処理を SQL の JOIN とインデックスを使ったクエリに寄せたところ、実行時間が大きく改善し、Go 側のコードもシンプルになりました。

この経験をきっかけに、バックエンドの性能改善では「どこが遅いのか」「DB とアプリケーションのどちらに何を任せるのか」「変更して本当に速くなったのか」を確認するのが大事だと感じました。

この記事は、そのときに見直したこと、今後も意識したいことの備忘録です。N+1 やインデックス、実行計画といった個別テーマを深掘りするというより、遅い処理を見たときに自分がどの順番で確認するかを整理したものです。

N+1 を疑う

最初に見直したいのは、DB への問い合わせ回数です。

Go のコード上では単純な for 文に見えても、その中でクエリを投げていると、データ件数に応じて問い合わせ回数が増えていきます。件数が少ないうちは気づきにくいですが、実運用に近いデータ量になると急に重くなります。

たとえば、次のような処理は注意が必要です。

users, err := userRepo.List(ctx)
if err != nil {
	return err
}

for _, user := range users {
	orders, err := orderRepo.ListByUserID(ctx, user.ID)
	if err != nil {
		return err
	}

	// orders を使ってレスポンスを組み立てる
}

この場合、ユーザー数が 100 件なら、ユーザー一覧の取得に 1 回、注文取得に 100 回のクエリが発生します。コードとしては読みやすくても、DB アクセスとしては重くなりやすい形です。

改善案としては、関連データをまとめて取得する方法があります。

  • JOIN で必要なデータをまとめて取る
  • WHERE user_id IN (...) で関連データを一括取得する
  • 取得後に Go 側で map[userID][]Order のように組み立てる
  • ORM を使っている場合は eager loading を検討する

ただし、IN (...) に大量の ID を詰め込めばよいわけではありません。DB やドライバによってはパラメータ数の上限がありますし、ID 数が多すぎるとクエリ自体が重くなります。件数が大きい場合は、分割して取得する、条件を見直す、一時テーブルや別の取得方法を検討する、といった判断も必要になります。

大事なのは、クエリの回数がデータ件数に比例して増えていないかを見ることです。まずここを潰すだけでも、かなりパフォーマンス向上に効果があります。

DB が得意な処理は SQL に寄せる

RDB は集合に対する操作が得意です。

  • 条件に合う行を絞り込む
  • テーブル同士を結合する
  • 重複を排除する
  • 集約する
  • ソートする
  • インデックスを使って探索する

こうした処理を Go 側で手作業のように書くと、DB が本来持っている最適化の仕組みを使いにくくなります。

自分が改善した処理でも、最初は複数のテーブルからデータを取ってきて、Go のループで ID を突き合わせていました。これを SQL の JOIN に寄せ、必要な条件で絞り込んでから Go に渡す形にしたところ、処理時間がかなり短くなりました。

ただし、SQL に寄せれば常に速くなるわけではありません。雑な JOIN や、インデックスのない検索は普通に遅くなります。SQL に寄せるときは、実行計画とセットで見る必要があります。

不用意な LEFT JOIN は避ける

JOIN に寄せるときに気をつけたいのが、なんとなく LEFT JOIN を使ってしまうことです。

LEFT JOIN は左側のテーブルの行を残したいときに使うものです。右側にデータがなくても左側の行を残したいなら必要です。一方で、右側に必ず存在するデータを取るだけなら、INNER JOIN で足りることがあります。

不用意に LEFT JOIN を増やすと、次のような問題が起きることがあります。

  • 結合対象の行数が膨らむ
  • 右側テーブルに複数行マッチして結果行数が増える
  • DB 側の結合順序の自由度が下がる
  • 本当は不要なテーブルまで読みに行く
  • WHERE 条件によって実質 INNER JOIN になる

特に分かりにくいのは、LEFT JOIN しているのに WHERE 句で右側テーブルのカラムを絞り込むケースです。

SELECT
  users.id,
  orders.id
FROM users
LEFT JOIN orders
  ON orders.user_id = users.id
WHERE orders.status = 'paid';

このクエリでは、orders が存在しないユーザーは orders.status = 'paid' を満たせないため、結果から落ちます。つまり、実質的には INNER JOIN に近い振る舞いになります。

もちろん、DB のオプティマイザが内部的に変換してくれる場合もあります。ただ、少なくとも人間が読むときには意図が分かりづらくなります。右側の存在が必須なら INNER JOIN、存在しない場合も左側を残したいなら LEFT JOIN、というように、意図した結合方法を選ぶのが大事だと思っています。

インデックスは貼ればいいわけではない

性能改善の話になると、すぐにインデックスを追加したくなります。ただ、インデックスは貼れば貼るほどよいものではありません。

インデックスの効果が最大化されるのは、主に次のようなカラムです。

  • WHERE 句でよく絞り込むカラム
  • JOINON 句で使うカラム
  • ORDER BY に使うカラム
  • GROUP BY に使うカラム
  • ユニーク性を保証したいカラム

一方で、使われないインデックスは書き込みコストやストレージコストになります。INSERTUPDATEDELETE のたびにインデックスも更新されるためです。

また、単一カラムのインデックスで十分なのか、複合インデックスが必要なのかも見ないといけません。複合インデックスはカラムの順序が重要なので、実際の検索条件に合わせて考える必要があります。

たとえば、次のようなクエリが多いなら、

SELECT *
FROM orders
WHERE user_id = ?
  AND status = ?
ORDER BY created_at DESC;

user_idstatuscreated_at の使われ方を見ながら、複合インデックスを検討することになります。ただし、実際に効果があるかは実行計画を見ないと分かりません。

EXPLAIN で確認する

クエリを直したら、感覚ではなく EXPLAINEXPLAIN ANALYZE で確認したいです。

とはいえ、本番と同じデータ量・分布をローカルにそろえるのは現実的ではありません。なので、「完全なコピーを用意する」ことを目指すよりは、「傾向がそれなりに近い環境で検証する」くらいを落としどころにしました。例えば、本番から期間やテナントを絞ってサンプリングしたステージング環境や、ダミーデータでサイズだけ寄せた検証用 DB を 1 つ用意しておく、といった形です。

表記や使えるオプションは DB によって少し違います。PostgreSQL なら EXPLAIN (ANALYZE)、MySQL なら EXPLAIN ANALYZE のように、使っている DB の形式に合わせて確認します。

見る観点としては、まずこのあたりです。

  • フルスキャンになっていないか
  • 意図したインデックスが使われているか
  • 結合順序がどうなっているか
  • 推定行数と実際の行数が大きくずれていないか
  • ソートや一時テーブルが重くなっていないか

性能改善で怖いのは、「たぶん速くなったはず」で終わることです。ローカルの少量データでは速く見えても、本番に近い件数では逆に遅くなることがあります。

そのため、できるだけ実運用に近い件数で確認したいです。本番そのままは難しくても、少なくとも「本番と傾向が近い環境」で、改善前後でクエリ回数、実行時間、実行計画を比較する、というラインまでは踏み込みたいところです。

Go 側のデータ構造も見る

DB だけでなく、Go 側の処理も見直す必要があります。

よくあるのは、配列同士を二重ループで突き合わせているケースです。

for _, user := range users {
	for _, order := range orders {
		if order.UserID == user.ID {
			// 紐づける
		}
	}
}

件数が少ないうちは問題になりませんが、データが増えると一気に遅くなります。この場合は、先に map を作るだけで改善できることがあります。

ordersByUserID := make(map[int64][]Order, len(orders))
for _, order := range orders {
	ordersByUserID[order.UserID] = append(ordersByUserID[order.UserID], order)
}

for _, user := range users {
	orders := ordersByUserID[user.ID]
	// orders を使って組み立てる
}

DB に寄せるべき処理もありますが、すべてを SQL に押し込めばよいわけでもありません。まとめて取得したあと、Go 側で効率よく組み立てるほうが読みやすく、十分速い場合もあります。

ここでも大事なのは、計算量とデータ量を意識することです。コードが短いかどうかではなく、件数が増えたときにどう増えるかを見る必要があります。

取得するカラムを絞る

地味ですが、必要なカラムだけを取ることも大事です。

SELECT * は書くのが楽ですが、不要なカラムまで取得します。特に、テキストの長いカラム、JSON、BLOB、使わない関連データなどを毎回取っていると、DB、ネットワーク、アプリケーション側のメモリに余計な負荷がかかります。

一覧画面や検索 API では、詳細画面ほど多くの情報が必要ないことが多いです。その場合は、一覧用のクエリや DTO を分けて、必要なカラムだけを返すほうがよいです。

性能改善というと大きなクエリ変更に目が行きがちですが、不要なデータを取らないことも基本として意識したいです。

キャッシュは最後に考える

遅い処理を見ると、キャッシュを入れたくなることがあります。ただ、個人的にはキャッシュは最後に考えたいです。

先に見るべきなのは、次のような基本的な部分です。

  • N+1 がないか
  • 不要な JOIN がないか
  • インデックスが効いているか
  • 取得件数や取得カラムが多すぎないか
  • Go 側で非効率なループをしていないか

ここを見直さないままキャッシュを入れると、根本的に遅い処理を隠してしまうことがあります。また、キャッシュにはキャッシュなりの難しさがあります。

  • いつ無効化するか
  • 古い値を返してよいか
  • 更新系とどう整合させるか
  • 障害時にどう振る舞うか

もちろん、読み取り頻度が高く、更新頻度が低いデータではキャッシュが効く場面もあります。ただ、まずはクエリとアプリケーションコードを素直に速くできないかを見るほうが、後の運用が楽だと思っています。

DB とアプリケーションの境界

性能改善をしていると、DB とアプリケーションの境界も考えることになります。

DB に寄せたいのは、データ取得や集合操作として表現できる処理です。

  • JOIN による関連データの取得
  • WHERE による絞り込み
  • GROUP BY による集約
  • ユニーク制約や外部キー制約
  • インデックスを前提にした検索

一方で、Go 側に置きたいのは、ドメインの意味やユースケースに関わる処理です。

  • 状態遷移の判断
  • 権限や業務ルールの判定
  • 外部 API や他サービスとの調整
  • エラーハンドリングの方針
  • ユースケースとしての処理の流れ

SQL で書ける処理でも、業務ルールとして頻繁に変わるなら Go 側に置いたほうがよいかもしれません。逆に、Go で書ける処理でも、単なるデータの突き合わせなら DB 側に寄せたほうがよいかもしれません。

この判断は、パフォーマンスだけでも、設計原則だけでも決まりません。データ量、変更頻度、テストのしやすさ、チームの読みやすさを合わせて考える必要があります。

見直すときのチェックリスト

今後、遅い処理を見たときは、まず次の順番で確認したいです。

  1. どの API / バッチ / 画面が遅いのかを特定する
  2. クエリ回数が件数に比例して増えていないか見る
  3. SELECT * や不要な関連取得がないか見る
  4. JOIN の種類が意図通りか確認する
  5. WHERE / JOIN / ORDER BY に必要なインデックスがあるか見る
  6. EXPLAIN で実行計画を確認する
  7. Go 側で二重ループや不要なメモリ確保がないか見る
  8. 改善前後で実行時間とクエリ回数を比較する
  9. 回帰しないようにテストやベンチマークを足す
  10. それでも必要ならキャッシュを検討する

この順番で見ると、いきなり複雑な対策に飛ばずに済みます。まずは、クエリ回数、取得量、インデックス、実行計画、Go 側の計算量を見る。キャッシュや非同期化は、その後で考えるくらいがちょうどよさそうです。

おわりに

ここまで見直してきたことを振り返ると、バックエンドの性能改善は、単に SQL を速くする話ではないと感じています。

どれも基本的な話ですが、実務ではこの基本がそのまま有効な場面が多いです。特別なテクニックよりも、まずは N+1 やインデックス、実行計画、データ構造といった足元を丁寧に見るほうが、結果として大きな改善につながりやすいと感じました。

今後は、実行計画を読めること、インデックスを設計できること、アプリケーション側の処理と DB 側の処理を切り分けられることを、もう少し丁寧に身につけていきたいです。あわせて、改善前後をきちんと計測して振り返れるような観測やテストの仕組みも、少しずつ整えていければと思います。

Discussion