Article

PostgreSQL 16の新機能で実現するパフォーマンス改善

高田晃太郎
PostgreSQL 16の新機能で実現するパフォーマンス改善

PostgreSQLはDB-Enginesの「DBMS of the Year」を複数年で受賞し続け、エンタープライズ採用が拡大しています[1,2]。この成長を支えているのは、派手さよりも地道な性能・運用性の改善です。PostgreSQL 16では、クエリ実行の並列化とプランナ(実行計画を決めるコンポーネント)の精度向上、I/O可視化、論理レプリケーションの強化がまとまり良く進みました[3,4]。単発のマイクロベンチにとどまらず、実務の待ち時間短縮とコスト削減に効く改良が多いのが特徴です。公式リリースノートとドキュメント、公開情報を読み解き、一般的な検証観点も交えて、CTOやエンジニアリーダーが意思決定しやすい粒度で整理します。

なぜ今、PostgreSQL 16に注目するのか

PostgreSQL 16の価値は、一つの「目玉機能」ではなく、複数の小さな改良が積み重なってレイテンシの裾野を削る点にあります[3,4]。プランナと並列実行の改善はCPUを活かしやすくし、I/O可視化はボトルネックの所在を明確にします[5]。さらに論理レプリケーションの強化は、ダウンタイムを抑えた移行や分析系へのスケールアウトを現実的にします。例えば、バッチや分析系の重い結合で並列度が効きやすくなると、同じSLOをより小さなインスタンスタイプで満たせる可能性が生まれます。I/O視点では新登場のpg_stat_io(I/O統計ビュー)がキャッシュヒットの裏側を可視化し、work_memやshared_buffersといったGUC(サーバ設定パラメータ)の判断をデータドリブンにします[5]。レプリケーションでは、スタンバイからの論理デコードや適用側の並列化がボトルネックを緩和し、初期同期や適用遅延の短縮に寄与します[6,7]。

クエリ実行エンジンの強化を生かす設計とチューニング

PostgreSQL 16では、並列実行が適用されるパターン拡張とプランナの見積もり精度向上が重なり、ハッシュ結合や集約、パーティションスキャンで効果が出やすくなりました[3,4]。実務ではテーブル設計と統計情報の鮮度、そして適切なGUCの組み合わせが効きます。特にmax_parallel_workers_per_gather(並列度の上限)とwork_mem(ソート・ハッシュに使うメモリ)は、並列効率とスピル(ディスク退避)の頻度に直結します。統計の粒度を上げる場合はextended statistics(拡張統計)を視野に入れ、相関の強い列群にndistinctやdependenciesを定義してプラン選択の精度を高めます。プランの安定性を優先したい集計処理では、インクリメンタルソート(先頭キーが既に整列済みなら追加コストが小さいソート)が効く順序設計に寄せるのも有効です。

並列実行とプランナ改善の具体策

並列実行は万能ではありませんが、適用されると全表走査やハッシュ結合の待ち時間を短縮しやすくなります。セッション単位で並列度を試す場合はGUCで制御すると安全です。以下はpsycopg 3を用い、同一クエリを並列度違いで計測する最小コードです。例のクエリは大規模な明細テーブルとディメンションを結合し、期間と属性で絞る典型的な分析パターンを想定しています。

import time
import psycopg
from contextlib import closing

DSN = "host=127.0.0.1 port=5432 dbname=app user=app password=secret"

QUERY = """
EXPLAIN (ANALYZE, BUFFERS, TIMING) 
SELECT d.category, sum(f.amount) AS revenue
FROM fact_sales f
JOIN dim_item d ON d.item_id = f.item_id
WHERE f.sold_at >= date '2024-01-01' AND f.sold_at < date '2024-04-01'
GROUP BY d.category
ORDER BY revenue DESC
LIMIT 20;
"""

