TL;DR
PostgreSQLで配列カラムに ANY() を使うクエリは、B-treeでは加速できずseq scanになります。GIN indexを貼り、@> 演算子で書けばBitmap Index Scanへ切り替わり、桁違いに速くなります。AIが量産したSQLでも「動くコード」のままマージされやすい現状に対して、レビューで実行計画まで取る運用と、AIに渡したい情報をまとめました。
背景
2026年、SQLはAIに書かせるのが普通になりました。CIも通ります。テストも通ります。レビュアーも「読めるし意図と合ってる」でapproveします。文法面で詰まることはほとんどありません。
しかし本番に出してしばらく経つと、APIがじわじわ重くなり始めます。あるAPIのP95が突然数秒に張り付きます。DBのCPUは高止まりし、接続プールが枯渇します。関連サービスにまでカスケード障害が広がり、夜間のオンコールも鳴り出します。いわゆる「本番が燃える」状況です。EXPLAINを取ってみると Seq Scan ばかり並んでおり、配列カラムへの ANY() のような「動くけれども実行計画は悪い」クエリが混ざっています。
AIは文法と意図の整合性を守ります。ただし実行計画までは見ません。レビュアーも、SQLレビューで EXPLAIN まで取って既存indexと突き合わせるところまではあまりやらないものです。配列カラムへの ANY() は、その典型例にあたります。
題材:配列カラムへの ANY()
たとえばこういうテーブルがあるとします。
CREATE TABLE mails (
id SERIAL PRIMARY KEY,
subject VARCHAR,
cc VARCHAR[] -- 宛先メールアドレスの配列
);
INSERT INTO mails (subject, cc) VALUES
('hello', ARRAY['alice@example.com', 'bob@example.com']),
('howdy', ARRAY['alice@example.com']),
('hi', ARRAY['carol@example.com']);
「cc(宛先)に alice@example.com が含まれるメールを取りたい」とAIに書かせると、こういうクエリが返ってきます。
SELECT m.id, m.subject
FROM mails m
WHERE 'alice@example.com' = ANY(m.cc);
期待する結果はこうです。
id | subject
----+---------
1 | hello
2 | howdy
ANY(配列) は、左辺の値が配列のどれかの要素と一致するかを判定する演算子です。'alice@example.com' = ANY(m.cc) なら「m.cc のどれかが alice@example.com と等しい」という意味になります。要は m.cc の各要素について 要素 = 'alice@example.com' を OR でつないで評価しているのと同じです。
mails.cc は varchar[] で、宛先メールアドレスがN個入っている配列カラムです。それを ANY() で走査して、対象アドレスを含む行を絞り込んでいます。読めばわかりますし、人間が書いてもだいたい同じ形になります。動きますし、テストも通ります。
ところがこのクエリ、本番で1本につき5秒ほど持っていかれることがあります。急に重くなったAPIのEXPLAINを取ったら、まさにこの形のクエリが犯人だった、というのは典型パターンです。
理由はふたつあります。ひとつは、varchar[] のような配列カラムにはB-treeインデックスが効かないこと。もうひとつは、= ANY(配列) という書き方自体が、あとで見るとおり適切なindex(GIN)を貼っても使ってくれないことです。どちらにせよ ANY() のままではseq scanになり、テーブルの規模が大きくなり配列も長くなれば、線形以上に遅くなっていきます。
そしてAIは、こういうクエリをほぼ確実に「動くコード」として返してきます。AIが見ているのは文法と意図の整合性で、実行計画は見ていません。データ量と既存indexの情報を渡されない限り「このクエリは将来遅くなる」と判断する材料がありません。レビュアーがSQLのレビューでここまで気を回すケースも多くはありません。
つまりAIコーディングを前提とする2026年の開発フローだと、配列カラムへの ANY() は静かにマージされやすく、データが増えてから問題が表面化します。
なぜテストもレビューも通るのか
開発環境のシードデータは、たいてい数十行から数百行しかありません。配列カラムも1〜2要素入っている程度です。この規模では ANY() のseq scanも一瞬で終わります。
CIで走る統合テストも同様です。テストが速いことを優先するため、データ量はあえて少なくしています。そのため「クエリが遅い」という症状を、テストで検出するのは原理的に難しいです。
人間のレビューでも、同じクエリが何百本も散らばっていれば、これが配列カラムへの ANY() だと気づくのはなかなか難しいです。気づくのは、本番でAPIが遅くなってEXPLAINを取ったときになります。
AIレビューも同じ理由でスルーします。AIレビュアーは静的にコードを読んでいて、本番のデータ量や既存のindex構成は見えていません。ANY() の文法は正しく、配列の使い方も意図と整合しています。結果として「動くコード」のまま通ってしまいます。
配列カラムには B-tree が効かない
PostgreSQLのデフォルトindexはB-treeで、これはスカラー値の順序で並べる構造になっています。配列カラムにもB-treeは貼れますが、それは配列同士の大小(辞書順)を並べるだけで、「['a', 'b'] に 'a' が含まれるか」のような中身の検索には使えません。そのため配列の要素に対する検索(ANY() や @> や &&)は、B-treeでは加速できません。
実行計画を見るとはっきり出ます。
Seq Scan on mails m (cost=0.00..XXXX rows=YYY width=...)
Filter: ('alice@example.com' = ANY(cc))
-- mails テーブル全件を舐めて、行ごとに cc 配列の中身と対象アドレスを突き合わせる
テーブル行数 × 配列平均長のオーダーで時間がかかります。1万行で配列長5なら5万回の比較、10万行になれば50万回。さらにこのクエリが他テーブルとのJOINに組み込まれると、組み合わせの分だけ体感は線形を超えていきます。
GIN index で救える
配列カラムに対してはGIN(Generalized Inverted Index)が効きます。GINは配列の各要素を逆引きするindexで、@>(含む)や &&(共通要素を持つ)のような検索が桁で速くなります。
CREATE INDEX idx_mails_cc_gin ON mails USING GIN (cc);
ただし、indexを貼るだけでは足りません。クエリが = ANY(cc) の形のままだと、GINがあってもPostgreSQLはそれを使わず、seq scanのままです(少なくとも現行のPostgreSQLでは、= ANY(配列) はindexに化けません)。
Seq Scan on mails m (cost=0.00..XXXX rows=YYY width=...)
Filter: ('alice@example.com' = ANY(cc))
-- GIN を貼っても、ANY() のままだと index は使われない
GINを効かせるには、@>(含むか)演算子に書き換えます。'alice@example.com' = ANY(cc) と cc @> ARRAY['alice@example.com'] は(ccにNULL要素が無ければ)結果は同じですが、GINを使えるのは後者だけです。
SELECT m.id, m.subject
FROM mails m
WHERE cc @> ARRAY['alice@example.com']::varchar[];
こう書くと実行計画が切り替わります。
Bitmap Heap Scan on mails m
Recheck Cond: (cc @> '{alice@example.com}'::varchar[])
-> Bitmap Index Scan on idx_mails_cc_gin
Index Cond: (cc @> '{alice@example.com}'::varchar[])
-- index で該当ブロックだけ拾ってから実データを読みにいく
Seq ScanがBitmap Index Scanへ変わっています。Recheckの段階でだけ実データを読むため、舐める行数が劇的に減ります。
配列カラムでGINを効かせたいなら ANY() ではなく @> で書く。これを徹底するのが安全です。
本番投入の作法
GIN indexを本番テーブルに貼るときは、必ず CREATE INDEX CONCURRENTLY を使います。通常の CREATE INDEX はSHAREロックを取るため、SELECTは通りますが、INSERTやUPDATEは全部止まります。テーブルが大きいほど時間がかかり、その間ずっと書き込みが詰まります。
CREATE INDEX CONCURRENTLY idx_mails_cc_gin ON mails USING GIN (cc);
CONCURRENTLY を付けると、SHAREではなくSHARE UPDATE EXCLUSIVEロックで動くため、INSERT/UPDATE/DELETEを止めずにindexを作れます。代わりに癖もあります。
- テーブルを2回スキャンするため、通常より時間とCPUを使う。
- トランザクション内では実行できない。
- 途中で失敗するとinvalid状態のindexが残るため、手動で
DROP INDEXする必要がある。
詳細はPostgreSQL公式ドキュメントを参照してください。本番ではこれ一択でよいと判断しています。
進捗は pg_stat_progress_create_index で見られます。長時間かかるindex作成では、これを定期的に覗いて進捗と所要時間の見積もりを取るのが安全です。
AI に SQL を書かせるなら、何を渡すか
AIが遅いクエリを返してくる主因は、AI側の能力ではなく、こちらが渡している情報の不足にあると考えています。動くか動かないかしか判断材料がなければ、AIも「動くコード」を返すしかありません。
実務で渡したいのは、最低限この3つです。
-
対象テーブルの想定行数と配列カラムの平均要素数 ——「
mailsは100万行、ccは平均3要素」と書いておけば、AIはANY()のseq scanが遅いことに触れる確率が上がる。 -
既存のindex一覧 ——「
mailsに貼ってあるのは主キーとsubjectのB-treeのみ」と渡しておけば、不足しているindexにも気づく。 - 許容レイテンシ ——「このAPIはP95で200ms以下」のような目標値があれば、AIは最適化を前提にしたクエリを返してくる。
逆に言えば、この3つを渡さない限り、AIが出してくるクエリは「文法的に正しい最短のもの」にしかなりません。
まとめ
AIコーディングが普通の前提となった2026年、コードレビューで見るべきは「文法」「意図」「テスト」の3つだけでは足りないと感じるようになりました。SQLに関しては、4つ目として「実行計画」を入れる必要があると考えています。
人間が書いたかAIが書いたかに関係なく、ANY(varchar[]) を見たら @> への書き換えを検討し、必要ならEXPLAINで確かめる。これだけ徹底すれば、配列カラムが原因で本番が燃える事故はかなり減らせるはずです。
ではどう検知するか。今回の = ANY(配列カラム) は、書き方そのものがアンチパターンです。データ量と関係なく常にseq scanになるので、まず静的なlintで弾くのが確実です(「= ANY(<配列カラム>) を見たら @> を要求する」ルール)。EXPLAINや本番データは不要です。
やっかいなのは、書き方は素直なのにデータが増えてから計画が退行するタイプです。これは静的には拾えず、つい「CIで pg_stat_statements を眺める」に頼りたくなりますが、それは効きません。速度優先の統合テストは小データで、実行時間がそもそも出ません。小テーブルでseq scanが選ばれるのは 正しい ので、計画を見ても判定できません。検知したいなら速いCIとは別建ての仕組み —— 本番相当の統計を注入して EXPLAIN の計画の形を検査する、本番相当データのperf環境を別レーンで回す、本番側で auto_explain を仕込む —— が要ります。
AIが量産する時代だからこそ、静的lintと、速いテストから切り離した実行計画チェックの二段構えで、検知を仕組みに落としていきたいと考えています。