お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、古い記事の掃除処理を見直しました。直近の既読がある記事は残すようにした後も、確認の直後に既読が保存されると、その記録まで消す経路が残っていたためです。今回は、本番の被害件数ではなく、テスト用データで再現した問題と実装の変更について書きます。
古い記事でも、読んだ記録は今日のもの
DevPickでは、取得から保持期間を過ぎていて、いいねの付いていない記事を定期的に削除します。既定の保持期間は90日です。記事には既読などの関連データも結び付いているため、掃除処理ではそれらを先に消し、最後に記事本体を削除する順序になっていました。
一方、画面には今日の既読件数や直近7日間の読了実績を表示しています。こちらが数えているのは、記事をいつ取得したかではなく、利用者がいつ読んだかです。記事が古いことと、読んだ記録が古いことは一致しません。
最初の問題は、この違いを削除条件に反映していなかったことでした。2026年9月5日の調査記録では、取得から91日の記事に今日の既読を付け、テスト用のSQLiteで掃除処理を呼んでいます。処理前に1件あった今日の既読は、記事が削除された後に0件になりました。
そこで、直近7日間の既読がある記事は、その記録が集計対象の期間を過ぎるまで削除を延期する形にしました。記事を無期限に残す変更ではありません。画面で実績として見せている間だけ、集計の材料が掃除で失われないようにする判断です。
以前の定期ジョブが再起動のたびに先送りされていた話では、掃除処理をいつ呼ぶかが問題でした。今回は、呼ばれた掃除処理が何を消してよいかを見直しています。実行の仕組みが整っても、後から追加した画面の要件と保持条件が噛み合っているかは、別に確認が必要でした。
IT女子 アラ美削除前に確認しても、その直後に保存される
直近の既読を持つ記事を除外したことで、掃除が始まる前から既読があるケースには対応できました。ただ、利用者は掃除処理が終わるまで記事を読むのを待ってくれません。削除対象を選ぶ処理と、既読を保存する処理は、別々に進みます。
残っていた実装には、削除するまとまりごとに直近の既読を確認し直す処理もありました。それでも足りませんでした。確認はその時点の状態を読むだけで、その結果を見た後に別の処理が保存することまでは止められないからです。
9月6日の再現では、最後の保護判定が終わった後、削除関数を呼ぶ直前に別のDBセッションから既読を保存し、確定する順序を作っています。実際の関数名などを省いて単純化すると、次の流れです。
掃除処理:直近の既読がないことを確認
保存処理:その記事の既読を保存し、確定
掃除処理:対象記事に結び付く既読をまとめて削除
結果 :保存に成功した既読が残らない
外部キーを有効にしたSQLiteでの再現結果は、保存された既読が1件、掃除で削除された記事が1件、最後に残った既読が0件でした。外部キーは、関連先が存在するかなどをDBが検査する制約です。しかし、この処理は既読を先に削除してから記事を削除するため、関連の矛盾を起こさずに、守りたかった記録まで消せてしまいます。
ここで「最後にもう一度確認する」を足しても、確認と削除が別々のままなら、その間に保存できる余地が残ります。これが今回の競合です。確認回数より、確認した後に状態が変わることをどう扱うかが問題でした。
保存処理が正常に終わったことだけでは、記録がその後も残るとは限りません。掃除のように同じデータへ触る別の処理まで見ると、保存側だけのテストでは分からなかった消失の経路が見えてきます。



