はじめに
「Just use Postgres」という言葉を初めて聞いたのは、いつだったか覚えていません。Twitter か Hacker News か、あるいは社内の Slack か。どこで聞いたにせよ、私の反応は決まっていました。「また極端なことを言う人がいる」と。
「それ、〇〇でもできますよ」——この手のフレーズはもう100回は聞いてきました。そして大抵の場合、その〇〇は専用ツールに置き換えられていきます。技術が専門分化していくのは自然な流れです。
全文検索なら Elasticsearch。時系列データなら InfluxDB。メッセージキューなら RabbitMQ。それぞれの分野に専門家がいて、専用のソリューションがあって、ベストプラクティスがあります。「とりあえず Postgres で」なんて、それは思考停止ではないか、と。でも、心のどこかで気になっていたんです。
ソフトウェアエンジニアとして 10 年近く働いてきて、システムが複雑化していく様子を何度も見てきました。「全文検索だから Elasticsearch」と導入したら、その運用は誰がやるのか。バックアップは? モニタリングは? バージョンアップは? 構成図に新しい箱が増えるたびに、誰かが深夜 3 時のアラート対応をする可能性が増えます。その「誰か」は、たいてい自分です。
以前関わったプロジェクトでは、Postgres、Redis、Elasticsearch、RabbitMQ、InfluxDB が同居していました。それぞれに理由があって導入されたはずですが、3 年後には「なぜこれが必要だったのか」を説明できる人が誰もいなくなっていました。ドキュメントはあっても、判断の背景までは残っていません。結局、「触ると怖いから残しておこう」という判断になります。技術的負債の典型です。
この本を手に取ったのは、そういう日常からの逃避だったのかもしれません。
「Postgres だけで済むなら、楽になれる」
そんな甘い期待を持って読み始めました。そして、最初の数ページで気づきました。この本が言っているのは、私が思っていたことと少し違います。
「Postgres は万能だから全部 Postgres でやれ」ではありません。
「既に Postgres を使っているなら、新しいデータベースを追加する前に、まず Postgres で試してみよう」ということです。
その違いに気づいた瞬間、なんというか、肩の力が抜けました。
これは、銀の弾丸を売りつける本ではなかったんです。私たちが日々向き合っている「技術選定」という名の意思決定に、1 つの視点を提供してくれる本でした。
10 年近くこの仕事をしてきて、技術選定について 1 つ学んだことがあります。
新しい機能や技術が出たとき、いきなり飛びつかない。
どれだけ魅力的に見えても、まず「運用時にどうなるか」を考えます。誰がバックアップを取るのか。障害時に誰が対応するのか。3 年後にメンテナンスできる人がいるのか。流行りの技術を追いかけることと、本番環境で安定して動かすことは、別の話です。
これは、Postgres の中でも同じです。pgvector や TimescaleDB のような比較的新しい拡張、あるいは Postgres 本体の新機能についても、本番投入前に運用面を検討する必要があります。「Postgres だから安心」ではなく、「その機能が十分に枯れているか」を見極める姿勢が大事です。
かといって、新しいことを学ばないわけにもいきません。技術は進歩します。昨日のベストプラクティスが、明日には技術的負債になることもあります。
結局のところ、謙虚に学び続けるしかありません。
私が最近考えているのは、こういう基準です。
- 替えの利く技術は、流行に従う。フロントエンドのフレームワークとか、CI/CD ツールとか。入れ替えやすいものは、その時点でのベストを選べばいい。
- 替えの利きづらい基盤は、標準に従う。データベースとか、認証基盤とか。長く使うものは、実績のある標準的な選択をする。
- 競争優位の核は、自ら設計する。ビジネスの差別化に直結する部分は、自分たちで考え抜いて設計する。
Postgres は、競争優位の核になる場合もありますが、基本的には 2 番目の「替えの利きづらい基盤」であることが多いです。40 年以上の実績があり、コミュニティ主導で開発され、世界中で使われている標準的な選択肢。だからこそ、その可能性を正しく理解しておきたいと思いました。
だから、読み進めることにしました。正直に言うと、全部を理解できたわけではありません。「FOR UPDATE SKIP LOCKED」の仕組みを完全に説明しろと言われたら、今でもちょっと怪しいです。でも、それでいいと思うことにしました。
完璧に理解することが目的ではありません。
「Postgres で試した?」
その一言を、自信を持って言えるようになること。それが、この本を読む目的でした。なので、この読書感想文には私の手元で動かした実行結果と書籍の中身がごちゃ混ぜになっています。基本的に明記しているつもりですが抜けていたらごめんなさい。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。では、はじめていきます。
1. Meeting Postgres
「Just use Postgres」の再解釈
1.2 節の「Just use Postgres」の説明を読んで、自分の理解が間違っていたことに気づきました。
私はこれまで、「Just use Postgres」を技術選定の初手として捉えていました。「新規プロジェクトならとりあえず Postgres 立てとけ」みたいな。
でも、著者が書いているのは違います。
Does this mean Postgres has become a Swiss Army knife and the only database every developer needs? Certainly not.
著者は明確に否定しています。Postgres は万能ツールではない、と。
じゃあ「Just use Postgres」は何を意味するのでしょうか。
「既に Postgres を使っているチームが、新しいユースケース(地理空間、時系列、生成 AI など)が発生したとき、別のデータベースを追加する前に Postgres で解決できるか確認してみよう」
これがこのモットーの正しい解釈だと著者は言います。
インフラエンジニアとして 10 年近く運用してきた身としては、この視点の転換にハッとしました。
「Elasticsearch で全文検索やりたい」と言われた時、私は内心「またか…(心の中で構成図に新しい箱を追加する手が震える)」と思っていました。でも、「Postgres で試した?」と聞き返すことはしませんでした。自分の仕事を増やしたくないという気持ちが先に立って(自分が起こされるのにね)。
これ、逆だったんです。Postgres で解決できるなら、新しいデータベースを追加するより運用負荷は減ります。バックアップ戦略も、モニタリングも、アラートルールも、既存のまま使えます。運用対象が増えるたびに、前世で何をしたのかと深夜に考える機会も減ります。
この本を読み終えて、「Postgres で試した?」と自信を持って聞き返せるようになったと思います。良いか悪いかは別として。
なぜ Postgres が人気なのか
1.1 節では、Postgres が人気な 3 つの理由が挙げられています。
- オープンソース・コミュニティ主導: 1994 年に MIT ライセンスでオープンソース化。単一ベンダーではなくコミュニティ主導で開発。
- エンタープライズ対応: 35 年の開発で培われた信頼性と堅牢性。年次メジャー バージョン リリース、段階的 改善 重視。
- 拡張性: Michael Stonebraker が設計当初から拡張性を重視。JSON、時系列、全文検索、ベクトル類似検索など多様なユースケースに対応。
この 3 つ目の「拡張性」が、「Just use Postgres」を可能にしている核心だと感じました。
著者の言葉を借りれば、Postgres は「従来のトランザクショナルワークロードを超えた幅広い用途に対応できる」。だから、新しいユースケースが出てきても、まず Postgres で試す価値があります。
運用の観点からも、この 3 つは重要です。
- オープンソースだから、ベンダーロックインのリスクがない
- エンタープライズ対応だから、夜中3時にPagerDutyが鳴って「どの DB だ...?」と確認する時間を省略できるインフラエンジニアとして信頼できる
- 拡張性があるから、新しいデータベースを追加する代わりに既存の Postgres を活用できる
Docker でサクッと起動
1.3 節では、Docker での起動方法が紹介されています。
docker run --name postgres \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password \
-p 5432:5432 \
-v postgres-volume:/var/lib/postgresql/data \
-d postgres:17.2
「1 分以内にコンテナとして起動可能」と Summary に書いてありますが、本当にその通りです。
この手軽さが、「Just use Postgres」の実践を支えています。新しいユースケースを試すために、まず手元で動かしてみる。それが 1 分でできます。ツールを開発していることがあるのですがこれはツールの普及にめちゃくちゃ大事です。
開発環境だからシンプルな設定で OK ですが、本番では当然違います。ユーザー名は postgres 以外にする、パスワードは環境変数じゃなく secrets で管理する、など。でも、それはこの本の scope 外でしょう。
psql と generate_series
1.4 節では psql での接続方法、1.5 節では generate_series を使ったモックデータ生成が紹介されています。
INSERT INTO trades (id, buyer_id, symbol, order_quantity, bid_price, order_time) SELECT id, random(1,10) as buyer_id, (array['AAPL','F','DASH'])[random(1,3)] as symbol, random(1,20) as order_quantity, round(random(10.00,20.00), 2) as bid_price, now() as order_time FROM generate_series(1,1000) AS id;
generate_series と random の組み合わせで、複雑なモックデータを SQL だけで生成できます。外部ツール不要。
これも「Just use Postgres」の一例だと感じました。「テストデータ生成ツールが必要だ」と言い出す前に、Postgres の標準機能で解決できます。
普段、テストデータ生成はアプリ側(Rust)でやることが多かったのですが、シンプルなケースなら generate_series で十分かもしれません。
試しに手を動かしてみたら、いくつか発見がありました。
まず、generate_series は日付生成にも使えます。generate_series('2025-01-01'::date, '2025-12-31', '1 day') でカレンダーテーブルを一発生成できます。これは便利。
次に、random() は毎回異なる値を返すので、再現可能なテストには setseed() を事前に呼ぶ必要があります。これを知らずに「テスト結果が毎回違う!」と焦った経験があります。
そして一番ハマったのは、配列のインデックスが 1 始まりだということ。(array['AAPL','F','DASH'])[random(1,3)] のように 1 から始めないと想定外の結果になります。Rust や Python に慣れていると、0 から始めたくなるんですよね。私の直感を裏切るポイントでした。ちょっと昔だとこちらの資料とかはめちゃくちゃ良いのでオススメです。
基本クエリ
1.6 節では、基本的な SQL クエリが紹介されています。
SELECT symbol, count(*) AS total_volume FROM trades GROUP BY symbol ORDER BY total_volume DESC;
著者は count(*) が「Postgres で特別に最適化されている」と書いています。この本を通して、Postgres の内部動作についての理解が深まりました。DBといえばそーだいさんの資料を読み漁ってほしいです。
2. Standard RDBMS capabilities
データベースの三層構造を理解していなかった
この章で再認識したのは、Database → Schema → Table という三層構造の実践的な使い方です。
10 年近く Postgres を運用してきた中で、Schema は使ってきました。ただ、この章で説明されているような「マイクロサービスのモジュールごとにスキーマを分ける」という設計パターンは、改めて整理されると納得感があります。
この章で説明されている eコマースプラットフォームの設計が、その例です。
coffee_chain (database)
├── products (schema)
│ ├── catalog (table)
│ └── reviews (table)
├── customers (schema)
│ └── accounts (table)
└── sales (schema)
├── orders (table)
└── order_items (table)
マイクロサービスのモジュールごとにスキーマを分ける。この設計パターン自体は知っていましたが、この本の整理の仕方は参考になります。
著者は明確に書いています。
Each application module or microservice has its own schema containing all the related data.
これなら、アプリケーション層のアーキテクチャとデータベース層の構造が一致します。名前の衝突も避けられます。でも、同じデータベース内だから、JOIN で複数スキーマのテーブルをまたいでクエリできます。
マルチテナント構成において Database レベルで分離していることも納得がいきました。テナントごとにデータベースを分け、リソースを共有しながら完全に隔離します。スケールアウト時には特定のテナントだけ別サーバーへ移動可能です。
この設計思想、次のプロジェクトで導入を検討しようと思います。
制約はデータベースでやるべきか
2.3 節のデータ整合性の話で、著者のスタンスが面白かったです。
著者のチームは、最初はアプリ層ですべてを検証する想定でした。でも、実際にプロダクトを構築していく中で、アプリ層のチェックが破られてデータ整合性の問題が発生しました。
その経験から、著者はこう述べています。
we decide to add additional constraints at the database level.
私の経験でも、制約をデータベースに入れるべきか、アプリ層でやるべきかという論争が何度もありました。
著者は両方を推奨しています。アプリ層でチェックしつつ、データベース層にも防御線を張ります。
この章では、バグでアプリ層のチェックが破られたとき、外部キー制約がデータの不整合を防いだ例が出てきます。
ERROR: insert or update on table "reviews" violates foreign key constraint "products_review_product_id_fk" DETAIL: Key (product_id)=(1004) is not present in table "catalog".
アプリのバグで product_id が 4 じゃなくて 1004 になっていました。でも、外部キー制約があったから、データベースにゴミが入らずに済みました。
多層防御。これがデータ整合性の正しいアプローチだと感じました。
アプリ層だけに頼ると、コードが変わったときに破綻します。データベース層だけに頼ると、エラーハンドリングが遅れて UX が悪化します。両方でやるべきです。
トランザクション分離レベルの実践理解
2.4 節のトランザクションで、MVCC と read committed 分離レベルの説明が具体的で良かったです。
理論は知っていました。でも、この章の Table 2.1 の 2 つの psql セッションを並行実行する例を見て、実際の動きがイメージできるようになりました。
2 つのトランザクションが同じ商品 (id=1) の在庫数を同時に減らそうとします。
- トランザクション 1 が
UPDATEを実行(まだコミットしてない) - トランザクション 2 が
SELECTを実行 → まだ 199 が見える(dirty read を防いでいる) - トランザクション 2 が
UPDATEを実行 → ブロックされる - トランザクション 1 が
COMMIT→ トランザクション 2 がアンブロックされる - トランザクション 2 の
UPDATEが最新の値(198)を読み直して実行される
最後のポイントが重要でした。ブロックが解除された後、トランザクション 2 は再度値を読み直します。だから、結果は 197 になります(199 → 198 → 197)。
もしこれがなかったら、トランザクション 2 は古い値(199)から 1 を引いて 198 にしてしまい、トランザクション 1 の更新が消えます(lost update)。
Postgres の read committed は dirty write も防ぎます。だから、本番環境でデフォルトの分離レベルとして十分に使えます。
もちろん、phantom read や non-repeatable read を防ぎたいケースもあります。そのときは repeatable read や serializable を使います。でも、大半のユースケースでは read committed で問題ありません。
この理解、実際に手を動かさないと身につきませんでした。
データベース関数で何をやるべきか
2.6 節の関数とトリガーは、この章で一番刺激的でした。
著者は order_add_item と order_checkout という 2 つの PL/pgSQL 関数を実装しています。ショッピングカートの管理ロジックをデータベース関数として実装した例です。
最初は「これ、アプリ層でやればいいんじゃない?」と思いました。モダンなマイクロサービスアーキテクチャを信奉する我々にとって、データベース関数は「おじいちゃんの時代の遺物」みたいなイメージがありました。
でも、著者の説明を読んで納得しました。書籍には「At least two scenarios come to mind」として 2 つのシナリオが紹介されています。
- 複雑なビジネスロジックがデータと密結合している場合 → すべてのクライアントアプリやマイクロサービスで同じロジックを実装するより、データベース関数 1 つで済む
- 複数ステップの処理でアプリとデータベース間の往復が必要な場合 → 大量のデータ転送が必要なとき、データベース内で完結させたほうが効率的
order_add_item 関数の実装を見ると、この 2 つの利点がよくわかります。
CREATE OR REPLACE FUNCTION sales.order_add_item(customer_id_param INT, product_id_param INT, quantity_param INT) RETURNS TABLE (...) AS $$ DECLARE pending_order_id UUID; BEGIN -- 1. 既存の pending order を探す SELECT id INTO pending_order_id FROM sales.orders WHERE customer_id = customer_id_param AND status = 'pending'; -- 2. なければ作る IF pending_order_id IS NULL THEN INSERT INTO sales.orders (customer_id, status) VALUES (customer_id_param, 'pending') RETURNING id INTO pending_order_id; END IF; -- 3. 商品を追加または更新(MERGE 文) MERGE INTO sales.order_items AS oi ... -- 4. 結果を返す RETURN QUERY SELECT ...; END; $$ LANGUAGE plpgsql;
これをアプリ層でやろうとすると、複数のクエリを順次実行する必要があります。
SELECTで pending order があるか確認- なければ
INSERTで作成 SELECTで商品の価格を取得INSERTorUPDATEで order_items に追加SELECTで最終的なカート内容を取得
アプリとデータベース間で何度もデータをやり取りする必要があり、ネットワークレイテンシの影響を受けます。
データベース関数なら 1 回の呼び出しで完結します。しかも、トランザクショナルに実行されます。途中でエラーが起きたら全部ロールバックされます。
でも、すべてをデータベース関数でやるべきではありません。著者も「少なくとも 2 つのシナリオ」と言っています。つまり、適切なユースケースを見極めることが重要です。
私の基準はこうです。
この判断基準、次のプロジェクトで使いたいです。
ちなみに、PL/pgSQL を書いていて何度かハマったポイントがあります。SELECT ... INTO で結果が 0 行の場合、変数は NULL になります。エラーにはなりません。これを知らずに「なぜ NULL が入る?」と 30 分悩んだことがあります。
あと、ON CONFLICT DO UPDATE で新しい値を参照するには EXCLUDED を使います。EXCLUDED.quantity のように書きます。最初は「新しい値をどう参照するんだ?」と混乱しました。
一番タチが悪いのは、変数名とカラム名の衝突です。SELECT * FROM orders WHERE order_id = order_id と書くと、両方が変数として解釈されて全行が返ってきます。デバッグが本当に難しい。だから p_customer_id や v_order_id のようにプレフィックスを付けるのがベストプラクティスです。
トリガーは「見えない魔法」になりやすい
2.6.2 節のトリガーの例も実践的でした。
order_items テーブルに変更があったら自動的に orders.total_amount を更新します。
CREATE TRIGGER trigger_update_order_total AFTER INSERT OR UPDATE OR DELETE ON sales.order_items FOR EACH ROW EXECUTE FUNCTION sales.update_order_total();
これはシンプルで便利ですが、トリガーは「見えない魔法」になりやすいと感じました。
アプリ層のエンジニアが INSERT INTO sales.order_items を実行したとき、裏で sales.orders が更新されていることに気づかないかもしれません。
トリガーが増えると、データベースのパフォーマンス問題の原因を追うのが難しくなります。「なぜこの INSERT が遅い?」と思ったら、実は裏で 3 つのトリガーが動いていた、みたいな。
著者はトリガーの適切なユースケースを挙げています。
Triggers are particularly useful in audit scenarios, where you need to track who made changes in the database, or in event-driven architectures.
- 監査ログ(誰がいつ変更したか)
- イベント駆動アーキテクチャ(変更を他のシステムに通知)
トリガーを書いていて一度ハマったのは、NEW と OLD の使い分けです。NEW は INSERT/UPDATE で使用可能で、OLD は UPDATE/DELETE で使用可能。DELETE トリガーで NEW.order_id にアクセスしようとしてエラーになりました。COALESCE(NEW.order_id, OLD.order_id) のような対応が必要だと、その時初めて知りました。
あと、大量の行を更新する場合、行ごとにトリガーが発火してパフォーマンスが低下します。FOR EACH ROW のトリガーは便利ですが、一括更新のパフォーマンスには注意が必要です。
これ以外のケースでは、慎重に検討すべきだと思います。
View は「名前付きクエリ」
2.7 節の View の説明はシンプルで明快でした。
A view is essentially a named query that returns data in a tabular format.
View = 名前付きクエリ。この理解が正しいです。
複雑な JOIN と集計を含むクエリを、アプリ層の複数箇所で使い回すより、View として定義してしまいます。
CREATE VIEW sales.product_sales_summary AS SELECT c.name AS product_name, c.category, SUM(oi.quantity) AS total_quantity_sold, SUM(oi.quantity * oi.price) AS total_revenue FROM products.catalog c LEFT JOIN sales.order_items oi ON c.id = oi.product_id GROUP BY c.id ORDER BY total_quantity_sold DESC, total_revenue DESC;
アプリ層からはこうです。
SELECT * FROM sales.product_sales_summary WHERE category='coffee';
これだけで済みます。
Materialized View も便利ですが、リフレッシュのタイミングが悩ましいです。
- 手動リフレッシュ:ユーザーが「更新」ボタンを押したとき
- 定期リフレッシュ:
pg_cronで 1 時間ごと - イベント駆動:トリガーで特定のテーブルが更新されたとき
著者は 3 つのアプローチを提案していますが、ユースケースによって使い分けるべきです。
3. Modern SQL
SQL-92 の呪縛から解き放たれる
この章を読んで改めて認識したのは、自分がまだ SQL-92 の世界に閉じこもっていたという事実です。
著者の Markus Winand の言葉を引用します。
Since 1999, SQL is not limited to the relational model anymore. Back then, ISO/IEC 9075 (the "SQL standard") added arrays, objects, and recursive queries. In the meantime, the SQL standard has grown five times bigger than SQL-92. In other words: relational SQL is only about 20% of modern SQL.
SQL-92 は全体の 20% でしかありません。残りの 80% が Modern SQL です。
でも、正直に言うと、私は長年その 20% の世界で生きていました。CTE は知っていたけど、「読みやすさのための構文糖衣」程度にしか思っていませんでした。Window Functions も「集計が少し楽になるやつ」くらいの認識。Recursive Queries に至っては、「使う機会がない」と決めつけていました。
この章を読み終えて、自分がどれだけ Postgres の可能性を狭めていたかを再認識しました。
なぜ Modern SQL を使わないのか
著者は、Modern SQL が普及しない理由を 2 つ挙げています。
理由1: 獲得した知識の粘着性(Stickiness of gained knowledge)
Some developers learned SQL many years ago and mastered the SQL-92 version of the language for various data processing tasks. Even if their SQL queries are verbose or less efficient, the tasks are still solvable. As a result, many people continue doing things the way they originally learned.
痛いほど身に覚えがあります。
私が SQL を覚えたのは 15 年以上前です。当時の教科書は SQL-92 ベースで、GROUP BY、JOIN、Subquery があれば何でも解決できました。その成功体験が、今も私の手を縛っています。まるで、「ガラケーで十分じゃん」と言い張っていた 2010 年の自分を見ているようです。
「CTE を使えば読みやすくなる」と頭ではわかっていても、「でも、Subquery でも書けるしな」と思ってしまいます。結果、冗長で読みにくいクエリを量産します。後輩に「このネストの深さ、どこまで行くんですか…?」と言われたことは秘密です。
理由2: ORM フレームワーク
Some developers fully rely on ORM frameworks as a layer between their application and the database. They trust the ORM framework to generate SQL queries, believing it knows the best way to query or manipulate data.
これも痛い指摘です。やめてくれおれにきく。
ORM は確かに便利です。でも、ORM が生成するクエリは「汎用的なワークロード」を想定しています。Window Functions を使えば 1 回のクエリで済むケースでも、ORM は複雑な Self-Join を生成するかもしれません。
第 1 章で学んだ「Just use Postgres」の思想は、ORM へ任せる前に、Postgres で何ができるかを知ることにも通じます。
CTE(Common Table Expressions)は単なる Subquery の糖衣構文ではない
CTE は「読みやすい Subquery」という側面で使うことが多かったです。でも、この章を読んで、それ以上の価値があることを再確認しました。
Listing 3.3 の例では、2 つの CTE を使って「3 人以上のユーザーが聴いて、半分以下の時間しか再生しなかった曲」をランキングしています。
WITH plays_cte AS ( SELECT s.title, s.duration, p.play_duration, p.user_id FROM streaming.plays p JOIN streaming.songs s ON p.song_id = s.id WHERE p.play_start_time::DATE BETWEEN '2024-09-15' AND '2024-09-16' AND p.play_duration < (s.duration / 2) ), user_play_counts AS ( SELECT title, duration, COUNT(DISTINCT user_id) AS user_count, MIN(play_duration) AS min_play_duration, COUNT(*) AS total_play_count FROM plays_cte GROUP BY title, duration ) SELECT title, duration, min_play_duration, total_play_count FROM user_play_counts WHERE user_count >= 3 ORDER BY min_play_duration ASC LIMIT 3;
このクエリを Subquery で書いたら、どうなるでしょうか。ネストが深くなって、読みにくくなります。メンテナンスもしにくくなります。
でも、著者が強調しているのは「読みやすさ」だけではありません。
If we want to understand how a query is actually executed by Postgres, we can look at the query execution plan using the
EXPLAINstatement.
EXPLAIN の結果を見ると、Postgres は plays_cte を user_play_counts に fold しています。つまり、CTE を使っても、実行計画は効率的なままです。
これは重要なポイントです。以前のバージョンでは CTE が「最適化の壁」になることがありましたが、現在は改善されています。
実際に EXPLAIN ANALYZE で確認してみました。Postgres は CTE をインライン展開して最適化しています。以前は CTE が「最適化の壁」と呼ばれていましたが、現在のバージョンでは改善されています。CTE が展開されて効率的なプランになっていることが確認できました。Postgres は賢いです。
Data-modifying CTE という選択肢
Listing 3.4 で紹介されている Data-modifying CTE は、私にとって完全に新しい概念でした。
WITH updated_play AS ( UPDATE streaming.plays SET play_duration = 200 WHERE id = 30 RETURNING song_id, play_duration ) SELECT s.title, s.duration, CASE WHEN up.play_duration = s.duration THEN 'Moved Up the Rank' ELSE 'Rank Not Changed' END AS rank_change_status FROM updated_play up JOIN streaming.songs s ON s.id = up.song_id;
UPDATE の結果を RETURNING で受け取り、その結果を使って SELECT を実行します。これが 1 つのトランザクション内で完結します。
これまで、「UPDATE してから SELECT」という処理は、2 つのクエリを順番に実行していました。でも、Data-modifying CTE を使えば、1 つのクエリで完結します。アトミック性も保証されます。
なぜこの機能を今まで積極的に使ってこなかったのでしょうか。使う場面を意識していなかったというのが正直なところです。
Recursive Queries は「特殊なケースでしか使わない」という誤解
Recursive Queries は「組織の階層構造を扱う時に使う機能」という認識でした。実際、それ以外の場面で使う機会は多くありませんでした。でも、この章を読んで、活用範囲が広いことを再確認しました。
著者が例として挙げているのは、音楽ストリーミングサービスの「連続再生」のトラッキングです。
plays テーブルには played_after というカラムがあり、「この曲の前に再生された曲の ID」を保持しています。つまり、連続再生はリンクリストの構造を持っています。
Listing 3.8 の Recursive Query は、この連続再生のシーケンスを取得します。
WITH RECURSIVE play_sequence AS ( SELECT id, user_id, song_id, play_start_time, play_duration, played_after FROM streaming.plays WHERE id = 5 UNION ALL SELECT p.id, p.user_id, p.song_id, p.play_start_time, p.play_duration, p.played_after FROM streaming.plays p JOIN play_sequence ps ON p.played_after = ps.id ) SELECT user_id, song_id, play_start_time, play_duration as duration, played_after FROM play_sequence ORDER BY play_start_time;
これを読んで、以前アプリ側で何度もループしてクエリを投げていた処理を思い出しました。
以前のプロジェクトで、SNS のスレッド返信を表示する機能を実装した時、「親コメント ID」を辿って、アプリ側で再帰的にクエリを投げていました。その結果、N+1 問題が発生して、パフォーマンスが悪化しました。当時のアプリログを見返すと、同じユーザーの操作で DB への接続数が 47 回。まるでチャットボットが会話のキャッチボールをしているかのようでした。もちろん、レスポンスタイムは 3 秒超え。
あの時、Recursive Query を知っていたら、1 回のクエリで全ての返信を取得できました。
Recursive Query の実行フロー
Listing 3.7 の擬似コードは、Recursive Query の実行フローを明確に説明しています。
# Step 1: 非再帰項を実行(初期データ) non_recursive_result = execute(non_recursive_term); # Step 2: 重複削除(UNION の場合) if (using UNION) non_recursive_result = remove_duplicates(non_recursive_result); # Step 3: 最終結果に追加 final_result.add(non_recursive_result); # Step 4: ワーキングテーブルを初期化 working_table = non_recursive_result; # Step 5: 再帰項を実行(ワーキングテーブルが空になるまで) while (working_table is not empty) { intermediate_table = execute(recursive_term, using=working_table); if (using UNION) intermediate_table = remove_duplicates(intermediate_table, excluding=final_result); final_result.add(intermediate_table); working_table = intermediate_table; }
このフローを読んで、「Recursive Query は魔法じゃなくて、ちゃんとした仕組みがある」と納得できました。
特に重要なのは、UNION と UNION ALL の違いです。UNION は重複削除するので、無限ループを防げます。UNION ALL は重複を許すので、パフォーマンスは良いですが、無限ループのリスクがあります。
ところで、Recursive CTE を書いていて気になったのは終了条件です。調べてみると、終了条件は「新しい行が生成されなくなるまで」で、明示的に書く必要はありません。
循環検出には配列で訪問済みノードを追跡する方法が有効です。ARRAY[id] AS path で初期化して、ps.path || p.id で追加していく。NOT p.id = ANY(ps.path) で循環を検出できます。このパターンは覚えておくと便利です。
ただし、深い階層(数千レベル)ではパフォーマンスが低下します。グラフ DB ほど柔軟なグラフ探索はできません。SNS の友達の友達を無限に辿るような処理には向いていないです。
この違いを理解していないと、本番環境で無限ループが発生します。怖いです。データベース監視の Slack チャンネルが「CPU 使用率 100%」「接続数の上限到達」で埋め尽くされる光景は、二度と見たくありません。
Window Functions は Self-Join の代替ではない
Window Functions は「Self-Join の代わりに使える構文」という認識で使ってきました。でも、この章を読んで、パフォーマンス面での違いを改めて確認しました。
Listing 3.11 の Self-Join と Listing 3.12 の Window Function を比較すると、違いが明確です。
Self-Join 版:
SELECT DISTINCT p.song_id, p.user_id, t.total_duration FROM streaming.plays p JOIN ( SELECT song_id, SUM(play_duration) AS total_duration FROM streaming.plays GROUP BY song_id ) t ON p.song_id = t.song_id ORDER BY p.song_id;
Window Function 版:
WITH plays_with_total AS ( SELECT song_id, user_id, SUM(play_duration) OVER (PARTITION BY song_id) AS total_duration FROM streaming.plays ) SELECT DISTINCT song_id, user_id, total_duration FROM plays_with_total ORDER BY song_id, user_id;
Self-Join 版は、テーブルを 2 回走査しています。Window Function 版は、1 回の走査で済みます。
著者の言葉を借りれば、次のようになります。
Although the self-join approach works as expected, it's not the most efficient, because every row of the table is accessed twice. Additionally, it's not the easiest to follow when trying to understand the query logic.
これを読んで、「Window Functions は単なる糖衣構文じゃなくて、パフォーマンス最適化の手段だった」と気づきました。
Running Total と Window Frame
Listing 3.13 の Running Total の計算は、Window Functions の本質を理解する上で重要でした。
SELECT song_id, user_id, play_duration, SUM(play_duration) OVER (PARTITION BY song_id ORDER BY user_id) AS total_play_duration FROM streaming.plays WHERE song_id = 2;
結果は次のようになります。
song_id | user_id | play_duration | total_play_duration
---------+---------+---------------+---------------------
2 | 1 | 144 | 144
2 | 2 | 206 | 350
2 | 3 | 186 | 654
2 | 3 | 118 | 654
PARTITION BY song_id で Window を作り、ORDER BY user_id で Window を Frame に分割します。各 Frame は、現在の行 + それ以前の行を含みます。
この仕組みを理解すると、「累積和」「移動平均」「ランキング」といった処理が、すべて Window Functions で解決できることがわかります。
以前のプロジェクトで、時系列データの累積和を計算する時、アプリ側でループを回していました。あれも、Window Functions を使えば 1 回のクエリで済みました。
RANK() と ROW_NUMBER() の違い
Listing 3.14 の RANK() は、同じ値に同じランクを付けます。
SELECT song_id, SUM(play_duration) AS total_play_duration, RANK() OVER (ORDER BY SUM(play_duration) DESC) AS song_rank FROM streaming.plays GROUP BY song_id ORDER BY song_rank;
もし ROW_NUMBER() を使っていたら、同じ値でも異なる番号が振られます。この違いを理解していないと、ランキング機能で不具合が発生します。
実際に試してみると、3 つの関数の違いがはっきりします。
ROW_NUMBER(): 同じ値でも異なる番号(1→2→3→4→5)RANK(): 同じ値は同じ番号で次は飛ぶ(1→2→2→4→5)DENSE_RANK(): 同じ値は同じ番号で次は飛ばない(1→2→2→3→4)
以前、ランキング機能で ROW_NUMBER() を使って、同点の処理がおかしくなったことがあります。「なぜ同じスコアなのに順位が違うの?」というバグ報告を受けて、RANK() に変更しました。この違いは一度経験すると忘れません。
4. Indexes
インデックスの「当たり前」を疑う
第 4 章「Indexes」の冒頭の一文が、自分の習慣を言い当てていました。
Indexes are often the first optimization technique that comes to mind when dealing with a long-running query or a slow database operation.
そうなんです。遅いクエリがあったら、とりあえずインデックス張る。それが 10 年間の私のパターンでした。まるで風邪を引いたら「とりあえずビタミン C」みたいな、根拠のない安心感でした。
でも、著者は続けます。
They've proven so effective in many scenarios that we sometimes overlook other optimization methods, turning to indexes right away.
インデックスに頼りすぎて、他の最適化手法を見落としている。この指摘は痛かったです。
実際、過去のプロジェクトで「遅いクエリ問題」が発生した時、私はいつもまずインデックスを疑っていました。でも、本当は EXPLAIN で実行計画を見て、ボトルネックを特定してから判断すべきでした。
この章では、インデックスの「なぜ」と「いつ」を徹底的に掘り下げています。単なるインデックス作成のチュートリアルじゃありません。インデックス戦略の哲学です。
なぜインデックスがこんなに人気なのか
4.1 節「Why are indexes so popular?」では、O(N)と O(log_b N)の違いが説明されています。
100 件のテーブルで ID=5 を探す場合。
- インデックスなし:最大 100 回のルックアップ(O(N))
- B-tree インデックスあり:最大 4 回のルックアップ(O(log_b N)、b=3 の場合)
これが 100 万件に増えても、インデックスがあれば 6 回のルックアップで済みます(b=10 の場合)。
正直、この計算量の違いは知っていました。でも、著者が示した表を見て改めて驚きました。
| テーブルサイズ | インデックスルックアップ回数 |
|---|---|
| 100件 | 2回 |
| 1,000件 | 3回 |
| 1,000,000件 | 6回 |
| 10,000,000件 | 7回 |
| 1,000,000,000件 | 9回 |
10億件のテーブルでも9回のルックアップ。これがインデックスの威力です。深夜の障害対応で「インデックス張れば解決するっしょ」と言い続けてきた自分が、ようやく理論武装できた瞬間でした。
そして、著者の言葉が刺さります。
As a result, it's no surprise that indexes are such a popular optimization technique.
インデックスが人気な理由は、この圧倒的な効率性にあります。でも、だからこそ安易に使いすぎるリスクもあります。
EXPLAIN — まず実行計画を見ろ
4.4 節で EXPLAIN が詳しく説明されています。
私はこれまで、EXPLAIN ANALYZE しか使っていませんでした。でも、この章を読んで EXPLAIN (analyze, costs off) や EXPLAIN (analyze, buffers on) といった他のオプションを知りました。
特に印象的だったのは、buffers オプションです。
Buffers: shared hit=3
これは「3 ページをメモリから読んだ(ディスクアクセスなし)」という意味です。もし read=4 があれば、「4 ページをディスクから読んだ」ということになります。
遅いクエリの原因はインデックスの欠如じゃなく、メモリ不足かもしれません。
この視点は新鮮でした。私は「遅い = インデックスがない」と決めつけていました。でも、buffers を見れば、ディスク I/O が原因なのか実行計画が原因なのか区別できます。
著者は次のように書いています。
This information is crucial because a query might run slowly not due to a suboptimal execution plan or missing index but because memory has become a limited resource.
インデックスは万能じゃありません。頭ではわかっていても、実務では軽視しがちでした。
単一カラムインデックス — B-tree vs Hash
4.5 節では、単一カラムインデックスが 2 種類紹介されています。
- B-tree:範囲検索(
>,<,BETWEEN)に対応 - Hash:等価検索(
=,IN)のみ
私は今まで、Hash インデックスを積極的に選択してきませんでした。「B-tree がデフォルトだから」という理由で、あえて変える必要性を感じていなかったためです。
でも、この章を読んで考えが変わりました。
例えば、ゲーム内のチャンピオンタイトル(5 種類のみ)を検索する場合。範囲検索は不要で、等価検索だけで十分です。この場合、Hash インデックスが最適です。
CREATE INDEX idx_champion_title ON game.player_stats USING hash(champion_title);
実行計画を見ると、Hash インデックスを使った場合の実行時間は 0.073 ms。フルテーブルスキャンの 1.463 ms と比べて 20 倍速いです。
ユースケースに合わせてインデックスタイプを選ぶ。これが正しいアプローチです。
複合インデックス — 順番が命
4.6 節「Composite indexes」は、この章で最も重要なセクションだと思います。
複合インデックスの順番は、クエリのパフォーマンスに直結します。
例えば、(region, score DESC, win_count DESC) というインデックスを作った場合。
CREATE INDEX idx_region_score_win_count ON game.player_stats (region, score DESC, win_count DESC);
このインデックスは、次のクエリで使われます。
-- ✅ 使われる WHERE region = 'NA' and score > 5000 and win_count > 10 -- ✅ 使われる WHERE region = 'EMEA' and score > 1000 -- ✅ 使われる(先頭カラムがあるから) WHERE region = 'EMEA' -- ❌ 使われない(先頭カラムがない) WHERE score > 1000 and win_count > 30
先頭カラム(leading column)が必須。これがないと、複合インデックスは使われません。
この章を読みながら実際に手を動かしてみました。
EXPLAIN ANALYZE の出力で Index Scan と Index Only Scan の違いを確認することが重要です。Index Only Scan はテーブルにアクセスしないので高速。Covering Index の威力を実感しました。
Partial Index については、WHERE 句が完全に一致する場合のみ使用されるという点に注意が必要です。WHERE play_time <= '50 hours' で作ったインデックスは、WHERE play_time <= '50 hours 1 second' では使われません。1 秒違うだけで使われない。厳密すぎる気もしますが、そういう仕様です。
Hash インデックスは範囲検索(<, >, BETWEEN)には使えません。等価検索専用です。これを知らずに「なぜインデックスが使われないんだ?」と悩んだことがあります。
インデックスサイズを比較した結果も興味深かったです。
idx_champion_hash - 696 kB idx_covering - 416 kB idx_region_score - 248 kB idx_perf_margin - 120 kB idx_casual_players - 48 kB -- Partial Index は最小
Partial Index のサイズの小ささは印象的でした。必要な部分だけをインデックス化するという発想、もっと早く知りたかったです。
ただし、著者は注記しています。
However, starting with Postgres 18, the database introduced support for skip scan lookups on composite B-tree indexes, allowing us to skip leading columns and still use the index in more scenarios.
Postgres 18 以降では、skip scan が導入されるらしいです。これは大きな改善です。
でも、現時点(Postgres 17 以前)では、複合インデックスの順番を慎重に設計する必要があります。
Covering Index — テーブルアクセスをゼロに
4.7 節「Covering indexes」は、インデックス最適化の最終形態だと感じました。
通常、インデックスは「どの行を読むか」を決めるだけで、実際のデータ(username など)はテーブルから取得します。でも、Covering Index を使えば、インデックスだけで全てのデータを取得できます。
CREATE INDEX idx_composite_covering_index ON game.player_stats (region, score DESC, win_count DESC) INCLUDE (username);
INCLUDE 句で username をインデックスに含めることで、テーブルアクセスが不要になります。
実行計画を見ると。
Index Only Scan using idx_composite_covering_index on player_stats Heap Fetches: 0 Execution Time: 0.602 ms
Heap Fetches: 0 — テーブルに一切アクセスしていません。実行時間は 0.602 ms。以前の 1.856 ms(Bitmap Index Scan)から 3 倍速くなりました。
ただし、トレードオフがあります。username を更新するたびに、インデックスも更新する必要があります。
However, as a tradeoff, all included columns must remain consistent with the table data.
更新頻度が低いカラムなら Covering Index は有効。逆に、頻繁に更新されるカラムには向きません。
Partial Index — 必要な部分だけインデックス化
4.8 節「Partial indexes」では、インデックスのサイズを減らす手法が紹介されています。
例えば、10,000 人のプレイヤーのうち、74 人(0.74%)だけが「occasional players(プレイ時間 50 時間以下)」だとします。
この 74 人だけを頻繁に検索するなら、全体にインデックスを張る必要はありません。
CREATE INDEX idx_occasional_players ON game.player_stats (play_time) WHERE play_time <= '50 hours';
この Partial Index により。
- インデックスサイズが大幅に削減される
- 更新時のインデックスメンテナンスコストが減る
- 検索速度は 2 ms から 0.168 ms に改善(20 倍速)
ただし、条件が少しでも違うとインデックスが使われません。
-- ✅ 使われる WHERE play_time <= '50 hours' -- ❌ 使われない(1秒超過) WHERE play_time <= '50 hours 1 second'
Partial Index は条件が厳密。これを理解して使う必要があります。
Expression Index — 計算結果にインデックス
4.9 節「Functional and expression indexes」は、私にとって全く新しい概念でした。
例えば、「勝数 - 負数」というパフォーマンスマージンで検索したい場合。
WHERE (win_count - loss_count) BETWEEN 300 and 450
通常、この式はクエリ実行時に毎回計算されます。でも、Expression Index を使えば、計算結果をインデックス化できます。
CREATE INDEX idx_perf_margin ON game.player_stats ((win_count - loss_count));
実行時間は 2.524 ms から 1.200 ms に改善(2 倍速)。
ただし、式が複雑になると、インデックスのメンテナンスコストが増えます。win_count または loss_count が更新されるたびに、インデックスも更新されます。
頻繁に検索される式にのみ使うのが正しい戦略です。
Over-Indexing という警告
この章の最後に、著者は重要な警告を発しています。
Throughout this chapter, we've explored and added various indexes to the game.player_stats table, bringing the total number of indexes for the table to seven.
7 つのインデックス。これは典型的な Over-Indexing だと著者は指摘します。
Although this is acceptable for learning purposes, in practice, it represents a classic case of over-indexing.
インデックスは無料じゃありません。
- 作成時にディスク容量を消費する
- 更新時にメンテナンスコストがかかる
- 計画時(Planning Time)にオプション評価のコストがかかる
私は過去、インデックスを「作りすぎる」傾向がありました。「とりあえずこのカラムにもインデックス張っとくか」という感じで。まるで保険に入りまくる不安な中年のように、あらゆるカラムに「念のため」インデックスを追加していました。そして毎回、INSERT が遅くなってから後悔する、という黄金パターンです。
でも、この章を読んで、インデックスは慎重に設計すべきだと改めて理解しました。
著者は Appendix A で Over-Indexing と Under-Indexing について詳しく説明しています。実際に読んでみて、自分の過去の設計を振り返る良い機会になりました。
5. Postgres and JSON
JSON 機能を使うべき場所と使わない場所
5.3 節の「JSON in Postgres: Striking the balance」が、この章の核心です。
著者が書いているのは、「JSON をすべてのデータに使うな」という明確な警告です。
Even though Postgres provides full-fledged support for JSON, you should avoid storing all application data in JSON-specific data types as you would in a pure document database.
これが「Just use Postgres」の真髄だと感じました。
MongoDB を追加する代わりに Postgres の JSON 機能を使う。でも、すべてのデータを JSON で保存する MongoDB みたいな使い方はするな。ハイブリッドアプローチを取れ、と。
pizzeria.order_items テーブルの構造が、この考え方を体現しています。
CREATE TABLE pizzeria.order_items ( order_id INT NOT NULL, order_item_id INT NOT NULL, pizza JSONB NOT NULL, -- ここは JSON price NUMERIC(5,2) NOT NULL, -- ここは通常の型 PRIMARY KEY (order_id, order_item_id) );
order_id と price は通常の型で、検索とデータ整合性を重視。pizza の詳細(トッピング、クラスト、ソースなど)は JSONB で、柔軟性を重視。
この設計、10 年前のプロジェクトで欲しかったです。
JSON を使うべき場面
著者が挙げている 3 つの基準が具体的でわかりやすいです。
- データが静的または更新頻度が低い(設定、メタデータ、顧客プリファレンス)
- データが疎(スパース)(多くの null や 0、feature flags など)
- スキーマの柔軟性が必要(外部 API のレスポンス、テレメトリイベント)
ピザ注文の詳細は「静的」に該当します。注文確定後はほぼ変更されません。
もし従来の正規化モデルで実装すると、5 つのテーブル(pizzas、order_items、pizza_cheeses、pizza_veggies、pizza_meats)が必要になります。著者が示した例を見て、「ああ、これは辛い」と思いました。
Write overhead: 1 つのピザ注文のために複数テーブルへの INSERT が必要。7 つのトッピングなら 7 レコード。
Read overhead: ピザのレシピを再構築するために複数テーブルの JOIN が必要。
Transformation overhead: フロントエンドが JSON で受け取るのに、わざわざ正規化モデルへ分解し、また JSON に戻す。
この 3 つの overhead、すべて経験があります。特に Transformation overhead が一番つらいです。API レスポンスを JSON で返すためだけに、複雑な JOIN と整形ロジックを書く。「JSON から分解して正規化して、また JOIN して JSON に戻す」という、まるで水を凍らせてから溶かすような無駄な作業。当時の自分に「Postgres の JSONB を使えばいいぞ」と教えてあげたいです。
著者が「hybrid approach」を推奨する理由がよくわかりました。
json vs jsonb
5.1 節で json と jsonb の違いが説明されています。
| 型 | 保存形式 | Write 性能 | Read 性能 | インデックス | 推奨度 |
|---|---|---|---|---|---|
json |
テキスト | 速い | 遅い(毎回パース) | 限定的 | ❌ |
jsonb |
バイナリ | 遅い(パースあり) | 速い | GIN など充実 | ✅ |
著者の推奨は明確です:jsonb をデフォルトで使え。
Overall, the
jsonbdata type is the recommended default for storing and processing JSON data in Postgres, unless you have a specific use case that requires preserving the order of keys in the original JSON objects.
「キーの順序を保持する必要がある」という特殊なケースでない限り、jsonb 一択です。
私が過去に扱ったプロジェクトでは、なんとなく json を選んでいたことがありました。「書き込みが速いから」という理由で。でも、検索の度にパースが走るコストを考えていませんでした。典型的な「入口だけ見て出口を見ない」パターン。インフラエンジニアあるあるです。
著者が書いているように、jsonb は write 時に変換コストがありますが、検索性能は圧倒的に速いです。そして GIN インデックスとの組み合わせでさらに速くなります。
JSON のクエリ:-> と ->>
5.4 節の JSON クエリ構文は充実しています。機能自体は知っていましたが、改めて整理すると活用の幅が広がります。
基本:-> と ->>
SELECT order_id, pizza->'size' as pizza_size, -- JSON 形式で返す pizza->>'crust' as pizza_crust -- テキスト形式で返す FROM pizzeria.order_items WHERE order_id = 100;
出力の違い。
pizza_size | pizza_crust -------------|------------- "small" | thin
-> は JSON 形式なのでダブルクォート付き。->> はテキスト型なのでダブルクォートなし。
最初は「なぜ 2 つの演算子が必要なのか?」と疑問でしたが、5.4.1 節を読んで納得しました。
WHERE 句での比較。
-- JSON 形式で比較(ダブルクォート必要) WHERE pizza->'size' = '"small"' -- テキスト形式で比較(ダブルクォート不要) WHERE pizza->>'crust' = 'gluten_free'
-> でダブルクォートを忘れると、こんなエラーが出ます。
DETAIL: Token "small" is invalid. CONTEXT: JSON data, line 1: small
この仕様、最初はわかりにくいですが、JSON の仕様に忠実だと理解すれば納得できます。
Rust から Postgres に接続して JSON を扱う時、この -> と ->> の違いでハマりました。-> は JSON 型を返すので、そのまま文字列としてデシリアライズしようとするとエラーになります。->> を使うか、適切な型変換が必要です。特に pg_typeof() で型を確認しようとした時、::TEXT でキャストしないと Rust 側でエラーになりました。
ネストした JSON へのアクセス
SELECT order_id, pizza->'toppings'->'veggies' as veggies_toppings FROM pizzeria.order_items WHERE order_id = 100;
出力。
veggies_toppings
-----------------------
[{"tomato": "light"}]
配列の特定要素にアクセスするには、インデックス(0 始まり)を指定。
SELECT order_id, pizza->'toppings'->'veggies'->0 as veggies_toppings FROM pizzeria.order_items WHERE order_id = 100;
出力([] が消える)。
veggies_toppings
---------------------
{"tomato": "light"}
さらに、配列内のオブジェクトのフィールドにアクセスします。
SELECT order_id, pizza->'toppings'->'veggies'->0->>'onion' as onions_amount FROM pizzeria.order_items WHERE order_id = 100;
出力。
onions_amount --------------- light
この連鎖、最初は読みづらいと思いましたが、慣れると直感的です。
? 演算子と @> 演算子
? 演算子:キーの存在確認
SELECT order_id, pizza->'toppings'->'meats' as meats FROM pizzeria.order_items WHERE pizza->'toppings' ? 'meats' ORDER BY order_id LIMIT 5;
「meats キーが存在する注文だけを取得」という意味です。
配列内のオブジェクトのキー存在確認は少し複雑になります。
SELECT order_id, pizza->'toppings'->'meats' AS meats FROM pizzeria.order_items WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements(pizza->'toppings'->'meats') AS meats WHERE meats ? 'sausage' ) ORDER BY order_id LIMIT 5;
この書き方、正直、冗長だと思いました。でも著者も同じ意見で、5.4.4 節で JSON path expression を使ってシンプルにしています。
@> 演算子:包含関係の確認
SELECT count(*) FROM pizzeria.order_items WHERE pizza @> '{"crust": "gluten_free"}';
「crust フィールドが gluten_free の注文を数える」という意味です。
複数条件。
SELECT count(*) FROM pizzeria.order_items WHERE pizza @> '{"crust": "gluten_free", "type": "custom"}';
ネストした構造も可能。
SELECT count(*) FROM pizzeria.order_items WHERE pizza @> '{"crust": "gluten_free", "type": "custom", "toppings": {"veggies": [{"tomato": "extra"}]}}';
この演算子、MongoDB の $elemMatch みたいな感じだと思いました。
ちなみに、@> 演算子は配列にも使えます。tags @> '["hot", "milk"]' のように書けば、配列が特定の要素を含むかどうかを検索できます。JSON オブジェクトだけでなく、配列にも対応しているのは便利です。
著者が「-> と @> を組み合わせるとより読みやすくなる」と書いています。
SELECT count(*) FROM pizzeria.order_items WHERE pizza @> '{"crust": "gluten_free", "type": "custom"}' AND pizza->'toppings'->'veggies' @> '[{"tomato": "extra"}]';
こっちの方が確かに読みやすいです。
JSON Path Expressions
5.4.4 節で、SQL/JSON path language が登場します。
先ほどの「sausage を含む注文を検索」のクエリが、path expression でこうなります。
SELECT order_id, pizza->'toppings'->'meats' AS meats FROM pizzeria.order_items WHERE jsonb_path_exists(pizza, '$.toppings.meats[*] ? (exists(@.sausage))') ORDER BY order_id LIMIT 5;
サブクエリが不要になりました。
構文の説明です。
$: 評価対象の JSON オブジェクト(pizzaカラム).toppings.meats: フィールドへのアクセス[*]: 配列のすべての要素?: フィルタの開始@: 現在評価中のオブジェクトexists(@.sausage):sausageフィールドが存在するか
最初は読みづらかったですが、いくつか例を見ていくうちに理解できました。
配列のクエリ
SELECT count(*) as total_cnt, jsonb_object_keys( jsonb_path_query(pizza, '$.toppings.cheese[*]') ) as cheese_topping FROM pizzeria.order_items GROUP BY cheese_topping ORDER BY total_cnt DESC;
$.toppings.cheese[*] で cheese 配列のすべてのオブジェクトを取得。
jsonb_object_keys で各オブジェクトのキー(チーズ名)を抽出。
出力。
total_cnt | cheese_topping
----------|----------------
2575 | mozzarella
771 | cheddar
762 | parmesan
フィルタ付き path expression
SELECT count(*) AS total_cnt, pizza->'type' as pizza_type FROM pizzeria.order_items WHERE jsonb_path_exists(pizza, '$.toppings.cheese[*] ? (exists(@.parmesan))') GROUP BY pizza_type ORDER BY total_cnt DESC;
評価順序。
$.toppings.cheese[*]: すべてのチーズオブジェクトを取得?: フィルタ開始exists(@.parmesan): 現在のオブジェクトにparmesanフィールドがあるか
複数フィルタのチェーン
SELECT count(*) FROM pizzeria.order_items WHERE jsonb_path_exists( pizza, '$ ? (@.type == "custom") .toppings.cheese[*].parmesan ? (@ == "extra")' );
評価順序(左から右)です。
$:pizzaオブジェクト? (@.type == "custom"):typeがcustomか確認.toppings.cheese[*].parmesan:parmesanオブジェクトを取得? (@ == "extra"): 量がextraか確認
この書き方、最初は難解だと思いましたが、左から右に評価されると理解すれば読めます。
JSON の更新:jsonb_set と #-
5.5 節で JSON の更新方法が紹介されています。
最も簡単な方法(非推奨)
-- アプリ側で JSON 全体を取得 SELECT pizza FROM pizzeria.order_items WHERE order_id = $1 and order_item_id = $2; -- アプリ側で JSON を修正 -- DB に書き戻す UPDATE pizzeria.order_items SET pizza = new_pizza_order_json WHERE order_id = $1 and order_item_id = $2;
著者が書いているように、簡単だけど効率的じゃないです。
複雑な JSON オブジェクト全体を転送するのではなく、必要なフィールドだけを更新する方が良いです。
jsonb_set 関数
UPDATE pizzeria.order_items SET pizza = jsonb_set(pizza, '{crust}', '"regular"', false) WHERE order_id = 20 and order_item_id = 5;
jsonb_set の引数です。
- 元の JSON オブジェクト(
pizza) - 更新対象のパス(
{crust}) - 新しい値(
"regular"— JSON 文字列なのでダブルクォート必要) - フィールドが存在しない場合に追加するか(
false)
ネストした配列の更新。
UPDATE pizzeria.order_items SET pizza = jsonb_set( pizza, '{toppings,veggies}', '[{"tomato":"extra"}, {"spinach":"regular"}]', false ) WHERE order_id = 20 and order_item_id = 5;
配列の特定要素を更新する場合、パスにインデックスを含められる。
-- 例:{toppings, veggies, 0, tomato} で配列の最初の要素の tomato を更新
1 つ注意点があります。jsonb_set のパスが存在しない場合、第 4 引数が true なら新しいキーが作成されます。これは便利な反面、タイプミスで意図しないキーが追加されるリスクもあります。
#- 演算子:フィールドの削除
UPDATE pizzeria.order_items SET pizza = pizza #- '{toppings,meats}' WHERE order_id = 20 AND order_item_id = 5;
{toppings, meats} パスのフィールドを削除します。
この演算子、シンプルで良いです。
インデックス:B-tree と GIN
5.6 節がこの章で一番技術的に深い部分でした。
Expression Index with B-tree
最初の試みです。
SELECT count(*) FROM pizzeria.order_items WHERE pizza ->> 'type' = 'custom';
実行計画。
Seq Scan on order_items (actual time=0.034..1.062 rows=563 loops=1) Filter: ((pizza ->> 'type'::text) = 'custom'::text) Rows Removed by Filter: 2375 Execution Time: 1.185 ms
全件スキャンです。
Expression Index を作成します。
CREATE INDEX idx_pizza_type ON pizzeria.order_items ((pizza ->> 'type'));
再度実行計画を確認。
Bitmap Index Scan on idx_pizza_type (actual time=0.068..0.068 rows=563 loops=1) Index Cond: ((pizza ->> 'type'::text) = 'custom'::text) Execution Time: 0.376 ms
4 倍近く高速化(1.185 ms → 0.376 ms)しました。
でも問題があります。
このインデックスは pizza ->> 'type' というexact expression にしか効きません。
-- これは idx_pizza_type を使わない SELECT count(*) FROM pizzeria.order_items WHERE pizza -> 'type' = '"custom"';
実行計画:Seq Scan に戻ります。
さらに、別のフィールド(size など)を検索する場合、また別の Expression Index が必要になります。
著者が書いているように、スケールしません。フィールドごとにインデックスを作り続けると、気づいたら「インデックスのインデックス」が欲しくなる世界へようこそ。
GIN Index(Default)
GIN(Generalized Inverted Index)を作成します。
CREATE INDEX idx_pizza_orders_gin ON pizzeria.order_items USING GIN(pizza);
GIN の仕組み(5.6.2 節の Figure 5.1 参照)です。
JSON オブジェクトからすべてのキーと値を抽出して、個別のインデックスエントリとして保存します。
例です。
{ "size": "large", "type": "three cheese", "crust": "thin", "sauce": "marinara", "toppings": { "cheese": [ {"cheddar": "regular"}, {"mozzarella": "extra"}, {"parmesan": "light"} ] } }
インデックスに保存されるエントリです。
Keys:
- size、type、crust、sauce、toppings、cheese、cheddar、mozzarella、parmesan
Values:
- large、three cheese、thin、marinara、regular、extra、light
これらのエントリは辞書順に保存され、複数のインデックスページに分散されます。
GIN を使ったクエリです。
SELECT count(*) FROM pizzeria.order_items WHERE pizza @> '{"type": "custom"}';
実行計画。
Bitmap Index Scan on idx_pizza_orders_gin (actual time=0.109..0.110 rows=563 loops=1)
Index Cond: (pizza @> '{"type": "custom"}'::jsonb)
Execution Time: 0.830 ms
複雑なネスト構造でも使えます。
SELECT count(*) FROM pizzeria.order_items WHERE pizza @> '{"toppings":{"cheese":[{"cheddar":"regular"}]}}';
Postgres はインデックスから toppings, cheese, cheddar, regular の 4 つのエントリを検索して、該当する行を絞り込みます。
GIN のメリット:1 つのインデックスで JSON 全体を検索可能です。
GIN Index with jsonb_path_ops
さらに効率的な GIN インデックスです。
CREATE INDEX idx_pizza_orders_paths_ops_gin ON pizzeria.order_items USING GIN (pizza jsonb_path_ops);
違いは、パス全体をハッシュ化して保存することです。
例です。
size.large type.three cheese crust.thin sauce.marinara toppings.cheese.cheddar.regular toppings.cheese.mozzarella.extra toppings.cheese.parmesan.light
これらのパスをハッシュ関数に通して、固定長の整数として保存します。
メリットです。
- 検索が速い:固定長整数の比較は可変長テキストより速い
- サイズが小さい:ハッシュコードはテキストより小さい
実際のサイズ比較です。
index_name | index_size --------------------------------|------------ idx_pizza_orders_gin | 112 kB idx_pizza_orders_paths_ops_gin | 56 kB
半分のサイズです。
デメリットです。
jsonb_path_ops は ? 演算子(キー存在確認)をサポートしません。
なぜなら、インデックスにはパスのハッシュのみが保存されていて、キー単体は保存されていないからです。
-- これは idx_pizza_orders_gin を使う(jsonb_path_ops は使えない) SELECT count(*) FROM pizzeria.order_items WHERE pizza ? 'special_instructions';
使い分け
| インデックスタイプ | サイズ | 検索速度 | サポート演算子 | 推奨用途 |
|---|---|---|---|---|
| Expression Index (B-tree) | 小 | 特定 expression のみ速い | ->, ->> |
特定フィールドの頻繁な検索 |
| GIN (default) | 大 | 速い | ?, @>, @?, @@ |
柔軟な検索、キー存在確認が必要 |
GIN (jsonb_path_ops) |
中 | 最速 | @>, @?, @@ |
包含検索のみ、サイズ重視 |
著者が書いているように、jsonb_path_ops が第一選択です。キー存在確認が必要なら default GIN を追加します。
6. Postgres for full-text search
「全文検索は難しい」という思い込み
この章を読み終えて思ったのは、「Postgres の全文検索は、思ったより実用的だ」ということです。
私はこれまで、全文検索といえばElasticsearchだと思っていました。実際、過去のプロジェクトで「検索機能が必要です」と言われたら、反射的に「Elasticsearch を構築しますか?」と答えていました。まるで、パブロフの犬のように。「検索」という言葉を聞いただけで、脳内で Kibana のダッシュボードが立ち上がっていました。
でも、第 1 章で学んだ「Just use Postgres」の真の意味を思い出します。
「別のデータベースを追加する前に、まず Postgres で解決できるか確認してみよう」
この章は、その実践編でした。
Tokenization と Normalization の仕組み
6.1 節では、Postgres が全文検索をどう実現しているかが説明されています。
基本的な流れは 4 ステップです。
- Tokenization(トークン化): 文書を単語やフレーズに分割
- Normalization(正規化): トークンを lexeme(語彙素)に変換
- Storing and Indexing: lexeme を
tsvector型で保存し、インデックスを作成 - Searching: 保存した lexeme に対してクエリを実行
著者が ts_debug 関数を使って、"5 explorers are traveling to a distant galaxy" という文がどう処理されるかを見せてくれます。
SELECT token, description, lexemes, dictionary FROM ts_debug('5 explorers are traveling to a distant galaxy');
結果を見ると、"explorers" は "explor" に、"traveling" は "travel" に変換されています。ストップワード("are", "to", "a")は空の lexeme {} にマッピングされています。
これがステミング(語幹抽出)です。試しに to_tsvector('english', 'running runs runner') を実行してみると、'run':1,2 'runner':3 と返ってきます。running と runs は run に統一されています。だから「running」で検索しても「runs」がヒットする。これは便利です。
位置情報を保持しながらストップワードを削除するという設計が巧妙です。<-> (FOLLOWED BY) オペレータで距離を計算するために、ストップワードの位置も必要になるからです。
Elasticsearch でも同じようなことをやっているはずですが、Postgres ではこれが標準機能だということに改めて気づかされました。
複数言語への対応
6.1.2 節では、Full-text search configuration が紹介されています。
Postgres には英語だけでなく、アラビア語、ロシア語、日本語など、多数の言語用の predefined configuration が用意されています。
SELECT token, description, lexemes, dictionary FROM ts_debug('russian', '5 исследователей путешествуют к далёкой галактике.');
ロシア語の例を見ると、russian_stem 辞書が使われています。"исследователей" が "исследовател" に、"путешествуют" が "путешеств" に変換されています。
これも Elasticsearch でやろうとすると、analyzer の設定が複雑になります。JSON の設定ファイルを書いて、tokenizer を選んで、filter を設定して、mapping を更新する作業が必要です。設定の沼にハマっていきます。Postgres ではデフォルトで対応しています。
でも、ここで疑問が湧きました。
日本語はどうなんだろう?
この本では日本語の例は出てきません。調べてみると、日本語は形態素解析が必要で、Postgres の標準機能だけでは難しいようです。pg_bigm(2-gram ベース)や pgroonga(Groonga ベース)といった拡張機能が必要になります。
「Postgres で試した?」と聞き返す前に、日本語対応が必要かどうかは確認が必要な部分だと思いました。英語圏のサービスなら問題ないですが、日本語がメインなら追加の検討が必要です。
tsvector と generated column の活用
6.2 節では、生成した lexeme をどう保存するかが説明されています。
3 つの選択肢があります。
- On-the-fly 生成: クエリごとに
to_tsvectorを実行(非効率) - Column に保存:
tsvector型のカラムを追加して保存(推奨) - Index のみ: テーブルには保存せず、直接インデックス作成(ストレージ節約)
著者は 2 番目の方法を推奨しています。
ALTER TABLE omdb.movies ADD COLUMN lexemes tsvector GENERATED ALWAYS AS ( to_tsvector( 'english', coalesce(name, '') || ' ' || coalesce(description, ''))) STORED;
GENERATED ALWAYS AS ... STORED という構文が便利です。これで、name や description が変更されると、lexemes も自動的に再生成されます。
ただし、configuration は明示的に指定する必要があります('english')。これは、generated column の式が immutable でなければならないからです。
この辺りの設計判断は、実際に運用してみないと分からない部分が多そうです。
全文検索クエリの実行
6.3 節では、実際のクエリの書き方が紹介されています。
plainto_tsquery: シンプルなクエリ
SELECT id, name FROM omdb.movies WHERE lexemes @@ plainto_tsquery('a computer animated film');
plainto_tsquery は、ユーザーが入力した自然な文章を tsquery 型に変換してくれます。ストップワード("a")を削除し、残りの単語を lexeme に変換して、& (AND) オペレータで結合します。
結果:'comput' & 'anim' & 'film'
この手軽さが良いです。Elasticsearch なら、query DSL を書く必要があります。
plainto_tsquery と to_tsquery の違いを実際に確認してみました。plainto_tsquery('english', 'ghost in shell') は 'ghost' & 'shell' を返します。「in」はストップワードとして除去されています。一方、to_tsquery は構文を直接指定できるので、OR 検索や NOT 検索も可能です。
to_tsquery: 高度なフィルタリング
SELECT id, name FROM omdb.movies WHERE lexemes @@ to_tsquery('computer & animated & (lion | clownfish | donkey)');
to_tsquery を使えば、AND、OR、NOT、FOLLOWED BY などのオペレータを直接指定できます。
SELECT id, name FROM omdb.movies WHERE lexemes @@ to_tsquery('lion & !''The Lion King''');
NOT オペレータで特定のフレーズの除外も可能です。
この柔軟性は、Elasticsearch と変わりません。むしろ、SQL の中で完結するので、アプリケーション側のコードがシンプルになります。
ランキングと重み付け
6.4 節では、検索結果のランキングが扱われています。
ts_rank による関連度スコア
SELECT id, name, vote_average, ts_rank(lexemes, to_tsquery('ghosts')) AS search_rank FROM omdb.movies WHERE lexemes @@ to_tsquery('ghosts') ORDER BY search_rank DESC, vote_average DESC NULLS LAST LIMIT 10;
ts_rank 関数は、lexeme の出現頻度と位置に基づいてスコアを計算します。
でも、最初の実行例では、タイトルに "ghost" が含まれる映画と、説明文にだけ含まれる映画が同じように扱われていました。
setweight による重み付け
ALTER TABLE omdb.movies ADD COLUMN lexemes tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(name, '')), 'A') || setweight(to_tsvector('english', coalesce(description, '')), 'B') ) STORED;
setweight 関数で、タイトル由来の lexeme に A ラベル、説明文由来の lexeme に B ラベルを付けます。重みは A > B > C > D の順で、デフォルトは D です。
これで、ts_rank はタイトルに含まれる単語をより高くランク付けするようになります。
id | name | vote_average | search_rank
--------+-----------------------+--------------+-------------
251 | Ghost | 6.3333301544 | 0.6957388
210675 | A Most Annoying Ghost | | 0.6957388
1548 | Ghost World | 8.1428575516 | 0.66871977
タイトルへ "ghost" が含まれる映画が上位へ来るようになりました。
この重み付けのメカニズムは、Elasticsearch の boosting と同じ発想です。でも、Postgres では setweight 一発で実現できます。
ハイライト表示
6.5 節では、ts_headline 関数が紹介されています。
SELECT id, name, description, ts_headline(description, to_tsquery('pirates')) AS fragments, ts_rank(lexemes, to_tsquery('pirates')) AS rank FROM omdb.movies WHERE lexemes @@ to_tsquery('pirates:B') ORDER BY rank DESC LIMIT 1;
結果はこうなります。
fragments | <b>pirate</b> Captain Jack is in a battle with the ocean ➥ itself. Jack knows it won't be easy
マッチした単語を <b> タグで囲んでくれます。
さらに、オプションでカスタマイズも可能です。
ts_headline(description, to_tsquery('pirates'), 'MaxFragments=3, MinWords=5, MaxWords=10, FragmentDelimiter=<ft_end>') AS fragments
ただし、著者が警告している通り、XSS 攻撃のリスクがあります。HTML マークアップを含む文書を扱う場合は、サニタイズが必要です。
この辺りは、Elasticsearch でも同じ問題があります。ハイライト機能は便利ですが、セキュリティには注意が必要です。
インデックスの選択:GIN vs GiST
6.6 節では、全文検索を高速化するためのインデックスが説明されています。
GIN インデックス
CREATE INDEX idx_movie_lexemes_gin ON omdb.movies USING GIN (lexemes);
GIN(Generalized Inverted Index)は、全文検索に最適化されたインデックスです。
各 lexeme ごとにインデックスエントリを作成し、その lexeme を含むテーブル行への参照を保持します。
実行計画を見ると、Seq Scan(15.328 ms)から Bitmap Index Scan(0.150 ms)に変わっています。100 倍以上の高速化です。
ただし、GIN インデックスは positional information を保存しません。<-> (FOLLOWED BY) オペレータを使うクエリでは、テーブル行を再確認する必要があります。
この制約を解決したい場合は、RUM インデックス(Postgres 拡張)を使うと良いようです。
GiST インデックス
CREATE INDEX idx_movie_lexemes_gist ON omdb.movies USING GIST (lexemes);
GiST(Generalized Search Tree)は、signature tree を構築します。
各文書の signature(ビット列)を作成し、lexeme の signature を bitwise OR で結合します。
実行時間は 0.395 ms で、GIN(0.150 ms)より遅いです。
理由は、signature collision が発生するため、マッチした文書をテーブル行で再確認する必要があるからです。
でも、GiST は インデックスサイズが小さく、更新が速いという特徴があります。
使い分け
著者の推奨はこうです。
- GIN: 検索速度が最重要で、インデックスメンテナンスコストを許容できる場合
- GiST: インデックスサイズや更新速度が重要な場合
この辺りの判断は、データ量や更新頻度によって変わります。実際に両方試してみる価値があります。
Postgres の限界を認識する
もちろん、Postgres の全文検索にも限界はあります。
- 日本語の形態素解析はサポートされていない(可能性が高い)
- 大規模データ(数億レコード)では Elasticsearch の方が速い可能性がある
- 分散検索や複雑な aggregation は Elasticsearch の方が得意
でも、多くのケースでは Postgres で十分というのがこの章の主張です。
7. Postgres extensions
拡張性こそが「Just use Postgres」の核心
第 7 章を読んで、ようやく腑に落ちました。
「Just use Postgres」というモットーは、Postgres の拡張機能によって生まれました。
この一文を読んだとき、第 1 章の理解が深まりました。
In fact, the motto "Just use Postgres" emerged largely due to its rich ecosystem of extensions, which allow us to use the database well beyond the use cases covered in the earlier chapters of the book.
第 1 章では「新しいユースケースが発生したとき、まず Postgres で解決できるか確認しよう」という意味だと学びました。でも、なぜ Postgres で解決できるのかという根拠は曖昧でした。
答えは拡張機能でした。JSON、全文検索、時系列、地理空間、メッセージキュー、ベクトル検索——これら全て、Postgres の拡張機能が可能にしています。
第 2 章から第 6 章までは、コア機能を使ったユースケースでした。でも、それは「氷山の一角」だったんです。本当の多様性は拡張機能にあります。
Michael Stonebraker のビジョン
7.1 節で、Postgres の拡張性が生まれた背景が語られています。
Michael Stonebraker(チューリング賞受賞者)の言葉が印象的でした。
1980 年代、多くの研究論文が同じことを言っていました:「リレーショナルデータベースは素晴らしいと言われているが、実際には特定のシナリオでまったく機能しない」
そして、それぞれの論文が独自の解決策を提案していました。
Stonebraker はこう考えました:それぞれの問題へ個別の解決策を追加するのではなく、RDBMS が特定のユースケースへ適応できるようにする、より良い方法があるはずだ。
この哲学が、Postgres の設計思想の根幹になっています。
拡張性が Postgres の強みであることは知っていました。ただ、この章を読んで、Postgres は最初から拡張性を前提に設計されているという設計思想を改めて確認できました。
インフラエンジニアとして、この設計思想は深く刺さります。運用の現場では、予期しないユースケースが次々に現れます。そのたびに新しいデータベースを追加していたら、運用負荷は青天井です。気づけば Kubernetes クラスタの中に MongoDB、Redis、Elasticsearch、TimescaleDB、Neo4j が同居しています。「あれ、俺たちデータベース動物園を運用してたっけ?」と遠い目をする羽目になります。
Postgres は、そういう現実を 40 年以上前から見据えていたんです。
拡張性を支える 3 つの基盤
7.2 節では、Postgres の拡張性を支える技術的な基盤が説明されています。
カタログ駆動操作
Postgres は、テーブル、カラム、データ型、関数などのメタデータをシステムカタログに保存しています。これは通常のテーブルと似た構造で、拡張機能はこのカタログを読み書きできます。
これ、地味だけど重要だと思いました。
システムカタログが「普通のテーブルのような構造」だから、拡張機能が新しいデータ型や関数を追加できます。もし、メタデータが隠蔽された独自フォーマットだったら、拡張機能の開発はもっと難しかったでしょう。
データベースフック
Postgres のコードベースには、拡張機能がカスタムロジックを注入できるフックポイントが定義されています。
クエリ計画、実行、認証など、様々なイベントにフックできます。
これ、Linux カーネルの LSM(Linux Security Modules)に似ていると思いました。カーネル本体を変更せずに、セキュリティポリシーを注入できる仕組みです。
Postgres も同じ哲学です。コアエンジンを変更せずに、動作を拡張できます。
動的ロード
拡張機能のロジックは、SQL、PL/pgSQL、C、Rust など、様々な言語で書けます。
SQL や PL/pgSQL で書かれた拡張機能は、データベースエンジンが直接解釈します。C や Rust で書かれた拡張機能は、共有ライブラリとして実行時に動的にロードされます。
コアエンジンの再コンパイルが不要です。これが重要です。
もし、拡張機能を追加するたびに Postgres 本体を再コンパイルしなければならないとしたら、運用はほぼ不可能でした。動的ロードのおかげで、拡張機能の追加・削除が柔軟にできます。
pgcrypto を使ってみた感覚
7.2.2 節では、pgcrypto 拡張機能を使ったユーザー認証の例が紹介されています。
CREATE EXTENSION pgcrypto; INSERT INTO accounts (username, password_hash) VALUES ('ahamilton', crypt('SuperSecret123', gen_salt('bf')));
gen_salt('bf') で Blowfish アルゴリズムを使ったソルトを生成し、crypt() で平文パスワードとソルトからハッシュを生成します。
この例を読んで、「データベース内で暗号化を完結させる」という選択肢があることに気づきました。
これまで、パスワードのハッシュ化はアプリケーション層でやるものだと思い込んでいました。でも、pgcrypto を使えば、データベース層でも実装できます。
どちらが良いかはケースバイケースでしょう。でも、選択肢があることを知っておくのは重要です。
認証のクエリも興味深いです。
SELECT username FROM accounts WHERE username = 'ahamilton' AND password_hash = crypt('SuperSecret123', password_hash);
crypt() 関数に、平文パスワードと保存済みのハッシュを渡します。関数がハッシュからソルトを抽出し、再計算して比較します。
この設計、エレガントだと思いました。ソルトを別カラムに保存する必要がありません。ハッシュ自体にソルトが含まれています。
ちなみに、bcrypt のコストパラメータ(gen_salt('bf', 8) の 8 の部分)は、8〜12 が推奨されています。数字が大きいほどハッシュ計算に時間がかかりますが、セキュリティは向上します。
拡張機能の 5 つのカテゴリ
7.3 節では、拡張機能を 5 つのカテゴリに分類しています。
"Postgres beyond relational"
Postgres を従来の RDBMS を超えた用途に拡張します。
pgvector、pg_ai、pgvectorscale: ベクトルデータベース(生成 AI ワークロード)TimescaleDB: 時系列データベースPostGIS: 地理空間データベースpgmq: メッセージキューpg_duckdb: 高性能分析ワークロード(DuckDB の列指向エンジンを埋め込み)
これらが「Just use Postgres」を可能にしている拡張機能です。
「Elasticsearch で検索やりたい」「MongoDB で JSON 保存したい」「Redis でキューやりたい」というよくある要求があります。これらに対して、「まず Postgres で試した?」と聞き返せる根拠です。 過去の自分に教えてあげたいです。技術選定会議で『最新トレンド』として提案された 3 つのデータベース、実は Postgres の拡張機能で済むやつだから、と。
プログラミング言語と手続き型言語
第 2 章で PL/pgSQL を学びましたが、それだけではありません。
PLV8: JavaScriptPL/Java: JavaPL/Python: PythonPL/Rust: Rust
自分の得意な言語で、データベース関数やプロシージャを書けます。
特に PLV8 の説明が興味深いです。V8 JavaScript エンジンを Postgres に埋め込むだけでなく、PgCompute クライアントライブラリと組み合わせることで実現します。アプリケーションから SQL を介さずに JavaScript 関数を直接実行できます。
これ、SQL とアプリケーションロジックの境界を曖昧にする、面白いアプローチだと思いました。
コネクタと外部データラッパー
外部のデータソースを、あたかも Postgres のテーブルであるかのようにクエリできます。
file_fdw: ファイルシステムからデータを読むpostgres_fdw、mysql_fdw、oracle_fdw、sqlite_fdw: 他の SQL データベースに接続redis_fdw、parquet_s3_fdw、kafka_fdw: Redis、S3、Kafka などの非 SQL データソースに接続
Postgres を統合データレイヤーとして使えます。
これ、マイクロサービスアーキテクチャで複数のデータソースを扱う場合に便利そうです。各サービスが独自のデータベースを持っていても、Postgres を経由して統一的にクエリできます。
でも、パフォーマンスはどうなんでしょう。ネットワーク越しにクエリを投げるわけですから、レイテンシーは増えるはずです。この辺りは実際に試してみないとわかりません。(試した結果「遅い!」ってなって、結局専用のデータ同期パイプラインを構築するところまでがテンプレ。)
クエリとパフォーマンス最適化
pg_stat_statements: SQL 文の実行統計を追跡auto_explain: 遅いクエリの実行計画を自動ログhypopg: 仮想インデックスのテスト
auto_explain は便利そうです。普段、遅いクエリを見つけたら、手動で EXPLAIN ANALYZE を実行しています。でも、auto_explain があれば、自動的にログに記録してくれます。
hypopg も面白いです。実際にインデックスを作らずに、仮想的にテストできます。本番環境で「このインデックス、効果あるかな?」と試す前に、リスクなしで検証できます。
ツールとユーティリティ
pg_cron: cron ベースのスケジューラーPostgreSQL Anonymizer: 個人情報の匿名化pgaudit: 監査ログpg_partman: パーティション管理の簡素化
pg_cron があれば、データベース内で定期タスクを実行できます。外部の cron や Airflow を使わずに。
「Just use Postgres」の精神に沿っています。
Postgres 互換ソリューション
7.4 節では、Postgres の拡張機能ではなく、Postgres のプロトコルやソースコードを活用した別のソリューションが紹介されています。
拡張機能で解決できない問題のために、こういった選択肢があります。
ゼロから構築されたソリューション
- Google Spanner
- CockroachDB
Postgres のワイヤレベルプロトコル、DML/DDL 構文、一部の機能をサポートしています。でも、内部実装は完全に別物です。
分散データベースとしての可用性とスケーラビリティを提供します。
Postgres ソースコードをベースにしたソリューション
- Neon(サーバーレスデータベース)
- YugabyteDB(分散データベース)
Postgres のソースコードを再利用しつつ、ストレージレイヤーを変更・拡張しています。
Postgres のアプリケーションをそのまま実行できます。ライブラリ、ツール、フレームワークもそのまま使えます。
この 2 つのアプローチの違いは興味深いです。
ゼロから構築したソリューションは、自由度が高い反面、Postgres との互換性は限定的になります。
Postgres ソースコードベースのソリューションは、互換性が高い反面、アーキテクチャの変更範囲は制約されます。
どちらが良いかは、ユースケース次第です。
でも、どちらも「Postgres のエコシステムを活用したい」という需要から生まれています。それだけ、Postgres が広く使われているということです。
8. Postgres for generative AI
Postgres が Vector Database になる瞬間
第 8 章を読んで最初に感じたのは、「Just use Postgres」が生成 AI の時代でも貫かれているということでした。
「RAG を実装するなら Pinecone か Weaviate を使おう」——これまでそう考えていました。でも、著者が示すのは違います。
Postgres can serve as a powerful vector database for implementing RAG and other gen AI use cases.
既に Postgres を使っているなら、まず Postgres で試してみよう。この章はその具体的な実装方法を示しています。
pgvector という選択肢
pgvector という拡張を有効化するだけで、Postgres が Vector Database になります。
CREATE EXTENSION vector;
たったこれだけ。新しいデータベースを立てる必要がありません。
(「Vector Database 導入提案書」を 3 日かけて書いた過去の自分に教えてあげたい...)
vector(1024) という型が使えるようになります。1024 次元のベクトル埋め込みを格納できます。映画の説明文を mxbai-embed-large モデルで変換した埋め込みを、そのまま Postgres のカラムに保存できます。
CREATE TABLE omdb.movies ( id BIGINT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, movie_embedding VECTOR(1024), ... );
この手軽さ。Docker で pgvector 入りの Postgres を起動するだけで試せます。
docker run --name postgres-pgvector \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password \
-p 5432:5432 \
-d pgvector/pgvector:0.8.0-pg17
「Vector Database を導入しましょう」という提案をする前に、「Postgres で試した?」と聞き返せるようになりました。
Cosine Distance とベクトル類似検索
埋め込みを保存するだけじゃありません。類似検索もできます。
SELECT id, name, description FROM omdb.movies ORDER BY movie_embedding <=> omdb.get_embedding('May the force be with you') LIMIT 3;
<=> は Cosine Distance を計算する演算子です。pgvector が提供しています。
この SQL を実行すると、「May the force be with you」というフレーズに最も関連する映画が返ってきます。当然、Star Wars の映画がトップに来ます。
埋め込みモデルが学習した「意味の空間」の中で、近い映画を見つけてくれます。
でも、最初は全件スキャンになります。4,000 件程度なら許容できますが、規模が大きくなったら?
そこでインデックスが必要になります。
IVFFlat と HNSW——2 つのインデックス戦略
pgvector は 2 種類のインデックスをサポートしています。
IVFFlat: クラスタリングベースの高速化
CREATE INDEX movie_embeddings_ivfflat_idx ON omdb.movies USING ivfflat (movie_embedding vector_cosine_ops) WITH (lists = 5);
IVFFlat は埋め込みをクラスタ (リスト) に分割します。k-means でセントロイドを計算し、各埋め込みを最も近いセントロイドのリストに配置します。
検索時は、クエリの埋め込みに最も近いセントロイドのリストだけをスキャンします。全件スキャンを避けられます。
でも、これは近似検索 (ANN: Approximate Nearest Neighbor) です。真の最近傍が他のリストにいたら、見逃す可能性があります。
Recall (再現率) が完璧じゃありません。
ivfflat.probes パラメータで、スキャンするリスト数を増やせます。Recall は改善しますが、検索速度は落ちます。
BEGIN; SET LOCAL ivfflat.probes = 2; SELECT ... COMMIT;
トレードオフです。
HNSW: 階層グラフによる高精度検索
CREATE INDEX movie_embeddings_hnsw_idx ON omdb.movies USING hnsw (movie_embedding vector_cosine_ops) WITH (m = 8, ef_construction = 16);
HNSW は多層グラフを構築します。上位層は疎で、下位層ほど密になります。
検索は最上層から始まり、段階的に下層に降りていきます。高速かつ高精度です。
著者の実験では、HNSW は IVFFlat より Recall が良いです。データが追加・更新されても Recall が安定しています。
インフラエンジニアとして、この安定性は魅力的です。 データが増えても再インデックスが不要です。IVFFlat はセントロイドが固定されるため、データが大きく変化すると Recall が落ちます。
映画カタログは継続的に成長します。HNSW を選ぶ理由があります。
(夜中の 2 時に「Recall が落ちてます!」というアラートで起こされるのは、もう懲り懲りです)
実際に試してみてわかったのは、ベクトルはランダム生成でも類似検索の動作確認は可能だということ。ジャンルごとにパターンを変えれば、「アクション映画同士が近くなる」という挙動を確認できます。本番データがなくても、仕組みの理解には十分です。
RAG の実装——Postgres を中心に
この章の核心は、RAG (Retrieval-Augmented Generation) の実装です。
RAG の流れです。
- ユーザーが質問を入力
- 質問を埋め込みに変換 (
mxbai-embed-large) - Postgres でベクトル類似検索を実行
- 検索結果をコンテキストとして LLM に渡す
- LLM がコンテキストを考慮して回答を生成
著者は Python の Jupyter Notebook で実装を示しています。LLM には TinyLlama (640 MB、1.1 B パラメータ) を使用しています。
def retrieve_context_from_postgres(question): # 埋め込みモデルに接続 embedding_model = OllamaEmbeddings(model="mxbai-embed-large:335m") # 質問を埋め込みに変換 embedding = embedding_model.embed_query(question) # Postgres でベクトル類似検索 query = """ SELECT name, vote_average, budget, revenue, release_date FROM omdb.movies ORDER BY movie_embedding <=> %s::vector LIMIT 3 """ cursor.execute(query, (embedding, )) # コンテキストを構築 context = "" for row in cursor.fetchall(): context += f"Movie title: {row[0]}, Vote Average: {row[1]}, ..." return context
Postgres から取得した映画情報を LLM に渡します。
def answer_question(question, context): llm = OllamaLLM(model="tinyllama", temperature=0.6) prompt = f""" You're a movie expert and your task is to answer questions about movies based on the provided context. This is the user's question: {question} Consider the following context: {context} Respond in an engaging style that inspires the user to watch the movies. """ response = llm.invoke(prompt) return response
「海賊映画のおすすめは?」と聞くと、Postgres が Pirates of the Caribbean シリーズを返し、LLM がそれをもとに魅力的な推薦文を生成します。
Postgres が RAG のコンテキスト取得レイヤーとして機能しています。
LLM は stateless
この章で確認しておきたいのは、「LLM は stateless」という点です。
Because LLMs are stateless—meaning they don't retain the history of the interaction—if we want the LLM to consider earlier conversation history, we need to store it separately and pass it to the prompt object.
LLM は会話履歴を記憶していません。毎回、コンテキストと履歴を渡す必要があります。
この設計は、Postgres のステートレス性とも通じます。Postgres はクライアントのセッション状態を保持しません (connection pooling の文脈で)。毎回のクエリは独立しています。
だから、会話履歴も Postgres に保存して、RAG のコンテキストとして渡せばいいのです。
全てが Postgres で完結します。
確認しておきたい拡張
pgai という拡張は、この章で初めて目にしました。
Explore the
pgaiextension if you'd like to implement the RAG workflow purely in SQL and execute it entirely within the database.
SQL だけで RAG を実装できます。アプリケーション側に gen AI フレームワークを導入する必要がありません。
調べてみたいです。もし実用的なら、Postgres の可能性がさらに広がります。
9. Postgres for time series
TimescaleDB を改めて評価する
この章で取り上げられている TimescaleDB は、名前は知っていましたが、実際に採用を検討したことはありませんでした。
時系列データベースと言えば、InfluxDB か Prometheus を中心に検討してきました。「時系列データを扱いたいなら専用のデータベースを追加しましょう」という提案をしてきたこともあります。
でも、この章を読んで気づきました。Postgres の拡張機能で時系列データベースができます。
「Just use Postgres」の考え方が、ここでも貫かれています。新しいデータベースを追加する前に、まず Postgres で解決できるか確認します。TimescaleDB はその選択肢の 1 つです。
運用エンジニアとしては、これは大きいです。新しいデータベースを追加するたびに、バックアップ戦略、モニタリング、アラートルール、障害対応手順が増えます。チームのメンバーも新しい技術を学ばなければいけません。
もし Postgres の拡張機能で解決できるなら、運用負荷は格段に減ります。
この章を読み終えて、「次に時系列データの相談が来たら、TimescaleDB を試してみよう」と思いました。
Postgres のパーティショニングと Hypertable
9.1 節では、Postgres のテーブルパーティショニングが紹介されています。
CREATE TABLE heart_rate_measurements ( watch_id INT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL, heart_rate INT NOT NULL, activity TEXT NOT NULL CHECK ( activity IN ('walking', 'sleeping', 'resting', 'workout')) ) PARTITION BY RANGE (recorded_at);
PARTITION BY RANGE (recorded_at) で、recorded_at カラムの値に基づいてテーブルを範囲でパーティション分割する。
その後、各パーティションを手動で作成する必要がある。
CREATE TABLE measurements_jan2025 PARTITION OF heart_rate_measurements FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); CREATE TABLE measurements_feb2025 PARTITION OF heart_rate_measurements FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
このパーティショニング自体は Postgres の標準機能です。時系列データの場合、直近のデータだけが頻繁にアクセスされて、古いデータは圧縮したり削除したりします。パーティショニングを使えば、それが簡単にできます。
でも、パーティションの作成と管理は手動でやる必要があります。著者も書いていますが、pg_partman と pg_cron という拡張機能を使えば自動化できます。
そして、9.2 節で登場するのが TimescaleDB です。
SELECT create_hypertable( relation => 'watch.heart_rate_measurements', dimension => by_range('recorded_at', interval '1 month'), create_default_indexes => false );
この一文で、テーブルが Hypertable に変換されます。Hypertable は Postgres の通常のテーブルですが、TimescaleDB が自動的にパーティション(chunk と呼ばれる)を作成・管理してくれます。
新しいデータが挿入されると、TimescaleDB が自動的に新しい chunk を作ります。
INSERT INTO watch.heart_rate_measurements VALUES (1,'2025-12-08 00:25:00',57,'sleeping');
この INSERT だけで、_timescaledb_internal._hyper_1_13_chunk という新しいパーティションが自動生成されます。
手動でパーティションを作る必要がありません。これは大きいです。
timescaledb_information.chunks でチャンクのメタデータを確認できます。実際に確認してみると、日付ごとにチャンクが自動生成されていることがわかります。
_hyper_1_1_chunk | 2025-01-01 - 2025-01-02 _hyper_1_2_chunk | 2025-01-02 - 2025-01-03 _hyper_1_3_chunk | 2025-01-03 - 2025-01-04
この透過性が TimescaleDB の魅力です。
過去のプロジェクトで、パーティショニングを手動で管理していたことがあります。月次バッチで次月のパーティションを作成するスクリプトを cron で回していました。でも、そのスクリプトが失敗したことに気づかず、翌月の INSERT が全部エラーになりました。月初の朝、Slack が火を噴きました。「データが入らない!」というメッセージが次々と流れてくる。あの日の朝のコーヒーは、確実に苦かったです。
TimescaleDB を使っていれば、そんなことは起きませんでした。というか、あの朝のコーヒーはもっと美味しかったはずです。
データ保持ポリシーの自動化
9.4 節では、データ保持ポリシー(retention policy)の話が出てきます。
SELECT add_retention_policy( 'watch.heart_rate_measurements', INTERVAL '30 days');
これだけで、30 日以上古いデータを自動的に削除するジョブが設定されます。
運用の観点から、これは非常にありがたいです。時系列データは増え続けます。ディスク容量は有限です。古いデータを定期的に削除する必要があります。
過去のプロジェクトでは、手動で SQL を書いて、古いパーティションを DROP していました。でも、これも失敗することがあります。削除スクリプトのバグで、間違ったパーティションを削除してしまったこともありました。具体的に言うと、measurements_jan2025 を消すはずが measurements_jan2024 を消しました。そう、1 年分のデータが吹っ飛びました。バックアップから復旧しましたが、あの日の胃痛は今でも忘れられません。エンジニアのキャリアにおいて、誰もが一度は通る「DELETE/DROP の洗礼」というやつです。
TimescaleDB の retention policy を使えば、そのリスクが減ります。胃痛も減ります。
ただし、著者も警告していますが、このコマンドは慎重に使う必要があります。間違った設定をすると、重要なデータを失う可能性があります。
time_bucket 関数の威力
9.5 節では、TimescaleDB の time_bucket 関数が紹介されています。
SELECT time_bucket('10 minutes', recorded_at) AS period, activity, AVG(heart_rate)::int AS avg_rate, MAX (heart_rate)::int AS max_rate FROM watch.heart_rate_measurements WHERE watch_id = 1 AND activity = 'workout' AND recorded_at >= '2025-04-23' AND recorded_at < '2025-04-24' GROUP BY period, activity ORDER BY period;
これで、10 分ごとのバケットに心拍数を集約できます。
普通の SQL でやろうとすると、DATE_TRUNC や複雑な計算が必要になります。でも、time_bucket を使えば、読みやすいクエリで簡単に集約できます。
さらに、time_bucket はタイムゾーンの指定もできます。
SELECT time_bucket('1 week', recorded_at, 'Asia/Tokyo', '2025-04-01'::timestamptz) AS period, activity, AVG(heart_rate)::int AS avg_rate, MAX (heart_rate)::int AS max_rate, MIN (heart_rate)::int AS min_rate FROM watch.heart_rate_measurements WHERE watch_id = 2 AND recorded_at >= '2025-04-01'AND recorded_at < '2025-04-15' GROUP BY period, activity ORDER BY period, activity;
ユーザーごとに異なるタイムゾーンでデータを集約できます。これはグローバルなサービスでは必須の機能です。
そして、time_bucket_gapfill 関数です。
SELECT watch_id, time_bucket_gapfill('1 minute', recorded_at) AS minute, LOCF(AVG(heart_rate)::int) AS avg_rate FROM watch.heart_rate_measurements WHERE watch_id=1 AND recorded_at BETWEEN '2025-03-02 07:25' AND '2025-03-02 07:36' GROUP BY watch_id, minute ORDER BY minute;
データが欠けている時間帯も含めて、連続した時間バケットを作成してくれます。さらに、LOCF(Last Observation Carried Forward)関数を使えば、欠損値を最後の値で埋めることができます。
過去に、時系列データのグラフを作ったことがあります。データに欠損があると、グラフが途切れてしまいます。アプリ側で欠損値を補間する処理を書きましたが、複雑でした。
time_bucket_gapfill と LOCF を使えば、データベース側で簡単に処理できます。
Continuous Aggregates という機能
9.6 節では、Continuous Aggregates(継続的集約)が紹介されています。
CREATE MATERIALIZED VIEW watch.low_heart_rate_count_per_5min WITH (timescaledb.continuous) AS SELECT watch_id, time_bucket('5 minutes', recorded_at) AS bucket, MIN(heart_rate) as min_rate, COUNT(*) FILTER (WHERE heart_rate < 50) AS low_rate_count, COUNT(*) AS total_measurements FROM watch.heart_rate_measurements GROUP BY watch_id, bucket;
これは Postgres の Materialized View(マテリアライズドビュー)ですが、TimescaleDB が自動的にリフレッシュしてくれます。
リフレッシュポリシーも設定できます。
SELECT add_continuous_aggregate_policy ('watch.low_heart_rate_count_per_5min', start_offset => INTERVAL '15 minutes', end_offset => INTERVAL '1 minute', schedule_interval => INTERVAL '1 minute');
これで、1 分ごとに集約結果が更新されます。
普通の Materialized View は、手動で REFRESH MATERIALIZED VIEW を実行しないと更新されません。でも、TimescaleDB の Continuous Aggregates は自動的に更新されます。
しかも、Hypertable に保存されるので、パーティショニングの恩恵も受けられます。
この章の例では、心拍数が 50 BPM 以下の回数をカウントして、徐脈(bradycardia)の兆候を検出しています。リアルタイムで集約結果を更新して、ユーザーにアラートを送ります。
これ、単なるデモではありません。実用的です。
過去に、IoT デバイスからのデータを集約して、異常を検知するシステムを運用したことがあります。集約処理は別のバッチジョブで定期的に実行していました。でも、リアルタイム性が求められると、バッチでは間に合いません。
TimescaleDB の Continuous Aggregates を使えば、リアルタイムに近い形で集約結果を更新できます。
B-tree インデックスと BRIN インデックス
9.7 節では、時系列データのインデックス戦略が紹介されています。
まず、B-tree インデックスです。
CREATE INDEX heart_rate_btree_idx ON watch.heart_rate_measurements (recorded_at, watch_id);
複合インデックスで、recorded_at と watch_id の両方を含めます。これで、時間範囲とデバイス ID の両方で絞り込むクエリが高速化されます。
著者の説明によれば、B-tree インデックスは実際のカラム値とテーブル行へのポインタを保存します。だから、特定の行に直接アクセスできます。
でも、B-tree インデックスはサイズが大きいです。この章の例では、パーティションごとに数 MB のサイズになっています。
そこで登場するのが BRIN(Block Range Index)です。
CREATE INDEX heart_rate_brin_idx ON watch.heart_rate_measurements USING brin (recorded_at);
BRIN インデックスは、ページ範囲ごとの最小値と最大値だけを保存します。だから、サイズが非常に小さいです。この章の例では、24 KB しかありません。B-tree の 100 分の 1 です。
でも、BRIN はページ全体をスキャンする必要がある場合があります。だから、少量のデータを取得するクエリでは B-tree の方が速いです。
著者の説明を読んで、BRIN の仕組みがよくわかりました。時系列データのように、カラム値が物理的な配置と強く相関している場合に BRIN は有効です。
心拍数測定データは常に追記されます。新しい測定は常に大きな recorded_at 値を持ちます。だから、ページ内のデータは時系列順に並びます。BRIN はこの特性を活かします。
過去に、ログテーブルにインデックスを作ったことがあります。そのテーブルは append-only で、タイムスタンプカラムがありました。B-tree インデックスを作りましたが、サイズが大きくなって困りました。「なんでインデックスがテーブルより大きいんだ?」と首を傾げながら、ディスク容量を確保するために古いインデックスを削除する日々でした。
当時は BRIN を検討していませんでした。Postgres のドキュメントで存在は知っていたはずですが、実際に使う場面を意識していませんでした。必要に迫られないと、知識は実践に結びつかないものです。
10. Postgres for geospatial data
「地理空間データ」の意外な身近さ
この章を読んで認識したのは、地理空間データベースの機能が、自分の仕事に意外と近いということです。
PostGIS の名前は知っていました。でも、「地理空間データベース」という言葉から受ける印象は、「GIS 専門家のための特殊な技術」でした。Google Maps みたいなサービスを作る時に使うやつ、くらいの認識。要するに、「自分には関係ない」と決めつけていたわけです。
実際には、もっと身近なユースケースがあります。
著者が冒頭で説明する Geofabrik(OpenStreetMap のデータ抽出サービス)、osm2pgsql(OSM データのインポートツール)、QGIS(データ可視化ツール)。これらのツールと PostGIS の組み合わせで、10 分以内にフロリダ州全体の地理データをローカル環境で扱える状態にできます。
この手軽さが、「Just use Postgres」の真髄だと感じました。
geometry と geography — 2つのデータ型の意味
10.1.2 節で説明される geometry と geography の違いに、初めて向き合いました。
geometry 型(Web Mercator projection、SRID 3857)。 - 平面(Euclidean plane)として計算 - 単位はメートル - 計算が速い - 距離が長いと精度が落ちる
geography 型(WGS 84、SRID 4326)。 - 球面(spherical model)として計算 - 単位は度(longitude/latitude)だが、計算結果はメートル - 計算が遅い - 地球の曲率を考慮するため正確
「なるほど、速度と精度のトレードオフか」と思いました。でも、本当に理解したのは、用途によって使い分ける必要があるということでした。
ローカルな範囲(例:Tampa 市内のレストラン検索)なら geometry で十分です。でも、大陸をまたぐような距離の計算なら geography が必要になります。
注意点として、ST_Distance に geometry 型を渡すと単位は「度」になります。geography 型を渡すと「メートル」です。最初、この違いを知らずに「距離が 0.003 って何?」と混乱しました。それ、度でした。
著者は本章で主に geometry を使っています。理由は明示されていませんが、フロリダ州内のデータを扱っているからでしょう。
ST_DWithin と ST_Distance — index の有無で 500 倍の差
10.6.2 節の実行計画の比較に目を奪われました。
ST_DWithin を使った場合(Listing 10.26): - 実行時間: 1.125 ms - GiST index を使用(Bitmap Index Scan) - 1,205 件を候補として抽出し、36 件にフィルタリング
ST_Distance を使った場合(Listing 10.27): - 実行時間: 488.119 ms - GiST index を使用せず、フルテーブルスキャン(Parallel Seq Scan) - 18,676 件を候補として抽出し、36 件にフィルタリング
同じ結果(36 件のレストラン)を得るのに、434 倍の時間がかかっています。
なぜこんなに違うのでしょうか。片や 1 ミリ秒でサクッと答え、片や半秒近く考え込んでいます。まるで、道を聞かれて地図アプリを開く人と、記憶を辿って一生懸命思い出そうとする人くらい違います。
ST_DWithinis one of the index-aware functions that can use the GiST index by performing an initial fast filtering of the data using the combination of the bounding box operator&&and theST_Expandfunction.
著者の説明によると、ST_DWithin は内部で bounding box(境界ボックス)を使った高速フィルタリングをします。GiST index がこの bounding box 検索に対応しています。
一方、ST_Distance は常に正確な距離を計算します。bounding box を使わないから、index を利用できません。
この違いを知らなかったら、「ST_Distance(point1, point2) <= 500 で 500m 以内を検索」と書いてしまっていたでしょう。数百万件のデータに対してフルスキャンが走ります。
「index-aware functions」という概念を、初めて意識しました。
GiST の構造 — R-tree で理解できた
10.6.1 節の GiST index の説明は、初めて「わかった」感覚がありました。
以前、B-tree index については理解していました。でも、GiST(Generalized Search Tree)は「汎用的な index」という説明しか見たことがなく、具体的なイメージが湧きませんでした。
著者の図解(Figure 10.6, 10.7, 10.8)がわかりやすかったです。
検索の流れ。 1. Downtown Miami の座標が、どの大きな矩形に含まれるかをチェック → R5 2. R5 の中で、どの小さな矩形に含まれるかをチェック → R24 3. R24 の中の全 points をスキャン → 該当するものだけ返す
R-tree(Rectangular tree)という名前の由来も理解できました。矩形(Rectangle)で空間を階層的に分割していく木構造です。
実際に座標変換も試してみました。Walt Disney World の座標を WGS 84 から Web Mercator へ変換すると、経度 -81.5639 が X -9079651.82 に変わります。緯度 28.3852 は Y 3297626.07 になります。単位がメートルに変わるのがわかります。
この構造、実は Chapter 6 の全文検索で出てきた GiST index と同じ基盤です。あの時は tsvector 型の lexemes を indexing していました。今回は geometry 型の bounding boxes を indexing しています。
GiST は、データ型ごとに異なる index 構造を実装できる汎用フレームワークなんだと、やっと腹落ちしました。
QGIS で可視化 — 「見える」ことの重要性
10.4 節の QGIS による可視化は、実際に手を動かしました。
SELECT name, ST_AsText(way) AS coordinates FROM florida.planet_osm_point WHERE name = 'Tampa' and place = 'city';
このクエリで得た Tampa の座標を、QGIS で表示した時、「あ、本当に Tampa の中心だ」と思いました。
データベースに入っている座標が、実際の地図上の位置と一致します。当たり前のことですが、自分の目で確認するまで信じられませんでした。
planet_osm_polygon テーブルの 6.8 100 万の polygons を QGIS で読み込むと、フロリダ州の地図が少しずつレンダリングされていきます。湖、道路、建物、公園。すべてが Postgres のテーブルに格納されています。
「データが見える」ことの重要性を、改めて実感しました。
osm2pgsql — データインポートの簡単さ
10.3 節で紹介されている osm2pgsql ツールは実用的です。
docker run --name osm2pgsql --network="host" \ -e PGPASSWORD=password \ -v osm2pgsql-volume:/data \ iboates/osm2pgsql:2.1.1 \ -H 127.0.0.1 -P 5432 -d postgres -U postgres --schema florida \ http://d3e4uq6jj8ld3m.cloudfront.net/florida-250501.osm.pbf
このコマンド 1 つで、フロリダ州全体の OSM データ(2025 年 5 月 1 日時点)を Postgres にインポートできます。
所要時間は約 10 分。自分の環境(M1 Mac)では 7 分ほどでした。
インポート後、以下のテーブルが自動生成されます。
planet_osm_point— 単一座標で表現できるもの(レストラン、ホテルなど)planet_osm_line— 線分(道路、川など)planet_osm_polygon— 閉じた領域(建物、公園、湖など)planet_osm_roads—planet_osm_lineのサブセット(ズームレベルが低い時のレンダリング用)
それぞれのテーブルに、既に GiST index が作成されています(planet_osm_point_way_idx など)。
この「すぐに使える」感覚が、PostGIS の魅力だと感じました。
ST_Within と ST_Intersects — 空間関係の判定
10.5.2 節と 10.5.3 節で紹介される ST_Within と ST_Intersects の違いが、最初は曖昧でした。
ST_Within(A, B)。
- A が B の中で完全に含まれている場合は true
- A の全ての点が、B の内部にある
- 例:あるアトラクションが、Disney's Hollywood Studios の中にあるか
ST_Intersects(A, B)。
- A と B が少なくとも 1 点を共有する場合は true
- 完全に含まれていなくてもいい、交差していれば OK
- 例:ある道路が、Miami の境界を横切っているか
Listing 10.22 のクエリで理解できました。
SELECT l.name, l.highway, ST_Length(l.way) AS len_meters FROM florida.planet_osm_line l JOIN miami m ON ST_Intersects(l.way, m.boundaries) WHERE l.highway IN ('primary', 'secondary')
このクエリは、Miami の境界内にある道路だけでなく、境界を横切る道路も取得します。ST_Within を使っていたら、境界を横切る道路は取得できません。
この違いを知らないと、「なぜこの道路が結果に含まれるのか」と混乱したでしょう。
「Just use Postgres」の再確認
この章を読んで、改めて「Just use Postgres」の意味を理解しました。
次に「位置情報を扱うから、MongoDB(GeoJSON 対応)を追加しよう」と言われた時、私は聞き返せます。
「Postgres で試した?PostGIS なら、既存のインフラでできるかもしれない」
新しいデータベースを追加する前に、まず既存の Postgres で何ができるかを確認します。これがこの本の一貫したメッセージです。そして、大抵の場合、Postgres でできてしまいます。追加のインフラを管理する手間(と、深夜の障害対応)が減るのは、エンジニアとしても組織やチームとしてもありがたいです。
地理データだって、Postgres でできます。それも、思ったより簡単に。
11. Postgres as a message queue
メッセージキューとして Postgres を使う、という選択
この章で参考になったのは、「Postgres をメッセージキューとして使う判断基準」が明確に示されていた点です。
正直に言うと、読む前は「Postgres でメッセージキュー? 無理がある」と思っていました。
10 年近くソフトウェアエンジニアをやっている中で、メッセージキューといえば RabbitMQ、Kafka、AWS SQS が標準でした。Postgres はあくまでリレーショナルデータベース。「餅は餅屋」という言葉が頭に浮かびました。というか、新しいツールを導入する言い訳が欲しかっただけかもしれません(インフラエンジニアの悪い癖です)。
でも、この章を読み終えて気づきました。
「Just use Postgres」の本質は、万能性じゃなくて、既存資産の最大活用でした。
Postgres をメッセージキューとして使う 3 つの基準
11.1 節で、著者は 3 つの基準を挙げています。
1. トランザクショナルな一貫性が必要な場合
DMV(運転免許センター)の例が分かりやすかったです。
来訪者がチェックインする(ビジネスロジック)と同時に、待機キューにメッセージを追加する(イベント記録)。この 2 つの操作がアトミックに実行される必要があります。
もし別々のシステム(Postgres + 専用メッセージキュー)だったら、チェックインは成功したのにメッセージ送信が失敗する可能性があります。その時、アプリケーション側で整合性を保証しなければなりません。
If you want the check-in operation and the message added to the visitors queue to be executed atomically (as a single transaction), then use Postgres.
この一文は重いです。
私が関わったプロジェクトで、「決済処理」と「メール送信キュー」が別々のシステムだったせいで、決済完了したのに確認メールが届かないトラブルがありました。結局、リトライ機構を複雑に実装して解決しましたが、あれは Postgres で統一できていれば避けられたかもしれません。深夜 3 時に「メールキューが詰まった」アラートで起こされることもなかったでしょう(遠い目)。
2. メッセージ量が Postgres で処理可能な場合
著者は正直です。
If the effort is too high or the configuration becomes overly complex, consider using a specialized message queue instead.
Postgres の書き込みスケールには限界があります。シングルプライマリインスタンスだから、書き込み負荷が高すぎる場合はシャーディングや分散 Postgres(CitusData、YugabyteDB)が必要になります。
でも、DMV の例では「メッセージ量は比較的低い」と明言しています。
この「正直さ」がいいです。Postgres は万能じゃない、でも適切なユースケースならシンプルで強力です。
3. 既に Postgres を使っている場合
If your application already uses Postgres and now needs to support a message queue use case, consider using Postgres first before bringing in a specialized solution.
これが「Just use Postgres」の核心です。
新しいシステムを追加するコストは、技術的負債だけじゃありません。学習コスト、運用コスト、監視・バックアップ・障害対応の複雑化。全てがチームの負担になります。
既に Postgres を運用しているなら、まず Postgres で試してみる。それで十分なら、アーキテクチャはシンプルなままです。
カスタムメッセージキューの実装
11.2 節と 11.3 節では、カスタムメッセージキューを実装しています。
シンプルな設計
CREATE TABLE mq.queue ( id BIGSERIAL PRIMARY KEY, message JSON NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), status mq.status NOT NULL DEFAULT 'new' );
この設計、シンプルだけど実用的です。
id: 自動採番(BIGSERIAL)で一意性を保証message: JSON 型でペイロードを格納(柔軟性重視)created_at: FIFO 順序の保証status: メッセージのライフサイクル管理(new → processing → completed)
著者が JSON 型を選んだ理由が面白いです。
The
JSONBtype would preprocess messages before storing them, which might slow down ingestion and alter the original structure—for example, by reordering object keys.
JSONB はクエリ効率のために前処理を行いますが、メッセージキューでは「プロデューサーからコンシューマーへそのまま渡す」だけだから、JSON 型で十分です。
この「ユースケースに応じた選択」が、エンジニアリングの本質だと感じました。
FOR UPDATE SKIP LOCKED の威力
mq.dequeue 関数の実装で、FOR UPDATE SKIP LOCKED が使われています。
SELECT id FROM mq.queue WHERE status = 'new' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT messages_cnt
この構文は、改めて確認すると有用です。
FOR UPDATE は行レベルロックをかけます。通常なら、他のトランザクションがロックされた行にアクセスしようとするとブロックされて待機します。
でも SKIP LOCKED を加えると、ロックされている行をスキップして、次の利用可能な行を取得します。
複数のコンシューマーが並行してメッセージを取得しても、お互いをブロックせずに並列処理できます。
This allows consumers to process new messages in parallel without blocking each other, improving overall throughput.
これは Postgres のメッセージキュー実装におけるキラー機能です。
実際に 2 つのワーカーを同時に動かして確認しました。Worker 1 がメッセージ 1, 2 を取得している間、Worker 2 はブロックされずにメッセージ 3, 4 を取得できます。お互いが異なるメッセージを処理する。これが SKIP LOCKED の威力です。
以前、複数ワーカーでジョブキューを処理する実装を Rust で書いた時、排他制御で悩んだことがあります。あの時、FOR UPDATE SKIP LOCKED を知っていれば、もっとシンプルに実装できたかもしれません。
LISTEN と NOTIFY
11.4 節の LISTEN / NOTIFY は、Postgres の隠れた名機能だと感じました。
DMV のシナリオでは、来訪者がチェックインすると、待機中の職員にリアルタイムで通知が届きます。
-- 職員側(リスナー) LISTEN queue_new_message; -- ターミナル側(ノティファイア) SELECT mq.enqueue('{"service": "car_registration", "visitor": "Marta Jones"}'); -- → pg_notify('queue_new_message', 'new_message')
これで、ポーリング不要の非同期通知が実現できます。
ただし、2 つの制限があります。
- 過去の通知は受け取れない: 接続後に発行された通知のみ受信可能
- レプリカでは使えない: プライマリノードへの接続が必要
特に 2 つ目は運用上重要です。読み取り負荷をレプリカに逃がしている構成でも、LISTEN/NOTIFY 専用にプライマリへの接続を維持する必要があります。
でも、この制限を理解した上で使えば、非常に強力な機能です。
あと、pg_notify はトランザクション終了時に送信されます。途中でロールバックすると通知も送られません。これは整合性の観点から正しい動作ですが、最初は「なぜ通知が来ない?」と悩みました。
実装上の考慮事項
11.5 節では、いくつかの重要な考慮事項が述べられています。
インデックス戦略
mq.dequeue 関数は、デフォルトではフルテーブルスキャンを行います。created_at と status にインデックスがないからです。
著者は 2 つのオプションを提示しています。
オプション 1: created_at のみのインデックス。
CREATE INDEX mq_created_at_index_btree ON mq.queue (created_at);
オプション 2: パーシャルインデックス(推奨)
CREATE INDEX mq_partial_index_btree ON mq.queue (created_at, status) WHERE status = 'new';
パーシャルインデックスは、status = 'new' の行だけをインデックスに含めます。これで、インデックスサイズが小さくなり、new メッセージへのアクセスがさらに高速化されます。
この「状況に応じた最適化」の姿勢が参考になります。DMV のユースケースでは不要かもしれませんが、高頻度メッセージングなら必須です。
パーティショニング
11.5.3 節のパーティショニングの話は、時系列データの章(第 9 章)とつながりました。
メッセージキューも時系列データの一種です。created_at でレンジパーティショニングすれば、古いメッセージを効率的にアーカイブ・削除できます。
CREATE TABLE mq.queue ( id BIGSERIAL, message JSON NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), status mq.status NOT NULL DEFAULT 'new', PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (created_at);
パーティションごとにメッセージを管理できるから、古いパーティションを削除(DROP TABLE)するだけで大量の古いメッセージを一瞬で消せます。
VACUUM の負荷も軽減されます。なぜなら、新しいパーティションだけが頻繁に更新されるからです。
この設計パターンは、ログ管理やイベントストアにも応用できそうです。
フェイルオーバー機構
11.5.4 節で、メッセージ処理の失敗対策が述べられています。
コンシューマーがメッセージを取得(status = 'processing')した後にクラッシュすると、そのメッセージは processing 状態のまま放置されます。
著者の提案は、pg_cron を使った定期的なリセットです。
a periodic job in the database to check for messages stuck in the
processingstate and reset their status tonew.
これは実用的です。ただし、同じメッセージが複数回処理される可能性があるから、コンシューマー側で冪等性を保証する必要があります。
pgmq 拡張
11.6 節と 11.7 節では、pgmq 拡張が紹介されています。
pgmq は「Postgres Message Queue」の略で、AWS SQS 互換の API を提供します。
カスタム実装で学んだ原理を、pgmq が抽象化してくれます。
可視性タイムアウト
pgmq.read 関数の vt(visibility timeout)が面白いです。
SELECT msg_id, message, enqueued_at FROM pgmq.read( queue_name => 'visitors_queue', vt => 120, -- 2分間の可視性タイムアウト qty => 1 );
メッセージを取得してから 120 秒間、そのメッセージは他のコンシューマーから見えなくなります。
でも、120 秒以内に pgmq.archive を呼ばないと、メッセージは再びキューに戻ります。
これで、コンシューマー失敗時の自動リトライが実現できます。
DMV の例では、職員が来訪者を呼び出してから 2 分以内に現れなければ、別の来訪者を呼び出せる仕組みに使われています。
この「タイムアウトベースのフェイルオーバー」は、AWS SQS と同じ設計パターンです。
アーカイブテーブル
pgmq.archive 関数は、メッセージを pgmq.q_visitors_queue から pgmq.a_visitors_queue に移動します。
削除(DELETE)ではなくアーカイブ(移動)だから、処理済みメッセージの監査ログを保持できます。
これは本番運用で重要です。「このメッセージ、本当に処理されたのか?」を後から確認できます。
本全体を読み終えて
第 11 章は、この本の最終章です。
第 1 章「Meeting Postgres」から始まり、JSON、地理空間、全文検索、時系列、ベクトル検索、グラフ、そしてメッセージキュー。
「Just use Postgres」の本質は、Postgres の万能性を主張することじゃありませんでした。
既に Postgres を使っているチームが、新しいユースケースに直面した時、別のデータベースを追加する前に、まず Postgres で解決できるか試してみよう、というメッセージです。
それは、アーキテクチャをシンプルに保つための選択であり、運用コストを抑えるための選択であり、チームの認知負荷を減らすための選択です。
10 年近くソフトウェアエンジニアをやってきて、システムが複雑化する様子を何度も見てきました。
「全文検索だから Elasticsearch」 「時系列データだから InfluxDB」 「メッセージキューだから RabbitMQ」
確かに、それぞれの専用ソリューションは強力です。でも、それぞれが運用コストを生みます。バックアップ、モニタリング、アラート、障害対応、バージョンアップ。全てがチームの負担になります。そして、構成図に新しいアイコンが増えるたびに、誰かが「これ誰がメンテするんですか?」と聞く声が聞こえます。
「Just use Postgres」は、その複雑化への抵抗です。
もちろん、これは「新しい技術を学ぶな」という意味ではありません。
新しいツールやサービスが出てきたとき、まず「運用時にどうなるか」を考える。それがベテランエンジニアに求められる姿勢だと思います。機能の魅力だけでなく、3 年後にメンテナンスできる人がいるか、障害時に対応できるか、既存システムとの整合性はどうか。
これは Postgres の新機能についても同じです。pgvector は便利ですが、まだ運用実績が浅い。TimescaleDB も Postgres の拡張とはいえ、独自のアップグレードパスがあります。「Postgres だから安心」ではなく、その機能の成熟度を見極める必要があります。
結局のところ、謙虚に学び続けるしかありません。新しい技術も、既存の技術も。
私が最近考えている技術選定の基準があります。
- 替えの利く技術は、流行に従う
- 替えの利きづらい基盤は、標準に従う
- 競争優位の核は、自ら設計する
Postgres は、競争優位の核になる場合もありますが、基本的には「替えの利きづらい基盤」であることが多いです。だからこそ、40 年の実績がある標準的な選択肢を使い、その可能性を最大限に活かす。それが、この本から学んだことです。
もちろん、Postgres で解決できないユースケースもあります。著者は正直にそれを認めています。
でも、試す前から諦めるのではなく、まず Postgres で試してみる。それで十分なら、アーキテクチャはシンプルなままです。
この本を読み終えて、次に「〇〇が必要だから△△を導入しましょう」と言われた時、私は自信を持って聞き返せるようになりました。
「Postgres で試しましたか?」
おわりに
読むことと、手を動かすこと
11 章を読み終えて、私は 1 つの疑問を持っていました。
「本当に、Postgres でこれだけのことができるのか?」
本に書いてあることを読んで「なるほど」と思うのと、実際に動かして確認するのは、全く別の体験です。少なくとも、私にとっては。
だから、手を動かすことにしました。
Docker で Postgres を立てて、Rust でコードを書いて、各章の内容を 1 つずつ検証しました。generate_series から始まって、CTE、Window Functions、Recursive Query と進みました。JSONB、全文検索、pgcrypto、pgvector、TimescaleDB、PostGIS、そしてメッセージキュー。全 11 章です。
その過程で、いくつかのことに気づきました。
手を動かして初めてわかったこと
本を読んでいるときは「ふーん」と思っていたことが、実際に動かすと「あ、そういうことか」に変わる瞬間があります。
例えば、FOR UPDATE SKIP LOCKED。
本には「複数のコンシューマーが並行してメッセージを取得できる」と書いてありました。でも、実際に 2 つのワーカーを同時に動かして、それぞれが異なるメッセージを取得するのを見たとき、初めて腑に落ちました。
Worker 1 がメッセージ 1 を取得: {"service":"registration","visitor":"Alice"}
Worker 2 がメッセージ 3 を取得: {"service":"registration","visitor":"Charlie"}
この出力を見て、「ああ、本当にブロックせずにスキップしてるんだ」と思いました。言葉で理解することと、目で見て理解することは、違うものです。
他にも気づきはありました。
pg_typeof() の結果を Rust で取得しようとしたらエラーになって、::TEXT でキャストする必要があることを知りました。PL/pgSQL の変数名がテーブルのカラム名と衝突してエラーになることも知りました。TEMP TABLE の名前が別のデモと衝突して、「なんでエラーになるんだ?」と 30 分悩んだこともあります。
これらは本には書いてありません。当たり前です。本は概念を説明するものであって、私が遭遇するエラーを予測するものではないから。
でも、そういうエラーと向き合う時間こそが、理解を深める時間だったと思います。
判断基準が見えてきた
11 章を読み終えて、そして検証を終えて、私の中に 1 つの判断基準ができました。
「いつ Postgres で十分で、いつ専用ツールを検討すべきか」
全文検索なら、数百万件以下のシンプルな検索であれば Postgres で十分です。ただし数億件規模や日本語の形態素解析、複雑なファセット検索が必要なら、Elasticsearch を検討すべきです。
ベクトル検索なら、pgvector で数百万ベクトルまでは対応できます。でも、数億ベクトル規模やリアルタイム更新が必要なら、Pinecone や Milvus の出番です。
メッセージキューなら、秒間数百メッセージ程度なら Postgres で十分です。でも、秒間数万メッセージや複雑なルーティングが必要なら、RabbitMQ や Kafka を使うべきです。
この判断基準は、本を読んだだけでは身につかなかったと思います。実際に動かして、限界を感じて、初めてわかることがありました。
「Postgres で試した?」
この本を読み始める前、私はこの言葉を言えませんでした。
「全文検索が必要です」と言われたら、「Elasticsearch ですね」と即答していました。「時系列データを扱いたい」と言われたら、「InfluxDB か TimescaleDB ですね」と答えていました。TimescaleDB が Postgres の拡張であることすら、あまり意識していませんでした。
今は違います。
「全文検索が必要です」と言われたら、「どのくらいのデータ量ですか? 検索の要件は? まず Postgres の tsvector で試してみませんか?」と聞き返せます。
「ベクトル検索がしたい」と言われたら、「pgvector で試してみましょうか。数百万ベクトルくらいなら対応できますよ」と提案できます。
それが良いことなのかどうか、正直わかりません。
もしかしたら、早めに専用ツールを導入した方が、長期的には幸せだったかもしれません。Postgres で頑張った結果、パフォーマンスの壁にぶつかって、結局移行することになるかもしれません。
でも、少なくとも「試した上で判断する」ことはできるようになりました。
「Postgres で試した?」
その一言を、自信を持って言えるようになりました。そして、自分自身にも問いかけるようになりました。
新しいデータベースを追加する前に、まず Postgres で試してみる。それで十分なら、アーキテクチャはシンプルなままです。運用負荷も増えません。深夜 3 時のアラート対応の可能性も、1 つ減ります。
それだけで、この本を読んだ価値はあったと思います。
最後に
11 章分の感想を書いて、検証コードを書いて、そしてこの「おわりに」を書いています。
読み始めたときは、「Postgres の可能性を広げる本」だと思っていました。
読み終えた今は、「技術選定の視点を変える本」だったと思っています。
「最適なツールを選ぶ」という言葉は、聞こえが良いです。でも、その「最適」は何を基準にしているのでしょうか。機能の豊富さ? パフォーマンス? それとも、運用の複雑さ?
この本は、「十数年単位の運用の複雑さ」という視点を私に与えてくれました。
新しいデータベースを追加することは、コストです。学習コスト、運用コスト、監視・バックアップ・障害対応の複雑化。全てがチームの負担になります。
既に Postgres を使っているなら、まず Postgres で試してみる。それで十分なら、そのコストを払わなくて済みます。
それが「Just use Postgres」の本当の意味だと、今は思っています。
「できる」と「やるべき」の違い
この本を読んで、1 つ注意しなければならないことがあります。
「Postgres でできる」と「Postgres でやるべき」は、違います。
本書は Postgres の可能性を示してくれますが、すべてのユースケースで Postgres を選ぶべきだとは言っていません。著者自身も、専用ツールが必要な場面があることを認めています。
大事なのは、選択肢を知った上で判断することです。
「Postgres でもできるけど、このユースケースでは Kafka の方が適している」と判断するのと、「Postgres でできることを知らずに Kafka を選ぶ」のでは、意味が違います。前者は informed decision、後者は思い込みです。
この本は、その informed decision をするための知識を与えてくれました。
チームと知識の継承
もう 1 つ、この本を読んで考えたことがあります。
技術選定は、個人の問題ではありません。チームの問題です。
新しいデータベースを導入するということは、チームメンバー全員がそれを学ぶ必要があるということです。障害対応できる人が増えなければ、特定の人に負荷が集中します。その人が退職したら、知識が失われます。
Postgres を選ぶということは、チームの認知負荷を抑えるという選択でもあります。
多くのエンジニアが Postgres の基本を知っています。採用市場でも、Postgres 経験者を見つけるのは比較的容易です。ドキュメントも豊富で、コミュニティも活発です。
「技術的に最適」と「チームにとって最適」は、必ずしも一致しません。十数年単位で考えたとき、チームの持続可能性も重要な判断基準です。
謙虚に学び続けること
この本を読んで、もう 1 つ気づいたことがあります。
10 年近くこの仕事をしていても、知らないことはたくさんあります。Recursive CTE の活用パターン、BRIN インデックスの使い所、FOR UPDATE SKIP LOCKED の仕組み。どれも Postgres に昔からある機能ですが、実務で使う機会がなければ、深く理解することはありませんでした。
新しい技術が出てきたとき、いきなり飛びつくのは危険です。でも、既存の技術の可能性を見落としているのも、同じくらい問題です。
これは Postgres の新機能についても同じです。pgvector や TimescaleDB は便利ですが、Postgres 本体と比べれば運用実績は浅い。「Postgres を使う」という判断と、「Postgres の新機能を本番投入する」という判断は、別々に評価する必要があります。
結局のところ、謙虚に学び続けるしかありません。
はじめにでも書きましたが、私は技術選定についてこう考えています。
- 替えの利く技術は、流行に従う
- 替えの利きづらい基盤は、標準に従う
- 競争優位の核は、自ら設計する
Postgres は、競争優位の核になる場合もありますが、基本的には「替えの利きづらい基盤」であることが多いです。だからこそ、流行りの新しいデータベースに飛びつく前に、まず Postgres で何ができるかを確認する。それが、この本から学んだ姿勢です。
もちろん、Postgres で全てが解決できるわけではありません。本当に専用ツールが必要な場面もあります。大事なのは、「試した上で判断する」ことです。
最適解を求めて複雑さを増やすより、十分解でシンプルさを保つ方が、長期的には幸せなことが多いです。
少なくとも、深夜 3 時のアラート対応は減ります。それは、間違いありません。