213 lines
6.5 KiB
C#
213 lines
6.5 KiB
C#
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);
|