0%
(updated )5 min read🟢BeginnerRedis

Redis Features: Cache the Online Path, Never the Source of Truth

We trained on warehouse snapshots and served from Redis. TTL eviction and a stampede later, the model looked broken. Online features are a cache, not a fact table.

D
Dinesh ChandraSenior Data & AI Engineer · SnowPro Certified
Illustrated overview of Redis Features: Cache the Online Path, Never the Source of Truth
  • ✓Redis is the online cache, not the system of record. Eviction is a successful cache, not a data loss surprise.
  • ✓TTL and `maxmemory` will drop keys. If training cannot rebuild them, you do not have features, you have luck.
  • ✓Train/serve leakage starts when the training join is not the same snapshot the cache was filled from.
  • ✓A cache stampede is thundering herd on expiry. Single-flight the fill or you will melt the warehouse.
  • ✓Point-in-time history lives in the warehouse. Redis holds now, and now is allowed to disappear.
Table of contents

The churn model’s live precision fell off a cliff on a Thursday. Batch backtests were fine. Serving logs showed orders_30d = 0 for users we knew were active. Redis had evicted the hash under memory pressure. The API treated a miss as zero, not as “go compute.” Product thought the model had died. The cache had.

Worse: training still joined a warehouse snapshot as-of the event. Serving read whatever Redis had at request time, if it had anything. We had two definitions of the same feature name and a TTL of six hours that nobody had matched to the training window. That is leakage and silence at the same time.

I treat Redis as an online materialization of a warehouse feature, with a miss path and a stampede plan. I do not treat it as a fact table. The MLOps post is the join discipline. This is the cache that sits in front of it.

Misses are normal. Zeros are a lie

maxmemory plus an eviction policy means keys go away. TTL means keys go away on a timer. Both are working as designed. The serving path has to know the difference between “feature is zero” and “feature is absent.”

import json
import time
from redis import Redis

redis = Redis.from_url("redis://features:6379/0")
FEATURE_TTL_S = 6 * 3600

def get_orders_30d(user_id: str) -> int:
    key = f"feat:orders_30d:{user_id}"
    raw = redis.get(key)
    if raw is not None:
        return int(raw)

    # Miss: compute from the same SQL the training snapshot uses.
    value = warehouse_orders_30d(user_id)
    # SET NX + TTL, or a lock, so expiry does not stampede.
    redis.set(key, value, ex=FEATURE_TTL_S, nx=True)
    if redis.get(key) is None:
        redis.set(key, value, ex=FEATURE_TTL_S)
    return value

The warehouse function is the source of truth. Redis is the hot copy. If you cannot run that SQL for a miss, you should not be serving the model from Redis.

flowchart TD
  req["Serving request"] --> cache{"Redis hit?"}
  cache -->|yes| model["Score"]
  cache -->|no| lock["Single-flight fill"]
  lock --> wh["Warehouse snapshot SQL"]
  wh --> set["SET with TTL"]
  set --> model
  evict["TTL or eviction"] --> miss["Next request misses"]
  miss --> lock

A miss recomputes. A miss that becomes zero is how a good model looks broken.

Training snapshot must match the fill

If training uses orders_30d as-of event_at from a type-2 table, and serving uses “whatever is in Redis right now,” you have two features. The honest online path is: fill Redis from the current row of that same model, on a schedule, and accept that online is “as of last fill,” not “as of an arbitrary historical event.”

Historical training rows never come from Redis. They come from the warehouse history. Mixing them is how notebooks beat production.

I keep a daily sample: pull N keys from Redis, recompute from batch SQL, diff. The parity habit in the MLOps post is the same check. Redis just makes the mismatch look like infrastructure.

Cache stampede

When a popular key expires, every replica of the API will miss at once and hit the warehouse. That is a self-inflicted load spike. Single-flight with a short lock, jitter the TTL, or refresh before expiry on a worker. Do not “retry harder” on the miss path.

I also do not persist Redis as the audit of what we served. If you need that, log the feature vector next to the score, or you will never reproduce a Thursday.

A fill you can single-flight

The sketch above can still stampede if every replica misses before the first SET lands. I use a short lock and jitter so expiry is not synchronized across the keyspace.

import random
from redis.exceptions import LockError

LOCK_TTL_S = 10

def get_orders_30d_locked(user_id: str) -> int:
    key = f"feat:orders_30d:{user_id}"
    raw = redis.get(key)
    if raw is not None:
        return int(raw)

    lock = redis.lock(f"lock:{key}", timeout=LOCK_TTL_S, blocking_timeout=2)
    try:
        lock.acquire()
        raw = redis.get(key)
        if raw is not None:
            return int(raw)
        value = warehouse_orders_30d(user_id)
        ttl = FEATURE_TTL_S + random.randint(0, 300)
        redis.set(key, value, ex=ttl)
        return value
    except LockError:
        # Another replica is filling. Fail closed, not to zero.
        raise FeatureMiss("orders_30d unavailable")
    finally:
        try:
            lock.release()
        except LockError:
            pass

Fail closed means the model is not scored, or you fall back to a batch score, not to orders_30d = 0. Product can retry. Finance cannot un-teach a model that zeros mean inactive.

What I put in the serving log

When something looks like a model regression, I need the feature vector next to the score, not a Redis dump from an hour later.

request_id  user_id  score  orders_30d  cache  fill_ms
a1b2        u_99     0.12   0           miss   84

cache=hit|miss|error is the column that saved us on Thursday. Without it, everyone debates hyperparameters.

More on train/serve joins: MLOps for data engineers and the Redis hub.

FAQ

Is RedisJSON / RedisSearch a warehouse? No. They are serving features. History and grain tests stay in SQL.

AOF or RDB so we do not lose features? Persistence is fine for restart. It is not point-in-time training data. If the instance is gone, the warehouse fill must rebuild the keyspace, or you did not have features.

How long should TTL be? Match the freshness the model was trained for, then add jitter. A 30-day feature with a 10-minute TTL is a different feature every request.

Can I use Redis as the CDC buffer? Use Kafka or the warehouse landing zone. Redis lists evict. Brokers do not (if you set retention on purpose).

What about Redis Cluster vs a single primary? Cluster if the keyspace will not fit. Hash tags if you need multi-key ops. The miss path and TTL rules do not change.

Pitfalls

GET miss mapped to 0. You taught the model that unknown users look like inactive ones.

No TTL and no maxmemory policy. You will OOM the instance and lose everything at once instead of gradually.

Training from Redis dumps. You cannot point-in-time join a cache.

Filling Redis from a different SQL than dbt. Train/serve skew with extra steps.

Using Redis lists as an event log. That is a broker’s job. Redis will forget the prefix you needed.

What this means for your pipelines

Online features belong in Redis when the latency SLO requires it. Facts belong in the warehouse, with history you can as-of join. I fill the cache from the same definition I train on, I treat eviction as a miss, and I single-flight the fill.

The Thursday drop was not a model regression. It was a cache that we had promoted to a database, then shrugged when memory ran out. Put the record in silver. Let Redis be fast and forgetful on purpose.

Share this post:X / TwitterLinkedInRedditWhatsApp
D

Dinesh Chandra

Senior Data & AI Engineer · SnowPro Core & Gen AI Certified

4+ years building production data platforms on Snowflake, Databricks, AWS, and Azure. I write from hands-on experience with Medallion architectures, dbt, Airflow, PySpark, and Snowflake Cortex AI. Every tutorial on this blog reflects real pipeline decisions — not documentation rewrites.

Read more about the author →

Search DataLane

↑↓ navigate↵ openesc close