Definition
- DB polling problem – most polls return nothing (wasted work), the query loads the database, and multiple workers fight over the same due rows.
- Redis sorted set – ZADD with execute-at as score; ZRANGEBYSCORE -inf NOW returns only due jobs in O(log N + K) time.
- Atomic claim – a Lua script does ZRANGEBYSCORE + ZREM in one atomic operation so no two workers grab the same job.
- Time bucketing – partition jobs into per-minute keys so each tick only looks at the current bucket.
- Sharding hot buckets – hash(jobId) % N spreads a busy minute across N partitions.
- Durability – Redis persistence + relational store as source of truth + periodic reconciliation.
The naive clerk walks the entire filing cabinet every second asking 'anything due?' The smart design files tasks in order of when they are due. The clerk glances at the front and pulls only the due ones. Time bucketing: one pigeonhole per minute, so at 10:05 the clerk opens only the 10:05 pigeonhole.
Understanding the Concepts
Why is polling the DB every second bad at scale?
-- This runs every second, across many worker instances
SELECT * FROM jobs WHERE execute_at <= NOW() FOR UPDATE;
-- Problems:
-- 1. Most polls return nothing (wasted work)
-- 2. Growing table makes the query slower
-- 3. Multiple workers fight over the same rows (lock contention)
-- 4. Constant query traffic competes with real workload
How does a Redis sorted set fix this?
package com.bank.scheduler;
import redis.clients.jedis.Jedis;
import java.util.Set;
public class SortedSetScheduler {
private final Jedis redis;
public SortedSetScheduler(Jedis redis) {
this.redis = redis;
}
public void schedule(String jobId, long executeAtEpochMs) {
redis.zadd("due_jobs", executeAtEpochMs, jobId);
}
public Set<String> findDueJobs() {
long now = System.currentTimeMillis();
return redis.zrangeByScore("due_jobs", 0, now);
// Only returns jobs whose time has passed
// Millions of future jobs cost nothing to skip
}
}
How do you claim jobs atomically?
-- Lua script: atomic fetch-and-remove
local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 100)
for _, job in ipairs(due) do
redis.call('ZREM', KEYS[1], job)
end
return due
-- Whichever worker's script runs first gets the jobs and removes them
-- Redis executes Lua atomically → no distributed locks needed
What is time bucketing?
Partition jobs into per-minute sorted sets or lists:
// Key per minute: jobs:2026-10-24T17:25
String bucket = "jobs:" + LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
redis.zadd(bucket, executeAtMs, jobId);
// Each tick only inspects the current (and any missed past) bucket
How do you shard a hot bucket?
If midnight has millions of due jobs:
int shardCount = 8;
int shard = Math.abs(jobId.hashCode()) % shardCount;
String key = bucket + ":shard-" + shard;
redis.zadd(key, executeAtMs, jobId);
// 8 scheduler workers each drain one shard in parallel
How do you handle durability?
Redis is primarily in-memory. Enable AOF persistence. Keep the authoritative job definitions in a relational store and use Redis as the fast time index. Reconcile periodically so a lost Redis entry can be rebuilt.
What are common mistakes?
-- WRONG: polling the DB every second at scale
SELECT * FROM jobs WHERE execute_at <= NOW();
-- Wasted queries, lock contention, DB load
-- CORRECT at modest scale:
SELECT * FROM jobs WHERE execute_at <= NOW()
FOR UPDATE SKIP LOCKED LIMIT 100;
-- SKIP LOCKED avoids contention but still loads the DB
When is DB polling acceptable?
At modest scale, a well-indexed table with SELECT ... FOR UPDATE SKIP LOCKED is perfectly fine and simpler. The Redis sorted set shines at high volume and high scheduling churn.
What should a senior engineer know?
- Due jobs accumulate during a scheduler outage; recovery naturally picks them all up (they run late, not lost).
- Match the tool to the scale: DB polling for modest volume, Redis sorted set for high volume, dedicated scheduler for extreme scale.
- The core interview point: naive polling wastes work and causes contention; a time-ordered structure makes finding due jobs cheap and claiming them atomic.
Visual Overview
DB Polling vs Redis Sorted Set
DB Polling (every second)
┌──────────┐ SELECT ┌──────────┐
│ Worker 1 │ ──────── │ │
│ Worker 2 │ ──────── │ DB │ lock contention!
│ Worker 3 │ ──────── │ │ mostly empty results
└──────────┘ └──────────┘
Redis Sorted Set
┌──────────┐ ZRANGEBYSCORE ┌──────────┐
│ Worker │ ──────────────► │ Redis │
└──────────┘ + Lua ZREM │ Sorted │
(atomic claim) │ Set │
└──────────┘
Only due jobs returned; future jobs skipped
Time Bucketing
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 10:03 │ │ 10:04 │ │ 10:05 │ ← current bucket
│ (done) │ │ (done) │ │ inspect! │
└───────────┘ └───────────┘ └───────────┘
Key Takeaway
A delayed job scheduler runs work at a future time. The naive design polls a database every second, but at scale this wastes queries (most return nothing), loads the database, and causes lock contention when workers fight over the same due rows.
The scalable design stores jobs in a Redis sorted set with the execute-at timestamp as the score. Finding due jobs is a single ZRANGEBYSCORE range query that returns only past-due jobs and skips millions of future ones. An atomic Lua script (ZRANGEBYSCORE + ZREM) prevents two workers from claiming the same job. Time bucketing partitions jobs into per-minute keys so each tick inspects only the current bucket, and sharding a hot bucket by hash(jobId) spreads load across parallel workers.
At National Bank of India, Pankaj used this to schedule millions of reminders and hold expirations after per-second DB polling started loading the database. Due jobs accumulate during any scheduler outage and are picked up naturally on recovery.
Remember one sentence: do not poll the whole database every second; store jobs ordered by execute-at in a Redis sorted set so finding due jobs is one cheap range query, claim atomically with a Lua script, and bucket by time so each tick inspects only the relevant slice.
---