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

Distributed Locks

Distributed lock ช่วยให้หลายโปรเซสหรือหลายเซิร์ฟเวอร์ตกลงกันได้ว่ามีเพียงตัวเดียวเท่านั้นที่สามารถเข้าถึง shared resource ในขณะใดขณะหนึ่ง Redis ทำให้เรื่องนี้ง่ายมากด้วยคำสั่ง atomic เพียงคำสั่งเดียว — ไม่ต้องใช้ watch หรือ transaction แยกต่างหาก

ใช้ SET lock:resource token NX PX 30000 เพื่อ acquire ล็อก:

  • NX — กำหนดค่า key ก็ต่อเมื่อ key นั้นยังไม่มีอยู่ เท่านั้น (atomic acquire)
  • PX 30000 — ปลดล็อกอัตโนมัติหลังจาก 30,000 ms แม้ว่าผู้ถือล็อกจะ crash ไป
  • token — ค่าเฉพาะตัว (UUID) ที่มีเพียงผู้ถือล็อกเท่านั้นที่รู้ ซึ่งจะต้องใช้เพื่อปลดล็อกอย่างปลอดภัย
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000
OK
127.0.0.1:6379> TTL lock:payment
(integer) 29
127.0.0.1:6379> SET lock:payment "token-uuid-xyz" NX PX 30000
(nil)

การเรียกครั้งแรกสำเร็จและคืนค่า OK ค่า TTL ยืนยันว่ามีการตั้งค่า auto-expiry ไว้แล้ว การพยายามครั้งที่สองจากผู้ถือล็อกคนอื่นจะคืนค่า (nil) เนื่องจาก key มีอยู่แล้ว NX จึงป้องกันการเขียนทับ

SET lock:payment "token-uuid-abc" NX PX 30000
TTL lock:payment
SET lock:payment "token-uuid-xyz" NX PX 30000

วิธีที่ผิด: DEL lock:payment — คำสั่งนี้ลบ key โดยไม่มีเงื่อนไข หาก TTL ของล็อกหมดอายุไปแล้วและ worker อื่นได้ acquire ล็อกนั้นแล้ว คุณก็เพิ่งลบล็อกของพวกเขาทิ้งไป ซึ่งก่อให้เกิด race condition

วิธีที่ถูกต้อง: ลบเฉพาะเมื่อ token ที่เก็บไว้ตรงกับของคุณเท่านั้น เนื่องจากการใช้ GET ธรรมดาตามด้วย DEL แบบมีเงื่อนไขคือสองคำสั่ง (ไม่ใช่ atomic) จึงต้องใช้ Lua script — Redis รัน Lua script แบบ atomic:

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

นี่คือ logic เดียวกันที่รวมไว้ในคำสั่ง EVAL:

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)
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

EVAL ครั้งแรกตรวจพบ token ที่ถูกต้อง ลบ key และคืนค่า 1 หลังจากลบแล้ว GET คืนค่า (nil) EVAL ครั้งที่สองที่ใช้ token ผิดจะคืนค่า 0 — key ยังคงอยู่ไม่เปลี่ยนแปลง

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"

สำหรับ Redis node เดียว SET NX PX เพียงพอแล้ว สำหรับ high availability ที่ครอบคลุม Redis หลายโหนด — เพื่อรับมือกับความล้มเหลวของโหนดโดยไม่ปลดล็อกโดยไม่ได้ตั้งใจ — ให้ใช้ Redlock algorithm: พยายาม acquire ล็อกบน N โหนดอิสระและถือว่าล็อกสำเร็จก็ต่อเมื่อส่วนใหญ่ (N/2+1) ยืนยันการ acquire ภายในระยะเวลาที่กำหนด ไลบรารีอย่าง ioredis-redlock (Node.js) implement algorithm นี้ไว้แล้ว ดังนั้นคุณไม่ต้องจัดการ quorum logic เอง

ตัวเลือกBenefitCost
SET NX PX บน Redis node เดียวง่าย เร็ว ใช้คำสั่งเดียวถ้า node นั้น failover ไปยัง replica ที่ยังไม่ sync อาจได้ล็อกซ้ำสองตัว
Redlock (หลาย node)ทนต่อความล้มเหลวของ node เดี่ยวได้ซับซ้อนกว่า ต้องพึ่ง clock ที่ใกล้เคียงกันระหว่าง node และ quorum logic
DB-level lock (เช่น SELECT ... FOR UPDATE)ผูกกับ transaction ของข้อมูลโดยตรงช้ากว่า Redis มาก และเพิ่มภาระให้ connection pool ของ DB
  • ปลดล็อกด้วย DEL ตรง ๆ โดยไม่เช็ค token — ถ้า TTL หมดอายุไปแล้วและ worker อื่น acquire ล็อกไปแล้ว การ DEL แบบไม่มีเงื่อนไขจะลบล็อกของคนอื่นทิ้ง
  • ตั้ง TTL สั้นเกินไปเทียบกับเวลาที่ critical section ต้องใช้จริง — ถ้างานยังไม่เสร็จแต่ล็อกหมดอายุ worker อีกตัวจะ acquire ล็อกซ้อนเข้ามาทำงานเดิม
  • ใช้ SET NX PX แบบ single-node แล้วคาดหวังความปลอดภัยระดับเดียวกับ Redlock — สำหรับงานที่ต้องการ high availability จริง (เช่น payment) ต้องใช้ Redlock หรือกลไก quorum อื่น ไม่ใช่ node เดียว

💡 ตัวอย่างจากของจริง

Sidekiq — ใช้ pattern แบบ SET NX เพื่อกันไม่ให้ job เดียวกันถูกประมวลผลซ้ำโดย worker หลายตัวพร้อมกัน

Stripe — ใช้ lock ที่มี token ตรวจสอบความเป็นเจ้าของแบบเดียวกับบทนี้ เพื่อรับประกันว่า operation ที่เกี่ยวกับเงินจะไม่ทำงานซ้ำจากหลาย process

flag NX ใน `SET lock:res token NX PX 30000` ทำหน้าที่อะไร?
ทำไมจึงต้องใช้ token เฉพาะตัวเมื่อ acquire ล็อก?
ทำไมจึงต้องใช้ Lua script ในการปลดล็อกแทนที่จะใช้ DEL ธรรมดา?
Redlock ใช้สำหรับอะไร?