PostgreSQL の JSONB を NoSQL と比べながら使ってみた

に公開

PostgreSQL に JSONB という型があるらしいと聞いて、どんなものか気になったので試してみました。

せっかくなので MongoDB と同じクエリを書き比べてパフォーマンスも計測しています。


まず JSONB ってなに?

PostgreSQL には JSON を格納する型が 2 つあります。

中身 特徴
JSON テキストをそのまま保存 読み出すたびにパース
JSONB バイナリに変換して保存 読み出し時のパース不要・インデックス対応

JSONB は書き込み時に一度だけパースして、バイナリ形式で保存します。その分書き込みはわずかに遅いですが、検索・集計は JSON より速く、インデックスも使えます。

迷ったら JSONB を選んでおけば問題ないと思います。JSON が必要になるのは「元の JSON テキストをキー順序まで含めてそのまま保持したい」という特殊なケースに限られます。


環境構築

Docker で PostgreSQL と MongoDB を立てます。

# docker-compose.yml
services:
  postgres:
    image: postgres:17.2
    container_name: bench_postgres
    environment:
      POSTGRES_USER: bench
      POSTGRES_PASSWORD: bench_pass
      POSTGRES_DB: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  mongo:
    image: mongo:8.0.4
    container_name: bench_mongo
    environment:
      MONGO_INITDB_ROOT_USERNAME: bench
      MONGO_INITDB_ROOT_PASSWORD: bench_pass
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db

volumes:
  postgres_data:
  mongo_data:
docker compose up -d

テーブルを作る

今回の題材は、EC サイトの商品カタログです。家電・衣料・食品でスペックの構造がまったく異なるので、JSONB の使いどころが分かりやすい例です。

CREATE DATABASE bench_ec;
\connect bench_ec

CREATE TABLE products (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name         TEXT NOT NULL,
    category     TEXT NOT NULL CHECK (category IN ('ELECTRONICS', 'CLOTHING', 'FOOD')),
    price_jpy    INT  NOT NULL,
    spec         JSONB NOT NULL   -- ← ここが JSONB カラム
);

CREATE TABLE orders (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id      UUID NOT NULL,
    ordered_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    total_jpy    INT  NOT NULL
);

CREATE TABLE order_items (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id     UUID NOT NULL REFERENCES orders(id),
    product_id   UUID NOT NULL REFERENCES products(id),
    quantity     INT  NOT NULL,
    unit_price   INT  NOT NULL
);

spec JSONB の 1 カラムに、カテゴリごとに構造が異なるデータを入れます。インデックスはあとで貼ります。

データを入れてみる

INSERT INTO products (name, category, price_jpy, spec) VALUES
  ('Acme ProBook 13', 'ELECTRONICS', 149800,
   '{"brand":"Acme","cpu":"X7-Pro","ram_gb":16,"storage_gb":512,"display_inch":13.3}'),
  ('Lumina AirBook', 'ELECTRONICS', 164800,
   '{"brand":"Lumina","cpu":"A2","ram_gb":8,"storage_gb":256,"display_inch":13.6}'),
  ('Vortex WorkPad X1', 'ELECTRONICS', 198000,
   '{"brand":"Vortex","cpu":"X5-Pro","ram_gb":32,"storage_gb":1024,"display_inch":14.0}'),
  ('Arco ドライT', 'CLOTHING', 1990,
   '{"brand":"Arco","size":"M","color":"ホワイト","material":"ポリエステル","washing":"洗濯機可"}'),
  ('Strida ランニングシャツ', 'CLOTHING', 4990,
   '{"brand":"Strida","size":"L","color":"ブラック","material":"ポリエステル","washing":"洗濯機可"}'),
  ('GoldBrew 一番搾り 350ml', 'FOOD', 220,
   '{"brand":"GoldBrew","weight_g":350,"ingredients":["麦芽","ホップ"],"allergens":["小麦"],"expiry_days":180}'),
  ('AlpFarm ミルク', 'FOOD', 158,
   '{"brand":"AlpFarm","weight_g":200,"ingredients":["生乳"],"allergens":["乳"],"expiry_days":14}');

