有没有办法锁定并发字典不被使用
Is there a way to lock a concurrent dictionary from being used
我有这个静态class
static class LocationMemoryCache
{
public static readonly ConcurrentDictionary<int, LocationCityContract> LocationCities = new();
}
我的过程
- Api 启动并初始化一个空字典
- 后台作业开始,运行每天从数据库重新加载字典
- 请求进来从字典中读取或更新字典中的特定城市
我的问题
如果收到更新城市的请求
- 我更新数据库
- 如果更新成功,更新字典中的城市对象
- 同时,在我更新特定城市之前,后台作业启动并查询了所有城市
- 请求完成并且字典 city 现在具有旧值,因为后台作业最后完成
我首先想到的解决方案
有没有办法从 reads/writes lock/reserve 并发字典,然后在我完成后释放它?
这样当后台作业启动时,它可以 lock/reserve 字典只供自己使用,完成后将释放它以供其他请求使用。
那么一个请求可能一直在等待字典被释放并用最新的值更新它。
对其他可能的解决方案有什么想法吗?
编辑
- 后台作业的目的是什么?
如果我手动 update/delete 数据库中的某些内容,我希望这些更改在后台作业 运行 之后再次显示。这可能需要一天的时间才能显示更改,我可以接受。
- 当 Api 想要访问缓存但未加载时会发生什么?
当 Api 启动时,我会阻止对这个特定“位置”项目的请求,直到后台作业将 IsReady 标记为真。在添加后台作业之前,我实现的缓存是线程安全的。
- 重新加载缓存需要多长时间?
对于“位置”项目中总共 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 之间。我可以在内存中有很多对象来提高性能并减少数据库的负载。
- 内存中有很多对象会给垃圾收集带来压力,这不是好事,但我认为这对我来说一点也不坏,因为当内存不足时垃圾收集仅 运行s我所要做的就是提前计划以确保有足够的内存。是的,它会 运行 因为日常操作,但不会有太大影响。
- 所有这些考虑只是为了让我的内存缓存触手可及。
不,无法根据需要从 reads/writes 锁定 ConcurrentDictionary
,然后在完成后释放它。此 class 不提供此功能。您可以在每次访问 ConcurrentDictionary
时手动使用 lock
,但如果这样做,您将失去此专用 class 必须提供的所有优势(在大量使用下争用较少) ,同时保留其所有缺点(笨拙 API、开销、分配)。
我的建议是使用普通 Dictionary
protected with a lock
。这是一种悲观的做法,偶尔会导致一些线程不必要地阻塞,但它的正确性也很简单易推理。基本上所有对字典和数据库的访问都将被序列化:
- 每次线程要读取存储在字典中的对象时,首先必须取
lock
,并保留lock
直到读取完对象。
- 每次一个线程要更新数据库然后更新对应的对象,都会先取
lock
(甚至更新数据库之前),并保持lock
直到所有对象的属性已更新。
- 每次后台作业想要用新词典替换当前词典时,首先必须取
lock
(甚至在查询数据库之前),并保留 lock
直到新词典已取代旧词典。
如果这种简单方法的性能被证明是不可接受的,您应该考虑更复杂的解决方案。但是此解决方案与下一个最简单的解决方案(也提供有保证的正确性)之间的复杂性差距可能相当大,因此在采用该方法之前最好有充分的理由。
我建议将 UpdatedAt
/CreatedAt
属性 添加到您的 LocationCityContract
或使用这样的 [= 创建包装器对象 (CacheItem<LocationCityContract>
) 49=]。这样你就可以检查你要 add/update 使用的项目是否比现有对象更新,如下所示:
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
)。然后它从数据库中读取所有内容并像上面一样将项目添加到缓存中,其中 newLocation
的 CreatedAt
设置为 updateStart
。这样,后台作业仅更新自启动以来未更新的缓存项。也许您并没有将数据库中的所有项目作为后台作业的第一件事,而是一次读取它们并相应地更新缓存。在这种情况下,应该在读取每个值之前设置 updateStart
(我们可以将其称为 itemReadStart
)。
由于更新缓存中的项目的方式有点麻烦,而且您可能会在很多地方进行,您可以创建一个辅助方法来稍微调用 LocationCities.AddOrUpdate
更容易。
注:
- 由于这种方法不会同步(锁定)数据库的更新,因此存在竞争条件,这意味着您最终可能会在缓存中获得稍微过时的项目。如果两个请求想要同时更新同一个项目,就会发生这种情况。您无法确定最后更新数据库的是哪一个,因此即使您在更新每个数据库后将
CreatedAt
设置为时间戳,它也可能无法真正反映最后更新的数据库。由于您可以接受从手动更新数据库到后台作业更新缓存的 24 小时延迟,也许这种竞争条件对您来说不是问题,因为后台作业将在 运行.[=42 时修复它=]
- 正如@Theodor 在评论中提到的,您应该避免直接从缓存中更新对象。如果要缓存新更新,请使用 C# 9 记录类型(而不是 class 类型)或克隆对象。也就是说,不要使用
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(或任何您称之为)引入锁定,以便自动更新数据库和缓存。这可以避免阻止试图更新其他位置的请求,从而最大限度地降低请求相互影响的风险。
我有这个静态class
static class LocationMemoryCache
{
public static readonly ConcurrentDictionary<int, LocationCityContract> LocationCities = new();
}
我的过程
- Api 启动并初始化一个空字典
- 后台作业开始,运行每天从数据库重新加载字典
- 请求进来从字典中读取或更新字典中的特定城市
我的问题
如果收到更新城市的请求
- 我更新数据库
- 如果更新成功,更新字典中的城市对象
- 同时,在我更新特定城市之前,后台作业启动并查询了所有城市
- 请求完成并且字典 city 现在具有旧值,因为后台作业最后完成
我首先想到的解决方案
有没有办法从 reads/writes lock/reserve 并发字典,然后在我完成后释放它?
这样当后台作业启动时,它可以 lock/reserve 字典只供自己使用,完成后将释放它以供其他请求使用。
那么一个请求可能一直在等待字典被释放并用最新的值更新它。
对其他可能的解决方案有什么想法吗?
编辑
- 后台作业的目的是什么?
如果我手动 update/delete 数据库中的某些内容,我希望这些更改在后台作业 运行 之后再次显示。这可能需要一天的时间才能显示更改,我可以接受。
- 当 Api 想要访问缓存但未加载时会发生什么?
当 Api 启动时,我会阻止对这个特定“位置”项目的请求,直到后台作业将 IsReady 标记为真。在添加后台作业之前,我实现的缓存是线程安全的。
- 重新加载缓存需要多长时间?
对于“位置”项目中总共 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 之间。我可以在内存中有很多对象来提高性能并减少数据库的负载。
- 内存中有很多对象会给垃圾收集带来压力,这不是好事,但我认为这对我来说一点也不坏,因为当内存不足时垃圾收集仅 运行s我所要做的就是提前计划以确保有足够的内存。是的,它会 运行 因为日常操作,但不会有太大影响。
- 所有这些考虑只是为了让我的内存缓存触手可及。
不,无法根据需要从 reads/writes 锁定 ConcurrentDictionary
,然后在完成后释放它。此 class 不提供此功能。您可以在每次访问 ConcurrentDictionary
时手动使用 lock
,但如果这样做,您将失去此专用 class 必须提供的所有优势(在大量使用下争用较少) ,同时保留其所有缺点(笨拙 API、开销、分配)。
我的建议是使用普通 Dictionary
protected with a lock
。这是一种悲观的做法,偶尔会导致一些线程不必要地阻塞,但它的正确性也很简单易推理。基本上所有对字典和数据库的访问都将被序列化:
- 每次线程要读取存储在字典中的对象时,首先必须取
lock
,并保留lock
直到读取完对象。 - 每次一个线程要更新数据库然后更新对应的对象,都会先取
lock
(甚至更新数据库之前),并保持lock
直到所有对象的属性已更新。 - 每次后台作业想要用新词典替换当前词典时,首先必须取
lock
(甚至在查询数据库之前),并保留lock
直到新词典已取代旧词典。
如果这种简单方法的性能被证明是不可接受的,您应该考虑更复杂的解决方案。但是此解决方案与下一个最简单的解决方案(也提供有保证的正确性)之间的复杂性差距可能相当大,因此在采用该方法之前最好有充分的理由。
我建议将 UpdatedAt
/CreatedAt
属性 添加到您的 LocationCityContract
或使用这样的 [= 创建包装器对象 (CacheItem<LocationCityContract>
) 49=]。这样你就可以检查你要 add/update 使用的项目是否比现有对象更新,如下所示:
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
)。然后它从数据库中读取所有内容并像上面一样将项目添加到缓存中,其中 newLocation
的 CreatedAt
设置为 updateStart
。这样,后台作业仅更新自启动以来未更新的缓存项。也许您并没有将数据库中的所有项目作为后台作业的第一件事,而是一次读取它们并相应地更新缓存。在这种情况下,应该在读取每个值之前设置 updateStart
(我们可以将其称为 itemReadStart
)。
由于更新缓存中的项目的方式有点麻烦,而且您可能会在很多地方进行,您可以创建一个辅助方法来稍微调用 LocationCities.AddOrUpdate
更容易。
注:
- 由于这种方法不会同步(锁定)数据库的更新,因此存在竞争条件,这意味着您最终可能会在缓存中获得稍微过时的项目。如果两个请求想要同时更新同一个项目,就会发生这种情况。您无法确定最后更新数据库的是哪一个,因此即使您在更新每个数据库后将
CreatedAt
设置为时间戳,它也可能无法真正反映最后更新的数据库。由于您可以接受从手动更新数据库到后台作业更新缓存的 24 小时延迟,也许这种竞争条件对您来说不是问题,因为后台作业将在 运行.[=42 时修复它=] - 正如@Theodor 在评论中提到的,您应该避免直接从缓存中更新对象。如果要缓存新更新,请使用 C# 9 记录类型(而不是 class 类型)或克隆对象。也就是说,不要使用
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(或任何您称之为)引入锁定,以便自动更新数据库和缓存。这可以避免阻止试图更新其他位置的请求,从而最大限度地降低请求相互影响的风险。