Files
TakeoutSaaS.TenantApi/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Caching/HybridCacheService.cs

230 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// 两级缓存封装L1 内存 + L2 Redis。
/// </summary>
public sealed class HybridCacheService : IDictionaryHybridCache
{
private static readonly RedisChannel InvalidationChannel = RedisChannel.Literal("dictionary:cache:invalidate");
private readonly MemoryCacheService _memoryCache;
private readonly RedisCacheService _redisCache;
private readonly ISubscriber? _subscriber;
private readonly ILogger<HybridCacheService>? _logger;
private readonly CacheMetricsCollector? _metrics;
private readonly IServiceScopeFactory? _scopeFactory;
/// <summary>
/// 初始化两级缓存服务。
/// </summary>
public HybridCacheService(
MemoryCacheService memoryCache,
RedisCacheService redisCache,
IConnectionMultiplexer? multiplexer = null,
ILogger<HybridCacheService>? logger = null,
CacheMetricsCollector? metrics = null,
IServiceScopeFactory? scopeFactory = null)
{
_memoryCache = memoryCache;
_redisCache = redisCache;
_logger = logger;
_subscriber = multiplexer?.GetSubscriber();
_metrics = metrics;
_scopeFactory = scopeFactory;
if (_subscriber != null)
{
_subscriber.Subscribe(InvalidationChannel, (_, value) =>
{
var prefix = value.ToString();
if (!string.IsNullOrWhiteSpace(prefix))
{
_memoryCache.RemoveByPrefix(prefix);
}
});
}
}
/// <summary>
/// 获取缓存,如果不存在则创建并回填。
/// </summary>
public async Task<T?> GetOrCreateAsync<T>(
string key,
TimeSpan ttl,
Func<CancellationToken, Task<T?>> factory,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key);
var l1Hit = false;
var l2Hit = false;
var cached = await _memoryCache.GetAsync<T>(key, cancellationToken);
if (cached != null)
{
l1Hit = true;
_metrics?.RecordHit("L1", dictionaryCode);
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return cached;
}
_metrics?.RecordMiss("L1", dictionaryCode);
try
{
cached = await _redisCache.GetAsync<T>(key, cancellationToken);
if (cached != null)
{
l2Hit = true;
_metrics?.RecordHit("L2", dictionaryCode);
await _memoryCache.SetAsync(key, cached, ttl, cancellationToken);
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return cached;
}
_metrics?.RecordMiss("L2", dictionaryCode);
}
catch (Exception ex)
{
_metrics?.RecordMiss("L2", dictionaryCode);
_logger?.LogWarning(ex, "读取 Redis 缓存失败,降级为数据库查询。");
}
var created = await factory(cancellationToken);
if (created == null)
{
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return default;
}
await _memoryCache.SetAsync(key, created, ttl, cancellationToken);
try
{
await _redisCache.SetAsync(key, created, ttl, cancellationToken);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "写入 Redis 缓存失败。");
}
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
return created;
}
/// <summary>
/// 失效指定前缀的缓存键。
/// </summary>
public async Task InvalidateAsync(
string prefix,
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
CancellationToken cancellationToken = default)
{
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(prefix);
_metrics?.RecordInvalidation(dictionaryCode);
var removedCount = _memoryCache.RemoveByPrefixWithCount(prefix);
long redisRemoved = 0;
try
{
redisRemoved = await _redisCache.RemoveByPrefixWithCountAsync(prefix, cancellationToken);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "删除 Redis 缓存失败。");
}
var totalRemoved = removedCount + (int)Math.Min(redisRemoved, int.MaxValue);
if (_subscriber != null && !string.IsNullOrWhiteSpace(prefix))
{
await _subscriber.PublishAsync(InvalidationChannel, prefix);
}
_ = WriteInvalidationLogAsync(prefix, dictionaryCode, totalRemoved, operation);
}
private async Task WriteInvalidationLogAsync(
string prefix,
string dictionaryCode,
int removedCount,
CacheInvalidationOperation operation)
{
if (_scopeFactory == null)
{
return;
}
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetService<ICacheInvalidationLogRepository>();
if (repo == null)
{
return;
}
var currentUser = scope.ServiceProvider.GetService<ICurrentUserAccessor>();
var tenantId = TryExtractTenantId(prefix) ?? 0;
var scopeType = tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
var log = new CacheInvalidationLog
{
TenantId = tenantId,
Timestamp = DateTime.UtcNow,
DictionaryCode = dictionaryCode,
Scope = scopeType,
AffectedCacheKeyCount = removedCount,
OperatorId = currentUser?.IsAuthenticated == true ? currentUser.UserId : 0,
Operation = operation
};
await repo.AddAsync(log);
await repo.SaveChangesAsync();
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "写入缓存失效日志失败。");
}
}
private static long? TryExtractTenantId(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
return null;
}
if (prefix.StartsWith("dict:groups:", StringComparison.Ordinal))
{
var token = prefix.Replace("dict:groups:", string.Empty, StringComparison.Ordinal).Trim(':');
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
? tenantId
: null;
}
if (prefix.StartsWith("dict:", StringComparison.Ordinal) && !prefix.StartsWith("dict:items:", StringComparison.Ordinal))
{
var token = prefix.Replace("dict:", string.Empty, StringComparison.Ordinal);
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
? tenantId
: null;
}
return null;
}
}