Job Queue
Redis as a job queue: producers LPUSH jobs onto a list; workers BRPOPLPUSH them onto a processing list (atomic ack); Lua scripts handle retries + dead-letter. Reliable queues require the “reliable pattern”, not naive LPUSH/BRPOP.
Lists, streams, reliable queue, alternatives
EXAMPLE
// 1) Naive (UNSAFE) queue — works until a worker crashes mid-job
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Producer
await redis.lpush('jobs', JSON.stringify({ type: 'email', to: 'a@b.com' }));
// Worker
async function worker() {
while (true) {
const raw = await redis.brpop('jobs', 0); // blocks until a job appears
const job = JSON.parse(raw[1]);
await handle(job); // ❌ if process dies here, job lost
}
}
// 2) Reliable queue — BRPOPLPUSH (or BLMOVE) to a processing list
async function reliableWorker(workerId) {
const processingKey = `processing:${workerId}`;
while (true) {
const raw = await redis.brpoplpush('jobs', processingKey, 0);
try {
await handle(JSON.parse(raw));
await redis.lrem(processingKey, 1, raw); // ack — remove from processing
} catch (e) {
// leave it on processingKey; cleanup script moves it to retry/dead-letter
log.error(e, 'job failed');
}
}
}
// On startup, requeue any leftover jobs from this worker's processing list:
await redis.eval(`
while true do
local v = redis.call('RPOPLPUSH', KEYS[1], KEYS[2])
if not v then break end
end
return 1
`, 2, processingKey, 'jobs');
// 3) Janitor — reclaim stuck jobs from dead workers
// Maintain a heartbeat key per worker; periodically check + recover.
setInterval(async () => {
await redis.set(`hb:${workerId}`, Date.now(), 'EX', 60);
}, 15_000);
async function reclaimStuck() {
const workers = await redis.keys('hb:*');
const known = new Set(workers.map((k) => k.slice(3)));
const processings = await redis.keys('processing:*');
for (const pk of processings) {
const id = pk.slice('processing:'.length);
if (!known.has(id)) {
// worker crashed → move all jobs back
await redis.eval(`
while true do
local v = redis.call('RPOPLPUSH', KEYS[1], KEYS[2])
if not v then break end
end
redis.call('DEL', KEYS[1])
return 1
`, 2, pk, 'jobs');
}
}
}
// Run reclaimStuck() every minute in a janitor process.
// 4) Retries + dead-letter
async function workerWithRetries(workerId) {
while (true) {
const raw = await redis.brpoplpush('jobs', `processing:${workerId}`, 0);
const job = JSON.parse(raw);
try {
await handle(job);
await redis.lrem(`processing:${workerId}`, 1, raw);
} catch (e) {
job.attempts = (job.attempts ?? 0) + 1;
if (job.attempts >= 5) {
await redis.lpush('jobs:deadletter', JSON.stringify(job));
} else {
await redis.lpush('jobs:retry', JSON.stringify(job));
}
await redis.lrem(`processing:${workerId}`, 1, raw);
}
}
}
// A scheduler periodically moves jobs from 'jobs:retry' back to 'jobs' with delay.
// 5) Delayed jobs — sorted set as priority queue
const score = Date.now() + 60_000; // run in 1 minute
await redis.zadd('jobs:delayed', score, JSON.stringify(job));
// Mover process
setInterval(async () => {
const now = Date.now();
const ready = await redis.zrangebyscore('jobs:delayed', 0, now);
if (ready.length === 0) return;
const multi = redis.multi();
multi.zrem('jobs:delayed', ...ready);
multi.lpush('jobs', ...ready);
await multi.exec();
}, 1000);
// 6) Redis Streams (modern; 5.0+)
// Streams add consumer groups, ack semantics, message IDs, persistence.
await redis.xadd('jobs', '*', 'type', 'email', 'to', 'a@b.com');
await redis.xgroupCreate('jobs', 'workers', '$', 'MKSTREAM');
const entries = await redis.xreadgroup('GROUP', 'workers', 'worker-1',
'COUNT', 10, 'BLOCK', 5000, 'STREAMS', 'jobs', '>');
for (const [, msgs] of entries ?? []) {
for (const [id, fields] of msgs) {
await handle(toObject(fields));
await redis.xack('jobs', 'workers', id);
}
}
// XPENDING + XCLAIM lets you recover messages stuck with crashed workers — built-in equivalent of the reliable pattern.
// Streams support PEL (pending entries list), trim by MAXLEN, message retention, consumer scaling.
// 7) Production libraries
// Most teams DON'T roll their own. Use a battle-tested library:
// • BullMQ (Node) — modern, TS-friendly, dashboard, jobs.add/process API
// • Sidekiq (Ruby) — gold standard for Ruby; uses Redis
// • Resque (Ruby) — older sibling
// • RQ (Python) — simple
// • Celery + Redis broker — Python; heavy
// • Asynq (Go) — Sidekiq-style for Go
//
// They handle reliability, retries, scheduling, rate limiting, priority, dashboards out of the box.
// 8) BullMQ example
import { Queue, Worker } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
const emailQueue = new Queue('email', { connection });
await emailQueue.add('welcome', { to: 'mara@example.com' }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
const worker = new Worker('email', async (job) => {
await sendEmail(job.data.to);
}, { connection, concurrency: 10 });
worker.on('failed', (job, err) => console.error(job.id, err));
worker.on('completed', (job) => console.log(job.id, 'done'));
// 9) Anti-patterns
// • Naive LPUSH + BRPOP with auto-ack → lost jobs on crash
// • Worker stores job in local memory before ack → same problem
// • Single worker without max-attempts → infinite retry loop on poisoned message
// • No dead-letter queue → can't inspect what failed
// • Sharing one Redis between queue + cache + sessions → eviction policy mismatch
// • Treating Redis as 'durable enough' — AOF + replicas yes; treat as ephemeral for finance-grade exactly-once
// 10) When NOT to use Redis as a queue
// • Need exactly-once semantics + complex routing → reach for RabbitMQ / Kafka
// • Multi-tenant SaaS with strict isolation → dedicated queue per tenant or Kafka partitions
// • Workflows + scatter/gather → Temporal / AWS Step Functions / Argo
//
// Redis queues are excellent for at-least-once jobs at moderate scale.
// 11) Monitoring
// • LLEN jobs — backlog
// • LLEN processing:* and stuck count — workers behind
// • LLEN jobs:deadletter — failed jobs to investigate
// • XINFO STREAM jobs / XPENDING for streams
// • App-level counters: jobs_processed_total, jobs_failed_total, retry_total
// 12) Common bugs
// • Forgetting LREM after success → processing list grows; memory leak
// • Worker keeps two Redis connections active — uses subscription + commands; needs separate clients
// • Lua script timeout → whole Redis blocked
// • Streams without trimming → unbounded growth; use MAXLEN ~ approximate
// • RDB-only persistence with bursty writes → can lose recent jobs on crash; enable AOF
// • Long-running jobs without heartbeat → janitor reclaims while still in progress; periodic ping or chunking
// • Race between janitor and worker → use SHA-locked Lua to make reclaim atomic
Why it matters
Reliable Redis queues use the LIST + processing-list pattern (BRPOPLPUSH) with a janitor that reclaims jobs from dead workers, retries with exponential backoff, and a dead-letter queue for poisoned messages. Redis Streams add modern consumer groups + ack semantics natively. In real projects, lean on BullMQ, Sidekiq, RQ, or Asynq instead of rolling your own.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Producer RPUSH jobs $jsonPayload # Worker BLPOP jobs 0 # blocks until a job arrivesTry it Yourself »
Discussion
Loading…