Appearance
Caching β
This document explains how our caching works with FusionCache without reading the codebase.
TL;DR
- Two-tier cache: in-memory (Level-1) + Redis (Level-2).
- Redis Backplane keeps nodes in sync (pub/sub invalidations).
- JSON serialization for distributed payloads.
- Time-based expirations + fail-safe + soft/hard timeouts prevent stampedes.
- Best practice: also cache missing data (negative caching) briefly to protect the database.
1) Registration (what we wire up) β
csharp
services.AddFusionCache()
.WithSerializer(new FusionCacheNewtonsoftJsonSerializer())
.WithDistributedCache(new RedisCache(
new RedisCacheOptions { Configuration = "CONNECTION STRING" }
))
.WithBackplane(new RedisBackplane(
new RedisBackplaneOptions { Configuration = "CONNECTION STRING" }
));Implications
- Serializer: payloads stored in Redis are serialized with Newtonsoft.Json. Only the distributed tier is serialized; in-memory keeps raw objects.
- Distributed cache: Redis backs Level-2 (L2). All instances share the same L2.
- Backplane: a Redis pub/sub channel used for coherent invalidations across app instances. When one node updates/evicts, others are notified.
2) Architecture at a Glance β
βββββββββββββββββββββββββββββββ
Request ββββββββΆ β FusionCache (per app node) β
βββββββββββ¬ββββββββββββββββββββ€
β L1: β In-Memory Cache β (fastest, per-process)
βββββββββββΌββββββββββββββββββββ€
β L2: β Redis Cache β (shared, serialized)
βββββββββββ΄ββββββββββββββββββββ
β² β²
β β
Redis Backplane (invalidations/broadcast)- L1 hit β return immediately.
- L1 miss β check L2 (Redis).
- L2 hit β deserialize, populate L1, return.
- L2 miss β execute the factory (your data loader), then set in L1 and L2, and broadcast change via the Backplane.
3) Expiration & Resilience β
FusionCache supports multiple timers per entry:
- Duration (Absolute Expiration) β fixed lifetime.
- Jitter β small randomization to spread expirations and prevent synchronized reloads.
- Fail-Safe Max Duration β how long a stale value can be served if the factory fails.
- Soft vs Hard Expiration β soft lets reads continue (possibly with stale) while a background refresh happens; hard means the entry is considered dead.
Recommended defaults
- Absolute: 5β30 minutes for read-heavy lists; 1β5 minutes for volatile entities.
- Jitter: 10β20% of the absolute TTL.
- Fail-Safe: 1β5 minutes for critical paths (enough to ride out brief outages).
Example:
csharp
var options = new FusionCacheEntryOptions()
.SetDuration(TimeSpan.FromMinutes(10))
.SetJitter(TimeSpan.FromMinutes(1))
.SetFailSafe(true)
.SetFailSafeMaxDuration(TimeSpan.FromMinutes(3))
.SetSoftTimeout(TimeSpan.FromMilliseconds(150))
.SetHardTimeout(TimeSpan.FromSeconds(2));4) Negative Caching (caching βmissingβ) β
Why If a key often results in βnot foundβ (e.g., an Item that doesnβt exist), repeated misses can hammer the database (N calls β N queries). Best practice: cache the βmissingβ result for a short time (e.g., 15β60 seconds).
How Store a sentinel or allow null and distinguish at the boundary.
csharp
// Example: cache "not found" for 30s to avoid DB hammering
var entity = await cache.GetOrSetAsync<MyEntity?>(
$"items:by-id:{id}",
async _ => await repository.GetByIdAsync(id), // returns null if missing
new FusionCacheEntryOptions()
.SetDuration(TimeSpan.FromSeconds(30)) // short TTL for negative cache
.SetJitter(TimeSpan.FromSeconds(5))
.SetFailSafe(true)
);
// Caller decides how to handle null (404, etc.)
if (entity is null)
return Results.NotFound();Notes
- Keep negative TTLs short to avoid masking eventual consistency (e.g., a record added right after a miss).
- Use a different policy or key suffix if you need different durations for βexistsβ vs βmissingβ.
5) FAQ β
Should we cache huge lists? No, caching huge lists changes the source of truth instead of just reducing the load on the database.
What about per-user cache? If needed, include the user or tenant in the key (e.g.,
tenant:{t}:items:...). Watch cardinality.Can we store nulls? Yes. Use short TTL and document that
nullmeans βnot found (recently).βWhen do we bypass cache? For strongly consistent reads after a critical write, read-through after
ExpireAsync, or use a βno-cacheβ path for admin tasks.