←記事一覧

SQL はどうやってディスク上のバイト列に届くのか — クエリーエンジンをスクラッチで実装する

1 本の SELECT 文が、字句解析・構文解析・束縛・論理プラン・カーディナリティー推定・コストベース最適化・物理プラン・Volcano 実行・アクセスメソッド・バッファープール・スロット付きページを経て pread(2) に到達するまでを、各段を Python で実装しながら追いかけます。work_mem を超えたときの溢れ方と、並列プランのコスト計算も扱います。

データベースPostgreSQLクエリ最適化パーサ実行エンジンストレージ

この記事で追いかけるもの

SELECT name FROM users WHERE age > 30 と打ち込むと、結果が返ってきます。その間に起きていることを、途中で「ここから先はブラックボックス」と済ませずに最後まで辿ります。

出発点はただの文字列です。終着点は「ファイル記述子 42 のオフセット 34,988,032 から 8192 バイト読み、そのうち 48 バイトを構造体として解釈する」という具体的な命令です。その間には十数段の変換があり、各段が固有の中間表現を持っています。

SQL 1本がバイト列に届くまでの全工程

同じ1本のクエリーが、テキスト → トークン → AST → 論理プラン → 物理プラン → 実行 → ページ読み出しへと姿を変えていく様子を、各段階の中間表現とともに追跡します。

フロントエンドSQL テキスト1 / 13
クライアントから届いたバイト列
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id
WHERE u.age > 30 AND o.total > 100
ORDER BY o.total DESC
LIMIT 10;

この時点ではただの文字列です。データベースにとっては users も u.age も意味を持たない文字の並びでしかありません。

1 / 13

全体の構造はこうなっています。上半分がクエリーを理解する層、下半分がデータに触る層です。

点線はデータの流れではなく参照を表します。カタログと統計情報は、上から下へ流れる本流の外側から参照されます。プランキャッシュを通る経路は、論理プランから最適化までの段を飛ばします。同じ SQL が 2 度目に来たら、束縛の直後にキャッシュ済みの物理プランを使えるからです(第 9 章)。

各段は Python で実装し、実装のスタイルは PostgreSQL に寄せます。pd_lower、t_hoff、clock-sweep のような実際の名前を使っておくと、あとで PostgreSQL のソースを読むときに対応を取りやすくなります。

主筋は、上のデモと同じ結合クエリー SELECT u.name, o.total FROM users AS u JOIN orders AS o ON u.id = o.user_id WHERE u.age > 30 AND o.total > 100 ORDER BY o.total DESC LIMIT 10(以下、例題)を上から下まで追うことです。途中で GROUP BY の集約、NULL の三値論理、work_mem を超えたときの挙動、プランのキャッシュ、並列実行のコスト計算といった横道にも入ります。どれも「本番でクエリーが突然遅くなる」理由に直接関わります。

インデックスそのものの構造(B+ 木・LSM-Tree)についてはデータベースインデックスの仕組みで扱っているので、この記事では「インデックスをどう使うか」に集中します。

動画で読む4:21合成音声ナレーション・字幕付き
文字起こしを表示

1本のSELECT文は、どうやってディスク上のバイト列まで届くのでしょうか。文字列からpread(2)まで、クエリエンジンの層を上から下へたどります。

出発点は、ただのSQLテキストです。最初に字句解析が、文字列をトークンへ切ります。ルールは最長一致です。>=は1トークン、空白なしのage30も1つの識別子です。次に構文解析が、再帰下降とPratt構文解析で、未解決のASTを作ります。

ASTはまだ、書かれた形を表すだけです。users表やage列があるかは知りません。束縛でカタログを引き、列名は表番号と列番号という位置、PostgreSQLのVarノードに変わります。その後はSQL構文を離れ、Scan、Filter、Joinなどの論理プランへ落ちます。

最初の改善は、意味を変えない書き換えです。ANDを分解し、述語をスキャンまで押し下げます。ただしOR、NULL、外部結合が絡むと、同じ変形は安全とは限りません。次に統計から行数を見積もります。ヒストグラムでage > 30の選択率は0.55、つまり10万行中55,000行です。

コストは時間ではなく相対値です。順次ページ1枚を1.0として、CPUとI/Oを足します。記事の例では、同じ索引でも、索引順と物理順の相関だけでヒープI/Oコストが75倍違います。結合では、入れ子ループ、マージ結合、ハッシュ結合を比べ、この例ではハッシュ結合が勝ちます。

3つ以上の表では、結合アルゴリズム以上に、結合順序が効きます。System R型の動的計画法は、部分集合ごとの最良プランだけを覚えるので、状態数は2のn乗で済みます。記事の例では、SQLに書いた順序のコストは605,000、最適順序は15,000で、約40倍安くなります。

ここで論理プランは、具体的な物理プランへ確定します。このSELECTは、ordersを順次スキャンし、usersをハッシュ表にして結合します。ORDER BYとLIMIT 10のおかげで、全体ソートではなくTop-Nヒープソートを選びます。

実行はVolcanoモデルです。すべての演算子がopen、next、closeを持ちます。next()呼び出しは上から下へ進み、行は下から上へ戻ってきます。ブロッキング演算子は、Sortなら入力すべてを、HashJoinならビルド側を読み切るまで、最初の行を返せません。

work_memを超えると、演算子ごとに壊れ方が違います。ソートは外部マージへ移り、比較コストにディスクI/Oが上乗せされるだけで、なだらかに劣化します。一方ハッシュは、入力を再パーティションして一時ファイルへ書き出すため、急に重くなります。並列実行では、ワーカーの行を集めるGatherを、プランの木のなるべく上に置き、渡す行数を減らすのが鍵です。

実行器の下では、アクセスメソッドが論理的な行を物理的な位置へ変えます。PostgreSQLのTIDは、ブロック番号とラインポインタ番号の組です。ページは必ずバッファプールを通り、ヒットなら再利用、ミスならclock-sweepで犠牲枠を選びます。

最下層では、1ページ8192バイトのスロット付きページに行が詰まっています。TIDがバイトオフセットではなくスロットを指すので、行はTIDを変えずにページ内で動けます。最後にブロック4271の読み取りは、fd 42、8192バイト、オフセット34,988,032のpreadになります。

SQL実行は、文字列から物理I/Oまで続く、変換の鎖です。記事では、トークナイザ、結合順序、Volcano、スピル、バッファプール、ページ構造をデモで試せます。


第 1 章 字句解析 — 文字列をトークンに切る

最初の仕事は、文字の並びを意味のある最小単位(トークン)に切り分けることです。

一見単純ですが、決めるべきことがあります。>= は 1 つのトークンか、> と = の 2 つか。age30 は識別子か、age と 30 か。この曖昧さは最長一致(maximal munch)で解決します。その位置から読めるだけ長く読むという規則で、>= は 1 トークンになり、age30 は 1 つの識別子になります。

from dataclasses import dataclass
 
KEYWORDS = {
    "SELECT", "FROM", "WHERE", "AND", "OR", "NOT",
    "JOIN", "ON", "AS", "ORDER", "BY", "ASC", "DESC", "LIMIT",
    "GROUP", "HAVING",
}
TWO_CHAR_OPS = (">=", "<=", "<>", "!=")
 
 
@dataclass(frozen=True)
class Token:
    kind: str   # KEYWORD / IDENT / NUMBER / STRING / OP / PUNCT / EOF
    text: str
    pos: int
 
 
def tokenize(sql: str) -> list[Token]:
    out: list[Token] = []
    i, n = 0, len(sql)
    while i < n:
        if sql[i].isspace():
            i += 1
            continue
 
        start, c = i, sql[i]
 
        if c.isalpha() or c == "_":
            while i < n and (sql[i].isalnum() or sql[i] == "_"):
                i += 1
            word = sql[start:i]
            kind = "KEYWORD" if word.upper() in KEYWORDS else "IDENT"
            out.append(Token(kind, word, start))
 
        elif c.isdigit():
            while i < n and sql[i].isdigit():
                i += 1
            # 小数点は「後ろに数字が続くとき」だけ取り込む。
            # そうしないと "t.1" のようなドット参照を壊す。
            if i + 1 < n and sql[i] == "." and sql[i + 1].isdigit():
                i += 1
                while i < n and sql[i].isdigit():
                    i += 1
            out.append(Token("NUMBER", sql[start:i], start))
 
        elif c == "'":
            i += 1
            while i < n and sql[i] != "'":
                i += 1
            if i >= n:
                raise SyntaxError("unterminated string literal")
            i += 1                       # 閉じ引用符
            out.append(Token("STRING", sql[start:i], start))
 
        elif sql[i:i + 2] in TWO_CHAR_OPS:   # ← 1文字演算子より先に試すのが肝
            out.append(Token("OP", sql[i:i + 2], start))
            i += 2
 
        elif c in "=<>+-*/%":
            out.append(Token("OP", c, start))
            i += 1
 
        elif c in ",().;":
            out.append(Token("PUNCT", c, start))
            i += 1
 
        else:
            raise SyntaxError(f"unexpected character {c!r} at position {i}")
 
    out.append(Token("EOF", "", n))
    return out

分岐の順序にも意味があります。sql[i:i+2] in TWO_CHAR_OPS の判定を 1 文字演算子の判定より前に置かないと、>= が > と = に割れます。

以下のデモで、この走査を 1 トークンずつ動かせます。

字句解析器を1トークンずつ動かす

SELECT name, age FROM users WHERE age >= 30; を左から走査し、どの規則でどのトークンが切り出されるかを追跡します。

入力文字列(バイト列)
SELECT name, age FROM users WHERE age >= 30;⏎
cursor = 0
keywordSELECT

英字で始まるので最長一致で読み取り、予約語表に「SELECT」があったので KEYWORD に分類

トークン列(1 個)
keyword: SELECT
1 / 12

予約語をどこで判定するか

上の実装では「英字で始まる語をまず全部読み、あとで予約語表を引く」という設計にしています。逆に「SELECT という文字列を先に探す」設計にすると、selected_at という列名が SELECT + ed_at に割れます。予約語の判定は、最長一致してから分類する順序で行います。

なお、SQL 標準の予約語は 200 語以上あり、実装ごとに差があります。PostgreSQL は語を「予約語」「予約語だが関数名・型名としては使える」「非予約語だが関数名・型名としては使えない」「非予約語」の 4 段階に分類しており、kwlist.h に一覧があります。この段階的な扱いのおかげで、value や name のような単語(どちらも非予約語)を列名として使えます。


第 2 章 構文解析 — トークン列を木にする

次は文法規則に従って木を組み上げます。SQL の文の骨格(SELECT … FROM … WHERE …)は再帰下降で素直に書けますが、式(a + b * c > d AND e)は演算子の優先順位を扱う必要があります。ここでは precedence climbing(Pratt 構文解析とも呼ばれます)を使います。

AST の定義

@dataclass
class ColRef:                       # 未解決の列参照
    table: str | None
    column: str
 
@dataclass
class Const:
    value: object
 
@dataclass
class BinOp:
    op: str
    left: object
    right: object
 
@dataclass
class FuncCall:
    name: str
    args: list
    star: bool = False       # COUNT(*) の * を表す
 
@dataclass
class TableRef:
    name: str
    alias: str | None
 
@dataclass
class JoinExpr:
    left: object
    right: object
    on: object
 
@dataclass
class SortKey:
    expr: object
    desc: bool
 
@dataclass
class Select:
    targets: list
    frm: object
    where: object | None
    group_by: list
    having: object | None
    order_by: list
    limit: int | None

パーサー本体

PRECEDENCE = {
    "OR": 1,
    "AND": 2,
    "=": 3, "<>": 3, "!=": 3, "<": 3, ">": 3, "<=": 3, ">=": 3,
    "+": 4, "-": 4,
    "*": 5, "/": 5,
}
 
 
class Parser:
    def __init__(self, tokens: list[Token]):
        self.toks, self.i = tokens, 0
 
    def peek(self) -> Token:
        return self.toks[self.i]
 
    def advance(self) -> Token:
        tok = self.toks[self.i]
        self.i += 1
        return tok
 
    def accept(self, text: str) -> Token | None:
        if self.peek().text.upper() == text.upper():
            return self.advance()
        return None
 
    def expect(self, text: str) -> Token:
        tok = self.accept(text)
        if tok is None:
            raise SyntaxError(f"expected {text!r}, found {self.peek().text!r}")
        return tok
 
    # ── 文の骨格:再帰下降 ────────────────────────────
    def parse_select(self) -> Select:
        self.expect("SELECT")
        targets = [self.parse_expr()]
        while self.accept(","):
            targets.append(self.parse_expr())
 
        self.expect("FROM")
        frm = self.parse_from()
 
        where = self.parse_expr() if self.accept("WHERE") else None
 
        group_by = []
        if self.accept("GROUP"):
            self.expect("BY")
            group_by.append(self.parse_expr())
            while self.accept(","):
                group_by.append(self.parse_expr())
 
        having = self.parse_expr() if self.accept("HAVING") else None
 
        order_by = []
        if self.accept("ORDER"):
            self.expect("BY")
            while True:
                expr = self.parse_expr()
                desc = self.accept("DESC") is not None
                if not desc:
                    self.accept("ASC")
                order_by.append(SortKey(expr, desc))
                if not self.accept(","):
                    break
 
        limit = int(self.advance().text) if self.accept("LIMIT") else None
        return Select(targets, frm, where, group_by, having, order_by, limit)
 
    def parse_from(self):
        node = self.parse_table_ref()
        while self.accept("JOIN"):
            right = self.parse_table_ref()
            self.expect("ON")
            node = JoinExpr(node, right, self.parse_expr())
        return node
 
    def parse_table_ref(self) -> TableRef:
        name = self.advance().text
        if self.accept("AS"):
            return TableRef(name, self.advance().text)
        if self.peek().kind == "IDENT":          # AS 省略形
            return TableRef(name, self.advance().text)
        return TableRef(name, None)
 
    # ── 式:優先順位登り ─────────────────────────────
    def parse_expr(self, min_prec: int = 0):
        left = self.parse_primary()
        while True:
            op = self.peek().text.upper()
            prec = PRECEDENCE.get(op, -1)
            if prec < 0 or prec < min_prec:
                return left
            self.advance()
            # 左結合なので、右辺には「自分より強い演算子」しか許さない
            right = self.parse_expr(prec + 1)
            left = BinOp(op, left, right)
 
    def parse_primary(self):
        tok = self.advance()
        if tok.kind == "NUMBER":
            return Const(float(tok.text) if "." in tok.text else int(tok.text))
        if tok.kind == "STRING":
            return Const(tok.text[1:-1])
        if tok.kind == "IDENT":
            if self.peek().text == "(":          # 集約関数の呼び出し
                self.advance()
                if self.accept("*"):
                    self.expect(")")
                    return FuncCall(tok.text.upper(), [], star=True)
                args = [self.parse_expr()]
                while self.accept(","):
                    args.append(self.parse_expr())
                self.expect(")")
                return FuncCall(tok.text.upper(), args)
            if self.peek().text == ".":
                self.advance()
                return ColRef(tok.text, self.advance().text)
            return ColRef(None, tok.text)
        if tok.text == "(":
            inner = self.parse_expr()
            self.expect(")")
            return inner
        raise SyntaxError(f"unexpected token {tok.text!r}")

parse_expr(prec + 1) の +1 が左結合を作っています。a - b - c を解析するとき、- の右辺で min_prec = 5 になるので、次の -(優先度 4)は右辺に取り込まれず、(a - b) - c になります。ここを parse_expr(prec) にすると右結合、つまり a - (b - c) になって計算結果が変わります。べき乗のような右結合演算子はこちらを使います。

AST は「書かれた通り」でしかない

この時点で得られる木は、構文を表しているだけです。users というテーブルが存在するかどうか、age という列があるかどうか、age > 30 の比較が型として妥当かどうかは、まだ検証していません。そのため SELECT nonexistent FROM nowhere も構文解析を通ります。


第 3 章 束縛 — 名前を実体に結びつける

意味を与えるのが束縛(binding)、あるいは意味解析と呼ばれる工程です。ここでカタログ(システムテーブル)を引き、名前を実体に対応づけます。

カタログ

@dataclass
class Column:
    name: str
    type: str            # "int" / "text" / "numeric"
 
@dataclass
class Relation:
    name: str
    columns: list[Column]
    n_rows: int          # pg_class.reltuples 相当
    n_pages: int         # pg_class.relpages 相当
    oid: int = 0         # pg_class.oid
    relfilenode: int = 0 # 実ファイル名。VACUUM FULL 等で変わりうる
 
