【MySQL】IN句に上限はある?大量ID指定が遅い原因と一時テーブル・JOINでの対策

【MySQL】IN句に上限はある?大量ID指定が遅い原因と一時テーブル・JOINでの対策 SQL

MySQLで大量のIDをIN (...)に並べるとき、「IN句は何件まで指定できるのか」「Oracleのように1000件上限があるのか」と迷うことがあります。

結論からいうと、MySQLのIN句にはOracleのORA-01795のような明確な1000件固定の上限はありません。ただし、SQL文が長くなりすぎる、プレースホルダが多すぎる、実行計画が悪くなる、max_allowed_packetに当たる、という実務上の限界があります。

先に結論
MySQLのIN句は1000件で必ずエラーになるわけではありません。数百件程度ならそのままでもよいことがありますが、数千件以上のIDを毎回渡すなら、一時テーブルにIDを入れてJOINする方法を優先します。SQL文字列を連結して巨大なIN句を作るのは、性能面でも保守面でも避けたいです。
スポンサーリンク

結論の早見表

まずは件数だけでなく、実行頻度、対象列のインデックス、SQLの作り方を見て判断します。迷ったら次の基準で考えると大きく外しにくいです。

状況 判断 理由
数十件までの固定値 INでよい SQLが短く、読みやすい
数百件のIDをたまに検索 INでも可 ただしEXPLAINでインデックス利用を確認
数千件以上を頻繁に検索 一時テーブル + JOIN SQL長・最適化コスト・ログ肥大を避けやすい
CSV文字列を渡して検索 避ける FIND_IN_SETなどはインデックスが効きにくい
DB内でID一覧を作れる サブクエリまたはJOIN アプリへ戻して巨大INにしない

MySQLのIN句に1000件上限はあるのか

MySQLには、OracleのIN句にある「リストは1000式まで」という制限とは同じ意味の上限はありません。そのため、次のように1000件を超える値を指定しても、構文だけを理由に必ずエラーになるわけではありません。

mysql-in-over-1000-example.sql
SELECT *
FROM orders
WHERE order_id IN (1, 2, 3, /* ... */, 1000, 1001, 1002);

ただし、これは「何万件でもINに並べてよい」という意味ではありません。MySQL公式ドキュメントでは、IN()は値が集合の中に含まれるかを判定する比較演算子として説明されています。さらに、col IN(val1, ..., valN)のような多値比較は、オプティマイザ上は複数の等価範囲として扱われることがあります。値が増えるほど、SQLの解析、最適化、転送、実行計画のコストが問題になりやすくなります。

件数の目安 考え方 おすすめ
数件から数十件 通常はINで問題になりにくい そのままINでよい
数百件 インデックスがあれば通ることも多いが、実行計画は確認したい EXPLAINで確認
数千件以上 SQLが長くなり、最適化や転送コストが目立つ 一時テーブルやJOINを検討
数万件以上 巨大SQLになりやすく、保守も難しい IDリストをテーブル化する

Oracleの1000件上限については、OracleのIN句に1000件以上を指定する方法で整理しています。MySQLとは制限の性質が違うため、同じ回避策をそのまま当てはめないようにしましょう。

IN句の基本形

MySQLのIN句は、列の値が指定した値リストのどれかに一致するかを判定します。

mysql-in-basic.sql
SELECT
    order_id,
    customer_id,
    total_amount
FROM orders
WHERE customer_id IN (101, 205, 309);

この例では、customer_id101205309のいずれかに一致する注文を取得します。条件に複数の値を指定するSQL全般は、SQLで抽出条件に複数の値を設定する方法も参考になります。

大量IN句で問題になりやすいこと

MySQLで大量の値をIN句に並べたとき、問題は「件数の固定上限」ではなく、次のような実務上の制約として出ます。

問題 起きること 対策
SQL文が長くなる アプリからDBへ送るSQLが巨大になる max_allowed_packetや通信量を意識する
プレースホルダが増える IN (?, ?, ? ...)が長くなり管理しにくい 一時テーブル、JOIN、バルク投入を検討
最適化コストが増える コンパイルや実行計画作成に時間がかかることがある EXPLAINで確認
インデックスが効きにくい 型不一致や関数適用でフルスキャンになる 列型を合わせ、対象列にインデックスを作る
ログや監視が読みにくい 巨大SQLがログに残り、調査しづらい IDリストをテーブル化する
SQLインジェクションの危険 文字列連結で値を並べると危険 プレースホルダか一時テーブルを使う