同じ products テーブルなのに、spec の中身がカテゴリごとにまったく違います。これが JSONB を使う動機です。


クエリを書いてみる

6 本のクエリを PostgreSQL と MongoDB で書き比べます。演算子は使うたびに説明します。


Q1: カテゴリ別の商品数を数える

一番シンプルなところから始めます。

PostgreSQL

SELECT category, COUNT(*)::int AS cnt
FROM products
GROUP BY category
ORDER BY category;

MongoDB

db.products.aggregate([
  { $group: { _id: "$category", cnt: { $sum: 1 } } },
  { $sort: { _id: 1 } },
])

SQL は GROUP BY + COUNT(*) で完結します。MongoDB は「集計パイプライン」という考え方で、ステージを配列でつなぎます。慣れるまで少し読みづらいかもしれません。


Q2: 家電で RAM 16GB 以上の商品を検索する

ここで初めて JSONB 固有の書き方が登場します。

PostgreSQL

SELECT id, name, (spec->>'ram_gb')::int AS ram_gb
FROM products
WHERE category = 'ELECTRONICS'
  AND (spec->>'ram_gb')::int >= 16;

->>ってなに?

->> は JSONB からフィールドを TEXT(文字列)として取り出す演算子です。

spec->>'ram_gb'   -- → '16'(TEXT)

TEXT で返ってくるので、数値として比較するには ::int のキャストが必要です。「なんでキャストが必要なの?」と最初は思いますが、「JSONB の世界から SQL の型付きの世界に戻す操作」と考えると自然です。

ちなみに -> というよく似た演算子もあって、こちらは JSONB のまま 取り出します。

spec->'ram_gb'    -- → 16(JSONB の数値)
spec->>'ram_gb'   -- → '16'(TEXT)

ネストしたデータをさらに操作したいときは -> を使い、最終的に値を取り出すときは ->> を使うイメージです。

MongoDB

db.products.find(
  { category: "ELECTRONICS", "spec.ram_gb": { $gte: 16 } },
  { projection: { _id: 0, id: 1, name: 1, "spec.ram_gb": 1 } }
)

MongoDB はドット記法 "spec.ram_gb" でネストしたフィールドを直接指定できます。数値として格納されているので型変換も不要です。この点は MongoDB の方がシンプルです。


Q3: アレルゲン「小麦」を含む食品を探す

配列を含む JSONB の検索です。ここで強力な演算子が登場します。

PostgreSQL

SELECT id, name
FROM products
WHERE category = 'FOOD'
  AND spec @> '{"allergens":["小麦"]}'::jsonb;

@>ってなに?

@> は「左辺の JSONB が右辺を包含するか」を調べる演算子です。

-- allergens 配列の中に "小麦" が含まれているか
spec @> '{"allergens":["小麦"]}'

「この JSON オブジェクトの中に、指定したキーと値が含まれているか」を 1 行で書けます。配列でも、ネストしたオブジェクトでも同じ記法で使えます。

あとで説明しますが、@>GIN インデックスと組み合わせると特に速くなります。

MongoDB

db.products.find(
  { category: "FOOD", "spec.allergens": "小麦" },
  { projection: { _id: 0, id: 1, name: 1 } }
)

MongoDB は配列フィールドにスカラー値で検索すると「配列の中に含まれるか」と自動で解釈します。直感的ですね。


Q4: ブランド別の商品数 TOP10

JSONB フィールドで GROUP BY します。

PostgreSQL

SELECT spec->>'brand' AS brand, COUNT(*)::int AS cnt
FROM products
GROUP BY spec->>'brand'
ORDER BY cnt DESC
LIMIT 10;

Q2 と同じく ->>'brand' で TEXT として取り出してグループ化します。

