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.
- ✓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.
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 →