Concurrent, Lazy, Redis, Cache
In this example, I combine concurrency, caching, and Redis in a single approach.
using Microsoft.Extensions.Caching.Memory;
using StackExchange.Redis;
namespace Concurrent;
public interface IWeatherRepository
{
Task<double> GetTemperatureAsync(string city, DateTime date);
}
public sealed class WeatherCache
{
private readonly IWeatherRepository _repository;
private readonly IMemoryCache _localCache;
private readonly IDatabase _redis;
private readonly int_ttl = 10; // use ExpiryTtl() for expiry for limit+random
public WeatherCache(IWeatherRepository repository, IMemoryCache localCache, IDatabase redis)
{
_repository = repository;
_localCache = localCache;
_redis = redis;
}
public async Task<double> GetTemperatureAsync(string city, DateTime date)
{
var key = $"weather:{city.ToLower()}:{date:yyyyMMdd}";
/*
* Step 1: Use IMemoryCache + Lazy<Task> for Single-Flight.
* GetOrCreate ensures that if 100 threads ask for "London",
* only ONE Lazy object is created and stored locally.
*/
var lazyTask = _localCache.GetOrCreate(key, entry =>
{
entry.Size = 1; // Protects against OOM
entry.AbsoluteExpirationRelativeToNow = ExpiryTtl();
return new Lazy<Task<double>>(() => FetchHybridAsync(key, city, date), LazyThreadSafetyMode.ExecutionAndPublication);
});
try
{
return await lazyTask!.Value;
}
catch
{
// If the fetch fails, remove from local cache so we can retry next time
_localCache.Remove(key);
throw;
}
}
private async Task<double> FetchHybridAsync(string key, string city, DateTime date)
{
// Step 2: Check Distributed Cache (Redis)
var cachedValue = await _redis.StringGetAsync(key);
if (cachedValue.HasValue)
{
return (double)cachedValue;
}
// Step 3: Distributed Lock (Optional but recommended for "World-Scale")
// To keep this example focused, we'll go straight to the Repo,
// but in high-load you'd add a Redis Lock here.
var temperature = await _repository.GetTemperatureAsync(city, date);
// Step 4: Update Redis
await _redis.StringSetAsync(key, temperature, ExpiryTtl());
return temperature;
}
private async Task<double> FetchHybridAsyncWithRedisLock(string key, string city, DateTime date)
{
// Step 2: Check Distributed Cache (Redis)
var cachedValue = await _redis.StringGetAsync(key);
if (cachedValue.HasValue)
{
return (double)cachedValue;
}
// Step 3: Distributed Lock
var lockKey = $"lock:{key}";
var lockValue = Guid.NewGuid().ToString(); // Unique ID for this specific request
// Try to acquire lock for 10 seconds (SET if Not Exists)
bool lockAcquired = await _redis.StringSetAsync(lockKey, lockValue, TimeSpan.FromSeconds(10), When.NotExists);
if (!lockAcquired)
{
// Another server is already fetching this data.
// Wait a bit and then check Redis again (don't call the Repo!)
await Task.Delay(500);
return await FetchHybridAsync(key, city, date);
}
try
{
// Re-check Redis one last time after getting the lock (Double-Check Locking pattern)
cachedValue = await _redis.StringGetAsync(key);
if (cachedValue.HasValue) return (double)cachedValue;
var temperature = await _repository.GetTemperatureAsync(city, date);
// Step 4: Update Redis
await _redis.StringSetAsync(key, temperature, ExpiryTtl());
return temperature;
}
finally
{
// Step 5: Release Lock (only if we are the ones who held it)
var currentLockValue = await _redis.StringGetAsync(lockKey);
if (currentLockValue == lockValue)
{
await _redis.KeyDeleteAsync(lockKey);
}
}
}
/// <summary>
/// Determines the time-to-live (TTL) for caching operations, using a randomized duration
/// to help prevent cache stampedes by spreading expiration times across a range.
/// </summary>
/// <returns>
/// A TimeSpan representing the expiration time-to-live for the cache,
/// calculated as a base duration of 10 minutes with a random variation of ±20 minutes.
/// </returns>
private TimeSpan ExpiryTtl() => TimeSpan.FromMinutes(_ttl + Random.Shared.Next(-20, 20));
}
No files yet, migration hasn't completed yet!