同時アクセスが増える時間帯に限ってレスポンスが急激に悪化する――データベースを運用していると、この「クエリ多重実行による性能劣化」は避けて通れない課題です。単一クエリの最適化だけでは解決できず、CPU・メモリ・ディスクI/Oといったサーバーリソースの競合が根本原因になっているケースが大半を占めます。

本記事では、リソース競合がなぜ発生するのかを仕組みから掘り下げ、RDBMS別の診断SQLと段階的な改善手順を具体例つきで整理しています。

クエリ多重実行で性能劣化が発生するメカニズム

データベースは同時に複数のクエリを処理する際、CPU時間・メモリバッファ・ディスクI/O帯域といった有限リソースを各セッションに分配します。同時実行数が一定の閾値を超えると、リソースの奪い合い(競合)が起き、個々のクエリの応答時間が指数関数的に悪化します。

リソース競合の3つのレイヤー

レイヤー競合対象典型的な兆候
CPU実行スレッド / ワーカープロセスCPU使用率100%張り付き、load average がコア数の数倍
メモリ共有バッファ / ソートバッファスワップ発生、OOM Killer起動、バッファキャッシュヒット率低下
ディスクI/Oデータファイル読み書きiowait の上昇、IOPS上限到達、WALの書き込み遅延

単一クエリが正常に動作する環境でも、同じクエリを10並列・50並列で実行すると急激に劣化する理由は、このリソース分配の上限にあります。たとえばPostgreSQLではshared_buffersのサイズが固定されているため、多数のクエリが同時にバッファプールを走査すると、キャッシュのスラッシング(頻繁な入れ替え)が起こり、本来キャッシュに乗るべきデータが追い出されてディスクI/Oが増大します。

ロック競合による連鎖的な待機

リソース競合と並んで問題になるのが、行ロックやテーブルロックによる待機の連鎖です。あるトランザクションがロックを保持したまま長時間実行されると、後続のトランザクションはロック待ちキューに入り、そのロック待ちがさらに次のトランザクションの待機を引き起こす「ロックチェーン」が形成されます。

-- PostgreSQL: 現在のロック待ち状況を確認する
SELECT
  blocked.pid     AS blocked_pid,
  blocked.query   AS blocked_query,
  blocking.pid    AS blocking_pid,
  blocking.query  AS blocking_query,
  blocked.wait_event_type
FROM pg_stat_activity AS blocked
JOIN pg_locks AS bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks AS kl ON kl.locktype = bl.locktype
  AND kl.database IS NOT DISTINCT FROM bl.database
  AND kl.relation IS NOT DISTINCT FROM bl.relation
  AND kl.page IS NOT DISTINCT FROM bl.page
  AND kl.tuple IS NOT DISTINCT FROM bl.tuple
  AND kl.pid != bl.pid
  AND kl.granted
JOIN pg_stat_activity AS blocking ON blocking.pid = kl.pid
WHERE blocked.wait_event_type = 'Lock';

性能劣化を引き起こす代表的な5つのパターン

パターン1: 統計情報の陳腐化

データベースのクエリオプティマイザは、テーブルの行数やカラムの値の分布(統計情報)をもとに最適な実行計画を選択します。データの大量INSERT・DELETE後に統計情報が更新されないと、オプティマイザが非効率な実行計画を選び、フルテーブルスキャンやNested Loopが不要に発生します。

多重実行環境では、1つのクエリが非効率な実行計画で動作するだけでもI/O負荷がDB全体に波及するため、影響が単一実行時の数倍に膨れ上がります。

パターン2: インデックスの欠落・断片化

WHERE句やJOIN条件に使うカラムにインデックスが存在しない場合、DBはフルテーブルスキャンを実行します。100万行のテーブルで10並列のフルスキャンが走れば、ディスクI/Oは単純計算で10倍に跳ね上がります。

また、大量の更新処理が続くとインデックスの断片化が進行し、論理的には連続したデータが物理的に散在するようになります。これによりシーケンシャルリードのはずがランダムリードになり、I/O効率が大幅に低下します。