CATALOG = {
    "users": Relation(
        "users",
        [Column("id", "int"), Column("name", "text"), Column("age", "int")],
        n_rows=100_000, n_pages=1_000, oid=16384, relfilenode=16384,
    ),
    "orders": Relation(
        "orders",
        [Column("id", "int"), Column("user_id", "int"), Column("total", "numeric")],
        n_rows=1_000_000, n_pages=10_000, oid=16390, relfilenode=16390,
    ),
}

レンジテーブルと Var

束縛後の列参照は、名前ではなく位置になります。PostgreSQL でいう Var(varno, varattno) です。varno は FROM 句に現れたテーブルの通し番号(レンジテーブルの添字)、varattno は列番号です。

@dataclass(frozen=True)
class Var:
    rel: int        # レンジテーブル内の添字
    attno: int      # 列番号(0起点。PostgreSQL 本体は1起点)
    type: str
    label: str      # 表示用。実行には使わない
 
 
COMPARISONS = {"=", "<>", "!=", "<", ">", "<=", ">="}
NUMERIC_TYPES = {"int", "numeric"}
 
 
class Binder:
    def __init__(self, catalog):
        self.catalog = catalog
        self.range_table: list[tuple[str, Relation]] = []
 
    def collect_from(self, node) -> None:
        if isinstance(node, TableRef):
            if node.name not in self.catalog:
                raise NameError(f'relation "{node.name}" does not exist')
            rel = self.catalog[node.name]
            self.range_table.append((node.alias or node.name, rel))
        elif isinstance(node, JoinExpr):
            self.collect_from(node.left)
            self.collect_from(node.right)
        else:
            raise TypeError(node)
 
    def bind_expr(self, expr) -> tuple[object, str]:
        if isinstance(expr, Const):
            if isinstance(expr.value, bool):
                return expr, "bool"
            if isinstance(expr.value, int):
                return expr, "int"
            if isinstance(expr.value, float):
                return expr, "numeric"
            return expr, "text"
 
        if isinstance(expr, ColRef):
            hits = []
            for rel_idx, (alias, rel) in enumerate(self.range_table):
                if expr.table is not None and expr.table != alias:
                    continue
                for attno, col in enumerate(rel.columns):
                    if col.name == expr.column:
                        hits.append(Var(rel_idx, attno, col.type,
                                        f"{alias}.{col.name}"))
            if not hits:
                raise NameError(f'column "{expr.column}" does not exist')
            if len(hits) > 1:
                raise NameError(f'column reference "{expr.column}" is ambiguous')
            return hits[0], hits[0].type
 
        if isinstance(expr, BinOp):
            left, lt = self.bind_expr(expr.left)
            right, rt = self.bind_expr(expr.right)
 
            if expr.op in ("AND", "OR"):
                if (lt, rt) != ("bool", "bool"):
                    raise TypeError(f"argument of {expr.op} must be boolean")
                return BinOp(expr.op, left, right), "bool"
 
            if lt != rt and not {lt, rt} <= NUMERIC_TYPES:
                raise TypeError(
                    f"operator does not exist: {lt} {expr.op} {rt}")
 
            result = "bool" if expr.op in COMPARISONS else lt
            return BinOp(expr.op, left, right), result
 
        if isinstance(expr, FuncCall):
            args = [self.bind_expr(a) for a in expr.args]
            bound = FuncCall(expr.name, [a for a, _ in args], expr.star)
            if expr.name == "COUNT":
                return bound, "int"                # COUNT は常に整数
            if not args:
                raise TypeError(f"{expr.name} requires an argument")
            arg_type = args[0][1]
            if expr.name == "AVG":
                return bound, "numeric"            # 整数の平均でも小数になる
            if expr.name in ("SUM", "MIN", "MAX"):
                # MIN/MAX は実際にこの型。SUM だけ簡略化してある——PostgreSQL は
                # 桁溢れを避けるため sum(int4) を int8、sum(int8) を numeric に広げる
                return bound, arg_type
            raise NameError(f'function "{expr.name}" does not exist')
 
        raise TypeError(f"cannot bind {expr!r}")

ここで初めて次のようなエラーが出せます。

  • column "ago" does not exist — カタログに無い列
  • column reference "id" is ambiguous — 両方のテーブルに id がある
  • operator does not exist: text > integer — 型が合わない

lt != rt and not {lt, rt} <= NUMERIC_TYPES の部分は暗黙の型変換を 1 行で済ませています。実際の PostgreSQL はもっと精緻で、pg_operator から候補となる演算子をすべて集め、pg_cast に定義された変換規則で優先順位づけをして解決します。o.total > 100 の 100 も、この仕組みで numeric に昇格されます。

束縛で書き方の違いが消える

束縛後は、u.age も(曖昧でなければ)age も同じ Var(0, 2, "int") になります。FROM users u と別名を付けた時点で users という名前は隠れるので、users.age は PostgreSQL でも上の束縛器でもエラーです。以降の最適化はこの正規化された表現の上で動くので、クエリーの書き方の違いを区別せずに済みます。


第 4 章 論理プラン — 関係代数に落とす

束縛済みの AST を、関係代数の演算子木に変換します。ここから先は SQL の構文から離れ、どの集合演算をどの順で適用するかだけを扱います。

@dataclass
class Scan:
    rel: int
    relation: Relation
    alias: str
    predicate: object | None = None    # 走査しながら評価する条件
 
@dataclass
class Filter:
    child: object
    predicate: object
 
@dataclass
class Join:
    left: object
    right: object
    predicate: object
 
@dataclass
class Project:
    child: object
    exprs: list
 
@dataclass
class Sort:
    child: object
    keys: list
 
@dataclass
class Aggregate:
    child: object
    group_keys: list
    aggs: list           # [FuncCall("COUNT", [], star=True), …]
 
@dataclass
class Limit:
    child: object
    n: int

変換規則は素直です。

SQL 句関係代数演算子
FROM tScan(t)
FROM a JOIN b ON pJoin(Scan(a), Scan(b), p)
WHERE pFilter(child, p)
GROUP BY gAggregate(child, [g], aggs)
HAVING hFilter(Aggregate(…), h)
SELECT e1, e2Project(child, [e1, e2])
ORDER BY kSort(child, [k])
LIMIT nLimit(child, n)

この表から、SQL のおなじみの規則の理由も読み取れます。WHERE は Aggregate の下に、HAVING は上に展開されます。WHERE は集約前の値しか見えない位置にあるので集約結果を参照できず、HAVING は参照できます。

例題を素直に組むと、こういう木ができます。

Limit(10)
└─ Sort(o.total DESC)
   └─ Project(u.name, o.total)
      └─ Filter(u.age > 30 AND o.total > 100)
         └─ Join(u.id = o.user_id)
            ├─ Scan(users AS u)
            └─ Scan(orders AS o)

演算子の入れ子の順番は SQL の評価順序(FROM → WHERE → SELECT → ORDER BY → LIMIT)に対応しています。SELECT 句が WHERE より上に来るので、WHERE の中では SELECT で定義した別名を参照できない、という SQL の制約もここから説明できます。

この木は正しい結果を返しますが、遅くなります。1,000,000 行の orders と 100,000 行の users を全部結合してから絞り込むからです。


第 5 章 書き換え — 意味を変えずに形を変える

最適化の第一段は、コストを計算するまでもなく明らかに得な変形です。関係代数の等価変換規則を使います。

述語プッシュダウン

σp(R⋈S)≡σp(R)⋈S(p が R の列だけを参照するとき)\sigma_{p}(R \bowtie S) \equiv \sigma_{p}(R) \bowtie S \quad (p \text{ が } R \text{ の列だけを参照するとき})

選択(フィルター)を結合より下に降ろします。結合に入る行が減るので、ほぼ常に得です。

実装では、AND を連言項に分解してから個別に降ろすことが要になります。u.age > 30 AND o.total > 100 はひとかたまりのままだと両方のテーブルを参照するので、結合より下には降ろせません。分解すれば、それぞれを自分のテーブルに降ろせます。

def conjuncts(expr) -> list:
    """AND の連鎖を平坦なリストに分解する。"""
    if isinstance(expr, BinOp) and expr.op == "AND":
        return conjuncts(expr.left) + conjuncts(expr.right)
    return [expr]
 
 
def and_all(preds: list):
    if not preds:
        return None
    node = preds[0]
    for p in preds[1:]:
        node = BinOp("AND", node, p)
    return node
 
 
def used_rels(expr) -> set[int]:
    """式が参照しているレンジテーブル添字の集合。"""
    if isinstance(expr, Var):
        return {expr.rel}
    if isinstance(expr, BinOp):
        return used_rels(expr.left) | used_rels(expr.right)
    return set()
 
 
def rels_of(node) -> set[int]:
    if isinstance(node, Scan):
        return {node.rel}
    if isinstance(node, Join):
        return rels_of(node.left) | rels_of(node.right)
    return rels_of(node.child)
 
 
def push_down(node, preds: list):
    """preds をできるだけ深い位置へ降ろす。
       戻り値は (書き換えた部分木, まだ降ろせなかった述語)。"""
    if isinstance(node, Scan):
        mine, stays = [], []
        for p in preds:
            (mine if used_rels(p) <= {node.rel} else stays).append(p)
        node.predicate = and_all(mine)
        return node, stays
 
    if isinstance(node, Join):
        node.left, preds = push_down(node.left, preds)
        node.right, preds = push_down(node.right, preds)
        here, stays = [], []
        scope = rels_of(node)
        for p in preds:
            (here if used_rels(p) <= scope else stays).append(p)
        return (Filter(node, and_all(here)) if here else node), stays
 
    raise TypeError(node)

WHERE を conjuncts() で分解して push_down() に渡すと、木はこうなります。

Limit(10)
└─ Sort(o.total DESC)
   └─ Project(u.name, o.total)
      └─ Join(u.id = o.user_id)
         ├─ Scan(users)   filter: age > 30
         └─ Scan(orders)  filter: total > 100

結合に入る行数は 100,000 × 1,000,000 から 55,000 × 100,000 に減りました(フィルター後の行数は第 6 章で推定します)。

OR は降ろせない

WHERE u.age > 30 OR o.total > 100 は分解できません。OR はどちらか一方が真であれば真なので、片方のテーブルだけで評価すると答えが変わります。実際にはこの形は結合後にしか評価できず、遅いクエリーのよくある原因です。対策としては、UNION に書き換える(各枝を独立に最適化できる)か、そもそも OR を避ける設計にします。

そのほかの書き換え

  • 列プルーニング — 上位で参照されない列を読み出し対象から外す。列指向ストレージで効果が最も大きく、SELECT * を避けるべき理由の一つになっています。
  • 定数畳み込み — WHERE x = 2 + 2 を WHERE x = 4 にするように、定数だけの式をプラン時に計算しておく。対象は不変(immutable)関数だけです。now() - interval '7 days' のように安定(stable)関数(同一文の中では値が変わらない)を含む式は、プラン時には畳み込みません。プランを使い回したとき(第 9 章)に古い値が残るからです。インデックススキャンの条件なら、実行開始時に 1 回だけ評価します。random() のような揮発性関数は、1 回の評価にまとめることもできません(関数の変動性分類)。
  • 述語推移閉包 — a.x = b.x AND a.x = 10 から b.x = 10 を導出して、b 側にも降ろせるようにする。
  • サブクエリーのデコリレーション — 相関サブクエリーを結合に書き換える。これは効果が最も大きい書き換えの一つです。

推移閉包は短いので実装を示します。「なぜか片方のテーブルだけインデックスが使われない」という問題の多くは、この書き換えが効いているかどうかで決まります。

def transitive_closure(preds: list) -> list:
    """x = y の同値クラスを作り、定数条件をクラス全体へ複写する。"""
    classes: list[set] = []                     # 同値と判明した Var の集合
    for p in preds:
        if (isinstance(p, BinOp) and p.op == "="
                and isinstance(p.left, Var) and isinstance(p.right, Var)):
            merged = {p.left, p.right}
            rest = []
            for c in classes:                   # 重なるクラスを統合する
                (merged.update(c) if merged & c else rest.append(c))
            classes = rest + [merged]
 
    derived = []
    for p in preds:
        if isinstance(p, BinOp) and isinstance(p.left, Var) and isinstance(p.right, Const):
            for c in classes:
                if p.left in c:
                    derived += [BinOp(p.op, v, p.right) for v in c if v != p.left]
    return preds + derived

a.x = b.x AND a.x = 10 に適用すると b.x = 10 が生まれ、push_down() がそれを b の走査直上まで降ろせるようになります。PostgreSQL ではこの仕組みを等価クラス(EquivalenceClass)と呼び、equivclass.c が担当します。

デコリレーションの例を挙げます。

-- 相関サブクエリ:外側の1行ごとに内側を評価する(ナイーブには N 回)
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 100);
 
-- 半結合(semi join)に書き換え:ハッシュ表を1回作れば済む
SELECT u.name FROM users u
SEMI JOIN orders o ON o.user_id = u.id AND o.total > 100;

SEMI JOIN は SQL の構文としては書けませんが、プランナー内部の演算子としては実在します。PostgreSQL の EXPLAIN で Hash Semi Join と表示されるのがこれです。

書き換えを壊すもの:三値論理と外部結合

ここまでの書き換えは、暗黙のうちに 2 つのことを仮定していました。述語は真か偽かのどちらかであることと、結合は交換できることです。SQL ではどちらも常に成り立つとは限りません。

1 つ目の問題は NULL と三値論理です。SQL の論理値は TRUE / FALSE / UNKNOWN の 3 つです。NULL > 30 は FALSE ではなく UNKNOWN になります。

AND    | T | F | U          OR     | T | F | U
-------+---+---+---        -------+---+---+---
   T   | T | F | U             T   | T | T | T
   F   | F | F | F             F   | T | F | U
   U   | U | F | U             U   | T | U | U

この真理値表から、実務に関わる帰結が 2 つ出てきます。

  1. WHERE が行を通すのは述語が TRUE のときだけで、UNKNOWN は落ちます。そのため述語の評価は if pred(row) is True と書くのが正確です(第 15 章で実ストレージ版の走査を書くときに、実際にそう書きます)。
  2. CHECK 制約は逆で、UNKNOWN を合格扱いにします。同じ式を書いても、置かれる場所によって NULL の扱いが反転します。

さらに NOT IN (… NULL を含むリスト) が常に空集合を返すのもこの帰結です。x NOT IN (1, NULL) は x <> 1 AND x <> NULL と同義で、右辺が常に UNKNOWN なので全体が TRUE になり得ません。このためプランナーは NOT IN を安全に反結合(anti join)へ変換できず、NOT EXISTS に比べて遅くなりがちです。

2 つ目の問題は外部結合です。述語プッシュダウンは内部結合にしか無条件では適用できません。

-- 元のクエリ:注文の無いユーザも残る
SELECT u.name, o.total
FROM users u LEFT JOIN orders o ON u.id = o.user_id
WHERE o.total > 100;          -- ← NULL 側を落とすので内部結合と同義になる
 
-- こちらは条件を WHERE(結合の外)へ移せない
SELECT u.name, o.total
FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.total > 100;

上の例は o.total > 100 が NULL を弾くので、プランナーはこれを内部結合に降格(join strength reduction)して自由に並べ替えられます。下の例は条件が ON 句にあるので、NULL 拡張された行を弾きません。結合は外部結合のまま残り、条件を結合の外(結合結果へのフィルター)に移すこともできません。移すと、該当する注文が無いユーザーが NULL 拡張されずに消えてしまいます。orders 側だけを参照する条件なので、結合の前に orders の走査で絞り込むことはできます。

外部結合は結合順序の自由度も奪います。(A⟕B)⋈C(A \mathbin{⟕} B) \bowtie C と A⟕(B⋈C)A \mathbin{⟕} (B \bowtie C) は一般に異なる結果になります。第 8 章の動的計画法は「どんな分割でも同じ結果になる」という内部結合の性質に依存しているので、外部結合が混ざるとそのままでは使えません。結合をグラフの辺ではなく超辺(hyperedge)で表し、「この結合をやる前にこれらのテーブルが揃っていなければならない」という依存関係を直接表現するのが、第 8 章で扱う DPhyp の発想です。


第 6 章 統計とカーディナリティー推定

ここからが最適化の本体で、クエリー最適化で最も壊れやすい部分でもあります。

コストを比較するには、各演算子が何行返すかを知る必要があります。しかし実際に実行するまで正確な行数は分かりません。そこで統計情報から推定します。

ヒストグラム

PostgreSQL は ANALYZE でテーブルをサンプリングし、pg_statistic に以下を記録します。

  • 最頻値(MCV)とその頻度 — 等値条件の選択率に使う
  • 等深度ヒストグラムの境界値 — 範囲条件の選択率に使う
  • n_distinct — 相異なる値の数(負値なら行数に対する比率)
  • 相関係数 — 列の物理的な並び順との相関。インデックススキャンのランダム I/O 量に効く

