Skip to content

Commit 17f9e0e

Browse files
authored
Merge pull request #1701 from future-architect/feature
skip scan
2 parents bbbbdb3 + 2c87c53 commit 17f9e0e

6 files changed

+257
-0
lines changed

source/_posts/2025/20251010a_PostgreSQL:_4億件のテーブルでSeq_Scanが選ばれる問題を、統計情報(n_distinct)の改善で解決するまでのプロセス.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ thumbnail: /images/2025/20251010a/thumbnail.png
1111
author: 市川裕也
1212
lede: "私が現場で行った PostgreSQL のパフォーマンスチューニングについて、原因調査から解決までのプロセスを共有します。この記事が、「なぜか適切な実行計画が選ばれない、インデックスが使われない」といった同様の問題に直面している方の助けになれば幸いです。"
1313
---
14+
[PostgreSQL18連載](/articles/20251006a/)の5本目の記事です。
15+
1416
## はじめに
1517

1618
こんにちは、CSIG (Cyber Security Innovation Group) の市川です。
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
---
2+
title: "PostgreSQL 18の新機能「B-treeインデックスのスキップスキャン」"
3+
date: 2025/10/14 00:00:00
4+
postid: a
5+
tag:
6+
- PostgreSQL
7+
- 実行計画
8+
- PostgreSQL18
9+
category:
10+
- DB
11+
thumbnail: /images/2025/20251014a/thumbnail.jpg
12+
author: 村田靖拓
13+
lede: "「B-treeインデックスのスキップスキャン」機能が気になったので、機能の特徴を深堀りしつつ、実際の挙動を確認してみます。複合インデックス(複数の列で構成されるインデックス)の利用効率を劇的に向上させる新しいスキャン方法です。"
14+
---
15+
16+
<img src="/images/2025/20251014a/top.jpg" alt="" width="800" height="664">
17+
18+
[PostgreSQL18連載](/articles/20251006a/)の6本目の記事です。
19+
20+
[PostgreSQL 18がリリース](https://www.postgresql.org/about/news/postgresql-18-released-3142/)されました。リリースされた機能のうち私は「B-treeインデックスのスキップスキャン」機能が気になったので、機能の特徴を深堀りしつつ、実際の挙動を確認してみます。
21+
22+
# B-treeインデックスのスキップスキャンとは
23+
24+
複合インデックス(複数の列で構成されるインデックス)の利用効率を劇的に向上させる新しいスキャン方法です。
25+
26+
## 従来の課題
27+
28+
PostgreSQLでは、例えば`(列A, 列B)`という順番で複合インデックスを作成した場合、これまではWHERE句に先頭の「列A」の条件がないと、インデックスを効率的に使えませんでした。
29+
30+
例えば、`WHERE 列B = 'hoge'`というクエリでは、せっかくの `(列A, 列B)` インデックスをうまく使えず、結果としてテーブル全体をスキャン(シーケンシャルスキャン)してしまう、あるいは、インデックスを使えたとしても「列A」の条件を指定していない分だけパフォーマンスが低下する原因となっていました。
31+
32+
これにより「列B」だけのインデックスを別途作成するケースもあり、ストレージの無駄やデータ更新時のコスト増につながっていました。
33+
34+
## 新機能による効果
35+
36+
スキップスキャン機能では、インデックスの先頭列(列A)がWHERE句になくてもPostgreSQLがインデックスの内部を「スキップ」しながら、2番目以降の列(列B)の条件に合うデータをより効率的なアルゴリズムで探し出してくれます。
37+
38+
## 機能の仕組み
39+
40+
1. まず、インデックスの先頭列(列A)にどのような値の種類があるかを把握
41+
2. 次に、列Aの各値の「先頭」にジャンプ
42+
3. そこから、2番目の列(列B)が条件に合致するかどうかをチェック
43+
44+
これを列Aの値の種類ぶんだけ繰り返すことで、インデックス全体を舐めるよりもはるかに効率的にデータを見つけ出すことができます。
45+
46+
公式ドキュメントでは、上記2,3における挙動が詳細に説明されています。
47+
>スキップスキャンは、インデックス列のすべての可能な値に一致する動的な等価制約を内部的に生成することによって機能します
48+
49+
つまり上記の例であれば、列Bに対してのみ条件が指定されている場合でも列Aに対する条件を内部的に生成して動作することを意味します。
50+
51+
>例えば、(x, y)に対するインデックスがあり、クエリ条件がWHERE y = 7700である場合、B-treeインデックススキャンはスキップスキャン最適化を適用できる可能性があります。これは一般的に、クエリプランナが、テーブルで利用可能なインデックスを考慮した上で、Nのすべての可能な値(またはインデックスに実際に格納されているすべてのxの値)に対してWHERE x = N AND y = 7700という検索を繰り返すことが最も高速なアプローチであると予測する場合に発生します。
52+
53+
この仕組みは、インデックスの先頭列の値の種類が少ない(カーディナリティが低い)場合に特に高い効果を発揮します。 例えば、「性別」「注文ステータス」「都道府県」のように、値のバリエーションが限られている列が先頭にある複合インデックスで非常に有効だと言えます。
54+
55+
https://www.postgresql.org/docs/current/indexes-multicolumn.html
56+
57+
# 実際に検証してみる
58+
59+
さて、PostgreSQL 17と18を比較してどのようにクエリ応答性能が変化しているか比べてみます。
60+
61+
## 検証環境
62+
63+
* Windows11 Home
64+
* WSL2(ubuntu)
65+
66+
## PostgreSQL 18 の場合
67+
68+
### 下準備
69+
70+
#### データベースの準備
71+
72+
まずはPostgreSQL18が動く環境を準備します。(今回はDockerを利用)
73+
74+
```sh
75+
docker run --name pg18-handson -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres:18
76+
```
77+
78+
コンテナの起動を確認。
79+
80+
```sh
81+
$ docker ps
82+
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
83+
0806a36cb87c postgres:18 "docker-entrypoint.s…" 8 seconds ago Up 7 seconds 0.0.0.0:5432->5432/tcp, :::5432->5432/tcp pg18-handson
84+
```
85+
86+
`psql`コマンドで入ってみると、、しっかりと動いてることが確認できたので次に進みます。
87+
88+
```sh
89+
$ docker exec -it pg18-handson psql -U postgres
90+
psql (18.0 (Debian 18.0-1.pgdg13+3))
91+
Type "help" for help.
92+
93+
postgres=#
94+
```
95+
96+
#### テーブルの作成と検証用データの投入
97+
98+
`orders`テーブルを作成し、100万件のデータを投入します。
99+
100+
```sql
101+
CREATE TABLE orders (
102+
order_id SERIAL PRIMARY KEY,
103+
order_status TEXT NOT NULL, -- 'pending', 'processing', 'shipped', 'delivered', 'cancelled' の5種類
104+
customer_id INTEGER NOT NULL,
105+
order_date TIMESTAMPTZ NOT NULL,
106+
order_details TEXT
107+
);
108+
109+
-- サンプルデータを100万件投入
110+
INSERT INTO orders (order_status, customer_id, order_date, order_details)
111+
SELECT
112+
-- 5種類のステータスをランダムに割り当て
113+
(ARRAY['pending', 'processing', 'shipped', 'delivered', 'cancelled'])[floor(random() * 5) + 1],
114+
-- 1万人の顧客IDをランダムに割り当て
115+
floor(random() * 10000) + 1,
116+
-- 過去1年間のランダムな日時
117+
NOW() - (random() * 365) * '1 day'::interval,
118+
'details...'
119+
FROM
120+
generate_series(1, 1000000);
121+
```
122+
123+
#### 複合インデックスの作成
124+
125+
スキップスキャンの効果を検証するため、カーディナリティの低い `order_status` を先頭にした複合インデックスを作成します。
126+
127+
```sql
128+
CREATE INDEX idx_orders_status_customer ON orders (order_status, customer_id);
129+
```
130+
131+
#### 統計情報の更新
132+
133+
クエリオプティマイザが正しい判断を下せるように、テーブルの統計情報を最新の状態にします。
134+
135+
```sql
136+
ANALYZE orders;
137+
```
138+
139+
### スキップスキャン機能の検証
140+
141+
では、ここから実際に機能の検証を行ってみます。
142+
143+
まずは複合インデックスの2番目の列である`customer_id`のみをwhere句に指定して検索してみます。
144+
145+
```sh
146+
postgres=*# EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 123;
147+
QUERY PLAN
148+
--------------------------------------------------------------------------------------------------------------------------------------------
149+
Index Scan using idx_orders_status_customer on orders (cost=0.42..418.60 rows=100 width=36) (actual time=0.052..0.250 rows=93.00 loops=1)
150+
Index Cond: (customer_id = 123)
151+
Index Searches: 11
152+
Buffers: shared hit=126
153+
Planning Time: 0.062 ms
154+
Execution Time: 0.276 ms
155+
```
156+
157+
Index Scanが行われており、 `0.276ms`で応答しました。高速ですね。ただし、実行計画にはスキップスキャンを示す表記が登場しないため、厳密にはスキップスキャンを行ったか否かを判断できないのが悩ましいところです。今回のクエリは複合インデックスの2列目に対してのみ等価条件を指定しており、1列目データ群はカーディナリティが低いため、"おそらく"スキップスキャンが行われているだろうと考えられます。
158+
159+
次に、Seq Scanが採択された場合にどのような結果となるかも試してみます。
160+
161+
```sh
162+
postgres=*# SET LOCAL enable_indexscan = off;
163+
SET
164+
postgres=*# SET LOCAL enable_bitmapscan = off;
165+
SET
166+
postgres=*# EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 123;
167+
QUERY PLAN
168+
--------------------------------------------------------------------------------------------------------------------------
169+
Gather (cost=1000.00..15164.33 rows=100 width=36) (actual time=0.363..18.606 rows=93.00 loops=1)
170+
Workers Planned: 2
171+
Workers Launched: 2
172+
Buffers: shared hit=8946
173+
-> Parallel Seq Scan on orders (cost=0.00..14154.33 rows=42 width=36) (actual time=0.141..14.359 rows=31.00 loops=3)
174+
Filter: (customer_id = 123)
175+
Rows Removed by Filter: 333302
176+
Buffers: shared hit=8946
177+
Planning Time: 0.062 ms
178+
Execution Time: 18.625 ms
179+
```
180+
181+
`18.625ms`で応答しており、Index Scanよりも大幅に遅い結果になりました。
182+
183+
## PostgreSQL 17 の場合
184+
185+
次は同様の検証をPostgreSQL 17にて実施してみます。
186+
187+
### 下準備
188+
189+
以下コマンドでコンテナを立ち上げた後は18の時と同じ手順でデータを投入していきます。
190+
191+
```sh
192+
docker run --name pg17-handson -e POSTGRES_PASSWORD=mysecretpassword -p 5433:5432 -d postgres:17
193+
```
194+
195+
データが揃ったところで実際に検索してみます。
196+
197+
>最も大きな変更点として、PostgreSQL 18からEXPLAIN ANALYZEを実行すると、バッファ使用量が自動的に表示されるようになりました。これまではBUFFERSオプションを明示的に指定する必要がありましたが、18からは標準で出力されます。
198+
199+
[先日の山本さんの記事](/articles/20251008a/)にて触れられてましたが、PostgreSQL 17時点では`EXPLAIN ANALYZE`のみではバッファ使用量が出力されないので、Buffersオプションを付けて実行します。
200+
201+
```sh
202+
postgres=# EXPLAIN (ANALYZE,BUFFERS) SELECT * FROM orders WHERE customer_id = 123;
203+
QUERY PLAN
204+
-------------------------------------------------------------------------------------------------------------------------------------------
205+
Index Scan using idx_orders_status_customer on orders (cost=0.42..12020.71 rows=100 width=36) (actual time=0.015..2.206 rows=95 loops=1)
206+
Index Cond: (customer_id = 123)
207+
Buffers: shared hit=1120
208+
Planning:
209+
Buffers: shared hit=5
210+
Planning Time: 0.077 ms
211+
Execution Time: 2.220 ms
212+
```
213+
214+
`2.220ms`で応答しました。
215+
216+
次に、Seq Scan時の応答性能を確認しておきます。
217+
218+
```sh
219+
postgres=*# SET LOCAL enable_indexscan = off;
220+
SET
221+
postgres=*# SET LOCAL enable_bitmapscan = off;
222+
SET
223+
postgres=*# EXPLAIN (ANALYZE,BUFFERS) SELECT * FROM orders WHERE customer_id = 123;
224+
QUERY PLAN
225+
-----------------------------------------------------------------------------------------------------------------------
226+
Gather (cost=1000.00..15162.33 rows=100 width=36) (actual time=0.269..18.116 rows=95 loops=1)
227+
Workers Planned: 2
228+
Workers Launched: 2
229+
Buffers: shared hit=8944
230+
-> Parallel Seq Scan on orders (cost=0.00..14152.33 rows=42 width=36) (actual time=0.281..14.290 rows=32 loops=3)
231+
Filter: (customer_id = 123)
232+
Rows Removed by Filter: 333302
233+
Buffers: shared hit=8944
234+
Planning Time: 0.066 ms
235+
Execution Time: 18.132 ms
236+
```
237+
238+
結果は`18.132ms`でした。試行回数は少ないですが、バージョン18と大きな乖離があるわけではないと考えられます。
239+
240+
## 結果の考察
241+
242+
改めてバージョン18および17で実施した検証結果をまとめると以下の通りです。
243+
244+
| Type | ver.18 | ver.17 |
245+
|:-----------|-----------:|-----------:|
246+
| Index Scan | 0.276 ms | 2.220 ms |
247+
| Seq Scan | 18.625 ms | 18.132 ms |
248+
249+
Index Scan性能は約8倍の差がありますね。18単体の結果を見た時点ではスキップスキャンによって高速化してるのかが正直分かりづらかったですが、こうして17と比較するとスキップスキャンが機能していることが確認できます。
250+
251+
# まとめ
252+
253+
PostgreSQL 18で追加されたスキップスキャン機構により、Index Scan時のクエリ応答性能が向上しました。また、複合インデックスを有効活用できるシーンが増えたことにより、不要なインデックスを削除でき、ディスク容量を節約するとともに登録・更新時の性能の向上も期待できます。
254+
255+
この新機能の特徴をしっかりとおさえた上で、インデックス設計および性能検証を行っていきましょう。
29.3 KB
Loading

source/images/2025/20251009a/thumbnail.jpg:Zone.Identifier renamed to source/images/2025/20251014a/thumbnail.jpg:Zone.Identifier

File renamed without changes.
119 KB
Loading
File renamed without changes.

0 commit comments

Comments
 (0)