パターン3: 不適切なクエリ設計

以下のような書き方は、単一実行では問題にならなくても多重実行時に深刻なボトルネックになります。

  • SELECT *の多用: 不要なカラムのデータ転送でメモリ・ネットワーク帯域を浪費
  • N+1クエリ: ループ内でクエリを繰り返し発行し、DB接続数とラウンドトリップが膨張
  • 相関サブクエリ: 外部クエリの各行に対して内部クエリが繰り返し実行される
  • 暗黙の型変換: WHERE句でカラム型と異なる型のリテラルを使うと、インデックスが効かなくなる

パターン4: コネクション数の過剰

アプリケーション側でコネクションプールが適切に設定されていない場合、ピーク時にDB接続数が上限に達します。PostgreSQLでは1接続につき1プロセスが生成されるため、接続数が増えるほどコンテキストスイッチのオーバーヘッドが増大し、全体のスループットが落ちます。

パターン5: チェックポイント・WAL書き込みの集中

PostgreSQLやMySQLでは、一定間隔またはWAL量に応じてチェックポイント処理が実行されます。大量の更新トランザクションが同時に走ると、WALの書き込みとチェックポイントのI/Oが重なり、参照系クエリまで巻き込んで全体の応答時間が悪化します。

性能劣化の診断手順:3ステップで原因を特定する

ステップ1: スロークエリの洗い出し

まず、どのクエリが遅いのかを特定します。

PostgreSQLの場合:

pg_stat_statements拡張を有効化し、累積実行時間の長いクエリを抽出します。

-- 累積実行時間TOP10のクエリを取得
SELECT
  queryid,
  calls,
  round(total_exec_time::numeric, 2) AS total_ms,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  rows,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

MySQLの場合:

スロークエリログを有効化します。

-- スロークエリログの有効化(1秒以上のクエリを記録)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

pt-query-digest(Percona Toolkit)で集計すると、最も影響の大きいクエリが効率よく特定できます。

SQL Serverの場合:

