哈Ha!预期课程“ C#ASP.NET Core Developer”的开始,我们准备了有关C#中缓存实现的有趣材料的翻译。享受阅读。
缓存
是软件开发中最常用的模式之一。这是一个简单且同时非常有效的概念。这个想法是重用执行操作的结果。经过耗时的操作,我们将结果保存在我们的缓存容器中。下次需要此结果时,我们将从缓存容器中提取结果,而不必再次执行费力的操作。例如,要获取用户头像,您可能必须从数据库中请求它。无需在每次调用时执行请求,我们都会将此头像存储在缓存中,并在每次需要时从内存中提取出来。缓存非常适合不经常更改的数据。或者,理想情况下,它们永远不会改变。不断变化的数据(例如当前时间)不应被缓存,否则会冒获得错误结果的风险。本地缓存,持久性本地缓存和分布式缓存
缓存共有3种类型:- 内存中高速缓存用于仅需要在一个进程中实现高速缓存的情况。当进程终止时,缓存也随之终止。如果在多台服务器上运行相同的进程,则每台服务器将具有单独的缓存。
- 持久的进程内高速缓存 -这是您在进程内存之外备份高速缓存时的情况。它可以位于文件或数据库中。它比内存中的缓存复杂,但是如果您的进程重新启动,则不会刷新缓存。最适合于获取缓存项的成本很高并且您的过程倾向于频繁重启的情况。
- 分布式缓存是当您需要多台计算机的共享缓存时。通常这些是几台服务器。分布式缓存存储在外部服务中。这意味着,如果一台服务器保留了缓存元素,则其他服务器也可以使用它。Redis之类的服务对此非常有用。
我们只会谈论本地缓存。原始实现
让我们从在C#中创建一个非常简单的缓存实现开始:public class NaiveCache<TItem>
{
Dictionary<object, TItem> _cache = new Dictionary<object, TItem>();
public TItem GetOrCreate(object key, Func<TItem> createItem)
{
if (!_cache.ContainsKey(key))
{
_cache[key] = createItem();
}
return _cache[key];
}
}
使用:var _avatarCache = new NaiveCache<byte[]>();
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));
这个简单的代码解决了一个重要的问题。为了获得用户头像,只有第一个请求将是来自数据库的实际请求。byte []
请求结果的化身数据()存储在过程存储器中。所有后续的头像请求都将从内存中检索它,从而节省时间和资源。但是,就像编程中的大多数事情一样,事情并不是那么简单。由于多种原因,上述实施方式不是一个好的解决方案。一方面,此实现不是线程安全的。当从多个线程使用时,可能会发生异常。此外,缓存的项目将永远保留在内存中,这实际上是非常糟糕的。这就是为什么我们应该从缓存中删除项目的原因:- 缓存可能开始占用大量内存,最终由于缓存不足和崩溃而导致异常。
- 高内存消耗会导致内存压力(也称为GC压力)。在这种状态下,垃圾收集器的工作量超出了应有的水平,从而降低了性能。
- 数据更改时可能需要更新缓存。我们的缓存基础结构必须支持此功能。
为了解决这些问题,流离失所政策的框架中存在着(也称为撤离政策-驱逐/撤离政策)。这些是根据给定逻辑从缓存中删除项目的规则。以下是常见的删除策略:- 绝对过期策略,无论经过什么时间,该策略都会在固定时间后从缓存中删除项目。
- 滑动过期策略,用于在一段时间内未访问任何项目时将其从缓存中删除。也就是说,如果将到期时间设置为1分钟,则该项目将保留在缓存中,而我每30秒使用一次。如果超过一分钟不使用它,则该项目将被删除。
- 大小限制策略,它将限制缓存的大小。
现在,我们已经确定了所需的一切,让我们继续寻求更好的解决方案。更好的解决方案
作为博客作者,我感到非常失望,微软已经创建了一个出色的缓存实现。这剥夺了我自己创建类似实现的乐趣,但是至少本文的写作也更少。我将向您展示Microsoft解决方案,如何有效使用它,然后在某些情况下如何改进它。System.Runtime.Caching / MemoryCache与Microsoft.Extensions.Caching.Memory
Microsoft有2个解决方案,2个不同的NuGet缓存程序包。两者都很棒。根据Microsoft 的建议,最好使用Microsoft.Extensions.Caching.Memory
,因为它与Asp的集成更好。NET核心。它可以轻松地集成到Asp .NET Core依赖项注入机制中。这是一个简单的示例Microsoft.Extensions.Caching.Memory
:public class SimpleMemoryCache<TItem>
{
private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
public TItem GetOrCreate(object key, Func<TItem> createItem)
{
TItem cacheEntry;
if (!_cache.TryGetValue(key, out cacheEntry))
{
cacheEntry = createItem();
_cache.Set(key, cacheEntry);
}
return cacheEntry;
}
}
使用:var _avatarCache = new SimpleMemoryCache<byte[]>();
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));
这让我很想起自己NaiveCache
,所以发生了什么变化?好吧,首先,这是一个线程安全的实现。您可以一次从多个线程安全地调用它。其次,它MemoryCache
考虑到了我们前面谈到的所有挤出政策。这是一个示例:具有抢占策略的IMemoryCache:public class MemoryCacheWithPolicy<TItem>
{
private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()
{
SizeLimit = 1024
});
public TItem GetOrCreate(object key, Func<TItem> createItem)
{
TItem cacheEntry;
if (!_cache.TryGetValue(key, out cacheEntry))
{
cacheEntry = createItem();
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetSize(1)
.SetPriority(CacheItemPriority.High)
.SetSlidingExpiration(TimeSpan.FromSeconds(2))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(10));
_cache.Set(key, cacheEntry, cacheEntryOptions);
}
return cacheEntry;
}
}
让我们分析一下新元素:- 已添加MemoryCacheOptions
SizeLimit
。这为我们的缓存容器添加了大小限制策略。高速缓存没有用于测量记录大小的机制。因此,我们需要设置每个缓存条目的大小。在这种情况下,每次我们使用将大小设置为1 SetSize(1)
。这意味着我们的缓存限制为1024个元素。 - , ?
.SetPriority (CacheItemPriority.High)
. : Low (), Normal (), High () NeverRemove ( ). SetSlidingExpiration(TimeSpan.FromSeconds(2))
2 . , 2 , .SetAbsoluteExpiration(TimeSpan.FromSeconds(10))
10 . , 10 , .
除了示例中的选项之外,您还可以设置一个代表RegisterPostEvictionCallback
,该代表在删除项目时将被调用。这是相当广泛的功能,但是,尽管如此,我们仍需要考虑是否还有其他要添加的功能。实际上有两件事。问题和缺少的功能
此实现缺少几个重要部分。- 尽管可以设置大小限制,但是缓存实际上并不能控制内存压力。如果我们进行监测,我们可以在高压力下收紧政策,而在低压力下收紧政策。
- , . . , , , 10 . 2 , , ( ), .
关于 gc 的压力的第一个问题:可以通过几种方法和试探法来控制gc的压力。这篇文章不是关于此的,但是您可以阅读我的文章“在C#.NET中查找,修复和防止内存泄漏:8个最佳实践”,以了解一些有用的方法。第二个问题比较容易解决。实际上,这是一个MemoryCache
完全解决它的实现:public class WaitToFinishMemoryCache<TItem>
{
private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
private ConcurrentDictionary<object, SemaphoreSlim> _locks = new ConcurrentDictionary<object, SemaphoreSlim>();
public async Task<TItem> GetOrCreate(object key, Func<Task<TItem>> createItem)
{
TItem cacheEntry;
if (!_cache.TryGetValue(key, out cacheEntry))
{
SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1));
await mylock.WaitAsync();
try
{
if (!_cache.TryGetValue(key, out cacheEntry))
{
cacheEntry = await createItem();
_cache.Set(key, cacheEntry);
}
}
finally
{
mylock.Release();
}
}
return cacheEntry;
}
}
使用:var _avatarCache = new WaitToFinishMemoryCache<byte[]>();
var myAvatar =
await _avatarCache.GetOrCreate(userId, async () => await _database.GetAvatar(userId));
在此实现中,当您尝试获取元素时,如果同一元素已经在由另一个线程创建,则将等到第一个线程完成。然后,您将获得另一个线程创建的已经缓存的项目。代码解析
此实现阻止元素的创建。锁定发生在钥匙上。例如,如果我们正在等待Alexey的化身,我们仍然可以在另一个线程中获取Zhenya或Barbara的缓存值。字典_locks
存储所有锁。常规锁不起作用async/await
,因此我们需要使用SemaphoreSlim。有2个检查,以检查if是否已经缓存了该值(!_Cache.TryGetValue(key, out cacheEntry))
。锁中的那个是唯一提供元素创建的那个。锁之外的一个用于优化。何时使用 WaitToFinishMemoryCache
该实现显然有一些开销。让我们看看它何时相关。在以下情况下使用WaitToFinishMemoryCache
:- 当项目的创建时间具有任何值时,并且您想要最大程度地减少创建次数。
- 创建项目的时间很长。
- 当必须为每个键创建一个项目时。
在以下情况下请勿使用WaitToFinishMemoryCache
:- 多个线程将不会访问同一缓存元素,这是没有危险的。
- 您绝对不会多次创建元素。例如,如果对数据库的一个附加查询不会对任何事情产生太大影响。
摘要
缓存是一种非常强大的模式。而且它也很危险并且有陷阱。缓存过多会给GC造成压力。缓存太少会导致性能问题。还有分布式缓存,代表了一个全新的探索世界。这是软件开发,总会有一些新东西可以掌握。希望您喜欢这篇文章。如果您对内存管理感兴趣,那么我的下一篇文章将重点介绍GC受到压力的危险以及如何防止它,因此请注册。享受您的编码。
了解有关该课程的更多信息。