ข้ามไปยังเนื้อหา

Distributed Locks

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ # cli/... คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ TTL ของ lock ในสคริปต์คือ 200 ms เพื่อให้ทำซ้ำ bug ตอนหมดอายุได้เร็ว DELEX และ SET ... IFEQ ต้องใช้ Redis 8.4+ บนเซิร์ฟเวอร์รุ่นเก่ากว่าและบน Valkey ให้ใช้ Lua release ด้านล่าง

distributed lock ให้หลาย process ตกลงกันว่ามีเพียงตัวเดียวที่ทำงานกับ resource ร่วมในแต่ละครั้ง บน Redis node เดียว การ acquire คือคำสั่ง atomic คำสั่งเดียว ส่วนที่ยากคือการ release อย่างปลอดภัย และการรอดเมื่อผู้ถือ lock ช้ากว่า lock ของตัวเอง

SET lock:resource token NX PX 30000:

  • NX: ตั้งเฉพาะเมื่อ key ยังไม่มี (acquire แบบ atomic)
  • PX 30000: ปล่อยอัตโนมัติหลัง 30 000 ms ผู้ถือที่ crash จึงบล็อกทุกคนตลอดไปไม่ได้
  • token: ค่าไม่ซ้ำ (UUID สุ่ม) ที่มีเพียงผู้ถือรู้ ต้องใช้มันเพื่อ release และต่ออายุอย่างปลอดภัย

การลบ key ถูกต้องก็ต่อเมื่อมันยังเก็บ token ของคุณ ถ้า TTL ของคุณหมดและ worker อื่น acquire ไปแล้ว DEL ธรรมดาจะลบ lock ของเขา “เช็กค่าแล้วค่อยลบ” คือสองคำสั่ง จึงต้อง atomic บนเซิร์ฟเวอร์ Redis 8.4 เพิ่มสิ่งนั้นเป็นคำสั่งเดียว DELEX key IFEQ value (DELEX ตั้งแต่ 8.4.0) และ SET key value IFEQ current สำหรับต่ออายุ TTL เฉพาะตอนที่ lock ยังเป็นของคุณ ก่อน 8.4 ทำแบบเดียวกันด้วยสคริปต์ Lua (ด้านล่าง)

cli/patterns-locks-1.redis
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000
OK
127.0.0.1:6379> PTTL lock:payment
(integer) 30000
127.0.0.1:6379> SET lock:payment "token-uuid-xyz" NX PX 30000
(nil)
127.0.0.1:6379> DELEX lock:payment IFEQ "wrong-token"
(integer) 0
127.0.0.1:6379> SET lock:payment "token-uuid-abc" PX 30000 IFEQ "token-uuid-abc"
OK
127.0.0.1:6379> SET lock:payment "token-uuid-xyz" PX 30000 IFEQ "wrong-token"
(nil)
127.0.0.1:6379> GET lock:payment
"token-uuid-abc"
127.0.0.1:6379> DELEX lock:payment IFEQ "token-uuid-abc"
(integer) 1
127.0.0.1:6379> GET lock:payment
(nil)
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000
OK
127.0.0.1:6379> EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "wrong-token"
(integer) 0
127.0.0.1:6379> EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "token-uuid-abc"
(integer) 1
127.0.0.1:6379> GET lock:payment
(nil)
  • SET ... NX ครั้งที่สองจากผู้ถืออีกคนคืน (nil): lock ถูกจองแล้ว
  • DELEX ... IFEQ ด้วย token ผิดคืน 0 และปล่อย key ไว้; ด้วย token ถูกคืน 1
  • SET ... PX 30000 IFEQ token ต่ออายุ TTL เฉพาะผู้ถือ คนแปลกหน้าได้ (nil)
  • Lua release ทำการเช็กแล้วลบแบบเดียวกันสำหรับเซิร์ฟเวอร์ก่อน 8.4 รูปแบบ EVAL คือ “วิธีที่ถูก” ที่บทเวอร์ชันก่อนสอน ยังถูกต้องอยู่ เพียงแต่ไม่ใช่ทางเลือกเดียวอีกแล้ว