def run_with_parallel(workers: int) -> float:
    with closing(psycopg.connect(DSN)) as conn:
        conn.autocommit = True
        with conn.cursor() as cur:
            cur.execute("SET max_parallel_workers_per_gather = %s", (workers,))
            cur.execute("SET parallel_leader_participation = on")
            cur.execute("SET work_mem = '256MB'")
            t0 = time.time()
            cur.execute(QUERY)
            _ = cur.fetchall()
            return time.time() - t0

if __name__ == "__main__":
    try:
        for w in (0, 2, 4):
            elapsed = run_with_parallel(w)
            print(f"workers={w}, elapsed={elapsed:.3f}s")
    except psycopg.Error as e:
        print(f"DB error: {e}")

効果はデータ分布やI/O状況に依存しますが、CPUに余力がありキャッシュが効いている状況では、workers=0に対して2や4で短縮が見られることがあります。EXPLAINのプランにParallel HashやGatherが現れること、Buffersでshared hitが優位であることを確認してから本番適用を検討すると良いでしょう。プランの揺れが問題になる場合は、統計の更新タイミングをジョブと合わせ、extended statisticsの導入で見積もり精度を上げると安定します。

JITやインクリメンタルソートに寄せる設計

PostgreSQL 16でもJIT(実行時コンパイル)はCPUバウンドな集約やフィルタで効きますが、短命クエリではオーバーヘッドが勝つことがあります[9]。セッションや関数単位で有効化を切り替え、集計バッチの時間帯だけオンにするのが実務的です。インクリメンタルソートは、先頭キーが既にソート済みのプランでのみ効果が出ます。ORDER BYとインデックスの先頭キーを合わせる基本に立ち返り、外部結合や集約の直前で並び替えを減らせるようリレーション設計を調整すると、ディスクへのスピルを抑制できます。

データロードと運用可視化の前進

取り込みの遅さは日次バッチの圧迫やアドホック分析との競合を招き、SLOを壊します。PostgreSQL 16ではCOPY(高速取り込み経路)の最適化が進み、クライアント側でも効率的なストリーミングを使うことで高スループットが狙えます[4]。取り込みと同じくらい大切なのが可視化で、pg_stat_ioはバッファキャッシュとOSキャッシュ、ストレージの境界で何が起きているかのヒントを与えます[5]。

COPYとインデックス運用の実務最適化

アプリケーションからの大量取り込みでは、トランザクションの粒度を大きく取り、サーバ側でのソートやハッシュに十分なwork_memを与えると安定します。pgxのCopyFromは安全で高速な取り込みAPIです。以下はGoで100万行をCopyFromし、エラーハンドリングと計時を含む例です。取り込み前にターゲットテーブルの不要なインデックスを外しておき、取り込み後に一括作成するのが定石です。

package main

import (
    "context"
    "fmt"
    "log"
    "math/rand"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    cfg, err := pgxpool.ParseConfig("postgres://app:secret@127.0.0.1:5432/app?pool_max_conns=8")
    if err != nil {
        log.Fatalf("parse config: %v", err)
    }
    pool, err := pgxpool.NewWithConfig(ctx, cfg)
    if err != nil {
        log.Fatalf("connect: %v", err)
    }
    defer pool.Close()

    // optional: tune session settings for bulk load
    _, _ = pool.Exec(ctx, "SET synchronous_commit = off; SET maintenance_work_mem = '1GB';")

    rows := make([][]any, 0, 1000)
    gen := func(n int) [][]any {
        rows = rows[:0]
        for i := 0; i < n; i++ {
            rows = append(rows, []any{time.Now().Add(-time.Duration(rand.Intn(86400)) * time.Second), rand.Intn(1000000), rand.Float64() * 100.0})
        }
        return rows
    }

    start := time.Now()
    total := 0

    for batch := 0; batch < 1000; batch++ {
        batchRows := gen(1000)
        copySrc := pgx.CopyFromRows(batchRows)
        n, err := pool.CopyFrom(ctx, pgx.Identifier{"fact_import"}, []string{"sold_at", "item_id", "amount"}, copySrc)
        if err != nil {
            log.Fatalf("copy from failed at batch %d: %v", batch, err)
        }
        total += int(n)
    }

    elapsed := time.Since(start)
    fmt.Printf("ingested=%d rows, throughput=%.1f rows/s\n", total, float64(total)/elapsed.Seconds())
}

