Distributed Locks
Distributed lock ช่วยให้หลายโปรเซสหรือหลายเซิร์ฟเวอร์ตกลงกันได้ว่ามีเพียงตัวเดียวเท่านั้นที่สามารถเข้าถึง shared resource ในขณะใดขณะหนึ่ง Redis ทำให้เรื่องนี้ง่ายมากด้วยคำสั่ง atomic เพียงคำสั่งเดียว — ไม่ต้องใช้ watch หรือ transaction แยกต่างหาก
การ acquire ล็อก
หัวข้อที่มีชื่อว่า “การ acquire ล็อก”ใช้ 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 30000OK127.0.0.1:6379> TTL lock:payment(integer) 29127.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 0endนี่คือ 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) 1127.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) 0EVAL ครั้งแรกตรวจพบ 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"Redlock สำหรับการตั้งค่าหลายโหนด
หัวข้อที่มีชื่อว่า “Redlock สำหรับการตั้งค่าหลายโหนด”สำหรับ Redis node เดียว SET NX PX เพียงพอแล้ว สำหรับ high availability ที่ครอบคลุม Redis หลายโหนด — เพื่อรับมือกับความล้มเหลวของโหนดโดยไม่ปลดล็อกโดยไม่ได้ตั้งใจ — ให้ใช้ Redlock algorithm: พยายาม acquire ล็อกบน N โหนดอิสระและถือว่าล็อกสำเร็จก็ต่อเมื่อส่วนใหญ่ (N/2+1) ยืนยันการ acquire ภายในระยะเวลาที่กำหนด ไลบรารีอย่าง ioredis-redlock (Node.js) implement algorithm นี้ไว้แล้ว ดังนั้นคุณไม่ต้องจัดการ quorum logic เอง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
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