Redis Beyond Caching: Locks, Limits, Queues and Streams
The 95% of Redis teams never open: atomic counters, rate limiters, sorted-set schedulers, pub/sub vs streams (one of them forgets!), queue pragmatics and the eviction-policy line that forces two instances.
Most teams rent a Redis, use 5% of it as a cache, and never open the rest of the toolbox. Which is a shame, because the rest of the toolbox is why Redis is on my shortlist of software I'd take to a desert island: atomic counters, rate limiters, distributed locks, leaderboards, queues, streams — all with single-digit-millisecond latency and data structures that map directly onto problems you actually have. A tour of the parts I use weekly, with the sharp edges labeled.
Rate limiting: INCR and a clock
The simplest production-grade limiter is a counter per window:
// Fixed window: 100 requests / minute / API key
$key = "rl:{$apiKey}:" . now()->format('YmdHi');
$count = Redis::incr($key);
if ($count === 1) {
Redis::expire($key, 90); // window + slack; NEVER incr without expiry
}
if ($count > 100) {
abort(429, headers: ['Retry-After' => 60 - now()->second]);
}
Fixed windows have the boundary burst problem (200 requests straddling a minute edge); a sorted-set sliding window fixes it at slightly more cost — I walk both, plus token buckets, in the dedicated rate-limiting post. The Redis-specific lesson is the comment above: every INCR-based key gets an expiry in the same breath, ideally via a Lua script or SET ... EX NX pattern so the "incremented but never expired" orphan can't exist. Orphan counters are how "temporary" keys become the top of your memory report.
Atomic counters and the things they unlock
INCR/INCRBY/HINCRBY are atomic without transactions — no read-modify-write race, ever. That primitive quietly powers: view counters that don't need a DB write per hit (batch-flush to the database every minute), stock reservations (DECRBY, refuse below zero via Lua), cache version stamps, and per-tenant usage metering for billing. The pattern in all of them: Redis absorbs the write storm, the database gets the periodic truth.
Sorted sets: the underrated one
ZSETs — members with scores, ordered — are the answer to a surprising range of "how do I…" questions: leaderboards (obviously), but also scheduled work (score = execute-at timestamp; a worker pops everything with score ≤ now), sliding-window anything (score = event timestamp, trim with ZREMRANGEBYSCORE), and recently-active lists ("last 50 items viewed", trimmed with ZREMRANGEBYRANK). If you find yourself polling a database table with WHERE run_at <= NOW() every second, a ZSET is often the same feature with 1/100th the load.
Pub/Sub vs Streams: know which one forgets
Redis has two messaging primitives and they differ in exactly one crucial way:
- Pub/Sub is fire-and-forget. No subscriber listening at that instant? Message gone forever. That makes it perfect for ephemeral fan-out — live dashboards, the backplane between WebSocket servers, cache-invalidation pings — and catastrophically wrong for anything that must be processed.
- Streams remember.
XADDappends to a persistent log; consumer groups track who has read what; unacknowledged entries can be claimed by another worker after a crash (XAUTOCLAIM). That's real work-queue semantics — at-least-once, with a pending list instead of prayer.
# Streams in four commands
XADD orders:events * type OrderPlaced order_id 4211
XGROUP CREATE orders:events billing $
XREADGROUP GROUP billing worker-1 COUNT 10 BLOCK 5000 STREAMS orders:events >
XACK orders:events billing 1712045023188-0
My honest guidance: Streams are excellent as a lightweight event log when you already run Redis and don't want a broker — but once you need replay-for-new-consumers-from-the-beginning, multi-day retention, or serious throughput, you're reinventing Kafka on hard mode. Use Streams for operational glue; graduate deliberately.
Queues: the pragmatic take
Laravel's Redis queue driver (lists + sorted sets under the hood) is genuinely production-ready for typical web workloads — with Horizon on top you get retries, backoff and a dashboard for very little ceremony. The honest caveats: it's at-least-once (so idempotent jobs, as always), and a Redis restart with weak persistence settings can drop in-flight jobs — which is why the persistence configuration post exists. Money-movement jobs get a database-backed queue or a proper broker; everything else lives happily here.
Locks: a teaser and a warning
SET lock:report:42 <token> NX PX 30000 gives you a lock in one atomic command — and about six ways to hurt yourself: expiry racing a slow process, deleting someone else's lock, the Redlock debate. It deserves its own post and has one. Short version: locks for efficiency (don't compute this twice) are fine; locks for correctness (never ever do this twice) need fencing tokens or a different design.
One instance, many jobs — where's the line?
Running cache + rate limits + queues + streams on one Redis is fine at modest scale and delightful operationally. The line to watch: eviction policy conflicts. Cache wants allkeys-lru (evict freely); queues and locks must never be evicted (noeviction semantics). One instance can only have one policy — so the moment queues matter, split into two Redises: a volatile one for cache, a durable one (AOF on, noeviction) for everything that must not vanish. Two small instances beat one conflicted one, every time.
The series concludes with the production config that keeps all of this alive. And if your Redis is a mystery box of 40 million keys, I enjoy exactly that kind of spelunking.