初期ロードや大規模なマージでは、インデックス再作成を並列ビルドの効くパターンに寄せ、式インデックスや部分インデックスの要否を改めて見直すと、クエリ時の利益とロード時のコストのバランスが整います。ロード後はANALYZE(統計更新)を忘れず、統計の鮮度を担保します。

pg_stat_ioでI/Oボトルネックを見抜く

PostgreSQL 16で追加されたpg_stat_ioは、backend_type(プロセス種別)やcontext(I/Oコンテキスト)ごとの読み書き状況を可視化します[5]。shared_buffers内のヒットとミス、チェックポイントや背景プロセスのI/Oを分けて観測できるため、「遅いのはアプリか、DBか、ストレージか」の切り分けが速くなります。以下はPythonでバージョンを検査しつつ、pg_stat_ioのスナップショットを取得する簡易スクリプトです。

import psycopg
from contextlib import closing

DSN = "host=127.0.0.1 port=5432 dbname=app user=app password=secret"

with closing(psycopg.connect(DSN)) as conn:
    with conn.cursor() as cur:
        cur.execute("SHOW server_version_num")
        v = int(cur.fetchone()[0])
        if v < 160000:
            raise RuntimeError("pg_stat_io requires PostgreSQL 16+")
        cur.execute("SELECT backend_type, context, reads, read_time, writes, write_time FROM pg_stat_io ORDER BY read_time DESC LIMIT 10")
        for row in cur.fetchall():
            print(row)

read_timeやwrite_timeの大きい行に注目し、チェックポイント由来の負荷が高ければcheckpoint_timeoutやmax_wal_size、背景書き込みの挙動を見直します。shared hitが低ければ、クエリ側のアクセス局所性を高めるか、並列度とwork_memを再検討します。I/Oの傾向が見えれば、インスタンスの垂直スケールか、スキーマとクエリの横方向の最適化か、投資先が定まりやすくなります。ドキュメントは公式監視統計の章が起点になります[5]。

論理レプリケーション強化でスケールと移行を楽にする

PostgreSQL 16では、スタンバイサーバからの論理デコードがサポートされ、プライマリへの負荷集中を避けられるようになりました[6,7]。これにより、本番の書き込み遅延を増やすことなく、分析系や検索系への変更ストリーム配信がしやすくなります。適用側でも大きなトランザクションの並列適用などが整備され、初期同期の並列度や適用レイテンシの短縮が見込めます[4]。レプリケーションは運用の複雑性を上げますが、フィルタリングや適用並列度の制御が柔軟になったことで、サブセット同期やテナント分割の設計が現実的になっています[3]。

スタンバイからのデコードと適用の設計

プライマリのWAL生成→スタンバイ受信→論理デコードという流れにすることで、プライマリのCPUとI/Oを温存できます[6,7]。適用側ではサブスクライバ単位で並列適用ワーカーを増やし、テーブルごとの初期同期スロットリングと合わせて遅延を抑えます。以下は最小構成の設定例です(ALTER SYSTEMは権限と再読み込みが必要)[4]。

import psycopg
from contextlib import closing

PRIMARY_DSN = "host=10.0.0.10 dbname=app user=replicator password=***"
SUB_DSN = "host=10.0.0.20 dbname=dw user=replicator password=***"

# primary: create publication with row/column filtering
with closing(psycopg.connect(PRIMARY_DSN)) as conn:
    conn.autocommit = True
    with conn.cursor() as cur:
        cur.execute("CREATE PUBLICATION pub_sales FOR TABLE fact_sales (sold_at, item_id, amount) WHERE (sold_at >= date '2024-01-01')")

