Ten Million Keys, One Missing Index
DEV Community

Ten Million Keys, One Missing Index

This article explores how a per-entity index improves Redis cache invalidation by replacing repeated full-keyspace scans with targeted lookups. All performance figures come from local benchmarks. The accompanying demo includes the implementation, benchmark scripts and recorded results. Run it locally to repeat the experiments, explore the code and test the approach under different workloads. ❗ The problem A cache often sits in front of a slower dependency: a third-party API, a busy database or a service with a rate limit. The read path is straightforward: request → check cache → hit? → return → miss? → fetch from origin → write to cache with a TTL → return An exact lookup needs a key that includes every input affecting the result: :: :: :: :: subscriptions-api::brand-a::activeSubscription::u_9f3a21::{"includeAddons":true} subscriptions-api::brand-a::planConfig::pro-monthly::{"currency":"USD","billingPeriod":"month"} Good design for reading: every lookup is one exact-match GET . And because the parameter tail varies and several services cache the same value independently, one entity's cached state usually lives under five or ten different key strings. Then something upstream changes. A user's subscription expires. A plan's price is updated. You get one identifier and must remove everything cached about it. The identifier sits in the middle of the key. The service prefix varies. The tail is arbitrary. You cannot construct the key names from what you were given. To delete an entity's cached state you first have to discover what it's called. And then comes the interesting part: not how long each removal process takes, but what happens to every other request while it is running, because Redis executes commands on a single thread. For example, if you use the KEYS command to search for records matching an invalidation pattern across a dataset containing several million keys, the command can block the Redis thread for seconds. During that time, every other client - including one performing nothing more than a single GET - has to wait for the command to complete. 🔑 How the read path builds a key In the demo I built for this article, a decorator handles caching around the method that calls the billing provider: // The cached read of a user's subscription. The caching is the decorator - this method never names a // key or touches Redis. configureCache must have run first (test-api's bootstrap does it). import { Cache, CacheKey, CacheStrategy, TTL } from "@redis-entity-index/cache"; import type { Subscription } from "@redis-entity-index/fixture"; /** * Call parameters; they become the params segment of the cache key, so this type IS the key * contract. v selects the variant. Add nothing here that the bulk seeder does not also write. / export interface SubscriptionParams { v?: number; } /* What the service reads from on a miss - the S2S BillingClient, or a stub in tests. / export interface SubscriptionOrigin { getActiveSubscription(userId: string, params?: SubscriptionParams): Promise ; } export class SubscriptionService { constructor(private readonly billing: SubscriptionOrigin) {} @Cache(CacheKey.ACTIVE_SUBSCRIPTION, TTL.MEDIUM, CacheStrategy.ENTITY_INDEX_CACHE) getActiveSubscription(userId: string, params: SubscriptionParams = {}): Promise { return this.billing.getActiveSubscription(userId, params); } } The service method delegates to the origin. The decorator handles lookup and cache population through the selected strategy. The key combines the configured service and tenant, the selected cache entity name, and the method's arguments: /* * service::tenant::category::entityId::params. The entity ID is the first argument; params is the * canonical JSON of the second argument (or {} when absent), or of the whole tail when there are * several. An entity ID outside the segment character class throws - it is never escaped. / export function buildCacheKey( service: string, tenant: string, category: string, args: readonly unknown[], ): string { const [entityId, ...rest] = args; if (typeof entityId !== "string") { throw new TypeError(@Cache: the first argument must be the entity ID string, got ${typeof entityId}); } assertSegment("entityId", entityId); const params = rest.length :: :: MEMBERS: the full cache keys currently written for that entity So entityIndex::brand-a::activeSubscription::u_9f3a21 contains: subscriptions-api::brand-a::activeSubscription::u_9f3a21::{} workflow-svc::brand-a::activeSubscription::u_9f3a21::{"includeAddons":true} entitlement-api::brand-a::activeSubscription::u_9f3a21::{} Every service that writes the cache records the key it wrote. Invalidation reads one set and unlinks what it finds. Here is what that actually looks like in a running instance of the demo - two million cache records, one tenant: One cached value. The key carries every dimension that changes the answer, which is what makes reads a single exact-match GET - and what makes the key impossible to reconstruct from a user ID alone. The same user's index: a three-member set, one member per cached variant, with its own TTL. This is the entire mechanism. Invalidating that user is SMEMBERS , UNLINK , SREM - no matter how many keys surround it. Why use a set and not a hash? My earlier design stored cache keys and their expiry timestamps in a hash. During invalidation, expired entries were skipped based on those timestamps. This introduced a correctness risk: if the client clock was ahead of Redis, a key could be considered expired even though Redis still held it. The current design avoids client-side expiry checks and simply deletes every key recorded in the index. Since no extra value is needed, a Redis Set is a better fit. Redis still manages the TTL of the index itself. Entities keep the approach reusable: subscriptions can be indexed by user ID, plans by plan ID, and only most critical pre-configured cache entities are indexed. 🏗️ The implementation All of it is quoted verbatim from msilberg/redis-entity-index - the working demo built with a plain TypeScript 5. Two strategies, one connection The base strategy owns the shared Redis connection and provides ordinary reads and writes: export class DefaultCacheStrategy { /* Shared with every subclass - the point of the base class. / protected readonly redis: RedisClient; constructor(redis: RedisClient, _options?: DefaultCacheStrategyOptions) { this.redis = redis; } /* A plain GET. null is a miss. / get(key: string): Promise { return this.redis.get(key); } /* SET key value EX ttl. The TTL is validated before any command, so a rejected call writes nothing. / async set(key: string, value: string, ttlSeconds: number): Promise { assertValidTtl(ttlSeconds); await this.redis.set(key, value, "EX", ttlSeconds); } The excerpt shows the constructor, get and set methods. The entity-index strategy inherits the connection and overrides set : /* * Write a cache value and register it in its entity's index. * * Deliberately does NOT call super.set() on the indexed path. super.set() followed by a * separate registration is two round trips, and an invalidation landing between them would never * see the value - it would survive to its TTL with no reference pointing at it. registerMany * queues SET EX, SADD, EXPIRE NX and EXPIRE GT in one MULTI, so the value and its * reference land together. A key in a category this strategy does not own is a plain SET. / override async set(key: string, value: string, ttlSeconds: number): Promise { if (this.parse(key) === null) { await super.set(key, value, ttlSeconds); return; } await this.registerMany([{ cacheKey: key, value, ttlSeconds }]); } Read the comment abbove, because the code looks like a mistake and isn't. The obvious implementation of "write the value, then register it" is await super.set(...) followed by a registration. That is two round trips, and an invalidation landing between them never sees the value - which then survives to its TTL with nothing pointing at it. Delegating to registerMany puts the SET in the same transaction as the SADD . A key in a category this strategy doesn't own falls through to the parent and gets a plain SET , which is what the base class is for. 🎨 The decorator /* * Cache an async method's result. On a hit the method is not called. On a miss the result is written * through the strategy's set. Concurrent misses for one key in this process share one call. * * A rejection is never cached and writes nothing. undefined is never cached. null is cached only * with { cacheNegative: true }. */ export function Cache(cacheKey: CacheKey, ttl: TTL | number, strategy: CacheStrategy, options: CacheOptions = {}) { assertValidTtl(ttl); const negativeTtl = options.negativeTtl ?? ttl; if (options.cacheNegative === true) assertValidTtl(negativeTtl); return function ( target: AsyncMethod , context: ClassMethodDecoratorContext >, ): AsyncMethod { const methodName = String(context.name); // Single-flight: one in-flight load per key, per decorated method, in this process. const inFlight = new Map >(); // async with no await on purpose: an unconfigured cache or a bad entity ID throws synchronously // below, and callers expect a rejected promise, not an exception from the call expression. return async function (this: This, ...args: Args): Promise { const { service, tenant, strategies } = requireRegistry(@Cache on ${methodName}()); const key = buildCacheKey(service, tenant, cacheKey, args); const store = strategies[strategy]; const pending = inFlight.get(key); if (pending !== undefined) return pending; const load = (async (): Promise => { const cached = await store.get(key); if (cached !== null) return JSON.parse(cached) as Result; // No try/catch here on purpose: a rejection must propagate and write nothing. const result = await target.apply(this, args); if (result === undefined) return result; if (result === null) { if (options.cacheNegative === true) await store.set(key, "null", negativeTtl); r

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.