480 lines
14 KiB
Markdown
480 lines
14 KiB
Markdown
# Redis Caching - Detailed Reference
|
|
|
|
Detailed code examples cho Redis caching patterns trong GoodGo.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Connection Setup](#connection-setup)
|
|
2. [Cache Service](#cache-service)
|
|
3. [Shopping Cart](#shopping-cart)
|
|
4. [Rate Limiting](#rate-limiting)
|
|
5. [Distributed Locks](#distributed-locks)
|
|
6. [Session Management](#session-management)
|
|
|
|
---
|
|
|
|
## Connection Setup
|
|
|
|
### Redis Configuration
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Configure Redis for distributed cache and direct access.
|
|
/// VI: Cấu hình Redis cho distributed cache và truy cập trực tiếp.
|
|
/// </summary>
|
|
|
|
// appsettings.json
|
|
{
|
|
"Redis": {
|
|
"ConnectionString": "localhost:6379,abortConnect=false,connectTimeout=5000",
|
|
"InstanceName": "GoodGo:",
|
|
"DefaultDatabase": 0
|
|
}
|
|
}
|
|
|
|
// Program.cs
|
|
builder.Services.AddStackExchangeRedisCache(options =>
|
|
{
|
|
options.Configuration = builder.Configuration["Redis:ConnectionString"];
|
|
options.InstanceName = builder.Configuration["Redis:InstanceName"];
|
|
});
|
|
|
|
// EN: For advanced scenarios (Lua scripts, pub/sub, etc.)
|
|
// VI: Cho các trường hợp nâng cao (Lua scripts, pub/sub, v.v.)
|
|
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
|
{
|
|
var config = ConfigurationOptions.Parse(
|
|
builder.Configuration["Redis:ConnectionString"]!);
|
|
config.AbortOnConnectFail = false;
|
|
config.ConnectRetry = 3;
|
|
return ConnectionMultiplexer.Connect(config);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Cache Service
|
|
|
|
### Complete Cache Service Implementation
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Full-featured cache service with serialization and error handling.
|
|
/// VI: Cache service đầy đủ tính năng với serialization và xử lý lỗi.
|
|
/// </summary>
|
|
public class RedisCacheService : ICacheService
|
|
{
|
|
private readonly IDistributedCache _cache;
|
|
private readonly IConnectionMultiplexer _redis;
|
|
private readonly ILogger<RedisCacheService> _logger;
|
|
private readonly JsonSerializerOptions _jsonOptions;
|
|
|
|
public RedisCacheService(
|
|
IDistributedCache cache,
|
|
IConnectionMultiplexer redis,
|
|
ILogger<RedisCacheService> logger)
|
|
{
|
|
_cache = cache;
|
|
_redis = redis;
|
|
_logger = logger;
|
|
_jsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
}
|
|
|
|
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var data = await _cache.GetStringAsync(key, ct);
|
|
if (string.IsNullOrEmpty(data))
|
|
{
|
|
_logger.LogDebug("Cache MISS: {Key}", key);
|
|
return default;
|
|
}
|
|
|
|
_logger.LogDebug("Cache HIT: {Key}", key);
|
|
return JsonSerializer.Deserialize<T>(data, _jsonOptions);
|
|
}
|
|
catch (RedisConnectionException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Redis connection failed for key: {Key}", key);
|
|
return default; // EN: Graceful degradation / VI: Xử lý graceful
|
|
}
|
|
}
|
|
|
|
public async Task SetAsync<T>(
|
|
string key,
|
|
T value,
|
|
TimeSpan? absoluteExpiry = null,
|
|
TimeSpan? slidingExpiry = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var options = new DistributedCacheEntryOptions();
|
|
|
|
if (absoluteExpiry.HasValue)
|
|
options.AbsoluteExpirationRelativeToNow = absoluteExpiry;
|
|
if (slidingExpiry.HasValue)
|
|
options.SlidingExpiration = slidingExpiry;
|
|
|
|
var data = JsonSerializer.Serialize(value, _jsonOptions);
|
|
await _cache.SetStringAsync(key, data, options, ct);
|
|
|
|
_logger.LogDebug("Cache SET: {Key}", key);
|
|
}
|
|
catch (RedisConnectionException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to cache key: {Key}", key);
|
|
// EN: Don't throw - cache is not critical / VI: Không throw - cache không critical
|
|
}
|
|
}
|
|
|
|
public async Task<T> GetOrSetAsync<T>(
|
|
string key,
|
|
Func<CancellationToken, Task<T>> factory,
|
|
TimeSpan? expiry = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var cached = await GetAsync<T>(key, ct);
|
|
if (cached != null)
|
|
return cached;
|
|
|
|
// EN: Use lock to prevent cache stampede
|
|
// VI: Dùng lock để tránh cache stampede
|
|
var lockKey = $"lock:{key}";
|
|
var db = _redis.GetDatabase();
|
|
|
|
if (await db.LockTakeAsync(lockKey, Environment.MachineName, TimeSpan.FromSeconds(10)))
|
|
{
|
|
try
|
|
{
|
|
// EN: Double-check after acquiring lock
|
|
// VI: Kiểm tra lại sau khi lấy lock
|
|
cached = await GetAsync<T>(key, ct);
|
|
if (cached != null)
|
|
return cached;
|
|
|
|
var value = await factory(ct);
|
|
await SetAsync(key, value, expiry, ct: ct);
|
|
return value;
|
|
}
|
|
finally
|
|
{
|
|
await db.LockReleaseAsync(lockKey, Environment.MachineName);
|
|
}
|
|
}
|
|
|
|
// EN: Lock not acquired, wait and retry
|
|
// VI: Không lấy được lock, chờ và thử lại
|
|
await Task.Delay(100, ct);
|
|
return await GetOrSetAsync(key, factory, expiry, ct);
|
|
}
|
|
|
|
public async Task RemoveAsync(string key, CancellationToken ct = default)
|
|
{
|
|
await _cache.RemoveAsync(key, ct);
|
|
_logger.LogDebug("Cache REMOVE: {Key}", key);
|
|
}
|
|
|
|
public async Task RemoveByPatternAsync(string pattern, CancellationToken ct = default)
|
|
{
|
|
var server = _redis.GetServer(_redis.GetEndPoints().First());
|
|
var keys = server.Keys(pattern: pattern).ToArray();
|
|
|
|
if (keys.Length > 0)
|
|
{
|
|
var db = _redis.GetDatabase();
|
|
await db.KeyDeleteAsync(keys);
|
|
_logger.LogDebug("Cache REMOVE pattern: {Pattern}, Count: {Count}", pattern, keys.Length);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Shopping Cart
|
|
|
|
### Cart Entity
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Shopping cart stored in Redis.
|
|
/// VI: Giỏ hàng lưu trong Redis.
|
|
/// </summary>
|
|
public class Cart
|
|
{
|
|
public string Id { get; set; } = default!;
|
|
public string UserId { get; set; } = default!;
|
|
public List<CartItem> Items { get; set; } = new();
|
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
|
|
public decimal TotalAmount => Items.Sum(i => i.Quantity * i.UnitPrice);
|
|
|
|
public void AddItem(Guid productId, string productName, int quantity, decimal unitPrice)
|
|
{
|
|
var existing = Items.FirstOrDefault(i => i.ProductId == productId);
|
|
if (existing != null)
|
|
{
|
|
existing.Quantity += quantity;
|
|
existing.UnitPrice = unitPrice; // EN: Update price
|
|
}
|
|
else
|
|
{
|
|
Items.Add(new CartItem
|
|
{
|
|
ProductId = productId,
|
|
ProductName = productName,
|
|
Quantity = quantity,
|
|
UnitPrice = unitPrice
|
|
});
|
|
}
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
public void RemoveItem(Guid productId)
|
|
{
|
|
Items.RemoveAll(i => i.ProductId == productId);
|
|
UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
public class CartItem
|
|
{
|
|
public Guid ProductId { get; set; }
|
|
public string ProductName { get; set; } = default!;
|
|
public int Quantity { get; set; }
|
|
public decimal UnitPrice { get; set; }
|
|
}
|
|
```
|
|
|
|
### Cart Repository
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Redis-backed cart repository.
|
|
/// VI: Repository giỏ hàng với Redis.
|
|
/// </summary>
|
|
public class RedisCartRepository : ICartRepository
|
|
{
|
|
private readonly IDatabase _db;
|
|
private readonly ILogger<RedisCartRepository> _logger;
|
|
private static readonly TimeSpan DefaultExpiry = TimeSpan.FromDays(7);
|
|
|
|
public RedisCartRepository(
|
|
IConnectionMultiplexer redis,
|
|
ILogger<RedisCartRepository> logger)
|
|
{
|
|
_db = redis.GetDatabase();
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<Cart?> GetByUserIdAsync(string userId, CancellationToken ct = default)
|
|
{
|
|
var key = GetKey(userId);
|
|
var data = await _db.StringGetAsync(key);
|
|
|
|
if (data.IsNullOrEmpty)
|
|
return null;
|
|
|
|
return JsonSerializer.Deserialize<Cart>(data!);
|
|
}
|
|
|
|
public async Task SaveAsync(Cart cart, CancellationToken ct = default)
|
|
{
|
|
var key = GetKey(cart.UserId);
|
|
var data = JsonSerializer.Serialize(cart);
|
|
|
|
await _db.StringSetAsync(key, data, DefaultExpiry);
|
|
|
|
_logger.LogInformation(
|
|
"Cart saved for user {UserId}, Items: {ItemCount}",
|
|
cart.UserId, cart.Items.Count);
|
|
}
|
|
|
|
public async Task DeleteAsync(string userId, CancellationToken ct = default)
|
|
{
|
|
var key = GetKey(userId);
|
|
await _db.KeyDeleteAsync(key);
|
|
|
|
_logger.LogInformation("Cart deleted for user {UserId}", userId);
|
|
}
|
|
|
|
private static string GetKey(string userId) => $"cart:{userId}";
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Rate Limiting
|
|
|
|
### Redis Rate Limiter
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Redis-based sliding window rate limiter.
|
|
/// VI: Rate limiter sliding window với Redis.
|
|
/// </summary>
|
|
public class RedisRateLimiter : IRateLimiter
|
|
{
|
|
private readonly IDatabase _db;
|
|
private readonly ILogger<RedisRateLimiter> _logger;
|
|
|
|
public RedisRateLimiter(
|
|
IConnectionMultiplexer redis,
|
|
ILogger<RedisRateLimiter> logger)
|
|
{
|
|
_db = redis.GetDatabase();
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<RateLimitResult> CheckAsync(
|
|
string key,
|
|
int maxRequests,
|
|
TimeSpan window)
|
|
{
|
|
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var windowStart = now - (long)window.TotalMilliseconds;
|
|
var redisKey = $"ratelimit:{key}";
|
|
|
|
// EN: Lua script for atomic sliding window
|
|
// VI: Lua script cho sliding window atomic
|
|
var script = @"
|
|
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
|
|
local count = redis.call('ZCARD', KEYS[1])
|
|
if count < tonumber(ARGV[2]) then
|
|
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])
|
|
redis.call('EXPIRE', KEYS[1], ARGV[4])
|
|
return {1, count + 1, tonumber(ARGV[2])}
|
|
else
|
|
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
|
local retryAfter = oldest[2] + tonumber(ARGV[5]) - tonumber(ARGV[3])
|
|
return {0, count, tonumber(ARGV[2]), retryAfter}
|
|
end
|
|
";
|
|
|
|
var result = await _db.ScriptEvaluateAsync(script,
|
|
new RedisKey[] { redisKey },
|
|
new RedisValue[]
|
|
{
|
|
windowStart,
|
|
maxRequests,
|
|
now,
|
|
(int)window.TotalSeconds,
|
|
(long)window.TotalMilliseconds
|
|
});
|
|
|
|
var values = (RedisResult[])result!;
|
|
var allowed = (int)values[0] == 1;
|
|
var currentCount = (int)values[1];
|
|
var limit = (int)values[2];
|
|
var retryAfter = values.Length > 3 ? TimeSpan.FromMilliseconds((long)values[3]) : TimeSpan.Zero;
|
|
|
|
if (!allowed)
|
|
{
|
|
_logger.LogWarning(
|
|
"Rate limit exceeded for {Key}: {Count}/{Limit}",
|
|
key, currentCount, limit);
|
|
}
|
|
|
|
return new RateLimitResult(allowed, currentCount, limit, retryAfter);
|
|
}
|
|
}
|
|
|
|
public record RateLimitResult(
|
|
bool IsAllowed,
|
|
int CurrentCount,
|
|
int Limit,
|
|
TimeSpan RetryAfter);
|
|
```
|
|
|
|
---
|
|
|
|
## Distributed Locks
|
|
|
|
### Redis Lock Service
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Distributed lock using Redis.
|
|
/// VI: Distributed lock dùng Redis.
|
|
/// </summary>
|
|
public class RedisLockService : IDistributedLockService
|
|
{
|
|
private readonly IDatabase _db;
|
|
private readonly ILogger<RedisLockService> _logger;
|
|
|
|
public async Task<IAsyncDisposable?> AcquireAsync(
|
|
string resource,
|
|
TimeSpan expiry,
|
|
TimeSpan? waitTime = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var lockKey = $"lock:{resource}";
|
|
var lockValue = Guid.NewGuid().ToString();
|
|
var deadline = DateTime.UtcNow + (waitTime ?? TimeSpan.Zero);
|
|
|
|
do
|
|
{
|
|
if (await _db.LockTakeAsync(lockKey, lockValue, expiry))
|
|
{
|
|
_logger.LogDebug("Lock acquired: {Resource}", resource);
|
|
return new RedisLock(_db, lockKey, lockValue, _logger);
|
|
}
|
|
|
|
if (waitTime.HasValue)
|
|
await Task.Delay(50, ct);
|
|
|
|
} while (waitTime.HasValue && DateTime.UtcNow < deadline && !ct.IsCancellationRequested);
|
|
|
|
_logger.LogWarning("Failed to acquire lock: {Resource}", resource);
|
|
return null;
|
|
}
|
|
|
|
private class RedisLock : IAsyncDisposable
|
|
{
|
|
private readonly IDatabase _db;
|
|
private readonly string _key;
|
|
private readonly string _value;
|
|
private readonly ILogger _logger;
|
|
|
|
public RedisLock(IDatabase db, string key, string value, ILogger logger)
|
|
{
|
|
_db = db;
|
|
_key = key;
|
|
_value = value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _db.LockReleaseAsync(_key, _value);
|
|
_logger.LogDebug("Lock released: {Key}", _key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// EN: Usage / VI: Cách dùng
|
|
await using var lockHandle = await _lockService.AcquireAsync(
|
|
$"order:{orderId}",
|
|
TimeSpan.FromMinutes(1),
|
|
waitTime: TimeSpan.FromSeconds(5));
|
|
|
|
if (lockHandle == null)
|
|
throw new ConflictException("Order is being processed");
|
|
|
|
await ProcessOrderAsync(orderId);
|
|
```
|
|
|
|
---
|
|
|
|
## Resources / Tài Nguyên
|
|
|
|
- [StackExchange.Redis](https://stackexchange.github.io/StackExchange.Redis/)
|
|
- [Microsoft Distributed Caching](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed)
|
|
- [Redis Documentation](https://redis.io/documentation)
|