# subscriber: create subscription with parallel apply tuning (requires 16+)
with closing(psycopg.connect(SUB_DSN)) as conn:
    conn.autocommit = True
    with conn.cursor() as cur:
        cur.execute("CREATE SUBSCRIPTION sub_sales CONNECTION 'host=10.0.0.10 dbname=app user=replicator password=***' PUBLICATION pub_sales WITH (two_phase = false, copy_data = true)")
        cur.execute("ALTER SUBSCRIPTION sub_sales SET (streaming = on)")
        cur.execute("ALTER SUBSCRIPTION sub_sales SET (binary = on)")
        # In PostgreSQL 16, apply workers can be increased per subscription
        cur.execute("ALTER SYSTEM SET max_parallel_apply_workers_per_subscription = 4")

二相コミットの扱いやDDLの伝播、スキーマ差異への対応など注意点は多く、変更ストリームの整合性要件に応じて監視とリトライ方針を固める必要があります。公式の論理レプリケーションの章を起点に、実データでの遅延と競合の観測を欠かさないことが肝要です[7]。

分析系へのフィードと遅延管理

分析系や検索系に変更を届けるときは、レプリケーションラグの上限をビジネス側のSLOと合意しておくと運用判断が楽になります。適用遅延が閾値を超えるときは、適用ワーカー数の引き上げやテーブルごとのフィルタリング見直しで負荷を均し、場合によってはスナップショットの取り直しを検討します。次の例は、適用遅延とサブスクリプションの状態を簡易に観測するためのGoスニペットです[8]。

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "postgres://replicator:***@10.0.0.20:5432/dw?sslmode=disable")
    if err != nil { log.Fatal(err) }
    defer db.Close()

    ctx := context.Background()
    // On subscriber, pg_stat_subscription reports apply progress and lag-related timestamps
    q := `
        SELECT subname, worker_type, status,
               EXTRACT(EPOCH FROM (now() - COALESCE(latest_end_time, now()))) AS apply_delay_seconds
        FROM pg_stat_subscription
        ORDER BY subname, worker_type;
    `
    rows, err := db.QueryContext(ctx, q)
    if err != nil { log.Fatal(err) }
    defer rows.Close()
    for rows.Next() {
        var subname, workerType, status string
        var delaySeconds sql.NullFloat64
        if err := rows.Scan(&subname, &workerType, &status, &delaySeconds); err != nil { log.Fatal(err) }
        fmt.Printf("%s worker=%s status=%s delay=%.0f s\n", subname, workerType, status, delaySeconds.Float64)
    }
    if err := rows.Err(); err != nil { log.Fatal(err) }
}

ラグが伸びる時間帯とアプリ側の負荷特性を重ね合わせ、適用側の並列度やネットワーク帯域、ストレージIOPSのいずれが支配的かを見極めると、投資先が明白になります。ストリーミング適用をオンにするだけでもピークリソース使用の平準化に効く場面は多くあります[3,4]。

アップグレード計画とROIを数式で捉える

パフォーマンス改善は最終的にコストとSLOに落ちます。PostgreSQL 16の改良は、クエリ待ち時間の中央値や95パーセンタイル、バッチ処理の締め切りまでの余裕、あるいはピーク時のCPU飽和率の低下として現れます[3,4]。例えば、日次ETLの取り込みと集計が短縮できれば、クラウドの時間課金やオンコールの緊急対応件数の抑制を通じて運用費削減に接続します。計測は嘘をつかないため、代表的なN本のクエリを選び、前後でEXPLAIN ANALYZEとpg_stat_io、pg_stat_statementsのスナップショットを必ず残します。

