C# .NET 中的缓存实现

开发 后端
缓存非常适用于不经常更改的数据。或者甚至更好,永远不会改变。不断变化的数据,比如当前机器的时间不应该被缓存,否则你会得到错误的结果。

[[423388]]

软件开发中最常用的模式之一是缓存。这是一个简单但非常有效的概念,这个想法的核心是记录过程数据,重用操作结果。当执行繁重的操作时,我们会将结果保存在我们的缓存容器中。下次我们需要该结果时,我们将从缓存容器中拉出它,而不是再次执行繁重的操作。

例如,要获取一个人的头像,您可能需要访问数据库。我们不会每次都执行那次旅行,而是将 Avatar 保存在缓存中,每次需要时从内存中提取它。

缓存非常适用于不经常更改的数据。或者甚至更好,永远不会改变。不断变化的数据,比如当前机器的时间不应该被缓存,否则你会得到错误的结果。

进程内缓存、持久性进程内缓存和分布式缓存

有 3 种类型的缓存:

  • In-Memory Cache用于在单个进程中实现缓存。当进程终止时,缓存也随之终止。如果您在多台服务器上运行相同的进程,您将为每台服务器提供一个单独的缓存。
  • 持久性进程内缓存是指在进程内存之外备份缓存。它可能在文件中,也可能在数据库中。这比较困难,但如果您的进程重新启动,缓存不会丢失。最适合在获取缓存项的情况下使用范围广泛,并且您的进程往往会重新启动很多。
  • 分布式缓存是指您希望为多台机器共享缓存。通常,它将是多个服务器。使用分布式缓存,它存储在外部服务中。这意味着如果一台服务器保存了一个缓存项,其他服务器也可以使用它。像Redis[1]这样的服务非常适合这一点。

我们将只讨论进程内缓存。

早期做法

让我们用 C# 创建一个非常简单的缓存实现:

  1. public class NaiveCache<TItem> 
  2.     Dictionary<object, TItem> _cache = new Dictionary<object, TItem>(); 
  3.  
  4.     public TItem GetOrCreate(object key, Func<TItem> createItem) 
  5.     { 
  6.         if (!_cache.ContainsKey(key)) 
  7.         { 
  8.             _cache[key] = createItem(); 
  9.         } 
  10.         return _cache[key]; 
  11.     } 

用法:

  1. var _avatarCache = new NaiveCache<byte[]>(); 
  2. // ... 
  3. var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId)); 

这个简单的代码解决了一个关键问题。要获取用户的头像,只有第一个请求才会真正执行到数据库的访问。然后将头像数据 ( byte[]) 保存在进程内存中。对头像的所有后续请求都将从内存中提取,从而节省时间和资源。

但是,正如编程中的大多数事情一样,没有什么是那么简单的。由于多种原因,上述解决方案并不好。一方面,这个实现不是线程安全的。从多个线程使用时可能会发生异常。除此之外,缓存的项目将永远留在内存中,这实际上非常糟糕。

这就是我们应该从缓存中删除项目的原因:

1.缓存会占用大量内存,最终导致内存不足异常和崩溃。

2.高内存消耗会导致GC 压力(又名内存压力)。在这种状态下,垃圾收集器的工作量超出其应有的水平,从而损害了性能。

3.如果数据发生变化,可能需要刷新缓存。我们的缓存基础设施应该支持这种能力。

为了处理这些问题,缓存框架具有驱逐策略(又名移除策略)。这些是根据某些逻辑从缓存中删除项目的规则。常见的驱逐政策有:

  • 无论如何,绝对过期策略将在固定时间后从缓存中删除项目。
  • 如果在固定的时间段内未访问某个项目,则滑动过期策略将从缓存中删除该项目。因此,如果我将过期时间设置为 1 分钟,只要我每 30 秒使用一次,该项目就会一直保留在缓存中。一旦我超过一分钟不使用它,该物品就会被驱逐。
  • 大小限制策略将限制缓存内存大小。

现在我们知道我们需要什么,让我们继续寻找更好的解决方案。

更好的解决方案

作为一名博主,令我非常沮丧的是,微软已经创建了一个很棒的缓存实现。这剥夺了我自己创建类似实现的乐趣,但至少我写这篇博文的工作量减少了。

我将向您展示微软的解决方案,如何有效地使用它,然后在某些场景中如何改进它。

System.Runtime.Caching/MemoryCache 与 Microsoft.Extensions.Caching.Memory