等深度ヒストグラムは「各バケットに同じ行数が入るように境界を決める」方式です。バケット幅が狭い区間は値が密集している区間を意味します。

@dataclass
class ColumnStats:
    n_distinct: int
    bounds: list         # 等深度ヒストグラムの境界値。len(bounds) - 1 個のバケット
 
 
def selectivity_gt(stats: ColumnStats, value) -> float:
    """col > value の選択率。バケット内は一様分布を仮定して線形補間する。"""
    bounds = stats.bounds
    nb = len(bounds) - 1                     # バケット数
    if value < bounds[0]:
        return 1.0
    if value >= bounds[-1]:
        return 0.0
    for i in range(nb):
        lo, hi = bounds[i], bounds[i + 1]
        if lo <= value < hi:
            above_in_bucket = (hi - value) / (hi - lo)
            return (nb - i - 1) / nb + above_in_bucket / nb
    return 0.0

users.age の十分位境界が次のようになっているとします。

age_stats = ColumnStats(
    n_distinct=63,
    bounds=[18, 22, 25, 27, 29, 31, 34, 38, 44, 53, 80],
)
selectivity_gt(age_stats, 30)     # → 0.55

計算を追うと、30 はバケット 4([29, 31))に入ります。バケット 5〜9 は丸ごと 30 より上なので、ここから 5/10=0.55/10 = 0.5 です。バケット 4 の中では (31−30)/(31−29)=0.5(31-30)/(31-29) = 0.5 が上側なので、0.5×0.1=0.050.5 \times 0.1 = 0.05 が加わります。合計は 0.550.55 で、55,000 行になります。

結合のカーディナリティー

等結合の推定には、教科書的にはこの式を使います。NDV\mathrm{NDV} は number of distinct values の略で、上の n_distinct のことです。

∣R⋈R.a=S.bS∣≈∣R∣⋅∣S∣max⁡(NDV(R.a),NDV(S.b))|R \bowtie_{R.a = S.b} S| \approx \frac{|R| \cdot |S|}{\max(\mathrm{NDV}(R.a), \mathrm{NDV}(S.b))}

def join_cardinality(left_rows, right_rows, ndv_left, ndv_right) -> float:
    """含有仮定(containment assumption)に基づく等結合の見積り。
       値の少ない側のすべての値が、多い側にも現れると仮定する。"""
    return left_rows * right_rows / max(ndv_left, ndv_right)

users はフィルター後 55,000 行、orders はフィルター後 100,000 行(total > 100 の選択率を 10% と仮定)で、NDV(users.id) = NDV(orders.user_id) = 100,000 なので、

55,000×100,000100,000=55,000\frac{55{,}000 \times 100{,}000}{100{,}000} = 55{,}000

推定が壊れるとき

この推定は 3 つの強い仮定に依存しています。

  1. 一様性 — ヒストグラムの各バケット内で値が一様に分布している
  2. 独立性 — 複数の条件の選択率は互いに独立で、掛け算できる
  3. 含有性 — 結合キーの値域が一方に含まれる

現実にはどれも成り立ちません。特に独立性の仮定は簡単に破れます。WHERE city = '横浜市' AND prefecture = '神奈川県' で、横浜市の行が全体の 1%、神奈川県の行が 5% だとします。独立と仮定すると選択率は 0.01×0.05=0.00050.01 \times 0.05 = 0.0005 ですが、実際には 0.010.01 です(横浜市なら神奈川県は確定するので)。20 倍の過小推定です。

誤差は結合を 1 段上がるごとに掛け算で伝播します。

推定誤差は結合を1段登るごとに掛け算で増える推定誤差は結合を1段登るごとに掛け算で増える×1基底テーブル×41回目の結合×162回目の結合×643回目の結合各段で推定が同じ向きに 4 倍ずれると仮定した場合

Leis らの論文 "How Good Are Query Optimizers, Really?"(PVLDB 9(3), 2015)は、実データ(IMDB)に対する 113 本の結合クエリー(Join Order Benchmark)を使い、PostgreSQL 9.4、研究用システムの HyPer、商用 3 製品について中間結果の推定行数を真の行数と比べました。どのシステムでも 1,000 倍以上ずれた推定が珍しくなく、誤差は結合の数とともに指数的に大きくなっていました。PostgreSQL では、結合 1 つの中間結果で推定の 16% が 10 倍以上外れ、その割合は結合 2 つで 32%、3 つで 52% に増えていました。論文はさらに、コストモデルの精度よりもカーディナリティー推定の精度のほうが、最終的なプランの質にはるかに大きく効くと結論づけています。

対策としては次のものがあります。

  • 多変量統計 — 相関する列の組に対して統計を取る。PostgreSQL の CREATE STATISTICS (dependencies, ndistinct, mcv) がこれです。
  • 適応的クエリー実行 — 実行中に実際の行数を観測し、プランを差し替える。Oracle の Adaptive Plans、SQL Server の Adaptive Joins、Spark AQE。
  • 学習ベースの推定器 — 実行履歴からカーディナリティーを学習する。研究段階の話題ですが、実運用に入りつつあります。

第 7 章 コストモデル — 何を数えるのか

行数が推定できたら、次はコストです。コストの単位は秒やミリ秒ではなく、無次元の相対値です。順次ページ読み 1 回を 1.0 とし、ほかの操作はその何倍かという比だけで表します。

import math
 
SEQ_PAGE_COST        = 1.0      # 順次読みでページを1枚読む
RANDOM_PAGE_COST     = 4.0      # ランダム読みでページを1枚読む
CPU_TUPLE_COST       = 0.01     # タプルを1件処理する
CPU_INDEX_TUPLE_COST = 0.005    # 索引エントリを1件処理する
CPU_OPERATOR_COST    = 0.0025   # 演算子や関数を1回評価する

ストレージへのランダムアクセスは、通常は順次アクセスの 4 倍よりはるかに高コストです。それでも random_page_cost の既定値が 4.0 と低めなのは、インデックス経由の読み取りなど、ランダムアクセスの大半がキャッシュに当たると見込んでいるからです(公式ドキュメント)。SSD 中心、あるいはほぼ全体がキャッシュに載るワークロードでは、実測に合わせて 1.1〜2 程度へ下げる(ランダムアクセスのペナルティーが実質ない場合だけ seq_page_cost と同じにする)のが一般的なチューニングです。この 1 つの定数が、インデックススキャンとシーケンシャルスキャンの分岐点を動かします。

走査コスト

def seq_scan_cost(rel: Relation, n_quals: int) -> float:
    """全ブロックを順に読み、全タプルに対して n_quals 個の条件を評価する。"""
    return (rel.n_pages * SEQ_PAGE_COST
            + rel.n_rows * CPU_TUPLE_COST
            + rel.n_rows * n_quals * CPU_OPERATOR_COST)
seq_scan_cost(CATALOG["users"],  1)   # 1,000 + 1,000 +   250 =  2,250
seq_scan_cost(CATALOG["orders"], 1)   # 10,000 + 10,000 + 2,500 = 22,500

この式は選択率に依存しません。1 行しか返さなくても全ブロックを読みます。逆にインデックススキャンは、おおむね選択率に比例してコストが増えます。この 2 本の線が交わる点が、プランが切り替わる境界です。

アクセスパス読み方I/O有利な条件落とし穴
Seq Scan全ブロックを順番に順次読み(先読みが効く)テーブルの大部分を返すとき、またはインデックスが無いとき1行だけ返す場合も全ブロックを読む
Index Scanインデックスを辿り、TID ごとにヒープを参照ランダム読み(1行につき最悪1ページ)選択率が十分低いとき行数が増えると、同じヒープページを何度も読むことがある
Index Only Scanインデックスだけを読み、ヒープには触れないインデックスページのみ必要な列がすべてインデックスに含まれるときPostgreSQL では可視性マップのビットが立っているブロックに限られる
Bitmap Heap ScanTID をビットマップに集めてから物理順にヒープを読むランダム読みを順次読みに変換中間的な選択率、複数インデックスの AND / ORビットマップが work_mem を超えるとページ単位に劣化する

インデックススキャンのコスト

もう一本の線を引きます。素直に考えると「nn 行取り出すならランダム読み nn 回」になりそうですが、実際には違います。同じページに複数の行が乗っているので、取り出す行が増えるほど「すでに読んだページ」に当たる確率が上がります。

この効果を定量化したのが Mackert と Lohman の式(1989。以下 ML 式)で、PostgreSQL はそのまま使っています。

def index_pages_fetched(tuples_fetched, pages, index_pages,
                        total_table_pages, effective_cache_size=524_288):
    """Mackert-Lohman。有限バッファのもとで実際に読むページ数。
       effective_cache_size の既定は 4GB = 524,288 ページ。"""
    T = float(pages) if pages > 1 else 1.0
    # クエリ内の全テーブルとこの索引でキャッシュを奪い合うと仮定し、T の割合で按分する
    total_pages = max(total_table_pages + index_pages, 1.0)
    b = max(1.0, math.ceil(effective_cache_size * T / total_pages))
 
    if T <= b:                                  # 表全体がキャッシュに収まる
        pf = (2.0 * T * tuples_fetched) / (2.0 * T + tuples_fetched)
        return T if pf >= T else math.ceil(pf)
 
    lim = (2.0 * T * b) / (2.0 * T - b)         # 再読みが発生し始める点
    if tuples_fetched <= lim:
        return math.ceil((2.0 * T * tuples_fetched) / (2.0 * T + tuples_fetched))
    return math.ceil(b + (tuples_fetched - lim) * (T - b) / T)

users(1,000 ページ / 100,000 行)から 5,000 行を取り出す場合を計算してみます。

PF=2×1,000×5,0002×1,000+5,000=1,428.6  ⟶  T=1,000 でクランプ\mathrm{PF} = \frac{2 \times 1{,}000 \times 5{,}000}{2 \times 1{,}000 + 5{,}000} = 1{,}428.6 \;\longrightarrow\; T = 1{,}000 \text{ でクランプ}

実際に読むのは多くても表全体の 1,000 ページで、5,000 回のランダム読みにはなりません。「1 行につき 1 ページ」という直感は、行数が増えると崩れます。

相関:同じインデックスでコストが 75 倍違う

もう一つの要因が、インデックスの順序とヒープ(テーブルの行を格納するファイル)の物理順の相関です。PostgreSQL は 2 つの端点を計算し、その間を補間します。

def index_scan_cost(rel, index_pages, selectivity, correlation=0.0,
                    random_page_cost=RANDOM_PAGE_COST, total_table_pages=11_000):
    tuples = rel.n_rows * selectivity
 
    # 索引側:該当するエントリを読む
    idx_pages = math.ceil(selectivity * index_pages)
    idx_cost = (idx_pages * random_page_cost
                + tuples * (CPU_INDEX_TUPLE_COST + CPU_OPERATOR_COST))
 
    # ヒープ側:無相関と完全相関の2つの端点を相関の二乗で補間する
    pf = index_pages_fetched(tuples, rel.n_pages, index_pages, total_table_pages)
    max_io = pf * random_page_cost                       # correlation = 0
    seq_pages = math.ceil(selectivity * rel.n_pages)
    min_io = random_page_cost + max(seq_pages - 1, 0) * SEQ_PAGE_COST  # = 1
    heap_cost = max_io + correlation ** 2 * (min_io - max_io)
 
    return idx_cost + heap_cost + tuples * CPU_TUPLE_COST

選択率 5%(5,000 行)で相関だけを動かすとこうなります。

相関ヒープ I/O コスト意味
0.04,0001,000 ページを全部ランダムに読む
0.53,013中間(二乗で補間するので 0.5 でも 0 寄り)
1.05350 ページを順次に読む(4.0 + 49 × 1.0)

同じインデックスで同じ行数を取り出しても、相関の違いだけで 75 倍の差がつきます。CLUSTER で物理順を揃える効果も、時系列データが挿入順のまま昇順に並ぶ効果も、この補間式 1 行に表れています。

重みに相関の二乗を使うことに、厳密な導出はありません。costsize.c のコメントには次のようにあります。

We currently interpolate linearly between the estimates based on the correlation squared (XXX is that appropriate?).

実際に使われているデータベースのコストモデルにも、開発者自身が妥当かどうか確信を持てていない近似が残っています。

交差点を求める

users(age) のインデックスを 275 ページ、相関 0 として、Seq Scan の 2,250 と比べます。

選択率取得行数ML 式のページ数Index Scan判定
0.1%10096390Index Scan の勝ち
0.5%5004001,617Index Scan の勝ち
0.8%8005722,314ほぼ互角
1.0%1,0006672,698Seq Scan の勝ち
5.0%5,0001,0004,144Seq Scan の勝ち

HDD 前提の random_page_cost = 4.0 では、交差点はおよそ 0.8% です。ここを SSD 向けの 1.1 に下げると、同じテーブル・同じインデックスで交差点は数十%のオーダーまで上がります。ランダム読みが安くなると、ML 式のクランプによってインデックススキャンの I/O は頭打ちになり、全行に CPU コストを払い続ける Seq Scan のほうが不利になるからです。

(このモデルはインデックス側の下降コストや B+ 木の高さを省略しているので、実際の PostgreSQL の交差点はこれより低めに出ます。数値そのものより、定数 1 つで境界が桁単位で動くことのほうが要点です。)

結合コスト

1 つのテーブルをどう読むかは決まりました。次は 2 つの入力をどう突き合わせるかです。結合アルゴリズムごとにコスト式を用意します。

@dataclass
class Plan:
    rows: float
    cost: float
    desc: str
 
 
def hash_join_cost(build: Plan, probe: Plan, out_rows: float) -> float:
    return (build.cost + probe.cost
            + build.rows * CPU_OPERATOR_COST     # ハッシュ表の構築
            + probe.rows * CPU_OPERATOR_COST     # プローブ
            + out_rows   * CPU_TUPLE_COST)       # 出力タプルの生成
 
 
def sort_cost(input_cost: float, rows: float, limit: int | None = None) -> float:
    """PostgreSQL に倣い、比較1回のコストを 2 * cpu_operator_cost とする。
       LIMIT が付くと上位 n 件だけを保つヒープになり、log の底が小さくなる。"""
    comparison_cost = 2.0 * CPU_OPERATOR_COST
    keep = rows if limit is None else min(rows, limit)
    return input_cost + comparison_cost * rows * math.log2(max(keep, 2.0))
 
 
def merge_join_cost(left: Plan, right: Plan, out_rows: float) -> float:
    ls = sort_cost(left.cost, left.rows)
    rs = sort_cost(right.cost, right.rows)
    return (ls + rs
            + (left.rows + right.rows) * CPU_OPERATOR_COST
            + out_rows * CPU_TUPLE_COST)

(sort_cost の有界ケースは簡略化してあります。PostgreSQL の cost_tuplesort は top-N ヒープソートに対して Nlog⁡22KN \log_2 2K を使います。定数倍の差なので本記事で比べるプランの順位は変わりませんが、実際の EXPLAIN 出力とは数字が一致しません。)

例題の結合について、3 つの候補を比べます。

候補内訳コスト
NestedLoop(users × IndexScan)2,250+55,000×48.1752{,}250 + 55{,}000 \times 48.1752,651,875
MergeJoin(両側ソート)6,580+30,805+9386{,}580 + 30{,}805 + 93838,323
HashJoin(build = users)2,250+22,500+9382{,}250 + 22{,}500 + 93825,688

NestedLoop の 48.175 は「外側 1 行あたりの内側インデックス参照コスト」です。users 1 人につき平均 10 件の orders があるので、インデックスのページ 2 枚とヒープのページ 10 枚をランダムに読み、CPU コストは 10×(0.005+0.01+0.0025)10 \times (0.005 + 0.01 + 0.0025) で、合計は 12×4.0+0.175=48.17512 \times 4.0 + 0.175 = 48.175 です。これを 55,000 回繰り返すので、ほかの 2 候補より桁違いに高くなります。

一方、WHERE u.id = 42 のように外側が 1 行なら、同じ NestedLoop が最速になります。アルゴリズムに絶対的な優劣はなく、行数によって順位が入れ替わります。コストベース最適化が必要なのはこのためです。

ハッシュ結合の build 側の選び方

hash_join_cost は build と probe を対称に扱っているように見えますが、実際には build 側を小さくすべき理由があります。ハッシュ表はメモリー(PostgreSQL なら work_mem)に収める必要があり、収まらなければ grace hash join に切り替わります。両側をハッシュ値でパーティションに分割して一時ファイルに書き出し、パーティション単位で処理する方式で、追加の I/O が発生します。プランナーは build 側の推定サイズが work_mem を超えるとバッチ数を増やす形でこれをコストに織り込みます。

