从秒级到毫秒级:如何通过优化缓存策略提升 .NET 微服务性能

作者:微信公众号:【架构师老卢】
2-25 7:59
20

当我们的团队注意到 API 响应时间从毫秒级上升到秒级时,我们知道出了问题。但我们没想到的是,原本旨在提高性能的缓存策略实际上才是问题的根源。接下来,让我们一起探索、调试并最终解决 .NET 微服务架构中的复杂缓存问题。


🔹 初始架构

我们的系统为全球支付平台处理金融交易(正如我在之前的文章中提到的那样),在高峰时段每分钟处理大约 50,000 个请求。

架构包括:

  • 6 个微服务:处理支付流程的不同方面。
  • 混合使用 Redis 和内存缓存
  • Postgres 作为主数据库。
  • Azure Service Bus 用于服务间通信。

缓存层最初的设计是为了减少数据库负载并提高响应时间。每个服务都维护自己的缓存,结合了以下内容:

// 初始缓存实现
public class CacheService
{
   private readonly IDistributedCache _distributedCache;
   private readonly IMemoryCache _memoryCache;
   
   public async Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null)
   {
     // 首先检查内存缓存
     if (_memoryCache.TryGetValue(key, out T value))
         return value;
    
     // 然后检查分布式缓存
     var cached = await _distributedCache.GetAsync(key);
     
     if (cached != null)
     {
       value = JsonSerializer.Deserialize<T>(cached);

       // 设置到内存缓存
       _memoryCache.Set(key, value, expiration ?? TimeSpan.FromMinutes(5));
       return value;
      }

    // 如果未找到,生成值
     value = await factory();
     
     // 存储到两个缓存中
     await _distributedCache.SetAsync(
       key, 
       JsonSerializer.SerializeToUtf8Bytes(value),
       new DistributedCacheEntryOptions 
       { 
         AbsoluteExpirationRelativeToNow = expiration 
       }
     );
     
     _memoryCache.Set(key, value, expiration ?? TimeSpan.FromMinutes(5));
     
     return value;
 }
}

◾ 问题所在

1. 缓存雪崩

我们的第一个主要问题出现在高峰时段。当缓存项过期时,多个并发请求会触发相同的昂贵数据库查询。这种“缓存雪崩”效应在服务之间级联:

public async Task<PaymentDetails> GetPaymentDetails(string paymentId)
{
   return await _cacheService.GetOrSetAsync(
           $"payment:{paymentId}",
           async () => await _dbContext.Payments
                             .Include(p => p.Customer)
                             .Include(p => p.TransactionHistory)
                             .FirstOrDefaultAsync(p => p.Id == paymentId),
           TimeSpan.FromMinutes(15));
}

当此缓存项过期时,数百个并发请求会同时命中数据库,导致 CPU 飙升和响应时间增加。


2. 内存泄漏

我们的内存使用显示出一种令人担忧的模式 → 即使配置了缓存过期,内存使用量仍会随着时间的推移而增长。真正的罪魁祸首是什么?我们在内存中存储了大型对象,但没有适当的大小限制:

// 初始实现中的内存泄漏
public class PaymentDetails
{
   public string Id { get; set; }
   public Customer Customer { get; set; }
   public List<Transaction> TransactionHistory { get; set; } // 无界列表
   public byte[] Receipt { get; set; } // 大型二进制数据
}

3. 缓存失效不一致

由于多个服务管理自己的缓存,我们面临数据一致性问题。当一个服务中的支付信息更新时,其他服务中的相关缓存并不总是正确失效:

// 不一致的缓存失效
public async Task UpdatePayment(Payment payment)
{
   await _dbContext.Payments.UpdateAsync(payment);
   await _cacheService.RemoveAsync($"payment:{payment.Id}");
   // 其他服务的缓存仍然有旧数据
}

◾ 我们通过多层缓存策略解决了问题

1. 滑动窗口缓存锁

为了防止缓存雪崩,我们实现了滑动窗口锁模式:

public class SlidingWindowCache<T>
{
   private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
   private readonly IDistributedCache _cache;
   private const int StaleBufferSeconds = 30;

   public async Task<T> GetOrSetAsync(string key, Func<Task<T>> factory, TimeSpan expiration)
   {
     var value = await TryGetValue(key);
     if (value != null) return value;
     
      try
     {
       await _lock.WaitAsync();
 
       // 获取锁后再次检查
       value = await TryGetValue(key);

       if (value != null) return value;

        // 生成新值
        value = await factory();

        // 存储并添加过期缓冲
         await _cache.SetAsync(
             key,
             JsonSerializer.SerializeToUtf8Bytes(new CacheEntry<T>
             {
               Value = value,
               ExpiresAt = DateTime.UtcNow.Add(expiration),
               IsStale = false
             }),
             new DistributedCacheEntryOptions
             {
               AbsoluteExpirationRelativeToNow = expiration.Add(TimeSpan.FromSeconds(StaleBufferSeconds))
             });

        return value;
     }
     finally
     {
       _lock.Release();
     }
 }
private class CacheEntry<TValue>
{
   public TValue Value { get; set; }
   public DateTime ExpiresAt { get; set; }
   public bool IsStale { get; set; }
}

2. 内存管理

我们实现了具有适当淘汰策略的大小感知缓存:

public class SizeAwareCache
{
   private readonly MemoryCache _cache;
   private long _currentSize;
   private readonly long _sizeLimit;