特に、INの対象列にインデックスがない場合や、数値列に文字列を渡すなど型がずれている場合は、件数以前に遅くなりやすいです。インデックスが使われない原因は、インデックスが使われない原因でも整理しています。

型をそろえないと遅くなりやすい

大量INで見落としやすいのが、列の型と渡す値の型のズレです。たとえばorder_idが数値列なのに、アプリ側で文字列として値を渡していると、暗黙変換や実行計画の悪化につながることがあります。

type-mismatch-in-list.sql
-- 避けたい例: 数値列に文字列を大量指定している
SELECT *
FROM orders
WHERE order_id IN ('101', '205', '309');

-- よい例: 数値列には数値として渡す
SELECT *
FROM orders
WHERE order_id IN (101, 205, 309);

プレースホルダを使う場合も、アプリ側の型が不自然になっていないか確認します。インデックス列に関数をかける、文字列列と数値値を混ぜる、照合順序が合わない、といった条件も遅さの原因になります。

max_allowed_packetに注意する

MySQLでは、クライアントとサーバー間で送受信できるパケットサイズにmax_allowed_packetが関係します。巨大なIN句を文字列で作ると、SQL文そのものが大きくなり、この制限や通信コストが問題になることがあります。

check-max-allowed-packet.sql
SHOW VARIABLES LIKE 'max_allowed_packet';

値を大きくすれば通る場合もありますが、根本的には巨大SQLを送らない設計にするほうが安全です。大量IDを毎回渡すなら、一時テーブルに入れてJOINする構成を検討しましょう。

大量IDは一時テーブルに入れてJOINする

数千件以上のIDを条件にしたいなら、巨大なIN句を作るより、IDリストを一時テーブルへ入れてJOINする方法が扱いやすいです。

temporary-table-join.sql
CREATE TEMPORARY TABLE tmp_target_ids (
    id BIGINT PRIMARY KEY
);

INSERT INTO tmp_target_ids (id)
VALUES
    (101),
    (205),
    (309);

SELECT o.*
FROM orders AS o
JOIN tmp_target_ids AS t
    ON t.id = o.order_id;

一時テーブル側に主キーやインデックスを持たせられるため、大量IDでも実行計画を読みやすくなります。また、アプリ側のログに巨大なSQLを残さずに済みます。JOINの基本は、SQLのJOIN完全ガイドも参考になります。

FIND_IN_SETでCSV検索するのは避ける

大量IDを1つのCSV文字列として渡し、FIND_IN_SETで検索する方法を見かけることがあります。一見シンプルですが、列に対する通常のインデックスが効きにくく、値の扱いも曖昧になりやすいため、実務では避けたい書き方です。

avoid-find-in-set.sql
-- 避けたい例: CSV文字列でID一覧を渡す
SELECT *
FROM orders
WHERE FIND_IN_SET(order_id, '101,205,309');

CSV文字列として渡すくらいなら、IDを一時テーブルへ投入してJOINするほうが安全です。検索条件をデータとして扱えるため、件数が増えたときの見通しも良くなります。

サブクエリで元データから絞る

ID一覧が別テーブルや条件から作れるなら、アプリ側でIDを取り出してINに並べるより、SQL内でサブクエリにしたほうが自然です。

in-subquery.sql
SELECT o.*
FROM orders AS o
WHERE o.customer_id IN (
    SELECT c.customer_id
    FROM customers AS c
    WHERE c.rank = 'gold'
);

サブクエリで表現できる条件をわざわざアプリへ戻して巨大なINにする必要はありません。DB内で完結できる条件なら、サブクエリやJOINで書くほうが保守しやすいです。

アプリ側でプレースホルダを作るときの注意

アプリからIN句へ値を渡す場合、値を文字列連結で埋め込むのは避けます。MySQL公式ドキュメントでは、プリペアドステートメントはパースのオーバーヘッド削減やSQLインジェクション対策に役立つと説明されています。

placeholder-example.py
ids = [101, 205, 309]
placeholders = ','.join(['%s'] * len(ids))
sql = f"SELECT * FROM orders WHERE order_id IN ({placeholders})"
cursor.execute(sql, ids)

ただし、プレースホルダを使っていても、数千から数万件の値を毎回渡せばSQLは大きくなります。安全性と性能は別問題です。大量データなら、一時テーブルやバルク投入を使うほうが安定します。

分割実行は暫定対応として考える

