Back to blogs
2025-08-09
3 min read
Redis – Beyond Just an In‑Memory Database
Practical patterns with Redis: caching, rate limits, streams, pub/sub, locks, and Lua. Tradeoffs, pitfalls, and production guidance.
Redis – Beyond Just an In‑Memory Database
Redis is often labeled a cache, but it’s a multi‑model data platform that can power queues, search, coordination, and analytics when used thoughtfully.
Project Name and Context
- Project: Redis usage patterns for production systems
- Context: Web backends, jobs, rate limiting, realtime features
- Goal: Low‑latency primitives with predictable ops and cost
Functionality and Purpose
- Caching (write‑through, write‑around, cache‑aside)
- Rate limiting (token bucket/leaky bucket)
- Queues/Streams for background work
- Distributed locks (best‑effort) and coordination
- Pub/Sub for real‑time fan‑out
Problem It Solves
- Offloads hot reads from primary DB
- Smooths bursty workloads (queue + workers)
- Enables real‑time UX and collaboration
- Provides cheap primitives for coordination (with caveats)
Tech Stack Used
- Server: Node.js/TypeScript with
ioredis
- Data: Redis standalone or cluster, persistence (AOF/RDB)
- Observability: Redis INFO, keyspace notifications, Grafana/Prometheus
How I Built It (or Use It)
Cache‑Aside with TTL
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
export async function getUser(userId: string) {
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const fresh = await db.users.findById(userId); // origin
if (fresh) await redis.set(cacheKey, JSON.stringify(fresh), "EX", 300);
return fresh;
}
Token Bucket Rate Limit
-- rate_limit.lua (atomic)
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = redis.call('TIME')[1]
local cnt = tonumber(redis.call('GET', key) or '0')
if cnt == 0 then redis.call('SETEX', key, window, 1) return 1 end
if cnt < max then redis.call('INCR', key) return max - (cnt + 1) end
return 0
// usage
const remaining = await redis.eval(rateLimitLua, 1, `ratelimit:${ip}`, 100, 60);
if (!remaining) return res.status(429).send("Too many requests");
Streams for Work Queues
// producer
await redis.xadd("video:ingest", "*", "file", fileId, "u", userId);
// consumer group
await redis.xgroup("CREATE", "video:ingest", "workers", "$", "MKSTREAM");
const msgs = await redis.xreadgroup(
"GROUP",
"workers",
workerId,
"COUNT",
10,
"BLOCK",
5000,
"STREAMS",
"video:ingest",
">"
);
// ack with XACK after processing
Best‑Effort Distributed Lock
// Simple: SET key value NX PX ttl
const token = crypto.randomUUID();
const ok = await redis.set(`lock:resource`, token, "NX", "PX", 5000);
if (!ok) throw new Error("locked");
try {
// do critical work
} finally {
// Lua compare‑and‑delete to avoid deleting other's lock
}
Unique Implementations or System Design
- Hot key mitigation: sharding or request coalescing
- Stale‑while‑revalidate: serve cached, refresh in background
- Write policies: choose WT/WA/CA per workload
- Persistence: AOF everysec vs RDB snapshots; understand durability
Realistic Challenges or Tradeoffs
- Lossy locks: Redlock is best‑effort; don’t guard financial invariants
- Cache invalidation: the hard problem—favor TTLs + versioned keys
- Memory pressure: eviction policy (allkeys‑lfu) affects hit ratios
- Cluster cost: network hops; watch p95/99 latency
What I Learned or Improved
- Treat Redis as a performance tier, not a source of truth
- Push invariants to durable stores; use idempotency on writers
- Prefer simple, observable patterns over clever ones