第17章:止め方② ロック/原子的操作(Atomic)で“同時を捌く”🔒⚡
17.1 この章のゴール🎯✨
- 「ロック🔒」と「原子的操作⚡(Atomic)」が何のためにあるかを説明できる🙂
- 同じIdempotency-Keyが“同時に”飛んできても、処理が二重実行されない設計がわかる🔁
processing / succeeded / failedの状態遷移で、先着1リクエストだけ実行できる✌️

17.2 まず結論(超大事)📌💡
冪等性の“最難関”はここ👇 **「同じキーのリクエストが同時に2つ来た」**ときに、両方が処理を進めちゃう事故😵💫💥
だから、実務の王道はこれ👇
-
先着1名だけ「processing」を取る(Atomicに)⚡
-
先着じゃない人は
- すでに
succeededなら 保存済みレスポンスを返す📦 - まだ
processingなら “処理中”として返す(例:202)⏳ failedなら 失敗を返す/再試行可にする(方針次第)🧯
- すでに
17.3 “同時”ってどういうこと?(Node/TSあるある)🧵😇
「JavaScriptって1スレッドでしょ? 同時って起きるの?」って思うよね🙂 でも、awaitをまたぐとこうなる👇
- リクエストAが途中で
await payment()で止まる - その間に、リクエストBが同じキーで入ってくる
- AもBも「まだレコード無いじゃん!」って判断しちゃうと… 二重決済💳💳 / 二重注文🧾🧾 が起きる😱
この“同時”をさばくのが ロック🔒 と Atomic⚡ だよ〜!
17.4 ロック🔒とAtomic⚡のざっくり定義
ロック🔒
- 「今このデータ触ってるの私だから、他の人はちょい待ってね🙅♀️」ってする仕組み
- 例:Mutex(ミューテックス)、DBの行ロック、Redisロック など
Atomic(原子的操作)⚡
-
「確認して→更新する」を1回の動作として絶対に割り込まれないようにすること✨
-
例:
- DBで
UPDATE ... WHERE status='new'みたいに 条件付き更新で先着を決める - Redisで
SET key value NX PX ...みたいに “無ければ作る”を一撃でやる(NX)(Redis)
- DBで
17.5 今日の周辺知識(なるべく新しめ)🆕📚
-
Node.js は v24がActive LTS、v25がCurrent という位置づけ(2026-01頃の情報)(Node.js)
-
TypeScript は公式リリースノートで 5.9 の項目が公開され、
tsc --initのデフォルトなどが更新されてるよ🛠️(typescriptlang.org)- 公式GitHub Releasesには 5.8.x 系の安定版タグも確認できるよ📦(GitHub)
※章の主役は「同時実行をどう安全に止めるか」なので、フレームワーク差より“設計”を優先していくよ🙂🔒⚡
17.6 状態遷移を決めよう(processing/succeeded/failed)🔁📋

まずは“脳内ルール”を固定するのが勝ち🏆✨ おすすめの状態はこの3つ👇
processing:今まさに処理中⏳succeeded:成功してレスポンス保存済み✅📦failed:失敗した(方針により保存)❌🧯
状態遷移(王道パターン)🧠✨
| いまの状態 | 同じキーが来たら | 返すもの |
|---|---|---|
| レコードなし | 先着が processing を作る | 先着:処理続行🚀 |
| processing | 二人目以降は待ち | 202(処理中)や、短いポーリング案内⏳ |
| succeeded | 保存済みレスポンスを返す | 200 + 同じ結果📦 |
| failed | 方針次第(失敗も固定で返す/再試行) | 4xx/5xx など🧯 |

