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

Transactions

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ # cli/... คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ ยอดรวม “attempts” ในผลลัพธ์ของสคริปต์ขึ้นกับจังหวะเวลา และจะต่างในเครื่องของคุณ

transaction คือลำดับคำสั่งที่ถูก เข้าคิว แล้วรันเป็นบล็อกเดียวที่ไม่ถูกขัด: ไม่มีคำสั่งของ client อื่นรันแทรกระหว่างคำสั่งเหล่านั้นเมื่อ EXEC ทำงาน เปิดด้วย MULTI สั่งคำสั่ง (แต่ละคำสั่งตอบ QUEUED) แล้วรันด้วย EXEC หรือทิ้งด้วย DISCARD พฤติกรรมทั้งหมดอยู่ในเอกสาร Transactions

cli/tsl-transactions-1.redis
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET balance 100
QUEUED
127.0.0.1:6379> DECRBY balance 30
QUEUED
127.0.0.1:6379> GET balance
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 70
3) "70"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET temp "draft"
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> EXISTS temp
(integer) 0
127.0.0.1:6379> SET mystr hello
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 a
QUEUED
127.0.0.1:6379> INCR mystr
QUEUED
127.0.0.1:6379> SET k2 b
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
127.0.0.1:6379> GET k1
"a"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k3 c
QUEUED
127.0.0.1:6379> SET k4
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> SET k5 e
QUEUED
127.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 MULTI
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> WATCH stock
(error) ERR WATCH inside MULTI is not allowed
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> SET stock 5
OK
127.0.0.1:6379> WATCH stock
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY stock 1
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 4
127.0.0.1:6379> UNWATCH
OK

session นี้แสดงอะไร:

  • ภายใน 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
EXEC
MULTI
SET temp "draft"
DISCARD
EXISTS temp

WATCH key ทำเครื่องหมาย key ที่ต้องเฝ้าดู ถ้า key ที่เฝ้าถูกแก้โดยใครก็ตาม (client อื่น หรือ connection ของคุณเองนอก transaction) ก่อน EXEC ทั้ง transaction จะถูกข้ามและ EXEC คืน (nil) นี่คือ check-and-set (CAS): อ่าน คำนวณ เขียนเฉพาะเมื่อไม่มีอะไรเปลี่ยน ไม่เช่นนั้นลองใหม่ UNWATCH (รวมถึง EXEC และ DISCARD) ล้าง watch บรรทัดสุดท้ายของ transcript คือเส้นทางที่ไม่มีการแย่ง ส่วนเส้นทางที่มีการแย่งต้องใช้สอง connection:

src/transactions-watch.ts
// @run
import { 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 key
const 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 between
const 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 back
await 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 null
async 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 oversells
async 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 = 4
runtime 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 b
queue-time error -> exec() rejects: EXECABORT Transaction discarded because of previous errors. | k3 = null
WATCH + retry, 20 buyers for 10 items: sold 10 | stock left 0 | total attempts 88
shared 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 จากค่าที่อ่านมาแล้วเก่า

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 connection
try { /* watch, get, multi().exec(), retry on null */ } finally { conn.disconnect(); }

MULTI/EXEC คือ ซองปิดผนึกที่ยื่นให้พนักงาน: ทุกอย่างในซองถูกประมวลผลด้วยกัน ไม่มีใครแทรก มันไม่ใช่ปุ่ม undo: ถ้าคำสั่งหนึ่งในซองทำไม่ได้ พนักงานก็ทำที่เหลือต่ออยู่ดี WATCH คือโน้ตที่ปักบนเคาน์เตอร์ว่า “ยกเลิกซองของฉันถ้ามีใครแตะ key นี้ก่อน” และโน้ตเป็นของ คุณที่หน้าต่างนั้น ไม่ใช่ของร้าน

ตัวเลือกข้อดีข้อเสีย
MULTI/EXECคำสั่งที่เข้าคิวรันต่อกันโดยไม่มีอะไรแทรกไม่ rollback เมื่อ error ตอนรัน; แตกกิ่งจากผลระหว่างทางไม่ได้
WATCH (optimistic CAS)ตรวจ race โดยไม่ต้อง lock; ถูกเมื่อแย่งกันน้อยต้องเขียน retry loop เองและถือ connection ต่อหนึ่งครั้งที่ลอง; การลองซ้ำกองขึ้นเมื่อแย่งกันมาก
สคริปต์ Lua แทนแตกกิ่งได้จริง atomic ไม่ต้อง retryโค้ดต้องดูแลมากกว่า และบล็อกเซิร์ฟเวอร์ขณะรัน
คำสั่ง atomic เดี่ยว (INCR, DECR, SET NX)ง่ายและเร็วที่สุดใช้ได้เมื่อคำสั่งเดียวแสดงการเปลี่ยนแปลงได้เท่านั้น
  • “MULTI/EXEC rollback เหมือน SQL” มีแต่ error ตอน queue ที่ทิ้งทั้งหมด; error ตอนรันปล่อยให้คำสั่งอื่นมีผลอยู่
  • “WATCH คือ lock” มันคือการตรวจตอน EXEC ไม่มีอะไรถูกบล็อก และ EXEC คืน null แปลว่าให้ลองใหม่
  • “client เดียวที่ใช้ร่วมกันใช้กับ WATCH ได้” watch เป็นของ connection; การแชร์ขายเกิน 20 จาก 10
  • “EXEC คืน error เมื่อคำสั่งล้มเหลว” มันคืนลิสต์ที่มี error ที่ตำแหน่งนั้น มีแค่ความล้มเหลวตอน queue ที่ reject ใน ioredis
  • คาดหวัง 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)

คำสั่ง Redis คืนอะไรขณะอยู่ในบล็อก MULTI?
เกิดอะไรขึ้นเมื่อเรียก EXEC และ key ที่ WATCH ถูก client อื่นเปลี่ยน?
error ตอนรันในหนึ่งคำสั่งภายใน EXEC (INCR บน string) ทำให้:
คำสั่งที่มีจำนวน argument ผิดถูกส่งระหว่าง MULTI กับ EXEC แล้ว EXEC ทำอะไร?
ทำไมผู้ซื้อ 20 คนที่ใช้ ioredis connection เดียวร่วมกันกับ WATCH ขายไป 20 จาก 10 ชิ้น?
คำสั่งใดยกเลิก transaction ที่เข้าคิวไว้โดยไม่รัน?