Microsoft 有 2 个解决方案 2 个不同的 NuGet 包用于缓存。两者都很棒。根据 Microsoft 的建议[2],更喜欢使用,Microsoft.Extensions.Caching.Memory因为它与 Asp.NET Core 集成得更好。它可以很容易地注入[3]到 Asp .NET Core 的依赖注入机制中。

这是一个基本示例Microsoft.Extensions.Caching.Memory:

  1. public class SimpleMemoryCache<TItem> 
  2.     private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); 
  3.  
  4.     public TItem GetOrCreate(object key, Func<TItem> createItem) 
  5.     { 
  6.         TItem cacheEntry; 
  7.         if (!_cache.TryGetValue(keyout cacheEntry))// Look for cache key
  8.         { 
  9.             // Key not in cache, so get data. 
  10.             cacheEntry = createItem(); 
  11.  
  12.             // Save data in cache. 
  13.             _cache.Set(key, cacheEntry); 
  14.         } 
  15.         return cacheEntry; 
  16.     } 

用法:

  1. var _avatarCache = new SimpleMemoryCache<byte[]>(); 
  2. // ... 
  3. var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId)); 

这和我自己的非常相似NaiveCache,所以有什么改变?嗯,一方面,这是一个线程安全的实现。您可以一次从多个线程安全地调用它。

第二件事是MemoryCache允许我们之前谈到的所有驱逐政策。下面是一个例子:

具有驱逐策略的 IMemoryCache:

  1. public class MemoryCacheWithPolicy<TItem> 
  2.     private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions() 
  3.     { 
  4.         SizeLimit = 1024 
  5.     }); 
  6.  
  7.     public TItem GetOrCreate(object key, Func<TItem> createItem) 
  8.     { 
  9.         TItem cacheEntry; 
  10.         if (!_cache.TryGetValue(keyout cacheEntry))// Look for cache key
  11.         { 
  12.             // Key not in cache, so get data. 
  13.             cacheEntry = createItem(); 
  14.  
  15.             var cacheEntryOptions = new MemoryCacheEntryOptions() 
  16.              .SetSize(1)//Size amount 
  17.              //Priority on removing when reaching size limit (memory pressure) 
  18.                 .SetPriority(CacheItemPriority.High) 
  19.                 // Keep in cache for this time, reset time if accessed. 
  20.                 .SetSlidingExpiration(TimeSpan.FromSeconds(2)) 
  21.                 // Remove from cache after this time, regardless of sliding expiration 
  22.                 .SetAbsoluteExpiration(TimeSpan.FromSeconds(10)); 
  23.  
  24.             // Save data in cache. 
  25.             _cache.Set(key, cacheEntry, cacheEntryOptions); 
  26.         } 
  27.         return cacheEntry; 
  28.     } 

1.SizeLimit被添加到MemoryCacheOptions. 这为我们的缓存容器添加了基于大小的策略。大小没有单位。相反,我们需要在每个缓存条目上设置大小数量。在这种情况下,我们每次将金额设置为 1 SetSize(1)。这意味着缓存限制为 1024 个项目。

2.当我们达到大小限制时,应该删除哪个缓存项?您实际上可以使用.SetPriority(CacheItemPriority.High). 级别为Low、Normal、High和NeverRemove。

3.SetSlidingExpiration(TimeSpan.FromSeconds(2))添加了,它将滑动过期时间设置为 2 秒。这意味着如果一个项目在 2 秒内未被访问,它将被删除。

4.SetAbsoluteExpiration(TimeSpan.FromSeconds(10))添加了,将绝对过期时间设置为 10 秒。这意味着该项目将在 10 秒内被驱逐,如果它还没有。

除了示例中的选项之外,您还可以设置一个RegisterPostEvictionCallback委托,该委托将在项目被驱逐时调用。

这是一个非常全面的功能集。它让你想知道是否还有什么要添加的。实际上有几件事。

问题和缺失的功能

在这个实现中有几个重要的缺失部分。

1.虽然您可以设置大小限制,但缓存实际上并不监控 gc 压力。如果真的监测,压力大的时候可以收紧政策,压力小的时候可以放松政策。

2.当多个线程同时请求同一个项目时,请求不会等待第一个完成。该项目将被创建多次。例如,假设我们正在缓存头像,从数据库中获取头像需要 10 秒。如果我们在第一次请求后 2 秒请求头像,它将检查头像是否已缓存(尚未缓存),并开始另一次访问数据库。

关于GC压力的第一个问题:可以使用多种技术和启发式方法来监控GC压力。这篇博文与此无关,但您可以阅读我的文章在 C# .NET 中查找、修复和避免内存泄漏:8 个最佳实践[4]以了解一些有用的方法。

