Fictional database incident

【創作】顧客184,392社の請求先メールアドレスを全部自分にした話

15時00分、私の受信トレイに、知らない会社の請求書が届きました。

15時00分03秒、もう1通届きました。

15時00分06秒、さらに1通。

最初は迷惑メールだと思いました。

5通目で「宛先は自分、会社名だけが毎回違う」ことに気づきました。

10通目で、自分がさっき実行したSQLを思い出しました。

この記事は、実在しないSaaS企業を舞台にした架空の事故対応記録です。会社、人物、時刻、件数、データ構造、被害はすべて創作で、特定の事故を再現したものではありません。PostgreSQL の挙動と対策は公式資料に基づいています。

例示するSQLを本番環境で試さないでください。

14時48分、1社だけ直したかった

舞台は、企業向けSaaSの運用チームです。

その日の問い合わせは穏やかでした。

請求先メールアドレスを変更したが、管理画面へ反映されない。今日の請求書を新しいアドレスで受け取りたい。

調べると、古い管理画面の不具合で更新に失敗していました。修正リリースは準備中。しかし、請求バッチは15時に始まります。

あと12分。

担当者は私。DBはPostgreSQL。対象の企業IDは 48291。変更後のアドレスは確認済みです。

管理画面の裏側を直すには間に合わない。しかし、本番DBなら1行です。

この「1行です」には、2つの意味がありました。

私は前者しか確認していませんでした。

14時54分、選択したところだけ実行

SQLエディタへ、次の文を書きました。

UPDATE organizations
SET billing_email = 'billing-debug@example.invalid',
    updated_at = now()
WHERE id = 48291;

本物のアドレスは出せないため、ここでは予約済みドメイン .invalid を使っています。

普段使っていたGUIクライアントには、便利な機能がありました。

テキストを選択して実行すると、選択範囲だけを送信する

長いSQLの一部を試すときには便利です。

私はマウスでSQLを選択し、実行ボタンを押しました。

選択されていたのは、ここまででした。

UPDATE organizations
SET billing_email = 'billing-debug@example.invalid',
    updated_at = now()

WHERE id = 48291 は、選択範囲の1行下で、静かに待っていました。

このGUIクライアントは、選択した文字列を1つのクエリとして送信します。PostgreSQLでは入力文字列の末尾もSQLコマンドを終端するため、単一文なら末尾のセミコロンがなくても実行できます。選択範囲の扱いはクライアント固有ですが、DBへ届いた構文は完全です。

PostgreSQLも空気を読みません。

UPDATE 184392

184,392行が更新されました。

クライアントの右下には、成功を示す緑色の表示が出ました。

Query executed successfully — 184,392 rows affected

数字は見ました。

見て、「成功したんだな」と思いました。

人間は緑色の表示を見ると、文章を読む能力が少し下がります。

15時00分、受信トレイが顧客名鑑になる

誤更新の約6分後、請求バッチが始まりました。

バッチは次の処理をします。

  1. 当日分の請求データを作る
  2. organizations.billing_email を読む
  3. PDFを生成する
  4. メール配信サービスへ投入する

すべての行の billing_email は、私のアドレスです。

バッチから見ると、何も異常はありません。

ただ、宛先だけが、全部私です。

件名の会社名だけが、東京、大阪、福岡と入れ替わっていきます。

受信トレイが全国の会社を巡り始めました。メールだけで全国ツアーを開催している場合ではありません。

15時00分27秒、Ctrl+Zが効かない

10通目で、14時54分のSQLを思い出しました。

エディタを見ると、WHERE id = 48291 だけが選択範囲の外に残っていました。

反射的に Ctrl+Z を押しました。

SQLエディタの文字が元に戻りました。

DBのデータは戻りませんでした。

このGUI接続ではautocommitが有効で、クライアントが自動的に BEGIN する設定でもありませんでした。送信した1文は暗黙のトランザクションとして実行され、正常終了時にコミットされます。

エディタのUndo履歴と、データベースのトランザクションログは知り合いではありません。

次に ROLLBACK; を実行しました。

WARNING: there is no transaction in progress
ROLLBACK

DBから、とても丁寧に「もう帰りましたよ」と言われました。

15時02分、止める順番

まず、新規ジョブの生成とキュー投入を止め、送信ワーカーも安全に一時停止しました。配信サービスには、保留中メッセージの取消・抑止を依頼しました。

強制停止は「DBでは未送信、配信サービスでは受付済み」のような不整合を作り得ます。しかし、追加の誤配信を許容する理由にはなりません。キューのジョブID、請求書ID、配信サービスのメッセージIDを保全し、受付済み・配信済み・保留・取消済みを照合しました。

次に、請求先メールアドレスを変更する管理画面を一時的に閉じました。

事故のあとにも正常な更新が入ると、復旧時に「事故で壊れた値」と「ユーザーが正しく変えた値」が混ざるからです。

