SQL とクエリの話

LINE CRM で「20,000人に送ると壊れる」理由を、データベースの基礎から理解する / 2026-04-24 作成

1. そもそも SQL って何?

データベース (= データの倉庫) に話しかける専用言語 のことです。

このプロジェクト (cherimi LINE CRM) で使っている Cloudflare D1 というサービスは、小さな SQLite データベースが Cloudflare のクラウドで動いている状態。そこに以下のような情報が棚に並んで保管されています:

このデータに対して「取り出して」「追加して」「書き換えて」「消して」といった依頼をするための言葉が SQL です。

たとえ話:
データベース = 巨大な倉庫
テーブル = 倉庫の中の棚 (ユーザー棚、配信履歴棚、タグ棚…)
SQL = 倉庫番に出す依頼書の書式
クエリ = 実際に書いた1枚の依頼書

2. クエリの基本形

よくある依頼書の例:

SELECT display_name FROM users WHERE id = 6;

これを日本語に訳すと:

「ユーザー棚 (users) から、id が 6 番の人の display_name (表示名) を取ってきて」

倉庫番 (= D1) はこれを読んで棚を探しに行き、木村敏之 という答えを返してきます。

よく使う動詞

SQL の動詞意味使う場面
SELECT取ってくる「VIP タグの人一覧を見たい」
INSERT新しく入れる「新しい友だちを user 棚に追加」
UPDATE書き換える「〇〇さんのブロック状態を解除済みに更新」
DELETE消す「テスト用の偽ユーザーを削除」

3. 「?」 (プレースホルダー) とバインド

実際のコードでは、クエリに具体的な値を直接書かず、穴埋め形式で書きます。

SELECT display_name FROM users WHERE id = ?;

この ? に後から 6 という値を埋め込む。これを 「バインド (bind)」 と言います。

たとえ話:
穴埋め形式の依頼書を事前に印刷しておいて、空欄に鉛筆で数字を書いてから倉庫番に渡す。
毎回ゼロから書くより速いし、悪意ある人が依頼内容を書き換える攻撃 (SQL インジェクション) も防げる。

複数の値を渡すとき: IN 句

「複数のIDの人をまとめて取りたい」場合はこう書きます:

SELECT id, line_user_id FROM users WHERE id IN (?, ?, ?);

3つの ? に、たとえば 6, 7, 8 をバインドすると、「id が 6 か 7 か 8 のどれかの人を取ってきて」という依頼になります。

4. 今回の LINE CRM で起きた問題

問題: 配信対象の LINE ユーザー全員を、一回のクエリで取ってくるコードになっていた。

実際のコード (worker/src/handlers/api/messages.ts):

const placeholders = targetUserIds.map(() => '?').join(',');
const lineUsers = await env.DB
  .prepare(
    `SELECT id, line_user_id FROM users WHERE id IN (${placeholders})`
  )
  .bind(...targetUserIds)
  .all();

これは配信対象の ID が N 個あれば、? を N 個並べるという意味です。

具体例: 配信対象が 3 人のとき

SELECT id, line_user_id FROM users WHERE id IN (?, ?, ?);
   ↓ バインド
SELECT id, line_user_id FROM users WHERE id IN (6, 1, 2);

問題なし ? が 3 個なので D1 の倉庫番は余裕で処理できる。実際、QA テストでは 1人〜3人での検証だったので、ここはずっと通っていた。

具体例: 配信対象が 20,000 人のとき

SELECT id, line_user_id FROM users WHERE id IN (?, ?, ?, ?, ?, ...もっと続く... ?, ?, ?);
   ↓ ? が 20,000 個並ぶ

破綻 D1 (SQLite ベース) の倉庫番は 「一度に受け取れる ? の数は約 100 個まで」 という制限を持っています。これを超えると too many SQL variables 的なエラーで突き返されます。

なぜ QA で気付けなかったか:
QA テストでは友だち 1〜3 人に対してしか送っていないので、? の個数は最大 3 個。制限に引っかからない。本番で 20,000 人に一斉送信しようとした瞬間に初めて破綻する、という潜在バグ。

5. Codex レビューが指摘してきた他の理由

SQL の問題だけでなく、20k 配信にはもう 2 つの落とし穴があります:

理由2: Worker の実行時間制限

1人に送るのに 20 ミリ秒の間隔を空けて送信すると:

20,000 人 × 20ms = 400,000ms = 400 秒 = 約 6分40秒

Cloudflare Workers には「1回の処理は◯秒以内に終わらせる」という上限があり、長時間かかる処理は途中で強制終了される可能性がある。

理由3: 途中で止まった時に追跡不能

仮に 7,500 人まで送った所で Worker が強制終了したとする。データベースには「7,500 人分は送った」という情報が部分的に残る。

これは SQL の問題ではなく「長時間処理の再開設計」の問題。配信履歴を「送信済み/送信中/失敗」でちゃんと記録し、どこから再開できるようにする必要がある。

6. 解決策 (= 今からやる修正)

チャンク化: 依頼を小分けにして、何回かに分けて倉庫番に渡す。

20,000 人分の ID を一度に WHERE id IN (?,?,?,...) で渡すのではなく、80 人ずつに区切って 250 回投げる:

// 修正前 (1回で全部)
WHERE id IN (?,?,?,...20,000個...)

// 修正後 (80個ずつ 250 回)
WHERE id IN (?,?,?,...80個...)   ← 1回目
WHERE id IN (?,?,?,...80個...)   ← 2回目
WHERE id IN (?,?,?,...80個...)   ← 3回目
...
WHERE id IN (?,?,?,...80個...)   ← 250回目

それぞれ倉庫番の制限内に収まるので確実に通る。結果を合算して返すようコードを書き換えればOK。

他にやる修正 (配信の安全性向上)

  1. 配信ステータスを正直に記録: 全員失敗でも「成功」と記録されてしまう仕様を「全成功/一部失敗/全失敗」に分ける
  2. 受信者ごとの送信状態: 誰に届いて、誰に届かなかったか、を個別に記録して、失敗した人だけ再送できるようにする

7. 一言でまとめ

SQL は倉庫番への依頼書の書式。
クエリ は具体的に書いた 1 枚の依頼書。
? は依頼書の穴埋め欄で、そこに値を入れる (=バインドする)。

問題は「穴埋め欄を 20,000 個並べた依頼書は倉庫番が受け取れない」ということ。
修正は「依頼書を 80 個ずつに分けて 250 回投げる」。