异步刷新缓存数据
使用异步刷新实现始终可用的缓存模式。
引言
Microsoft .NET 提供了一些出色的数据缓存机制。它们相对容易在您的应用程序中实现和集成。在本文中,我们将讨论一些特定的场景,在这种场景下,由于您要缓存的数据检索成本非常高,因此仅缓存机制是不够的。
背景
如果您想全面深入了解缓存,可以点击此处开始:https://codeproject.org.cn/search.aspx?q=Caching&doctypeid=1%3b2%3b3 。
什么构成一个好的缓存机制?
- 它必须快速:听起来很傻,但这是绝对的首要目标。
- 它必须可清除:用户必须能够按需清除缓存。当知道有新数据可用时,谁想处理过时的数据?
- 它必须有时间限制:缓存的数据不太可能永远不变。因此,好的缓存机制必须提供一种设置内容过期的方法。
通过使用System.Runtime.Caching.MemoryCache ,您可以获得这些好处以及更多好处,例如通过监视器自动失效。
为什么它(有时)不够好?
在某些情况下,填充缓存的成本非常高(数据由第三方系统通过 Web 服务提供,或计算量巨大的计算过程的结果等)。
在这种情况下,每当缓存失效(通过自动过期或手动失效)时,您的应用程序在获取新数据时就会被阻塞。我有一些生产环境中的 Web 应用程序,第三方需要两个多分钟才能提供数据!
如何改善这种情况?
在初始填充缓存时,没有什么太多可做的。数据必须不惜一切代价检索。您可以提供的最佳解决方案是在应用程序启动时加载所有缓存数据,如果可能,则使用并行线程。
但是,在运行时,当缓存失效时,**为什么不将其缓存数据在后台线程中重新填充**,然后隐藏数据检索对最终用户的成本呢?
一种略有不同的数据缓存模式
标准的缓存机制要求开发人员填充它,然后它会在内容过期后自动失效。通过后续方法,缓存机制将**自动填充**以及自动失效。
从编码员的角度来看,使用此模式时,您将能够提供一种检索数据及其生命周期的方法。换句话说,该机制被翻译为一个具有以下抽象方法的抽象类:
protected abstract T GetData();// where T is the type of data you want to cache
protected virtual TimeSpan GetLifetime();
然后,您将通过具有以下签名的静态属性访问缓存数据:
public static T Data { get ;}
您可以通过以下静态方法强制失效:
public static void Invalidate();
请仔细阅读
我将向您介绍此模式的实现,但请阅读有关此模式**固有局限性**的重要说明。
缓存失效后,您可能无法立即获得“最新”数据!
原因很简单:该模式规定数据检索耗时很长,并且我们将要在后台检索它以防止 UI 阻塞。
如果调用 Invalidate()
方法,则会在后台异步调用 GetData()
方法。这意味着,在数据重新填充期间,只要您没有获得新数据,您的唯一选择就是提供“旧”数据。
话虽如此,您在为该模式提供实现时需要注意几件事:
- 首次填充缓存时:所有客户端线程都必须阻塞,直到数据完全就绪为止。
- 防止阻塞:当缓存的数据过期或失效时,只要新数据不可用,缓存仍应提供旧内容。
- 您必须确保只启动一个线程在后台刷新数据。
此模式的一种可能实现
这是您可以实现的最基本的一种,它将数据存储在内存中,每次请求数据时,它都会检查生命周期是否已过期。如果缓存过期时间设置为 10 分钟,并且您有一个小时内没有调用,那么数据至少将在缓存中保留一小时又 10 分钟。
读者可以自行提供不同的实现,这些实现可能涉及计时器或其他任何内容。
这对我来说非常有用,因为我不缓存很少使用的数据(您呢?)。
public abstract class Cache<U,T>
where U : Cache<U,T>, new()
where T : class
{
protected abstract T GetData();
protected virtual TimeSpan GetLifetime() { return TimeSpan.FromMinutes(10); }
protected Cache() { }
enum State
{
Empty,
OnLine,
Expired,
Refreshing
}
static U Instance = new U();
static T InMemoryData { get; set; }
static volatile State CurrentState = State.Empty;
static volatile object StateLock = new object();
static volatile object DataLock = new object();
static DateTime RefreshedOn = DateTime.MinValue;
public static T Data
{
get
{
switch (CurrentState)
{
case State.OnLine: // Simple check on time spent in cache vs lifetime
var timeSpentInCache = (DateTime.UtcNow - RefreshedOn);
if (timeSpentInCache > Instance.GetLifetime())
{
lock (StateLock)
{
if (CurrentState == State.OnLine) CurrentState = State.Expired;
}
}
break;
case State.Empty: // Initial load : blocking to all callers
lock (DataLock)
{
lock (StateLock)
{
if (CurrentState == State.Empty)
{
InMemoryData = Instance.GetData(); // actually retrieve data from inheritor
RefreshedOn = DateTime.UtcNow;
CurrentState = State.OnLine;
}
}
}
break;
case State.Expired: // The first thread getting here launches an asynchronous refresh
lock (StateLock)
{
if (CurrentState == State.Expired)
{
CurrentState = State.Refreshing;
Task.Factory.StartNew(() => Refresh());
}
}
break;
}
lock (DataLock)
{
if (InMemoryData != null) return InMemoryData;
}
return Data;
}
}
static void Refresh()
{
if (CurrentState == State.Refreshing)
{
var dt = Instance.GetData(); // actually retrieve data from inheritor
lock (StateLock)
{
lock (DataLock)
{
RefreshedOn = DateTime.UtcNow;
CurrentState = State.OnLine;
InMemoryData = dt;
}
}
}
}
public static void Invalidate()
{
lock (StateLock)
{
RefreshedOn = DateTime.MinValue;
CurrentState = State.Expired;
}
}
}
用法示例
一个持有非常昂贵的字符串列表的缓存,该列表需要三秒钟才能构建。
public class MyExpensiveListOfStrings : Cache<MyExpensiveListOfStrings, List<string>>
{
protected override List<string> GetData()
{
System.Diagnostics.Trace.WriteLine("Getting fresh data...");
// Make it a really expensive list of string...
Thread.Sleep(3000);
List<string> result = new List<string>();
for (int i = 0; i < 10000; i++)
{
result.Add("Data - " + i.ToString());
}
return result;
}
protected override TimeSpan GetLifetime()
{
// Refreshed asynchronously when has spent more than 30 seconds in memory
return TimeSpan.FromSeconds(30);
}
}
使用缓存的程序示例
该程序将创建 10 个线程来访问缓存的字符串列表。
首次填充缓存时,所有线程都将被阻塞。
然后,缓存将在每 30 秒后在后台刷新,或者在您按下空格键时失效。将不再有任何线程被阻塞。
class Program
{
static void Main(string[] args)
{
for (int i =0;i<10; i++)
{
var t = Task.Factory.StartNew(() =>
{
while (true)
{
System.Diagnostics.Trace.WriteLine("Looping " +
Thread.CurrentThread.ManagedThreadId + " -> " +
MyExpensiveListOfStrings.Data.Count);
Thread.Sleep(50);
}
});
}
ConsoleKeyInfo key = Console.ReadKey();
while (key.Key == ConsoleKey.Spacebar)
{
MyExpensiveListOfStrings.Invalidate();
key = Console.ReadKey();
}
}
}
观点的不同
我知道构建一个处理并非最新数据的程序听起来很奇怪……
现在从全局角度来看,您会发现是最终用户在使用略微过时的数据,程序并不在意……再进一步看:无论您如何缓存数据,如果用户需要最新数据而数据需要两分钟才能加载,那么您的用户**将不得不等待**两分钟。
现在的问题是:**当一个需要最新版本数据的用户正在等待时,是否有任何好理由阻塞应用程序中所有依赖于缓存数据的线程?**
如果您的答案是
- 毫无疑问地是!那么请忽略本文
- 这取决于数据的类型!那么我希望您在这里读到了一些有趣的内容。
伙计们,非常欢迎您提供反馈。