Claude Media
Claude CodeでJSONBのGINインデックスを設計する手順 — PostgreSQL

Claude CodeでJSONBのGINインデックスを設計する手順 — PostgreSQL

PostgreSQLのJSONB列に入れるものと列に出すものを分け、GINの2つの演算子クラスを選び、EXPLAINで索引が使われるかまで確かめる手順を、Claude Codeに任せる範囲とあわせてまとめます。

Claude CodeでJSONBのGINインデックスを設計する手順 — PostgreSQL

JSONB列の設計で決めることは3つあります。何をJSONBに入れ、何を普通の列に出すか。GINの演算子クラスをどちらにするか。そして、その索引が実際に使われているかをどう確かめるか。Claude Codeに任せやすいのは3つ目で、1つ目と2つ目は「どんなクエリを引くか」を人が先に渡せるかで決まります。

この記事では、PostgreSQLのドキュメントに書かれたJSONBとGINの仕様を土台に、Claude Codeへ頼む順番、CLAUDE.mdに置く約束、EXPLAINで索引の効きを確かめるSQLを順に並べます。

JSONBに入れるものと、列に出すものの線引き

JSONBは、要件が固まりきっていない部分を柔軟に持つための型です。PostgreSQLのドキュメントも、リレーショナルな列とJSONが同じアプリケーションの中で共存し、補い合えると述べています。ただし柔軟性を最大にしたい場合でも、JSON文書にはある程度固定した構造を持たせることが勧められています。構造が予測できるほど、文書の集合を集計するクエリが書きやすくなるためです。

更新の面でも注意があります。JSONの更新は行全体の行ロックを取ります。大きな文書を入れると、同じ行を更新するトランザクション同士のロック競合が増えます。ドキュメントは、文書を管理できる大きさに抑え、業務ルール上それ以上は分割できない1つのデータ(atomic datum)にすることを理想としています。

ここから、列に出すかどうかの目安を編集上の判断として引けます。

項目の性質置き場所の目安理由
全行にあり、結合キーや一意制約の対象になる置き場所の目安普通の列理由型と制約を宣言できる
頻繁に絞り込みや並べ替えに使い、値の型が一定置き場所の目安普通の列理由通常の索引で足りる
行ごとに有無や形が違う属性(外部サービスの応答、ユーザー定義の項目)置き場所の目安JSONB理由構造が固定できない部分だけを任せる
別々のタイミングで更新される値の束置き場所の目安列か別テーブル理由1行まるごとのロックを避ける
配列で持つ分類(タグなど)置き場所の目安JSONB(式インデックス候補)理由後述の式インデックスで絞れる

最後の列の「理由」は、ドキュメントの記述(構造を固定する推奨、行ロック、式インデックス)を設計の言葉に直したものです。ドキュメントがこの表のとおりに分類しているわけではありません。

Claude Codeに頼む前に、クエリの一覧を渡す

この線引きは、リポジトリのコードだけからは決まりません。どの画面がどの条件で引くか、更新が同じ行に集中するか、といった情報が要るためです。DynamoDBの設計でも同じことが言え、アクセスパターンから詰める手順では、一覧づくりを人が答える側に置いています。

JSONB設計でも、先にdb/QUERY_PATTERNS.mdのような一覧を作らせると進めやすくなります。次のように頼みます。

orders テーブルの attrs(jsonb)列の使われ方を、リポジトリの SQL とクエリビルダから洗い出して。
条件に使うキー、演算子(->> / @> / ? など)、ORDER BY の有無を表にして。
コードから読み取れない頻度や更新の偏りは推測せず、私に質問して。

出てくる表は、たとえば次のような形になります(列名は例です)。

画面・処理条件の書き方演算子索引の候補
注文検索条件の書き方attrs @> '{"channel": "web"}'演算子@>索引の候補jsonb_path_ops
タグ絞り込み条件の書き方attrs -> 'tags' ? 'gift'演算子?索引の候補(attrs -> 'tags') への式インデックス
管理画面の一覧条件の書き方attrs ->> 'memo' を表示するだけ演算子なし索引の候補索引は不要

右端の列は演算子から機械的に導ける候補で、採用の可否は件数と更新頻度を見て決めます。「表示するだけ」の行を索引の対象から外せるのも、一覧にしておく利点です。

洗い出した表を見て、人が「このキーは全件に必ずある」「この属性は更新が多い」と答え、そのうえで列に出す候補とJSONBに残す候補を割り振ります。頻度を尋ねさせる一文を入れておくのは、数字を推測で埋められないようにするためです。

GINの2つの演算子クラスの違い

JSONBに対するGINには、演算子クラスが2つあります。既定のjsonb_opsと、明示的に指定するjsonb_path_opsです。