起動コストと総コスト

ここまでコストをスカラー 1 つとして扱ってきましたが、EXPLAIN の出力はこうなっています。

Hash Join  (cost=2387.50..25687.50 rows=55000 width=36)

数字が 2 つあります。前が起動コスト(startup cost)で、最初の 1 行を返すまでに支払うコストです。後が総コスト(total cost)で、全行を返し切るまでのコストです。

この 2 つを分けて持つのは、上位ノードが全行を必要としない場合があるからです。部分取得のコストは、costsize.c の冒頭に書かれた式で求められます。

@dataclass
class CostedPlan:
    rows: float
    startup_cost: float
    total_cost: float
 
 
def cost_for(plan: CostedPlan, tuples_to_fetch: float) -> float:
    """部分取得のコスト。起動コストはどうせ払うので按分しない。"""
    frac = min(tuples_to_fetch, plan.rows) / plan.rows
    return plan.startup_cost + (plan.total_cost - plan.startup_cost) * frac

ブロッキングとは「起動コストが高い」こと

ハッシュ結合の起動コストには、build 側の総コストが丸ごと入ります。ハッシュ表を作り終わるまで 1 行も出せないからです。このように「入力を読み切るまで 1 行も返せない」性質を持つ演算子をブロッキングと呼びます(実装は第 10 章)。

def hash_join_startup(build: Plan) -> float:
    """build を読み切り、ハッシュ表を埋め切るまでが startup。"""
    return build.cost + build.rows * CPU_OPERATOR_COST

例題のハッシュ結合なら起動コストは 2,250+55,000×0.0025=2,387.52{,}250 + 55{,}000 \times 0.0025 = 2{,}387.5、総コストは 25,687.525{,}687.5 です。プランナーにとっての「ブロッキング」は、この 2 つの数字の組で定義されています。

(PostgreSQL の initial_cost_hashjoin はさらに cpu_tuple_cost を build 行ごとに課すので 2,250+55,000×(0.0025+0.01)=2,937.52{,}250 + 55{,}000 \times (0.0025 + 0.01) = 2{,}937.5 になります。ここでは本章の簡易モデルに合わせています。)

LIMIT を付けると順位がひっくり返る

第 9 章では「LIMIT が下位のアルゴリズム選択を変える」と述べますが、この主張はここまでの 2 つの数字だけで確かめられます。

クエリープラン計算コスト
… ORDER BY o.total DESC LIMIT 10Sort がブロックstartup ≈ total26,601
… LIMIT 10(ORDER BY 無し)HashJoin2,387.5+23,300×1055,0002{,}387.5 + 23{,}300 \times \frac{10}{55{,}000}2,392
… LIMIT 10(ORDER BY 無し)NestedLoop + IndexScan0+2,651,875×1055,0000 + 2{,}651{,}875 \times \frac{10}{55{,}000}482

全行を返す場合は HashJoin(25,688)が NestedLoop(2,651,875)に 100 倍以上の差をつけて勝ちます。ところが 10 行だけでよければ順位が逆転します。NestedLoop は起動コストがほぼ 0 なので、必要な分だけ外側を回せば済むからです。

「LIMIT 10 を付けたら 1,000 倍遅くなった」という障害も、この仕組みで起こります。プランナーは「外側 10 行も回せば 10 行揃う」と見積もって起動コストがほぼ 0 の NestedLoop を選びますが、条件を満たす行が実際には稀だった場合、外側を最後まで走査することになります。カーディナリティー推定の誤りが最も高くつくのは、LIMIT が付いているときです。

なおカーソル(DECLARE … CURSOR)はこの仕組みを常時使います。cursor_tuple_fraction(既定 0.1)に従って「全体の 10% だけ取り出す」前提でプランを選ぶので、同じ SQL でもカーソル経由だと違うプランになることがあります。


第 8 章 結合順序 — 探索空間との戦い

3 テーブル以上の結合では、どの順で結合するかがアルゴリズム選択より大きな差を生みます。

探索空間の大きさ

nn 個のテーブルの結合順序は、左深木(left-deep tree)だけに限っても n!n! 通りあります。ブッシー木(bushy tree)まで含めると (2n−2)!(n−1)!\frac{(2n-2)!}{(n-1)!} 通りです。

テーブル数 n左深木 n!ブッシー木 (2n−2)!/(n−1)!DP 表の項目数 2ⁿ
2224
42412016
672030,24064
840,32017,297,280256
103,628,80017,643,225,6001,024

列挙する木の数は階乗的に増えますが、動的計画法が管理するのは部分集合の数 2ⁿ です。それでも指数的に増えるので、PostgreSQL は n が geqo_threshold(既定 12)以上になると遺伝的アルゴリズムに切り替えます。

全列挙は無理ですが、部分構造の最適性という性質が使えます。ある全体プランが {A,B,C}\{A, B, C\} の結合結果を中間結果として使っているなら、その中間結果を作る部分木を {A,B,C}\{A, B, C\} 単体の最適プランに差し替えても、コスト関数が単調なら全体のコストは悪化しません。そのため部分集合ごとに最適プランを 1 つだけ覚えておけば十分で、状態数は 2n2^n に落ちます。これが 1979 年の System R が導入した動的計画法です。

def dp_join_order(rels, cardinality, connected):
    """部分集合の小さい順に最適プランを確定していく(DPsize)。"""
    n = len(rels)
    best = {}
    for i in range(n):
        best[1 << i] = Plan(rows=rels[i].n_rows, cost=0.0, desc=rels[i].name)
 
    for size in range(2, n + 1):
        for mask in range(1, 1 << n):
            if bin(mask).count("1") != size:
                continue
            if not connected(mask):          # 直積になる部分集合は刈る
                continue
            rows = cardinality(mask)
            for right in submasks(mask):     # mask の真部分集合を列挙
                left = mask ^ right
                if left == 0 or right == 0:
                    continue
                if left not in best or right not in best:
                    continue
                if not has_join_edge(left, right):
                    continue
                cost = rows + best[left].cost + best[right].cost   # C_out
                if mask not in best or cost < best[mask].cost:
                    best[mask] = Plan(rows, cost,
                                      f"({best[left].desc} ⋈ {best[right].desc})")
    return best[(1 << n) - 1]
 
 
def submasks(mask: int):
    """mask の空でない真部分集合をすべて列挙する定番のイディオム。"""
    sub = (mask - 1) & mask
    while sub > 0:
        yield sub
        sub = (sub - 1) & mask

submasks のイディオムでは、(sub - 1) & mask によって mask の部分集合を降順に 1 つずつ辿れます。全部分集合の全部分集合を列挙する計算量は O(3n)O(3^n) です。

ここで使っているコストモデルは C_out で、中間結果の行数の総和です。走査コストや結合アルゴリズムのコストは無視しますが、結合順序の良し悪しはこれでかなり捉えられます。

この DP を 4 テーブル(users、orders、order_items、フィルター後 10 行の products)の結合で動かすと、次のようになります。

結合順序を動的計画法で決める

4テーブルの結合について、部分集合の小さい順に最適な部分プランを確定していきます。直積になる部分集合は探索空間から刈り取られます。

テーブルと推定行数
uusers10,000
oorders100,000
iorder_items500,000
pproducts10
結合述語と選択率
u.id = o.user_id1 / 10,000
o.id = i.order_id1 / 100,000
i.product_id = p.id1 / 1,000
サイズ 1 の部分集合

初期化:単一テーブルのアクセスパスを DP 表に登録します。ここでは走査コストを 0 とみなし、結合で生じる中間結果の行数だけを積み上げる C_out コストモデルを使います。

DP 表(確定済みの最適部分プラン)
部分集合推定行数コスト最適部分プラン
{u}10,0000u
{o}100,0000o
{i}500,0000i
{p}100p
1 / 13

デモでは、SQL に書いた順序 ((u⋈o)⋈i)⋈p((u \bowtie o) \bowtie i) \bowtie p のコストが 605,000、最適な ((i⋈p)⋈o)⋈u((i \bowtie p) \bowtie o) \bowtie u が 15,000 で、約 40 倍の差がつきます。「最も選択的なテーブルから結合を始める」という直感が、コストの計算で裏づけられます。

直積を刈る

DP の中で connected(mask) を確認しているのが効いています。結合述語で繋がっていない部分集合を作ることは直積を意味し、行数が爆発します。この枝刈りだけで探索空間は大幅に縮み、たとえばチェーン状の結合グラフでは候補数が O(n3)O(n^3) まで落ちます。

ただし刈りすぎには副作用があります。片方が極端に小さいテーブル(次元表など)では直積のほうが速いことが実際にあり、多くの実装は「連結な部分集合が 1 つも作れないときだけ直積を許す」というフォールバックを持っています。

n が大きいとき

PostgreSQL は結合テーブル数が geqo_threshold(既定 12)以上になると、動的計画法をやめて遺伝的アルゴリズム(GEQO)に切り替えます。プランが最適である保証は失われますが、プラン生成にかかる時間を抑えられます。

ただし、実際に先に効いてくるのは join_collapse_limit(既定 8)のほうです。明示的な JOIN 構文で 8 テーブルを超えると、プランナーは FROM 句を平坦化するのをやめ、書いた順序をそのまま保ちます。この章の DP を 10 テーブルの実クエリーで再現しようとして「並べ替えてくれない」となったら、まずこの値を疑ってください(サブクエリーの平坦化には別途 from_collapse_limit があります)。

逆に、join_collapse_limit = 1 にすると明示的な JOIN の並べ替えが一切行われなくなるので、結合順序を手で固定するときの定石になります。推定がどうしても当てにならない場面での最後の手段です。

ほかの実装や研究は、別のやり方をとっています。

  • DPccp / DPhyp(Moerkotte & Neumann)— 結合グラフの連結部分グラフだけを効率的に列挙し、無駄な部分集合を最初から作らない。単純な結合グラフ向けの DPccp が VLDB 2006、超グラフ(外部結合や複雑な述語を含む形)へ一般化した DPhyp が SIGMOD 2008 の "Dynamic Programming Strikes Back" です。DuckDB や HyPer が採用しています。
  • Cascades / Volcano フレームワーク(Graefe)— 変換規則の集合とメモ化(memo 構造)による top-down 探索。SQL Server、Apache Calcite、CockroachDB がこの系統です。ボトムアップ DP と違い、規則を足すだけで探索空間を拡張できるのが利点です。
  • Interesting orders — 「ソート順」を状態の一部として持つ。ソート済みの結果は後段の MergeJoin や ORDER BY で再利用できるので、単体では高くても全体では得になることがあります。System R の論文で既に提案されていた概念です。

第 9 章 物理プラン — アルゴリズムを確定する

論理演算子 1 つに対して、複数の物理演算子が対応します。

論理演算子物理演算子の候補
ScanSeq Scan / Index Scan / Index Only Scan / Bitmap Heap Scan
JoinNested Loop / Hash Join / Merge Join
SortQuicksort(メモリー内)/ External Merge Sort / Top-N Heapsort
AggregateHash Aggregate / Sorted Aggregate
DistinctHash / Sort + Unique

最終的な物理プランはこうなります。

Limit(10)                              rows=10       cost≈26,601
└─ Sort(o.total DESC) top-N heapsort   rows=10       cost≈26,601
   └─ HashJoin(u.id = o.user_id)       rows=55,000   cost≈25,688
      ├─ SeqScan(orders)               rows=100,000  cost≈22,500   [probe]
      │    Filter: total > 100
      └─ Hash                          rows=55,000
         └─ SeqScan(users)             rows=55,000   cost≈2,250    [build]
              Filter: age > 30

Sort が top-N heapsort になっているのは LIMIT 10 のおかげです。55,000 行を全部ソートするコストは 0.005×55,000×log⁡255,000≈4,3300.005 \times 55{,}000 \times \log_2 55{,}000 \approx 4{,}330 ですが、上位 10 件だけを保つヒープなら 0.005×55,000×log⁡210≈9140.005 \times 55{,}000 \times \log_2 10 \approx 914 で済みます。メモリー使用量も O(n)O(n) から O(10)O(10) に落ちます。

上位ノードの要求が下位のアルゴリズム選択を変えるのは、物理最適化でよく見られるパターンです。LIMIT は「必要な行数」という情報を下に流し、ORDER BY は「必要なソート順」という情報を下に流します。

こうした「結果の中身には影響しないが、実現方法に制約を与える性質」を物理プロパティー(physical property)と呼びます。代表的なものはソート順(PostgreSQL の PathKey)とデータの分布方法(分散エンジンならパーティション)です。Cascades 系のオプティマイザーはこれを探索の状態に含め、「この部分集合を、このソート順で、最安に作るプラン」を順序ごとに覚えます。第 8 章の interesting orders はこの考え方の先駆けです。

プランを使い回す — プリペアドステートメントと汎用プラン

第 1 章からここまでの工程は安くありません。同じクエリーを秒間何千回も投げるアプリケーションが、毎回字句解析からやり直すのは無駄です。そのため実装はプランをキャッシュします。

ところが、ここにトレードオフがあります。パラメーター付きのクエリーをキャッシュするには、パラメーターの値を知らないままプランを作らなければなりません。しかし第 6 章の selectivity_gt は、値がなければ選択率を計算できません。

DEFAULT_INEQ_SEL = 0.3333333333333333    # selfuncs.h
DEFAULT_EQ_SEL   = 0.005
UNKNOWN = object()                       # 値未定のバインドパラメータ
 
 
def selectivity_gt(stats: ColumnStats, value) -> float:
    if value is UNKNOWN:
        return DEFAULT_INEQ_SEL          # ヒストグラムを引けないので定数を返す
    ...                                  # 以降は第6章と同じ

同じヒストグラムに対して WHERE u.age > $1 を評価すると、こうなります。

プラン選択率推定行数選ばれるプラン
カスタム($1 = 30)0.5555,000HashJoin、コスト 25,688
カスタム($1 = 79)0.0037370NestedLoop + IndexScan
汎用(値不明)0.333333,333どちらにも合わない中間プラン

汎用プランは 33,333 行を前提に作られます。$1 = 30 にとっては小さすぎ、$1 = 79 にとっては大きすぎます。

PostgreSQL の妥協策は「まず試す」です。plancache.c の choose_custom_plan() は、

  1. 最初の 5 回は必ずカスタムプランを作る
  2. その平均推定コストと、一度だけ作った汎用プランのコストを比べる
  3. 汎用プランが安ければ以降はそれを使い回す(プラン時間が不要になる)

という規則で判断します。「最初の数回は速かったのに、突然 100 倍遅くなった」という障害は、この切り替えで起こります。どちらかのプランを強制したければ plan_cache_mode を force_custom_plan または force_generic_plan に設定します。

パラメーターを使わなければこの問題は避けられますが、代償があります。SQL 文字列に値を埋め込んで毎回組み立てると、推定は正確になる代わりに、プランキャッシュがまったく効かなくなり、SQL インジェクションの入り口も作ります。推定精度とプラン再利用は、原理的にトレードオフの関係にあります。


第 10 章 実行エンジン — Volcano 反復子モデル

1990 年に Goetz Graefe が提案したVolcano モデル(反復子モデル、あるいは pull ベース実行)は、すべての演算子に同じ 3 つのメソッドを持たせます。

class Operator:
    def open(self) -> None: ...          # 状態を初期化する
    def next(self) -> dict | None: ...   # 1タプル返す。もう無ければ None
    def close(self) -> None: ...         # 資源を解放する

このインタフェースが統一されているので、任意の演算子を任意の演算子の上に積めます。木の形をプランナーが自由に決められることが、Volcano モデルの価値です。

実装

class SeqScanExec:
    def __init__(self, rows, predicate=None):
        self.rows, self.predicate = rows, predicate
        self.cursor = 0
 
    def open(self):
        self.cursor = 0
 
    def next(self):
        while self.cursor < len(self.rows):
            row = self.rows[self.cursor]
            self.cursor += 1
            if self.predicate is None or self.predicate(row):
                return row
        return None
 
    def close(self):
        pass
 
 
class NestedLoopJoinExec:
    def __init__(self, outer, inner, predicate):
        self.outer, self.inner, self.predicate = outer, inner, predicate
        self.outer_tuple = None
 
    def open(self):
        self.outer.open()
        self.inner.open()
        self.outer_tuple = None
 
    def next(self):
        while True:
            if self.outer_tuple is None:
                self.outer_tuple = self.outer.next()
                if self.outer_tuple is None:
                    return None
                self.inner.open()          # 内側を先頭に巻き戻す(rescan)
            inner_tuple = self.inner.next()
            if inner_tuple is None:
                self.outer_tuple = None    # 内側を1周した。次の外側へ
                continue
            if self.predicate(self.outer_tuple, inner_tuple):
                return {**self.outer_tuple, **inner_tuple}
 
    def close(self):
        self.outer.close()
        self.inner.close()

