Claude Media
Claude Codeで分散ジョブの二重実行を防ぐ — Redisのリースとフェンシングトークン

Claude Codeで分散ジョブの二重実行を防ぐ — Redisのリースとフェンシングトークン

SET NX PXのリースだけでは、期限切れ後も動き続けるジョブの二重書き込みを防げません。フェンシングトークンを足し、期限切れを再現するテストまでClaude Codeに書かせる手順です。

複数のワーカーが同じジョブを拾う構成では、RedisにSET key value NX PXでロックを置く実装が定番です。ただしこのロックは期限で自動解放されるため、処理が期限より長引くと、ロックを失ったワーカーがそのまま書き込みを続けます。Claude Codeに実装を任せるときは、取得と解放のコードに加えて、この期限切れ中の二重実行を止める仕組みと、それを再現するテストまで依頼します。

ロックの期限が切れても処理は止まらない

Redisのロックは、キーに有効期限を付けて作ります。期限を付けるのは、クライアントが落ちてもいずれ解放されるようにするためです。期限は同時に、ロックを持つクライアントが作業を終えるべき時間の枠でもあります。

問題は、期限が切れても、ロックを持っていたプロセスは何も知らされないことです。次の流れで二重実行が起きます。

  1. ワーカーAがロックを取り、処理を始める
  2. GCの停止やネットワーク遅延でAの処理が期限を超えて止まる
  3. キーが期限切れで消え、ワーカーBがロックを取って処理を始める
  4. Aが復帰し、自分がまだロックを持っていると信じて書き込む

Redisのパターン解説には、クライアントがロック有効時間より長く操作で詰まり、その後にロックを解放しようとする状況が例として載っています。解放時に他人のロックを消さない対策は後述しますが、書き込みそのものが二重になる問題は、解放側の工夫では解決しません。

対策

二重実行を止める三つの層

  • 取得: SET NX PX

    キーが無いときだけ作成し、同時に期限を付けます。1コマンドなので、取得と期限設定の間に落ちる隙間がありません。

  • 解放: 値の照合

    自分が置いたランダム値のときだけ消します。期限切れ後に他人のロックを消す事故を防ぎます。

  • 書き込み: フェンシングトークン

    ロックを取るたびに増える番号を書き込み先へ渡し、古い番号を拒否します。期限切れ後の書き込みを最後に止める層です。

取得と解放はSET NX PXと値の照合で書く

取得のコマンドは、パターン解説の例では次の形です。

SET resource_name my_random_value NX PX 30000

NXは「キーが存在しないときだけ設定する」、PXは「ミリ秒単位の有効期限」です。値は全クライアント、全ロック要求で一意にします。解説は20バイトを/dev/urandomから取る例を挙げ、タイムスタンプとクライアントIDの連結は「安全性は下がるが、多くの環境では足りる」としています。実装ではcrypto.randomUUID()のような一意な値で十分です。

解放は、キーの値が自分の値と一致するときだけ消します。Redis 8.4以降ならDELEX key IFEQ my_random_valueの1コマンドです。それ以前のバージョンでは、次のLuaスクリプトを使います。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

単純なDELは使いません。期限切れ後に他のクライアントが取ったロックを、遅れて戻ったAが消してしまうからです。

フェンシングトークンを同じ操作で発行する

パターン解説は、一貫性と正しさが気になるならフェンシングトークンを実装するよう求めています。処理に時間がかかるプロセスで特に重要で、あらゆる分散ロックに当てはまるという説明です。ロックを延長する方法もあるが、プロセスが生きている間ロックが保たれると仮定してはいけない、という注意も同じ箇所にあります。

フェンシングトークンは、ロックを取るたびに単調に増える番号です。書き込み先(DBやAPI)が、受け取った番号を覚えている最大値と比べ、古ければ拒否します。AがトークンN、BがN+1を持っていれば、復帰したAの書き込みは番号が小さいので弾かれます。