-- CPU時間の長いクエリTOP10を取得
SELECT TOP 10
  qs.total_worker_time / qs.execution_count AS avg_cpu_time,
  qs.execution_count,
  SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
    ((CASE qs.statement_end_offset
        WHEN -1 THEN DATALENGTH(st.text)
        ELSE qs.statement_end_offset
      END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
ORDER BY qs.total_worker_time DESC;

ステップ2: 実行計画の分析

スロークエリを特定したら、EXPLAIN(PostgreSQL/MySQL)または実行プラン(SQL Server)で実行計画を確認します。

-- PostgreSQL: 実際の実行統計を含む実行計画
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;

確認すべきポイントは以下の通りです。

チェック項目問題のサイン
Seq Scan(フルスキャン)大きなテーブルでインデックスが使われていない
Nested Loop の rows 推定値と actual rows の乖離統計情報が古い可能性
Sort / Hash が disk を使用work_mem が不足している
Buffers: shared read が大きいキャッシュミスが多発している

ステップ3: リソース消費の可視化

クエリ単体の問題だけでなく、サーバー全体のリソース消費を把握します。

# Linux: CPU・I/O・メモリの状況を1秒間隔で確認
vmstat 1
iostat -x 1

PostgreSQLのバッファキャッシュヒット率を確認:

SELECT
  sum(blks_hit) AS cache_hit,
  sum(blks_read) AS disk_read,
  round(
    sum(blks_hit)::numeric /
    nullif(sum(blks_hit) + sum(blks_read), 0) * 100, 2
  ) AS hit_ratio_pct
FROM pg_stat_database;

キャッシュヒット率が99%を下回っている場合は、shared_buffersの増加を検討する余地があります。

RDBMS別の多重実行対策

PostgreSQLの対策

接続管理の最適化:

PostgreSQLはプロセスベースのアーキテクチャで、接続数の増加がそのままOSプロセス数の増加に直結します。max_connectionsを安易に上げるのではなく、PgBouncerなどのコネクションプーラーを導入してDB側の実接続数を制限するのが効果的です。

# pgbouncer.ini の設定例
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
default_pool_size = 20
max_client_conn = 200

自動VACUUM・ANALYZEの調整:

更新頻度の高いテーブルではautovacuum_vacuum_scale_factorを小さめに設定し、統計情報の鮮度を保ちます。

ALTER TABLE high_update_table SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_analyze_scale_factor = 0.005
);

チェックポイントの平滑化:

checkpoint_completion_targetを0.9に設定すると、チェックポイントのI/Oが分散され、スパイク的な負荷を緩和できます。

MySQLの対策

InnoDB Buffer Poolの適正化:

innodb_buffer_pool_sizeは物理メモリの60〜80%を目安に設定します。バッファプールが不十分だとディスク読み取りが頻発し、多重実行時にI/O待ちが深刻化します。

-- バッファプールヒット率の確認
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

Innodb_buffer_pool_read_requests(論理読み取り)に対するInnodb_buffer_pool_reads(物理読み取り)の割合が1%を超えていればバッファプール不足の兆候です。

スレッドプールの活用(MySQL Enterprise / Percona Server):

デフォルトのone-thread-per-connectionモデルでは、同時接続数が多いとスレッド生成のオーバーヘッドが問題になります。スレッドプールを有効化すると、少ないスレッドで多数の接続を効率よく処理できます。

SQL Serverの対策

Resource Governorによるリソース制御:

SQL ServerにはResource Governor機能があり、ワークロードグループごとにCPUやメモリの使用量を制限できます。レポート用の重いクエリとオンライントランザクションを分離することで、多重実行時の相互干渉を防ぎます。

-- リソースプールの作成例
CREATE RESOURCE POOL ReportPool
WITH (
  MAX_CPU_PERCENT = 30,
  MAX_MEMORY_PERCENT = 25
);

-- ワークロードグループをプールに割当
CREATE WORKLOAD GROUP ReportGroup
USING ReportPool;

パラメータスニッフィング対策:

SQL Serverはクエリの初回実行時にパラメータ値に基づいて実行計画をキャッシュします。初回のパラメータが偏った値だった場合、以降の実行で非効率な計画が再利用され続ける現象(パラメータスニッフィング)が起きます。多重実行環境ではその影響が増幅されます。

対処としては OPTION (RECOMPILE) ヒントの付与や、OPTIMIZE FOR ヒントによる典型的なパラメータ値の指定があります。

クエリ最適化の実践テクニック

SELECT句で必要なカラムだけを指定する

SELECT * を避け、必要なカラムのみを取得します。カバリングインデックスが効く状態になれば、テーブル本体へのアクセス自体が不要になり、I/O削減の効果は大きくなります。

-- 改善前: 全カラム取得(テーブルアクセスが必要)
SELECT * FROM orders WHERE customer_id = 100;

-- 改善後: 必要カラムのみ(インデックスオンリースキャンの可能性)
SELECT order_id, order_date, total_amount
FROM orders
WHERE customer_id = 100;

JOINの見直しとバッチ処理化

N+1クエリの解消には、ループ内のクエリ発行をJOINまたはIN句によるバッチ取得に置き換えます。

-- 改善前: ループ内で1件ずつ取得(N+1問題)
-- アプリ側で customer_ids を1件ずつ SELECT

-- 改善後: IN句でバッチ取得
SELECT c.customer_id, c.name, o.order_id, o.total_amount
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
WHERE c.customer_id IN (1, 2, 3, 4, 5);

大量データのSELECTはページネーションで分割する

数百万行を一度に取得すると、DBのメモリだけでなくアプリケーション側のメモリも圧迫します。カーソルベースのページネーション(キーセットページネーション)で分割取得するのが安全です。

-- OFFSETベース(行数が増えるほど遅くなる)
SELECT * FROM logs ORDER BY id LIMIT 100 OFFSET 100000;

-- キーセットベース(一定速度で取得可能)
SELECT * FROM logs
WHERE id > 100000
ORDER BY id
LIMIT 100;

インデックス設計の基本方針

原則説明
WHERE句の等価条件カラムを先頭に複合インデックスの左端に等価検索(=)のカラムを配置する
選択性の高いカラムを優先カーディナリティ(値の種類)が多いカラムほどインデックスの効果が大きい
範囲検索カラムは末尾にBETWEEN・不等号は範囲検索扱いのため、複合インデックスの末尾に配置する
更新頻度とのバランスインデックスが多すぎるとINSERT/UPDATE/DELETEの速度が低下する

アーキテクチャレベルの対策

クエリ単体の最適化やDB設定の調整だけでは限界がある場合、アーキテクチャ面からの対策が必要です。

リードレプリカの活用

参照系クエリをリードレプリカに分散させることで、プライマリDBの負荷を大幅に軽減できます。PostgreSQLではストリーミングレプリケーション、MySQLではGTIDベースのレプリケーション、SQL ServerではAlways On可用性グループがそれぞれ利用できます。

ただし、レプリケーション遅延(レプリカラグ)には注意が必要です。書き込み直後のデータをレプリカから読み取ると古いデータが返る可能性があるため、強い一貫性が必要な処理はプライマリで実行します。

キャッシュ層の導入

頻繁に参照されるが更新頻度が低いデータは、RedisやMemcachedなどのインメモリキャッシュに格納することで、DBへのクエリ自体を減らせます。

キャッシュ戦略としては以下のパターンがあります。

  • Cache-Aside: アプリケーションがキャッシュを確認→ミスならDBから取得してキャッシュに格納
  • Write-Through: 書き込み時にDBとキャッシュの両方を更新
  • TTLベースの失効: 一定時間後にキャッシュを自動で無効化

コネクションプールの適正化

アプリケーションサーバーとDB間の接続数を適切に制御することは、多重実行環境での性能維持に不可欠です。

目安として、コネクションプールのサイズは以下の公式で見積もれます。

最適プールサイズ ≒ ((CPUコア数 × 2) + ディスクスピンドル数)

この公式はPostgreSQLコミュニティで広く参照されているもので、SSD環境ではディスクスピンドル数を1として計算します。つまり4コアCPU + SSD構成であれば、プールサイズは約9が目安です。

継続的な監視と予防のポイント

性能劣化は突然発生するように見えて、実際にはデータ量の増加や利用パターンの変化によって徐々に進行していることがほとんどです。以下の指標を定期的に監視し、閾値を超えた場合にアラートを飛ばす仕組みを構築することが重要です。

監視すべき主要メトリクス

メトリクス正常値の目安注意が必要な閾値
バッファキャッシュヒット率99%以上95%未満
アクティブ接続数プールサイズ以下max_connectionsの80%超
スロークエリ発生率全クエリの1%未満5%以上
CPU使用率70%以下90%超が5分以上継続
ディスクI/O待ち(iowait)5%以下20%超
ロック待ちセッション数0〜数件10件以上
レプリカラグ1秒以下10秒超

監視ツールの選択肢

ツール特徴対応DB
pg_stat_statementsPostgreSQL標準の拡張、クエリ単位の統計PostgreSQL
Performance SchemaMySQL標準の内部統計MySQL
Dynamic Management ViewsSQL Server標準のリソース・クエリ監視SQL Server
Prometheus + Grafanaオープンソースの汎用監視基盤、エクスポーター連携全般
Datadog / New RelicSaaS型のフルスタック監視全般

まとめ

クエリ多重実行による性能劣化は、CPU・メモリ・ディスクI/Oのリソース競合とロックの連鎖が根本原因です。対策は「スロークエリの特定 → 実行計画の分析 → リソース消費の可視化」という3段階の診断から始め、クエリ最適化 → DB設定調整 → アーキテクチャ改善の順に段階的に進めるのが効率的です。

PostgreSQL・MySQL・SQL Serverのいずれも、多重実行環境では接続数の制御が特に重要であり、コネクションプーラーやリソースガバナンスの導入が大きな効果を発揮します。一度の対策で終わりにするのではなく、継続的な監視体制を整え、データ量やアクセスパターンの変化に追従し続ける運用が求められます。