配信サービスでは、同じ宛先へ短時間に大量送信するレート制御が働き、612件を受け付けたところで残りが保留されていました。受付と実際の配信は別の状態なので、612件をすべて「送信済み」とは数えませんでした。

612通は少なくありません。

社内の1人へ届いたとしても、異なる顧客の請求情報が本来と違う宛先へ送られています。主として請求情報の機密性を損なった情報セキュリティ事故であり、送信停止による業務影響も伴うものとして扱いました。セキュリティ、法務、プライバシー担当へ連絡し、含まれる情報、実際の配信状態、閲覧可能だった範囲を調べたうえで通知要否を判断しました。

私の受信トレイは、復旧資料ではありません。閲覧権限を制限し、メール、配信ログ、監査ログ、キュー状態の来歴を保ったまま必要な証拠を保全しました。保持期間の判断と承認を経たあと、不要な複製を手順に従って削除しました。

レプリカも元気に壊れていた

「読み取りレプリカなら事故前の値が残っているのでは」と期待しました。

残っていませんでした。

この環境の読み取りレプリカは、通常の物理ストリーミングレプリケーションでした。誤更新を含むWALも、スタンバイがその位置まで再生すれば反映されます。

プライマリで184,392行を間違えれば、レプリカでも184,392行が間違います。

構成によっては、再生を止めたレプリカや意図的な遅延スタンバイに事故前の状態が一時的に残ることもあります。論理レプリケーションもpublicationの対象次第です。「レプリカなら残る」とも「すべてのレプリカが必ず壊れる」とも一般化はできません。

バックアップはありました。WALアーカイブもあり、Point-in-Time Recovery(PITR)ができます。

では、14時53分へDBを丸ごと戻せば解決でしょうか。

解決しません。

事故のあとにも、正常な注文、契約変更、入金記録が作られています。DB全体を過去へ戻せば、請求先は直っても、それらが消えます。

タイムマシンは便利ですが、乗客全員を過去へ連れていきます。

戻したいのは、1テーブルの1列だけでした。

復旧:事故前のDBを隣に作る

復旧方針は、次のようにしました。

  1. バックアップとWALから、事故の直前を指す一時DBクラスタを別環境へ復元
  2. 一時DBから id と事故前の billing_email を抽出
  3. 本番DBの隔離した作業用テーブルへ取り込む
  4. 事故のUPDATE以後に正しく変更された行を除外
  5. 件数とサンプルをレビューし、明示的なトランザクションで更新

一時DBには本番のデータと認証情報が含まれます。ネットワークを隔離し、外部送信を無効化し、読み取り専用の権限と限定したアクセスで扱いました。抽出データには暗号化、アクセス記録、廃棄記録を適用しました。

復旧時点はGUIに見えた時計だけで決めません。誤更新のトランザクションとWAL位置をログから特定し、事故トランザクションを含めない時点または検証済みのLSNへ復元しました。復元後に、誤更新がなく、その直前の正常更新まで含まれることを照合しました。

この事故では、誤更新した文が全行へ同じ updated_at = now() を設定していました。PostgreSQLの now()transaction_timestamp() と同じくトランザクション開始時刻を返すため、この1文の暗黙トランザクションでは全行に同じ値が残ります。

ただし、この時刻は事故対象の候補を絞るマーカーにすぎません。同時刻の別更新、トリガー、時刻精度などを考慮し、事故値、監査ログ、実行ログ、PITR側との差分を組み合わせて対象IDを確定しました。事故後に正しく更新された行は機械的に上書きせず、個別に扱いました。

一時DBから取り込む recovery_billing_email には id の一意制約を設けました。UPDATE ... FROM で1行へ複数の復旧候補が結合する状態を許さないためです。

復旧SQLの考え方は、次のようなものです。

BEGIN;

UPDATE organizations AS current
SET billing_email = before_incident.billing_email,
    updated_at = now()
FROM recovery_billing_email AS before_incident
WHERE current.id = before_incident.id
  AND current.billing_email = 'billing-debug@example.invalid'
  AND current.updated_at = TIMESTAMPTZ '2025-12-01 14:54:37+09';

-- 件数、差分、代表サンプルを別セッションではなく
-- このトランザクション内で確認する

ROLLBACK;

これは考え方を示す架空の例であり、汎用の復旧手順ではありません。実際には分離レベル、トリガー、外部キー、監査要件、並行更新、タイムゾーン、復元元の整合性、updated_at を利用するキャッシュや差分連携への影響などを確認する必要があります。

事前レビューでは差分と対象IDを確定しました。本番実行時は、人間のレビュー待ちで長時間ロックを保持せず、短いトランザクション内で事故値と事故時刻を再検証しました。条件から外れた行は更新せず、監査ログと照合しました。修復自体は新しい更新として updated_at と監査ログへ残しました。

「さっき全件更新した人」が、復旧SQLも単独で本番実行する構成にはしませんでした。