RedisではINCRで番号を作れます。キーが無いときは0から始まって1が返るため、初期化は不要です。ただしロック取得とINCRを別々に打つと、間で落ちたワーカーがロックだけ持ってトークンが無い状態になります。1本のLuaスクリプトにまとめます。

import Redis from "ioredis";
import { randomUUID } from "node:crypto";
 
const ACQUIRE = `
if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
  return redis.call("INCR", KEYS[2])
end
return 0
`;
 
const RELEASE = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0
`;
 
export type Lease = { owner: string; token: number };
 
export async function acquire(
  redis: Redis, name: string, ttlMs: number,
): Promise<Lease | null> {
  const owner = randomUUID();
  const token = Number(await redis.eval(
    ACQUIRE, 2, `lock:${name}`, `lock:${name}:fence`, owner, ttlMs,
  ));
  return token > 0 ? { owner, token } : null;
}
 
export async function release(redis: Redis, name: string, lease: Lease) {
  await redis.eval(RELEASE, 1, `lock:${name}`, lease.owner);
}

トークン用のキー(:fence)には期限を付けません。期限を付けると、消えた後に番号が1へ戻り、古いトークンのほうが大きく見えてしまいます。RedisのフェイルオーバーでINCRの結果が失われる可能性は、ロックのキーで説明されている非同期レプリケーションの問題と同じ構造です。厳密さが要る書き込みでは、番号の発行をDBのシーケンスなど書き込み先と同じ場所に置く選択肢もあります。

書き込み先は、トークンの比較を条件に含めます。

UPDATE jobs
SET result = $1, fence = $2
WHERE id = $3 AND fence < $2;
-- 更新行数が0なら、古いトークンの書き込みとして破棄する

行数が0だったときは、ジョブ側で例外にして結果を捨てます。楽観ロックのversionカラムと同じ発想で、詳しい書き方はClaude Codeの楽観ロック実装にあります。

期限切れ中の処理を再現するテストを書かせる

ロックの実装は、正常系のテストでは壊れていることに気づけません。期限を短くして、1本目の処理を期限より長く止めるテストを書かせます。Claude Codeのワークフロー集は、テストを頼むときは検証したい挙動を具体的に指定するよう勧めています。次のように伝えます。

lock.ts の acquire/release に対して、期限切れ中の二重実行を再現する
テストを書いて。ワーカーAが TTL 200ms のロックを取り、500ms 停止する。
その間にワーカーBがロックを取って書き込みを終える。
Aが復帰して書き込んだとき、フェンシングトークンの比較で拒否される
ことを検証して。Redisは docker で立てた実物を使い、モックにしない。

期待されるテストの形は次のようなものです(例示であり、出力は環境で変わります)。

test("期限切れ後の古いトークンの書き込みは拒否される", async () => {
  const a = await acquire(redis, "job-1", 200);
  expect(a).not.toBeNull();
 
  await sleep(500); // Aが停止している間に期限が切れる
 
  const b = await acquire(redis, "job-1", 5000);
  expect(b!.token).toBeGreaterThan(a!.token);
  expect(await store.write("job-1", "B", b!.token)).toBe(true);
 
  // Aが復帰して書き込む
  expect(await store.write("job-1", "A", a!.token)).toBe(false);
  expect(await store.read("job-1")).toBe("B");
});

あわせて、フェンシングを外した版のテストも書かせます。store.writeのトークン比較を外すとAの書き込みが通ってreadが"A"になり、テストが落ちることを確認します。ガードを外すと落ちるテストだけが、ガードが効いていることの証拠になるためです。

テストを書かせたあとは、Claude Codeにnpm testを実行させ、落ちたときの出力を読ませて直させます。ここで見てほしいのは、期限を短くしたテストが毎回同じ結果になるかです。sleepの長さを期限の2倍以上にして、時間のゆらぎで結果が変わらないようにします。

長い処理はリースを延長し、失ったら中断する

リースを延長する方法もあります。処理が小さな段階に分かれるなら、ロックの有効時間を短く取り、途中で延長する仕組みを足せます。延長も解放と同じく、値が自分のものであるときだけTTLを更新するスクリプトで行います。

延長の失敗を検知したら、ワーカーは処理を中断します。延長できなかったのに続けるのは、期限切れ中の処理を自分で作るのと同じです。ただし、延長が成功していても保証にはなりません。ロックが保たれていると仮定してはいけない、という注意のとおり、延長は二重実行の確率を下げるだけで、最後の砦はフェンシングです。

もう一つ、RedisのTTLは単調時計を使いません。サーバーの時刻が手動やNTPのずれで動くと、ロックが複数のプロセスに取られうるという注意です。管理者が手動で時刻を変えない運用とNTPの設定で軽減できますが、問題が起きる可能性は残ります。フェンシングは、時計の問題にも効く層です。

どこまでの正しさが要るかで構成を選ぶ

二重実行の害が小さいなら、単一のRedisにSET NX PXと値の照合を置くだけでも足ります。たまに競合が起きても許容できる用途なら、単一インスタンスの実装が現実的な解だと解説にもあります。

状況構成
二重実行しても冪等で害がない構成SET NX PX + 値の照合
結果の二重書き込みが困る構成上に加えてフェンシングトークン
Redisの単一障害点が許容できない構成Redlock(5台程度の独立したマスター)
同一ホスト内の多重起動だけ防ぎたい構成Redisを使わずflock

Redlockは、独立したN台のRedisマスターの過半数でロックを取るRedisのパターン解説が示すアルゴリズムです。同じ解説にこの手法の分析(Martin Kleppmannの批判とantirezの反論)への案内があり、一貫性が気になる場合はフェンシングを入れるよう書かれています。台数を増やしても、フェンシングが不要になるわけではありません。

同一サーバー上のcronの重なりだけなら、Redisを使わずflockで多重起動を防ぐ方法が簡単です。ロックは期限ではなく、プロセスの終了で解放されます。

ジョブキューを使っている場合は、キュー側にも重複排除の仕組みがあります。BullMQでのリトライと失敗ジョブの検証はBullMQワーカーの実装で扱っています。

キャッシュの再計算を1本に絞りたい場合は、ロックの目的が違うため、期限のゆらぎも含めたキャッシュスタンピード対策が向いています。

Claude Codeに任せる範囲を設定で絞る

ロックのテストでは、Claude CodeがRedisのコマンドを直接叩くことがあります。CLAUDE.mdに書いた指示は、モデルが読んで判断するもので、守られる保証はありません。ベストプラクティスのページも、指示を強調しても守られない行は、決定論的に動くhooksへ移すべき兆候だとしています。

CLAUDE.mdには、実装の規約だけを書きます。

## 分散ロック
- ロックの取得・解放・延長は lock.ts の関数を使い、redis.set を直接呼ばない
- 書き込み先は fence 列でトークンを比較する。比較を外す変更は入れない
- テストは docker のRedisで実行し、TTLは200ms程度に縮める

破壊的なコマンドは、権限のdenyルールで止めます。権限の仕様では、ルールはdeny、ask、allowの順に評価され、denyに一致した呼び出しは許可ルールがあっても通りません。

{
  "permissions": {
    "deny": [
      "Bash(redis-cli FLUSHALL *)",
      "Bash(redis-cli FLUSHDB *)"
    ]
  }
}

テスト用のRedisを使い捨てにしておけば、本番のロックキーを誤って消す事故そのものを避けられます。

まとめ

SET NX PXのロックは、取得と自動解放を安く作れる一方、期限切れ後に動き続ける処理を止められません。止める層はフェンシングトークンで、取得と同じLuaスクリプトでINCRし、書き込み先が番号を比較します。Claude Codeには、期限を縮めて処理を止めるテストと、ガードを外すと落ちる対のテストを書かせると、実装の穴が見えます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. redis.io/docs/latest/develop/clients/patterns/distributed-locks
  2. redis.io/docs/latest/commands/set
  3. redis.io/docs/latest/commands/incr
  4. code.claude.com/docs/en/common-workflows
  5. code.claude.com/docs/en/best-practices
  6. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn