This commit is contained in:
Ho Ngoc Hai
2026-05-23 18:37:02 +07:00
parent f15d91ee29
commit 76d75c753b
3993 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,356 @@
---
name: redis-caching
description: Redis caching patterns cho distributed systems. Use for cache-aside, session storage, rate limiting, và distributed locks.
compatibility: ".NET 8+, StackExchange.Redis, Microsoft.Extensions.Caching.StackExchangeRedis"
metadata:
author: Velik Ho
version: "1.0"
---
# Redis Caching Patterns / Mẫu Caching Redis
Redis caching và distributed data patterns cho GoodGo microservices.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Implementing distributed cache / Triển khai distributed cache
- Caching API responses / Caching responses API
- Managing user sessions / Quản lý user sessions
- Implementing rate limiting / Triển khai rate limiting
- Using Redis as primary store (e.g., shopping cart) / Dùng Redis làm store chính
## Core Concepts / Khái Niệm Cốt Lõi
### Caching Strategies / Các Chiến Lược Caching
```
┌─────────────────────────────────────────────────────────────┐
│ CACHING PATTERNS │
├─────────────────────┬─────────────────────┬─────────────────┤
│ CACHE-ASIDE │ WRITE-THROUGH │ WRITE-BEHIND │
│ (Read pattern) │ (Write pattern) │ (Async write) │
├─────────────────────┼─────────────────────┼─────────────────┤
│ 1. Check cache │ 1. Write to cache │ 1. Write cache │
│ 2. If miss, get DB │ 2. Write to DB sync │ 2. Queue DB │
│ 3. Populate cache │ │ 3. Async flush │
└─────────────────────┴─────────────────────┴─────────────────┘
```
### Redis Data Types / Các Kiểu Dữ Liệu Redis
| Type | Use Case | Example |
|------|----------|---------|
| **String** | Simple cache | User profile, JWT token |
| **Hash** | Object cache | Shopping cart |
| **List** | Queues | Task queue |
| **Set** | Unique items | Active users |
| **Sorted Set** | Rankings | Leaderboard |
### Cache Invalidation / Xóa Cache
| Strategy | Description | Use When |
|----------|-------------|----------|
| **TTL** | Auto-expire after time | Data changes rarely |
| **Event-based** | Invalidate on update | Data changes often |
| **Version key** | Cache with version | Complex objects |
## Key Patterns / Mẫu Chính
### Redis Configuration
```csharp
/// <summary>
/// EN: Configure Redis distributed cache.
/// VI: Cấu hình Redis distributed cache.
/// </summary>
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
options.InstanceName = "GoodGo:";
});
// EN: Register Redis connection multiplexer for advanced scenarios
// VI: Đăng ký Redis connection multiplexer cho các trường hợp nâng cao
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
```
### Cache Service Implementation
```csharp
/// <summary>
/// EN: Generic cache service with typed operations.
/// VI: Cache service generic với typed operations.
/// </summary>
public interface ICacheService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null, CancellationToken ct = default);
Task RemoveAsync(string key, CancellationToken ct = default);
Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiry = null, CancellationToken ct = default);
}
public class RedisCacheService : ICacheService
{
private readonly IDistributedCache _cache;
private readonly ILogger<RedisCacheService> _logger;
private static readonly TimeSpan DefaultExpiry = TimeSpan.FromMinutes(30);
public RedisCacheService(
IDistributedCache cache,
ILogger<RedisCacheService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
var data = await _cache.GetStringAsync(key, ct);
if (data == null)
{
_logger.LogDebug("Cache miss for key: {Key}", key);
return default;
}
_logger.LogDebug("Cache hit for key: {Key}", key);
return JsonSerializer.Deserialize<T>(data);
}
public async Task SetAsync<T>(
string key,
T value,
TimeSpan? expiry = null,
CancellationToken ct = default)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiry ?? DefaultExpiry
};
var data = JsonSerializer.Serialize(value);
await _cache.SetStringAsync(key, data, options, ct);
_logger.LogDebug("Cached key: {Key} with expiry: {Expiry}", key, expiry ?? DefaultExpiry);
}
public async Task RemoveAsync(string key, CancellationToken ct = default)
{
await _cache.RemoveAsync(key, ct);
_logger.LogDebug("Removed cache key: {Key}", key);
}
public async Task<T> GetOrSetAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan? expiry = null,
CancellationToken ct = default)
{
var cached = await GetAsync<T>(key, ct);
if (cached != null)
return cached;
var value = await factory();
await SetAsync(key, value, expiry, ct);
return value;
}
}
```
### Cache-Aside Pattern in Query Handler
```csharp
/// <summary>
/// EN: Query handler with cache-aside pattern.
/// VI: Query handler với cache-aside pattern.
/// </summary>
public class GetUserProfileQueryHandler
: IRequestHandler<GetUserProfileQuery, UserProfileDto?>
{
private readonly ICacheService _cache;
private readonly IUserRepository _userRepository;
private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(15);
public async Task<UserProfileDto?> Handle(
GetUserProfileQuery request,
CancellationToken ct)
{
var cacheKey = $"user:profile:{request.UserId}";
// EN: Try get from cache first
// VI: Thử lấy từ cache trước
return await _cache.GetOrSetAsync(
cacheKey,
async () =>
{
var user = await _userRepository.GetByIdAsync(request.UserId, ct);
return user?.ToProfileDto();
},
CacheExpiry,
ct);
}
}
```
### Cache Invalidation on Write
```csharp
/// <summary>
/// EN: Invalidate cache when data changes.
/// VI: Xóa cache khi dữ liệu thay đổi.
/// </summary>
public class UpdateUserProfileCommandHandler
: IRequestHandler<UpdateUserProfileCommand, Unit>
{
private readonly ICacheService _cache;
private readonly IUserRepository _userRepository;
public async Task<Unit> Handle(
UpdateUserProfileCommand request,
CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(request.UserId, ct)
?? throw new NotFoundException("User", request.UserId);
user.UpdateProfile(request.DisplayName, request.Bio);
await _userRepository.UnitOfWork.SaveChangesAsync(ct);
// EN: Invalidate cache after update
// VI: Xóa cache sau khi cập nhật
await _cache.RemoveAsync($"user:profile:{request.UserId}", ct);
return Unit.Value;
}
}
```
### Shopping Cart with Redis Hash
```csharp
/// <summary>
/// EN: Shopping cart repository using Redis Hash.
/// VI: Repository giỏ hàng dùng Redis Hash.
/// </summary>
public class RedisCartRepository : ICartRepository
{
private readonly IDatabase _redis;
private static readonly TimeSpan CartExpiry = TimeSpan.FromDays(7);
public RedisCartRepository(IConnectionMultiplexer redis)
{
_redis = redis.GetDatabase();
}
public async Task<Cart?> GetAsync(string cartId)
{
var data = await _redis.StringGetAsync($"cart:{cartId}");
return data.IsNullOrEmpty
? null
: JsonSerializer.Deserialize<Cart>(data!);
}
public async Task SaveAsync(Cart cart)
{
var key = $"cart:{cart.Id}";
var data = JsonSerializer.Serialize(cart);
await _redis.StringSetAsync(key, data, CartExpiry);
}
public async Task DeleteAsync(string cartId)
{
await _redis.KeyDeleteAsync($"cart:{cartId}");
}
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. No Cache Expiry
```csharp
// ❌ BAD: No expiry leads to stale data
await _cache.SetStringAsync(key, data);
// ✅ GOOD: Always set expiry
await _cache.SetStringAsync(key, data, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
});
```
### 2. Cache Stampede
```csharp
// ❌ BAD: Multiple requests hit DB simultaneously on cache miss
var data = await _cache.GetAsync<Data>(key);
if (data == null)
{
data = await _db.GetDataAsync();
await _cache.SetAsync(key, data);
}
// ✅ GOOD: Use locking to prevent stampede
var data = await _cache.GetOrSetWithLockAsync(key, async () =>
{
return await _db.GetDataAsync();
});
```
### 3. Caching Null Values
```csharp
// ❌ BAD: Not caching null causes repeated DB calls
if (data != null)
await _cache.SetAsync(key, data);
// ✅ GOOD: Cache null with short TTL
var cacheValue = new CacheWrapper<Data> { Value = data, IsNull = data == null };
await _cache.SetAsync(key, cacheValue, data == null ? TimeSpan.FromMinutes(1) : TimeSpan.FromHours(1));
```
## Quick Reference / Tham Chiếu Nhanh
### Cache Key Naming
```csharp
// EN: Pattern: {entity}:{id}:{optional-subkey}
// VI: Pattern: {entity}:{id}:{optional-subkey}
"user:profile:123"
"order:items:456"
"product:details:789"
"user:orders:123:page:1"
```
### Common TTL Values
| Data Type | TTL | Reason |
|-----------|-----|--------|
| User profile | 15-30 min | Changes moderately |
| Product catalog | 1-4 hours | Batch updates |
| Shopping cart | 7 days | User convenience |
| Session | 30 min sliding | Security |
| Rate limit | 1 min | Short window |
### Redis Commands via CLI
```bash
# EN: View all keys with pattern
redis-cli KEYS "user:*"
# EN: Get TTL of key
redis-cli TTL "user:profile:123"
# EN: Delete keys by pattern
redis-cli KEYS "cart:*" | xargs redis-cli DEL
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
- [Error Handling](../error-handling-patterns/SKILL.md) - Cache failure handling
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access patterns
- [Docker Traefik](../docker-traefik/SKILL.md) - Redis container setup

View File

@@ -0,0 +1,479 @@
# 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)