信頼の問題ではありません。

脳のキャッシュを一度消すためです。

復旧後に待っていたもの

DBを直せば終了ではありませんでした。

データ事故の復旧は、DBの値が元へ戻った瞬間には終わりません。

間違った値を読んだすべてのシステムが、その値から何を作ったかを追う必要があります。

DBの1列は、メール、PDF、キュー、ログ、キャッシュ、分析テーブルへ枝分かれします。

全件UPDATEは1文でした。

後片付けは分散システムでした。

惨劇はなぜ起こったのか

直接原因

SQLクライアントで WHERE 句を含まない範囲だけを選択し、本番DBへ送信しました。

しかし、タイプミスでは終わらない

  1. 本番が既定で書き込み可能だった 調査用の接続でも、同じ資格情報からUPDATEできました。

  2. 「1行だけ」がコメントにしか存在しなかった DBにも実行ツールにも、期待更新件数が1であることを伝えていませんでした。

  3. autocommitのまま緊急変更した 更新件数を見てからコミットする停止点がありませんでした。

  4. 選択範囲実行を安全だと思っていた 選択は入力支援であって、影響範囲を制限する機能ではありません。

  5. 締切がレビューを省く理由になった 「あと12分」が、管理画面を止める、請求バッチを遅らせる、別担当者へ確認する、といった安全な選択肢を見えなくしました。

  6. メール側に不変条件がなかった 2,000社以上の請求書が同一アドレスへ集中しても、送信前検査は通りました。

事故は、WHERE 句が1行足りなかったから起きたのではありません。

止まる仕組みが6個足りませんでした。

二度と惨劇を起こさないために変えたこと

1. 本番接続を読み取り専用から始める

日常の調査は読み取り専用ロールで行い、書き込みは時間制限つきのbreak-glass手続きへ分離しました。

接続先名、プロンプト色、ウィンドウ背景も変えました。ただし、色は補助です。最後の防御はDB権限に置きました。

2. 期待更新件数を実行可能にする

「1件のはず」を人間の記憶ではなく、処理へ入れます。

たとえば、専用の変更ツールでは次のように、実更新件数が期待値と違えば例外にしてトランザクション全体を失敗させます。

DO $$
DECLARE
  affected_rows bigint;
BEGIN
  UPDATE organizations
  SET billing_email = 'billing-debug@example.invalid',
      updated_at = now()
  WHERE id = 48291;

  GET DIAGNOSTICS affected_rows = ROW_COUNT;

  IF affected_rows <> 1 THEN
    RAISE EXCEPTION
      'Expected 1 affected row, got %', affected_rows;
  END IF;
END
$$;

WHERE 句の有無だけを検査しても、WHERE true や間違った条件は防げません。大事なのは、期待する結果を検査することです。

件数検査は、想定外の変更をコミットさせない最後の安全網です。大量UPDATE自体のロック、WAL、負荷や、DBトランザクション外の副作用までは防げません。「間違った1行」でも件数は1なので、対象IDと変更前後の値を事前に確認する仕組みと組み合わせます。

3. SELECTとUPDATEを同じ材料から作る

事前確認のSELECTと、本番のUPDATEで条件を手入力し直すと、2つは簡単にずれます。

対象IDをレビュー可能な変更ファイルへ固定し、そこからプレビューと更新を生成するようにしました。実行前には対象件数だけでなく、IDと変更前後の値を表示します。

4. 管理画面の障害をDB直書きで埋めない

期限つき変更のための監査可能な管理コマンドを用意しました。入力検証、認可、冪等性、監査ログ、期待件数チェックをアプリケーション側で共有します。

「たまにしか使わないから手作業」は、安全機構が最も育たない手順を、最も不慣れな状態で実行するという意味でした。

5. 送信前に受信者の偏りを見る

請求バッチに、次の停止条件を追加しました。

メールアドレスは形式が正しいだけでは不十分です。データセット全体として不自然でないかを見る必要があります。

6. 「1列だけ戻す」訓練をする

PITRで別クラスタを起動できること、必要な時点を特定できること、特定テーブル・列を安全に比較できることを定期的に試します。

「バックアップがあります」は、復旧方法の説明ではありません。

どのデータを、どの時点へ、何を失わず、どれくらいの証跡を残して戻せるか。そこまで答えられて初めて、事故対応に使えるバックアップになります。

おわりに

SQLの WHERE は、更新対象を絞るための句です。

事故のあとでは、「私たちはどこで止まるはずだったのか」を問う言葉にも見えました。

実行前の権限で止まれた。

トランザクションで止まれた。

更新件数で止まれた。

レビューで止まれた。

送信前の異常検知で止まれた。

それらを全部通り抜け、最後に私の受信トレイが止めました。

受信トレイは、インシデント検知システムとしては距離が近すぎます。

人間は WHERE 句を選び損ねます。

システムまで一緒に選び損ねる必要はありません。

参考資料