観点jsonb_ops(既定)jsonb_path_ops
キー存在演算子(? ?| ?&)jsonb_ops(既定)使えるjsonb_path_ops使えない
包含(@>)、jsonpathの@? @@jsonb_ops(既定)使えるjsonb_path_ops使える
jsonpathの.* .**jsonb_ops(既定)使えるjsonb_path_ops使えない
索引の大きさjsonb_ops(既定)大きめjsonb_path_ops通常は小さい
検索の絞り込み精度jsonb_ops(既定)低めjsonb_path_ops高い。頻出するキーを含む条件で特に差が出る
値を持たない構造({"a": {}})の検索jsonb_ops(既定)値以外も索引に載るjsonb_path_ops索引項目が作られず、全索引走査になる

jsonb_opsは、キーと値のそれぞれに独立した索引項目を作ります。jsonb_path_opsは値ごとに項目を作り、その項目は値と、そこへ至るキーの経路をまとめたハッシュです。たとえば{"foo": {"bar": "baz"}}は、foo・bar・bazを1つにまとめた項目になります。包含の検索なら非常に特定的な探索になる一方で、fooというキーがあるかどうかを調べる手段はありません。

作る索引は、次のように書き分けます。

-- 既定の演算子クラス(jsonb_ops)
CREATE INDEX idxgin ON api USING GIN (jdoc);
 
-- jsonb_path_ops
CREATE INDEX idxginp ON api USING GIN (jdoc jsonb_path_ops);

選び方は、使う演算子で決まります。キー存在の?を使うなら既定のjsonb_opsしか選べません。包含(@>)だけで足りるなら、小さくて絞り込みが細かいjsonb_path_opsが候補になります。{"a": {}}のような「空の構造」を探すクエリが多い場合だけは、jsonb_path_opsは向きません。

式インデックスで、必要なキーだけを索引にする

文書全体にGINを張ると、すべてのキーと値のコピーが索引に入ります。検索したいのがtagsの中だけなら、式インデックスのほうが小さく、速くなりやすいとドキュメントは述べています。

ここに落とし穴があります。ドキュメントの例では、次のクエリはjdocへの単純なGINを使えません。

SELECT jdoc->'guid', jdoc->'name' FROM api WHERE jdoc -> 'tags' ? 'qui';

?は索引化できる演算子ですが、索引を張った列jdocにそのまま適用されていないからです。次の式インデックスなら、同じ条件を認識できます。

CREATE INDEX idxgintags ON api USING GIN ((jdoc -> 'tags'));

別の書き方として、包含でWHERE jdoc @> '{"tags": ["qui"]}'と書けば、単純なGINが使えます。柔軟さを取るなら列全体のGIN、サイズと速度を取るなら式インデックスという分かれ目です。

jsonpathの条件(@?と@@)も、GINで支えられます。索引はjsonpathの式からaccessors_chain == constantの形の節を取り出し、そこに出てくるキーと値で検索します。等値で比べる条件を含むjsonpathが、索引で絞れる対象です。クエリを@>から書き換える提案が出たときは、この形を保っているかを見ます。

CLAUDE.mdに置く約束と、権限の線引き

設計をClaude Codeに任せるときは、約束をCLAUDE.mdに書いておきます。CLAUDE.mdはコンテキストとして読まれるもので、強制される設定ではありません。ドキュメントは、検証できる具体さで書くこと、200行を目安に短く保つことを勧めています。

# JSONB と GIN
- 索引案を出すときは、対象クエリの演算子(`@>` / `?` / `->>`)を先に表にする
- `?` を使うクエリがあるなら jsonb_ops、`@>` のみなら jsonb_path_ops を第一候補にする
- 索引を提案したら、db/checks/jsonb_gin.sql の EXPLAIN 結果を貼って根拠にする
- 検証は開発用コピーだけで行う。接続先を変えない

強制したい部分は権限で切ります。権限ルールはモデルではなくClaude Codeが適用し、プロンプトやCLAUDE.mdの指示では許可は変わりません。検証用のSQLファイルだけを確認なしで流せるようにする設定は、次のとおりです。

{
  "permissions": {
    "allow": [
      "Bash(psql -f db/checks/*)"
    ]
  }
}

別の書き方をしたコマンドはこのルールに合致せず、通常どおり確認が出ます。ただし、接続先を守る本当の境界は、接続に使うロールの権限と、本番の接続情報を実行環境に置かないことです。この組み立てはスロークエリをコピー先で検証する記事が詳しく、同じ考え方をそのまま使えます。

EXPLAINで、索引が使われているかを確かめる

索引を作ったら、効いているかを計画で確かめます。db/checks/jsonb_gin.sqlを用意し、Claudeには「結果を貼って説明する」ところまでを求めます。

