バックオフィス基盤第2グループの名嘉眞です。私の関わっているプロダクトで利用しているDBはほぼPostgreSQLを利用しています。今回は業務でよく利用するようなSQLを例にPostgreSQLの実行計画について記事を書こうと思います。書いてあることの技術的な点は普段PostgreSQLを利用しているとおそらく知っている方も多い内容ですが、少しバックオフィス基盤第2グループの業務も含めて書いてみました。
これは私の経験がMySQL(ver 5.7)ばかりで業務でPostgreSQLを使用したことがなく、PostgreSQLの実行計画を見たとき「あ、フォーマット違う」となりました。 初見でも実行計画の内容と読み方をざっと調べればなんとなくわかるのですが、チーム内での対面レビューをお願いして問題解決したのがきっかけです。 若干、備忘録も兼ねていますがチーム内で実行計画を眺めるという機会がもあったので私自信が調べたことも含めてblogにしたいと考えました。 ちなみにMySQLの8.0以降はPostgreSQLと完全に同じというわけではないですが、実行計画が木構造形式で出力できます。(format=tree オプション)
バックオフィス基盤第2グループでは主に支払い請求基盤を開発しています。 支払い請求基盤は名前の通り、タクシー事業者様や、経理などに向けた支払いと請求の金額や取引の件数を集計し、その結果を管理画面向けのAPIとして提供したり、 会計システムに連携したりしています。 「GO Pay」や、電子マネー、QRコードで決済したデータを溜め込んで、その用途に合わせて集計します。 今回は主に毎月の締め日に合わせてタクシー事業者様ごとに金額を集計するようなSQLを題材にします。
以下のような集計SQLをバッチ処理で実行してタクシー事業者などにお支払い(または請求)する金額を算出したりします。 ※あくまで実際のSQLに近い例です。実際に利用されているSQLではないです。
EXPLAIN ANALYZE
SELECT
store_id AS store_id,
COUNT(payments.id) AS count,
SUM(amount) AS amount
FROM
payments AS payments
JOIN payment_stores AS stores
ON payments.store_id = stores.store_id AND store_type = '0'
WHERE
payment_brand_type_id = $1
AND settlement_at >= $2
AND settlement_at < $3
AND CASE
WHEN $1 IN (1, 2, 4) THEN payment_status_id IN (1, 2)
ELSE payment_status_id = 1
END
AND payment_refund_status_id = 1
GROUP BY
store_id
ORDER by
store_id;
上記のSQLの実行計画が以下です。
QUERY PLAN
Sort (cost=6303.44..6305.01 rows=630 width=24) (actual time=276.802..276.839 rows=593 loops=1)
Sort Key: stores.store_id
Sort Method: quicksort Memory: 71kB
-> HashAggregate (cost=6267.84..6274.14 rows=630 width=24) (actual time=276.527..276.637 rows=593 loops=1)
Group Key: stores.store_id
Batches: 1 Memory Usage: 121kB
-> Hash Join (cost=34.76..5978.34 rows=38601 width=20) (actual time=0.386..248.455 rows=119314 loops=1)
Hash Cond: ((payments.store_id)::text = (stores.store_id)::text)
-> Index Scan using payments_settlement_at_idx on payments (cost=0.43..5808.20 rows=51488 width=33) (actual time=0.066..204.891 rows=157574 loops=1)
Index Cond: ((settlement_at >= '2023-06-01 00:00:00+09'::timestamp with time zone) AND (settlement_at < '2023-06-16 00:00:00+09'::timestamp with time zone))
Filter: ((payment_brand_type_id = 6) AND (payment_status_id = 1) AND (payment_refund_status_id = 1))
Rows Removed by Filter: 73503
-> Hash (cost=26.20..26.20 rows=650 width=28) (actual time=0.313..0.314 rows=650 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 47kB
-> Seq Scan on payment_stores_offices stores (cost=0.00..26.20 rows=650 width=28) (actual time=0.011..0.196 rows=650 loops=1)
Filter: (store_type = '0'::bit varying)
Rows Removed by Filter: 6
Planning Time: 2.328 ms
Execution Time: 276.997 ms
最後の行の Execution Time がSQLの稼働時間になります。
実行計画は木構造となっています。今回の場合、 Sort が一番上位のノードで、その直下に集計を実施した HashAggregate 、さらにその下に Hash Join 、さらに下位のノードに Index Scan と Hash があり Hash の下位に Seq Scan の記載があります。 ノードごとに記載されている actual time のそれぞれの値は上位ノードの場合下位のノードも含めた時間になります。そのため各ノードごとでかかるコストや時間を確認したい場合は、含まれる下位ノードの値を除いて算出する必要があります。 actual time 自体の表記については以下のようになります。
最初の1行目を返却した時間..最後の行を返却した時間(単位はミリ秒)
ちなみに、今回は EXPLAIN ANALYZE を実行していますが、 ANALYZE はオプションで、実際にクエリを実行して実行計画を出力します。そのため本番DBで実行するとその分の負荷がかかります。
全ての種類ではないですが以下のようなノードの種類があります。
ノード名の次の括弧が推定コストの表記になります。
推定コストの次の括弧が実際の実行結果に関する表記になります。
※ 上記の各表記に関しては以下の資料を参考・引用させて頂きました。ありがとうございます。
では先ほどの実行計画をもう一度見てその内容を読み解いてみます。
EXPLAIN ANALYZE
SELECT
store_id AS store_id,
COUNT(payments.id) AS count,
SUM(amount) AS amount
FROM
payments AS payments
JOIN payment_stores AS stores
ON payments.store_id = stores.store_id AND store_type = '0'
WHERE
payment_brand_type_id = $1
AND settlement_at >= $2
AND settlement_at < $3
AND CASE
WHEN $1 IN (1, 2, 4) THEN payment_status_id IN (1, 2)
ELSE payment_status_id = 1
END
AND payment_refund_status_id = 1
GROUP BY
store_id
ORDER by
store_id;
---------------------------------------------------------------------------------------------------------------
QUERY PLAN
① Sort (cost=6303.44..6305.01 rows=630 width=24) (actual time=276.802..276.839 rows=593 loops=1)
Sort Key: stores.store_id
Sort Method: quicksort Memory: 71kB
-> ② HashAggregate (cost=6267.84..6274.14 rows=630 width=24) (actual time=276.527..276.637 rows=593 loops=1)
Group Key: stores.store_id
Batches: 1 Memory Usage: 121kB
-> ③ Hash Join (cost=34.76..5978.34 rows=38601 width=20) (actual time=0.386..248.455 rows=119314 loops=1)
Hash Cond: ((payments.store_id)::text = (stores.store_id)::text)
-> ④ Index Scan using payments_settlement_at_idx on payments (cost=0.43..5808.20 rows=51488 width=33) (actual time=0.066..204.891 rows=157574 loops=1)
Index Cond: ((settlement_at >= '2023-06-01 00:00:00+09'::timestamp with time zone) AND (settlement_at < '2023-06-16 00:00:00+09'::timestamp with time zone))
Filter: ((payment_brand_type_id = 6) AND (payment_status_id = 1) AND (payment_refund_status_id = 1))
Rows Removed by Filter: 73503
-> ⑤ Hash (cost=26.20..26.20 rows=650 width=28) (actual time=0.313..0.314 rows=650 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 47kB
-> ⑥ Seq Scan on payment_stores_offices stores (cost=0.00..26.20 rows=650 width=28) (actual time=0.011..0.196 rows=650 loops=1)
Filter: (store_type = '0'::bit varying)
Rows Removed by Filter: 6
Planning Time: 2.328 ms
Execution Time: 276.997 ms
実行計画の各ノードに上から番号を記載しました。 まず実行の流れについて読み解いてみると以下のようになると考えられます。
実行の流れはわかったので、その詳細を読み解いてみます。
まず① Sort や② HashAggregate はSQLも内容もシンプルで、actual timeでもここで時間がかかっているわけではないです。 これは① Sort と② HashAggregate のactual timeで最初と最後の差がほとんどないことや、② HashAggregate の最後の行を返したactual timeの値とと① Sort の最初の行を返したactual timeの差が少ないことで判断できます。 このSQLで一番時間がかかっているのは、③ Hash Join になります(actual time=0.386..248.455)。さらにその中でも④ Index Scan ノードで時間がかかっていることがわかります。(204ms) paymentsテーブル検索では payments_settlement_at_idx インデックスは利用できていますが、その後の絞り込みで時間を要している可能性があります。対象のレコード数の多さも要因になっているかも知れません。 更に効率良くしたい場合は別のインデックスの追加などを検討することになりますが、例えば payment_brand_type_id = 6 のレコードが payments テーブル全体の大部分を占めている場合などはインデックスの追加で解決しない可能性もあります。
ネット上には多くの同じような内容のblogもありますが、実際の業務で使うようなクエリを題材にして書きました。 そういえば、チームのテックリードも勉強会で「DBの気持ちになる」って言ってたことを思い出しました。 今回のblogで書いたような実行計画を理解することは、もしかすると少しDBの気持ちに近づくことかも知れません。 既にご存知の方も多いような内容ですが誰かの参考になったり、チームの業務や雰囲気が伝わると幸いです。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @goinc_techtalk のフォローもよろしくお願いします!