feat: 实现字典管理后端

This commit is contained in:
2025-12-30 19:38:13 +08:00
parent a427b0f22a
commit dc9f6136d6
83 changed files with 6901 additions and 50 deletions

View File

@@ -0,0 +1,212 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// 缓存命中/耗时指标采集器。
/// </summary>
public sealed class CacheMetricsCollector
{
private const string MeterName = "TakeoutSaaS.DictionaryCache";
private static readonly Meter Meter = new(MeterName, "1.0.0");
private readonly Counter<long> _hitCounter;
private readonly Counter<long> _missCounter;
private readonly Counter<long> _invalidationCounter;
private readonly Histogram<double> _durationHistogram;
private readonly ConcurrentQueue<CacheQueryRecord> _queries = new();
private readonly TimeSpan _retention = TimeSpan.FromDays(7);
private long _hitTotal;
private long _missTotal;
/// <summary>
/// 初始化指标采集器。
/// </summary>
public CacheMetricsCollector()
{
_hitCounter = Meter.CreateCounter<long>("cache_hit_count");
_missCounter = Meter.CreateCounter<long>("cache_miss_count");
_invalidationCounter = Meter.CreateCounter<long>("cache_invalidation_count");
_durationHistogram = Meter.CreateHistogram<double>("cache_query_duration_ms");
Meter.CreateObservableGauge(
"cache_hit_ratio",
() => new Measurement<double>(CalculateHitRatio()));
}
/// <summary>
/// 记录缓存命中。
/// </summary>
public void RecordHit(string cacheLevel, string dictionaryCode)
{
Interlocked.Increment(ref _hitTotal);
_hitCounter.Add(1, new TagList
{
{ "cache_level", cacheLevel },
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 记录缓存未命中。
/// </summary>
public void RecordMiss(string cacheLevel, string dictionaryCode)
{
Interlocked.Increment(ref _missTotal);
_missCounter.Add(1, new TagList
{
{ "cache_level", cacheLevel },
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 记录缓存查询耗时。
/// </summary>
public void RecordDuration(string dictionaryCode, double durationMs)
{
_durationHistogram.Record(durationMs, new TagList
{
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 记录查询详情,用于统计窗口分析。
/// </summary>
public void RecordQuery(string dictionaryCode, bool l1Hit, bool l2Hit, double durationMs)
{
var record = new CacheQueryRecord(DateTime.UtcNow, NormalizeCode(dictionaryCode), l1Hit, l2Hit, durationMs);
_queries.Enqueue(record);
PruneOldRecords();
}
/// <summary>
/// 记录缓存失效事件。
/// </summary>
public void RecordInvalidation(string dictionaryCode)
{
_invalidationCounter.Add(1, new TagList
{
{ "dictionary_code", NormalizeCode(dictionaryCode) }
});
}
/// <summary>
/// 获取指定时间范围内的统计快照。
/// </summary>
public CacheStatsSnapshot GetSnapshot(TimeSpan window)
{
var since = DateTime.UtcNow.Subtract(window);
var records = _queries.Where(record => record.Timestamp >= since).ToList();
var l1Hits = records.Count(record => record.L1Hit);
var l1Misses = records.Count(record => !record.L1Hit);
var l2Hits = records.Count(record => record.L2Hit);
var l2Misses = records.Count(record => !record.L1Hit && !record.L2Hit);
var totalHits = l1Hits + l2Hits;
var totalMisses = l1Misses + l2Misses;
var hitRatio = totalHits + totalMisses == 0 ? 0 : totalHits / (double)(totalHits + totalMisses);
var averageDuration = records.Count == 0 ? 0 : records.Average(record => record.DurationMs);
var topQueried = records
.GroupBy(record => record.DictionaryCode)
.Select(group => new DictionaryQueryCount(group.Key, group.Count()))
.OrderByDescending(item => item.QueryCount)
.Take(5)
.ToList();
return new CacheStatsSnapshot(
totalHits,
totalMisses,
hitRatio,
new CacheLevelStats(l1Hits, l2Hits),
new CacheLevelStats(l1Misses, l2Misses),
averageDuration,
topQueried);
}
/// <summary>
/// 从缓存键解析字典编码。
/// </summary>
public static string ExtractDictionaryCode(string cacheKey)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
return "unknown";
}
if (cacheKey.StartsWith("dict:groups:", StringComparison.Ordinal))
{
return "groups";
}
if (cacheKey.StartsWith("dict:items:", StringComparison.Ordinal))
{
return "items";
}
if (cacheKey.StartsWith("dict:", StringComparison.Ordinal))
{
var parts = cacheKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3)
{
return parts[2];
}
}
return "unknown";
}
private static string NormalizeCode(string? code)
=> string.IsNullOrWhiteSpace(code) ? "unknown" : code.Trim().ToLowerInvariant();
private double CalculateHitRatio()
{
var hits = Interlocked.Read(ref _hitTotal);
var misses = Interlocked.Read(ref _missTotal);
return hits + misses == 0 ? 0 : hits / (double)(hits + misses);
}
private void PruneOldRecords()
{
var cutoff = DateTime.UtcNow.Subtract(_retention);
while (_queries.TryPeek(out var record) && record.Timestamp < cutoff)
{
_queries.TryDequeue(out _);
}
}
private sealed record CacheQueryRecord(
DateTime Timestamp,
string DictionaryCode,
bool L1Hit,
bool L2Hit,
double DurationMs);
}
/// <summary>
/// 缓存统计快照。
/// </summary>
public sealed record CacheStatsSnapshot(
long TotalHits,
long TotalMisses,
double HitRatio,
CacheLevelStats HitsByLevel,
CacheLevelStats MissesByLevel,
double AverageQueryDurationMs,
IReadOnlyList<DictionaryQueryCount> TopQueriedDictionaries);
/// <summary>
/// 命中统计。
/// </summary>
public sealed record CacheLevelStats(long L1, long L2);
/// <summary>
/// 字典查询次数统计。
/// </summary>
public sealed record DictionaryQueryCount(string Code, int QueryCount);

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.Dictionary.Services;
using TakeoutSaaS.Infrastructure.Dictionary.Options;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// 字典缓存预热服务。
/// </summary>
public sealed class CacheWarmupService(
IServiceScopeFactory scopeFactory,
IOptions<DictionaryCacheWarmupOptions> options,
ILogger<CacheWarmupService> logger) : IHostedService
{
private const int MaxWarmupCount = 10;
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var codes = options.Value.DictionaryCodes
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxWarmupCount)
.ToArray();
if (codes.Length == 0)
{
logger.LogInformation("未配置字典缓存预热列表。");
return;
}
using var scope = scopeFactory.CreateScope();
var queryService = scope.ServiceProvider.GetRequiredService<DictionaryQueryService>();
foreach (var code in codes)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await queryService.GetMergedDictionaryAsync(code, cancellationToken);
logger.LogInformation("字典缓存预热完成: {DictionaryCode}", code);
}
catch (Exception ex)
{
logger.LogWarning(ex, "字典缓存预热失败: {DictionaryCode}", code);
}
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,229 @@
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;
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// 本地内存缓存封装。
/// </summary>
public sealed class MemoryCacheService(IMemoryCache cache)
{
private readonly ConcurrentDictionary<string, byte> _keys = new(StringComparer.Ordinal);
/// <summary>
/// 读取缓存。
/// </summary>
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
}
/// <summary>
/// 写入缓存。
/// </summary>
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
{
cache.Set(key, value, new MemoryCacheEntryOptions
{
SlidingExpiration = ttl
});
_keys.TryAdd(key, 0);
return Task.CompletedTask;
}
/// <summary>
/// 删除缓存键。
/// </summary>
public void Remove(string key)
{
cache.Remove(key);
_keys.TryRemove(key, out _);
}
/// <summary>
/// 按前缀删除缓存键。
/// </summary>
public void RemoveByPrefix(string prefix)
=> RemoveByPrefixWithCount(prefix);
/// <summary>
/// 按前缀删除缓存键并返回数量。
/// </summary>
public int RemoveByPrefixWithCount(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
return 0;
}
var removed = 0;
foreach (var key in _keys.Keys)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
{
Remove(key);
removed += 1;
}
}
return removed;
}
/// <summary>
/// 清理所有缓存。
/// </summary>
public void Clear()
{
foreach (var key in _keys.Keys)
{
Remove(key);
}
}
}

View File

@@ -0,0 +1,79 @@
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
using StackExchange.Redis;
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
/// <summary>
/// Redis 缓存访问封装。
/// </summary>
public sealed class RedisCacheService(IDistributedCache cache, IConnectionMultiplexer? multiplexer = null)
{
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
private readonly IDatabase? _database = multiplexer?.GetDatabase();
private readonly IConnectionMultiplexer? _multiplexer = multiplexer;
/// <summary>
/// 读取缓存。
/// </summary>
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
var payload = await cache.GetAsync(key, cancellationToken);
if (payload == null || payload.Length == 0)
{
return default;
}
return JsonSerializer.Deserialize<T>(payload, _serializerOptions);
}
/// <summary>
/// 写入缓存。
/// </summary>
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(value, _serializerOptions);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
};
return cache.SetAsync(key, payload, options, cancellationToken);
}
/// <summary>
/// 删除缓存键。
/// </summary>
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
=> cache.RemoveAsync(key, cancellationToken);
/// <summary>
/// 按前缀删除缓存键。
/// </summary>
public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
=> await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false);
/// <summary>
/// 按前缀删除缓存键并返回数量。
/// </summary>
public async Task<long> RemoveByPrefixWithCountAsync(string prefix, CancellationToken cancellationToken = default)
{
if (_multiplexer == null || _database == null || string.IsNullOrWhiteSpace(prefix))
{
return 0;
}
var pattern = prefix.EndsWith('*') ? prefix : $"{prefix}*";
long removed = 0;
foreach (var endpoint in _multiplexer.GetEndPoints())
{
var server = _multiplexer.GetServer(endpoint);
foreach (var key in server.Keys(pattern: pattern))
{
await _database.KeyDeleteAsync(key).ConfigureAwait(false);
removed += 1;
}
}
return removed;
}
}