17.7 実装パターン3段階(小→大)🪜✨
同時実行対策はスケールに合わせて変わるよ🙂
① 1プロセス内だけ守れればOK:SingleFlight(同一キーの同時実行を1回にまとめる)🛫🔁
- 同じキーが同時に来たら、最初のPromiseをみんなで共有しちゃう作戦✨
- 単体のNodeプロセスで動くミニアプリなら超便利!
type Key = string;
export class SingleFlight<T> {
private inFlight = new Map<Key, Promise<T>>();
async do(key: Key, fn: () => Promise<T>): Promise<T> {
const existing = this.inFlight.get(key);
if (existing) return existing;
const p = (async () => {
try {
return await fn();
} finally {
// 成功でも失敗でも必ず消す(超大事)🧹
this.inFlight.delete(key);
}
})();
this.inFlight.set(key, p);
return p;
}
}
✅いいところ
- 実装が軽い✨
- “同時に20回”来ても処理は1回にできる🔁
⚠️注意
- サーバーが複数台になると、各台で別々に走っちゃう😵💫 → 複数台なら②③へ!
② 1プロセス内で“排他”したい:Mutex(async-mutex等)🔒🧵
「同じ注文IDを触る処理は、必ず1個ずつにしたい!」みたいな時に便利🙂
async-mutex は “asyncの作業をmutexで守る” 目的のライブラリだよ(GitHub)
import { Mutex } from "async-mutex";
const locks = new Map<string, Mutex>();
function getMutex(name: string): Mutex {
const existing = locks.get(name);
if (existing) return existing;
const m = new Mutex();
locks.set(name, m);
return m;
}
export async function withOrderLock<T>(orderId: string, fn: () => Promise<T>): Promise<T> {
const mutex = getMutex(orderId);
return mutex.runExclusive(async () => {
return await fn();
});
}
✅コツ
runExclusiveの中は 短く✂️(ロック時間が長いと渋滞🚗🚗🚗)finally相当で必ず解放される形にする(上の例はOK)🙆♀️
⚠️注意
- これも複数台だと効かない(メモリが別)😇
③ 複数台でも守りたい:DBのAtomic更新 or Redisロック🌍🔒⚡
ここからが“実務感”✨ 同時に来た2つのリクエストのうち、先着1名だけが通る仕組みを、DB/Redisの力で作るよ💪
③-A DBで先着を決める(条件付きUPDATEが強い)🗄️⚡
考え方はこれ👇
status='new'の時だけprocessingに変更する- 変更できた人が先着🏁
- 変更できなかった人は「誰かが先に取ったな」とわかる🙂
(SQLの雰囲気)
UPDATE idempotency_requests
SET status = 'processing', locked_at = NOW()
WHERE key = $1 AND status = 'new';
-- 更新件数が 1 なら先着、0 なら負け!
さらにガチにやるなら、トランザクション+行ロックもあるよ🔒
SELECT ... FOR UPDATE は「選んだ行を更新されないようにロック」できる(PostgreSQLの公式ドキュメント)(PostgreSQL)
③-B Redisでロック(SET NX PX)🧰⚡
Redisなら「キーが無い時だけ作る」NXが使えるよ✨
SET key value NX は “無いなら取る” を一撃でできる!(Redis)
例(雰囲気)
// ロック取得(成功したら true みたいな感じ)
SET lockKey token NX PX 10000
⚠️超大事:分散ロックは“難しい世界”😵💫
- Redis公式は Redlock を紹介してる(Redis)
- 一方で「安全性に注意が必要」という有名な批判もある(fencing token等の話)(martin.kleppmann.com)
なので入門の結論はこれ👇
- **まずDBのAtomic更新(③-A)**が扱いやすくておすすめ🗄️⚡
- Redisロックは、仕組みと落とし穴を理解してから使う🔒🧠
17.8 ミニ注文APIに落とす(“先着1名だけ実行”をやってみる)🍰🧾💳
ここでは「同じIdempotency-Keyの注文確定」をイメージするよ🙂
データ(保存したいもの)📦
key(例:userId + idempotencyKey)🔑status(processing/succeeded/failed)🔁response(成功時のレスポンス)📦error(失敗も固定で返すなら)🧯createdAt / updatedAt / lockedAt(タイムアウト救出に使う)⏱️
ざっくり処理フロー(擬似)🧠✨
- 受け取ったキーでレコードを見る👀
- なければ processing を取りに行く(Atomicで!)⚡
- 取れた人だけ、決済っぽい処理を実行💳
- 成功したら
succeededにしてレスポンス保存📦✅ - 失敗したら
failed(保存するかは方針)🧯
17.9 まずは“メモリ版”で練習(状態+SingleFlight)🧪✨
「processing を取る」練習として、メモリで雰囲気を作るよ🙂 (本番はDBに置き換えるイメージ🗄️)
type Status = "processing" | "succeeded" | "failed";
type OrderResult = {
orderId: string;
paid: boolean;
};
type IdempotencyRecord =
| { status: "processing"; startedAt: number }
| { status: "succeeded"; finishedAt: number; result: OrderResult }
| { status: "failed"; finishedAt: number; errorCode: string; message: string };
const store = new Map<string, IdempotencyRecord>();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function confirmPaymentLike(key: string): Promise<OrderResult> {
const existing = store.get(key);
if (existing?.status === "succeeded") {
return existing.result; // ✅同じ結果を返す
}
if (existing?.status === "processing") {
// ⏳本来は 202 を返して「あとで同じキーで再試行してね」でもOK
// ここでは簡単に「少し待ってからもう一回見る」方式にしてるよ🙂
await sleep(50);
const again = store.get(key);
if (again?.status === "succeeded") return again.result;
throw new Error("Still processing"); // 教材用
}
// ここに来た人が「processing を取る」🏁
store.set(key, { status: "processing", startedAt: Date.now() });
try {
// 💳決済っぽい処理(時間がかかる想定)
await sleep(200);
const result: OrderResult = { orderId: "order_" + Math.random().toString(16).slice(2), paid: true };
store.set(key, { status: "succeeded", finishedAt: Date.now(), result });
return result;
} catch (e) {
store.set(key, { status: "failed", finishedAt: Date.now(), errorCode: "PAYMENT_FAILED", message: "failed" });
throw e;
}
}
⚠️この実装、同時に2つ入ると「processing取るところ」が危ないことがあるよね😵💫 だから本番はここを Atomicに(DB/Redis)する!
17.10 “Atomicでprocessingを取る”ってこういう感覚(DB版のイメージ)🗄️⚡
ここがこの章の核心🧠✨ (SQLは雰囲気、クエリビルダーでも同じ考え方だよ🙂)
方式A:条件付きUPDATEで先着決定🏁
status='new'の時だけprocessingにできる- 更新件数が 1 → 先着
- 更新件数が 0 → 誰かが先に取った
方式B:行ロック(SELECT … FOR UPDATE)で“読んだ瞬間にロック”🔒
- トランザクション内で行をロックしてから判断できる
- PostgreSQLでは
SELECT ... FOR UPDATEが行レベルロックとして説明されてるよ(PostgreSQL)
17.11 演習📝🌸(手を動かすやつ!)
演習1:状態遷移を書こう✍️🔁
次のケースで、返すHTTPステータスと次の状態を埋めてみてね🙂
- レコードなし → 先着が処理開始
processingのときに同じキーが来たsucceededのときに同じキーが来たfailedのときに同じキーが来た(保存する派/しない派の2通りで)
演習2:同時に10回叩いても“結果が1つ”になるテスト🧪🔁
Vitestは 4.0 が出てるよ(2025-10頃)(vitest.dev)
import { describe, it, expect } from "vitest";
import { confirmPaymentLike } from "./confirmPaymentLike";
describe("idempotency concurrency", () => {
it("same key concurrently should produce single success result", async () => {
const key = "user1:idem-123";
// 同時に10回!😆
const results = await Promise.all(
Array.from({ length: 10 }, async () => {
try {
return await confirmPaymentLike(key);
} catch {
return null; // 教材用:processing中エラーを無視
}
})
);
const succeeded = results.filter((x) => x !== null);
// succeededが複数あっても、最終的に同じ結果になってる設計が理想💡
// (ここでは簡略化してるので、次の章以降で改善していこう🙂)
expect(succeeded.length).toBeGreaterThan(0);
});
});
17.12 AI活用🤖✨(この章で効く使い方)
① 状態遷移表を作らせる📋
- 「processing/succeeded/failedの状態遷移を、HTTPステータス込みで表にして」
- その後に自分でチェック✅(“processing中は202にする?”など判断練習!)
② DBのAtomic更新案を複数出させる🗄️⚡
- 「条件付きUPDATEで先着を決めるSQL案を3つ」
- 「SELECT FOR UPDATE を使う版も」
- ただし、トランザクションを長くしない注意点もセットで聞く👂🔒 (PostgreSQL)
③ Redisロックの注意点をまとめさせる🔒🌍
- 「SET NX PXを使うロックのやり方」
- 「なぜ分散ロックは難しいの?」(fencing token も含めて)(Redis)
17.13 この章のチェックリスト✅🔒⚡
- 同じキーが同時に2本来ても、先着1本だけが処理する?🏁
-
processingの間、後続は 202/待機/ポーリングなどの方針がある?⏳ - 成功時は 同じレスポンスを返せる(保存 or 再計算しない)?📦
- ロックやトランザクションは 短くできてる?🚗💨
- ロックが“取りっぱなし”になる事故に備えて TTL/救出策がある?⏱️🧯
- テストで「2回」「10回」「同時」をやってる?🧪🔁