以下はpg_stat_statementsから合計時間の大きいクエリを抽出し、改善候補をリストアップするGoの例です。拡張の有効化はユーティリティで済ませ、アプリ側では読み取るだけに留めます。

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "postgres://app:secret@127.0.0.1:5432/app?sslmode=disable")
    if err != nil { log.Fatal(err) }
    defer db.Close()

    ctx := context.Background()
    q := `SELECT query, calls, total_time, mean_time FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10`
    rows, err := db.QueryContext(ctx, q)
    if err != nil { log.Fatal(err) }
    defer rows.Close()

    for rows.Next() {
        var query string
        var calls int64
        var total, mean float64
        if err := rows.Scan(&query, &calls, &total, &mean); err != nil { log.Fatal(err) }
        fmt.Printf("calls=%d total=%.2fms mean=%.2fms\n%s\n---\n", calls, total, mean, query)
    }
    if err := rows.Err(); err != nil { log.Fatal(err) }
}

最後に、セーフティネットとしての設定変更をコードで束ねておくと、検証やロールバックが容易になります。次のPython例は、検証用セッションのガードレールをまとめて設定するスニペットです。

import psycopg
from contextlib import closing

DSN = "host=127.0.0.1 port=5432 dbname=app user=app password=secret"

SETUP = """
SET statement_timeout = '5min';
SET lock_timeout = '5s';
SET jit = on;               -- batch window only
SET work_mem = '256MB';     -- adjust to avoid spills
SET max_parallel_workers_per_gather = 4;
"""

with closing(psycopg.connect(DSN)) as conn:
    with conn.cursor() as cur:
        cur.execute(SETUP)
        print("session ready for performance tests")

ここまでの取り組みを、ビジネス価値に翻訳しておきましょう。ピークCPUが下がれば、同一SLOで一段小さなインスタンスに移行できる可能性が生まれます。ETLが早く終われば、レポートの鮮度が上がり、意思決定のタイムラグが縮みます。変更投資に対する純便益は、計測と観測の習慣によって初めて信頼できる数に変わります。

まとめ:小さな改善の積み重ねを、確かな成果に

PostgreSQL 16は、並列実行とプランナの改善、pg_stat_ioによるI/O可視化、レプリケーション機能の強化が、それぞれ独立して効くのではなく、全体最適として相乗します。まずは代表的な重いクエリを三つ選び、前後のプランとI/Oの違いを観測し、次に取り込みと統計更新の運用を整え、最後にレプリケーションの経路を最適化するという順番が現実的です。移行の是非を迷うときは、ひとつのサービスやテーブルに限定したA/B検証で、小さく始めて確信を持って広げてください。

性能は設計・実装・運用の掛け算で決まり、PostgreSQL 16はそのどれにも手を伸ばせるアップデートです。今日、ひとつの計測スクリプトを用意し、ひとつのクエリでEXPLAIN ANALYZEを取り、ひとつの設定を見直してみませんか。

参考文献

  1. DB-Engines Blog. PostgreSQL is our DBMS of the Year. https://db-engines.com/en/blog_post/106
  2. DB-Engines Blog. DBMS of the Year. https://db-engines.com/en/blog_post/110
  3. InfoWorld. PostgreSQL 16 advances query parallelism. https://www.infoworld.com/article/2338556/postgresql-16-advances-query-parallelism.html
  4. PostgreSQL 16 Release Notes. https://www.postgresql.org/docs/16/release-16.html
  5. PostgreSQL Documentation 16: Monitoring Database Activity (pg_stat_io). https://www.postgresql.org/docs/16/monitoring-stats.html
  6. Crunchy Data Blog. Logical replication on standbys in Postgres 16. https://www.crunchydata.com/blog/logical-replication-on-standbys-in-postgres-16
  7. PostgreSQL Documentation 16: Logical Replication. https://www.postgresql.org/docs/16/logical-replication.html
  8. PostgreSQL Documentation 16: Monitoring of Logical Replication. https://www.postgresql.org/docs/16/logical-replication-monitoring.html
  9. PostgreSQL Documentation 16: Just-in-Time Compilation (JIT). https://www.postgresql.org/docs/16/jit.html