本番OJのリファクタリング:技術的負債からクリーンアーキテクチャへ
すべてのコードベースには物語がある。私がメンテナンスしているOnline JudgeプラットフォームのLeverageにもあり、それは美しくない。何年ものインクリメンタルな機能追加、深夜に押し込んだ素早い修正、そして時折の「私のマシンでは動く」ハックが本番に入った後、コードベースは小さなスタートアップに資金を提供できるほどの負債を蓄積していた。
完全な書き換えを行う決定は軽くなされなかった。書き換えはリスキーだ。「セカンドシステム効果」は本物だ。しかしコードレビューで29の異なるバグが見つかったとき——すべてのAC/WA提出カウンターが静かに間違っているものを含めて——再考を始める。
なぜリファクタリング?数えてみよう
バグ1:PM2クラスタが機能をゴーストに変えた
オリジナルのコードはメモリ内のArray<Set<number>>であるpendingSetを使用して、どのコンテスト/コース部門のランキングを再構築する必要があるかを追跡していた。15分のcronジョブがセットをチェックし、保留中のものがあれば再構築をトリガーする。
紙の上では賢い。PM2クラスタモード(複数プロセス)の本番では?災害。
ジャッジャーコールバックが来て提出が受理されたとき、プロセスAはローカルのpendingSetに部門IDを追加する。しかしプロセスBで実行されるcronジョブは独自の別のpendingSetを持っている——完全に空。ランキング再構築は決して起こらない。またはこの1回はプロセスAで起こるが、次のバッチはプロセスCに着地するかもしれない。勝者のいない競合状態だった。
この1つのバグが、なぜコンテストランキングがコンテスト中に更新を停止することがあったかを説明する。
バグ2:フリーズしたスコアボード
ランキング再構築ロジックはrebuildSaAndRank()を使用し、データベースからすべての提出をロードし、メモリ内でO(N log N)でソートし、結果を1行ずつN個の個別のUPDATEステートメントで書き戻す。数万件の提出がある複数日の練習セッションでは、これはNode.jsイベントループを数分間ピン留めするブロッキング操作だった。
Node.jsはシングルスレッドだ。数百ミリ秒のCPU作業は他のすべてのリクエストをブロックする。1分?サーバーはダウンしているも同然だ。
バグ3:ソルトなしのパスワード
// user.entity.ts — 実際の実装
static hash(password: string): string {
const md5 = crypto.createHash('md5').update(password).digest('hex')
return crypto.createHmac('sha256', config.security.hmac).update(md5).digest('hex')
}HMAC-SHA256(MD5(password))でグローバルで固定のHMACキー。ユーザーごとのソルトなし。これはHMACキーが漏洩した場合——設定ファイルにただ置いてある——レインボーテーブルを事前計算してデータベース内のすべてのパスワードをオフラインでクラックできることを意味する。MD5も毎秒数十億ハッシュでGPUアクセラレート可能だ。
バグ4:chenjingyuのマシンで動作する
// main.ts — これを作り話ではないと約束する
if (process.env.USER !== 'chenjingyu') {
await initService.init()
}初期化動作がOSログインユーザー名に依存する本番サーバー。開発者はローカル開発中に初期化をスキップするために自分のユーザー名をハードコードし、それが本番に入った。サーバーが別のユーザーとして実行される場合——または他の誰かがプロジェクトを引き継ぐ場合——これはデバッグが非常に困難な方法で静かに壊れる。
技術的決定:何を置き換え、なぜ
JWT vs セッションクッキー
オリジナルのシステムはRedisストアでexpress-sessionを使用していた。本質的に間違ったことはないが、サーバー上にセッション状態が必要で、水平スケーリングで複雑になる。
JWT(アクセス + リフレッシュトークンパターン)に切り替える:
- アクセストークン:15分、ステートレス
- リフレッシュトークン:7日、失効のためにRedisに保存
- ContestUser認証:JWTペイロードに
contestIdを含め、ガードがデバイス/IPバインディングを検証
主な利点はパフォーマンスではない——サーバーが真にステートレスになることで、Dockerベースの水平スケーリングが簡単になる。
BullMQ vs 手作りキュー
オリジナルのコードにはRedis ListsでバックアップされたカスタムQueue<T>クラスがあった。一応動いたが、リトライ、デッドレターキュー、ジョブ優先度、可観測性がなかった。すべてのエッジケースを手動で処理する必要があった。
BullMQはこれらすべてを無料で提供し、さらにダッシュボード(bull-board)、適切なTypeScript型、負荷下で戦闘テストされた動作も。提出 → ジャッジャーパイプラインはクリティカルパスだ——ここで実績のあるライブラリを使用するのはオプションではない。
Redis Sorted Set vs 全テーブルスキャン
これは最もインパクトのあるアーキテクチャ変更だ。以前は:
- すべての提出をロード → ソート → ランキングテーブルを再構築 → N行書き込む
今は:
- AC時:
ZADD ranking:{contestId} {score} {userId}— O(log N) - ランク照会時:
ZREVRANK ranking:{contestId} {userId}— O(log N)
リアルタイム、cronジョブなし、ブロッキングなし。ランキングは定義上常に正しい。
シングルプロセス vs PM2クラスタ
オリジナルのPM2クラスタセットアップはpendingSetバグの根本原因だった。Redisに移動する「修正」は正しいが、根本的な問題に対処していない:ステートフルなメモリ内データは水平スケールされたサービスに居場所がない。
新しい設計は明示的にシングルプロセス(デプロイメントユニットごとに1つのDockerコンテナ)。スループットがもっと必要なら、複数のコンテナにまたがるnginxロードバランシングでスケールし、それぞれステートレス。これはこの種のサービスにとって正しいメンタルモデルだ。
リファクタリング戦略
原則1:データベーススキーマに触れない
本番データベースには実際のデータがある。ユーザーには提出履歴がある。スキーマを変更するとマイグレーションが必要で、メンテナンスウィンドウが必要で、プラットフォームを使用するすべての人との調整が必要になる。それはしない。
新しいコードは同じスキーマを話す。ORMエンティティは明確さのために書き直されるが、同じテーブルとカラムにマップする。
原則2:機能パリティ、機能リグレッションなし
現在のシステムに存在するすべてのAPIエンドポイントは新しいシステムにも存在する必要がある。ルートは変わる可能性がある(URL構造をクリーンアップしている)が、機能は削除できない。これはユーザーと結んでいる契約だ。
原則3:テストファースト
オリジナルのコードベースには正確にゼロのテストファイルがある。ゼロ。「カバレッジが低い」ではなく、文字通りどこにも.spec.tsファイルがない。
書き換えでは、モジュールを「完了」と見なす前にクリティカルパス(提出カウント、ランキング計算、認証フロー)で≥80%のカバレッジを目標にしている。この制約はテスト可能なコードを書くことを強制する——それは関心の分離を改善することを意味し、それが全体のポイントだ。
コードベースがどのように腐敗するかについて学んだこと
オリジナルの開発者たちは悪いエンジニアではなかった。コードから彼らが制約の下で働いていた思慮深い人々だったことがわかる。バグは以下の組み合わせで蓄積した:
出荷へのプレッシャー: あのpendingSetバグは、オリジナルの開発者がおそらくシングルプロセステストでcron + メモリ内アプローチを動作させていたから存在する。マルチプロセスは後で追加された最適化だった。2つのプロセスを実行するテストを書いた人はいなかった。
設定エントロピー: ハードコードされたユーザー名とgitにチェックインされたシークレット付きの設定ファイル——これらは1人が1つのサーバーでこれを実行していたときには理にかなったショートカットだった。プロジェクトが成長するとタイムボムになる。
テストハーネスなし: テストなしでは、すべての変更は「以前動いていたものを壊したか?」というリスクを伴う。その不安は動いているものに触れないことにつながる、たとえそれが間違っていても。技術的負債は複利がある。
教訓は「これらの開発者はずさんだった」ではない。良いプラクティスは耐荷重性があるということだ。プロジェクトが小さいときは重要ではない。そうでなくなったときには非常に重要になり、その頃には書き換えなしで追加するには通常遅すぎる。
だから今これをしている。