次のデモで、この呼び出し連鎖を 1 イベントずつ追えます。next() が根から葉へ降りていき、タプルが葉から根へ登っていく様子が見えます。

Volcano 反復子モデル — next() が下へ、タプルが上へ

Projection → NestedLoopJoin → Filter → SeqScan という演算子木を pull 型で実行します。内側の SeqScan(orders) は外側の行ごとに読み直されるため、next() の呼び出し回数が最も多くなります。

物理演算子木
Projectionu.name, o.total
next() × 0出力 0cols: u.name, o.total
NestedLoopJoinu.id = o.user_id
next() × 0出力 0outer = NULL
Filteru.age > 30
next() × 0出力 0pred: u.age > 30
SeqScanusers
next() × 0出力 0cursor = 0
SeqScanorders (inner)
next() × 0出力 0cursor = 0
呼び出しスタック
(空)
クライアントへ返した行(0)
(まだ無し)
openopen() が根から葉へ伝播し、各演算子が内部状態(カーソル・ハッシュ表など)を初期化する
1 / 56

ブロッキング演算子

1 タプルずつ返せない演算子もあります。Sort は最後の 1 行を見るまで最初の 1 行を返せず、HashJoin は build 側を全部読むまで probe を始められません。こうした演算子が、第 7 章で「先に読み切る入力のコストが丸ごと起動コストに入る演算子」としてコストの側から見たブロッキング演算子(pipeline breaker)です。ここではその中身を実装として書きます。

from collections import defaultdict
 
class HashJoinExec:
    def __init__(self, build, probe, build_key, probe_key):
        self.build, self.probe = build, probe
        self.build_key, self.probe_key = build_key, probe_key
 
    def open(self):
        # ── build フェーズ:ここで build 側を最後まで読み切る(ブロッキング)
        self.build.open()
        self.table = defaultdict(list)
        while (row := self.build.next()) is not None:
            self.table[self.build_key(row)].append(row)
        self.build.close()
 
        # ── probe フェーズの準備
        self.probe.open()
        self.matches = iter(())
        self.current = None
 
    def next(self):
        while True:
            m = next(self.matches, None)
            if m is not None:
                return {**m, **self.current}
            self.current = self.probe.next()
            if self.current is None:
                return None
            self.matches = iter(self.table.get(self.probe_key(self.current), ()))
 
    def close(self):
        self.probe.close()
        self.table = None

ブロッキング演算子がどこにあるかで、最初の行を返すまでに読む量が決まります。LIMIT 10 を付けても ORDER BY があれば全行を読むのは、Sort がブロッキングだからです。逆に ORDER BY の無い LIMIT 10 は、10 行取れた時点で next() の呼び出しが止まります。

Top-N ヒープ

ORDER BY … LIMIT n は、全件ソートせずに大きさ nn のヒープで処理できます。

import heapq
import itertools
 
class TopNExec:
    def __init__(self, child, key, n, desc=True):
        self.child, self.key, self.n, self.desc = child, key, n, desc
 
    def open(self):
        self.child.open()
        heap = []                       # (整列キー, 連番, 行) の最小ヒープ
        seq = itertools.count()
        while (row := self.child.next()) is not None:
            k = self.key(row)
            if not self.desc:
                k = -k                  # ASC のときは符号を反転して向きを揃える
            if len(heap) < self.n:
                heapq.heappush(heap, (k, next(seq), row))
            elif k > heap[0][0]:        # 現在の最下位より良いものだけ入れ替える
                heapq.heapreplace(heap, (k, next(seq), row))
        heap.sort(key=lambda x: x[0], reverse=True)
        self.out = iter([row for _, _, row in heap])
 
    def next(self):
        return next(self.out, None)
 
    def close(self):
        self.out = None

連番 next(seq) をタプルの 2 番目に入れているのは、キーが同値のときに row(辞書)同士の比較が起きて TypeError になるのを防ぐためです。Python でヒープを使うときの定番の対策です。

式の評価は木を歩かない

ここまでの演算子は predicate(row) を Python の関数として済ませてきました。実際の実装は、ここにもう一層入れています。

素直に書けば、束縛済みの式木を 1 行ごとに再帰的に歩くことになります。しかし木の形は実行中に変わりません。そこで PostgreSQL はエグゼキューターの初期化時に式木を1 回だけ平坦なステップ列にコンパイルし、以降はその配列を for ループで回します。ExprState と EEOP_* ステップがそれです。

Filter: (total > 100 AND user_id = 42) は、おおよそこういう列になります。

 0  EEOP_SCAN_FETCHSOME       last_var=3     # 参照する最大 attnum までしか deform しない
 1  EEOP_SCAN_VAR             attnum=3       # total
 2  EEOP_CONST                value=100
 3  EEOP_FUNCEXPR_STRICT      numeric_gt     # 引数のどちらかが NULL なら関数を呼ばない
 4  EEOP_QUAL                 jumpdone=9     # FALSE も NULL も即座に不合格
 5  EEOP_SCAN_VAR             attnum=2       # user_id
 6  EEOP_CONST                value=42
 7  EEOP_FUNCEXPR_STRICT      int4eq
 8  EEOP_QUAL                 jumpdone=9
 9  EEOP_DONE

(オペコード名はバージョンによって変わります。上は PostgreSQL 17 までの名前で、18 では EEOP_DONE が EEOP_DONE_RETURN / EEOP_DONE_NO_RETURN に分割され、引数 2 個の strict 関数は EEOP_FUNCEXPR_STRICT_2 になりました。また attnum は構造体上は 0 始まり(varattno - 1)で、1 始まりの属性番号なのは last_var だけです。ここでは形を見れば十分です。)

このステップ列には、ほかの章で扱った仕組みが 3 つ表れています。

EEOP_SCAN_FETCHSOME の last_var — ここが第 15 章の deform_tuple を呼ぶ場所です。しかも式が参照する最大の属性番号までで止めます。列は先頭から順にしか取り出せないので、attnum 3 までしか使わないなら 4 番目以降には触りません。40 列のテーブルで SELECT * が 3 列指定より遅い理由の一つはこれで、ネットワークに 1 バイトも出ないうちに、エグゼキューターはこのループの長さぶんのコストを払っています(実際にはこの上に、列ごとの出力関数の呼び出しや転送量も乗ります)。

_STRICT という接尾辞 — 第 5 章の三値論理を命令の形にしたものです。厳格(strict)な関数は引数に NULL があれば結果も NULL なので、ステップは関数を呼ばずに NULL を返します。

EEOP_QUAL — 「WHERE が通すのは TRUE のときだけ」という規則の実体です。直前の結果が FALSE でも NULL でも同じく末尾へジャンプし、不合格として抜けます。式の内側に AND を書いた場合はこれとは別に EEOP_BOOL_AND_STEP_* が使われます。こちらは FALSE と UNKNOWN を区別しなければならないので、anynull フラグをステップ間で引き回します。FIRST がフラグをクリアし、中間ステップは NULL を見るとフラグを立て、FALSE を見ると即座に抜けます。LAST は「FALSE は無いが NULL はあった」場合を NULL に変換します。この役割分担を実行時の分岐ではなく 3 つのオペコード(FIRST / 中間 / LAST)に分けて表しているのは、速度のための特別扱いです。

PostgreSQL の JIT がコンパイルするのも、この平坦な配列です。ステップごとに間接分岐するインタプリターループが、直線的なネイティブコードになります。第 12 章で「PostgreSQL の JIT は式の評価とタプルの deform をコンパイルするものだ」と書くのは、この配列のことです。


第 11 章 メモリーが足りなくなったとき — 外部ソートとハッシュ集約

前章の演算子は、すべての中間結果がメモリーに収まることを前提にしていました。現実には収まらないことがあります。代表例はソートと集約で、両者はメモリーが足りないときの崩れ方がまったく違います。

外部マージソート

work_mem は第 7 章で「ハッシュ表が収まるかどうかの境界」として一度出てきました。ソートでこの境界を超えると、こうなります。

def sort_external(rows, key, work_mem_rows, tmp):
    """run 生成 → マージ。tuplesort.c の骨格。"""
    runs, buf = [], []
    for row in rows:
        buf.append(row)
        if len(buf) == work_mem_rows:      # work_mem を使い切った
            buf.sort(key=key)
            runs.append(tmp.spill(buf))    # ソート済みの塊を一時ファイルへ
            buf = []
    if buf:
        buf.sort(key=key)
        runs.append(tmp.spill(buf))
 
    # 各 run から1行ずつヒープに載せ、最小値を取り出しては補充する
    return heapq.merge(*(tmp.read(r) for r in runs), key=key)

一度に全部マージできれば 1 パスで済みます。できない理由は、マージ中の各テープ(run を書き出した一時ファイル上の領域)にも読み出しバッファーが要るからです。PostgreSQL は 32 ブロック(MERGE_BUFFER_SIZE = 256KB)にテープ分のオーバーヘッドを加えた分を 1 本あたりに見積もるので、work_mem = 4MB なら同時にマージできるのは 15 本前後です(tuplesort_merge_order())。run がそれより多ければ、マージを何段か重ねることになります。

パス数=⌈log⁡Mr⌉(r=run 数,  M=マージ次数)\text{パス数} = \lceil \log_M r \rceil \qquad (r = \text{run 数},\; M = \text{マージ次数})

PostgreSQL の cost_tuplesort はこれをそのまま式にしています。

startup=c Nlog⁡2N  +  2 npages⋅⌈log⁡Mr⌉×(0.75 cseq+0.25 crand)\text{startup} = c\,N \log_2 N \;+\; 2\,\text{npages} \cdot \lceil \log_M r \rceil \times (0.75\,c_{\text{seq}} + 0.25\,c_{\text{rand}})

最後の重み付けは推測だと、コメント自身が認めています。

