Visual Studio 2019 16.8.3
.NET 5.0
一、缓存过期策略
1.实现三种过期策略
2.被动清理过期数据
3.主动清理过期数据
二、线程安全缓存
1.线程安全问题
2.解决线程安全问题
3.性能对比
三、总结
在上一篇《.NET缓存系列(一):缓存入门》中实现了基本的缓存,接下来需要对缓存进行改进,解决一些存在的问题。
问 题:当源数据更改或删除时,服务器程序并不知道,导致缓存中存在脏数据,如何避免?
解决方案:①让数据只能通过缓存所在的程序进行更改或删除(禁止数据源通过其他方式更改或删除)
②缓存程序对外提供接口,当数据源更改或删除时,调用接口告知
③容忍脏数据,指定时间后缓存过期
方案1、2常使用,可以说不太现实,所以通常会给缓存添加过期策略。
首先,我们需要做一些基础准备,新建一个缓存实体类和一个过期策略枚举:
class CacheModel
{
/// <summary>
/// 缓存值
/// </summary>
public object Value { get; set; }
/// <summary>
/// 过期类型
/// </summary>
public ExpireType ExpireType { get; set; }
/// <summary>
/// 过期时间
/// </summary>
public DateTime DeadLine { get; set; }
/// <summary>
/// 滑动时间段
/// </summary>
public TimeSpan Duration { get; set; }
}
enum ExpireType
{
/// <summary>
/// 永不过期
/// </summary>
Nerver,
/// <summary>
/// 绝对过期
/// </summary>
Absolutely,
/// <summary>
/// 滑动过期
/// </summary>
Relative
}
其次,对添加数据的方法进行改造,指定过期策略(为了方便调用,这里使用方法重载)。
①永不过期
永不过期不需要添加任何参数,方法签名不变:
public static void AddWithExpire<T>(string key, T value)
{
Cache[key] = new CacheModel
{
Value = value,
ExpireType = ExpireType.Nerver
};
}
缓存的对象不再是value,而是CacheModel对象,Value就是原本的缓存值,然后指定过期类型为永不过期。
②绝对过期(指定时间后过期)
public static void AddWithExpire<T>(string key, T value, int timeoutSecend)
{
Cache[key] = new CacheModel
{
Value = value,
ExpireType = ExpireType.Absolutely,
DeadLine = DateTime.Now.AddSeconds(timeoutSecend)
};
}
绝对过期增加了一个int类型参数,该参数表示缓存的数据经过多少秒过期。
③滑动过期(指定时间后过期,但在有效期内如果使用缓存,则会刷新过期时间)
public static void AddWithExpire<T>(string key, T value, TimeSpan timespan)
{
Cache[key] = new CacheModel
{
Value = value,
ExpireType = ExpireType.Relative,
DeadLine = DateTime.Now.Add(timespan),
Duration = timespan
};
}
滑动过期增加了一个TimeSpan,这个参数表示缓存的时间,同时还表示滑的时间。
然后,需要在获取缓存中的数据时,进行是否过期的判断:
//获取缓存中的数据
public static T GetWithExpire<T>(string key, Func<T> func)
{
T res;
if (Exist(key))
{
res = (T)Cache[key];
}
else
{
res = func.Invoke();
Cache[key] = res;
}
return res;
}
//缓存中是否存在数据 包含有效期筛选
public static bool Exist(string key)
{
if (!Cache.ContainsKey(key))
return false;
var res = false;
var cacheModel = Cache[key] as CacheModel;
switch (cacheModel.ExpireType)
{
case ExpireType.Nerver:
res = true;
break;
case ExpireType.Absolutely:
res = cacheModel.DeadLine > DateTime.Now;
break;
case ExpireType.Relative:
if (cacheModel.DeadLine > DateTime.Now)
{
cacheModel.DeadLine.Add(cacheModel.Duration);
res = true;
}
else
res = false;
break;
}
return res;
}
这里对获取缓存数据的方法进行了小小的改进,使用了委托。当缓存中没有数据时,在委托中将数据查询出来并返回,同时将数据保存在缓存中。
在Exist()方法中,先获取Key对应的缓存对象,然后根据对象的ExpireType属性,对相应的过期策略进行判断,如果数据有效,则返回true,否则返回false。
缓存策略是添加了,但是过期的数据依然存在于缓存中,如何进行清理?
switch (cacheModel.ExpireType)
{
case ExpireType.Nerver:
res = true;
break;
case ExpireType.Absolutely:
res = cacheModel.DeadLine > DateTime.Now;
break;
case ExpireType.Relative:
if (cacheModel.DeadLine > DateTime.Now)
{
cacheModel.DeadLine.Add(cacheModel.Duration);
res = true;
}
else
res = false;
break;
}
if (!res)
Cache.Remove(key); //无效的数据移除
将Exist()方法进行小小的改动即可,当每次调用Exist()方法时,判断如果数据无效,则移除。这种方法需要每次获取对应数据时才会去判断是否过期,然后清除,只达到了被动清理的效果。
只有被动清理时,如果一直没有去获相应的数据,那么它还是会一直存在缓存中。
所以我们需要主动去清理过期缓存:
static CustomCache()
{
#region 主动清理过期缓存
Task.Run(() =>
{
while (true)
{
Thread.Sleep(1000 * 60 * 10); //每10分钟清理
List<string> removeList = new List<string>();
foreach (var key in Cache.Keys)
{
var cacheModel = Cache[key] as CacheModel;
if (cacheModel.ExpireType != ExpireType.Nerver && cacheModel.DeadLine < DateTime.Now)
{
//不能在集合遍历时删除集合项
//Cache.Remove(key);
removeList.Add(key);
}
}
foreach (var key in removeList)
Cache.Remove(key);
}
});
#endregion
}
添加一个静态构造函数,在里面使用单独的一个线程,每过一定时间,遍历缓存中所有项,判断过期是否过期,过期则清除。这样就达到了一个主动清理的效果。
模拟多线程访问缓存:
//模拟多线程
var taskList = new List<Task>();
for (int i = 0; i < 1000; i++)
{
var key = $"{i}_key";
taskList.Add(Task.Run(() =>
{
CustomCache.AddWithExpire(key, "你好", timeoutSecend: 10);
}));
}
Task.WaitAll(taskList.ToArray()); //同时执行1000个线程,执行添加数据操作
将主动清理缓存中Thread.Sleep()去掉,方便测试:
可以看到,在catch中抛出一个异常:集合已经更改,枚举操作无法执行。也就是说,在遍历集合的同时,又有多个线程同时往集合中添加数据,导致异常。
①线程安全集合
将存储缓存数据的集合由Dictionary更改为ConcurrentDictionary,这是一个线程安全的集合,然后将相关方法修改一下即可。
②使用锁
定义一个锁对象,在所有对缓存进行增、删、改、遍历的地方加上锁,能够有效解决线程安全问题。
public static object LockObj { get; set; } = new object(); //锁对象
public static void AddWithExpire<T>(string key, T value, int timeoutSecend)
{
lock (LockObj) //添加时加锁
{
Cache[key] = new CacheOption
{
Value = value,
ExpireType = ExpireType.Absolutely,
DeadLine = DateTime.Now.AddSeconds(timeoutSecend)
};
}
}
//Thread.Sleep(1000 * 60 * 10); //每10分钟清理
List<string> removeList = new List<string>();
lock (LockObj) //清理时加锁
{
foreach (var key in Cache.Keys)
{
var cacheModel = Cache[key] as CacheOption;
if (cacheModel.ExpireType != ExpireType.Nerver && cacheModel.DeadLine < DateTime.Now)
{
//不能在集合遍历时删除集合项
//Cache.Remove(key);
removeList.Add(key);
}
}
foreach (var key in removeList)
{
Cache.Remove(key);
}
}
③锁 + 数据分片
数据分片,顾名思义,就是将缓存分片存储。然后各自加锁,减少锁的使用,提高性能:
/// <summary>
/// 缓存集合分片列表
/// </summary>
public static List<Dictionary<string, object>> Cache = new List<Dictionary<string, object>>();
/// <summary>
/// 每个缓存片区对应的锁
/// </summary>
private static List<object> LockList = new List<object>();
/// <summary>
/// 缓存片区数量
/// </summary>
private static int areaCount = 0;
在构造函数中根据片区数量初始化缓存集合和对应的锁:
areaCount = 3;//设置缓存片区数量
for (int i = 0; i < areaCount; i++)
{
Cache.Add(new Dictionary<string, object>());
LockList.Add(new object());
}
添加数据方法中,根据key的hashcode来分配缓存片区:
public static void Add<T>(string key, T value, int timeoutSecend)
{
var index = Math.Abs(key.GetHashCode()) % areaCount; //根据key的HashCode,分配均匀
lock (LockList[index]) //锁对应片区数据
{
Cache[index][key] = new CacheOption
{
Value = value,
ExpireType = ExpireType.Absolutely,
DeadLine = DateTime.Now.AddSeconds(timeoutSecend)
}; //添加到对应片区
}
}
获取方法和清理方法等都可以通过key的hashcode找到对应数据所在的片区,然后进行操作,整体逻辑相同,就懒得写了。
普通加锁和数据分片性能对比:
{
Console.WriteLine("------------普通加锁------------");
Stopwatch sw = new Stopwatch();
sw.Start();
//模拟多线程
var taskList = new List<Task>();
for (int i = 0; i < 100_000; i++)
{
var key = $"{i}_key";
taskList.Add(Task.Run(() =>
{
CustomCache.AddWithExpire(key, "你好", timeoutSecend: 10);
}));
}
Task.WaitAll(taskList.ToArray());
sw.Stop();
Console.WriteLine($"耗时:{sw.ElapsedMilliseconds}");
}
{
Console.WriteLine("------------数据分片------------");
Stopwatch sw = new Stopwatch();
sw.Start();
var taskList = new List<Task>();
for (int i = 0; i < 100_000; i++)
{
var key = $"{i}_key";
taskList.Add(Task.Run(() =>
{
CustomCacheSlicing.Add(key, "你好", timeoutSecend: 10);
}));
}
Task.WaitAll(taskList.ToArray());
sw.Stop();
Console.WriteLine($"耗时:{sw.ElapsedMilliseconds}");
}
同时添加10万条数据,运行结果:
运行多次,数据分片耗时始终要低于普通加锁。
本文通过增加缓存过期策略来优化缓存中脏数据的问题,然后通过数据分片、加锁的方式避免了多线程问题。有了过期策略和多线程优化的缓存才是终极方案,至此一个小小的缓存类库算是封装完毕。
暂无
版权声明:本文由不落阁原创出品,转载请注明出处!
广告位
暂无评论,大侠不妨来一发?
跟不落阁,学DOTNET!
广告位