Transactions
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ ยอดรวม “attempts” ในผลลัพธ์ของสคริปต์ขึ้นกับจังหวะเวลา และจะต่างในเครื่องของคุณ
Redis transaction คืออะไร?
หัวข้อที่มีชื่อว่า “Redis transaction คืออะไร?”transaction คือลำดับคำสั่งที่ถูก เข้าคิว แล้วรันเป็นบล็อกเดียวที่ไม่ถูกขัด: ไม่มีคำสั่งของ client อื่นรันแทรกระหว่างคำสั่งเหล่านั้นเมื่อ EXEC ทำงาน เปิดด้วย MULTI สั่งคำสั่ง (แต่ละคำสั่งตอบ QUEUED) แล้วรันด้วย EXEC หรือทิ้งด้วย DISCARD พฤติกรรมทั้งหมดอยู่ในเอกสาร Transactions
127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET balance 100QUEUED127.0.0.1:6379> DECRBY balance 30QUEUED127.0.0.1:6379> GET balanceQUEUED127.0.0.1:6379> EXEC1) OK2) (integer) 703) "70"127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET temp "draft"QUEUED127.0.0.1:6379> DISCARDOK127.0.0.1:6379> EXISTS temp(integer) 0127.0.0.1:6379> SET mystr helloOK127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k1 aQUEUED127.0.0.1:6379> INCR mystrQUEUED127.0.0.1:6379> SET k2 bQUEUED127.0.0.1:6379> EXEC1) OK2) (error) ERR value is not an integer or out of range3) OK127.0.0.1:6379> GET k1"a"127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k3 cQUEUED127.0.0.1:6379> SET k4(error) ERR wrong number of arguments for 'set' command127.0.0.1:6379> SET k5 eQUEUED127.0.0.1:6379> EXEC(error) EXECABORT Transaction discarded because of previous errors.127.0.0.1:6379> GET k3(nil)127.0.0.1:6379> EXEC(error) ERR EXEC without MULTI127.0.0.1:6379> MULTIOK127.0.0.1:6379> WATCH stock(error) ERR WATCH inside MULTI is not allowed127.0.0.1:6379> DISCARDOK127.0.0.1:6379> SET stock 5OK127.0.0.1:6379> WATCH stockOK127.0.0.1:6379> MULTIOK127.0.0.1:6379> DECRBY stock 1QUEUED127.0.0.1:6379> EXEC1) (integer) 4127.0.0.1:6379> UNWATCHOKsession นี้แสดงอะไร:
- ภายใน
MULTIทุกคำสั่งตอบQUEUEDผลจริงมาเป็นลิสต์เดียวจากEXEC - error ตอนรันไม่ rollback
INCR mystrล้มเหลว (ค่าไม่ใช่ตัวเลข) แต่k1และk2ก็ถูกเขียนอยู่ดี: Redis ไม่มี rollback โดยการออกแบบ ตรวจทุกสมาชิกของผลลัพธ์EXEC - error ตอน queue ยกเลิกทุกอย่าง
SETที่ขาด argument ถูกปฏิเสธทันที และEXECตอบEXECABORT: ไม่มีอะไรรัน (GET k3เป็น(nil)ทั้งที่SET k3 cถูกเข้าคิวไว้) นี่เป็นกรณีเดียวที่ทำตัวเหมือนการยกเลิกแบบ all-or-nothing EXECโดยไม่มีMULTIเป็น error และออกWATCHภายในMULTIไม่ได้ ต้องออกก่อน
MULTI
SET balance 100
DECRBY balance 30
GET balance
EXECMULTI
SET temp "draft"
DISCARD
EXISTS tempOptimistic locking ด้วย WATCH
หัวข้อที่มีชื่อว่า “Optimistic locking ด้วย WATCH”WATCH key ทำเครื่องหมาย key ที่ต้องเฝ้าดู ถ้า key ที่เฝ้าถูกแก้โดยใครก็ตาม (client อื่น หรือ connection ของคุณเองนอก transaction) ก่อน EXEC ทั้ง transaction จะถูกข้ามและ EXEC คืน (nil) นี่คือ check-and-set (CAS): อ่าน คำนวณ เขียนเฉพาะเมื่อไม่มีอะไรเปลี่ยน ไม่เช่นนั้นลองใหม่ UNWATCH (รวมถึง EXEC และ DISCARD) ล้าง watch บรรทัดสุดท้ายของ transcript คือเส้นทางที่ไม่มีการแย่ง ส่วนเส้นทางที่มีการแย่งต้องใช้สอง connection:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const redis = new Redis(url);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// 1) WATCH aborts the transaction when another connection touches the keyconst other = new Redis(url);await redis.set('b3:stock', '5');await redis.watch('b3:stock');const seen = Number(await redis.get('b3:stock'));await other.decr('b3:stock'); // a second client sells one item in betweenconst aborted = await redis.multi().set('b3:stock', String(seen - 1)).exec();console.log('EXEC after another client changed the key ->', aborted, '| stock =', await redis.get('b3:stock'));
// 2) errors: queue-time error aborts everything, runtime error does not roll backawait redis.set('b3:str', 'hello');const runtime = await redis.multi().set('b3:k1', 'a').incr('b3:str').set('b3:k2', 'b').exec();console.log('runtime error, per-command tuples:', JSON.stringify(runtime!.map(([e, r]) => [e ? e.message : null, r])));console.log(' k1 and k2 were written anyway:', await redis.get('b3:k1'), await redis.get('b3:k2'));try { await redis.multi().set('b3:k3', 'c').call('SET', 'b3:k3').exec(); // SET with a missing argument} catch (e) { console.log('queue-time error -> exec() rejects:', (e as Error).message, '| k3 =', await redis.get('b3:k3'));}
// 3) optimistic locking done right: one connection PER attempt, retry on nullasync function buy(): Promise<{ ok: boolean; attempts: number }> { const conn = redis.duplicate(); // WATCH state lives on the connection, so it must not be shared try { for (let attempt = 1; attempt <= 50; attempt++) { await conn.watch('b3:cas'); const stock = Number(await conn.get('b3:cas')); if (stock <= 0) { await conn.unwatch(); return { ok: false, attempts: attempt }; } const res = await conn.multi().set('b3:cas', String(stock - 1)).exec(); if (res) return { ok: true, attempts: attempt }; // null = someone else changed it, try again await sleep(Math.random() * 5); } return { ok: false, attempts: 50 }; } finally { conn.disconnect(); }}await redis.set('b3:cas', '10');const buyers = await Promise.all(Array.from({ length: 20 }, buy));console.log('WATCH + retry, 20 buyers for 10 items: sold', buyers.filter((b) => b.ok).length, '| stock left', await redis.get('b3:cas'), '| total attempts', buyers.reduce((n, b) => n + b.attempts, 0));
// 4) the same code on ONE shared connection oversellsasync function buyShared(): Promise<boolean> { await redis.watch('b3:shared'); const stock = Number(await redis.get('b3:shared')); if (stock <= 0) return false; const res = await redis.multi().set('b3:shared', String(stock - 1)).exec(); return res !== null;}await redis.set('b3:shared', '10');const sold = (await Promise.all(Array.from({ length: 20 }, buyShared))).filter(Boolean).length;console.log('shared connection, 20 buyers for 10 items: sold', sold, '| stock left', await redis.get('b3:shared'));
await redis.del('b3:stock', 'b3:str', 'b3:k1', 'b3:k2', 'b3:k3', 'b3:cas', 'b3:shared');other.disconnect();await redis.quit();ผลลัพธ์จริง (Mac เครื่องนี้):
EXEC after another client changed the key -> null | stock = 4runtime error, per-command tuples: [[null,"OK"],["ERR value is not an integer or out of range",null],[null,"OK"]] k1 and k2 were written anyway: a bqueue-time error -> exec() rejects: EXECABORT Transaction discarded because of previous errors. | k3 = nullWATCH + retry, 20 buyers for 10 items: sold 10 | stock left 0 | total attempts 88shared connection, 20 buyers for 10 items: sold 20 | stock left 9สิ่งที่พิสูจน์ได้:
- การยกเลิก connection อื่นลด
stock(5 เป็น 4) หลังWATCHของเราEXECของเราคืนnullและไม่เขียนอะไร (stock คงที่ 4) - error สองชนิดใน ioredis error ตอนรันกลับมา ภายใน ผลลัพธ์
exec()เป็น tuple[err, result]และคำสั่งอื่นยังรัน ส่วน error ตอน queue ทำให้exec()reject ด้วยEXECABORT - CAS loop ที่ถูกต้อง ผู้ซื้อ 20 คนแย่ง 10 ชิ้น แต่ละคนใช้ connection ของตัวเอง (
redis.duplicate()) ลองใหม่เมื่อได้nullและขายได้พอดี 10 ชิ้น stock จบที่ 0 การลอง 88 ครั้งคือราคาของการแย่งกัน: ผู้แพ้ลองใหม่ เมื่อแย่ง key เดียวกันหนักๆ จะแพง ให้ใช้สคริปต์ Lua (บท Lua) หรือคำสั่ง atomic อย่างDECR - connection ที่ใช้ร่วมกันทำให้พัง โค้ดเดียวกันบน connection เดียวที่ใช้ร่วมกัน ขายไป 20 จาก 10 ชิ้นและเหลือ stock ที่ 9 สถานะ
WATCHเป็นของ connection และEXECล้าง watch ทั้งหมดของ connection นั้น:EXECของผู้ซื้อคนแรกเลิกเฝ้า key ให้ทุกคนที่อ่าน stock ค่าเดียวกัน transaction ของพวกเขาจึงไม่ถูกยกเลิก และทุกคนเขียนstock - 1จากค่าที่อ่านมาแล้วเก่า
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียนการลด stock “อย่างปลอดภัย” ด้วย optimistic locking:
🐛 หา bug ในโค้ดนี้
const redis = new Redis(); // one client shared by every request
async function buy(): Promise<boolean> {await redis.watch('stock');const stock = Number(await redis.get('stock'));if (stock <= 0) return false;const res = await redis.multi().set('stock', String(stock - 1)).exec();return res !== null; // null means someone else won; caller may retry}เฉลย
ตรรกะดูเหมือน CAS loop ตามตำรา แต่ WATCH ผูกกับ connection และ client Redis ใน Node.js คือ connection เดียวที่ทุกคำขอพร้อมกันใช้ร่วมกัน คำขอสลับกันเรียก WATCH/GET/EXEC EXEC ตัวแรกล้าง watch ของทุกคน และที่เหลือ commit stock - 1 ที่เก่า ในการรันข้างบน ผู้ซื้อ 20 คนบน connection เดียวขายไป 20 จาก 10 ชิ้น พอมี connection ต่อหนึ่งครั้งที่ลองขายได้พอดี 10 ใช้ connection เฉพาะสำหรับแต่ละครั้งที่ลอง แล้วตัดการเชื่อมต่อ (หรือคืนเข้า pool) ภายหลัง และเพิ่มขีดจำกัดการลองซ้ำ:
const conn = redis.duplicate(); // WATCH state lives on this connectiontry { /* watch, get, multi().exec(), retry on null */ } finally { conn.disconnect(); }Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”MULTI/EXEC คือ ซองปิดผนึกที่ยื่นให้พนักงาน: ทุกอย่างในซองถูกประมวลผลด้วยกัน ไม่มีใครแทรก มันไม่ใช่ปุ่ม undo: ถ้าคำสั่งหนึ่งในซองทำไม่ได้ พนักงานก็ทำที่เหลือต่ออยู่ดี WATCH คือโน้ตที่ปักบนเคาน์เตอร์ว่า “ยกเลิกซองของฉันถ้ามีใครแตะ key นี้ก่อน” และโน้ตเป็นของ คุณที่หน้าต่างนั้น ไม่ใช่ของร้าน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
MULTI/EXEC | คำสั่งที่เข้าคิวรันต่อกันโดยไม่มีอะไรแทรก | ไม่ rollback เมื่อ error ตอนรัน; แตกกิ่งจากผลระหว่างทางไม่ได้ |
WATCH (optimistic CAS) | ตรวจ race โดยไม่ต้อง lock; ถูกเมื่อแย่งกันน้อย | ต้องเขียน retry loop เองและถือ connection ต่อหนึ่งครั้งที่ลอง; การลองซ้ำกองขึ้นเมื่อแย่งกันมาก |
| สคริปต์ Lua แทน | แตกกิ่งได้จริง atomic ไม่ต้อง retry | โค้ดต้องดูแลมากกว่า และบล็อกเซิร์ฟเวอร์ขณะรัน |
คำสั่ง atomic เดี่ยว (INCR, DECR, SET NX) | ง่ายและเร็วที่สุด | ใช้ได้เมื่อคำสั่งเดียวแสดงการเปลี่ยนแปลงได้เท่านั้น |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
MULTI/EXECrollback เหมือน SQL” มีแต่ error ตอน queue ที่ทิ้งทั้งหมด; error ตอนรันปล่อยให้คำสั่งอื่นมีผลอยู่ - “
WATCHคือ lock” มันคือการตรวจตอนEXECไม่มีอะไรถูกบล็อก และEXECคืนnullแปลว่าให้ลองใหม่ - “client เดียวที่ใช้ร่วมกันใช้กับ
WATCHได้” watch เป็นของ connection; การแชร์ขายเกิน 20 จาก 10 - “
EXECคืน error เมื่อคำสั่งล้มเหลว” มันคืนลิสต์ที่มี error ที่ตำแหน่งนั้น มีแค่ความล้มเหลวตอน queue ที่ reject ใน ioredis
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- คาดหวัง rollback — ตรวจทุกสมาชิกของผลลัพธ์
EXEC WATCHบน connection ที่ใช้ร่วมกัน — ขายเกิน (พิสูจน์แล้ว)- ไม่ retry (หรือ retry ไม่จำกัด) เมื่อ
EXECเป็นnull— การอัปเดตหายเงียบๆ หรือ livelock; ลองไม่กี่ครั้งพร้อมหน่วงสุ่มเล็กน้อย - อ่านค่าก่อน
WATCH— การตรวจต้องมาหลังWATCHไม่เช่นนั้นค่าเปลี่ยนโดยไม่รู้ตัวได้ - ใช้
WATCHทั้งที่DECRเดียวก็พอ
Real-world example
เอกสารของ Redis เอง นำเสนอ
WATCHเป็น optimistic locking ที่ “very similar to a check-and-set (CAS) operation” และบอกให้ client ลองใหม่เมื่อEXECล้มเหลว (Transactions)