第二个问题更容易解决。事实上,这是一个MemoryCache完全解决它的实现:

  1. public class WaitToFinishMemoryCache<TItem> 
  2.     private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); 
  3.     private ConcurrentDictionary<object, SemaphoreSlim> _locks = new ConcurrentDictionary<object, SemaphoreSlim>(); 
  4.  
  5.     public async Task<TItem> GetOrCreate(object key, Func<Task<TItem>> createItem) 
  6.     { 
  7.         TItem cacheEntry; 
  8.  
  9.         if (!_cache.TryGetValue(keyout cacheEntry))// Look for cache key
  10.         { 
  11.             SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1)); 
  12.  
  13.             await mylock.WaitAsync(); 
  14.             try 
  15.             { 
  16.                 if (!_cache.TryGetValue(keyout cacheEntry)) 
  17.                 { 
  18.                     // Key not in cache, so get data. 
  19.                     cacheEntry = await createItem(); 
  20.                     _cache.Set(key, cacheEntry); 
  21.                 } 
  22.             } 
  23.             finally 
  24.             { 
  25.                 mylock.Release(); 
  26.             } 
  27.         } 
  28.         return cacheEntry; 
  29.     } 

用法:

  1. var _avatarCache = new WaitToFinishMemoryCache<byte[]>(); 
  2. // ... 
  3. var myAvatar =  
  4.  await _avatarCache.GetOrCreate(userId, async () => await _database.GetAvatar(userId)); 

代码说明

此实现锁定项目的创建。锁是特定于钥匙的。例如,如果我们正在等待获取 Alex 的 Avatar,我们仍然可以在另一个线程上获取 John 或 Sarah 的缓存值。

字典_locks存储了所有的锁。常规锁不适用于async/await,因此我们需要使用SemaphoreSlim[5].

如果 (!_cache.TryGetValue(key, out cacheEntry)),有 2 次检查以查看该值是否已被缓存。锁内的那个是确保只有一个创建的那个。锁外面的那个是为了优化。

何时使用 WaitToFinishMemoryCache

这个实现显然有一些开销。让我们考虑什么时候甚至有必要。

在以下情况下使用 WaitToFinishMemoryCache:

  • 当项目的创建时间具有某种成本时,您希望尽可能减少创建。
  • 当一个项目的创建时间很长时。
  • 当必须确保每个键都创建一个项目时。

在以下情况下不要使用 WaitToFinishMemoryCache:

  • 没有多个线程访问同一个缓存项的危险。

?您不介意多次创建该项目。例如,如果对数据库的额外访问不会有太大变化。

概括

缓存是一种非常强大的模式,它也很危险,并且有其自身的复杂性。缓存太多,可能会导致 GC 压力,缓存太少会导致性能问题。而分布式缓存,这是一个需要探索的全新世界。软件开发职业就这样,总是有新的东西要学习。

References

[1] Redis: https://redis.io/

[2] 建议: https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory?view=aspnetcore-2.2#systemruntimecachingmemorycache

[3] 容易地注入: https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory?view=aspnetcore-2.2#using-imemorycache

[4] 在 C# .NET 中查找、修复和避免内存泄漏:8 个最佳实践: https://michaelscodingspot.com/find-fix-and-avoid-memory-leaks-in-c-net-8-best-practices/

 

[5] SemaphoreSlim: https://blog.cdemi.io/async-waiting-inside-c-sharp-locks/

 

责任编辑:武晓燕 来源: DotNET技术圈
相关推荐

2019-07-02 15:21:39

缓存NET单线程

2011-06-08 13:50:39

C#类型转换

2009-08-12 14:10:37

asp.net分页代码

2014-03-11 11:35:00

.NETC#

2009-09-04 15:45:29

C#缓存流

2015-07-28 10:06:03

C#内部实现剖析

2023-09-08 09:12:57

内存缓存图像

2009-08-26 15:25:06

.NET Framew

2009-08-26 15:10:34

脱离.net fram

2009-08-06 16:36:20

研究C#和.Net

2009-07-15 18:25:52

ASP.NET控件数组

2009-08-12 17:19:51

ASP.NET图片加水

2014-04-17 10:37:43

C++.NET Native

2009-08-20 10:13:49

ASP.NET和C#的

2009-09-01 16:29:03

QuickSort C

2009-07-27 14:13:56

调用c#方法Java scriptASP.NET

2009-08-26 14:27:03

C# Framewor

2009-05-13 11:50:17

C#多继承接口

2009-09-01 18:29:10

C#继承C#多态

2009-08-26 09:54:45

C#打印预览C#打印
点赞
收藏

51CTO技术栈公众号