ウィンドウ関数の実行内部とフレーム計算
ウィンドウ関数がなぜ速くも遅くもなるのかが分かります。PARTITION・ORDER・フレームの物理実行と、累積/スライディングの増分計算、ソート共有の最適化原理を押さえれば、重い集計クエリの正体が読めます。
- 1.ウィンドウ関数は GROUP BY と違い行を畳まない。PARTITION BY でパーティションへ分割し、ORDER BY で各パーティション内を整列したうえで、各行のフレーム(評価対象の行範囲)に対し関数を適用する。物理的には Sort → WindowAgg の二段が基本。
- 2.累積(RANGE/ROWS UNBOUNDED PRECEDING)は集約状態を持ち越す O(N) の増分計算。固定幅のスライディングウィンドウは、出ていく行を引き SUM や追加で running 状態を更新するが、MAX/MIN など逆操作できない集約は単調デックや再走査が要る。
- 3.複数のウィンドウ関数が同じ PARTITION/ORDER を共有すれば、ソートは1回で済み WindowAgg をパイプライン化できる。並び順が違うと別ソートが要り、ここが重いウィンドウクエリの主因。
ウィンドウ関数は「畳まない集約」である
SUM(...) OVER (...) のようなウィンドウ関数は、集約に似ていながら決定的に違う性質を持ちます。GROUP BY がグループを1行へ畳むのに対し、ウィンドウ関数は入力の行数をそのまま保ったまま、各行に「その行から見た集約値」を付け足します。この「行を消さずに各行ごとへ集約を配る」という要求が、実行エンジンに特有の物理戦略を強います。本稿は PARTITION BY / ORDER BY / フレーム指定がどう物理実行へ落ち、累積・スライディングをどう増分計算し、ソートをどう共有して速くするかを原理から解説します。
論理的には、各出力行 i に対して「フレーム」と呼ばれる行範囲 frame(i) が定まり、関数はその範囲に適用されます。素朴に書けば各行ごとにフレームを舐め直す O(N^2) ですが、実エンジンはこれを増分計算で O(N) 付近へ落とします。その鍵が、パーティション分割と整列、そしてフレームの単調性です。
三層構造:パーティション・整列・フレーム
ウィンドウ指定 OVER (PARTITION BY p ORDER BY o frame_clause) は、独立した三つの層に分解できます。
- PARTITION BY: 行を
pの値ごとに互いに独立なパーティションへ分ける。パーティション間に計算依存はなく、各パーティションは完全に分離して処理できる。GROUP BYと同じ分割だが、畳まずに保持する点が違う。 - ORDER BY: 各パーティション内の行に順序を与える。
ROW_NUMBERやLAGのような位置依存関数、およびRANGE/ROWSフレームの「前/後」はこの順序が前提。 - フレーム指定: 整列済みパーティション内で、現在行を基準にどの行範囲を集約対象とするかを定める。
ROWS BETWEEN ... AND ...は物理的な行数で、RANGE BETWEEN ... AND ...は順序キーの値で範囲を切る。
物理プランの基本形は、この三層をそのまま反映して Sort(p, o で整列)→ WindowAgg(パーティション境界を検出しフレーム計算) の二段になります。
-- 物理プランの基本形
WindowAgg -- パーティション境界を検出し各行へ関数適用
└─ Sort (PARTITION BY 列, ORDER BY 列) -- p で寄せ、その中で o に整列
└─ Scan / 下位プラン
WindowAgg は整列済み入力を1パスで流し、p の値が変わった点をパーティション境界として検出します。境界をまたいで集約状態を持ち越さないことが正しさの条件で、境界で running 状態をリセットします。
PARTITION BY は同値の行を物理的に隣接させ、ORDER BY はその中の順序を確定させます。両方ともソートで一挙に満たせるため、エンジンは「p を主キー、o を副キーにした1回のソート」で三層のうち二層を片付けます。同じ並びを与えるなら既存のインデックス順スキャンでソートを省ける場合があり、その時 Sort ノードは消えて WindowAgg だけが残ります。
フレームの種類:ROWS と RANGE の決定的な違い
フレームは「現在行から見て集約に含める行範囲」です。指定の二大様式が ROWS と RANGE で、ピーク(同順位行)の扱いが本質的に異なります。
ROWS BETWEEN a PRECEDING AND b FOLLOWING: 物理的な行数でフレーム端を決める。現在行の前a行・後b行という、順序キーの値に依存しない固定幅。RANGE BETWEEN a PRECEDING AND b FOLLOWING: 順序キーの値でフレーム端を決める。a PRECEDINGは「現在行のキー値マイナスa以上」の行すべてを含むため、同じキー値の行(ピア)は一括で入る。
この差が顕著に出るのが RANGE のデフォルト、すなわち RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW です。ここで CURRENT ROW は「現在行のキー値と等しい行すべて(ピア群)の末尾まで」を意味し、同順位の行は全員が同じ累積値を共有します。ROWS で同じ書き方をすると、ピアを区別して1行ずつ進むため値が変わります。
-- 同じ ORDER BY o でもフレーム様式で結果が変わる
ROWS UNBOUNDED PRECEDING AND CURRENT ROW : 物理行で1行ずつ累積(ピアを区別)
RANGE UNBOUNDED PRECEDING AND CURRENT ROW : ピア群末尾まで累積(同値は同じ値)
ORDER BY を伴うウィンドウでフレーム句を省くと、多くの RDB は RANGE UNBOUNDED PRECEDING AND CURRENT ROW を既定として補います。意図せずピア単位の累積になり、同順位行が同じ値を持つことに驚くケースが頻発します。行単位の厳密な累積が欲しいなら明示的に ROWS を書く必要があります。GROUPS 様式(同順位を1グループとして数える第三の様式)を持つエンジンもあります。
増分計算:累積とスライディングをどう O(N) で回すか
各行でフレームを舐め直せば O(N^2) ですが、フレームには単調性があります。ORDER 整列済みなら、行を1つ進めるとフレームの両端も前方へしか動きません。これを使うと、前の行のフレーム状態から差分だけ更新できます。
累積(UNBOUNDED PRECEDING AND CURRENT ROW)は最も単純で、入ってくる行を集約状態へ足し込むだけです。フレーム左端は固定なので「引く」操作が要らず、純粋な running 集約になります。
-- 累積 SUM(フレーム左端は固定、入る行を足すだけ)
state = 初期値(0)
for row in partition (o 順):
state += row.val -- 右端が進む:1行を取り込む
出力[row] = state -- ROWS なら毎行、RANGE ならピア末尾で確定
固定幅のスライディングウィンドウ(例 ROWS BETWEEN 3 PRECEDING AND CURRENT ROW)は、右端が入るたびに左端から出る行を引くことで一定状態を保ちます。SUM・COUNT・AVG は加算の逆である減算で「出る行」を打ち消せるため、ウィンドウ幅に依存しない O(1) 更新で回せます。
-- スライディング SUM(出る行を引き、入る行を足す:O(1)/行)
for row in partition:
state += 入る行.val -- 右端を1つ進める
if 左端が範囲外:
state -= 出る行.val -- 左端を1つ進める(逆操作で打ち消す)
出力[row] = state
問題は 逆操作を持たない集約です。MAX / MIN は、現在の最大値が「出ていく行」だったとき、残りからの最大を引き算では復元できません。エンジンはこれを、フレーム内の候補を保つ**単調デック(monotonic deque)で O(1) 償却に保つか、状態を保持できない実装ではフレーム再走査(O(幅))**にフォールバックします。DISTINCT を伴う集約や中央値系も逆操作不可で、コストが跳ねやすい領域です。
スライディングウィンドウのコストは「集約が逆操作を持つか」で決まります。可逆(SUM/COUNT/AVG)なら出入りの差分で O(1)/行。非可逆(MAX/MIN/中央値/DISTINCT 系)は補助構造(デック・順序統計木)か再走査が要り、幅に比例する項が出ます。重いスライディングクエリを見たら、まず使っている集約が可逆かを確認するのが定石です。
位置依存関数とピア検出
ROW_NUMBER / RANK / DENSE_RANK / LAG / LEAD / NTILE は集約ではなく位置(順序)に依存する関数で、フレームではなく ORDER の順序そのものを使います。整列済み入力を流しながら、行カウンタやピア境界で値を決めます。
ROW_NUMBER: 行ごとに+1。ピアを区別する。RANK: 直前のピア群の行数ぶん飛ばす(同順位は同じ値、次は飛ぶ)。DENSE_RANK: ピア群を1つずつ詰める(飛ばさない)。LAG(x, k)/LEAD(x, k): 整列順でk行前/後の値を引く。
これらの正しさはピア(順序キーが等しい行群)の検出にかかっています。WindowAgg は ORDER キーの値が変わった点をピア境界として捉え、RANK 系や RANGE フレームの確定タイミングに使います。だから ORDER の整列はフレーム計算だけでなくランク付けの前提でもあります。
| 観点 | 累積(UNBOUNDED〜CURRENT) | 固定幅スライディング | 位置依存(RANK/LAG 等) |
|---|---|---|---|
| フレーム左端 | 固定 | 現在行に追従して前進 | フレーム不使用(順序のみ) |
| 更新コスト | 入る行を足すだけ O(1) | 可逆なら O(1)/非可逆は幅依存 | カウンタ・ピア境界で O(1) |
| 逆操作の要否 | 不要(引かない) | 可逆集約で有利 | 該当なし |
| ピア(同順位) | RANGE は同値で同じ値 | ROWS は物理行で区別 | RANK は飛ぶ/DENSE は詰める |
| 律速 | 1パスの整列+走査 | 補助構造の有無 | 整列順の確定 |
ソート共有:複数ウィンドウを1回の整列でさばく
ウィンドウクエリが重くなる最大の要因はソートです。WindowAgg 自体は整列済み入力に対し基本1パス O(N) ですが、その前段のソートは O(N log N) で、メモリを超えれば外部ソートのスピルが走ります。だから最適化の主戦場は「ソートを何回やるか」です。
鍵は、複数のウィンドウ関数が同じ PARTITION BY と ORDER BY を共有するなら、ソートは1回でよい点です。同じ並びの上に複数の WindowAgg を積み重ね(あるいは1つの WindowAgg 内で複数関数を評価し)、整列を共有できます。
-- 同じ OVER を共有:ソート1回で2関数を評価
SUM(x) OVER (PARTITION BY p ORDER BY o)
ROW_NUMBER() OVER (PARTITION BY p ORDER BY o)
→ Sort(p,o) 1回 → WindowAgg で両方を一緒に計算
逆に、並び順の異なるウィンドウが混在すると、エンジンはウィンドウごとに別のソートを入れざるを得ません。OVER (ORDER BY a) と OVER (ORDER BY b) が同一クエリにあれば、最低でも2回ソートが走ります。オプティマイザはソート回数が減るよう WindowAgg の評価順を並べ替えますが、根本的に並びが両立しなければソートは増えます。
1つのクエリに 並び順の異なる OVER 句を多数書くと、それぞれにソートが積まれ、各ソートがスピルすれば I/O が掛け算で効きます。緩和策は (1) 可能な限り PARTITION/ORDER を揃えてソートを共有する、(2) 主要なウィンドウの並びに合うインデックスを用意してソート自体を消す、(3) work_mem 系を見直して in-memory ソートに収める、の三つです。実行計画で WindowAgg の下に Sort が何個あるかが直接の指標になります。
実行計画の読み方とチューニング
PostgreSQL の EXPLAIN ANALYZE では、WindowAgg ノードと、その下の Sort ノードに Sort Method(quicksort=in-memory、external merge=スピル)が出ます。WindowAgg が複数積まれていれば、それは並びの異なるウィンドウを別々に処理している合図です。チューニングの着眼点は次の通りです。
- Sort の数: WindowAgg 直下の Sort を数える。同じ
p, oに揃えられないか、評価順の見直しで共有できないかを検討する。 - Sort Method:
external mergeが出たらスピル。work_memを増やして in-memory に収まれば桁で速くなることがあるが、同時実行時のメモリ枯渇に注意。 - インデックス順スキャン: ウィンドウの
ORDER BYに一致するインデックスがあれば Sort ごと消える。WindowAgg が Index Scan に直結していれば理想形。 - フレームと集約の相性: 非可逆集約(MAX/MIN/DISTINCT 系)の固定幅スライディングは幅依存コストが出やすい。可逆集約への置き換えやフレーム様式の見直しを検討する。
ウィンドウ関数は論理的にはベクトル化実行のようなバッチ処理とも組み合わさり、整列済みパーティションをバッチで流して関数適用を償却するエンジンもあります。実行モデル全体の位置づけはクエリ実行モデル、ソートを消す計画選択はオプティマイザの内部もあわせて参照してください。
ウィンドウ関数は行を畳まない、物理的には Sort(p, o)→ WindowAgg の二段、を即答できるように。ROWS は物理行数・RANGE はキー値(ピアを一括)でフレーム端を切る違いが頻出。スライディングの計算量は集約が可逆(SUM/COUNT/AVG は O(1))か非可逆(MAX/MIN は補助構造か再走査)かで決まる。重さの主因は並びの異なる OVER ごとのソートで、PARTITION/ORDER を揃えるかインデックスでソートを消すのが基本対策、と説明できれば十分です。
まとめ
- ウィンドウ関数は GROUP BY と違い行を畳まず、各行へ「その行から見た集約・順位」を配る。物理形は Sort → WindowAgg の二段で、ソートが PARTITION(寄せ)と ORDER(整列)の二層を一挙に満たす。
- フレームは集約対象の行範囲。
ROWSは物理行数、RANGEは順序キーの値で端を切り、RANGEはピア(同順位)を一括で含むため同値行が同じ値になる。ORDER 付きでフレーム省略時の既定はRANGE UNBOUNDED PRECEDING AND CURRENT ROW。 - 累積は左端固定で足すだけ
O(1)。固定幅スライディングは可逆集約(SUM/COUNT/AVG)なら出入りの差分でO(1)、非可逆(MAX/MIN/DISTINCT 系)は単調デックや再走査で幅依存コストが出る。 RANK/LAG等の位置依存関数は順序とピア境界だけで決まり、フレームを使わない。- 重いウィンドウクエリの主因は並びの異なる OVER ごとのソート。PARTITION/ORDER を揃えてソートを共有する、インデックス順でソートを消す、
work_memを見直してスピルを避ける、が基本対策。実行計画では WindowAgg 直下の Sort の数と Sort Method を見る。
データベース Article
ウィンドウ関数の実行内部とフレーム計算を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
RDB
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
累積(RANGE/ROWS UNBOUNDED PRECEDING)は集約状態を持ち越す O(N) の増分計算。固定幅のスライディングウィンドウは、出ていく行を引き SUM や追加で running 状態を更新するが、MAX/MIN など逆操作できない集約は単調デックや再走査が要る。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「RDB / ウィンドウ関数」に近いか確認する。
- 強みである「ウィンドウ関数は GROUP BY と違い行を畳まない。PARTITION BY でパーティションへ分割し、ORDER BY で各パーティション内を整列したうえで、各行のフレーム(評価対象の行範囲)に対し関数を適用する。物理的には Sort → WindowAgg の二段が基本。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。