SET lock:payment "token-uuid-abc" NX PX 30000
PTTL lock:payment
SET lock:payment "token-uuid-xyz" NX PX 30000
SET lock:payment "token-uuid-abc" NX PX 30000
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "token-uuid-abc"
GET lock:payment
SET lock:payment "token-uuid-abc" NX PX 30000
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "wrong-token"
src/patterns-locks.ts
// @run
import { randomUUID } from 'node:crypto';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const KEY = 'b3:lock:payment';
const acquire = async (ttlMs: number) => {
const token = randomUUID();
return (await redis.set(KEY, token, 'PX', ttlMs, 'NX')) === 'OK' ? token : null;
};
// release: delete the key only if it still holds MY token (Redis 8.4+: one command)
const release = async (token: string) => (await redis.call('DELEX', KEY, 'IFEQ', token)) as number;
// the same check as a Lua script, for Redis < 8.4 (and for Valkey)
redis.defineCommand('releaseLua', {
numberOfKeys: 1,
lua: `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end`,
});
declare module 'ioredis' {
interface RedisCommander<Context> {
releaseLua(key: string, token: string): Promise<number>;
}
}
await redis.del(KEY);
// 1) basic: one winner, the loser gets null, a wrong token cannot release, the right token can
const a = await acquire(30_000);
console.log('A acquires:', a !== null, '| B acquires while A holds it:', await acquire(30_000));
console.log('release with wrong token ->', await release('not-my-token'), '| with A token ->', await release(a!), '| key now:', await redis.get(KEY));
// 2) the bug: A's TTL (200 ms) expires while A is still working (400 ms); B takes the lock; A "releases"
async function scenario(safeRelease: boolean) {
await redis.del(KEY);
const tokenA = await acquire(200);
await sleep(300); // A is slow; its lock has expired
const tokenB = await acquire(30_000); // B legitimately acquires
await sleep(100); // A finishes and releases
if (safeRelease) console.log(' A releases with token check ->', await release(tokenA!));
else { await redis.del(KEY); console.log(' A releases with plain DEL -> deleted B\'s lock'); }
const tokenC = await acquire(30_000); // C arrives while B is still working
console.log(' C acquires while B is still inside its critical section:', tokenC !== null, '| B still holds it:', tokenB !== null && (await redis.get(KEY)) === tokenB);
}
console.log('plain DEL release:'); await scenario(false);
console.log('token-checked release:'); await scenario(true);
// 3) Lua release works the same (pre-8.4 path)
await redis.del(KEY);
const t = await acquire(30_000);
console.log('Lua release wrong ->', await redis.releaseLua(KEY, 'nope'), '| right ->', await redis.releaseLua(KEY, t!));
// 4) fencing token: the lock gives out an increasing number; the RESOURCE rejects stale ones
const storage = { maxToken: 0, value: '' };
function write(token: number, value: string) { // what the protected resource must do
if (token < storage.maxToken) return false;
storage.maxToken = token; storage.value = value; return true;
}
await redis.del('b3:lock:seq');
const tokA = await redis.incr('b3:lock:seq'); // A gets 1, then stalls (GC pause, slow network)
const tokB = await redis.incr('b3:lock:seq'); // its lock expired; B gets 2
console.log('fencing: B writes with', tokB, '->', write(tokB, 'B'), '| A wakes up and writes with', tokA, '->', write(tokA, 'A'), '| value =', storage.value);
// 5) a small helper: acquire with retry + jitter, always release in finally
async function withLock<T>(ttlMs: number, work: () => Promise<T>, waitMs = 1000): Promise<T | null> {
const deadline = Date.now() + waitMs;
let token: string | null;
while (!(token = await acquire(ttlMs))) {
if (Date.now() > deadline) return null;
await sleep(20 + Math.random() * 30);
}
try { return await work(); } finally { await release(token); }
}
await redis.del(KEY);
let inside = 0, maxInside = 0;
await Promise.all(Array.from({ length: 5 }, () => withLock(5_000, async () => {
maxInside = Math.max(maxInside, ++inside); await sleep(30); inside--;
})));
console.log('5 workers through withLock: max inside the critical section at once =', maxInside);
await redis.del(KEY, 'b3:lock:seq');
await redis.quit();

ผลลัพธ์จริง (Mac เครื่องนี้):

A acquires: true | B acquires while A holds it: null
release with wrong token -> 0 | with A token -> 1 | key now: null
plain DEL release:
A releases with plain DEL -> deleted B's lock
C acquires while B is still inside its critical section: true | B still holds it: false
token-checked release:
A releases with token check -> 0
C acquires while B is still inside its critical section: false | B still holds it: true
Lua release wrong -> 0 | right -> 1
fencing: B writes with 2 -> true | A wakes up and writes with 1 -> false | value = B
5 workers through withLock: max inside the critical section at once = 1

