有没有办法锁定并发字典不被使用

时间:2021-07-10 21:28:22

标签: c# .net dictionary asp.net-core concurrentdictionary

我有这个静态类

static class LocationMemoryCache
{
    public static readonly ConcurrentDictionary<int, LocationCityContract> LocationCities = new();
}

我的过程

  1. Api 启动并初始化一个空字典
  2. 后台作业每天启动并运行一次以从数据库重新加载字典
  3. 请求进来读取字典或更新字典中的特定城市

我的问题

如果收到更新城市的请求

  1. 我更新了数据库
  2. 如果更新成功,更新字典中的city对象
  3. 同时,后台作业启动,在我更新特定城市之前查询了所有城市
  4. 请求完成并且字典城市现在具有旧值,因为后台作业最后完成

我首先想到的解决方案

有没有办法从读/写中锁定/保留并发字典,然后在我完成后释放它?

这样当后台作业开始时,它可以只为自己锁定/保留字典,完成后它会释放它以供其他请求使用。

然后请求可能一直在等待字典发布并使用最新值更新它。

对其他可能的解决方案有什么想法吗?

编辑

  1. 后台工作的目的是什么?

如果我手动更新/删除数据库中的某些内容,我希望在后台作业再次运行后显示这些更改。这可能需要一天时间才能显示更改,我对此表示同意。

  1. 当 Api 想要访问缓存但未加载时会发生什么?

当 Api 启动时,我会阻止对这个特定“位置”项目的请求,直到后台作业将 IsReady 标记为 true。在我添加后台作业之前,我实现的缓存是线程安全的。

  1. 重新加载缓存需要多长时间?

对于“位置”项目中总共 310,000 多条记录,我会说不到 10 秒。

我为什么选择这个答案

我选择 Xerillio 的答案是因为它通过跟踪日期时间解决了后台作业问题。类似于“对象版本”方法。我不会走这条路,因为我已经决定,如果我在数据库中进行手动更新,我不妨创建一个 API 路由来为我执行此操作,以便我可以同时更新数据库和缓存。所以我可能会删除后台作业,或者每周只运行一次。感谢您提供所有答案,我可以接受可能与我更新对象的方式不一致的数据,因为如果一条路由更新 2 个特定值而另一条路由更新 2 个不同的特定值,则出现问题的可能性非常小< /p>

编辑 2

假设我现在有这个缓存和 10,000 个活跃用户

static class LocationMemoryCache
{
    public static readonly ConcurrentDictionary<int, LocationCityUserLogContract> LocationCityUserLogs = new();
}

我考虑过的事情

  1. 更新只会发生在用户拥有的对象上,并且用户更新这些对象的频率很可能是每分钟一次。因此,对于此特定示例,这大大降低了出现问题的可能性。
  2. 我的大部分缓存对象仅与特定用户相关,因此它与要点 1 相关。
  3. 应用程序拥有数据,我不拥有。所以我永远不应该手动更新数据库,除非它很重要。
  4. 内存可能有问题,但 1,000,000 个正常对象的大小介于 80MB 到 150MB 之间。我可以在内存中拥有大量对象以提高性能并减少数据库负载。
  5. 在内存中有很多对象会给垃圾收集带来压力,这并不好,但我认为这对我来说一点也不坏,因为垃圾收集只在内存变低时运行,我所要做的就是提前计划以确保有足够的内存。是的,它会因为日常运营而运行,但不会产生太大影响。
  6. 所有这些考虑都是为了让我可以在我的指尖拥有一个内存缓存。

2 个答案:

答案 0 :(得分:3)

我建议将 UpdatedAt/CreatedAt 属性添加到您的 LocationCityContract 或创建具有此类属性的包装器对象 (CacheItem<LocationCityContract>)。这样您就可以检查您要添加/更新的项目是否比现有对象新,如下所示:

public class CacheItem<T>
{
    public T Item { get; }
    public DateTime CreatedAt { get; }
    // In case of system clock synchronization, consider making CreatedAt 
    // a long and using Environment.TickCount64. See comment from @Theodor
    public CacheItem(T item, DateTime? createdAt = null)
    {
        Item = item;
        CreatedAt = createdAt ?? DateTime.UtcNow;
    }
}

