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

Transactions, Lua & Tooling: Overview

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) ผลลัพธ์ของสคริปต์มาจากการรันของผม

Redis ไปไกลกว่าการทำงานแบบ key-value ธรรมดา เมื่อแอปโตขึ้น คุณต้องรันหลายคำสั่งโดยไม่มีการรบกวน ย้าย logic ไปไว้ใกล้ข้อมูล และวินิจฉัยเซิร์ฟเวอร์เมื่อช้าลง

บทเรียนหัวข้อ
TransactionsMULTI/EXEC/DISCARD, EXECABORT, ไม่มี rollback, optimistic locking ด้วย WATCH
Pipelinesรวม round trip (SET 5000 ครั้งใช้ 17 ms เทียบ 1688 ms), redis-cli --pipe, result tuple
Lua ScriptingEVAL, redis.call/pcall, script cache ฝั่งเซิร์ฟเวอร์, SCRIPT KILL, Functions
Tooling & MonitoringINFO, SLOWLOG, --bigkeys/--keystats, MEMORY, LATENCY, ต้นทุนของ MONITOR

การรันคำสั่งเป็น single-thread; ตัว process ไม่ใช่ Redis รันทีละคำสั่ง คำสั่งเดี่ยวจึงไม่มีทางแทรกกับอีกคำสั่ง แต่แอปของคุณมักต้องใช้หลายคำสั่ง และระหว่างสองคำสั่ง client อื่นรันแทรกได้ มีสามเครื่องมือ และแต่ละอันแก้คนละปัญหา:

  • Transactions (MULTI/EXEC) เข้าคิวแล้วรันเป็นบล็อกเดียวที่ไม่ถูกขัด แตกกิ่งตามผลระหว่างทางไม่ได้และไม่มี rollback
  • Pipelines ส่งหลายคำสั่งใน network round trip เดียว ได้ความเร็ว ไม่ใช่ atomicity และ error ต่อคำสั่งกลับมาในอาร์เรย์คำตอบ
  • สคริปต์ Lua รันทั้งหมดบนเซิร์ฟเวอร์แบบ atomic แตกกิ่งได้และใช้คำสั่งได้ครบ มันบล็อกเซิร์ฟเวอร์ขณะรัน

สคริปต์ด้านล่างขายของ 5 ชิ้นให้ผู้ซื้อ 20 คนพร้อมกันสองครั้ง เวอร์ชันแรกตรวจ stock แล้วลดใน pipeline (ความเชื่อที่พบบ่อยคือ pipeline “ทำให้ atomic”) เวอร์ชันที่สองตรวจและลดภายในสคริปต์ Lua เดียว

src/tsl-overview-oversell.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const BUYERS = 20;
const STOCK = 5;
// "check, then decrement" as two steps. Wrapping the second step in a pipeline does not help:
// the check already happened, and other buyers ran their checks before any decrement arrived.
async function buyPipelined() {
const stock = Number(await redis.get('b3:tsl:stock'));
if (stock > 0) await redis.pipeline().decr('b3:tsl:stock').exec();
}
// the same decision inside one Lua script: read, compare and write cannot be interleaved
redis.defineCommand('buyLua', {
numberOfKeys: 1,
lua: `local s = tonumber(redis.call('GET', KEYS[1]))
if s and s > 0 then redis.call('DECR', KEYS[1]) return 1 end
return 0`,
});
declare module 'ioredis' {
interface RedisCommander<Context> {
buyLua(key: string): Promise<number>;
}
}
await redis.set('b3:tsl:stock', String(STOCK));
await Promise.all(Array.from({ length: BUYERS }, buyPipelined));
console.log(`GET then pipelined DECR : ${BUYERS} buyers, stock ${STOCK} -> stock left ${await redis.get('b3:tsl:stock')}`);
await redis.set('b3:tsl:stock', String(STOCK));
const sold = (await Promise.all(Array.from({ length: BUYERS }, () => redis.buyLua('b3:tsl:stock')))).reduce((a, b) => a + b, 0);
console.log(`Lua check-and-decrement : ${BUYERS} buyers, stock ${STOCK} -> sold ${sold}, stock left ${await redis.get('b3:tsl:stock')}`);
await redis.del('b3:tsl:stock');
await redis.quit();

ผลลัพธ์จริง (Mac เครื่องนี้):

GET then pipelined DECR : 20 buyers, stock 5 -> stock left -15
Lua check-and-decrement : 20 buyers, stock 5 -> sold 5, stock left 0

เวอร์ชัน pipeline ขายของ 20 ชิ้นที่ไม่มี (stock เหลือ -15): ผู้ซื้อทั้ง 20 ผ่านการตรวจ stock > 0 ก่อนที่การลดใดๆ จะมาถึง สคริปต์ Lua ขายพอดี 5 ชิ้น MULTI/WATCH ก็ให้ 5 พอดีเช่นกัน โดยแลกกับการลองซ้ำ (Transactions)

