问题描述
我有这个静态类
static class LocationMemoryCache
{
public static readonly ConcurrentDictionary<int,LocationCityContract> LocationCities = new();
}
我的过程
我的问题
如果收到更新城市的请求
我首先想到的解决方案
有没有办法从读/写中锁定/保留并发字典,然后在我完成后释放它?
这样当后台作业开始时,它可以只为自己锁定/保留字典,完成后它会释放它以供其他请求使用。
然后请求可能一直在等待字典发布并使用最新值更新它。
对其他可能的解决方案有什么想法吗?
编辑
- 后台工作的目的是什么?
如果我手动更新/删除数据库中的某些内容,我希望在后台作业再次运行后显示这些更改。这可能需要一天时间才能显示更改,我对此表示同意。
- 当 Api 想要访问缓存但未加载时会发生什么?
当 Api 启动时,我会阻止对这个特定“位置”项目的请求,直到后台作业将 IsReady 标记为 true。在我添加后台作业之前,我实现的缓存是线程安全的。
- 重新加载缓存需要多长时间?
对于“位置”项目中总共 310,000 多条记录,我会说不到 10 秒。
我为什么选择这个答案
我选择 Xerillio 的答案是因为它通过跟踪日期时间解决了后台作业问题。类似于“对象版本”方法。我不会走这条路,因为我已经决定,如果我在数据库中进行手动更新,我不妨创建一个 API 路由来为我执行此操作,以便我可以同时更新数据库和缓存。所以我可能会删除后台作业,或者每周只运行一次。感谢您提供所有答案,我可以接受可能与我更新对象的方式不一致的数据,因为如果一条路由更新 2 个特定值而另一条路由更新 2 个不同的特定值,则出现问题的可能性非常小
编辑 2
假设我现在有这个缓存和 10,000 个活跃用户
static class LocationMemoryCache
{
public static readonly ConcurrentDictionary<int,LocationCityUserLogContract> LocationCityUserLogs = new();
}
我考虑过的事情
- 更新只会发生在用户拥有的对象上,并且用户更新这些对象的频率很可能是每分钟一次。因此,对于此特定示例,这大大降低了出现问题的可能性。
- 我的大部分缓存对象仅与特定用户相关,因此它与要点 1 相关。
- 应用程序拥有数据,我不拥有。所以我永远不应该手动更新数据库,除非它很重要。
- 内存可能有问题,但 1,000,000 个正常对象的大小介于 80MB 到 150MB 之间。我可以在内存中拥有大量对象以提高性能并减少数据库负载。
- 在内存中有很多对象会给垃圾收集带来压力,这并不好,但我认为这对我来说一点也不坏,因为垃圾收集只在内存变低时运行,我所要做的就是提前计划以确保有足够的内存。是的,它会因为日常运营而运行,但不会产生太大影响。
- 所有这些考虑都是为了让我可以在我的指尖拥有一个内存缓存。
解决方法
我建议将 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
)。然后它从数据库中读取所有内容并将项目添加到缓存中,如上所示,其中 CreatedAt
的 newLocation
设置为 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(或您调用的任何名称)引入锁定,以便自动更新数据库和缓存。这样可以避免阻止尝试更新其他位置的请求,从而最大限度地降低请求相互影响的风险。
不,无法根据读取/写入的需要锁定 ConcurrentDictionary
,然后在完成后将其释放。此类不提供此功能。您可以在每次访问 lock
时手动使用 ConcurrentDictionary
,但这样做将失去此专用类必须提供的所有优点(大量使用下的低争用),同时保留所有它的缺点(笨拙的 API、开销、分配)。
我的建议是使用受 Dictionary
保护的普通 lock
。这是一种悲观的方法,偶尔会导致一些线程不必要地阻塞,但它也很简单,易于推理其正确性。基本上所有对字典和数据库的访问都会被序列化:
- 每当一个线程想要读取存储在字典中的对象时,首先必须获取
lock
,并保留lock
直到它完成读取对象。 - 每次一个线程想要更新数据库然后更新对应的对象时,都会先取
lock
(甚至在更新数据库之前),并保留lock
直到所有的属性对象已更新。 - 每次后台作业想要用新字典替换当前字典时,首先要取
lock
(甚至在查询数据库之前),并保留lock
直到新字典取代了旧的。
如果证明这种简单方法的性能不可接受,您应该查看更复杂的解决方案。但是,此解决方案与下一个最简单的解决方案(也提供有保证的正确性)之间的复杂性差距可能非常大,因此您最好在走这条路之前有充分的理由。