背景メモ: https://www.aceround.app/ja/blog/backend-developer-interview-ai
この記事は、システム設計面接でよく問われる「リースが切れたワーカーの遅延書き込みをどう防ぐか」を、最小限の TypeScript 実装に落とし込んだものです。
結論
TTL 付きのロック(リース)だけでは、期限切れした所有者の処理を止められません。
- ワーカー A がリースを取得する
- A が一時停止している間にリースが期限切れになる
- ワーカー B が同じキーを取得する
- A が遅れて書き込む
この順番では、B の正常な書き込みを A が上書きする危険があります。取得のたびに単調増加する fencing token を発行し、リソース側で「最後に受理した token より大きい書き込みだけ」を通せば、遅延した A の操作を拒否できます。
リースと token の責務を分ける
リースは「今、誰が処理を担当できるか」を短時間だけ表します。一方、fencing token は「その担当権が何世代目か」を表す番号です。
- リースストア:
ownerとexpiresAtを管理する - リース取得: 取得成功のたびに token を増やす
- 保護対象リソース: 直近の token を保存し、古い token を拒否する
token を単なる UUID にすると大小比較ができません。Redis の INCR やデータベースのシーケンスのように、全取得で一意かつ単調増加する値が必要です。
実装
ここでは説明を簡単にするため、リースストアと保護対象を同一プロセス内のクラスで表します。実運用では、リースストアの更新を原子的に行い、リソース側の条件付き更新まで同じ不変条件で設計してください。
type Lease = {
owner: string;
token: number;
expiresAt: number;
};
class LeaseStore {
private readonly leases = new Map<string, Lease>();
private nextToken = 0;
acquire(key: string, owner: string, now: number, ttlMs: number): Lease | null {
const current = this.leases.get(key);
if (current && current.expiresAt > now) {
return null;
}
const lease: Lease = {
owner,
token: ++this.nextToken,
expiresAt: now + ttlMs,
};
this.leases.set(key, lease);
return lease;
}
renew(key: string, lease: Lease, now: number, ttlMs: number): boolean {
const current = this.leases.get(key);
if (
!current ||
current.owner !== lease.owner ||
current.token !== lease.token ||
current.expiresAt <= now
) {
return false;
}
current.expiresAt = now + ttlMs;
return true;
}
release(key: string, lease: Lease): boolean {
const current = this.leases.get(key);
if (
!current ||
current.owner !== lease.owner ||
current.token !== lease.token
) {
return false;
}
this.leases.delete(key);
return true;
}
}
class FencedResource {
private lastToken = 0;
private value = "initial";
write(token: number, value: string): void {
if (!Number.isSafeInteger(token) || token <= this.lastToken) {
throw new Error(`stale fencing token: ${token}`);
}
this.lastToken = token;
this.value = value;
}
read(): { value: string; lastToken: number } {
return { value: this.value, lastToken: this.lastToken };
}
}
LeaseStore は期限切れを見て次の所有者に token を渡します。重要なのは、FencedResource.write がリースの有効期限を再確認していない点です。ネットワーク遅延や停止から復帰した A は、リースストア上ではなく、書き込み先の token 比較によって拒否されます。
遅延した所有者を再現する
次のテストでは、A の token=1 が期限切れした後に B が token=2 を取得します。B の書き込み後に A が遅れて到着しても、token=1 は拒否されます。
const store = new LeaseStore();
const resource = new FencedResource();
const leaseA = store.acquire("job", "worker-a", 0, 100);
if (!leaseA) throw new Error("A should acquire the lease");
const leaseB = store.acquire("job", "worker-b", 101, 100);
if (!leaseB) throw new Error("B should acquire after expiry");
if (leaseA.token >= leaseB.token) throw new Error("token must increase");
resource.write(leaseB.token, "written-by-b");
let staleRejected = false;
try {
resource.write(leaseA.token, "late-write-by-a");
} catch (error) {
staleRejected = error instanceof Error && error.message.includes("stale");
}
if (!staleRejected) throw new Error("stale owner must be rejected");
console.log(resource.read());
// { value: "written-by-b", lastToken: 2 }
Bun で実行します。
bun run fencing-token.ts
失敗しやすい実装
リースの有効期限だけを信じる
A がリースを取得した直後に停止すると、A のメモリ上には「自分が所有者」という情報が残ります。再開後にその情報だけで書き込むと、B の結果を壊せます。リースの確認は必要ですが、それだけでは遅延した処理を取り消せません。
token をクライアントが生成する
クライアント時刻や UUID を token にすると、所有者間で順序を比較できません。token の発行は、競合を直列化できる単一のストアに置きます。
token 比較をアプリケーションだけで行う
「読み取り → 比較 → 書き込み」を別々の操作にすると、その間に別ワーカーが書き込めます。データベースなら、条件付き UPDATE ... WHERE fencing_token < :token のように、比較と更新を一つの原子的な操作にしてください。
面接での説明順
- まず「リース切れ後に古い処理が復帰する」競合を具体例で示す
- リース(現在の所有権)と token(所有権の世代)を分ける
- 取得のたびに単調増加する token を発行する
- 書き込み先で token を条件にした原子的更新を行う
- token の保存場所、停止時の再試行、監視する期限を確認する
fencing token はロックを強くする魔法ではありません。リースストアの原子性、書き込み先の条件付き更新、そして期限切れを検知する運用監視がそろって初めて、古い所有者の書き込みを安全に止められます。