MongoDB

db.products.aggregate([
  { $group: { _id: "$spec.brand", cnt: { $sum: 1 } } },
  { $sort: { cnt: -1 } },
  { $limit: 10 },
])

Q5: カテゴリ別の売上合計(JOIN が絡む)

ここが一番差が出たクエリです。

PostgreSQL

SELECT p.category, SUM(oi.quantity * oi.unit_price)::bigint AS total
FROM order_items oi
JOIN products p ON p.id = oi.product_id
GROUP BY p.category
ORDER BY total DESC;

普通の SQL です。JSONB はまったく関係なく、JOIN と集計だけです。

MongoDB

db.order_items.aggregate([
  {
    $lookup: {
      from: "products",
      localField: "product_id",
      foreignField: "id",
      as: "product"
    }
  },
  { $unwind: "$product" },
  { $group: {
      _id: "$product.category",
      total: { $sum: { $multiply: ["$quantity", "$unit_price"] } }
  }},
  { $sort: { total: -1 } },
])

$lookup で別コレクションを結合し、$unwind で配列を展開してから集計します。SQL の JOIN に相当しますが、MongoDB のドキュメント指向とは少し相性が悪い操作です(後述)。


Q6: 月別の注文件数

日付集計です。

PostgreSQL

SELECT DATE_TRUNC('month', ordered_at) AS month, COUNT(*)::int AS cnt
FROM orders
GROUP BY month
ORDER BY month;

MongoDB

db.orders.aggregate([
  {
    $group: {
      _id: { $dateToString: { format: "%Y-%m", date: "$ordered_at" } },
      cnt: { $sum: 1 }
    }
  },
  { $sort: { _id: 1 } },
])

GIN インデックスを貼る

@> の包含検索をさらに速くするために GIN インデックスを貼ります。

-- JSONB カラム全体に GIN を貼る(@> 演算子に効く)
CREATE INDEX idx_products_spec_gin ON products USING GIN (spec);

-- 特定フィールドの値に B-tree を貼る(等値・範囲比較に効く)
CREATE INDEX idx_products_category ON products (category);
CREATE INDEX idx_products_spec_brand ON products ((spec->>'brand'));
CREATE INDEX ON order_items (product_id);

GIN インデックスは「この JSONB の中に〇〇が含まれるか」という検索に特化したインデックスです。ただ、小さいテーブルでは使われないことがあります(理由は後述)。


パフォーマンス計測

計測環境: MacBook Pro M2 / メモリ 16GB / macOS 26.5 / Docker Desktop / PostgreSQL 17.2 / MongoDB 8.0.4 / Node.js 25.2.1 / tinybench

products 1,000件・orders 5,000件・order_items 約15,000件のデータで計測しました。各クエリはまず10回空打ち(ウォームアップ)してからの本計測100回の平均です。ウォームアップはキャッシュが温まっていない最初の数回が結果に混ざらないようにするためです。

クエリ PostgreSQL (avg) MongoDB (avg) PG 優位倍率
Q1: カテゴリ別商品数 0.305 ms 1.045 ms 3.4x
Q2: 家電 RAM≥16GB 検索 0.472 ms 3.199 ms 6.8x
Q3: 食品アレルゲン検索 0.355 ms 0.630 ms 1.8x
Q4: ブランド別件数 TOP10 0.461 ms 1.079 ms 2.3x
Q5: カテゴリ別売上合計(JOIN) 2.796 ms 229.509 ms 82.1x
Q6: 月別注文件数 1.216 ms 2.368 ms 1.9x

Q5 が 82 倍差。データは約 1.5 万件と大規模ではないのに、なぜこんなに差がつくのか、EXPLAIN で確認してみます。


EXPLAIN で中を見る

EXPLAIN は「このクエリを PostgreSQL がどう実行するか」を教えてくれるコマンドです。インデックスが使われているか、どこで時間がかかっているかを確認できます。調べたいクエリの先頭に付けて実行するだけです。

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, name
FROM products
WHERE spec @> '{"allergens":["小麦"]}'::jsonb;