   public SizeAwareCache(long sizeLimit)
   {
     _sizeLimit = sizeLimit;
     _cache = new MemoryCache(new MemoryCacheOptions
     {
     SizeLimit = sizeLimit,
     ExpirationScanFrequency = TimeSpan.FromMinutes(5)
     });
    }

    public void Set<T>(string key, T value, TimeSpan expiration)
   {
     var size = CalculateSize(value);
     var entryOptions = new MemoryCacheEntryOptions
     {
     Size = size,
     AbsoluteExpirationRelativeToNow = expiration,
     Priority = CacheItemPriority.Normal
     };
    _cache.Set(key, value, entryOptions);
   }

  private long CalculateSize<T>(T value)
  {
   // 根据对象类型实现大小计算
   // 对于字符串:返回 UTF8 字节
   // 对于对象:使用序列化大小
   // 添加缓存条目元数据的开销
   }
}

3. 分布式缓存失效

我们使用 Azure Service Bus 实现了发布/订阅系统,用于协调缓存失效:

public class DistributedCacheInvalidator
{
   private readonly IServiceBusClient _serviceBus;
   private readonly IDistributedCache _cache;
   private readonly string _topicName = "cache-invalidation";

  public async Task InvalidateAsync(string key, string reason)
  {
     var message = new InvalidationMessage
     {
       Key = key,
       Timestamp = DateTime.UtcNow,
       Reason = reason
      };
      
      await _serviceBus.SendMessageAsync(_topicName, message);
   }

  public async Task HandleInvalidationMessage(InvalidationMessage message)
  {
     await _cache.RemoveAsync(message.Key);
     // 记录失效原因和时间戳
   }
}

◾ 生产环境监控模式

1. 缓存命中率监控

我们实现了详细的指标收集:

public class CacheMetrics
{
   private readonly IMetricClient _metrics;
    
   public async Task TrackCacheOperation(string cacheType, string operation, string key, long duration)
   {
     _metrics.TrackMetric(new MetricTelemetry
     {
       Name = $"Cache.{cacheType}.{operation}",
       Value = duration,
       Properties = new Dictionary<string, string>
                   {
                     ["Key"] = key,
                     ["Success"] = "true"
                    }
       });
     }
}

2. 缓存大小监控

我们添加了内存监控和警报:

public class CacheHealthCheck : IHealthCheck
{
   private readonly SizeAwareCache _cache;
   private readonly ILogger<CacheHealthCheck> _logger;

   public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context)
   {
     var metrics = _cache.GetMetrics();
 
     if (metrics.CurrentSize > metrics.SizeLimit * 0.9)
     {
         _logger.LogWarning("缓存大小接近限制: {CurrentSize}/{SizeLimit}", 
         metrics.CurrentSize, metrics.SizeLimit);
 
         return HealthCheckResult.Degraded();
       }

      return HealthCheckResult.Healthy();
     }
}

🔹 我们学到了什么

在实施这些更改后,我们看到了显著的改进:

  1. 响应时间从 8 秒下降到 200 毫秒(95% 分位)。
  2. CPU 使用率降低了 60%。
  3. 内存使用稳定且可预测。
  4. 缓存命中率从 65% 提高到 92%。

最佳实践

  1. 缓存条目大小

    • 始终为内存缓存实现大小限制。
    • 对大型对象使用压缩。
    • 监控内存使用模式。
  2. 过期策略

    • 对频繁访问的项使用滑动过期。
    • 实现“陈旧时重新验证”模式。
    • 在设置 TTL 时考虑业务需求。
  3. 失效模式

    • 使用发布/订阅进行分布式失效。
    • 为缓存键实现版本控制。
    • 记录所有缓存失效及其原因。
  4. 监控

    • 跟踪缓存命中/未命中率。
    • 监控内存使用和淘汰率。
    • 设置异常模式的警报。
  5. 错误处理

    • 为缓存操作实现断路器。
    • 制定缓存故障的备用策略。
    • 记录所有与缓存相关的错误及其上下文。

⚡ 何时使用不同的缓存策略

内存缓存

  • 最适合:频繁访问的小数据集。
  • 优点:最快的访问时间,无网络延迟。
  • 缺点:受可用内存限制,无法跨实例共享。
  • 使用场景:数据可以最终一致且内存充足时。

分布式缓存(Redis)

  • 最适合:较大的数据集,跨服务共享。
  • 优点:跨实例一致,容量更大。
  • 缺点:网络延迟,需要额外的基础设施。
  • 使用场景:数据必须在服务之间保持一致时。

混合方法

  • 最适合:具有不同需求的复杂系统。
  • 优点:结合了两种方法的优点。
  • 缺点:实现和维护更复杂。
  • 使用场景:性能要求证明复杂性合理时。

缓存是提高应用程序性能的强大工具,但它需要仔细考虑实现细节、监控和维护。我们从响应时间缓慢到高性能系统的旅程教会了我们以下重要经验:

  • 理解缓存决策的全面影响。
  • 从一开始就实施适当的监控。
  • 制定清晰的失效策略。
  • 谨慎管理内存。
  • 在负载下测试缓存行为。

请记住,缓存不是“设置后就不管”的解决方案。它需要持续的监控、维护,以及随着系统增长和需求变化而进行的偶尔重构。

相关留言评论
昵称:
邮箱:
阅读排行