// Use it like...
static class LocationMemoryCache
{
    public static readonly
        ConcurrentDictionary<int, CacheItem<LocationCityContract>> LocationCities = new();
}

// From some request...
var newItem = new CacheItem(newLocation);
// or the background job...
var newItem = new CacheItem(newLocation, updateStart);

LocationMemoryCache.LocationCities
    .AddOrUpdate(
        newLocation.Id,
        newItem,
        (_, existingItem) =>
            newItem.CreatedAt > existingItem.CreatedAt
            ? newItem
            : existingItem)
    );

当一个请求想要更新缓存条目时,他们会像上面一样使用他们完成将项目添加到数据库时的时间戳(参见下面的注释)。

后台作业应在启动后立即保存时间戳(我们称之为 updateStart)。然后它从数据库中读取所有内容并将项目添加到缓存中,如上所示,其中 CreatedAtnewLocation 设置为 updateStart。这样,后台作业只更新自启动以来未更新的缓存项。也许您不是在后台作业中首先读取数据库中的所有项目,而是一次读取一个项目并相应地更新缓存。在这种情况下,应该在读取每个值之前设置 updateStart(我们可以改为将其称为 itemReadStart)。

由于更新缓存中项目的方式有点麻烦,而且您可能会从很多地方进行更新,因此您可以创建一个辅助方法,使对 LocationCities.AddOrUpdate 的调用更容易一些。

注意:

  • 由于这种方法不会同步(锁定)数据库更新,因此存在竞争条件,这意味着您最终可能会在缓存中得到一个稍微过时的项目。如果两个请求想要同时更新同一个项目,就会发生这种情况。您无法确定最后更新的是哪个数据库,因此即使您在更新每个数据库后将 CreatedAt 设置为时间戳,它也可能无法真正反映最后更新的是哪个。由于您可以从手动更新数据库到后台作业更新缓存之间延迟 24 小时,因此这种竞争条件对您来说可能不是问题,因为后台作业会在运行时修复它。
  • 正如@Theodor 在评论中提到的,您应该避免直接从缓存中更新对象。如果要缓存新更新,请使用 C# 9 记录类型(而不是类类型)或克隆对象。这意味着,不要使用 LocationMemoryCache[locationId].Item.CityName = updatedName。相反,你应该例如像这样克隆它:
// You need to implement a constructor or similar to clone the object
// depending on how complex it is
var newLoc = new LocationCityContract(LocationMemoryCache[locationId].Item);
newLoc.CityName = updatedName;
var newItem = new CacheItem(newLoc);
LocationMemoryCache.LocationCities
    .AddOrUpdate(...); /* <- like above */
  • 通过不锁定整个字典,您可以避免请求被彼此阻止,因为它们试图同时更新缓存。如果第一点不可接受,您还可以在更新数据库时根据位置 ID(或您调用的任何名称)引入锁定,以便自动更新数据库和缓存。这样可以避免阻止尝试更新其他位置的请求,从而最大限度地降低请求相互影响的风险。

答案 1 :(得分:1)

不,无法根据读取/写入的需要锁定 ConcurrentDictionary,然后在完成后将其释放。此类不提供此功能。您可以在每次访问 lock 时手动使用 ConcurrentDictionary,但这样做将失去此专用类必须提供的所有优点(大量使用下的低争用),同时保留所有它的缺点(笨拙的 API、开销、分配)。

我的建议是使用受 Dictionary 保护的普通 lock。这是一种悲观的方法,偶尔会导致一些线程不必要地阻塞,但它也很简单,易于推理其正确性。基本上所有对字典和数据库的访问都会被序列化:

  1. 每当一个线程想要读取存储在字典中的对象时,首先必须获取 lock,并保留 lock 直到它完成读取对象。
  2. 每次一个线程想要更​​新数据库然后更新对应的对象时,都会先取lock(甚至在更新数据库之前),并保留lock直到所有的属性对象已更新。
  3. 每次后台作业想要用新字典替换当前字典时,首先要取lock(甚至在查询数据库之前),并保留lock直到新字典取代了旧的。

如果证明这种简单方法的性能不可接受,您应该查看更复杂的解决方案。但是,此解决方案与下一个最简单的解决方案(也提供有保证的正确性)之间的复杂性差距可能非常大,因此您最好在走这条路之前有充分的理由。

相关问题