Slide 1

Slide 1 text

© 2012-2026 BASE, Inc. 2026/7/20 PHPカンファレンス2026 2年前に削除したPHPクラスが、 ある日突然決済をエラーにした 1 @ykagano

Slide 2

Slide 2 text

2 © 2012-2026 BASE, Inc. 氏名:加賀野 祐(ykagano) 所属:BASE株式会社    BASE Department Product Division 役割:シニアエンジニア(バックエンド) 趣味:お酒、旅行、キャンプ 2024年8月にBASE株式会社に入社 その前は以下の開発を経験してきました - 2009年〜:NET CASH - 2012年〜:WebMoney - 2017年〜:Yahoo!ウォレット、PayPay ykagano 自己紹介

Slide 3

Slide 3 text

3 © 2012-2026 BASE, Inc. アジェンダ ● ある日突然、決済がエラーに ● なぜ2年後に突然爆発したのか ● どう修正したのか

Slide 4

Slide 4 text

© 2012-2023 BASE, Inc. ある日突然、決済がエラーに 4

Slide 5

Slide 5 text

5 © 2012-2026 BASE, Inc. ある日、Sentryが鳴った 普段見ないエラーだった Sentry調査用のBotから AIに調査してもらった

Slide 6

Slide 6 text

6 © 2012-2026 BASE, Inc. 購入失敗は2件だけだった BASEはECなので、購入失敗は少ない件数でもサービスの根幹に関わる 絶対にケアが必要 → インシデント扱い 同じ原因で何度もエラーが起こるかもしれないので、Slackのインシデントチャンネルを立てて共有 何が起きていたのか エラーはどこで起きていたのか DBに保存された 詳細データ をPHPのクラスに復元する処理 クラスの存在チェックに失敗して LogicException → 決済フローが停止

Slide 7

Slide 7 text

7 © 2012-2026 BASE, Inc. MukashinoDetail 犯人は「2年前に削除したクラス」 2024 昔のコードの一掃 クラス削除 2年間の沈黙 エラーなし ・ 誰も気づかない ! 2026 突然の発火 決済エラー LogicException 削除したとき問題は何もなかった ● grepで参照ゼロを確認 ✓ ● テストはすべて通過 ✓ ● コードレビューも通過 ✓ ● リリース後もエラーなし ✓ それでも「時限装置」は仕掛けられていた

Slide 8

Slide 8 text

© 2012-2023 BASE, Inc. なぜ2年後に突然爆発したのか なぜ2年後に突然爆発したのか 8

Slide 9

Slide 9 text

9 © 2012-2026 BASE, Inc. 決済が失敗した pending と同じ keyである failed の行には、クラス名がシリアライズされたまま残っていた DBには「遺物」が眠っていた key status detail 753190E46D820159 2023年・昔の決済 failed {"class": "MukashinoDetail"...} ← クラスは削除済み・復元できない 753190E46D820159 2026年・新規の決済 pending {"class": "ShinkinoDetail"...} 同じキーなのに並存できる (key は非UNIQUE) DBの中に眠る「遺物」 コードを消してもデータは残る 新規の key は作成時に以下の仕様で作られていた ● 完了した決済なら key は重複しない ● 失敗した決済は key の重複を許容する ← 今回はこのパターン

Slide 10

Slide 10 text

10 © 2012-2026 BASE, Inc. これまで新規の key 作成時に、削除済みのクラスと同じ key を引き当てることはなかった 今回が初めての事象 単独では誰も悪くない実装が時間差で噛み合って「時限装置」になった 発火の瞬間に対象のkeyの検索が「壊れた行」まで一緒にロードしてしまった 2年越しのキー衝突 1 決済処理 keyで検索 2 同一 key の 全行をロード 3 クラスに 復元を試みる 4 クラス存在 チェック → 失敗 5 決済エラー ⚠ 削除済みクラスを参照 する行も混入 購入失敗    

Slide 11

Slide 11 text

11 © 2012-2026 BASE, Inc. key の生成には str_shuffle() を使用している エラーが発生する8日前のリリースで、key 生成より前に mt_rand() を消費するコードが購入経路に入り、 乱数消費位置の分布が過去と重なった(テープの同じ位置から key を引き始めた) これにより衝突確率が跳ね上がったと想定される(不具合ではなく、DBでの key の重複は仕様通り) キーの衝突がなぜ突然起きたか 過去のリクエスト 83 17 52 96 41 08 75 29 63 34 90 12 57 … リリース前 83 17 52 96 41 08 75 29 63 34 90 12 57 … リリース後(今回) 83 17 52 96 41 08 75 29 63 34 90 12 57 … 事前に消費 key生成に使用(str_shuffle) key生成に使用 → 開始位置がズレていて衝突しない 新コードが消費(mt_rand) key生成に使用 str_shuffle() と mt_rand() は同じ seed(初期値) なら毎回同じ乱数列(=同じテープ)を返す テープの同じ位置から同じ並びを引く → 同じ key が生成される(衝突) 同じ seed

Slide 12

Slide 12 text

12 © 2012-2026 BASE, Inc. grepが探せるのは「コードの参照」だけ テストが検証するのも「コードの動き」だけ DBに残るクラス名入りのデータは誰も見ていなかった さらに発火には低確率のキー衝突が必要 → だから2年間、沈黙していた コードは消したが、データは消えていなかった

Slide 13

Slide 13 text

© 2012-2023 BASE, Inc. どう修正したのか 13

Slide 14

Slide 14 text

14 © 2012-2026 BASE, Inc. status でDBにフィルタをかけるようにした 壊れた行をそもそもロードしない Before DBから全行を取得  PHP側で除外 ⚠壊れた行もロードされて爆発 $details = $this->detailMapper->find(key: $key); $unfailedDetails = array_values(array_filter($details, function (Detail $detail) { return $detail->getStatus()->includedIn(Status::unfailed()); })); After DBクエリの段階で failed を除外  必要な行だけ取得 ✓壊れた行はそもそもロードしない $details = $this->detailMapper->find( key: $key, statuses: Status::unfailed(), );

Slide 15

Slide 15 text

15 © 2012-2026 BASE, Inc. 今回の事象は、2年前に削除したクラスが、DBにシリアライズされて残っており、 新規の決済が同じ key を引き当てたことで、過去データの復元に失敗し、決済がエラーになったものです 「もう使われていない」をgrepだけで判断していませんか コードを消してもDBのデータは残る クラス名をシリアライズして保存する設計は時限装置になりうる 削除するときは「コードの参照」だけでなく「データの参照」も確認しましょう 皆さんの現場でも、同じ時限装置が眠っているかもしれません まとめ

Slide 16

Slide 16 text

16 © 2012-2026 BASE, Inc. 以上です ご清聴ありがとうございました!! BASE社の採用情報: https://binc.jp/jobs