The disk traffic is assumed to be 3/4ths sequential and 1/4th random accesses (XXX can't we refine that guess?)

数字を入れてみます。1 行 60 バイトとすると、次のようになります。

  • 55,000 行 ≈ 3.3MB — 既定の work_mem = 4MB に収まるので、メモリー内でクイックソートします。ディスクは使いません。
  • 5,500,000 行 ≈ 330MB — r≈330,000,000/(4×1,048,576)≈79r \approx 330{,}000{,}000 / (4 \times 1{,}048{,}576) \approx 79 run、M≈15M \approx 15 なので ⌈log⁡1579⌉=2\lceil \log_{15} 79 \rceil = 2 パスです。
    • npages=⌈330,000,000/8192⌉=40,284\text{npages} = \lceil 330{,}000{,}000 / 8192 \rceil = 40{,}284
    • npageaccesses=2×40,284×2=161,136\text{npageaccesses} = 2 \times 40{,}284 \times 2 = 161{,}136
    • ディスク =161,136×1.75≈282,000= 161{,}136 \times 1.75 \approx 282{,}000
    • CPU =0.005×5,500,000×log⁡25,500,000≈616,000= 0.005 \times 5{,}500{,}000 \times \log_2 5{,}500{,}000 \approx 616{,}000

ディスクへ溢れても、このコストモデル上は比較コストに 46% が上乗せされるだけです。ソートは work_mem を超えてもなだらかに遅くなります。急激に悪化するのはハッシュのほうです。ハッシュ結合は両側の入力をパーティションに書き直し、ハッシュ集約は(入力が 1 つしかないので)その 1 つを再帰的に分割し直します。

また、一時ファイルはバッファープールを通りません。第 14 章の shared_buffers はテーブルとインデックスのためのもので、ソートの中間結果は base/pgsql_tmp/ に直接読み書きされます。そのため「メモリーは余っているのにソートが遅い」という状況が起こり得ます。

実測値は EXPLAIN ANALYZE に出ます。第 9 章の表の 3 つのソート方式が、そのまま文字列として現れます。

Sort Method: top-N heapsort  Memory: 25kB
Sort Method: quicksort       Memory: 3400kB
Sort Method: external merge  Disk: 341000kB

集約 — ハッシュかソートか

第 9 章の物理演算子の表に Aggregate → Hash Aggregate / Sorted Aggregate と書きました。両方を実装します。ハッシュ集約はブロッキング、ソート集約は非ブロッキングです。

class HashAggExec:
    """ブロッキング。グループごとの状態をハッシュ表に持つ。"""
 
    def __init__(self, child, group_keys, init, accum, final):
        self.child, self.group_keys = child, group_keys
        self.init, self.accum, self.final = init, accum, final
 
    def open(self):
        self.child.open()
        table = {}
        while (row := self.child.next()) is not None:      # ← 全部読む
            k = tuple(row[c] for c in self.group_keys)
            # `table.get(k) or self.init()` と書いてはいけない。累算器の状態が
            # 偽値(0 や False)になった瞬間に初期値へ巻き戻ってしまう。
            state = table[k] if k in table else self.init()
            table[k] = self.accum(state, row)
        self.out = iter([(k, self.final(s)) for k, s in table.items()])
 
    def next(self):
        return next(self.out, None)
 
    def close(self):
        self.child.close()
 
 
class SortedAggExec:
    """非ブロッキング。キーが変わった瞬間に1行出せる。入力はソート済みが前提。"""
 
    def __init__(self, child, group_keys, init, accum, final):
        self.child, self.group_keys = child, group_keys
        self.init, self.accum, self.final = init, accum, final
 
    def open(self):
        self.child.open()
        self.cur_key, self.state = None, None
 
    def next(self):
        while True:
            row = self.child.next()
            if row is None:
                if self.cur_key is None:
                    return None
                out = (self.cur_key, self.final(self.state))    # 最後のグループ
                self.cur_key = None
                return out
 
            k = tuple(row[c] for c in self.group_keys)
            if self.cur_key is None:
                self.cur_key = k
                self.state = self.accum(self.init(), row)
            elif k == self.cur_key:
                self.state = self.accum(self.state, row)
            else:
                out = (self.cur_key, self.final(self.state))    # キーが変わった
                self.cur_key = k
                self.state = self.accum(self.init(), row)
                return out
 
    def close(self):
        self.child.close()

このコストモデルでは、2 つの総コストは等しくなります。costsize.c の cost_agg にこう書いてあります。

Note: in this cost model, AGG_SORTED and AGG_HASHED have exactly the same total CPU cost, but AGG_SORTED has lower startup cost.

SELECT user_id, count(*) FROM orders GROUP BY user_id で計算してみます。入力は SeqScan(orders) で 1,000,000 行、コストは 20,000 です。グループ数は NDV(user_id) = 100,000 です。

起動コスト総コスト
HashAgg20,000+0.0025×106=22,50020{,}000 + 0.0025 \times 10^6 = 22{,}50022,500+0.01×105=23,50022{,}500 + 0.01 \times 10^5 = 23{,}500
SortedAgg(入力がソート済み)≈0\approx 020,000+2,500+1,000=23,50020{,}000 + 2{,}500 + 1{,}000 = 23{,}500

総コストはどちらも 23,500 で、違うのは起動コストだけです(0 と 22,500)。第 7 章で導入した起動コストと総コストの区別が、そのまま表れています。

ただし、この比較が成り立つのはソート済みの入力が無料で手に入るときだけです。わざわざソートするなら、

20,000+0.005×106×log⁡2106≈119,70020{,}000 + 0.005 \times 10^6 \times \log_2 10^6 \approx 119{,}700

となり、HashAgg の 23,500 に 5 倍以上の差をつけられます。そのため、既存のインデックスや下位の MergeJoin がたまたまソート順を提供しているかどうかで、集約方式が決まります。第 8 章の interesting orders が実際のコストの差になって表れるのは、こういう場面です。

HashAgg にも弱点があります。ハッシュ表がメモリーに収まらなければ溢れます。予算は work_mem そのものではなく work_mem × hash_mem_multiplier(PostgreSQL 15 以降の既定は 2.0)で、ハッシュ結合のハッシュ表も同じ予算を使います。溢れた場合、cost_agg は書き出しページに random_page_cost、読み戻しページに seq_page_cost を課し、さらに両方を 2 倍に膨らませます。コメントには「HashAgg has somewhat worse IO behavior than Sort on typical hardware/OS combinations」とあります。ここでも、溢れたときにコストの増え方が穏やかなのはソートのほうです。

下のデモで work_mem を動かすと、2 つの崩れ方の違いを並べて比べられます。デモの入力は直前の表(1,000,000 行 / 100,000 グループ)ではなく、本章前半のソート例と同じ 5,500,000 行・330MB で、キー 1,000,000 種でグループ化します。この入力をソートと集約の両方に流しています。

work_mem を超えたとき — ソートとハッシュ集約の崩れ方

同じ 330MB の入力に対して、work_mem を動かしながら、PostgreSQL のコスト式に倣って計算したソートとハッシュ集約のコストを比べます。ソートはなだらかに、ハッシュ集約は段差で悪化します。

4 MB
外部マージソート1.46×
run 数
79
マージ次数
15
パス数
2
CPU
615,753
ディスク
281,988
合計
897,741
ハッシュ集約22.59×
予算
8 MB
ハッシュ表
107 MB
バッチ数
14
再帰の深さ
1
CPU
23,750
溢れ処理
512,840
合計
536,590
入力とメモリー予算

5,500,000 行 × 60 バイト = 330,000,000 バイトを、work_mem = 4MB(4 × 1,048,576 バイト)で処理します。ソートは全行を並べ替え、集約は同じ入力を 1,000,000 グループにまとめます(第11章前半のソート例と同じ入力です)。予算はソートが work_mem そのもの、ハッシュ集約は hash_mem_multiplier = 2 倍の 8MB です。ソートは収まりません。ハッシュ表 1,000,000 × 112B ≈ 107MB は収まりません。

1 / 7
メモリー内実行に対するコスト倍率ソートハッシュ集約work_mem (MB)
work_mem を変えたときの、メモリー内実行に対するコスト倍率の折れ線グラフ。正確な数値はこの下の「グラフの数値を表で見る」にあります。1.0×22.6×48163264128256512

ハッシュ集約の線が 4〜32MB で平らなのは、cost_agg が見ているのが連続量ではなく整数の再帰の深さだからです。この範囲で work_mem を増やすとバッチ数は減りますが、深さは 1 のままなので、コストも動きません。平坦な区間はモデルの性質であって、実測値が本当に一定だという意味ではありません。

グラフの数値を表で見る
work_memソートハッシュ集約
4 MB1.46×22.59×
8 MB1.46×22.59×
16 MB1.23×22.59×
32 MB1.23×22.59×
64 MB1.23×1.00×
128 MB1.23×1.00×
256 MB1.23×1.00×
512 MB1.00×1.00×

実測値は EXPLAIN ANALYZE の HashAggregate ノードに出ます(次の数値は説明用の例です)。Batches が 2 以上なら溢れが起きていて、そのときだけ Disk Usage も表示されます。

HashAggregate  (actual rows=100000 loops=1)
  Batches: 5  Memory Usage: 6273kB  Disk Usage: 21456kB

第 12 章 Volcano から先へ — ベクトル化・コード生成・並列実行

ここまで作ってきた Volcano モデルには、性能上の弱点があります。1 行返すたびに演算子の数だけ関数呼び出しが起きます。1,000 万行 × 5 演算子なら 5,000 万回です。1 回あたり数十ナノ秒でも、それだけで数秒かかります。しかも仮想関数呼び出しは分岐予測が効きにくく、実際の処理(比較 1 回)よりオーバーヘッドのほうが大きくなることもあります。

この制約を取り除く方向は 3 つあります。粒度を変えるベクトル化、open/next/close の契約そのものを消すコード生成、木を複製して並べる並列実行です。

ベクトル化とコード生成

実行モデルnext() が返す単位呼び出し回数利点欠点採用例
Volcano(1タプルずつ)1 タプル行数 × 演算子数実装が単純。演算子を自由に組み合わせられる仮想関数呼び出しと分岐予測ミスのコストが、実処理を上回ることがあるPostgreSQL, MySQL, Oracle
ベクトル化(バッチ)1,024 タプル程度のバッチ行数 ÷ バッチ幅 × 演算子数呼び出し回数が3桁減り、SIMD と列指向レイアウトが活きる中間バッチのメモリーが必要。行指向ストレージとは相性が悪いDuckDB, ClickHouse, Velox, Photon
コード生成(JIT)パイプライン全体0(融合されて消える)中間タプルを実体化せず、値をレジスターに載せたまま処理できるコンパイル時間。短いクエリーでは元が取れないHyPer, Umbra, Spark(全段コード生成)

ベクトル化実行は next() が 1 タプルではなく 1,024 行程度のバッチを返すようにします。呼び出し回数が 3 桁減り、バッチ内はタイトなループになるので SIMD やループ展開が効きます。MonetDB/X100 の論文(CIDR 2005)が起点で、DuckDB や ClickHouse が採用しています。

コード生成はさらに踏み込み、演算子木そのものを消して 1 つのループに融合します。Neumann の論文 "Efficiently Compiling Efficient Query Plans for Modern Hardware"(VLDB 2011)が定式化したプッシュベースのパイプラインがこれで、ブロッキング演算子の境界(pipeline breaker)でパイプラインを区切り、各パイプラインを 1 つのループにコンパイルします。中間タプルを実体化せず、値をレジスターに載せたまま処理します。

なお、PostgreSQL の JIT(LLVM)は演算子木を消しません。ネイティブコードにコンパイルするのは式の評価とタプルの deform で、プラン全体のコード生成とは別物です。コンパイル対象は第 10 章の EEOP_* の列です。

並列実行 — Gather をどこに置くか

3 つ目の方向は、演算子木の一部を複数プロセスで同時に回すことです。ここまでのプランはすべて直列でした。並列化で安くなるかどうかは Gather をどこに置くかで変わり、コストを計算すると分かります。

まずワーカー数が決まります。PostgreSQL はテーブルサイズから対数的に決めます。テーブルが min_parallel_table_scan_size(既定 8MB = 1,024 ページ)以上になると 1 本目が付き、以降は 3 倍ごとに 1 本増えます。

ページ数サイズワーカー
< 1,024< 8MB0(直列)
≥ 1,0248MB1
≥ 3,07224MB2
≥ 9,21672MB3
≥ 27,648216MB4

users は 1,000 ページで、しきい値に 24 ページ足りないので並列化されません。orders は 10,000 ページなので 3 本になりそうですが、max_parallel_workers_per_gather(既定 2)で頭打ちになり 2 本です。

次に除数を求めます。リーダーも作業に参加するので、単純なワーカー数ではありません。ただしワーカーが増えるほどリーダーは受け取りに忙しくなるので、寄与は 1 本につき 0.3 ずつ目減りし、正でなくなったら(ワーカー 4 本以上)リーダーは数に入りません。

divisor=2+(1.0−0.3×2)=2.4\text{divisor} = 2 + (1.0 - 0.3 \times 2) = 2.4

割り算されるのは CPU コストだけです。ページ読み取りの総量は変わらないので、I/O コストはそのまま残ります。

Parallel Seq Scan(orders)=10,000⏟I/O(割らない)+10,000+2,5002.4=15,208.3\text{Parallel Seq Scan(orders)} = \underbrace{10{,}000}_{\text{I/O(割らない)}} + \frac{10{,}000 + 2{,}500}{2.4} = 15{,}208.3

ここで終わればかなり安くなりますが、ワーカーからリーダーへ行を送るのにもコストがかかります。parallel_tuple_cost は既定 0.1 で、cpu_tuple_cost の 10 倍です。起動にも parallel_setup_cost = 1,000 がかかります。

走査の真上に Gather を置くと、直列より高くなります。

15,208.3+1,000⏟setup+0.1×100,000⏟行の受け渡し=26,208  >  22,500 (直列)15{,}208.3 + \underbrace{1{,}000}_{\text{setup}} + \underbrace{0.1 \times 100{,}000}_{\text{行の受け渡し}} = 26{,}208 \;>\; 22{,}500\ \text{(直列)}

結合の上に置くと、直列より安くなります。キューを渡る行が 100,000 から 55,000 に減るからです。

ノードコスト
Seq Scan users(内側。割らない)2,250
Parallel Seq Scan orders15,208.3
build + probe + 出力の CPU≈ 471
Hash Join 小計≈ 17,929
+ setup 1,000 + 0.1 × 55,00024,429
(参考)直列の Hash Join25,688
Gather  (cost=3387.50..24429.00 rows=55000 width=36)
  Workers Planned: 2
  ->  Hash Join  (cost=2387.50..17929.00 rows=22917 width=36)
        Hash Cond: (o.user_id = u.id)
        ->  Parallel Seq Scan on orders o  (cost=0.00..15208.33 rows=41667 width=20)
              Filter: (total > '100'::numeric)
        ->  Hash  (cost=2250.00..2250.00 rows=55000 width=24)
              ->  Seq Scan on users u  (cost=0.00..2250.00 rows=55000 width=24)
                    Filter: (age > 30)

Gather の下の rows= が小さいのは、ワーカー 1 本あたりの行数だからです(100,000 / 2.4 = 41,667、55,000 / 2.4 = 22,917)。初めて並列プランを読むときにつまずきやすい点です。

ここから規則が 2 つ導かれます。

1. Gather は木のなるべく上に置きたい — 境界を越える行 1 行につき cpu_operator_cost の 40 倍かかります。PostgreSQL が部分パス(partial path)をボトムアップに組み上げ、最後に Gather で蓋をするのはこのためです。Gather Merge が存在するのも同じ理由で、順序を保ったまま集められれば Sort を Gather の上に出さずに済みます。

2. 結合の内側は分割されない — 上のプランでは各ワーカーが users のハッシュ表をそれぞれ作ります(PostgreSQL 11 以降の Parallel Hash は共有します)。つまりハッシュの予算はノードごと・プロセスごとにかかり、リーダーも実行に参加します。ワーカー 2 本 + リーダーの 3 プロセス × ハッシュノード 1 つ × 8MB(前章の work_mem 4MB × hash_mem_multiplier 2.0)で 24MB になります。ハッシュノードが複数あるプランなら、さらに掛け算されます。「溢れたら work_mem を上げればいい」というよくある助言が危険なのは、この掛け算のせいです。

なお、max_parallel_workers_per_gather が 0 でなくても、書き込みや行ロックを伴う操作、途中で中断されうる実行(DECLARE … CURSOR や PL/pgSQL の FOR … IN query LOOP)、PARALLEL UNSAFE な関数があると並列化されません(SERIALIZABLE も PostgreSQL 11 までは並列化を妨げていましたが、12 でこの制限はなくなりました)。

ここまで使ってきた例題は、既定設定の PostgreSQL では実際には並列プランになります(24,429 < 25,688)。ほかの章で直列プランを示してきたのは、議論を 1 本の実行経路に絞るための簡略化(max_parallel_workers_per_gather = 0 相当)です。


第 13 章 アクセスメソッド — 論理的な行から物理的な位置へ

第 10 章で作った SeqScanExec が読んでいたのは Python のリストでした。ここからは実際のストレージを扱います。

TID — 行の物理アドレス

PostgreSQL では、行の物理位置を TID(tuple identifier、ctid)で表します。中身は (ブロック番号, ラインポインター番号) の組です。

SELECT ctid, id, name FROM users LIMIT 3;
--  ctid  | id  | name
-- -------+-----+------
--  (0,1) |   1 | Ann
--  (0,2) |   2 | Bob
--  (0,3) |   3 | Cid

TID が指すのはブロック内のバイトオフセットではなく、スロット番号です。この間接参照のおかげで、ページ内でタプルを移動しても TID が変わらず、インデックスを書き換えずに済みます。スロット付きページの構造は第 15 章で扱います。

シーケンシャルスキャン

PAGE_SIZE = 8192
 
class HeapScan:
    """ブロック単位でヒープファイルを走査する。"""
 
    def __init__(self, rel, buffer_pool, snapshot):
        self.rel, self.pool, self.snapshot = rel, buffer_pool, snapshot
 
    def open(self):
        self.blkno = 0
        self.slot = 0
        self.page = None
 
    def next(self):
        while self.blkno < self.rel.n_pages:
            if self.page is None:
                self.page = self.pool.read_buffer(self.rel, self.blkno)  # pin される
                self.slot = 0
 
            while self.slot < self.page.n_slots:
                slot, raw = self.slot, self.page.item(self.slot)
                self.slot += 1
                if raw is None:            # LP_UNUSED / LP_DEAD は飛ばす
                    continue
                tup = parse_tuple(raw)
                if not visible(tup, self.snapshot):    # MVCC の可視性判定
                    continue
                return tup, (self.blkno, slot + 1)
 
            self.pool.release_buffer(self.rel, self.blkno)   # unpin
            self.page = None
            self.blkno += 1
        return None

この走査には可視性判定が入っています。ページ上に物理的に存在する行が、すべて自分から見えるわけではありません。MVCC(多版型同時実行制御)のもとでは、同じ論理行の複数バージョンがページ上に共存します。

def visible(tup, snapshot) -> bool:
    """PostgreSQL の HeapTupleSatisfiesMVCC を大幅に簡略化したもの。"""
    if not committed(tup.xmin, snapshot):
        return False                       # まだコミットされていない挿入
    if tup.xmin >= snapshot.xmax or tup.xmin in snapshot.in_progress:
        return False                       # スナップショット取得より後の挿入
    if tup.xmax == 0:
        return True                        # 削除されていない
    if not committed(tup.xmax, snapshot):
        return True                        # 削除がまだコミットされていない
    if tup.xmax >= snapshot.xmax or tup.xmax in snapshot.in_progress:
        return True                        # スナップショット取得より後の削除
    return False

この判定にはヒープ上のタプルが必要です。これが PostgreSQL の Index Only Scan を難しくしています。インデックスのエントリーには可視性情報が無いので、原則としてヒープを確認する必要があります。そこで PostgreSQL は可視性マップ(visibility map)を用意しています。「このブロックの全タプルが全トランザクションから見える」ことを 1 ビットで記録した補助ファイルで、ビットが立っているブロックについてはヒープ参照を省略します。ビットを立てるのは VACUUM だけで、データを変更する操作はそのブロックのビットを消すので、更新の激しいテーブルでは Index Only Scan が期待どおり効かないことがあります。

インデックススキャンとビットマップスキャン

インデックススキャンはインデックスを辿って TID を得て、TID ごとにヒープを参照します。問題は、インデックスの順序(キー順)とヒープの順序(物理順)が一般には一致しないことです。100 行取り出すのに 100 回のランダム読みが発生しうるうえ、同じヒープページを何度も読むこともあります。第 7 章の Mackert–Lohman の式は、この「同じページに何度も当たる」効果をコストとして織り込むものでした。ビットマップスキャンは、同じ問題を実行の仕組みの側から解決します。

  1. インデックスを最後まで辿り、該当する TID をビットマップ(ブロック番号のビット集合)に集める
  2. ビットマップをブロック番号順に走査してヒープを読む

これでランダム読みが順次読みに変わり、同じページを 2 度読むこともなくなります。複数のインデックスの結果を AND / OR でビット演算的に合成できるのも利点です。

一方で、ビットマップが work_mem に収まらなくなると、ブロック単位の粗いビットマップ(lossy bitmap)に劣化し、そのブロックの全タプルに対して条件を再評価する必要が生じます。EXPLAIN ANALYZE の Heap Blocks: exact=… lossy=… がこれを示しています。


第 14 章 バッファープール — ページのキャッシュ

実行エンジンはテーブルやインデックスのページを直接ディスクから読みません。必ずバッファープールを経由します。

バッファープールは固定サイズのフレーム配列と、(データベース, リレーション, フォーク, ブロック番号) からフレーム番号を引くハッシュ表でできています。

MAX_USAGE = 5                # PostgreSQL の BM_MAX_USAGE_COUNT
 
 
@dataclass
class Frame:
    page: object | None = None    # Page オブジェクト(第15章で定義)
    tag: tuple | None = None
    usage: int = 0
    pin: int = 0
    dirty: bool = False
 
 
class BufferPool:
    def __init__(self, n_frames: int, storage):
        self.frames = [Frame() for _ in range(n_frames)]
        self.table = {}          # buffer tag -> フレーム番号
        self.hand = 0            # clock-sweep の針
        self.storage = storage
 
    def read_buffer(self, rel, blkno) -> "Page":
        tag = (rel.relfilenode, "main", blkno)
        idx = self.table.get(tag)
        if idx is not None:                       # ── ヒット
            f = self.frames[idx]
            f.usage = min(f.usage + 1, MAX_USAGE)
            f.pin += 1
            return f.page
 
        idx = self.evict_one()                    # ── ミス
        victim = self.frames[idx]
        if victim.tag is not None:
            if victim.dirty:
                old_node, _, old_blkno = victim.tag
                self.storage.flush_wal_up_to(victim.page.lsn)   # WAL 先行書き込み
                self.storage.write_page(old_node, old_blkno, victim.page)
            del self.table[victim.tag]
 
        victim.page = Page(self.storage.read_page(rel.relfilenode, blkno))
        victim.tag, victim.usage, victim.pin, victim.dirty = tag, 1, 1, False
        self.table[tag] = idx
        return victim.page
 
    def release_buffer(self, rel, blkno) -> None:
        """pin を一つ返す。ゼロになったフレームは再び置換の候補に戻る。"""
        idx = self.table[(rel.relfilenode, "main", blkno)]
        self.frames[idx].pin -= 1
 
    def evict_one(self) -> int:
        """clock-sweep(second chance)で犠牲フレームを選ぶ。"""
        for _ in range(len(self.frames) * (MAX_USAGE + 2)):
            f = self.frames[self.hand]
            here = self.hand
            self.hand = (self.hand + 1) % len(self.frames)
            if f.pin > 0:
                continue                    # 使用中のページは追い出せない
            if f.usage > 0:
                f.usage -= 1                # 猶予を与えて次へ
                continue
            return here
        raise RuntimeError("no unpinned buffers available")

バッファープールと clock-sweep 置換

4フレームのバッファープールに対する実行エンジンからのページ要求を1イベントずつ追います。pin されたページは追い出されず、dirty なページを追い出す前には WAL を flush します。

バッファーフレーム要求中: IDX:0
frame 0▼
(空)
usage 0pin 0
frame 1
(空)
usage 0pin 0
frame 2
(空)
usage 0pin 0
frame 3
(空)
usage 0pin 0
0
ヒット
1
ミス
0
追い出し
0
書き戻し
インデックスのルートページ。この例では走査が終わるまで pin したままにする
missBufTableLookup(IDX:0) がミス。ページを読み込むフレームを探す必要がある
1 / 34

この実装には、重要な規則が 3 つ含まれています。

1. pin されたページは追い出せない

pin > 0 は「今まさに誰かがこのページのメモリーを指している」ことを意味します。追い出すとポインターが宙に浮きます。実行エンジンはタプルを読んでいる間ページを pin し、次のブロックに移るときに release_buffer() で unpin します。unpin を呼び忘れるとバッファーリークになり、やがて全フレームが pin されて PostgreSQL なら ERROR: no unpinned buffers available で失敗します(上の evict_one の上限付きループはその再現です)。

2. LRU ではなく clock-sweep

厳密な LRU は、ヒットのたびにリンクリストを付け替えるためロック競合が激しくなります。clock-sweep は「参照カウンターを持ち、針が来たら 1 減らし、0 なら追い出す」という近似で、ヒット時の更新がカウンターのインクリメントだけで済みます。PostgreSQL の usage_count は 0〜5 の範囲です。

なお PostgreSQL にはリングバッファーという追加の仕組みもあります。大きなテーブルのシーケンシャルスキャン(shared_buffers の 1/4 を超えるサイズ)は専用の小さなリング(既定 256KB)だけを使い、バッファープール全体を汚さないようにします。1 回のシーケンシャルスキャンでキャッシュが全部入れ替わってしまう事故を防ぐためです。

3. dirty ページの追い出しは WAL の flush を伴う

これがWAL 先行書き込み規則(write-ahead logging)です。データページを永続化する前に、そのページに対する変更を記録した WAL レコードが永続化されていなければなりません。順序が逆だと、クラッシュ時に「データは新しいが、それを作った操作のログが無い」状態になり、復旧できなくなります。

この規則があるおかげで、トランザクションのコミット時にデータページを書き出す必要がありません(NO-FORCE)。逆に、コミットしていないトランザクションの変更をデータページに書き出してもよくなります(STEAL)。STEAL/NO-FORCE は最も高速な組み合わせですが、その代わり復旧時には REDO と UNDO の両方が必要になります。


第 15 章 スロット付きページ — 行がバイトになる場所

ここが最下層で、行は 8192 バイトのページにバイト列として詰め込まれています。

レイアウト

PostgreSQL のヒープページはスロット付きページ(slotted page)という構造です。

+--------------------------------------------------+
| PageHeaderData (24B)                             |
+--------------------------------------------------+
| ItemIdData[0] | ItemIdData[1] | ItemIdData[2] |… |  ← 前から伸びる
+--------------------------------------------------+
|                                                  |
|                   free space                     |
|                                                  |
+--------------------------------------------------+
| … | tuple 2 | tuple 1 | tuple 0                  |  ← 後ろから伸びる
+--------------------------------------------------+

ラインポインター(ItemIdData)の配列は前から、タプルは後ろから、それぞれ中央に向かって伸びます。空き領域が中央の 1 か所にまとまるので、可変長のタプルとラインポインター配列を 1 ページに同居させるには、この配置が最も無駄が少なくなります。空き領域の大きさは pd_upper - pd_lower だけで求まります。

スロット付きページ — 行がバイトとして置かれる場所

挿入・削除・VACUUM・スロット再利用を通して、pd_lower / pd_upper とラインポインター配列がどう動くかを追います(実際は 8192 バイトですが、ここでは 256 バイトに縮めています)。

ページ(256 バイト)pd_lower = 24 · pd_upper = 256 · 空き = 232 B
064128192256
ラインポインター配列(ItemIdData)
#lp_offlp_lenlp_flags
(空)
空のページ
PageInit(page, 256, 0)
  pd_lower = 24    // ヘッダー直後
  pd_upper = 256   // ページ末尾

ページはヘッダーから始まり、ラインポインター配列が前から、タプル本体が後ろから伸びていきます。pd_lower と pd_upper に挟まれた領域が空き領域です。

1 / 7

なぜ間接参照を挟むのか

ラインポインター配列は 1 段の間接参照になっています。TID が直接バイトオフセットを指さない理由は第 13 章で触れたとおりで、ページ内でタプルを移動できるようにするためです。

削除された行の隙間を詰める(PageRepairFragmentation)とき、タプルのバイト位置は変わりますが、ラインポインター番号は変わりません。インデックスは TID を保持しているので、この間接参照が無ければ、ページを詰め直すたびに全インデックスの該当エントリーを書き換える必要が出てきます。

ItemIdData は 4 バイトのビットフィールドです。

/* src/include/storage/itemid.h */
typedef struct ItemIdData
{
    unsigned    lp_off:15,      /* ページ先頭からのオフセット */
                lp_flags:2,     /* 状態 */
                lp_len:15;      /* タプルのバイト長 */
} ItemIdData;

lp_flags は 4 つの状態を取ります。

値名前意味
0LP_UNUSED未使用。再利用可能
1LP_NORMAL有効なタプルを指している
2LP_REDIRECT別のラインポインターへの転送(HOT チェーン用)
3LP_DEAD死んだタプル。バイトはまだ残っている

LP_REDIRECT は HOT(Heap-Only Tuple)という最適化のためのものです。インデックス対象の列を変更しない UPDATE で、新バージョンが同じページに収まる場合、インデックスを更新せずに済ませます。古いラインポインターを LP_REDIRECT にして新しいスロットへ転送し、インデックスは古い TID を指したまま追跡できるようにする仕組みです。

ページを読む

import struct
 
PAGE_SIZE = 8192
SIZEOF_PAGE_HEADER = 24
SIZEOF_ITEM_ID = 4
 
LP_UNUSED, LP_NORMAL, LP_REDIRECT, LP_DEAD = 0, 1, 2, 3
 
 
def parse_page(buf: bytes):
    """8192 バイトのページから有効なタプルのバイト列を取り出す。"""
    # PageHeaderData: pd_lsn(8) pd_checksum(2) pd_flags(2)
    #                 pd_lower(2) pd_upper(2) pd_special(2)
    #                 pd_pagesize_version(2) pd_prune_xid(4)
    pd_lower, pd_upper = struct.unpack_from("<HH", buf, 12)
    n_slots = (pd_lower - SIZEOF_PAGE_HEADER) // SIZEOF_ITEM_ID
 
    for slot in range(n_slots):
        (item_id,) = struct.unpack_from(
            "<I", buf, SIZEOF_PAGE_HEADER + slot * SIZEOF_ITEM_ID)
        # リトルエンディアン環境の一般的なビットフィールド配置
        lp_off = item_id & 0x7FFF
        lp_flags = (item_id >> 15) & 0x0003
        lp_len = (item_id >> 17) & 0x7FFF
        if lp_flags != LP_NORMAL:
            continue
        yield slot + 1, buf[lp_off:lp_off + lp_len]   # TID の offset は1起点

pd_lower の位置(オフセット 12)は、ヘッダーの各フィールドのサイズから決まります。ストレージ層では、このようにオフセットを数えて構造体を読む作業が日常的に出てきます。

これを第 13・14 章が期待するオブジェクトに包んでおきます。第 14 章の BufferPool が Page を返し、第 13 章の HeapScan が page.item(i) を呼ぶのでした。

class Page:
    def __init__(self, buf: bytes):
        self.buf = buf
        (pd_lower,) = struct.unpack_from("<H", buf, 12)
        self.n_slots = (pd_lower - SIZEOF_PAGE_HEADER) // SIZEOF_ITEM_ID
        live = dict(parse_page(buf))                     # {ラインポインタ番号: raw}
        # 添字 i をラインポインタ番号 i+1 に揃える。詰めてしまうと TID がずれる
        self.slots = [live.get(i + 1) for i in range(self.n_slots)]
        (self.lsn,) = struct.unpack_from("<Q", buf, 0)   # pd_lsn
 
    def item(self, i: int) -> bytes | None:
        """0起点のスロット添字。LP_UNUSED / LP_DEAD なら None。"""
        return self.slots[i]

Storage.read_page() は bytes を返すので、バッファープール側で victim.page = Page(self.storage.read_page(…)) と包めば、第 14 章の victim.page.lsn も繋がります。

タプルの中身

HeapTupleHeaderData のバイト配置(固定部 23 バイト)HeapTupleHeaderData(固定部 23 バイト)4B4B4B6B2B2B1Bbits列データ023t_hofft_xmin作成トランザクション IDt_xmax削除トランザクション IDt_cidコマンド ID(t_xvac と共用)t_ctid自身または新しいバージョンの TIDt_infomask2属性数と各種フラグt_infomaskNULL の有無・可視性ヒントt_hoffヘッダー長(= データ開始位置)t_bitsNULL ビットマップ。NULL が1つも無ければ省略されるt_hoff は MAXALIGN(8バイト境界)に切り上げられるので、NULL が無ければ実効ヘッダー長は 24 バイトになる

タプルヘッダーの固定部は 23 バイトですが、t_hoff(データ開始位置)は MAXALIGN(一般的な 64bit 環境では 8 バイト境界への切り上げ)されるので、NULL が無ければ実効ヘッダー長は 24 バイトになります。

HEAP_HASNULL = 0x0001
HEAP_NATTS_MASK = 0x07FF
 
@dataclass(frozen=True)
class HeapTuple:
    xmin: int
    xmax: int
    n_atts: int
    null_bits: bytes
    data: bytes
 
 
def parse_tuple(raw: bytes) -> HeapTuple:
    t_xmin, t_xmax, t_field3 = struct.unpack_from("<III", raw, 0)
    # t_ctid は 12..17(BlockIdData 4B + OffsetNumber 2B)
    t_infomask2, t_infomask = struct.unpack_from("<HH", raw, 18)
    (t_hoff,) = struct.unpack_from("<B", raw, 22)
 
    n_atts = t_infomask2 & HEAP_NATTS_MASK        # 下位11ビットが属性数
    has_nulls = bool(t_infomask & HEAP_HASNULL)
    null_bits = raw[23:t_hoff] if has_nulls else b""
    return HeapTuple(t_xmin, t_xmax, n_atts, null_bits, raw[t_hoff:])

NULL ビットマップは、NULL が 1 つも無ければ丸ごと省略されます。そのため「NULL 可能な列を追加すると全行が太る」ということはありません。逆に 1 つでも NULL があると、列数ぶんのビット(切り上げてバイト単位)が消費されます。ただし 8 列までなら 1 バイトで済み、その 1 バイトは直前の MAXALIGN(23 → 24)がすでに空けている隙間に収まるので、追加の容量は実質かかりません。ビットは 1 なら NOT NULL、0 なら NULL を表します。直感と逆に感じる人が多いところです。

列の切り出し(deform)

def align_to(offset: int, boundary: int) -> int:
    return (offset + boundary - 1) & ~(boundary - 1)
 
 
def read_varlena(data: bytes, offset: int) -> tuple[bytes, int]:
    """PostgreSQL の可変長表現。1バイトヘッダと4バイトヘッダがある。"""
    first = data[offset]
    if first == 0x01:                      # 外部 TOAST ポインタ
        raise NotImplementedError("external TOAST pointer")
    if first & 0x01:                       # 短い値(ヘッダ込みで最大127バイト)
        size = (first >> 1) & 0x7F         # size はヘッダを含む長さ
        return data[offset + 1:offset + size], offset + size
    # 4バイトヘッダ。この場合は4バイト境界に整列している
    (header,) = struct.unpack_from("<I", data, offset)
    size = (header >> 2) & 0x3FFFFFFF
    return data[offset + 4:offset + size], offset + size
 
 
def deform_tuple(data: bytes, columns, null_bits: bytes) -> list:
    values, offset = [], 0
    for i, col in enumerate(columns):
        if null_bits and not (null_bits[i // 8] >> (i % 8)) & 1:
            values.append(None)            # ビットが 0 → NULL。オフセットは進めない
            continue
        if col.type == "int":
            offset = align_to(offset, 4)
            values.append(struct.unpack_from("<i", data, offset)[0])
            offset += 4
        elif col.type == "text":
            # 短い varlena は整列不要。4バイトヘッダのときだけ整列が要る
            if data[offset] & 0x01 == 0:
                offset = align_to(offset, 4)
            raw, offset = read_varlena(data, offset)
            values.append(raw.decode("utf-8"))
        else:
            raise NotImplementedError(col.type)
    return values

このコードから、列の順序が性能に効く理由が分かります。deform_tuple は先頭から順に走査するので、kk 番目の列を読むには k−1k-1 個の列のオフセットを計算しなければなりません。可変長列や NULL があると事前計算もできません。PostgreSQL がキャッシュしたオフセットで直接アクセスできるのも、「先頭から連続して固定長かつ NOT NULL の列」に限られます。

さらに整列パディングの問題もあります。(a boolean, b bigint, c boolean, d bigint) という定義だと、bigint の 8 バイト整列のために各 boolean の後ろに 7 バイトの穴が空きます。大きい型から順に並べるだけで、1 行あたり十数バイト縮むことがあります。

TOAST

1 つのタプルはページをまたげません。しかも、1 ページに複数行が入るようにするため、PostgreSQL は 1 タプルをページの約 1/4 に収めようとします。この上限を超える行には、TOAST(The Oversized-Attribute Storage Technique)で対処します。タプルが TOAST_TUPLE_THRESHOLD(既定 2032 バイト、8KB ページの約 1/4)を超えると、大きな可変長列を順に

  1. 圧縮する(pglz または lz4)
  2. それでも収まらなければ、専用の TOAST テーブルにチャンク分割して追い出し、本体には 18 バイトのポインターだけを残す

と処理します。この結果、SELECT id FROM docs(TOAST された列に触らない)は速く、SELECT body FROM docs では TOAST テーブルへの追加アクセスが発生します。ここでも、読む列を絞ると速くなります。

層を繋ぐ — SeqScanExec を実ストレージ版にする

第 10 章の SeqScanExec は Python のリストを読んでいました。ここまでの部品が揃ったので、置き換えます。

class HeapScanExec:
    """第10章の SeqScanExec を、実ストレージを読む版に差し替えたもの。"""
 
    def __init__(self, scan: HeapScan, columns, predicate=None):
        self.scan, self.columns, self.predicate = scan, columns, predicate
 
    def open(self):
        self.scan.open()
 
    def next(self):
        while (hit := self.scan.next()) is not None:
            tup, tid = hit                                    # 第13章
            row = deform_tuple(tup.data, self.columns, tup.null_bits)  # 第15章
            row = dict(zip((c.name for c in self.columns), row))
            if self.predicate is None or self.predicate(row) is True:
                return row
        return None
 
    def close(self):
        pass

差し替えても、上の層はまったく変わりません。NestedLoopJoinExec も HashJoinExec も TopNExec も、子が open/next/close を実装していることしか知らないからです。第 10 章で書いた「インタフェースが統一されている価値」が、ここで具体的に表れています。

この置き換えで、層の境界で何が起きているかも見えます。next() を 1 回呼ぶたびに、48 バイトの構造体が Python の辞書に解きほぐされます。行指向データベースの実行時プロファイルでタプルの deform が常に上位に来るのは、この処理が行ごとに走るためです。PostgreSQL の JIT が「式の評価とタプルの deform」をコンパイルするのも、ベクトル化エンジンが列指向レイアウトを選ぶのも、このコストを避けるためです。

なお self.predicate(row) is True と書いているのは第 5 章の三値論理のためです。UNKNOWN を None で表すなら、真偽値として扱うのではなく TRUE との一致で判定する必要があります。


第 16 章 ディスクまで — 最後の一歩

バッファープールがミスしたら、実際にファイルを読みます。

import os
 
class Storage:
    """PostgreSQL の smgr(storage manager)相当。"""
 
    SEGMENT_SIZE = 1 << 30            # 1GB でファイルを分割する
 
    def __init__(self, base_dir):
        self.base_dir, self.fds = base_dir, {}
 
    def _fd(self, relfilenode: int, segno: int) -> int:
        key = (relfilenode, segno)
        if key not in self.fds:
            suffix = "" if segno == 0 else f".{segno}"
            path = os.path.join(self.base_dir, f"{relfilenode}{suffix}")
            self.fds[key] = os.open(path, os.O_RDWR)
        return self.fds[key]
 
    def read_page(self, relfilenode: int, blkno: int) -> bytes:
        blocks_per_segment = self.SEGMENT_SIZE // PAGE_SIZE   # 131072
        segno, local = divmod(blkno, blocks_per_segment)
        offset = local * PAGE_SIZE
        buf = os.pread(self._fd(relfilenode, segno), PAGE_SIZE, offset)
        if len(buf) != PAGE_SIZE:
            raise IOError(f"short read at block {blkno}")
        return buf

ここで処理は終わります。SELECT u.name, o.total FROM … という宣言的な文字列は、最終的に次の 1 本のシステムコールになります。

pread(fd=42, buf, 8192, offset=34988032)

ブロック 4271 のオフセットは 4271×8192=34,988,0324271 \times 8192 = 34{,}988{,}032 です。

なぜ 1GB で分割するのか

歴史的には、32bit 環境や一部のファイルシステムに 2GB のファイルサイズ上限があったためです。現在ではその制約は無くなりましたが、テーブル削除時に大きなファイルを一気に unlink するとファイルシステムが固まる、といった実務上の理由で残っています。ビルド時に --with-segsize で変更できます。

O_DIRECT を使わない理由

PostgreSQL は既定でカーネルのページキャッシュを経由します。つまり二重キャッシュです。同じページが shared_buffers とページキャッシュの両方に載ることがあります。

一見無駄ですが、意図的な設計です。O_DIRECT を使うなら、データベース側が先読み・書き戻しスケジューリング・非同期 I/O をすべて自前で実装しなければなりません。Oracle や MySQL/InnoDB はそれを実装していますが、PostgreSQL はカーネルに任せる方針を選びました。とはいえ近年は非同期 I/O(io_uring)の導入が進んでおり、この設計判断は変わりつつあります(io_uring と epoll も参照)。

fsync と耐久性

コミット時に永続化されなければならないのは WAL だけです。

def commit(self, xid: int) -> None:
    self.wal.append(CommitRecord(xid))
    os.write(self.wal_fd, self.wal.pending_bytes())
    os.fsync(self.wal_fd)          # ← ここが返るまでクライアントに OK を返さない

write(2) が返ってもデータは永続化されていません。カーネルのページキャッシュに載っただけです。fsync(2) を呼んで初めて、ストレージデバイスへの到達が保証されます(デバイスの書き込みキャッシュが有効なら、この保証も崩れることがあります)。

fsync にかかる時間はストレージデバイスとその書き込みキャッシュによって変わり、マイクロ秒のオーダーからミリ秒のオーダーまで幅があります。実際の環境での値は pg_test_fsync で測れます。1 トランザクションごとに呼ぶと、スループットが fsync の回数で頭打ちになります。そこで、複数トランザクションの WAL をまとめて 1 回の fsync で永続化するグループコミットが使われます。PostgreSQL の commit_delay / commit_siblings がこれを制御します。

synchronous_commit = off にすると fsync を待たずにクライアントへ応答します。応答は大幅に速くなりますが、クラッシュ時に直近数百ミリ秒ぶんのコミット済みトランザクションが失われる可能性があります。速さと耐久性のトレードオフが、この設定に直接表れています。


第 17 章 全部つなげる

ここまでの部品を 1 本の関数に繋ぎます。

PLAN_CACHE: dict[str, object] = {}
 
 
def execute(sql: str, catalog, stats, pool, snapshot, params=None):
    # ── フロントエンド
    tokens = tokenize(sql)                       # 第1章
    ast = Parser(tokens).parse_select()          # 第2章
    binder = Binder(catalog)
    binder.collect_from(ast.frm)                 # 第3章
    bound = bind_statement(binder, ast)
 
    # ── プランナ(同じ SQL なら第9章のプランキャッシュを引く。
    #    ここでは常に初回のプランを保持する──カスタム/汎用の再評価は省略)
    if sql not in PLAN_CACHE:
        logical = to_logical_plan(bound)                        # 第4章
        logical = rewrite(logical)                              # 第5章
        estimated = estimate_cardinality(logical, stats, params)  # 第6章
        PLAN_CACHE[sql] = optimize(estimated, stats)            # 第7〜9章
    physical = PLAN_CACHE[sql]
 
    # ── エグゼキュータ
    op = build_executor(physical, pool, snapshot)      # 第10〜15章
    op.open()
    try:
        while (row := op.next()) is not None:
            yield row
    finally:
        op.close()

各段の入出力を並べると、全体が 1 つの型の連鎖になっていることが分かります。

str
  → list[Token]
  → Select                  (未束縛の AST)
  → Select                  (束縛済み。ColRef が Var になった)
  → LogicalPlan             (関係代数)
  → LogicalPlan             (書き換え後)
  → LogicalPlan + 推定行数
  → PhysicalPlan            (アルゴリズム確定)
  → Operator                (open/next/close を持つ実行可能な木)
  → Iterator[dict]

各層は次の層のためだけに存在し、層をまたぐ知識を持ちません。この分離があるので、たとえば実行エンジンを Volcano からベクトル化に差し替えても、パーサーには手を入れずに済みます。

EXPLAIN で答え合わせをする

最後に、例題のクエリーの実行計画を見ます。出てくる数字の多くは、これまでのいずれかの章に対応しています。

(以下の出力は実測値ではなく、説明用の例です。cost は本記事のコストモデルで計算した値を EXPLAIN の書式に流し込んだもので、actual time や実際の行数、Buffers、Planning Time、Execution Time など実行して初めて分かる数値は、例題の設定に合うように作りました。実際の PostgreSQL は top-N ソートに Nlog⁡22KN \log_2 2K を使うので、Sort の起動コストはこれよりわずかに大きく出ます。)

Limit  (cost=26601.03..26601.06 rows=10 width=36) (actual time=241.88..241.89 rows=10 loops=1)
  Buffers: shared hit=1024 read=9976
  ->  Sort  (cost=26601.03..26738.53 rows=55000 width=36) (actual time=241.88..241.88 rows=10 loops=1)
        Sort Key: o.total DESC
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Hash Join  (cost=2387.50..25687.50 rows=55000 width=36) (actual time=18.40..228.12 rows=54812 loops=1)
              Hash Cond: (o.user_id = u.id)
              Buffers: shared hit=1024 read=9976
              ->  Seq Scan on orders o  (cost=0.00..22500.00 rows=100000 width=20) (actual time=0.02..191.44 rows=99873 loops=1)
                    Filter: (total > '100'::numeric)
                    Rows Removed by Filter: 900127
                    Buffers: shared hit=24 read=9976
              ->  Hash  (cost=2250.00..2250.00 rows=55000 width=24) (actual time=18.30..18.30 rows=54903 loops=1)
                    Buckets: 65536  Batches: 1  Memory Usage: 3584kB
                    ->  Seq Scan on users u  (cost=0.00..2250.00 rows=55000 width=24) (actual time=0.01..9.88 rows=54903 loops=1)
                          Filter: (age > 30)
                          Rows Removed by Filter: 45097
                          Buffers: shared hit=1000 read=0
Planning Time: 0.284 ms
Execution Time: 241.94 ms
断片どこから来たか
'100'::numeric第 3 章 — 束縛器が整数リテラルを暗黙キャストした結果
Filter: が結合ではなく走査に付いている第 5 章 — 述語プッシュダウン
rows=55000第 6 章 — selectivity_gt = 0.55 × 100,000
rows=100000 と actual rows=99873第 6 章 — 誤差 0.13%。この程度なら第 7 章の順位は崩れない
cost=0.00..22500.00第 7 章 — 10,000+106×0.01+106×0.002510{,}000 + 10^6 \times 0.01 + 10^6 \times 0.0025
cost=2387.50..25687.50第 7 章 — 起動コストに build 側の総コストが乗っている
Hash が独立したノードになっている第 10 章 — HashJoinExec.open() の build フェーズ
Batches: 1第 11 章 — ハッシュの予算に収まった。2 以上なら grace hash join
Memory Usage: 3584kB第 11 章 — ハッシュの予算 8MB(work_mem 4MB × hash_mem_multiplier 2.0)の下なので溢れていない
Sort Method: top-N heapsort第 9 章 — LIMIT がアルゴリズムを選ばせた。実装は第 10 章の TopNExec
26,601.03−25,687.50≈913.526{,}601.03 - 25{,}687.50 \approx 913.5第 9 章 — 0.005×55,000×log⁡2100.005 \times 55{,}000 \times \log_2 10
shared hit=1024 read=9976第 14 章 — 1,024 ページが常駐していて、残りはミス
read=9976第 16 章 — バッファープールへストレージから読み込んだブロックが 9,976 個。必ずしも 1 ブロック 1 システムコールではない
Planning Time: 0.284 ms第 9 章 — プランキャッシュが消すのはこの時間

このうちメモリー不足を示すのは 2 つの行です。Batches: 1 が Batches: 8 になっていたり、Sort Method: quicksort が external merge になっていたりしたら、第 11 章で説明したメモリー不足時の動作に入っています。


第 18 章 実在のデータベースとの対応

ここまで PostgreSQL 寄りに書いてきましたが、設計判断は実装ごとに異なります。

PostgreSQLSQLiteMySQL / InnoDBDuckDB
最適化方式ボトムアップ DP(+ GEQO)コストベース + 貪欲探索(NGQP)コストベースボトムアップ DP(DPhyp)
実行モデルVolcano(式は JIT 可)バイトコード VMVolcano(8.0 以降ハッシュ結合)ベクトル化(列指向バッチ)
行の格納ヒープ + 非クラスタードインデックスB 木にレコードを直接格納主キーのクラスタードインデックス列指向(行グループ単位)
MVCCページ上に複数バージョンロールバックジャーナル / WALUNDO ログ + read viewローカル差分ベース
ページサイズ8KB(ビルド時に変更可)4KB(既定)16KB(既定)256KB ブロック

SQLite の実行モデルは Volcano ではなく、独自のバイトコード VM(sqlite3_stmt は VDBE プログラム)です。EXPLAIN を打つと OpenRead / Rewind / Column / Next といった命令列が出てきます。反復子の入れ子ではなく、明示的なジャンプでループを表現します(バイトコード VM の記事も参照)。

InnoDB のクラスタードインデックスでは、行データそのものが主キーの B+ 木のリーフに格納されます。そのためセカンダリインデックスのリーフには TID ではなく主キーの値が入り、セカンダリインデックスに無い列を読むには「セカンダリインデックス → 主キーのインデックス」の 2 段の探索が必要です。PostgreSQL のヒープ + TID とは根本的に違う設計です。

DuckDB は列指向 + ベクトル化で、ここまで説明してきた「1 タプルずつ」の処理とはかなり違います。それでも、パーサー(PostgreSQL の文法をフォークしている)から論理プラン、DP による結合順序決定までの前半はよく似ています。層の分離がうまく働いていることの表れです。


まとめ

処理は変換の連鎖として設計されています。文字列 → トークン → AST → 束縛済み AST → 論理プラン → 物理プラン → 実行木と変換が続き、各段は前段の出力だけを見て、次段の入力だけを作ります。

意味づけは束縛の段階で起きます。名前が実体に結びつくのはこの段階で、ここを境に、以降の最適化は正規化された表現の上で動きます。

最適化はカーディナリティー推定に賭けています。コストモデルがどれだけ精密でも、行数の推定が 2 桁ずれれば役に立ちません。クエリーが突然遅くなる原因の大半はここにあります。

コストは 1 つの数ではなく 2 つの数です。起動コストと総コストを別々に持つので、LIMIT やカーソルのように全行を必要としない上位ノードが、下位のアルゴリズム選択をひっくり返せます。ブロッキング演算子の起動コストには、先に読み切る入力のコストが丸ごと入ります。Sort ではこの 2 つの数がほぼ一致します。

推定精度とプランの再利用は、原理的にトレードオフの関係にあります。値を知らないまま作る汎用プランは、プラン時間を節約する代わりに推定の精度を落とします。値ごとにプランを作れば推定は正確になりますが、キャッシュが効きません。「最初の数回は速かったのに突然遅くなった」という現象は、この綱引きが表に出たものです。

実行モデルは統一インタフェースの上に成り立っています。open/next/close という 3 メソッドの契約があるので、演算子を自由に組み合わせられます。一方で、この契約は呼び出しオーバーヘッドの原因でもあります。ベクトル化は契約の粒度を変え、コード生成は契約そのものを消すことで、このオーバーヘッドを減らしています。

メモリー不足時の崩れ方は演算子ごとに違います。ソートは work_mem を超えると外部マージへ移行し、なだらかに遅くなります。ハッシュは急激に悪化します。ハッシュ結合は両側の入力を、ハッシュ集約は唯一の入力を分割し直すためです。

NULL がもたらす第 3 の真理値は、全層に影響します。三値論理は、どの書き換えが許されるかを決める制約です。WHERE が UNKNOWN を落とすこと、NOT IN を反結合にできないこと、外部結合の下に述語を常に降ろせるとは限らないことは、すべてここから導かれます。

物理層は間接参照で柔軟性を得ています。TID がスロット番号を指すので、ページ内でタプルを動かしてもインデックスを書き換えずに済みます。バッファープールも、実行エンジンとディスクの間に挟まる間接参照です。

耐久性はすべて WAL の順序規則に支えられています。「データページより先に WAL を永続化する」という 1 つの規則があるので、STEAL/NO-FORCE という高速なバッファー管理を安全に使えます。

ここまで読むと、EXPLAIN (ANALYZE, BUFFERS) の出力の読み方が変わるはずです。Hash Join の下にぶら下がる Hash はブロッキング演算子で、cost=2387.50..25687.50 の左の数字は第 7 章の起動コスト、rows=55000 は第 6 章の推定、Buffers: shared hit=1024 read=9976 は第 14 章のバッファープールの挙動です。ただし read=9976 はストレージからバッファープールへ読んだブロック数で、pread(2) の呼び出し回数と 1 対 1 に対応するとは限りません。


参考資料

論文

  • Selinger, P. G. et al. Access Path Selection in a Relational Database Management System. SIGMOD 1979. — コストベース最適化と結合順序 DP の原典。interesting orders もここで提案されている。
  • Mackert, L. F., Lohman, G. M. Index Scans Using a Finite LRU Buffer: A Validated I/O Model. ACM TODS 14(3):401–424, 1989. — 第 7 章の index_pages_fetched の原典。
  • Graefe, G. Volcano — An Extensible and Parallel Query Evaluation System. IEEE TKDE 1994. — 反復子モデルの定式化。初出は SIGMOD 1990 の Encapsulation of Parallelism in the Volcano Query Processing System。
  • Graefe, G. The Cascades Framework for Query Optimization. IEEE Data Eng. Bull. 1995. — 規則ベース top-down 最適化。
  • Boncz, P., Zukowski, M., Nes, N. MonetDB/X100: Hyper-Pipelining Query Execution. CIDR 2005. — ベクトル化実行の起点。
  • Neumann, T. Efficiently Compiling Efficient Query Plans for Modern Hardware. VLDB 2011. — プッシュベースのコード生成。
  • Moerkotte, G., Neumann, T. Analysis of Two Existing and One New Dynamic Programming Algorithm for the Generation of Optimal Bushy Join Trees without Cross Products. VLDB 2006. — DPccp(連結部分グラフ対の列挙)。
  • Moerkotte, G., Neumann, T. Dynamic Programming Strikes Back. SIGMOD 2008. — DPhyp。超グラフへの一般化。
  • Leis, V. et al. How Good Are Query Optimizers, Really? PVLDB 9(3):204–215, 2015. — カーディナリティー推定誤差の実測。
  • Mohan, C. et al. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS 1992. — WAL と復旧の標準的な定式化。

ソースコード

書籍

  • Hellerstein, J. M., Stonebraker, M., Hamilton, J. Architecture of a Database System. Foundations and Trends in Databases, 2007.
  • Moerkotte, G. Building Query Compilers. (オンラインで公開されている草稿)
  • Petrov, A. Database Internals. O'Reilly, 2019.

関連記事