ANALYZE を付けると実際に実行して実測時間も出してくれます(付けない場合は予測のみ)。本番データで重いクエリを調べるときは ANALYZE なしにしておく方が安全です。

発見1: GIN インデックスを貼ったのに使われなかった(Q3)

Q3 のアレルゲン検索は @> 演算子を使っていて、GIN インデックスが効くはずのクエリです。ところが実際の EXPLAIN はこうなりました。

Seq Scan on products
  Filter: (spec @> '{"allergens": ["小麦"]}'::jsonb)
  Rows Removed by Filter: 947
Execution Time: 0.492 ms

Seq Scan はテーブルを全件なめる操作で、インデックスを使っていません。

理由はテーブルが小さいことです。EXPLAIN を見ると Buffers: shared hit=30 とあり、テーブル全体が 30 ページ(約 240KB)しかありません。PostgreSQL のプランナーはコストを計算して実行計画を決めるのですが、30 ページを順番に読む Seq Scan より GIN インデックスを経由する方がかえってコストが高いと判断しました。データが増えてページ数が増えれば GIN が活きてきます。

発見2: Q5 の 82 倍差はなぜ起きたか

PostgreSQL の EXPLAIN はこうなっていました。

Hash Join
  Hash Cond: (oi.product_id = p.id)
  ->  Seq Scan on order_items oi  (rows=15161)
  ->  Hash
        ->  Seq Scan on products p  (rows=1000)
Execution Time: 9.288 ms

products(1,000 件)をメモリ上のハッシュテーブルに展開し、order_items(15,161 件)を 1 回スキャンしながら一気に照合しています。1 回の操作で結合が終わるので速い。

一方、MongoDB の explain を見ると、$lookup ステージで totalDocsExamined: 30248 とあり、インデックスを使いながらも products を 30,248 回個別に参照していました(executionTimeMillis: 272)。order_items の 1 件ごとに products を引きに行くため、件数分だけ繰り返しが発生します。

PostgreSQL との処理方法の違いがそのまま 82 倍の差になりました。


まとめ

PostgreSQL の JSONB は「想像より全然使える」というのが率直な感想です。

今回検証したクエリで MongoDB を上回り、特に JOIN を含む集計では 82 倍という差がつきました。小規模データでのローカル計測という条件ですが、既存の PostgreSQL 環境で「レコードごとにスキーマが違うデータを持ちたい」という場面では JSONB は十分現実的な選択肢です。

JSONB が向いているケース

  • カテゴリや種類によって属性の構造が変わるデータ
  • スキーマが固まっていない初期フェーズ
  • 固定カラムと可変属性が混在する「ハイブリッド設計」

JSONB が向いていないケース

  • 全フィールドが固定で型付きカラムが使えるなら正規化の方が速い
  • ->> で取り出すたびにキャストが必要でコードが冗長になる

検証結果だけ見ると「PostgreSQL の方が全部速いじゃん、MongoDB いらなくない?」と思えますが、条件が違います。
MongoDB はそもそも「全データをドキュメントとして持つ」前提で設計されていて、JOIN が不要な設計にします。
Q5 で $lookup が遅かったのも、MongoDB でその設計をすること自体がアンチパターンで、本来は注文情報を商品ドキュメントに埋め込む設計にします。
なので今回の検証は「MongoDB を不利な使い方で計測している」面があります。

また、MongoDB には水平スケールアウト(シャーディング)や、ドキュメントが深くネストした構造の扱いやすさなど、PostgreSQL にない強みもあります。(気が向いたら別記事で書くかもです)

「JSONB があれば MongoDB はいらない」ではなく、「PostgreSQL をすでに使っていてスキーマに柔軟性が欲しい場面では JSONB も結構使えるよ」という話です。

NCDC テックブログ

Discussion