-- 開発用コピーでのみ実行する
SET enable_seqscan = off;
 
EXPLAIN (ANALYZE, BUFFERS)
SELECT jdoc->'guid' FROM api WHERE jdoc @> '{"company": "Magnafone"}';
 
EXPLAIN (ANALYZE, BUFFERS)
SELECT jdoc->'guid' FROM api WHERE jdoc -> 'tags' ? 'qui';
 
SELECT pg_size_pretty(pg_relation_size('idxgin'));

見るところは3つあります。

  1. 計画に索引名が出るか。ドキュメントの例では、索引を使うノードはBitmap Index Scan on <索引名>のように索引名が付いて現れます。Seq Scanだけなら、索引は使われていません。
  2. Disabled: trueが付いていないか。 enable_seqscan = offは順次走査を「抑える」だけで、完全に禁じるわけではありません。計画に無効化したノードが残ると、EXPLAINの出力にそれが示されます。
  3. サイズ。同じデータでjsonb_opsとjsonb_path_opsを作り、pg_relation_sizeで比べます。ドキュメントは後者が通常小さいと述べていますが、手元のデータでの差は測らないと分かりません。

計画の結果は、次のように読み分けます。

出力読み取れること次の一手
索引名つきのノードが出る読み取れることクエリの形は索引に合っている次の一手実データに近い件数で実行時間とBuffersを比べる
Seq Scanのまま読み取れること演算子が列に直接かかっていないか、演算子クラスが合っていない次の一手条件の書き方か索引の定義を見直す
Seq ScanにDisabled: true読み取れることほかに使える方法がなく、順次走査しか残らなかった次の一手索引が作られているか、対象列が合っているかを確かめる

enable_seqscan = offは「索引が使える形のクエリか」を確かめる手段です。小さなテーブルでは、オフにしないと順次走査が選ばれることが普通にあります。オフにして索引が出たなら形は合っており、あとは実データに近い件数で実行時間を比べる段階です。

EXPLAIN ANALYZEはクエリを実際に実行します。副作用のある更新を調べるときは、BEGINとROLLBACKで囲むとテーブルを変えずに済みます。計画の読み方そのものは、EXPLAIN ANALYZEの実行計画を読む記事にまとまっています。

更新が多い列で、GINを足す前に知っておくこと

GINの索引は、1行の挿入や更新で多くの索引項目への挿入が起こりやすく、更新が遅くなりがちです。この遅さをならす仕組みとして、新しい項目を一時的な未整列の保留リストに貯めておく「高速更新」があります。代わりに、検索時には通常の索引に加えて保留リストも走査するため、リストが大きくなると検索が目に見えて遅くなります。

応答時間のばらつきを避けたいときは、索引のfastupdateストレージパラメータで保留リストを無効にできます。書き込みが多いJSONB列にGINを足す提案を受けたら、検索の速さだけでなく、こうした更新側の代償を確かめたかを尋ねる価値があります。Claudeに頼む場合は、更新頻度の高い列かどうかを先に伝え、「更新側の影響も説明して」と添えます。

つまずきやすい点

  • ?を使ったのに索引が出ない。演算子が索引を張った列に直接かかっているかを見ます。jdoc -> 'tags' ? 'qui'は単純なGINに乗りません。式インデックスにするか、包含へ書き換えます。
  • jsonb_path_opsにしたら?が遅い。 jsonb_path_opsはキー存在演算子を支えません。クエリの演算子を一覧にしてから演算子クラスを選びます。
  • 索引が巨大になった。列全体のGINは、全キーと全値を載せます。検索するキーが決まっているなら式インデックスを検討します。
  • 更新で待たされる。大きな文書は1行のロックが長く、競合しやすくなります。待ちが実際に出ているなら、デッドロックの原因をログから調べる手順で、どの更新が重なっているかを確かめます。

まとめ

JSONB設計の勘所は、柔軟さが要る部分だけをJSONBに残し、検索と更新の実態から索引の形を決めることです。包含だけならjsonb_path_ops、キー存在も使うなら既定のGIN、検索するキーが決まっているなら式インデックス、という順に絞れます。

Claude Codeに任せるときは、クエリの一覧を人が先に確定させ、検証はSQLファイルにして計画と索引サイズを貼らせます。説得力のある提案より、開発用コピーで測ったEXPLAINの出力のほうが、採否の根拠になります。マネージドのPostgreSQLに接続して試すなら、Cloud SQL for PostgreSQLプラグインの記事が接続側の手順を扱っています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. postgresql.org/docs/current/datatype-json.html
  2. postgresql.org/docs/current/gin.html
  3. postgresql.org/docs/current/using-explain.html
  4. code.claude.com/docs/en/memory
  5. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn