概要
バックエンドの性能問題で最も頻繁にボトルネックになるのが、データベース層です。このページでは、データベースの性能改善の根底にある原則と、そこから導かれる具体的な方法を解説します。 改善の目的の定め方や対処を検討する順序といった前提はパフォーマンスを参照してください。また、どの種類のデータベースにデータを置くかという手前の判断はデータベースの選び方が扱います。このページは、選んだデータベースを速く使うための話です。 便宜上リレーショナルデータベースを主な例としますが、ここで扱う考え方の多くは、NoSQL(DynamoDB、MongoDB等)にも通用します。なお、データベースサーバーのパラメータ設定のチューニングは扱わず、クエリ・スキーマ・アプリケーション側の改善に絞ります。 扱うのは主に読み取りの性能です。多くのWebサービスでは読み取りが書き込みを大きく上回り、ボトルネックもまず読みに現れるためです。書き込みに固有の論点は、インデックスの維持コストや書き込みの分散など、関係する箇所で触れます。なぜデータベースがボトルネックになるのか
データベースがボトルネックになりやすいのは、データをどこから読むかによって、かかる時間が桁違いに異なるからです。
数値はハードウェアや構成で変わるため、絶対値ではなく桁の比較として読んでください。読み取りや往復の回数が多い処理ではレイテンシが、一度に大量のデータを読む処理ではスループットが、所要時間を決めます。データベースではインデックス経由の参照がランダム読み取りの世界、フルスキャンや大きな集計がシーケンシャル読み取りの世界です。
注意したいのは表の最終行です。クラウドのマネージドデータベースの多くはストレージ自体がネットワーク接続のため、1回の読み取りにローカルNVMeの数十倍のレイテンシがかかります。
スループットの数字が大きくても安心はできません。ランダム読み取りは1回ごとにレイテンシを支払うため、1ミリ秒の読み取りを1,000回繰り返せば、それだけで1秒です。帯域がどれだけ太くても、回数の積み重ねで生じる待ち時間は埋められません。
実際のデータベースは、この遅さを大量のメインメモリ(バッファプール)で埋めており、構成によってはローカルNVMeをキャッシュ層として挟むこともあります。裏を返せば、キャッシュに収まらない量を読んだ瞬間に、表の数字がそのまま現れます。
アプリケーションの他の処理がどれだけ速くても、データベースがストレージを読む量と往復の回数が、システム全体の速度をほぼ決めます。
大原則:読むストレージ量の最小化
したがって、データベースの性能改善の根底にある考え方は、「ストレージから読み出すデータ量を最小化する」という1つに集約されます。インデックス設計も、クエリの調整も、アプリケーション側の工夫も、無駄なストレージ読み出しを減らすという点に帰着します。データがメモリ(バッファプール)にキャッシュされている場合でも、走査する量が少ないほど速いことは変わりません。また、読み出し量がそのままインフラコストに直結する課金体系もあります(AuroraのI/O課金、DynamoDBの読み取りキャパシティなど)。 「このクエリはどれだけのストレージを読むか」という1つの問いを持つと、以降の個別テクニックが暗記事項ではなく、同じ原則の応用としてつながります。読んでいる量を観測する
読む量を減らすには、まず「どのクエリが、どれだけ読んでいるか」を知る必要があります。入口は3つです。- スロークエリログ — 閾値(例: 0.5秒)を超えたSQLを記録する機能です。「どのクエリが読みすぎているか」の候補を挙げる、最初のデータソースです。
- 実行計画(
EXPLAIN) — そのクエリが「どれだけ読む計画か」を実行前に確認できます(読み方は後述)。 - エンジンの状態メトリクス — クエリ単体ではなく、全体で読みがどうなっているかを示します。キャッシュヒット率(バッファプールヒット率等。低下は物理ディスクへの読みが増えている兆候)、CPU・IOPS、アクティブ接続数(コネクションプールの枯渇)、ロック待ち(トランザクション同士の詰まり)。
読む量を減らす実践
読みすぎているクエリを特定したら、読む量を減らす手を打ちます。クエリに近い側から順に、インデックス、実行計画、アプリケーション設計、データの持ち方の4つの層があります。インデックスの本質:読むブロック数を減らす
インデックスは、目的のデータがストレージのどこ(どのブロック/ページ)にあるかを特定し、無駄な読み取りを回避するための仕組みです。 フルテーブルスキャンが遅い理由 — インデックスがない場合、データベースエンジンは求めているデータが1件であっても、テーブル全体のデータをストレージから読み出さなければなりません。ストレージ読み込み量が最大化した状態です。なお、レコード数が少ないテーブルでは全体を読んでも問題にならないことが多く、オプティマイザが意図的に全走査を選ぶこともあります。問題になるのは、読む量が大きいときです。 インデックスによる読み取りの削減 — インデックスを適切に設計すると、データベースは少ない参照で、目的のデータが存在するブロックだけを読み出せます。- 複合インデックスの順序(最左前方一致) —
WHERE tenant_id = 1 AND status = 'active' AND created_at > :sinceのような条件では、等値条件で使うカラムを前に、範囲条件で使うカラムを後ろに配置します。範囲条件のカラムより後ろは絞り込みに使えず、そのぶんデータブロックを広く読むことになります。等値条件同士の並び順は絞り込み効率にほぼ影響しないため、他のクエリと前方部分を共用しやすい並びを選びます。 - カバリングインデックス —
SELECTで指定するカラムがすべてインデックスに含まれている場合、データベースはテーブル本体への読み取りをスキップし、インデックスの情報だけで処理を完了できます。
実行計画の解読
クエリをチューニングする際は、まず実行計画を確認し、どのようなアルゴリズムで、どれだけのデータ(行数・ページ数)を読み出そうとしているかを分析します。実行計画を取得するコマンド(EXPLAIN等)や出力形式はデータベースの種類によって異なりますが、見るべき共通のポイントは3つです。
- スキャン方式 — インデックスを使わず、テーブル全体をストレージから読み出していないか
- 読み出し対象の規模(行数見積もり) — 絞り込みが機能せず、不必要に膨大な行数を読む計画になっていないか
- ディスクへの一時書き出し — ソートや中間データの生成がメモリ容量を超え、ストレージへの書き出しが発生していないか
アプリケーション設計による読み込みの削減
クエリ単体の最適化だけでなく、アプリケーションとデータベースのやり取りの見直しでも読み込み量を減らせます。 N+1問題の解消 — ループ処理の中でクエリをN回発行すると、毎回データベースとの往復が発生し、個別にストレージやキャッシュへのアクセスが走ります。結合やIN句での一括取得(Eager Loading)により、アクセス回数と総読み出しブロック数を削減します。往復のコストは書き込みでも同じです。1件ずつのINSERTやUPDATEの繰り返しは、一括の書き込みにまとめます。
深いOFFSETの回避 — SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 100000のようなクエリは、100,000件目からの20件を得るために、手前の100,000件分を読み出して捨てています。前回取得した最後の位置を条件に指定する形(カーソル方式)に書き換えると、手前のレコードを読み飛ばさずに次の20件だけを読めます。
カーソル方式は直前の取得位置に依存するため、「任意のページ番号へのジャンプ」には使えません。「次へ / 前へ」や無限スクロールのようなUIに限られる、というUI側とのトレードオフがあります。
in_batchesは、バッチを取得するたびに、元のクエリ全体を再実行します。クエリが軽いうちは気になりませんが、重い結合や副問い合わせを含んでいると、その高コストな部分をバッチの回数だけ払うことになります。便利なメソッドを大量のデータに使うときは、実際に発行されるSQLをログで確認します。
データの持ち方で読む量を減らす
クエリやアプリケーションの改善で読む量を減らしきれないときは、データの持ち方を変えて、読む時点の仕事を減らせます。ただしここまでの手と違い、どちらも対価を伴います(パフォーマンスの「何を引き換えにするか」を参照してください)。 非正規化 — 結合で都度組み立てていた値を、あらかじめ行に持たせます。読み取りは単純な参照になりますが、更新時に複数箇所を整合させる責任を負い、不整合のリスクを抱えます。 事前集計(サマリーテーブル) — 集計結果をあらかじめテーブルに書き出し、読む側は小さな結果だけを参照します。大量のスキャンが数行の読み取りに変わりますが、集計軸が固定され、結果の鮮度は更新頻度に縛られます。キャッシュヒット率の維持
どれほどクエリを最適化しても、ストレージへのアクセスが発生する限りレスポンスには限界があります。そこで重要なのが、バッファプールなどのデータベース内部キャッシュです。頻繁にアクセスされるデータ(ワーキングセット)がメモリに乗っていれば、ストレージへのI/Oは発生しません。クエリによる読み込み量の削減を行った上で、なおキャッシュヒット率が低下する場合は、メモリの増設や、キャッシュを追い出す原因になる全件読み込みクエリの排除を検討します。単一データベースの限界を超えるとき
ここまでの最適化を行ってもなお限界に達する場合は、単一データベース内のチューニングにとどまらず、アーキテクチャレベルでの役割分離や専門エンジンの採用を検討します。何を足すか、そして2つ目のデータベースが要求する代償については、データベースの選び方も参照してください。 リードレプリカによる読み取り分散 — 書き込み(Primary)と読み取り(Replica)を分離し、負荷を物理的に分散します。レプリケーション遅延による結果整合性の許容が必要です。 機能分割・シャーディングによる書き込みとデータ量の分散 — 書き込みのスループットとデータ総量は、レプリカでは分散できません。まずはドメインやテーブル群ごとに別のデータベースへ分ける機能分割を検討し、それでも足りなければ、テナントやユーザーIDなどのキーで複数のデータベースに分けるシャーディングを使います。ただし、どちらの分割でも、境界をまたぐ結合やトランザクションは使えなくなり、分け方を後から変えるのも困難です。他の手で足りないことを確かめてから選びます。分割を自前で運用する代わりに、分割を引き受けるデータベースを採用する選択肢もあります。Cloud SpannerやCockroachDBのような分散SQLデータベースはSQLと分散トランザクションを保ったまま分割をマネージドに担い、DynamoDBのような分散前提のNoSQLは、必要なぶんだけ書き込みの処理能力と容量を増やしていけます。それぞれの得意・不得意はデータベースの選び方を参照してください。
- データウェアハウス(DWH。BigQuery / Snowflake / Redshift等) — 大規模データのバッチ集計やレポート生成に特化
- リアルタイムOLAP(ClickHouse等) — ログ解析や分析ダッシュボードなど、大規模データの集計・検索の低レイテンシ化に特化
- HTAP(TiDB等) — Hybrid Transactional/Analytical Processing。OLTPとOLAPを1つの分散データベースで両立
関連ページ
パフォーマンス
何のために、どの順序で、何を見て性能を改善するかという、チューニング全体の判断の土台を解説します。
データベースの選び方
どの種類のデータベースにデータを置くかという、このページより手前の判断を解説します。2つ目のデータベースを足すときの代償も扱います。
変更容易性
低いコストと低いリスクで変更を受け入れる性質を解説します。非正規化や事前集計が損ないうるのはこの性質です。