現場検証 — 隔壁の中の異変
木曜の午前10時。わたしのオフィスは普段通りの空気だった。
チームの4人中3人が出社していて、隣のデスクの小川くんはイヤホンをしてコードを書いている。わたしはGrafanaのダッシュボードを開いて、注文APIのp99レスポンスタイムのグラフを見ていた。8時台のピークに350msの山が立っている。通常は80ms前後。3週間前にBulkheadを導入してから、月末バッチの暴走による全館停電は完全に止まった。それは成果だ。でも、代わりに別の異変が始まっていた。
メールアプリを開く。3日前にロックさんへ送ったメールの返信が来ていた。
木曜の午前、現場検証に向かう。住所を送りたまえ。
……現場検証って、うちの会社に来るということだろうか。
10時ちょうど。オフィスの入口から、ツイードジャケットに蝶ネクタイの男が入ってきた。片手に木製の帆船模型を抱え、もう片手にビニール袋を下げている。受付で一瞬だけ立ち止まったが、すぐにわたしたちのエリアに向かって歩いてくる。迷いのない足取り。
小川くんがイヤホンを外してこちらを見た。わたしは小さく首を振った。
「ワトソン君。現場検証は現場で行うものだ」
ロックさんはわたしの前に立つと、帆船模型をデスクの端に置いた。前回LCIの事務所で組み立てていた模型だ。帆が張られ、甲板にミニチュアの舵輪が付いている。完成している。
「……ロックさん。受付は通りましたか」
ロックさんは空いている椅子を引き寄せて座った。ビニール袋からエナジードリンクを2本出し、1本をわたしのデスクに置く。もう1本を自分で開けた。
「通行証は不要だった。入口の人間に"外部セキュリティ監査"と伝えたら通してくれた」
セキュリティ監査。嘘ではないけど、正確でもない。
小川くんが小声で聞いてきた。「……あの人、誰?」
「外部のコンサルタントです」
小川くんは帆船模型を見て首を傾げたが、それ以上は聞かなかった。
わたしはモニタの方を向き直った。
「Bulkheadの件、うまくいきました。月末のバッチ暴走、完全に止まっています。……ただ」
ロックさんは帆船模型の甲板を指先で軽くなぞっていた。関心は帆ではなく船体にあるようだった。
「ただ?」
Grafanaのグラフを指した。
「注文APIのp99が350msに跳ねるピークが出始めました。ピーク帯の秒間30リクエストのときだけ。調べたら、リクエストごとにDBI->connectして$dbh->disconnectしていました。接続確立のコストが積もっています」
ロックさんはモニタを見ていた。p99のグラフではなく、その下のDB接続数の推移グラフに目を止めている。
「……接続数が波を打っている。リクエストのたびに上がり、すぐ下がる。——ワトソン君、この接続は使い捨てか」
「はい。1リクエストに1接続。使ったら捨てています」
ロックさんがデスクの端の帆船模型を軽く叩いた。
「隔壁は立てた。部屋は分かれた。だが部屋の中で、バケツの水を毎回汲んでは捨てている。……水道管を引く方が合理的だとは思わなかったか」
「思いました。だから、直しました」
わたしはそこで一拍置いた。
「……そこで問題が起きたんです」
汚染された接続 — Object Cesspool の発見
エディタを開いた。自分が書いたコードのファイル。指がキーボードの上で一瞬止まった——それからファイルを開く。
「接続をキャッシュするようにしました。同じプール名なら前の接続を返す。……1週間は動きました。2週目に在庫データが壊れ始めました」
| |
「pingで接続の生存確認はしています。切れていたら新しく作る。……問題はここじゃなくて」
ロックさんはコードを読んでいた。人差し指でモニタのreturn $cache->{$pool_name}の行を指す。
「返すときに、何をしている?」
「……何も」
「何もしていない。——ワトソン君、この接続が前のリクエストでBEGINしたままCOMMITしていなかったら?」
3秒、コードに目を戻した。
「……次のリクエストが、前のトランザクションの中で動く」
「そうだ。前の事件の証拠が、次の事件の現場に持ち込まれている。——これはObject Cesspool(汚染されたプール)だ」
AutoCommit の罠
「AutoCommitは1にしてあるのに……なぜトランザクション状態が残るんですか?」
コードのAutoCommit => 1を指した。設定してある。にもかかわらずトランザクション漏れが起きた。
「AutoCommitはコネクション作成時の設定だ。だが、アプリケーションコードの中で明示的に$dbh->begin_workを呼んだらどうなる?」
「……AutoCommitが一時的にオフになる。commitかrollbackを呼ぶまで」
「では、begin_workの後に例外が発生して、commitもrollbackも呼ばれなかったら?」
注文処理のコードを思い出した。begin_workを使っている。在庫確保と注文レコード挿入をアトミックにするために。
「注文処理の中でbegin_workを使っています。例外が起きたらevalで捕まえてrollbackしているはずですが」
「“はず”。——コードを見せたまえ」
注文処理のファイルを開いた。
| |
コードを追った。begin_work。在庫チェック。在庫不足なら——。
「……returnしている。dieじゃなくてreturn。rollbackを通らない」
「そうだ。この接続がキャッシュに戻り、次のリクエストに渡される。トランザクションは開いたままだ。——使った道具を洗わずに棚に戻した。次に使う人は、汚れた道具を受け取る」
ロックさんはホワイトボードの前に立った。わたしが3週間前に描いたBulkheadの構成図——注文API:8本、検索API:8本、バッチ:4本の隔壁図がまだ残っている。その横に赤ペンで図を描き始めた。
| |
「……returnで関数を抜けても、接続は閉じない。キャッシュに残ったまま」
「そうだ。次にこの接続を受け取ったリクエストは、開いたトランザクションの中で動作する。前の事件の証拠品が、次の事件の現場に混入した——鑑識が汚染された証拠で容疑者を特定したら、冤罪になる」
わたしの描いたBulkheadの構成図の横に、ロックさんの赤い字が並んでいる。自分の空間にロックさんの痕跡が侵入してくるのは、前回のLCI事務所を訪ねたときとは違う感覚だった。
checkout と checkin — 借りて返す作法
ノートを開いた。前回のように「図にしていいですか?」とは聞かなかった。描き始めた。
get_connection → use → ???
???の部分が空白だ。
「わたしの実装にはgetしかない。返す概念がない。……つまり、返すときにリセットする仕組みが要る」
ロックさんがノートの???を指で叩いた。
「そうだ。Object Poolの核は2つの動作だ。checkout——有効性を確認してから貸し出す。checkin——状態をリセットしてからプールに戻す。君のコードにはcheckoutの半分——ping——はあったが、checkinが完全に欠けていた」
ノートに描き足した。
| |
「そして重要なのは——リセットの責任はプール側にある。使う側がリセットを忘れても、プールがcheckin時に必ずリセットを実行する。さっきのbegin_workの抜け穴は、リセットをアプリケーションコード側に委ねていたから起きた」
「……プール側でやれば、使う側のコードパスに抜けがあっても安全。構造で保証する」
「そうだ。これはBulkheadと同じ原則だ——個々のコードの正しさに依存しない。構造で安全を保証する」
3週間前にLCIの事務所で聞いた言葉と重なった。壁を立てるのは、個々のリクエストの行儀を信用しないため。プールのリセットも同じだ。個々の使い手の後始末を信用しない。
ロックさんはホワイトボードに戻り、赤ペンでConnectionPoolクラスの設計を描き始めた。
| |
checkout — 検証付きの貸し出し
「checkoutの中を見たまえ」
| |
「checkoutのとき、接続が切れていたらどうするんですか? pingするだけで十分ですか?」
「pingは最低限の確認だ。“接続が生きているか”。だが、生きていても使えない場合がある」
「たとえば?」
「接続のアイドル時間が長すぎて、サーバー側でセッションが切れている場合。ファイアウォールが長時間アイドル接続を静かに切断する場合。pingはTCPレベルの応答を見るが、サーバー側のセッション状態は検証しない」
ノートに描きながら聞いた。
「じゃあ、checkoutでpingして、ダメなら破棄して新しく作る。……作れなかったら?」
「プールの上限に達していなければfactory(生成関数)で新しく作る。上限に達していれば——」
「待つか、断るか」
「速やかに断る方がいい。Bulkhead回で話したことを覚えているか?」
「……“隔壁の原則は速やかな失敗”。空きがなければ即座に断る」
「同じだ。プールが空なら、タイムアウト付きで短く待ち、それでも取れなければ即座にエラーを返す。待ち続ければ、リクエストがプールの手前で渋滞する——Bulkheadで防いだはずの問題が、プールの入口で再発する」
……2回目だとこの呼び方にも慣れるものだな。ワトソン君。ロックさんの発話パターンの一部として処理できるようになっていた。
checkin — リセット付きの返却
| |
with_connection — スコープベースの自動返却
| |
ロックさんの設計と自分のコードの差分を確認した。
「わたしの実装との違い……3つあります」
「言ってみたまえ」
ノートに差分を書き出した。
「1つ目。checkinメソッドがある。返却時にresetterでリセットする。わたしの実装には返却の概念がなかった」
ロックさんがうなずいた。
「2つ目。with_connection。ブロックで囲って自動返却する。evalで例外を捕まえてからcheckinを呼ぶから、例外が起きてもリークしない。わたしのprocess_orderの早期return問題は、これで構造的に防げる」
「そうだ。使い手がリセットを忘れても、返却を忘れても、プールが保証する」
「3つ目。resetterの失敗時。evalで囲って、リセット自体が失敗した接続は破棄する。壊れた道具は棚に戻さない」
ロックさんの口角がわずかに上がった。前回の事務所で、わたしが即時拒否の問題を自分で指摘したときと同じ系統の、小さな反応。
「……3つ目に気づいたか。resetterの失敗処理は、見落とされやすい」
checkout/checkin の全体の流れを図にした。
flowchart LR
A[checkout] --> B{プールに空きあり?}
B -->|Yes| C{validator 検証}
C -->|有効| D[オブジェクトを返す]
C -->|無効| E[破棄して再試行]
B -->|No| F{上限未満?}
F -->|Yes| G[factory で新規生成]
G --> D
F -->|No| H{タイムアウト?}
H -->|Yes| I[例外を投げる]
H -->|No| J[短い待機 → 再試行]
J --> B
K[checkin] --> L[resetter でリセット]
L -->|成功| M[プールに戻す]
L -->|失敗| N[破棄]
隔壁の中の水流 — プールの設計
「プールのサイズはBulkheadの割り当てと同じにすべきですか? 注文APIの隔壁が8接続なら、プールも8ですか?」
実装を週明けにチームに持ち帰ることを考えると、パラメータは具体的に詰めておきたかった。
「8は上限——同時に存在できる最大数だ。だが常に8本が必要か?」
「……通常時は秒間10リクエスト程度。接続は1リクエストあたり数ミリ秒で返ってくる。同時に4本もあれば十分です」
「ならばmin_sizeを2〜3、max_sizeを8にする。需要に応じて2から8の間でプールが伸縮する。——しかし、もう1つ重要なパラメータがある」
「アイドルタイムアウト?」
ロックさんが薄く目を見開いた。前回と同じ反応。
「そうだ。使われていない接続はいつまでもプールに居座るべきではない。サーバー側にも接続リソースのコストがある。idle_timeoutを設定し、一定時間使われなかった接続は静かに閉じる。min_size分だけは残す」
ホワイトボードの前に立った。ロックさんが赤ペンで描いた図の横に、自分のペンで追記した。Bulkheadの「8接続」枠の中に、プールのパラメータを書き込む。
「Bulkheadが壁。プールが壁の中の水位管理。……壁が"同時にいくつまで"で、プールが"どう回すか"」
「正しい。Bulkheadは量の隔離。Object Poolは質の管理。壁を立てるだけでは、中が腐る」
リファクタリング後の注文処理
DB接続プールの構築。アプリケーション初期化時に一度だけ行う。
| |
注文処理のリファクタリング後。with_connectionで自動checkout/checkin。
| |
ノートにBeforeとAfterを並べた。
「with_connectionのブロックの中でもrollbackを書いていますが……ブロックを出た後のcheckinでもresetterがrollbackする。二重ですか?」
「二重だ。——しかし、rollbackは冪等だ。開いたトランザクションがなければ何もしない。ブロック内のrollbackは"作法として正しいコード"。resetterのrollbackは"仕組みとしての安全網"。作法と仕組み、両方あって初めて安全だ」
「……ベルトとサスペンダー」
ロックさんが小さく笑った。
「そうだ。コードの世界にも冗長性は必要だ」
壁の中の作法
ロックさんが立ち上がった。ツイードジャケットの埃を払い、ホワイトボードの前で一瞬止まる。
わたしが3週間前に描いたBulkhead構成図——注文API:8本、検索API:8本、バッチ:4本の隔壁図がまだ残っている。ロックさんは赤ペンを取り、注文APIの隔壁の中に小さく描き足した。
checkout → use → checkin
「……壁の中に、もう1つ仕組みが要ったんですね」
ロックさんは赤ペンを置いた。帆船模型に目をやった——が、取り上げない。そのままわたしのデスクに残した。
「壁が守るのは"隣の部屋"だ。部屋の中を守るのは、別の仕事だ。——また異変があれば連絡したまえ、ワトソン君」
ロックさんがオフィスを出ていった。小川くんが「帰った?」と小声で聞いてきた。うなずいた。
ホワイトボードの前に立った。Bulkheadの構成図の中に、ロックさんの赤い字。checkout → use → checkin。その横に、自分のペンで(validate)と(reset)を書き足した。
目が帆船模型に移った。完成した模型。帆を張り、甲板があり、内部に隔壁がある。ロックさんが忘れていったのか、置いていったのか。
……全部のコネクションを最初から作っておく必要あるのかな。必要になったときに作れば、起動時のコストは——。
思考を途中で止めた。それは今日の問題ではない。
帆船模型を片付けずに、そのまま置いておいた。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| 高コストオブジェクトの毎回生成・破棄 | Object Pool(プールによる再利用) | p99レスポンス 350ms → 90ms |
| Object Cesspool(返却時リセット漏れ) | checkin 時のリセットプロトコル | 在庫データ不整合ゼロ |
| リーク(例外時の返却忘れ) | with_connection(スコープベース自動返却) | 接続リーク検出ゼロ |
推理のステップ
- Bulkheadでプールを分離した後、各プール内部でDB接続を毎回connect/disconnectしていた(使い捨て)
- 接続確立コスト(TCPハンドシェイク+認証 ≒ 15ms/回)がピーク時に積み重なり、p99レスポンスが80ms→350msに劣化
- 応急策として接続をキャッシュ(
_cacheハッシュ)したが、返却時のリセット処理が欠けていた begin_work後の早期returnで未コミットのトランザクション状態が次のリクエストに漏れた(Object Cesspool)- Object Poolの
checkout/checkinプロトコルで「検証付き貸出」と「リセット付き返却」を構造化 with_connectionパターンで例外時のリーク(返却忘れ)を構造的に防止- プールサイズ(
min_size/max_size)でBulkheadの割り当てと整合させ、idle_timeoutで不要な接続を回収
ロックより
壁を立てただけでは足りない。中のものを腐らせるなら壁を立てた意味がない。隔壁は隣室への延焼を止める。だがプールの中身——接続の状態、トランザクションの残骸、前の使用者の痕跡——を清掃する仕事は、隔壁の管轄外だ。
借りて、使って、返す。返すときに前の痕跡を消す。checkoutで検証し、checkinでリセットし、with_connectionでスコープを閉じる。この循環をプール側が保証する限り、個々の使い手の後始末に依存しなくて済む。鑑識の基本と同じだよ、ワトソン君——現場に入ったら、出るときに自分の痕跡を残すな。
