Lists เป็น Queue และ Stack
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) คำสั่ง blocking (BLPOP,BLMOVE) ที่ตั้ง timeout0harness ของคอร์สรันซ้ำไม่ได้ พฤติกรรมของมันจึงแสดงด้วย script ioredis แบบ@runและ transcript ที่จับเวลาด้านล่าง
FIFO Queue — LPUSH + RPOP
หัวข้อที่มีชื่อว่า “FIFO Queue — LPUSH + RPOP”queue ประมวลผล item ตามลำดับเข้าก่อนออกก่อน (first-in, first-out) ด้วย Redis list: producer เรียก LPUSH (push เข้าหัว) และ consumer เรียก RPOP (pop จากท้าย) เพราะ LPUSH แทรกที่หัวเสมอ item ที่เก่าที่สุดจึงอยู่ที่ท้ายเสมอ — ตรงกับที่ RPOP อ่าน
127.0.0.1:6379> LPUSH jobs "job:1"(integer) 1127.0.0.1:6379> LPUSH jobs "job:2"(integer) 2127.0.0.1:6379> LPUSH jobs "job:3"(integer) 3127.0.0.1:6379> RPOP jobs"job:1"127.0.0.1:6379> RPOP jobs"job:2"127.0.0.1:6379> RPOP jobs"job:3"127.0.0.1:6379> RPOP jobs(nil)job:1 ถูก push ก่อนจึงออกก่อน — FIFO queue ที่ว่างตอบ (nil) ทันที คุณต้อง poll เอง (RPOP jobs 2 pop ได้สูงสุดสองตัวพร้อมกันตั้งแต่ 6.2)
LPUSH jobs "job:1"
LPUSH jobs "job:2"
LPUSH jobs "job:3"
RPOP jobs
RPOP jobsStack — LPUSH + LPOP
หัวข้อที่มีชื่อว่า “Stack — LPUSH + LPOP”stack ประมวลผล item แบบเข้าทีหลังออกก่อน (LIFO) ทั้ง push และ pop เกิดที่ปลายเดียวกัน (หัวของ list) item ที่ push ล่าสุดจึงเป็นตัวถัดไปที่ออกเสมอ
127.0.0.1:6379> DEL stack(integer) 0127.0.0.1:6379> LPUSH stack "frame:1"(integer) 1127.0.0.1:6379> LPUSH stack "frame:2"(integer) 2127.0.0.1:6379> LPUSH stack "frame:3"(integer) 3127.0.0.1:6379> LPOP stack"frame:3"127.0.0.1:6379> LPOP stack"frame:2"DEL stack
LPUSH stack "frame:1"
LPUSH stack "frame:2"
LPUSH stack "frame:3"
LPOP stack
LPOP stackLMOVE — ย้ายระหว่าง list แบบ atomic
หัวข้อที่มีชื่อว่า “LMOVE — ย้ายระหว่าง list แบบ atomic”LMOVE source destination LEFT|RIGHT LEFT|RIGHT pop จากปลายหนึ่งของ source และ push ไปปลายหนึ่งของ destination ในขั้นตอน atomic เดียว (Redis 6.2+) นี่คือส่วนประกอบพื้นฐานของ reliable queue: job ย้ายจาก pending ไป processing ในขั้นเดียว จึงไม่มีช่วงเวลาไหนที่มันไม่อยู่ในทั้งสอง list
| คำสั่งเก่า | เทียบเท่า |
|---|---|
RPOPLPUSH src dst (deprecated ตั้งแต่ 6.2) | LMOVE src dst RIGHT LEFT |
BRPOPLPUSH src dst timeout (deprecated) | BLMOVE src dst RIGHT LEFT timeout |
127.0.0.1:6379> DEL pending processing(integer) 0127.0.0.1:6379> LPUSH pending "task:A" "task:B" "task:C"(integer) 3127.0.0.1:6379> LMOVE pending processing RIGHT LEFT"task:A"127.0.0.1:6379> LRANGE pending 0 -11) "task:C"2) "task:B"127.0.0.1:6379> LRANGE processing 0 -11) "task:A"127.0.0.1:6379> LREM processing 1 "task:A"(integer) 1127.0.0.1:6379> LRANGE processing 0 -1(empty array)127.0.0.1:6379> LMOVE pending processing RIGHT LEFT"task:B"127.0.0.1:6379> LMOVE processing pending LEFT RIGHT"task:B"127.0.0.1:6379> LRANGE pending 0 -11) "task:C"2) "task:B"task:A ถูก push ก่อน เมื่อใช้ LPUSH มันจึงอยู่ที่ท้าย LMOVE pending processing RIGHT LEFT หยิบท้าย (ตัวเก่าที่สุด) ไปวางที่หัวของ processing จากนั้น LREM processing 1 "task:A" คือ การยืนยัน (ack): งานเสร็จแล้ว ลืมมันได้ สองการย้ายสุดท้ายแสดงการ requeue: task:B ถูกหยิบไป แล้ว LMOVE processing pending LEFT RIGHT วางกลับที่ท้ายของ pending ซึ่งมันกลายเป็นงานถัดไปอีกครั้ง
DEL pending processing
LPUSH pending "task:A" "task:B" "task:C"
LMOVE pending processing RIGHT LEFT
LRANGE pending 0 -1
LRANGE processing 0 -1Blocking pop — BLPOP, BRPOP และ BLMOVE
หัวข้อที่มีชื่อว่า “Blocking pop — BLPOP, BRPOP และ BLMOVE”BLPOP key [key ...] timeout บล็อก connection จนกว่าจะมี element แล้ว pop จาก list แรกที่ไม่ว่าง BRPOP ทำเหมือนกันแต่ pop จากท้าย; BLMOVE คือ LMOVE แบบ blocking timeout 0 หมายถึงรอไม่จำกัด ค่าอื่นเป็นวินาที บน list ว่างมันตอบ (nil) หลังหมดเวลา (CLI แบบ interactive พิมพ์เวลาที่ผ่านไปด้วย):
127.0.0.1:6379> BLPOP emptyq 1(nil)(1.04s)127.0.0.1:6379> LPUSH jobs a(integer) 1127.0.0.1:6379> BRPOP jobs 11) "jobs"2) "a"สังเกตว่า reply บอกชื่อ list ที่ pop มา ("jobs") พร้อมค่า — จำเป็นเมื่อบล็อกรอหลาย list พร้อมกัน ใน worker คุณเรียกคำสั่ง blocking ในลูป เมื่อ producer เรียก LPUSH การเรียกที่บล็อกอยู่จะคืนทันที ไม่ต้อง poll
connection ที่บล็อกอยู่คือ connection ที่ไม่ว่าง client ที่ส่ง BLPOP ไปแล้วทำอย่างอื่นไม่ได้จนกว่าจะคืน เราวาง PING ไว้หลัง BLPOP 1 วินาทีบน connection เดียวกัน แล้วเทียบกับอีก connection หนึ่ง:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const shared = new Redis(url);const listener = shared.duplicate(); // a second connection for blocking callsawait shared.del('b1:wait');
// Blocking on the SAME connection stalls everything queued behind itlet t = performance.now();const blocked = shared.blpop('b1:wait', 1); // nothing to pop: blocks for 1 sconst ping = shared.ping().then(() => performance.now() - t); // queued behind BLPOPawait blocked;console.log(`PING behind a BLPOP on the same connection: ${(await ping).toFixed(0)} ms`);
// On its own connection the rest of the app is untouchedt = performance.now();const blocked2 = listener.blpop('b1:wait', 1);const pingMs = await shared.ping().then(() => performance.now() - t);console.log(`PING while another connection blocks: ${pingMs.toFixed(1)} ms`);
// A push wakes the blocked connection immediatelysetTimeout(() => shared.lpush('b1:wait', 'wake-up'), 100);t = performance.now();const got = await blocked2; // still waiting from aboveconsole.log('blocked worker got', got?.[1], 'after', (performance.now() - t).toFixed(0), 'ms (push was at ~100 ms)');
await shared.del('b1:wait');shared.disconnect(); listener.disconnect();output จริง: PING behind a BLPOP on the same connection: 1023 ms, PING while another connection blocks: 2.4 ms, blocked worker got wake-up after 106 ms (push was at ~100 ms) สรุป: ให้ worker ที่ blocking ทุกตัวมี connection ของตัวเอง (duplicate()) อย่าใช้ connection จาก pool ที่ใช้ร่วมกัน หรือ connection ที่ web handler ใช้
Reliable queue: BLMOVE + processing list + reaper
หัวข้อที่มีชื่อว่า “Reliable queue: BLMOVE + processing list + reaper”เทียบ BRPOP ธรรมดากับ BLMOVE เข้า processing list ภายใต้ crash เดียวกัน (“worker ตายทันทีหลังหยิบ job”):
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const producer = new Redis(url);const worker = new Redis(url); // blocking commands get their own connectionconst K = { q: 'b1:queue', proc: 'b1:queue:processing', plain: 'b1:plainq' };await producer.del(K.q, K.proc, K.plain);
// ---------- 1. plain BRPOP: a crash after the pop loses the job ----------await producer.lpush(K.plain, 'job-1', 'job-2', 'job-3'); // job-1 is at the tail = oldestconst popped = await worker.brpop(K.plain, 1); // [key, value]console.log('BRPOP took', popped?.[1], '... worker crashes before finishing');const remaining = await producer.lrange(K.plain, 0, -1);console.log(' pending:', remaining, '| job-1 still in the list?', remaining.includes('job-1'));
// ---------- 2. reliable: BLMOVE pending -> processing, ack with LREM ----------await producer.lpush(K.q, 'job-1', 'job-2', 'job-3');const first = await worker.blmove(K.q, K.proc, 'RIGHT', 'LEFT', 1); // atomic pop + pushconsole.log('BLMOVE took', first, '... worker crashes before LREM');console.log(' pending:', await producer.lrange(K.q, 0, -1), '| processing:', await producer.lrange(K.proc, 0, -1));
// reaper (runs on start-up, or on a timer): put unfinished work back at the front of the queuelet requeued = 0;while (await producer.lmove(K.proc, K.q, 'LEFT', 'RIGHT')) requeued++;console.log('reaper requeued', requeued, '| pending now:', await producer.lrange(K.q, 0, -1));
// a healthy worker: move, do the work, then acknowledge with LREMconst done: string[] = [];for (;;) { const job = await worker.blmove(K.q, K.proc, 'RIGHT', 'LEFT', 1); if (job === null) break; // timeout: queue drained done.push(job); // ...do the work... await producer.lrem(K.proc, 1, job); // ack}console.log('processed in order:', done, '| processing left:', await producer.llen(K.proc));
await producer.del(K.q, K.proc, K.plain);producer.disconnect(); worker.disconnect();output จริง:
BRPOP took job-1 ... worker crashes before finishing pending: [ 'job-3', 'job-2' ] | job-1 still in the list? falseBLMOVE took job-1 ... worker crashes before LREM pending: [ 'job-3', 'job-2' ] | processing: [ 'job-1' ]reaper requeued 1 | pending now: [ 'job-3', 'job-2', 'job-1' ]processed in order: [ 'job-1', 'job-2', 'job-3' ] | processing left: 0กับ BRPOP, job-1 ไม่อยู่ที่ไหนเลย — หาย กับ BLMOVE มันอยู่ใน processing และ reaper วางกลับที่ท้าย ทำให้มันรันเป็นตัวถัดไป แล้ว worker ที่ปกติทำครบทั้งสามตามลำดับ
สิ่งที่ pattern นี้ไม่ได้ให้ reaper ข้างบน requeue ทุกอย่าง ใน processing ซึ่งปลอดภัยเฉพาะตอนเริ่มระบบ หรือเมื่อรู้ว่า worker ทุกตัวตายแล้ว ใน fleet ที่ทำงานอยู่คุณแยก “ค้าง” จาก “ช้า” ไม่ได้: item ใน list ไม่มี timestamp และไม่มีเจ้าของ ดังนั้น reaper ที่รันขณะ worker กำลังทำ job จะส่ง job เดียวกันให้ worker สองตัว (การส่งแบบ at-least-once — ทำให้ job เป็น idempotent) ยังไม่มีตัวนับ retry, การหน่วงเวลา, dead-letter queue, หรือการมองเห็นว่าใครถืออะไร เมื่อต้องการสิ่งเหล่านี้ให้ใช้ Streams กับ consumer group ซึ่งมี pending-entries list พร้อม idle time และรองรับ XAUTOCLAIM (Streams, Consumer Groups) หรือ library job ที่สร้างมาเฉพาะ
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน background worker “ด้วย pattern BLPOP ระดับ production”:
🐛 หา bug ในโค้ดนี้
const redis = new Redis(process.env.REDIS_URL!); // same client used by the HTTP handlers
async function worker() {while (true) { const [, job] = (await redis.blpop('jobs', 0))!; await handle(JSON.parse(job)); // send the invoice email}}เฉลย
มีสอง bug จริง ข้อแรก BLPOP ลบ job ก่อนที่ handle จะรัน: ถ้า handle throw หรือ process ถูก kill/redeploy ใบแจ้งหนี้หายไปโดยไม่เหลือร่องรอย (เรา reproduce การสูญหายด้วย BRPOP ข้างบน: job-1 still in the list? false) ให้ใช้ BLMOVE เข้า processing list แล้ว LREM หลังสำเร็จ พร้อม reaper ข้อสอง worker บล็อก client ที่ใช้ร่วมกับ HTTP handler คำสั่งของ handler จึงต่อคิวหลัง BLPOP 0 นานเท่าที่ queue ว่าง — วัด latency ของ PING ได้ 1,023 ms บน connection ที่ใช้ร่วม เทียบกับ 2.4 ms บน connection เฉพาะ วิธีแก้:
const workerConn = redis.duplicate(); // dedicated blocking connection// BLMOVE jobs -> jobs:processing, handle(), then LREM jobs:processing 1 <job> (see the runnable script above)Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”queue ด้วย Redis list คือ สายพานที่มีทางออกเดียว LPUSH/RPOP เลื่อนพัสดุไปตามสายพาน; BLPOP คือคนงานที่ยืนรอที่ทางออก สายพานไม่จำว่าใครหยิบพัสดุไป เพื่อให้สายพาน เชื่อถือได้ คุณเพิ่มสายพานที่สอง (processing) และย้ายพัสดุข้ามไปแบบ atomic เสมอก่อนลงมือทำ — พัสดุทุกชิ้นจึงอยู่บนสายพานใดสายพานหนึ่งพอดีทุกขณะ — และลบมันหลังงานเสร็จเท่านั้น อะไรที่เกินกว่านี้ (ใคร ตั้งแต่เมื่อไร ลองกี่ครั้ง) คือหน้าที่ของ Streams
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
BLPOP/BRPOP (blocking pop) | ไม่ต้องเขียน polling loop; worker ตื่นทันทีเมื่อมีงาน | ต้องมี connection blocking ต่อ worker; job หายถ้า worker ตายหลัง pop |
| Redis List เป็น queue | ตั้งค่าง่าย latency ต่ำ ไม่ต้องติดตั้ง broker แยก | ไม่มี retry, dead-letter queue, delay หรือการมองเห็นในตัว; ได้อย่างมากแค่ at-least-once |
LMOVE/BLMOVE ไป processing list + ack ด้วย LREM | ไม่เสีย job ถ้า worker crash กลางทาง | ต้องเขียน ack และ reaper เอง; reaper แยก “ช้า” จาก “ตาย” ไม่ได้ จึงต้องทำ job ให้ idempotent |
| ใช้ Streams + consumer group แทน | กรรมสิทธิ์ต่อข้อความ, idle time, XAUTOCLAIM, นับ retry | แนวคิดและคำสั่งมากขึ้น; หน่วยความจำต่อ entry มากกว่า |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
BLPOPคือ reliable queue” มันคือ queue แบบ blocking ความน่าเชื่อถือมาจากBLMOVE+ ack หรือจาก Streams - “connection ที่บล็อกอยู่คือ connection ที่ว่าง” มันไม่ว่าง: ทุกอย่างที่ส่งบนมันต้องรอ (วัด
PINGได้ 1,023 ms) - “reaper ทำให้เป็น exactly-once” ได้แค่ at-least-once; job ของ worker ที่ช้า (ไม่ใช่ตาย) อาจซ้ำ
- “
RPOPLPUSHคือคำสั่งปัจจุบัน” deprecated ตั้งแต่ 6.2 เพื่อใช้LMOVEแทน
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ย้าย job ไป processing list ด้วย
LMOVE/BLMOVEแต่ไม่เคยล้างเมื่อประมวลผลเสร็จ — processing list โตไม่จำกัด กลายเป็น memory leak ให้ ack ด้วยLREM processing 1 job - ใช้ Redis List เป็น queue เมื่อต้องการ retry ที่เข้มงวด, dead-letter queue หรือการรับประกันการส่ง — Redis List ไม่มีสิ่งเหล่านี้ในตัว ต้องสร้างเองทั้งหมด
- รัน
BLPOPบน connection ที่ใช้ร่วมกัน — ทุกคำสั่งอื่นบนมันค้างจนกว่า pop จะคืน - ปล่อยให้ list โตไม่จำกัดโดยไม่มี
LTRIM— ถ้า consumer ตาม producer ไม่ทัน list จะโตไม่หยุดจนหน่วยความจำหมด
Real-world example
Sidekiq library งาน background ยอดนิยมของ Ruby ระบุว่าใช้โครงสร้างข้อมูลของ Redis — รวมถึง List — เป็นที่เก็บ job โดย worker ดึง job จาก Redis คล้าย pattern
BLPOPในบทนี้ (Sidekiq FAQ, Sidekiq Wiki)