C# LazyCache 并发字典垃圾回收

C# LazyCache concurrent dictionary garbage collection

基于 Web 的 .Net(C#) 应用程序遇到了一些问题。我正在使用 LazyCache 库为跨用户会话属于同一公司的用户缓存频繁的 JSON 响应(一些在 80+KB 左右)。

我们需要做的一件事是跟踪特定公司的缓存键,因此当公司中的任何用户对缓存的项目进行突变更改时,我们需要为此清除这些项目的缓存特定公司的用户在收到下一个请求时强制立即重新填充缓存。

我们选择 LazyCache 库是因为我们希望在内存中执行此操作而无需使用外部缓存源(例如 Redis 等),因为我们没有大量使用。

我们在使用这种方法时遇到的一个问题是,我们需要在缓存时随时跟踪属于特定客户的所有缓存键。因此,当公司用户对相关资源进行任何突变更改时,我们需要使属于该公司的所有缓存键过期。

为了实现这一点,我们有一个所有网络控制器都可以访问的全局缓存。

private readonly IAppCache _cache = new CachingService();

protected IAppCache GetCache()
{
    return _cache;
}

我们使用此缓存的控制器的简化示例(请原谅任何拼写错误!)如下所示

[HttpGet]
[Route("{customerId}/accounts/users")]
public async Task<Users> GetUsers([Required]string customerId)
{
    var usersBusinessLogic = await _provider.GetUsersBusinessLogic(customerId)

    var newCacheKey= "GetUsers." + customerId;

    CacheUtil.StoreCacheKey(customerId,newCacheKey)

    return await GetCache().GetOrAddAsync(newCacheKey, () => usersBusinessLogic.GetUsers(), DateTimeOffset.Now.AddMinutes(10));
}

我们使用带有静态方法的实用程序 class 和静态并发字典来存储缓存键 - 每个公司 (GUID) 可以有很多缓存键。

private static readonly ConcurrentDictionary<Guid, ConcurrentHashSet<string>> cacheKeys = new ConcurrentDictionary<Guid, ConcurrentHashSet<string>>();

public static void StoreCacheKey(Guid customerId, string newCacheKey)
{
    cacheKeys.AddOrUpdate(customerId, new ConcurrentHashSet<string>() { newCacheKey }, (key, existingCacheKeys) =>
    {
        existingCacheKeys.Add(newCacheKey);
        return existingCacheKeys;
    });
}

在同一个 util class 中,当我们需要删除特定公司的所有缓存键时,我们有一个类似于下面的方法(这是在其他控制器中进行变异更改时引起的)

public static void ClearCustomerCache(IAppCache cache, Guid customerId)
{
    var customerCacheKeys = new ConcurrentHashSet<string>();

    if (!cacheKeys.TryGetValue(customerId,out customerCacheKeys))
    {
        return new ConcurrentHashSet<string>();
    }


    foreach (var cacheKey in customerCacheKeys)
    {
        cache.Remove(cacheKey);
    }

    cacheKeys.TryRemove(customerId, out _);
}

我们最近遇到了性能问题,即我们的 Web 请求响应时间随着时间的推移显着变慢 - 我们没有看到每秒请求数方面的显着变化。

查看垃圾收集指标,我们似乎注意到第 2 代堆大小和对象大小似乎都在上升——我们没有看到内存被回收。

我们仍在对此进行调试,但我想知道使用上述方法是否会导致我们看到的问题。我们想要线程安全,但是使用上面的并发字典可能会出现问题,即使我们删除了内存未被释放的项目,也会导致过多的 Gen 2 收集。

我们也在使用工作站垃圾收集模式,想象一下切换到服务器模式 GC 会帮助我们(我们的 IIS 服务器有 8 个处理器 + 16 GB 内存)但不确定切换是否会解决所有问题。

大对象 (> 85k) 属于第 2 代大对象堆 (LOH),它们固定在内存中。

  1. GC 扫描 LOH 并标记死对象
  2. 相邻的死对象合并到可用内存中
  3. LOH 压缩
  4. 进一步的分配只会尝试填补死对象留下的空洞

不压缩,只重新分配可能会导致内存碎片。 长 运行 服务器进程可以由此完成 - 这并不少见。 随着时间的推移,您可能会看到碎片化。

服务器 GC 恰好是多线程的 - 我不希望它解决碎片问题。

您可以尝试分解大对象 - 这对您的应用程序来说可能不可行。

您可以在缓存清除后尝试设置 LargeObjectHeapCompaction - 假设它不常见。

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

最后,我建议分析堆以找出有效的方法。

您可能想利用 ExpirationTokens property of the MemoryCacheEntryOptions class。您还可以从 LazyCache.Providers.MemoryCacheProvider.GetOrCreateAsync 方法的委托中传递的 ICacheEntry 参数中使用它。例如:

Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory,
    int durationMilliseconds = Timeout.Infinite, string customerId = null)
{
    return GetMemoryCacheProvider().GetOrCreateAsync<T>(key, (options) =>
    {
        if (durationMilliseconds != Timeout.Infinite)
        {
            options.SetSlidingExpiration(TimeSpan.FromMilliseconds(durationMilliseconds));
        }
        if (customerId != null)
        {
            options.ExpirationTokens.Add(GetCustomerExpirationToken(customerId));
        }
        return factory();
    });
}

现在 GetCustomerExpirationToken 应该 return 一个实现 IChangeToken 接口的对象。事情变得有点复杂,但请耐心等待一分钟。 .NET 平台不提供适合这种情况的内置 IChangeToken 实现,因为它主要关注文件系统观察器。不过实现一个并不困难:

class ChangeToken : IChangeToken, IDisposable
{
    private volatile bool _hasChanged;
    private readonly ConcurrentQueue<(Action<object>, object)>
        registeredCallbacks = new ConcurrentQueue<(Action<object>, object)>();

    public void SignalChanged()
    {
        _hasChanged = true;
        while (registeredCallbacks.TryDequeue(out var entry))
        {
            var (callback, state) = entry;
            callback?.Invoke(state);
        }
    }

    bool IChangeToken.HasChanged => _hasChanged;

    bool IChangeToken.ActiveChangeCallbacks => true;

    IDisposable IChangeToken.RegisterChangeCallback(Action<object> callback,
        object state)
    {
        registeredCallbacks.Enqueue((callback, state));
        return this; // return null doesn't work
    }

    void IDisposable.Dispose() { } // It is called by the framework after each callback
}

这是IChangeToken接口的一般实现,用SignalChanged方法手动激活。该信号将传播到基础 MemoryCache 对象,该对象随后将使与该令牌关联的所有条目无效。

现在剩下要做的就是将这些令牌与客户相关联,并将它们存储在某个地方。我认为 ConcurrentDictionary 应该足够了:

private static readonly ConcurrentDictionary<string, ChangeToken>
    CustomerChangeTokens = new ConcurrentDictionary<string, ChangeToken>();

private static ChangeToken GetCustomerExpirationToken(string customerId)
{
    return CustomerChangeTokens.GetOrAdd(customerId, _ => new ChangeToken());
}

最后,发出信号表明特定客户的所有条目都应无效所需的方法:

public static void SignalCustomerChanged(string customerId)
{
    if (CustomerChangeTokens.TryRemove(customerId, out var changeToken))
    {
        changeToken.SignalChanged();
    }
}