Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Our lock expired while the first worker was still writing invoices

2026年10月13日に「Dev.to」が公開したITニュース「Our lock expired while the first worker was still writing invoices」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

請求処理が遅延し、重複を防ぐためのロックが期限切れになった。ロック切れ後も元の処理は続き、別の処理も同じ請求書を作成。結果、多くの顧客に二重・三重に送付された。データベースのユニーク制約とロックの継続更新で重複送信を防ぐよう改善した。

ITニュース解説

ある企業で、多数の顧客に毎月請求書を送付するシステムを運用していたところ、ある月に200社以上の顧客に同じ請求書が複数回送られてしまうというトラブルが発生した。中には3回も同じ請求書が届いた顧客もいたという。この問題は、システムが複数の処理を同時に行う際に、処理の重複を防ぐための「ロック」と呼ばれる仕組みに想定外の事態が発生したことで引き起こされた。

この請求書発行システムでは、8台の「ワーカー」と呼ばれる処理担当プログラムが、顧客リストから順番にアカウントを取り出し、請求書を作成して送付する。複数のワーカーが同時に同じ顧客の請求書を発行してしまうことを防ぐため、各ワーカーは処理を開始する前に、分散キャッシュストアであるRedisというシステム上で、その顧客に対する「ロック」を取得する仕組みになっていた。このロックは、Redisの「SET」コマンドを使い、特定のキー(顧客の識別子)が存在しない場合にのみ値を設定する「NX」オプションと、60秒の「有効期限」を付けて設定される。通常、1枚の請求書の発行にかかる時間は約4秒だったため、60秒という有効期限は十分に長いと判断されていた。ワーカーは請求書の発行を終えると、そのロックを削除するように設計されていた。

しかし、問題が発生した月には、最大規模の顧客の請求書に、過去にないほど多くの利用明細が含まれていた。加えて、税金計算のために連携している外部サービスへの問い合わせが非常に遅かったため、一部の請求書の発行には2分から5分もの時間がかかるようになってしまった。すると、ワーカーが請求書を作成している途中で、取得していたロックの有効期限である60秒が経過し、ロックが自動的に解放されてしまった。Redisは、ロックの保持者がまだ作業中かどうかを知る術がないため、単に期限が来たロックを消去したのだ。

有効期限が切れてロックが解放されると、次にその顧客の処理を担当しようとした別のワーカーは、ロックが取得されていないと判断し、新しいロックを取得して、同じ顧客の請求書発行を最初から開始してしまう。最初のワーカーは、自分が保持していたはずのロックがすでに期限切れで失われていることに気づかないまま、作業を続行する。そして、最初のワーカーがようやく請求書を完成させて保存し、メールで送信し終えた後、最後にロックを削除しようとする。しかし、その時にはすでにそのロックは2番目のワーカーが取得していたものになっていたため、最初のワーカーが行ったロック削除は、実際には2番目のワーカーのロックを削除してしまったことになる。この結果、3番目のワーカーもロックが解放されたと判断し、同じ顧客の請求書発行を開始してしまい、同じ請求書が3回届く事態も発生した。

有効期限付きのロックは、厳密には「リース」と呼ばれる。これは、作業が完了しているかどうかに関わらず、定められた時間でその占有権が終了してしまうという特徴を持つ。そして、その占有権を失ったワーカーは、自分自身がそれを失ったことに気づくことができない。たとえシステムの処理が一時的に停止するような、ごみ収集(ガーベージコレクション)と呼ばれるメモリ管理の処理や、外部システムへの遅い呼び出しが発生しても、ワーカー内部からはロックが失われたことが感知できないため、ワーカーは失効したロックを保持していると思い込んだまま作業を進めてしまう。

この問題の解決策として、企業はまず、システムの一番奥にある「データベース」の仕組みを見直した。請求書をデータベースに保存する際に、「顧客」と「請求期間」の組み合わせで必ず一意になるように「一意制約」というルールを導入したのだ。これにより、たとえ2番目のワーカーが同じ請求書を発行しようとしても、データベースがそれを重複と判断し、保存を拒否する。結果として、顧客のメールボックスに届く前に重複請求を防ぐことが可能になった。

さらに、ロックの仕組みも大幅に強化された。従来のロックは重複発行を防ぐ目的だったが、データベースの一意制約によって重複発行が防がれるようになったため、ロックの役割は「重複する無駄な作業を避ける」ことに変わった。新しいロックシステムでは、ロックを取得する際に「フェンシングトークン」と呼ばれる、時間とともに増加する一意の番号をワーカーに付与する。ワーカーがデータベースに書き込みを行う際には、このトークンを合わせてデータベースに渡し、データベース側でそのトークンが最新のものであるかを確認する。これにより、古いロックを保持したワーカーが間違ってデータを書き換えようとしても、それが防げるようになった。

また、ワーカーがロックを解放する際には、単純にロックを削除するのではなく、「自分が設定した値と、現在のロックの値が一致する場合にのみ削除する」というスクリプト(簡単なプログラム)を使って、安全にロックを解放するように変更された。これにより、自分が失効してしまったロックを、別のワーカーが取得した後に誤って削除してしまうことを防ぐ。

さらに、ワーカーは作業中に20秒ごとにロックの有効期限を自動的に更新するようになった。もしロックの更新に失敗した場合は、ワーカーは現在のジョブを直ちに中断し、異常を検出する。加えて、ロックが有効期限切れになったにもかかわらず、そのロックを保持しているはずのワーカーがまだ動いている場合、システムが自動的にアラートを発し、問題の早期発見に努めるようになった。

今回のトラブルは、ロックの有効期限を設定する際に、「最も遅いジョブがどれくらいの時間で完了するか」という推測に基づいて行われたことが原因であった。この推測は4年間はうまく機能していたが、システムにかかる負荷や外部サービスの変化によって、処理時間が想定以上に長くなったときに破綻してしまった。システムの設計においては、時間の推測がいかに難しいか、そして想定外の事態にも対応できる堅牢な仕組みが求められることを示唆する事例であった。

関連コンテンツ