2026年2月23日月曜日

【実行計画を読む 第1回】100万件データによる学習環境の構築と全件スキャンの正体

プロのデータベースエンジニアを目指す上で、実行計画(Execution Plan)を正確に読み解く力は必須スキルです。SQLが「なぜ遅いのか」を推測ではなく、確信を持って指摘できるようになることが本シリーズの目標です。

第1回は、100万件規模のデータを用いて、PostgreSQL 16における「全件スキャン」の実行計画を読み解くための実験環境を構築します。


0. 動作環境

本記事では、エンタープライズ環境で標準的なOracle Linux 9を使用します。

$ cat /etc/os-release
NAME="Oracle Linux Server"
VERSION="9.7"

1. PostgreSQL 16 のインストール

OS標準のリポジトリではなく、PostgreSQL公式のPGDGリポジトリから最新のv16を導入します。

# ① 公式リポジトリの追加
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm

# ② OS標準モジュールの無効化とインストール
sudo dnf -qy module disable postgresql
sudo dnf install -y postgresql16-server

# ③ データベースの初期化
sudo /usr/pgsql-16/bin/postgresql-16-setup initdb

# ④ サービスの起動と自動起動設定
sudo systemctl enable --now postgresql-16

2. ログイン認証の仕組みと設定

PostgreSQLのデフォルトは「ピア認証」です。作業をスムーズにするため、自分のOSユーザーと同じ名前のDBユーザーを作成しておきます。

# OSユーザーと同じ名前のDBユーザーとDBを作成
sudo -u postgres createuser --superuser $(whoami)
sudo -u postgres createdb $(whoami)

# 以降は「psql」だけで接続可能になります
psql -c "SELECT version();"

3. 学習用サンプルの構築(100万件データ)

十分なデータ量を持つテーブルを作成し、プランナが「コスト」を計算しやすい状態を作ります。

-- ① 100万件のユーザーテーブル
CREATE TABLE users (
    user_id INT,
    user_name TEXT,
    age INT,
    registered_at TIMESTAMP
);

INSERT INTO users (user_id, user_name, age, registered_at)
SELECT 
    i, 
    'user_name_' || i, 
    (random() * 80 + 10)::int,
    now() - (random() * interval '365 days')
FROM generate_series(1, 1000000) s(i);

-- ② 300万件の注文履歴テーブル
CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    user_id INT,
    amount DECIMAL(10, 2),
    order_date TIMESTAMP
);

INSERT INTO orders (user_id, amount, order_date)
SELECT 
    (random() * 999999 + 1)::int,
    (random() * 10000)::decimal,
    now() - (random() * interval '365 days')
FROM generate_series(1, 3000000) s(i);

-- 統計情報を最新にする
ANALYZE users;
ANALYZE orders;

4. 実行計画の取得と解読

インデックスがない状態で、特定の user_id を検索した際の実行計画を確認します。

EXPLAIN ANALYZE SELECT * FROM users WHERE user_id = 500000;

出力された実行計画(全内容)

 Gather  (cost=1000.00..14444.43 rows=1 width=32) (actual time=98.328..99.137 rows=1 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on users  (cost=0.00..13444.33 rows=1 width=32) (actual time=71.401..85.771 rows=0 loops=3)
         Filter: (user_id = 500000)
         Rows Removed by Filter: 333333
 Planning Time: 0.163 ms
 Execution Time: 99.167 ms

実行計画の読み方のポイント

実行計画を読む際の最大のコツは、「インデント(字下げ)が最も深い行から順に、上に向かって読み進める」ことです。

今回の例では、以下の順序でデータが流れています(-> は上の処理へデータを渡す子ノードを示します)。

  1. Parallel Seq Scan:まず、複数のプロセスでテーブル users を読み込み始めます。
  2. Filter:読み込みながら、その行が user_id = 500000 かどうかを判定します。
  3. Gather:各プロセスの結果を合流させ、最終的な結果として返します。

「Parallel Seq Scan」= 全件スキャンの証拠

プロのエンジニアがこれを見て「全件スキャンだ」と断定する理由は2つあります。

  • 名称の意味Seq Scan は Sequential Scan(順次走査)の略。インデックスを使わず、先頭から1行ずつ順番に中身を確認する動作そのものを指します。
  • Rows Removed by Filter:これは条件に合わず除外された行数です。今回 loops=3(3プロセス)が約33万件ずつ除外しており、合計で約100万件をチェックしたことがわかります。
項目 意味
cost=1000.00..14444.43 プランナが算出した予測コスト。左が最初の1行、右が完了までの累積。
actual time=... 実測値。このクエリは約99ミリ秒かかっています。
loops=3 処理が3つのプロセスで並列実行されたことを示します。

まとめ

100万件のデータに対し、インデックスがないとDBは「力技(全件スキャン)」で解決しようとします。たった1件を探すために100万件を読み込むこの挙動を、実行計画から読み取ることができました。

次回は、この「無駄」を劇的に改善するインデックスの作成と、それに伴う実行計画の変化を解説します。




0 件のコメント:

コメントを投稿