アプリ側でIDを100件や500件ずつに分け、複数回SQLを実行する方法もあります。小規模なバッチや一時対応では使えますが、恒久対策としては注意が必要です。

split-in-list-python.py
def chunks(values, size):
    for i in range(0, len(values), size):
        yield values[i:i + size]

for part in chunks(ids, 500):
    placeholders = ','.join(['%s'] * len(part))
    sql = f"SELECT * FROM orders WHERE order_id IN ({placeholders})"
    cursor.execute(sql, part)

分割実行はDBへの往復回数が増えます。検索結果をアプリ側で結合する必要もあり、並び順や重複排除の扱いも複雑になります。継続的に必要な処理なら、一時テーブルや通常テーブルへIDを入れてJOINする設計を優先します。

EXPLAINで確認する

IN句が遅いときは、件数だけで判断せず、EXPLAINでインデックスが使われているかを確認します。

explain-in-clause.sql
EXPLAIN
SELECT *
FROM orders
WHERE order_id IN (101, 205, 309);
確認する列 見ること
type rangerefconstなどになっているか
key 期待したインデックスが使われているか
rows 読み取り見積もりが大きすぎないか
Extra 余計なファイルソートや一時テーブルが出ていないか

MySQL公式ドキュメントでは、col_name IN(val1, ..., valN)のような比較が等価範囲として扱われることが説明されています。対象列に適切なインデックスがあれば効率よく読めることがありますが、値が増えすぎれば最適化や実行計画のコストが無視できなくなります。

NOT INではNULLに注意する

INの上限とは別ですが、NOT INではNULLが混じると結果が想定と変わることがあります。サブクエリにNULLが含まれる可能性があるなら、NOT EXISTSLEFT JOIN ... IS NULLを検討します。

not-in-null-risk.sql
-- NULLが混じる可能性があるなら注意
SELECT *
FROM orders
WHERE customer_id NOT IN (
    SELECT customer_id
    FROM blacklist_customers
);

NOT INのNULL問題は、SQLのNOT IN完全ガイドで詳しく扱っています。

どの方法を選ぶべきか

状況 おすすめ
IDが数件から数十件 IN句をそのまま使う
IDが数百件で、頻度が低い IN句でもよいがEXPLAINで確認
IDが数千件以上 一時テーブルに入れてJOINする
ID一覧がDB内の条件から作れる サブクエリまたはJOINで書く
除外条件でNULLが混じる NOT EXISTSLEFT JOINを検討
ロックや更新も絡む 実行時間とロック保持時間も確認する

大量IDで更新や削除を行う場合は、SQLが遅いだけでなくロック時間も長くなります。ロック調査が必要な場合は、MySQLでロックを確認する方法もあわせて確認してください。

よくある質問

MySQLのIN句は1000件を超えるとエラーになりますか?

OracleのORA-01795のような1000件固定の上限はありません。ただし、SQL文の長さ、max_allowed_packet、プレースホルダ数、実行計画の悪化などで問題になることがあります。

IN句とORはどちらが速いですか?

同じ列への等価条件なら、INのほうが短く読みやすいです。MySQLのオプティマイザでは、INや複数のORが等価範囲として扱われることがあります。実際の速度はインデックス、件数、データ分布で変わるためEXPLAINで確認します。

大量のIDを分割してIN句を何回も実行すればよいですか?

一時的な回避としては使えますが、回数が多いとDBへの往復が増えます。恒久対応なら、一時テーブルへIDを入れてJOINするほうが安定しやすいです。

FIND_IN_SETでID一覧を検索してもよいですか?

少量の管理画面や一時調査なら使う場面はありますが、大量データや高頻度処理では避けるのが無難です。インデックスを活かしにくく、CSV文字列の整形ミスも起きやすいため、一時テーブルやJOINを使いましょう。

まとめ

MySQLのIN句には、Oracleのような1000件固定の上限はありません。しかし、大量IDをINに並べると、SQL長、転送量、プレースホルダ、最適化コスト、実行計画、ロック時間が問題になりやすくなります。

少数ならIN句で十分です。数千件以上を継続的に扱うなら、一時テーブルや通常テーブルにIDを入れてJOINする設計へ切り替えましょう。

参考

Comparison Functions and Operators – MySQL 8.4 Reference Manual

Range Optimization – MySQL 8.4 Reference Manual

Server System Variables – MySQL 8.4 Reference Manual

Prepared Statements – MySQL 8.4 Reference Manual