สิ่งที่พิสูจน์ได้:

  • พื้นฐาน: ผู้ชนะหนึ่งคน ผู้แพ้ได้ null token ผิด release ไม่ได้ token ถูก release ได้
  • bug ตอนหมดอายุ ทั้งสองแบบ lock 200 ms ของ A หมดอายุขณะ A ยังทำงานอยู่ (ผ่านไป 300 ms) B acquire ได้ถูกต้อง แล้ว A ทำงานเสร็จและ release ด้วย DEL ธรรมดา A ลบ lock ของ B C จึง acquire ได้ขณะ B ยังอยู่ใน critical section: มีผู้ถือสองคนพร้อมกัน ด้วยการเช็ก token การ release ของ A คืน 0 lock ของ B อยู่ต่อ และ C ถูกปฏิเสธ
  • Lua release ทำงานเหมือนกัน ใช้กับเซิร์ฟเวอร์ที่ไม่มี DELEX
  • TTL ไม่ใช่การรับประกันความเป็นเอกสิทธิ์ แม้ release ด้วยการเช็ก token งานของ A หลัง lock หมดอายุก็ยังรันคู่กับของ B ได้ lock ที่มี TTL ปลอดภัยต่อผู้ถือที่ crash ไม่ใช่ผู้ถือที่ ช้า (GC pause นาน, เครือข่ายค้าง, stop-the-world) นี่คือหน้าที่ของ fencing token: lock แจกตัวเลขที่เพิ่มขึ้น (INCR) และ resource ที่ถูกปกป้อง ปฏิเสธการเขียนที่มีเลขน้อยกว่าที่เคยเห็น ในการรัน B เขียนด้วย 2 และ A ที่ตื่นขึ้นมาด้วย 1 ถูกปฏิเสธ lock อย่างเดียวทำไม่ได้ resource ต้องมีส่วนร่วม
  • helper ที่ใช้ซ้ำได้ withLock ลองซ้ำพร้อม jitter จนถึง deadline รันงาน และ release ใน finally เสมอ: worker พร้อมกัน 5 ตัวไม่เคยทับกัน (max inside = 1) ตั้ง TTL ให้นานกว่างานอย่างสบาย และต่ออายุด้วย SET ... IFEQ (หรือแบ่งงาน) ถ้างานอาจรันนาน

lock บน node เดียวหายเมื่อ node นั้น failover ไปยัง replica ที่ยังไม่ได้รับ key เพราะการ replicate ของ Redis เป็นแบบ asynchronous เอกสาร Redis อธิบายสถานการณ์นี้ในหน้า distributed locks สำหรับ high availability หน้าเดียวกันอธิบาย อัลกอริทึม Redlock: acquire lock บน master อิสระ N ตัว และถือว่าได้ lock ก็ต่อเมื่อเสียงข้างมาก (N/2+1) ยืนยันภายในขอบเวลา ใน Node.js แพ็กเกจที่ดูแลอยู่คือ redlock (npm redlock ปัจจุบัน 5.0.0-beta.2 ใช้กับ ioredis ได้) แพ็กเกจ ioredis-redlock ที่บางแหล่งกล่าวถึงไม่มีอยู่บน npm (npm view ioredis-redlock คืน 404 ตรวจเมื่อ 2026-09-30)

AI เขียน lock helper และบอกว่า “ปลอดภัยเพราะใช้ NX และ TTL”:

🐛 หา bug ในโค้ดนี้

async function withLock(name: string, work: () => Promise<void>) {
const ok = await redis.set(`lock:${name}`, '1', 'PX', 10_000, 'NX');
if (!ok) throw new Error('busy');
try {
await work(); // might take longer than 10 s
} finally {
await redis.del(`lock:${name}`); // release
}
}
เฉลย

ค่าเป็นค่าคงที่ '1' จึงไม่มีอะไรบอกว่า lock นี้ ของใคร และ DEL ลบทุกอย่างที่อยู่ตรงนั้น ในการรันข้างบน เมื่อผู้ถือช้ากว่า TTL ของตัวเอง DEL ของมันลบ lock ของผู้ถือคนถัดไปและ worker ตัวที่สามเดินเข้ามา: มี worker สองตัวใน critical section เก็บ token ที่ไม่ซ้ำและ release เฉพาะเมื่อมันยังตรงกัน (DELEX key IFEQ token บน 8.4+ หรือสคริปต์ Lua):

const token = randomUUID();
const ok = await redis.set(`lock:${name}`, token, 'PX', 10_000, 'NX');
// ...work...
await redis.call('DELEX', `lock:${name}`, 'IFEQ', token);

