NoSQL databases · Intermediate

Redis for caching, rate limits and leaderboards

3 min read

Why it matters

Redis is the most common companion to a main database. It is an in-memory key-value store with rich data types, used to take load off databases, protect APIs and serve fast counters. Almost every backend interview that touches performance ends up at caching, and caching is where many subtle production bugs live.

The concept

  • Cache-aside. Read from the cache; on a miss, read from the source and store the result with a time-to-live (TTL). The TTL bounds how stale data can get.

  • Invalidation. When the source changes, delete or update the cached key. Choose TTLs based on how stale the data may safely be.

  • Rate limiting. Count requests per user per time window with INCR and EXPIRE. A fixed window is simple; sliding windows or token buckets are smoother.

  • Sorted sets keep members ordered by score, so rankings and "top N" are single commands.

  • Durability. Redis keeps data in memory. Persistence options exist, but treat a cache as disposable: your system must work, more slowly, if it is empty.

Worked example

Start Redis with docker run -d -p 6379:6379 redis:7 and install the client with pip install redis.

cache_redis.py
import json
import time

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.flushdb()

def slow_product_lookup(sku: str) -> dict:
    time.sleep(0.3)  # stands in for a slow database query or API call
    return {"sku": sku, "name": "Mixer", "price": 3299}

def get_product(sku: str) -> dict:
    key = f"product:{sku}"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)
    product = slow_product_lookup(sku)
    r.set(key, json.dumps(product), ex=60)  # cache-aside with a 60-second TTL
    return product

for attempt in range(2):
    t0 = time.perf_counter()
    get_product("SKU-2")
    print(f"lookup {attempt + 1}: {(time.perf_counter() - t0) * 1000:.0f} ms")
print("ttl left:", r.ttl("product:SKU-2"), "s")

def allow(user: str, limit: int = 5, window: int = 60) -> bool:
    key = f"rate:{user}:{int(time.time() // window)}"
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, window)
    count, _ = pipe.execute()
    return count <= limit

print("requests allowed:", [allow("asha") for _ in range(7)])

r.zadd("leaderboard", {"asha": 1200, "ravi": 950, "meera": 1430, "john": 700})
r.zincrby("leaderboard", 300, "ravi")
print("top 3:", r.zrevrange("leaderboard", 0, 2, withscores=True))
shellsession
$ python3 cache_redis.py
lookup 1: 302 ms
lookup 2: 0 ms
ttl left: 60 s
requests allowed: [True, True, True, True, True, False, False]
top 3: [('meera', 1430.0), ('ravi', 1250.0), ('asha', 1200.0)]

The second lookup comes from memory. The rate limiter lets five requests through in the current minute and blocks the rest (if you run it exactly as a minute ends, the count restarts in the new window, which is the known weakness of fixed windows). ZINCRBY updates Ravi's score and the ranking in one step.

Common mistakes

  • Stale data. A price changes but the cache serves the old one until the TTL ends. Delete the key on writes, and keep TTLs short for data that matters.

  • Stampedes. A popular key expires and hundreds of requests hit the database at once. Add jitter to TTLs, or let one request refresh while others wait or serve stale data.

  • Caching errors or empty results by accident, or caching per-user data under a shared key, which can leak one user's data to another.

  • Unbounded memory. Set maxmemory and an eviction policy such as allkeys-lru for pure caches.

Interview angle

"How would you add caching to this endpoint?" Explain cache-aside, TTL choice, invalidation on writes, what happens when Redis is down, and how you would measure hit rate. "Design a rate limiter" is a classic: start with the fixed window above, then explain its boundary problem and the sliding-window or token-bucket alternatives.

Practice task

  1. Add a update_price(sku, price) function that writes to the source and invalidates the cache, and show that the next read returns the new price.

  2. Implement a sliding-window limiter with a sorted set of request timestamps, and compare it with the fixed window at a window boundary.

Further reading