Cache<T>: 一个线程安全、简单、高效、泛型的内存缓存






4.94/5 (65投票s)
比MemoryCache更简单,干扰更少
引言
今天,我想讨论“缓存”这个话题,并向您展示我实现的泛型内存缓存Cache<K,T>
。您可以在此处找到我完整的实现文档和下载,包括一些简单的单元测试用于演示。
更新:原始文章中的类名为Cache<T>
,仅包含缓存的通用内容。现已扩展为Cache<K,T>
,允许您指定缓存键的类型。Cache<T>
仍然可用,且未作更改!它只是从Cache<K,T>
派生,如下所示:
public class Cache<T> : Cache<string, T>
更新 2:感谢@Kochise的贡献,我在Cache
类中添加了两个新方法。
Clear()
和AddOrUpdate(K, T)
,默认寿命为Timeout.Infinite
。非常感谢这些想法,Kochise :)。这让我想到了实现一个Remove()
方法,该方法接受一个keyPattern
作为Predicate<K>
,这样您就可以通过一次调用删除缓存中的多个条目。您可以在下面的“使用代码”部分找到详细的解释。
下载中的两个zip文件已更新。LoadTest
在Release模式下由于优化出现了一个奇怪的行为。现已修复。我还更新了Cache.zip中包含的单元测试,以调用新的Clear()
方法和新的Remove()
方法。
我早在2005年.NET 2.0发布时就编写了我的第一个对象缓存。这个类(多多少少:))至今仍在沿用,多年来它不断发展,现在使用线程计时器(利用ThreadPool
),是线程安全的,并且我最近将其更新为C# 6.0语法。
今天我写下这篇文章的原因是我与一位同事的讨论,关于我为什么不使用.NET Framework自带的MemoryCache
类。嗯,一个原因肯定是,我“已经习惯了我自己缓存的接口”,但另一个原因(至少和第一个原因一样重要)是:MemoryCache
的接口很难看。我不喜欢它。
当我看一个类时,我喜欢什么?一个简单、直接的接口。当我想缓存一个对象,或者更确切地说,一个类型为T
的项时,我希望看到:
- 如何添加/更新它,以及它存储多长时间?
- 如何访问(获取)它?
- 如何删除它?
- 有时:它是否存在?
我不希望看到的是:
- 使用
CacheItemPolicy
、DateTimeOffsets
、不同的策略、过期……大量的子类和子结构。 - 网站上有好几页长的文档。
不幸的是,当你查阅MSDN上的MemoryCache
类时(https://msdn.microsoft.com/de-de/library/system.runtime.caching.memorycache(v=vs.110).aspx),所有这些都会出现。
如果你和我的想法一样,如果你更喜欢一个简单易用的界面,没有干扰,那么你可能会喜欢这个类。
我从来都不是MemoryCache
的粉丝,因为它给我带来了很多额外的负担。我不想去思考的事情。
我之前不喜欢MemoryCache
的另一件事是:它不是泛型的。它处理的是object
。
背景
在许多情况下,内存缓存是有意义的。无论是可能频繁运行的数据库查询结果,还是反射,以及更多。
尤其是反射,这是个棘手的问题……您加载一个程序集,获取类型、接口,也许查找属性,找到一些您想稍后调用的MethodInfo
……为什么要一遍又一遍地扫描程序集?只需将您想调用的MethodInfo
保留在缓存中,给它一个唯一的键,然后在需要时调用它。消除反射的成本。只做一次。是的,当然您可以创建一个private Dictionary
并存储您的MethodInfo
对象。但您需要自己处理线程安全,处理存在/替换以及这个Cache
实现已经为您处理的所有这些事情。所以,在我看来,使用“Cache
”来存储进程生命周期内的item<T>
是没问题的,因为所有周围的噪音都已经为您处理并封装好了。
下一个好的选择是本地缓存数据库查询结果。您会说Cache<DataTable>
没有多大意义吗?因为数据库服务器本身就有缓存?嗯,是的,它有。它确实缓存。但在更大的规模下,比如2000个客户端甚至更多(我目前在工作中正在处理一个拥有50k客户端的系统),这对数据库集群来说会产生巨大的影响。50k使用数据库缓存的查询仍然会产生50k到数据库集群的请求!如果大部分请求可以由本地Cache<DataTable>
处理,您就会减轻一个真正的痛苦,从数据库中卸载巨大的负载。
因此,您的本地客户端可以“随心所欲”地刷新,每秒或每3秒获取一些状态——如果您处于轮询场景中——您可以在业务逻辑或数据层中定义,远离GUI客户端层,客户端将新数据呈现的频率,只需设置一个值:缓存时间。所有这些都是本地发生的,数据库对此一无所知,也不需要关心。
我的许多应用程序都利用了这个缓存的许多不同实例,有些只保留条目几秒钟,有些则保留到进程生命周期(许多反射场景都是这样做的)。
那么,让我们来看看我的泛型缓存,好吗?
Using the Code
正如我在文章开头所说,我喜欢查看类时,希望有一个干净、直接的界面。
Cache<T>
类看起来是这样的:
它被精简到您真正*需要*的东西:Add
(Update
)、(Try
)Get
、Remove
、Exist
和Clear
。为了方便,还实现了一个索引器,所以您可以使用
cache.Get(key); or cache[key];
您更喜欢什么来Get()
一个缓存条目。
让我们从Cache
类的基础开始。可下载的文件包含三个类:
Cache : Cache<object>
Cache<T> : Cache<string, T>
Cache<K,T>
非泛型的Cache
类仅在您需要将不同类型的对象存储在单个缓存实例中时实现一个纯粹的对象缓存。
个人建议:如果您只有2或3种要缓存的类型(这对于绝大多数用例来说都是如此),创建2或3个Cache<T>
实例比单个Cache<object>
更好、更干净。没有后台线程介入,计时器在触发时使用ThreadPool
线程,所以没有真正的原因坚持使用一个单独的Cache实例。请牢记这一点。
该类做什么,不做什么?
- 它以秒为间隔缓存对象,而不是毫秒。
- 它支持
Timeout.Infinite
以永远保留条目(=进程生命周期)。 - 它实现了
IDisposable
。 - 它是线程安全的。
- 它是泛型的。
- 缓存过期使用
System.Threading.Timers.Timer
,它在执行过期回调时利用ThreadPool
线程,所以当您的缓存变大或您拥有多个Cache实例运行时,不会有大量不活跃的睡眠线程闲置。
它*不*做什么:
- 提供监控、列表、迭代、检查内容的功能。
- 不同的过期策略和方式。要么您想让它在缓存中,要么不想。如果不想,就
.Remove
它。 - 提供事件以在条目被清除之前做出反应。
- 最大内存设置等。您放入缓存中的内容量由您自己负责。
让我们从一个小演示开始。
我将通过一些单元测试代码来展示演示,因为它非常清楚地解释了如何使用这个类。
[TestMethod]
public void Cache_Generic_Expiration_Ok()
{
// Create a cache to hold DataTables
Cache<DataTable> cache = new Cache<DataTable>();
DataTable testTable = new DataTable();
// Add a DataTable with the key "test" to the cache, for 1 second
c.AddOrUpdate("test", testTable, 1);
// Check, whether the cache entry "test" exists.
Assert.IsTrue(c.Exists("test"));
// Check the references of our local table and the entry from the cache...
Assert.AreSame(c.Get("test"), testTable);
// Now wait a little more than a second to give the cache time to expire...
Thread.Sleep(1050);
// Now the entry no longer exists.
Assert.IsFalse(c.Exists("test"));
}
这几行代码已经解释了您需要了解的关于这个类的最重要的事情:只需创建一个实例——没有复杂的线程,没有杂乱的代码。
AddOrUpdate
一个条目将其放入缓存。第三个参数是缓存时间。指定Timeout.Infinite
以永远保留它。
Exists
告诉您特定键是否已存在于缓存中。
Remove
从缓存中删除一个条目。
Get(key)
、TryGet(key, out T item)
或[key]
将为您返回缓存中的条目,如果找不到,则返回default(T)
。不会引发异常,如果条目不存在。为什么?因为它实际上没有区别。正常的用例是这样的代码结构:
(这摘自我编写的一个数据层,使用了缓存,隐式null
检查以及在null
时执行查询)。
result.Data = sqlCache[commandKey] ?? ExecuteQuery(command);
sqlCache.AddOrUpdate(commandKey, result.Data, cacheTime);
在这里,异常不会带来好处。如果您真的想在条目当前未缓存时采取完全不同的行为,请使用TryGet
或Exists
。
如果您仔细看了这两行代码,您可能会说:“等等!如果你再次用cacheTime
更新此条目,该条目将永远不会过期!这是错误的!”
这里的问题是:AddOrUpdate
有一个第四个(可选)参数,默认为false
。它的名称是restartTimerIfExists
。此参数为您提供了对缓存条目的完全控制。默认情况下,计时器不会被修改(如果您只更新条目,则会忽略cacheTime
参数),因此更新条目只会更新条目的内容,而不是其TTL(生存时间)。控制(因此,决定)是否要将计时器重置为新值,完全取决于您,取决于您刚刚更新条目的原因/情况。
从缓存中删除条目值得仔细研究,因为它提供了一个整洁的小签名,可以批量删除满足特定条件的条目。Remove
方法提供两个签名:
Remove(K key)
and
Remove(Predicate<K> keyPattern)
第二个签名允许您指定任何谓词(如Lambda表达式),该谓词对于您要从缓存中删除的每个键都必须评估为true
。
其中一个单元测试很好地演示了这一点(请查看测试中间的c.Remove
行)。
[TestMethod]
public void Cache_Remove_By_Pattern_Ok()
{
Cache c = new Cache();
c.AddOrUpdate("test1", new object());
c.AddOrUpdate("test2", new object());
c.AddOrUpdate("test3", new object());
c.AddOrUpdate("Other", new object());
Assert.IsTrue(c.Exists("test1"));
Assert.IsTrue(c.Exists("Other"));
c.Remove(k => k.StartsWith("test")); // <-- This one here :)
Assert.IsFalse(c.Exists("test1"));
Assert.IsFalse(c.Exists("test2"));
Assert.IsFalse(c.Exists("test3"));
Assert.IsTrue(c.Exists("Other"));
}
如您所见,任何Lambda都可以用于删除多个条目。如果您通过特定模式构建缓存键,无论它们是int
还是string
键(或其他任何类型),您都可以通过一次调用来清除缓存的部分。
让我给您一个简单的例子,以便您能理解。
我以ImageCache
为例,因为这是一个非常常见的用例。
您的键的构建方式是[content].[userid]
,以便使它们唯一。
UserPortrait.356
?UserPortrait.409
?UserPortrait.1158
?UserPortrait.22
Avatar.869
Avatar.223
Avatar.127
Avatar.256
Avatar.1987
因此,您可以通过调用来轻松删除缓存中的所有Avatar
图像:
Remove(k => k.StartsWith("Avatar."));
但您也可以删除指定[userid]
的所有图像,通过:
Remove(k => k.EndsWith($".{userid}");
这就是类用法的全部!Add
、Remove
、Get
以及一两个小方便方法。干净简单!
Cache<K,T>的源代码
现在我想展示并讨论Cache<T>
类的源代码。它非常简单,没有什么高深的技巧,但我觉得在文章中“幕后看看”并解释正在发生的事情很重要。
构造一个Cache<T>
所有Cache
实现都只有一个默认构造函数,不带参数。
Cache<DataTable> sqlCache = new Cache<DataTable>();
内部数据存储在两个简单的Dictionary中。是的,我本来可以用一个Dictionary
,但由于Timers
可以独立于缓存对象进行重置和处理,所以两个Dictionary也很好。
线程安全通过ReaderWriterLockSlim
实现,它处理并行访问。
public class Cache<K, T> : IDisposable
{
#region Constructor and class members
/// <summary>
/// Initializes a new instance of the <see cref="Cache{K,T}"/> class.
/// </summary>
public Cache() { }
private Dictionary<K, T> cache = new Dictionary<K, T>();
private Dictionary<K, Timer> timers = new Dictionary<K, Timer>();
private ReaderWriterLockSlim locker = new ReaderWriterLockSlim();
#endregion
IDisposable
实现是完全标准的,所以我在这里不详细讨论,而是跳到类中更有趣的部分。
Add、Remove、Get和Exist
这些方法是您与Cache<T>
交互的接口。
AddOrUpdate
public void AddOrUpdate(K key, T cacheObject, int cacheTimeout, bool restartTimerIfExists = false)
{
if (disposed) return;
if (cacheTimeout != Timeout.Infinite && cacheTimeout < 1))
throw new ArgumentOutOfRangeException("cacheTimeout must be greater than zero.");
locker.EnterWriteLock();
try
{
CheckTimer(key, cacheTimeout, restartTimerIfExists);
if (!cache.ContainsKey(key))
cache.Add(key, cacheObject);
else
cache[key] = cacheObject;
}
finally { locker.ExitWriteLock(); }
}
AddOrUpdate
中发生了什么?
- 如果我们已处置,则不再允许访问。
- 参数值检查。
- 在
write
锁内。- 计时器检查(稍后详细介绍)。
- 在
Dictionary
中简单地Add
/Replace
cacheObject
。
正如承诺的,这里没有多少技巧:)。
移除
public void Remove(K key)
{
if (disposed) return;
locker.EnterWriteLock();
try
{
if (cache.ContainsKey(key))
{
try { timers[key].Dispose(); }
catch { }
timers.Remove(key);
cache.Remove(key);
}
}
finally { locker.ExitWriteLock(); }
Remove
也很简单。只需在写锁安全的环境下Remove
两个条目:计时器和缓存的对象。Remove
的第二个签名允许指定一个Predicate<K>
来查找要删除的键。
public void Remove(Predicate<K> keyPattern)
{
if (disposed) return;
locker.EnterWriteLock();
try
{
var removers = (from k in cache.Keys.Cast<K>()
where keyPattern(k)
select k).ToList();
foreach (K workKey in removers)
{
try { timers[workKey].Dispose(); }
catch { }
timers.Remove(workKey);
cache.Remove(workKey);
}
}
finally { locker.ExitWriteLock(); }
}
填充var removers
的Linq必须使用.ToList()
,因为它不允许在迭代集合时修改它。谓词仅在此处的where
子句中使用,并且必须为每个要从缓存中删除的键求值为true
。
Get
/// <summary>
/// Gets the cache entry with the specified key or return <c>default(T)</c>
/// if the key is not found.
/// </summary>
/// <param name="key">The cache-key to retrieve.</param>
/// <returns>The object from the cache or <c>default(T)</c>, if not found.</returns>
public T Get(K key)
{
if (disposed) return default(T);
locker.EnterReadLock();
try
{
T rv;
return (cache.TryGetValue(key, out rv) ? rv : default(T));
}
finally { locker.ExitReadLock(); }
}
/// <summary>
/// Tries to gets the cache entry with the specified key.
/// </summary>
/// <param name="key">The key.</param>
/// <param name="value">(out) The value,
/// if found, or <c>default(T)</c>, if not.</param>
/// <returns><c>True</c>, if <c>key</c> exists,
/// otherwise <c>false</c>.</returns>
public bool TryGet(K key, out T value)
{
if (disposed)
{
value = default(T);
return false;
}
locker.EnterReadLock();
try
{
return cache.TryGetValue(key, out value);
}
finally { locker.ExitReadLock(); }
}
在Get
方法中,您可以看到我使用ReaderWriterLockSlim
而不是简单的lock()
语句的原因。如果您不知道:lock()
始终是写锁,因此lock()
块只能顺序进入,一次一个线程,而像这里所示的ReadLock
可以被多个线程并行处理。
当一个线程请求WriteLock
时,此WriteLock
必须等待,直到当前读者完成,然后WriteLock
以独占方式运行,就像lock()
语句一样(所有读者必须等到write
完成)。WriteLock
完成后,所有读者都可以继续。
Exists
public bool Exists(K key)
{
if (disposed) return false;
locker.EnterReadLock();
try
{
return cache.ContainsKey(key);
}
finally { locker.ExitReadLock(); }
}
Exists
简单明了地返回特定条目是否包含在内部Dictionary
中。
Clear
public void Clear()
{
locker.EnterWriteLock();
try
{
try
{
foreach (Timer t in timers.Values)
t.Dispose();
}
catch
{ }
timers.Clear();
cache.Clear();
}
finally { locker.ExitWriteLock(); }
}
Clear
负责在清除两个Dictionary之前.Dispose()
所有挂起的计时器。
计时器检查/计时器回调
让我们看看Cache<T>
是如何处理缓存条目的计时(TTL)的,以及restartTimerIfExists
标志是如何处理的。
先看代码:)
#region CheckTimer
// Checks whether a specific timer already exists and adds a new one, if not
private void CheckTimer(K key, int cacheTimeout, bool restartTimerIfExists)
{
Timer timer;
if (timers.TryGetValue(key, out timer))
{
if (restartTimerIfExists)
{
timer.Change(
(cacheTimeout == Timeout.Infinite ? Timeout.Infinite : cacheTimeout * 1000),
Timeout.Infinite);
}
}
else
timers.Add(
key,
new Timer(
new TimerCallback(RemoveByTimer),
key,
(cacheTimeout == Timeout.Infinite ? Timeout.Infinite : cacheTimeout * 1000),
Timeout.Infinite));
}
private void RemoveByTimer(object state)
{
Remove((K)state);
}
#endregion
首先,我检查这个缓存条目是否存在Timer
。当条目正在添加而不是更新时,可能不存在。
如果Timer
存在,它将被修改,如果restartTimerIfExists
设置为true
,否则它将被保持原样。
传递给计时器的参数是:
- 添加时:
- 回调(
RemoveByTimer
),当dueTime
到期时调用。 - 缓存条目的
key
(这是回调方法的state
参数,也是我用来查找要删除的缓存条目的键)。 dueTime
(在调用回调方法之前等待的时间)。period
。在这个类中,它总是Timeout.Infinite
,因为每个条目只能被删除一次。
- 回调(
- 更新/更改时:
- 与添加时提供的
dueTime
和period
参数相同,只是值不同。
- 与添加时提供的
RemoveByTimer
方法简单地调用Cache<T>
类的(线程安全)Remove
方法。
非泛型Cache : Cache<object>类
最后,我想向您展示非泛型版本Cache
类背后的非常简单的代码。
public class Cache : Cache<object>
{
#region Static Global Cache instance
private static Lazy<Cache> global = new Lazy<Cache>();
/// <summary>
/// Gets the global shared cache instance valid for the entire process.
/// </summary>
/// <value>
/// The global shared cache instance.
/// </value>
public static Cache Global => global.Value;
#endregion
}
这个类是Cache<object>
,可以存储任何类型的项,并用于高度混合的场景,但带有object
带来的所有缺点。
唯一值得一提的是static Global
成员的Lazy
初始化。
我一直试图支持尽可能多的场景,我能想象到有些情况下您不想实例化自己的缓存实例(也许您太懒了:)),所以这个类甚至提供了一个static Global
缓存成员。
性能
在评论中,有一些关于缓存性能的担忧。因此,我想再次明确,这是一个本地缓存,而不是一个处理50k客户端并行访问的Web服务器缓存!它适用于您的前端应用程序,或者可能是您的业务/数据层。
我们在专业环境中使用它,没有任何问题。
您可以下载负载测试并在本地机器上测试缓存,并将您的值与我的进行比较。
我的测试是在一台配备16GB RAM的Lenovo Y50四核(8个逻辑核心)游戏笔记本上运行的。
这是我的本地负载测试结果:
Cache<T> load test.
===================
1: Single threaded tests
1.1: Adding 1 Million entries.
<K,T> = <long, string*32>
Performance: 492/ms
-
1.2: 1 Million random Get calls
Performance: 4166/ms
-
1.3: Removing 1 Million entries (with exists check)
Performance: 3623/ms
-
2: Multi threaded tests
2.1: Adding 1 Million entries.
Threads: 100, each adds 10k
<K,T> = <long, string*32>
Performance: 355/ms
-
2.2: 1 Million random Get calls
Threads: 100, each adds 10k
<K,T> = <long, string*32>
Performance: 3937/ms
-
2.3: Removing 1 Million entries (with exists check)
Threads: 100, each removes 10k
<K,T> = <long, string*32>
Performance: 1689/ms
-
--- End of test ---
我将复制我从对一个成员的评论中解释的关于此测试的内容:
- 在单线程场景下(也许当它仅用作反射缓存或GUI应用程序的数据库查询结果缓存时),我可以在100万个条目范围内以约2微秒的时间添加一个条目(哪个GUI应用程序缓存100万个不同的查询或100万个反射?)。
在加载的缓存上,每毫秒有超过4000个get请求,或每微秒4个请求,或不到250纳秒的访问一次。
Remove
的速度几乎与Get
一样快。
然后,在100个线程的负载下(再次强调,这不是用于数千个并行访问请求的服务器缓存——即使这些由于读锁而全并行运行——只有数千个并行*添加*可能会减慢速度)。
所以,多线程测试结果为:
- 每毫秒的
Add
数从492下降到355,但仍然是每add 3微秒。 - 由于读锁,Get请求显示几乎相同的性能(3930:4160)。
- 移除性能确实有所下降,同意,下降了约50%,但仍然在每毫秒移除纳秒的范围内。
即使在100个并行线程的场景下,这仍然表现*非常出色*,这比大多数前端应用程序使用的线程数都要多。
就是这样!
我希望您能看到这个类的用处,欢迎根据您的特殊需求进行改编。
请花时间评论您的投票,以便我能有机会回复!
祝好,
迈克
历史
- 2015-09-30 本文的初始发布。
- 2015-10-08 感谢@Roger500的贡献,将类更新为泛型键,
Cache<K, T>
。谢谢! - 2015-10-10 添加了单线程和多线程(100个线程)访问的负载测试。
- 修正了Cache.zip中的一个编译错误——抱歉——这个下载可以编译!
- 2015-10-16 修复了
LoadTest
中的一个bug(附上了新的zip文件)。Cache
中的新方法:Remove
(pattern)、Clear()
和AddOrUpdate
(新签名)。- 在文章中记录了新方法。
- 修正了文章“性能”部分的一些格式问题。