lock ของ Redis คือ ตั๋วฝากของที่มีตัวจับเวลา: SET NX ส่งตั๋วให้คนแรก TTL เอาตั๋วคืนถ้าเขาหายไป และ token คือเลขบนหางตั๋ว การคืนของด้วยหางตั๋วผิดต้องไม่ทำอะไร แต่ตัวจับเวลาแยกผู้ถือที่ตายออกจากผู้ถือที่ช้าไม่ได้ ดังนั้นถ้างานอันตรายเมื่อทับกัน สิ่งที่ถูกปกป้องต้องตรวจเลข fencing ด้วย

ตัวเลือกข้อดีข้อเสีย
SET NX PX บน node เดียวเรียบง่าย เร็ว คำสั่งเดียวถ้า node failover ไป replica ที่ยังไม่ได้รับ key อาจมีผู้ถือสองคน (replication แบบ asynchronous)
Token + DELEX IFEQคำสั่งเดียว เช็กความเป็นเจ้าของแบบ atomicRedis 8.4+ เท่านั้น
Token + Lua releaseใช้ได้ทุกเวอร์ชันและบน Valkeyต้องดูแลสคริปต์
Fencing token ที่ resourceปลอดภัยแม้ผู้ถือช้าresource ต้องรองรับ (compare-and-set บน version)
Redlock (N master)ทนต่อ node ล้มเหลวชิ้นส่วนมากขึ้น สมมติฐานเรื่องเวลา และข้อโต้แย้งข้างบน
DB row lock (SELECT ... FOR UPDATE)ผูกกับ transaction ของข้อมูลเองช้ากว่าและใช้ connection ของ DB
  • “NX กับ TTL ทำให้ lock ปลอดภัย” acquire ปลอดภัย; release ต้องเช็ก token และผู้ถือที่ช้าต้องมี fencing
  • “DEL ก็พอ TTL เก็บกวาดให้อยู่แล้ว” DEL ธรรมดาลบ lock ของผู้ถือคนถัดไปในการรันข้างบน
  • “Redlock ทำให้ lock ปลอดภัยเสมอ” มันกำจัดช่องโหว่ failover ของ node เดียว แต่ไม่กำจัด pause (Kleppmann กับ antirez)
  • “TTL คือเวลาที่งานใช้” มันคือขอบบนสำหรับผู้ถือที่ crash ต้องนานกว่างาน
  • release ด้วย DEL ธรรมดา — ลบ lock ของคนอื่นหลัง TTL ของคุณหมด (ทำซ้ำได้)
  • ค่า lock คงที่ — ไม่มีความเป็นเจ้าของให้เช็ก
  • TTL สั้นกว่า critical section — worker ตัวที่สองเข้ามาขณะตัวแรกยังทำงาน
  • SET แล้ว PEXPIRE เป็นสองคำสั่ง — crash ระหว่างสองคำสั่งทิ้ง lock ที่ไม่หมดอายุ ใช้ option ของ SET
  • คาดหวังความปลอดภัยระดับ Redlock จาก node เดียว — หรือคาดหวังให้ lock อย่างเดียวปกป้อง resource ได้โดยไม่มี fencing

Real-world example

เอกสารของ Redis เอง นำเสนอ SET resource_name my_random_value NX PX 30000 พร้อมการลบที่เช็ก token เป็น lock แบบ instance เดียวที่ถูกต้อง และระบุข้อจำกัดเรื่อง failover แล้วจึงนำเสนอ Redlock สำหรับกรณีหลาย node (Distributed Locks with Redis); บทความปี 2016 ของ Kleppmann คือบทวิจารณ์ที่ถูกอ้างถึงอย่างกว้างขวาง

flag NX ใน SET lock:res token NX PX 30000 ทำอะไร?
ทำไมเก็บ token ที่ไม่ซ้ำเป็นค่าของ lock?
lock 200 ms ของผู้ถือหมดอายุขณะทำงาน worker อื่น acquire แล้วตัวแรก release ด้วย DEL ธรรมดา เกิดอะไรขึ้น?
คู่ใดคือ 'ลบเฉพาะถ้าเป็นของฉัน' แบบ atomic บน Redis 8.4+ และบนเซิร์ฟเวอร์รุ่นเก่า?
fencing token ป้องกันอะไรที่การ release ด้วยการเช็ก token ไม่ป้องกัน?
แพ็กเกจ npm ใดใช้ implement Redlock สำหรับ Node.js?
failure mode หลักของ lock แบบ SET NX PX บน node เดียวที่ Redlock แก้คืออะไร?