Multi-Tenant Rate Limits: Redis Cluster Token Buckets Without Hot Keys

Multi-Tenant Rate Limits: Redis Cluster Token Buckets Without Hot Keys

A single Redis key per tenant becomes a hotspot when that tenant is your largest customer—or when everyone shares one global key. On Redis Cluster you also cannot multi-key Lua across slots casually. Design buckets that shard by (tenant, route, shard), enforce fairness with per-tenant ceilings, and keep Lua atomic within one slot.

⚡ TL;DR: Key = {tenantId}:rl:{route}:{bucketShard}; hash-tag tenant for locality; atomic token bucket via Lua; independent limits per route; avoid one key for the whole fleet. Complements Lambda Reserved Concurrency: Bulkheads That Protect Tenant Workloads and Multi-Tenant Coding Assistants: Isolated ECS Fargate Spot Runtimes.

Token bucket Lua (single slot)

// rateLimit.js — Node 20
import Redis from "ioredis";

const redis = new Redis.Cluster([{ host: process.env.REDIS_HOST }]);

// KEYS[1] bucket key (must include hash tag)
// ARGV: capacity, refill_per_ms, now_ms, cost
const BUCKET = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * refill)
local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 60000)
return {allowed, tokens}
`;

export async function takeToken({ tenantId, route, capacity, rps }) {
  const shard = hashShard(tenantId, 16); // optional spread for huge tenants
  const key = `{${tenantId}}:rl:${route}:${shard}`;
  const refillPerMs = rps / 1000;
  const [allowed] = await redis.eval(
    BUCKET, 1, key, capacity, refillPerMs, Date.now(), 1
  );
  return allowed === 1;
}

function hashShard(id, n) {
  let h = 0;
  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
  return h % n;
}
// ❌ Hot key — every request in the company
await redis.decr("global:rate");

Fairness under bursty tenants

  • Per-tenant capacity separate from per-route capacity (both must pass).
  • Soft limit then hard limit with 429 + Retry-After.
  • Optional global cell limit to protect shared downstreams (bulkheads).
export async function authorize(tenantId, route) {
  const tenantOk = await takeToken({
    tenantId, route: "_tenant", capacity: 500, rps: 100,
  });
  if (!tenantOk) return { ok: false, reason: "tenant_limit" };
  const routeOk = await takeToken({
    tenantId, route, capacity: 50, rps: 20,
  });
  if (!routeOk) return { ok: false, reason: "route_limit" };
  return { ok: true };
}

Cluster and hash tags

{tenantId} keeps all that tenant’s bucket keys on one slot—good for locality, bad if one tenant is enormous (then shard suffix). Never attempt a single Lua script across keys in different slots without a Cluster-aware design.

Observability

Emit rate_limit.allow / rate_limit.deny with tenant, route, reason. Alert on deny spikes and on Redis hot-key CPU. Load-test the largest tenant’s key; if Redis node CPU pegs, increase shard count.

Closing checklist

✅ Dos
– ✅ Hash-tag keys for Cluster-safe Lua
– ✅ Shard buckets for mega-tenants
– ✅ Stack tenant + route (+ cell) limits
– ✅ Return Retry-After on 429
– ✅ Meter denies per tenant for abuse detection

❌ Don’ts
– ❌ Don’t use one global counter key
– ❌ Don’t multi-key Lua across slots
– ❌ Don’t silently drop requests without metrics
– ❌ Don’t set infinite TTL on bucket hashes
– ❌ Don’t rely on Redis alone for billing-grade quotas without durable logs

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply