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 ของตัวเอง
การ acquire lock
หัวข้อที่มีชื่อว่า “การ acquire lock”SET lock:resource token NX PX 30000:
- NX: ตั้งเฉพาะเมื่อ key ยังไม่มี (acquire แบบ atomic)
- PX 30000: ปล่อยอัตโนมัติหลัง 30 000 ms ผู้ถือที่ crash จึงบล็อกทุกคนตลอดไปไม่ได้
- token: ค่าไม่ซ้ำ (UUID สุ่ม) ที่มีเพียงผู้ถือรู้ ต้องใช้มันเพื่อ release และต่ออายุอย่างปลอดภัย
Release และต่ออายุอย่างปลอดภัย
หัวข้อที่มีชื่อว่า “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 (ด้านล่าง)
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000OK127.0.0.1:6379> PTTL lock:payment(integer) 30000127.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) 0127.0.0.1:6379> SET lock:payment "token-uuid-abc" PX 30000 IFEQ "token-uuid-abc"OK127.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) 1127.0.0.1:6379> GET lock:payment(nil)127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000OK127.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) 0127.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) 1127.0.0.1:6379> GET lock:payment(nil)SET ... NXครั้งที่สองจากผู้ถืออีกคนคืน(nil): lock ถูกจองแล้วDELEX ... IFEQด้วย token ผิดคืน0และปล่อย key ไว้; ด้วย token ถูกคืน1SET ... 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 30000SET 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"lock ใน ioredis และ bug ที่มันต้องรอด
หัวข้อที่มีชื่อว่า “lock ใน ioredis และ bug ที่มันต้องรอด”// @runimport { 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 canconst 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 onesconst 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 2console.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 finallyasync 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: nullrelease with wrong token -> 0 | with A token -> 1 | key now: nullplain 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: falsetoken-checked release: A releases with token check -> 0 C acquires while B is still inside its critical section: false | B still holds it: trueLua release wrong -> 0 | right -> 1fencing: B writes with 2 -> true | A wakes up and writes with 1 -> false | value = B5 workers through withLock: max inside the critical section at once = 1สิ่งที่พิสูจน์ได้:
- พื้นฐาน: ผู้ชนะหนึ่งคน ผู้แพ้ได้
nulltoken ผิด 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 คืน0lock ของ 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(หรือแบ่งงาน) ถ้างานอาจรันนาน
Redlock สำหรับหลาย node
หัวข้อที่มีชื่อว่า “Redlock สำหรับหลาย node”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)
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”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);Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”lock ของ Redis คือ ตั๋วฝากของที่มีตัวจับเวลา: SET NX ส่งตั๋วให้คนแรก TTL เอาตั๋วคืนถ้าเขาหายไป และ token คือเลขบนหางตั๋ว การคืนของด้วยหางตั๋วผิดต้องไม่ทำอะไร แต่ตัวจับเวลาแยกผู้ถือที่ตายออกจากผู้ถือที่ช้าไม่ได้ ดังนั้นถ้างานอันตรายเมื่อทับกัน สิ่งที่ถูกปกป้องต้องตรวจเลข fencing ด้วย
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
SET NX PX บน node เดียว | เรียบง่าย เร็ว คำสั่งเดียว | ถ้า node failover ไป replica ที่ยังไม่ได้รับ key อาจมีผู้ถือสองคน (replication แบบ asynchronous) |
Token + DELEX IFEQ | คำสั่งเดียว เช็กความเป็นเจ้าของแบบ atomic | Redis 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 |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “NX กับ TTL ทำให้ lock ปลอดภัย” acquire ปลอดภัย; release ต้องเช็ก token และผู้ถือที่ช้าต้องมี fencing
- “
DELก็พอ TTL เก็บกวาดให้อยู่แล้ว”DELธรรมดาลบ lock ของผู้ถือคนถัดไปในการรันข้างบน - “Redlock ทำให้ lock ปลอดภัยเสมอ” มันกำจัดช่องโหว่ failover ของ node เดียว แต่ไม่กำจัด pause (Kleppmann กับ antirez)
- “TTL คือเวลาที่งานใช้” มันคือขอบบนสำหรับผู้ถือที่ crash ต้องนานกว่างาน
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- 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 คือบทวิจารณ์ที่ถูกอ้างถึงอย่างกว้างขวาง