確認と削除を一体化し、消す条件も古い記録だけに絞った
この競合を防ぐため、掃除処理の流れを大きく見直しました。具体的には、「保護判定と削除の分離をなくすこと」「最新の確定状態を確実に読むこと」、そして「そもそも直近の記録を消す対象に含めないこと」の3点です。
まず、対象記事を判定する処理と削除処理を1つの関数にまとめました。別々のステップに分かれていると、その隙間に別のトランザクションが割り込む余地が生まれます。そこで、削除対象の記事を取得する際にロック(FOR UPDATE)をかけ、最新の確定データを読み取るようにしました。MySQLの一般的な分離レベルでは、過去のスナップショットを読んでしまう可能性があるため、現在の最新状態を確実に把握することが重要でした。
次に、ロックを取得した状態で、直近に既読やいいねが付いていないかを改めて除外します。
さらに最も根本的な変更として、関連データの削除条件そのものを見直しました。以前は、対象記事を消す準備として、結び付く既読データをすべて無条件に削除していました。これを改め、集計期間を過ぎた古い既読データだけを削除対象とし、直近の既読データは削除処理の条件から完全に外す形にしました。
概念的な条件の違いで表すと、次のようになります。
【以前の削除条件】
・対象記事に紐づく既読データをすべて無条件に削除
【見直し後の削除条件】
・対象記事に紐づくデータのうち、「記録日時が保持期限より前」の古いものだけを削除
(※直近の読了実績は、削除処理の対象から完全に除外)
こうすることで、仮に掃除処理と既読保存がほぼ同時に動いたとしても、直近の既読が掃除処理によって巻き込まれて消える事態を防げます。
加えて、削除の直前にも直近の既読やいいねが残っていないか二重で確認し、残っている記事は本体の削除をスキップして安全側に倒す構成としました。確認を何回挟むかだけでなく、消す条件自体を安全にすることが保護の実体となりました。



再現テストで確認できたことと、検証の限界
実装を変更した後は、問題の挙動が意図通り防げているかをテストで確かめました。今回の修正では、自動テストの設計でも意識した点があります。
まず行ったのは、問題となった処理順序をテストコード側で固定して再現することです。削除処理が対象を読み込んだ直後、実際に削除を実行する手前のタイミングで、別のDBセッションから既読やいいねを保存・確定させるフックを差し込みました。
これにより、以前のコードであれば「保存されたはずの記録が0件になる」という事象が、修正後は「直近の記録が1件残ったまま保護され、該当記事の削除が安全にスキップされる」状態へと変化したことを確認できました。さらに、保持期限を過ぎた古い既読だけが正しく消え、直近の既読は保護される単体テストも合わせて追加しています。
また、本番環境のデータベースで使われるSQLを検証するため、SQLの生成テストも用意しました。開発環境で主に利用しているSQLiteでは行ロックの構文がそのまま動かない制約があるため、本番のMySQL向けに「FOR UPDATE」が付与された正しいクエリが組み立てられているかをコンパイル結果で確認しています。
一方で、今回の検証には明確な限界もあります。テストコードで行ったのは、あくまで処理の呼び出し順序を人為的に固定したシミュレーションです。本番環境のMySQL実機上で、大量のリクエストを並行して流しながら意図しないロック待ちや競合が起きないかを調べる、負荷検証までは行っていません。
開発用のDBと本番のDBでは、トランザクション分離やロックの挙動に細かな違いがあります。すべてをテスト環境だけで完全に再現し切ることは難しいため、どの範囲までを自動テストで担保し、どこから先が実機での観察領域なのかを意識して境界を引くことが大切だと感じました。



まとめ
今回は、DevPickの古い記事を掃除する処理において、直近の既読記録が削除されてしまう競合の再現と、その対策について書きました。
この記事の要点は次の通りです。
- 記事の寿命と記録の寿命は異なる:古い記事であっても、読んだ記録は「今日の実績」になるため、削除の延期条件に直近の読了実績を反映させた。
- 確認を増やすだけでは競合を防げない:確認とその後の削除が分かれていると、その間に保存された記録が消える余地が残る。
- 削除条件そのものを安全に倒す:関連データの無条件全削除をやめ、期限切れの古い記録だけを削除対象に限定することで、直近の記録が巻き込まれない構造にした。
- テストの境界を意識する:順序を固定した回帰テストやSQL生成のテストで動作を担保しつつ、実機での高負荷検証とは分けて捉える。
保存処理が単体で正常に終わっていても、バックグラウンドで動く掃除処理との組み合わせによって、意図しないデータ消失が起きることがあります。確認のステップを重ねること以上に、「そもそも危険な条件で削除しない」「並行する操作を考慮して処理を一体化する」といった設計の重要性を改めて実感した改修でした。