แอปที่ถูกต้องก็ยังเจอ query ช้า key ใหญ่เกินไป หรือ connection รั่วได้ Redis มาพร้อมเครื่องมือวินิจฉัยที่เปิดทิ้งได้อย่างปลอดภัย (INFO, SLOWLOG, --keystats) และหัววัดที่ไม่ปลอดภัย (KEYS, MONITOR) บทเครื่องมือแสดง output จริงและวัดต้นทุนของตัวที่เสี่ยง

PING
INFO server

AI ถูกขอให้ทำ “ตรวจ stock แล้วลด” ให้ปลอดภัยและหยิบ pipeline มาใช้:

🐛 หา bug ในโค้ดนี้

async function buy() {
const stock = Number(await redis.get('stock'));
if (stock > 0) {
await redis.pipeline().decr('stock').exec(); // "the pipeline makes the decrement atomic"
}
}
เฉลย

pipeline รวมเฉพาะ I/O เครือข่ายของคำสั่ง ที่อยู่ข้างใน GET และการตรวจเกิดก่อนหน้านั้น ผู้ซื้อ 20 คนพร้อมกันจึงเห็น stock = 5 ทั้งหมด ผ่าน stock > 0 ทั้งหมด และลดทั้งหมด: stock จบที่ -15 ใส่การอ่าน การเปรียบเทียบ และการเขียนในหน่วย atomic เดียว ที่นี่คือสคริปต์ Lua (หรือ WATCH + MULTI พร้อม retry loop หรือ DECR ธรรมดาตามด้วยการตรวจแล้วยกเลิก):

redis.defineCommand('buy', {
numberOfKeys: 1,
lua: `local s = tonumber(redis.call('GET', KEYS[1]))
if s and s > 0 then redis.call('DECR', KEYS[1]) return 1 end
return 0`,
});

ลองคิดเป็นสามวิธีส่งงานให้พนักงานคนเดียวที่เร็วมาก pipeline คือสลิปเป็นปึกที่ยื่นในเที่ยวเดียว: เร็ว แต่พนักงานอาจให้บริการคนอื่นคั่นระหว่างสลิปของคุณ transaction คือซองปิดผนึก: สลิปข้างในถูกประมวลผลด้วยกัน แต่พนักงานอ่านคำตอบของสลิปหนึ่งเพื่อตัดสินใจสลิปถัดไปไม่ได้ สคริปต์ คือขั้นตอนที่เขียนไว้ซึ่งพนักงานทำตามขณะปิดเคาน์เตอร์: แตกกิ่งได้และ atomic แต่ทุกคนรอขณะพนักงานทำตาม

ตัวเลือกข้อดีข้อเสีย
Pipelineround trip น้อยที่สุด throughput สูงสุดไม่ atomic; error ซ่อนในอาร์เรย์คำตอบ
MULTI/EXECรันต่อเนื่องกันแตกกิ่งไม่ได้ ไม่มี rollback
WATCH + MULTIcheck-and-set โดยไม่ต้อง lockต้องมี retry loop และหนึ่ง connection ต่อหนึ่งครั้งที่ลอง
สคริปต์ Lua / Functionlogic แตกกิ่งแบบ atomicบล็อกเซิร์ฟเวอร์ขณะรัน; ต้องสั้น
เครื่องมือวินิจฉัยในตัวไม่ต้องใช้ซอฟต์แวร์เพิ่มหัววัดที่เสี่ยง (KEYS, MONITOR) มีต้นทุนที่วัดได้
  • “pipeline คือ transaction” มันแค่รวม round trip; การรัน oversell ข้างบนจบที่ -15
  • “Redis เป็น single-thread ดังนั้น logic หลายขั้นของฉัน atomic” มีแค่แต่ละคำสั่ง (หรือ transaction หรือสคริปต์) ที่ atomic
  • “สคริปต์ Lua ไม่มีต้นทุน” มันบล็อก client อื่นทุกตัวขณะรัน
  • “อะไรก็ตามใน redis-cli ปลอดภัยบน production” KEYS และ MONITOR ไม่ปลอดภัย (วัดในบทเครื่องมือ)
ฟีเจอร์ใดของ Redis ให้การรันแบบ atomic สำหรับกลุ่มคำสั่ง?
เป้าหมายหลักของ pipelining คืออะไร?
สคริปต์ Lua ที่รันผ่าน EVAL คือ:
ผู้ซื้อ 20 คนแต่ละคนรัน GET stock ตรวจ > 0 แล้ว DECR ผ่าน pipeline stock เริ่มที่ 5 จบที่ไหน?
เครื่องมือใดที่คุณจะเลือกสำหรับ 'ลดเฉพาะเมื่อค่ามากกว่าศูนย์' แบบ atomic โดยไม่ต้องมี retry loop?