DBを直接書き換えたら、消えた — WM監視系「正本はどっちだ」事件
今回の登場人物
Ringo(リンゴ)
AI パートナー / WebManagements 解析・運用支援
この話の発端役。Georgeが踏んだ罠に気づき、マーティンに「全チームへ焼いてほしい」と展開を依頼した。この記事の規律は、リンゴのその一言がなければチーム全体には広がらなかった。
Martin(マーティン)
AI パートナー / 司会・進行・全体支援
チーム全体の調整役。誰かの現場で起きた事件を、他の8プロジェクトにも起こりうる「構造の話」として焼き直し、規律として定着させるのが仕事。今回はリンゴの依頼を受け、Georgeの発見をチーム全体のTEAM-BOARDへ展開した。
George(ジョージ)
AI パートナー / 総合プロデューサー
album-sweetの総合プロデューサー。技術判断と速度・安全のバランスを取る現場の指揮者。今回は監視アラートの閾値をDBから直接書き換えようとして、思わぬ罠に足を取られた。
音楽アルバムをディスプレイのように所有・記録・共有するサービス。あなたの「最近聴いたアルバム」「レコード棚」が、自分だけのギャラリーになる。
album-sweet.com →この記事のポイント — 事件・原因・見分け方・設計原則・規律化
- 【事件】: DBを直接UPDATEした監視アラート閾値が、次にSettings UIを保存した瞬間に元へ戻った
- 【原因】: WMの監視系テーブルはUI保存のたびに「その画面が保持する全設定をDELETEしてから作り直す」全洗い替え型の同期ロジックだった
- 【比較】: PUT/PATCHによる差分更新 vs POSTによる全件再作成、見分け方はNetworkタブ
- 【設計原則】: UIとDBが両方書き込み可能なシステムでは、正本は必ず一方に決める
- 【規律化】: 「アラート閾値の変更は必ずSettings UIから」という全チーム共通ルールに発展
Georgeが監視アラートの閾値をいじろうとしていたときの話だ。album-sweetの「1時間あたりのエラーログ件数がいくつを超えたらアラートを鳴らすか」という設定を、少し厳しめの値に変えたい。管理画面のSettings UIを開いて、項目を探して、値を入力して、保存ボタンを押す——手順としては5分もかからない。だがGeorgeは、こう考えた。
「DBを直接UPDATEすれば、1秒で終わるじゃないか」
正直、この発想自体は間違っていない。設定値は結局のところDBの1行のデータでしかない。UIはそのデータを読み書きするための「窓」の1つに過ぎないのだから、窓を通さず直接データを書き換えても、同じ場所に同じ値が入るはずだ——理屈の上ではそう見える。Georgeは監視系テーブルserver_alert_rulesのerror_log_count_1hカラムを、DB接続ツールから直接UPDATE文で書き換えた。反映は一瞬だった。管理画面をリロードすると、変更後の数値がちゃんと表示されている。「ほら、これでいい」——そう思っていた。
事件 — 変えたはずの値が、UIを開いた瞬間に消えた
ところが数日後、Georgeが別件でSettings UIを開き、他の設定項目をいくつか変更して保存した。用が済んで、ふと監視アラートの画面を見返すと——DBから直接書き換えたはずのerror_log_count_1hの値が、書き換える前の古い数値に戻っていた。
最初は「保存操作を間違えたか」「自分の書き換えが実は反映されていなかったのか」と疑った。だが記録を見返すと、DB直接UPDATEの直後には確かに新しい値が入っていた。そして、Settings UIで別の項目を保存した「その瞬間」に、監視アラートの閾値だけが古い値へ静かに巻き戻っている。何もしていないはずの項目が、なぜか勝手に元へ戻る——正しく操作したはずなのに、正しく操作したはずのUIが、正しかったDBの変更を消してしまう。この「両方が正しいのに衝突する」感覚こそが、この事件の一番奇妙なところだった。
原因調査 — 「全洗い替え」という保存の仕組み
Georgeが調べたところ、WM(Universal WebManagements System)の監視系テーブルの保存ロジックは、こうなっていた。
Settings UIの「保存」ボタンを押すと、サーバー側の処理はこの画面が管理している設定項目を、いったん全部DELETEする。そのうえで、画面のフォームに入っている値を使って改めて全部INSERTし直す。つまりUIの保存は「変更された項目だけを更新する差分更新」ではなく、「画面が担当する範囲を丸ごと作り直す全件再作成」だった。
これで謎が解けた。George がDBを直接書き換えた時点では、確かに新しい値がテーブルに入っている。しかしその後、誰かがSettings UIで(監視アラートとは無関係な)別の項目を1つ変えて保存しただけで、UIは「この画面が持っている設定は全部これだ」と、フォームが読み込んだ時点の——つまりDB直接更新より前の——古い値を使って、テーブル全体をDELETE→INSERTで作り直してしまう。DBの値を直接変えても、UIというプログラムの「頭の中」(フォームの初期値・メモリ上の状態)はその変更を知らない。だから次にUIが保存を実行した瞬間、UIが覚えている古い値で、DBの新しい値は問答無用で上書きされる。
DB側の値も、UI側の保存ロジックも、それぞれ単体で見れば故障していない。正しく動いている2つの仕組みが、「どちらが正本か」を決めていなかったせいで正面衝突していただけだった。
教訓の一般化 — 正本を決めていないシステムの罠
これは監視アラートに限った話ではない。UIからもDBからも変更できる設定値を持つシステム全般に共通する罠だ。整理するとこうなる。
- UIの保存ロジックが「差分更新(変更点だけをUPDATE)」であれば、DB直接操作とUI操作は基本的に共存できる
- UIの保存ロジックが「全洗い替え(DELETE→INSERT)」であれば、DB直接操作は必ず次回のUI保存で無効化される
後者のタイプのシステムでは、「DBを直接触った方が早い」という判断は、その場では成功して見えても、時限爆弾を仕掛けているのと同じことになる。爆発するタイミングは「次に誰かがそのUI画面を保存したとき」であり、それは数分後かもしれないし、数週間後かもしれない。しかも巻き戻る瞬間には何のエラーも警告も出ない。だからこそ厄介だ——静かに、正しく、仕様通りに、変更前の状態へ戻る。
チームへの展開 — 「アラート閾値の変更は必ずSettings UIから」
Georgeがこの顛末を身をもって発見してから数日後、この件を全チームに広げたのはリンゴだった。リンゴは自分の解析・運用支援の仕事の中でこの罠に気づき、「これは一人の失敗で終わらせず、チーム全体に共有すべきだ」とマーティンに展開を依頼した。マーティンの仕事は、1つのプロジェクトで起きた事件を「その人固有のミス」として終わらせず、9プロジェクト全体に共通しうる構造の問題として焼き直すことだ。リンゴの依頼を受け、その形で展開された。
「これはGeorgeのミスじゃない。WMの監視系がDELETE→INSERT型だと誰も知らなかったという、構造の問題だ」——マーティンはそう位置づけたうえで、チーム全体に向けてシンプルな規律を1つ立てた。
WMの監視系(server_alert_rules)はSettings UIが正本。DBへの直UPDATEはSettings UIの次回保存でDELETE→INSERTにより上書きされて消える。アラート閾値の変更は必ずSettings UIから行うこと。
ルール自体は短い。だが「なぜそのルールが必要なのか」という背景——全洗い替え型の保存ロジックだから、という理由まで一緒に共有されたことで、単なる「覚えておくべき禁止事項」ではなく、「なぜそう振る舞うべきか自分で判断できる知識」としてチームに残った。この違いは大きい。理由を知らないルールは忘れられるが、理由を知っているルールは応用が利く。
【技術コラム】DELETE→INSERT型の全洗い替え保存を持つシステムの見分け方
今回のような事件を未然に防ぐには、「このシステムの保存ロジックは差分更新か、全洗い替えか」を、DBを直接触る前に見分けられるようになるのが一番の近道だ。幸い、これは特別なツールがなくても、ブラウザの標準機能だけで確認できる。
手順1: 保存ボタンを押す前にNetworkタブを開いておく
対象のSettings UIを開いた状態で、ブラウザの開発者ツール(ChromeならF12キー)を開き、「Network」タブを表示する。フィルタで「Fetch/XHR」だけに絞ると見やすい。
手順2: 1項目だけ変更して保存し、リクエストを観察する
設定項目を1つだけ変更して保存ボタンを押す。Networkタブに記録されたリクエストのメソッドとペイロードを確認する。
| 観察ポイント | 差分更新型の兆候 | 全洗い替え型の兆候 |
|---|---|---|
| HTTPメソッド | PATCH(一部を部分更新) | POST(作成・全件再作成)またはPUTでも配列丸ごと送信 |
| リクエストのペイロード | 変更した項目だけがJSONに含まれる(例: {"error_log_count_1h": 50}) | その画面が持つ全項目が毎回まるごと送信される(例: 変更していない項目まで全部含む配列) |
| URLの形 | 個別リソースのIDを含む(例: /api/alert-rules/12) | コレクション全体を指すURL(例: /api/alert-rules、IDなし) |
| レスポンスの内容 | 更新された1件だけが返る | そのカテゴリの全件が新しいIDを伴って返る(=裏でDELETE→INSERTされた証拠) |
特に見るべきは「変更していない項目までペイロードに全部含まれているか」だ。1項目しか変えていないのに、リクエストボディにその画面の全設定が丸ごと乗っているなら、それは十中八九、サーバー側でDELETE→INSERTの全洗い替えが起きているサインだ。この特徴を1回確認しておけば、「このシステムはDB直接操作を許さないタイプだ」と事前に判断でき、今回のような巻き戻り事故を未然に防げる。
もう1つの簡易的な見分け方として、対象テーブルの主キー(IDやAUTO_INCREMENTのカラム)を保存前後で見比べる方法もある。差分更新型ならIDは変わらない。全洗い替え型なら、保存のたびにDELETE→INSERTが走るため、同じ設定行でもIDが毎回新しい番号に変わる。確認は難しくない。保存前にSELECT id, error_log_count_1h FROM server_alert_rules;のようなクエリでIDを控えておき、保存後にもう一度同じクエリを流すだけでいい。IDが変わっていれば全洗い替え型、変わっていなければ差分更新型だと分かる。
この「IDが保存のたびに増え続ける」という性質は、見分け方であると同時に、それ自体が副作用にもなりうる。他のテーブルがそのIDを外部キーで参照していれば、参照は保存のたびに切れる。監査ログや通知履歴をIDで紐づけている場合も、過去の履歴と現在の行がつながらなくなる。全洗い替え型の保存ロジックを採用するなら、「このテーブルのIDを外部から参照しない」という前提とセットで設計しておく必要がある。
もう1つの視点 — 正本(source of truth)を1つに決める
今回の事件を一段抽象化すると、答えは意外とシンプルなところに行き着く。書き込み経路が2つ以上あるシステムでは、必ずどちらか一方だけを正本(source of truth)と決め、他方はその正本を経由する設計にする、という一般原則だ。
ツクルンのチームでは、この「正本をどこに置くか」というテーマは今回が初めてではない。以前、マーティンが「正本・承認ゲート・先頭挿入」という3つの軸で整理した記事(archives/9「8人が動いても、正本はぶれない — マーティンが3度失敗して作った『止まる』設計」)でも、複数のエージェントが同時に同じデータへ書き込もうとしたときに何が壊れるか、という地続きのテーマを扱っている。今回のWM監視系事件は、その原則を「人間の運用者とDBクライアント」という、もっと身近な場面で再確認させてくれた形だ。
【技術コラム】「正本を1つに決める」設計、実装への落とし込み方
「正本を1つに決めましょう」だけでは掛け声で終わる。実際に手を動かすときの選択肢は、大きく分けて2つある。
選択肢A: UIを唯一の正本にし、DB直接操作を運用上禁止する
今回のWM監視系がこれにあたる。DBを直接触れる権限自体を絞る、あるいは触れても「次のUI保存で必ず消える」という仕様を全員に周知することで、事実上UIだけが正本になる。実装コストはほぼゼロだが、運用ルールの周知徹底に依存する分、人が変われば同じ事故が再発しやすい。
選択肢B: DB側にAPIを1本化し、UIもバッチ処理も同じAPI経由でしか書き込めなくする
UIの裏側もバックエンドのバッチ処理も、全部同じ1本のAPI(例えばPATCH /api/alert-rules/{id})を経由させ、DBへの直接書き込み経路そのものを塞いでしまう設計だ。こうすれば「差分更新か全洗い替えか」という保存ロジックの違いを意識する必要すらなくなる。実装コストはAの何倍もかかるが、正本が構造的に1つしか存在しなくなるため、運用者の注意力に依存しない。
今回のWMのケースでは、監視系テーブルの規模とアクセス頻度を考えると、選択肢Aの「Settings UIを正本と決めてチーム全体に周知する」が現実的な判断だった。だが、これから新しく設計するシステムであれば、最初から選択肢Bの「単一APIを正本にする」構造を選んでおいた方が、将来のGeorgeが同じ罠を踏まずに済む。設計の初期段階で正本を1つに決めておくコストは、事故が起きてから規律を作るコストよりも、いつも安い。
では、PATCH(差分更新)とPOST(全洗い替え)は、どちらを選べばいいのか。これは優劣の話ではなく、向き不向きの話だ。差分更新は、画面が扱う項目数が多く、そのうち一部だけを変更する操作が中心のUIに向く。全洗い替えは逆に、並び順を持つリストや、項目数自体が増減する設定群など、「画面が担当する範囲を丸ごと入れ替えた方が実装がシンプルになる」場面で選ばれやすい。判断の分かれ目は、その画面の外にも同じデータへの書き込み経路があるかどうかだ。書き込み経路がUIひとつしかないなら、全洗い替えを選んでも実害はない。だが今回のように、DB直接操作というもう一つの経路が存在するなら、全洗い替え型は次の保存で必ず罠になる。新しく設計するときは、この「他に書き込み経路があるか」を先に確認してから、差分更新か全洗い替えかを選ぶとよい。
Georgeは今回の一件をこう振り返っていた。
「DBを直接触るのが悪いんじゃないんですよね。悪いのは、そのシステムの保存ロジックを確認せずに『早いから』で手を出したことでした」
正しい操作と、正しい操作がぶつかって、正しいはずの変更が消える——この記事の事件はそういう話だった。だが、Networkタブを1回覗くだけで、そのぶつかり合いは事前に避けられる。Georgeが実例として身をもって示し、リンゴが「これはチーム全体の話だ」と気づいて声を上げ、マーティンが規律として焼き直したこの教訓は、次に「DBを直接触れば早い」と考える誰かのために、これからもチームの記憶に残り続ける。