首页 学海无涯 .NET进阶 .NET缓存系列(二):缓存进阶
.NET缓存系列(二):缓存进阶
摘要 缓存类库完善、缓存过期策略、线程安全缓存。

环境准备

Visual Studio 2019 16.8.3

.NET 5.0

目录导航

一、缓存过期策略

    1.实现三种过期策略

    2.被动清理过期数据

    3.主动清理过期数据

二、线程安全缓存

    1.线程安全问题

    2.解决线程安全问题

    3.性能对比

三、总结

文章正文

在上一篇《.NET缓存系列(一):缓存入门》中实现了基本的缓存,接下来需要对缓存进行改进,解决一些存在的问题。

一、缓存过期策略

问       题:当源数据更改或删除时,服务器程序并不知道,导致缓存中存在脏数据,如何避免?

解决方案:①让数据只能通过缓存所在的程序进行更改或删除(禁止数据源通过其他方式更改或删除)

                  ②缓存程序对外提供接口,当数据源更改或删除时,调用接口告知

                  ③容忍脏数据,指定时间后缓存过期

方案1、2常使用,可以说不太现实,所以通常会给缓存添加过期策略

1.实现三种过期策略

首先,我们需要做一些基础准备,新建一个缓存实体类和一个过期策略枚举

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。

2.被动清理过期数据

缓存策略是添加了,但是过期的数据依然存在于缓存中,如何进行清理?

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()方法时,判断如果数据无效,则移除。这种方法需要每次获取对应数据时才会去判断是否过期,然后清除,只达到了被动清理的效果。

3.主动清理过期数据

只有被动清理时,如果一直没有去获相应的数据,那么它还是会一直存在缓存中。

所以我们需要主动去清理过期缓存:

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
}

添加一个静态构造函数,在里面使用单独的一个线程,每过一定时间,遍历缓存中所有项,判断过期是否过期,过期则清除。这样就达到了一个主动清理的效果。

二、线程安全缓存

1.线程安全问题

模拟多线程访问缓存:

//模拟多线程
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中抛出一个异常:集合已经更改,枚举操作无法执行。也就是说,在遍历集合的同时,又有多个线程同时往集合中添加数据,导致异常。

2.解决线程安全问题

①线程安全集合

将存储缓存数据的集合由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找到对应数据所在的片区,然后进行操作,整体逻辑相同,就懒得写了。

3.性能对比

普通加锁和数据分片性能对比:

{
    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万条数据,运行结果:


运行多次,数据分片耗时始终要低于普通加锁

三、总结

本文通过增加缓存过期策略来优化缓存中脏数据的问题,然后通过数据分片、加锁的方式避免了多线程问题。有了过期策略和多线程优化的缓存才是终极方案,至此一个小小的缓存类库算是封装完毕。

附录

暂无


版权声明:本文由不落阁原创出品,转载请注明出处!

本文链接:http://www.leo96.com/article/detail/62

广告位

来说两句吧
最新评论

暂无评论,大侠不妨来一发?