Fallback Routing Strategies in Redis Cluster Environments
This page covers how to choose and implement a deterministic fallback path when a primary Redis Cluster read degrades — deciding between replica reads, a secondary datastore, or a bounded stale-cache serve — without amplifying load or violating slot ownership.
Fallback routing in distributed caching systems is not a simple retry mechanism; it is a traffic-shaping discipline that preserves latency SLAs and prevents thundering-herd conditions when primary cache paths degrade. A robust router must anticipate slot migrations, eviction pressure, and network partitions while holding consistency boundaries fixed. It builds directly on the mechanics described in Redis Caching Architecture & Invalidation Fundamentals, where the interaction between client-side routing logic, cluster gossip, and key distribution dictates how traffic can be safely redirected under stress.
Architectural Trade-offs
There is no single fallback path. Production routers pick — per key class, per failure signal — between serving from a replica, cascading to a secondary store, serving bounded-stale data from a local process cache, or failing fast. Each choice trades freshness against availability and operational cost. The core decision mirrors the broader question of where a miss is resolved, which is examined in depth under cache-aside vs read-through patterns.
| Fallback strategy | Consistency | Latency | Write Amplification | Operational Complexity |
|---|---|---|---|---|
| Replica read | Eventual (bounded by replication lag) | Low — same cluster, one extra hop | None on the read path | Low — reuses cluster topology |
| Secondary datastore | Strong (authoritative source) | High — cross-system round trip | High — every miss re-populates cache | Medium — connection pools, retries, coalescing |
| Bounded stale local cache | Weak (age-capped) | Lowest — in-process, no network | None | Medium — staleness budget + invalidation feed |
| Fail-fast (no fallback) | N/A — caller handles absence | Lowest on failure | None | Lowest, but pushes burden to callers |
The two strategies that carry most production traffic are replica reads (Approach A) and secondary-store degradation (Approach B). The stale-local and fail-fast options are refinements layered on top of those two, and are developed further in Designing Graceful Fallback Routing for Cache Misses.
Topology-Aware Routing and Hash Slot Ownership
Redis Cluster routes requests by mapping every key to one of 16,384 slots via CRC16. Fallback logic cannot blindly redirect traffic to an arbitrary node without violating slot ownership or triggering cascading MOVED/ASK redirections. The full model of how slots are assigned and moved is covered in Redis Cluster slot allocation basics; a fallback router only has to respect three rules that follow from it. A MOVED reply means the slot has permanently changed owner and the client must refresh its slot map before retrying. An ASK reply means the slot is mid-migration — the router must issue ASKING and retry against the target node exactly once, without caching the new location. A timeout or connection error means the node is unreachable and the request should be routed to a replica or a secondary path.
Setting cluster-require-full-coverage no in production lets the Redis cluster keep serving reachable slots while a subset is unavailable during a reshard or node failure, rather than refusing all traffic:
redis-cli CONFIG SET cluster-require-full-coverage no
redis-cli CONFIG REWRITE
A resilient router classifies each failure signal and routes accordingly rather than treating every error the same way:
Slot refresh intervals must be instrumented so the routing table never goes stale. The mechanics of replica promotion and how a lost primary is replaced are covered in Understanding Redis Cache Topology. When a slot loses its primary, the fallback router should target the highest-priority replica, apply a short exponential backoff, and validate the replica's master_link_status before it trusts the response.
Approach A — Replica-Read Fallback Router
The lowest-latency fallback keeps traffic inside the Redis cluster: when the primary owning a slot is degraded, read from one of its replicas. This tolerates the primary being slow or briefly unreachable and adds only one intra-cluster hop, at the cost of serving data that may lag the primary by the current replication delay. The router below uses redis-py 5.x async cluster support, refreshes its slot map on MOVED, handles ASK mid-migration, targets replicas on connection failure, and emits OpenTelemetry spans and Prometheus metrics for every routing decision.
import asyncio
import time
from typing import Optional, Any, Callable
from redis.asyncio.cluster import RedisCluster, ClusterNode
from redis.exceptions import MovedError, AskError, ConnectionError, TimeoutError
from opentelemetry import trace
from opentelemetry.trace import SpanKind
from prometheus_client import Counter, Histogram
FALLBACK_ROUTING_ATTEMPTS = Counter(
"redis_fallback_routing_attempts_total",
"Total fallback routing attempts",
["status"],
)
FALLBACK_LATENCY = Histogram(
"redis_fallback_routing_latency_seconds", "Fallback routing latency"
)
tracer = trace.get_tracer("redis.fallback_router")
class ResilientClusterRouter:
def __init__(
self, cluster_nodes: list[str], max_retries: int = 3, base_backoff: float = 0.05
):
# startup_nodes expects ClusterNode objects, not "host:port" strings.
nodes = [ClusterNode(h, int(p)) for h, p in (n.split(":") for n in cluster_nodes)]
self.cluster = RedisCluster(startup_nodes=nodes, decode_responses=True)
self.max_retries = max_retries
self.base_backoff = base_backoff
self._slot_cache_ts = 0.0
self._slot_refresh_interval = 15.0
async def _refresh_slots_if_stale(self):
if time.monotonic() - self._slot_cache_ts > self._slot_refresh_interval:
await self.cluster.initialize()
self._slot_cache_ts = time.monotonic()
async def get_with_fallback(
self, key: str, fallback_source: Optional[Callable] = None
) -> Any:
with tracer.start_as_current_span("redis.get_with_fallback", kind=SpanKind.CLIENT) as span:
span.set_attribute("db.key", key)
attempt = 0
backoff = self.base_backoff
while attempt < self.max_retries:
try:
await self._refresh_slots_if_stale()
with FALLBACK_LATENCY.time():
value = await self.cluster.get(key)
FALLBACK_ROUTING_ATTEMPTS.labels(status="primary_hit").inc()
return value
except MovedError as e:
# Slot permanently reassigned: refresh the map, then retry on the owner.
span.add_event("slot_moved", attributes={"moved_to": str(e.args[0])})
await self.cluster.initialize()
backoff *= 2
except AskError as e:
# Slot mid-migration: retry on target WITHOUT caching the new location.
span.add_event("ask_redirect", attributes={"ask_node": str(e.args[0])})
await asyncio.sleep(backoff)
backoff *= 2
except (ConnectionError, TimeoutError):
# Primary unreachable: attempt a replica read before giving up.
FALLBACK_ROUTING_ATTEMPTS.labels(status="primary_failover").inc()
await asyncio.sleep(backoff)
backoff *= 2
try:
replica_value = await self._route_to_replica(key)
if replica_value is not None:
return replica_value
except Exception:
pass
except Exception as e:
span.record_exception(e)
FALLBACK_ROUTING_ATTEMPTS.labels(status="error").inc()
break
attempt += 1
if fallback_source:
FALLBACK_ROUTING_ATTEMPTS.labels(status="secondary_fallback").inc()
return await fallback_source(key)
return None
async def _route_to_replica(self, key: str) -> Optional[str]:
"""Best-effort replica read when the primary is degraded.
For automatic replica routing on every read, build the cluster client with
read_from_replicas=True. This method iterates available replica nodes as a
manual fallback when the primary is unreachable.
"""
for node in self.cluster.get_nodes():
if node.server_type == "replica" and node.redis_connection is not None:
try:
return await node.redis_connection.get(key)
except Exception:
continue
return None
Replica reads are only safe when the caller tolerates replication lag. Before committing to a replica value on a critical read path, check master_link_status:up on the replica; a replica whose link to its primary is down may be serving arbitrarily stale data and should be skipped in favour of Approach B.
Approach B — Secondary-Store Degradation with a Staleness Budget
When the whole slot is unavailable — both primary and replicas unreachable, or the Redis cluster refusing the slot — the router must cascade to an authoritative secondary source (the origin database or an object store) and re-populate the cache on the way back. This restores correctness at the cost of a cross-system round trip and write amplification: every miss now re-writes the cache. The freshness contract for this path depends entirely on the invalidation model in use, examined in TTL vs Explicit Invalidation. Under a TTL-driven policy the router may serve age-capped data from a local process cache; under explicit invalidation it must route strictly to the source and honour the last-invalidation timestamp.
The staleness budget makes that contract explicit. Before serving anything older than the primary, the router asks whether the data is within an acceptable age given the key's invalidation semantics. Invalidation timestamps are cheapest to track via a lightweight feed — for example a Redis Stream fanned out by pub/sub routing for cross-service invalidation — that the router consumes into a local map:
import time
async def validate_staleness_budget(
key: str, max_age_seconds: float = 2.0
) -> bool:
"""Return True if a stale fallback value is still within its age budget.
last_invalidation_ts is sourced from a local map kept warm by an
invalidation feed (Redis Streams / pub-sub), not a synchronous lookup.
"""
last_invalidation_ts = await get_invalidation_timestamp(key)
return (time.time() - last_invalidation_ts) <= max_age_seconds
async def get_with_degraded_read(router, key: str, load_from_origin):
"""Approach B: prefer bounded-stale, else authoritative origin, then repopulate."""
cached = await router.get_with_fallback(key)
if cached is not None and await validate_staleness_budget(key):
return cached
value = await load_from_origin(key) # authoritative, expensive path
await router.cluster.set(key, value) # repopulate for the next reader
return value
A secondary-store fallback that runs unguarded is how a slow origin turns a partial cache outage into a full one. Two guards are mandatory: request coalescing, so N concurrent misses on the same key trigger one origin load rather than N, and a circuit breaker that trips the secondary path when its latency exceeds the SLO — both detailed in the child page on designing graceful fallback routing for cache misses.
When to Choose Which
The decision is driven by concrete production signals, not preference. Evaluate them in order:
- Consistency SLA. If the read must reflect the last write within milliseconds (payments, inventory decrement, auth tokens), skip replica fallback and route to the authoritative source (Approach B) or fail fast. Replica lag is unbounded during a failover storm.
- Read/write ratio and origin cost. For read-heavy keys backed by an expensive origin query (>10 ms, or a rate-limited upstream), prefer replica reads (Approach A) and cap secondary-store fallback with coalescing — the write amplification of re-populating on every miss will otherwise overload the origin.
- Replication lag headroom. If
master_repl_offsetminus the replica'sslave_repl_offsetroutinely stays under your staleness budget, replica fallback is safe; if lag spikes above it during peak, demote replica reads to a last resort behind the staleness check. - Blast radius under partition. During a network split, replica reads inside a minority partition can serve data that a promoted primary in the majority partition has already superseded. High-value writes must route only to the majority partition (see below), regardless of latency cost.
- Team operational burden. A secondary-store path adds connection pools, breakers, and coalescing to own and alert on. If the on-call team cannot operate that reliably, a bounded stale-local cache with a tight age budget is a lower-risk default for non-critical keys.
As throughput climbs, the crossover point shifts: below a few thousand reads/sec the origin can usually absorb secondary-store fallback directly; above it, replica reads plus coalescing become mandatory to keep the origin from collapsing under a stampede.
Failure Modes and Diagnostics
Three failure modes dominate fallback routing in a live cluster.
Stale routing table (MOVED storms). After a reshard, a client with a cold slot map issues requests to the old owner and takes a MOVED on nearly every call, doubling latency and load. Diagnose by watching redirect volume and cluster state:
redis-cli CLUSTER INFO | grep cluster_state
redis-cli --cluster check 127.0.0.1:7000 # lists migrating / unassigned slots
If cluster_state is ok but redirects persist, the client is not refreshing its map — force cluster.initialize() and shorten the refresh interval. Resharding-specific handling is covered in zero-downtime slot migration.
Split-brain replica serving superseded data. During a partition, a replica isolated in the minority partition keeps answering reads while a replacement primary is promoted in the majority partition. The fallback router must detect the partition and route writes and critical reads exclusively to the majority. Set cluster-node-timeout low enough that a rogue primary stops accepting writes before quorum re-forms, and set cluster-migration-barrier so replicas promote only with sufficient consensus.
Eviction-driven miss cascade. When memory pressure mounts, the router must distinguish transient misses caused by capacity eviction from structural misses caused by explicit invalidation. Recently evicted hot keys should fall back to a secondary path rather than triggering full recomputation for every reader. The eviction behaviour itself — and how policy choice changes which keys disappear first — is covered in LRU vs LFU eviction policies. Confirm eviction is the cause before changing routing:
redis-cli INFO stats | grep -E "evicted_keys|keyspace_misses"
redis-cli INFO memory | grep -E "used_memory:|maxmemory:"
A rising evicted_keys delta alongside a miss spike points at capacity, not invalidation — widen maxmemory or adjust maxmemory-policy rather than re-tuning the router.
Verification
Confirm the router behaves correctly in a live cluster before and after any routing change.
# 1. Slot coverage is complete and the cluster is healthy
redis-cli CLUSTER INFO | grep -E "cluster_state|cluster_slots_assigned"
redis-cli --cluster check 127.0.0.1:7000
# 2. Replica links are up before trusting replica-read fallback
redis-cli -h <replica-host> -p <port> INFO replication | grep master_link_status
# 3. Replication lag is inside the staleness budget
redis-cli -h <primary> -p <port> INFO replication | grep master_repl_offset
redis-cli -h <replica> -p <port> INFO replication | grep slave_repl_offset
Then assert the routing metrics reflect a healthy fallback rate. Export and alert on these:
redis_cluster_slots_assigned/redis_cluster_slots_okredis_fallback_routing_attempts_total{status="primary_hit|primary_failover|secondary_fallback|error"}redis_fallback_routing_latency_seconds(p50, p95, p99)
A sustained secondary-fallback rate above roughly 15% over a five-minute window indicates real cluster degradation rather than isolated node flapping, and should page. For forced recovery once a primary is confirmed dead:
redis-cli -h <replica-host> -p <port> CLUSTER FAILOVER TAKEOVER
# CLUSTER RESET HARD only on decommissioned nodes that will never rejoin the cluster
redis-cli -h <decommissioned-host> -p <port> CLUSTER RESET HARD
Runbook — fallback routing degradation:
- Verify
CLUSTER INFOshowscluster_state:okandcluster_slots_assigned:16384. - Run
redis-cli --cluster checkfor unassigned or migrating slots. - Confirm client-side slot refresh is firing and
cluster-require-full-coverage nois active. - If fallback p95 latency exceeds 50 ms, enable request coalescing and drop
max_retriesto 2. - Check
master_link_statuson replicas; ifdown, trigger a manualCLUSTER FAILOVER TAKEOVER. - Post-incident, audit
INFO statsevicted_keysand reconcilemaxmemory-policy.
Up one level: Redis Caching Architecture & Invalidation Fundamentals