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

Lists เป็น Queue และ Stack

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) คำสั่ง blocking (BLPOP, BLMOVE) ที่ตั้ง timeout 0 harness ของคอร์สรันซ้ำไม่ได้ พฤติกรรมของมันจึงแสดงด้วย script ioredis แบบ @run และ transcript ที่จับเวลาด้านล่าง

queue ประมวลผล item ตามลำดับเข้าก่อนออกก่อน (first-in, first-out) ด้วย Redis list: producer เรียก LPUSH (push เข้าหัว) และ consumer เรียก RPOP (pop จากท้าย) เพราะ LPUSH แทรกที่หัวเสมอ item ที่เก่าที่สุดจึงอยู่ที่ท้ายเสมอ — ตรงกับที่ RPOP อ่าน

cli/lists-hashes-list-queues-1.redis
127.0.0.1:6379> LPUSH jobs "job:1"
(integer) 1
127.0.0.1:6379> LPUSH jobs "job:2"
(integer) 2
127.0.0.1:6379> LPUSH jobs "job:3"
(integer) 3
127.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 jobs

stack ประมวลผล item แบบเข้าทีหลังออกก่อน (LIFO) ทั้ง push และ pop เกิดที่ปลายเดียวกัน (หัวของ list) item ที่ push ล่าสุดจึงเป็นตัวถัดไปที่ออกเสมอ

cli/lists-hashes-list-queues-2.redis
127.0.0.1:6379> DEL stack
(integer) 0
127.0.0.1:6379> LPUSH stack "frame:1"
(integer) 1
127.0.0.1:6379> LPUSH stack "frame:2"
(integer) 2
127.0.0.1:6379> LPUSH stack "frame:3"
(integer) 3
127.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 stack

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
cli/lists-hashes-list-queues-3.redis
127.0.0.1:6379> DEL pending processing
(integer) 0
127.0.0.1:6379> LPUSH pending "task:A" "task:B" "task:C"
(integer) 3
127.0.0.1:6379> LMOVE pending processing RIGHT LEFT
"task:A"
127.0.0.1:6379> LRANGE pending 0 -1
1) "task:C"
2) "task:B"
127.0.0.1:6379> LRANGE processing 0 -1
1) "task:A"
127.0.0.1:6379> LREM processing 1 "task:A"
(integer) 1
127.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 -1
1) "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 -1

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) 1
127.0.0.1:6379> BRPOP jobs 1
1) "jobs"
2) "a"

สังเกตว่า reply บอกชื่อ list ที่ pop มา ("jobs") พร้อมค่า — จำเป็นเมื่อบล็อกรอหลาย list พร้อมกัน ใน worker คุณเรียกคำสั่ง blocking ในลูป เมื่อ producer เรียก LPUSH การเรียกที่บล็อกอยู่จะคืนทันที ไม่ต้อง poll

connection ที่บล็อกอยู่คือ connection ที่ไม่ว่าง client ที่ส่ง BLPOP ไปแล้วทำอย่างอื่นไม่ได้จนกว่าจะคืน เราวาง PING ไว้หลัง BLPOP 1 วินาทีบน connection เดียวกัน แล้วเทียบกับอีก connection หนึ่ง:

src/lists-hashes-list-queues-blocking.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const shared = new Redis(url);
const listener = shared.duplicate(); // a second connection for blocking calls
await shared.del('b1:wait');
// Blocking on the SAME connection stalls everything queued behind it
let t = performance.now();
const blocked = shared.blpop('b1:wait', 1); // nothing to pop: blocks for 1 s
const ping = shared.ping().then(() => performance.now() - t); // queued behind BLPOP
await 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 untouched
t = 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 immediately
setTimeout(() => shared.lpush('b1:wait', 'wake-up'), 100);
t = performance.now();
const got = await blocked2; // still waiting from above
console.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 ใช้

เทียบ BRPOP ธรรมดากับ BLMOVE เข้า processing list ภายใต้ crash เดียวกัน (“worker ตายทันทีหลังหยิบ job”):

src/lists-hashes-list-queues-reliable.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const producer = new Redis(url);
const worker = new Redis(url); // blocking commands get their own connection
const 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 = oldest
const 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 + push
console.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 queue
let 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 LREM
const 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? false
BLMOVE 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 ที่สร้างมาเฉพาะ

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)

queue ด้วย Redis list คือ สายพานที่มีทางออกเดียว LPUSH/RPOP เลื่อนพัสดุไปตามสายพาน; BLPOP คือคนงานที่ยืนรอที่ทางออก สายพานไม่จำว่าใครหยิบพัสดุไป เพื่อให้สายพาน เชื่อถือได้ คุณเพิ่มสายพานที่สอง (processing) และย้ายพัสดุข้ามไปแบบ atomic เสมอก่อนลงมือทำ — พัสดุทุกชิ้นจึงอยู่บนสายพานใดสายพานหนึ่งพอดีทุกขณะ — และลบมันหลังงานเสร็จเท่านั้น อะไรที่เกินกว่านี้ (ใคร ตั้งแต่เมื่อไร ลองกี่ครั้ง) คือหน้าที่ของ Streams

ตัวเลือกข้อดีข้อเสีย
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 มากกว่า
  • “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 แทน
  • ย้าย 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)

คู่คำสั่งไหน implement FIFO queue?
BLPOP jobs 0 ทำอะไรเมื่อ list ว่าง?
LMOVE (ตัวแทนของ RPOPLPUSH) ทำอะไร?
คู่คำสั่งไหน implement stack (LIFO)?
worker หยิบ job ด้วย BRPOP แล้วถูก kill ก่อนทำเสร็จ job เป็นอย่างไร?
ทำไม worker ที่ blocking ควรมี connection ของตัวเอง?