分散ロックを取ったのに二重更新が起きる理由:リース・時計・フェンシングトークン
期限が切れたワーカーは、停止から戻ってまた書き込めます。夜間レポートを例に、世代番号を保存先で検査する仕組み、冪等性との違い、時計と整合性の条件を4件のTipsから解説します。
分散ロックを取ったのに二重更新が起きる理由
架空の夜間レポート生成で、ワーカーAが10秒のリースを取得し、15秒停止したとします。その間に期限が切れ、Bが引き継ぎました。Aは再開しても自分の停止時間を認識できるとは限らず、Bの結果を書き換えるかもしれません。分散ロックは誰が担当かを調整しますが、失効したプロセスへ物理的な停止命令を届ける仕組みではありません。
| 順序 | ワーカーと保存先の状態 | 危険な構成での結果 |
|---|---|---|
| 1 | Aがトークン33で処理し、その後停止 | Aの計算は残っている |
| 2 | リース失効後、Bがトークン34を取得 | Bは新しい担当として進む |
| 3 | 保存先が34を認識してBの結果を公開 | 新しい結果が見える |
| 4 | Aが33のまま遅れて公開を要求 | 世代検査がなければ古い結果へ戻る |
「書く直前にロックを確認する」案でも、確認後から実際の書き込みまでに停止すれば同じです。リースを長くすれば頻度は変わりますが、任意の長い停止や遅れを排除できません。Redisの公式資料もフェンシングを勧め、client-goのリーダー選出資料はフェンシングを保証しないと明記しています。
ロックが守るのは効率か、正しさか
重いレポート計算の重複を減らしたいだけなら、一時的に2台が計算しても、正しい結果だけを公開できれば許容できる場合があります。残高や唯一の公開先を守るなら、保存先が不正な更新を拒否する必要があります。計算の重複を減らすロックと、業務の不変条件を守る仕組みを同一視しません。
フェンシングトークンは保存先が比較して初めて効く
フェンシングトークンは、権限の交代順に増える世代番号です。信頼する発行元が払い出し、保存先が新しい世代番号を永続ストレージへ保存して、以後の古い世代を拒否します。時計の時刻やランダムなUUIDを付けるだけでは交代順になりません。発行元の復旧で番号が巻き戻る場合も、単調性を保つ設計が必要です。
with destination_transaction():
lock_resource_control_row(resource_id)
require supplied_epoch == current_epoch(resource_id)
if not applied(operation_id):
apply_business_change()
record_operation(operation_id)世代の比較、結果の変更、操作IDの記録は、他の処理が途中へ割り込めない一つの処理として実行します。これが原子的という意味です。クライアントで古くないかを読んでから無条件に書くと、間に新世代が登録できます。同じ世代の再送は番号だけでは見分けられないため、操作IDで重複を処理します。公開済みの結果を返せるようにして、応答喪失後も新しい業務操作として実行し直さない設計にします。
世代番号が最新とは限らないレプリカで、ロックの所有者を判定しない
AとBが別のレプリカで「自分の世代が現在」と判断して両方成功すると、後で値が揃っても既に出した帳票や送金は取り消せません。今回の保存先では、世代の登録と条件付き更新が同じ順序で判定されることを求めます。線形化可能性は呼び出しと完了の間の1点で操作が起きたように見える保証で、単なる「いずれ同じ値になる」とは違います。
DB内の世代検査は、外部メールサービスやファイルへの書き込みへ自動的には及びません。外部先が世代を検査できなければ、結果を一時ファイルへ保存し、世代番号が現在と一致する場合だけ公開先の参照を切り替える方式などを検討します。この世代番号の確認と参照の切り替えも、保存先で原子的に行います。外部APIでは業務単位の冪等キーや結果照会も使い、各仕組みで防げる失敗と、保存先が実際に検査できる条件を確認します。ロックを付けただけで複数システムのexactly-onceを得たとは扱いません。
リースと時計:短くすれば安全になるわけではない
短いリースは障害後の交代を早めやすい一方、通常の一時停止でも誤って引き継ぎが起きます。5秒のリースで6秒停止すれば、健全なワーカーでも更新できません。実際の交代時間には検知・再試行・通信・選出が入り、リース+再試行だけを普遍的な上限にはしません。停止時間と交代回数を観測して決めます。
経過時間は単調時計で測ります。JavaのSystem.nanoTimeは同じJVM内の差分用で、別マシンの値を比べる時刻ではありません。単調時計を採用しても停止しているプロセスを動かせるわけではなく、保存先での世代検査は依然必要です。壁時計の新しさを「現在の持ち主」の証拠にしません。
停止から戻る古い持ち主を試す
- Aをロック取得後、書き込み前に止め、期限切れ後にBで新世代を登録する。
- Bの結果公開後にAを戻し、古い世代が保存先で拒否されることを確認する。
- 世代確認と更新の間、結果反映と応答の間でも停止し、原子性と操作IDによる再送処理を確認する。
- 発行元と保存先の復旧を試し、世代が巻き戻らず、外部副作用の結果も照会できることを確認する。
参考にした一次資料
以下の公式資料は2026年10月27日に確認しました。数値例とシナリオは独自の仮定です。設定例は説明用で、実サービスでの実行や性能測定は行っていません。
- Martin Kleppmann — Distributed locking analysis
- Redis — Distributed locks
- client-go — Leader election guarantees
- Java SE 25 — nanoTime
- Jepsen — Linearizability