缓存写法总结

基本写法

为了方便演示,这里使用Runtime.Cache做缓存容器,并定义个简单操作类。如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class CacheHelper
   {
       public static object Get(string cacheKey)
       {
           return HttpRuntime.Cache[cacheKey];
       }
       public static void Add(string cacheKey, object obj, int cacheMinute)
       {
           HttpRuntime.Cache.Insert(cacheKey, obj, null, DateTime.Now.AddMinutes(cacheMinute),
               Cache.NoSlidingExpiration, CacheItemPriority.Normal, null);
       }
   }

 简单读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
public object GetMemberSigninDays1()
    {
        const int cacheTime = 5;
        const string cacheKey = "mushroomsir";
 
        var cacheValue = CacheHelper.Get(cacheKey);
        if (cacheValue != null)
            return cacheValue;
 
        cacheValue = "395"//这里一般是 sql查询数据。 例:395 签到天数
        CacheHelper.Add(cacheKey, cacheValue, cacheTime);
        return cacheValue;
    }

在项目中,有不少这样写法,这样写并没有错,但在并发量上来后就容易出问题。

 缓存雪崩

缓存雪崩是由于缓存失效(过期),新缓存未到期间。

这个中间时间内,所有请求都去查询数据库,而对数据库CPU和内存造成巨大压力,前端连接数不够、查询阻塞。

这个中间时间并没有那么短,比如sql查询1秒,加上传输解析0.5秒。  就是说1.5秒内所有用户查询,都是直接查询数据库的。

碰到这种情况,使用最多的解决方案就是加锁排队。

全局锁,实例锁

复制代码
复制代码
  public static object obj1 = new object();
        public object GetMemberSigninDays2()
        {
            const int cacheTime = 5;
            const string cacheKey = "mushroomsir";

            var cacheValue = CacheHelper.Get(cacheKey);

            if (cacheValue != null)
                return cacheValue;

            //lock (obj1)         //全局锁
            //{
            //    cacheValue = CacheHelper.Get(cacheKey);
            //    if (cacheValue != null)
            //        return cacheValue;
            //    cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数
            //    CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            //}
            lock (this)
            {
                cacheValue = CacheHelper.Get(cacheKey);
                if (cacheValue != null)
                    return cacheValue;

                cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数
                CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            }
            return cacheValue;
        }
复制代码
复制代码

第一种:lock (obj1)  是全局锁可以满足,但要为每个函数都声明一个obj,不然在A、B函数都锁obj1时,必然会让其中一个阻塞。

第二种:lock (this)  这个锁当前实例,对其他实例无效,那这个锁就没什么效果了,当然使用单例模式的对象可以锁。

            在当前实例中:A函数锁当前实例,其他也锁当前实例的函数的读写,也被阻塞,这种做法也不可取。

字符串锁

既然锁对象不行,利用字符串的特性,直接锁缓存的key呢

复制代码
复制代码
    public object GetMemberSigninDays3()
        {
            const int cacheTime = 5;
            const string cacheKey = "mushroomsir";

            var cacheValue = CacheHelper.Get(cacheKey);
            if (cacheValue != null)
                return cacheValue;
            const string lockKey = cacheKey + "n(*≧▽≦*)n";

            //lock (cacheKey)
            //{
            //    cacheValue = CacheHelper.Get(cacheKey);
            //    if (cacheValue != null)
            //        return cacheValue;
            //    cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数
            //    CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            //}
            lock (lockKey)
            {
                cacheValue = CacheHelper.Get(cacheKey);
                if (cacheValue != null)
                    return cacheValue;
                cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数
                CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            }
            return cacheValue;
        }
复制代码
复制代码

第一种:lock (cacheName)  有问题,因为字符串也是共享的,会阻塞其他使用这个字符串的操作行为。  

         具体请参考之前的博文 c#语言-多线程中的锁系统(一)。 

         因为字符串被公共语言运行库 (CLR)暂留,这意味着整个程序中任何给定字符串都只有一个实例,所以才会用下面第二种方法。

第二种:lock (lockKey)  可以满足。其目的就是为了保证锁的粒度最小并且全局唯一性,只锁当前缓存的查询行为。

缓存穿透

先举个简单例子:一般网站经常会缓存用户搜索的结果,如果数据库查询不到,是不会做缓存的。但如果频繁查这个空关键字,会导致每次请求都直接查询数据库了。

例子就是缓存穿透,请求绕过缓存直接查数据库,这也是经常提的缓存命中率问题。

复制代码
复制代码
  public object GetMemberSigninDays4()
        {
            const int cacheTime = 5;
            const string cacheKey = "mushroomsir";

            var cacheValue = CacheHelper.Get(cacheKey);
            if (cacheValue != null)
                return cacheValue;
            const string lockKey = cacheKey + "n(*≧▽≦*)n";

            lock (lockKey)
            {
                cacheValue = CacheHelper.Get(cacheKey);
                if (cacheValue != null)
                    return cacheValue;

                cacheValue = null; //数据库查询不到,为空。
                //if (cacheValue2 == null)
                //{
                //    return null;  //一般为空,不做缓存
                //}
                if (cacheValue == null)
                {
                    cacheValue = string.Empty; //如果发现为空,我设置个默认值,也缓存起来。
                }
                CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            }
            return cacheValue;
        }
复制代码
复制代码

如果把查询不到的空结果,也给缓存起来,这样下次同样的请求就可以直接返回null了,即可以避免当查询的值为空时引起的缓存穿透。

可以单独设置个缓存区域存储空值,对要查询的key进行预先校验,然后再放行给后面的正常缓存处理逻辑。

再谈缓存雪崩

前面不是用加锁排队方式就解决了吗?其实加锁排队只是为了减轻数据库的压力,本质上并没有提高系统吞吐量。

假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。导致的结果是用户等待超时,这是非常不优化的体验。

这种行为本质上是把多线程的Web服务器,在此时给变成单线程处理了,会导致大量的阻塞。对于系统资源也是一种浪费,因缓存重建而阻塞的线程本可以处理更多请求的。

这里提出一种解决方案是:

复制代码
复制代码
  public object GetMemberSigninDays5()
        {
            const int cacheTime = 5;
            const string cacheKey = "mushroomsir";

            //缓存标记。
            const string cacheSign = cacheKey + "_Sign";
            var sign = CacheHelper.Get(cacheSign);

            //获取缓存值
            var cacheValue = CacheHelper.Get(cacheKey);
            if (sign != null)
                return cacheValue; //未过期,直接返回。

            lock (cacheSign)
            {
                sign = CacheHelper.Get(cacheSign);
                if (sign != null)
                    return cacheValue;

                CacheHelper.Add(cacheSign, "1", cacheTime);
                ThreadPool.QueueUserWorkItem((arg) =>
                {
                    cacheValue = "395"; //这里一般是 sql查询数据。 例:395 签到天数
                    CacheHelper.Add(cacheKey, cacheValue, cacheTime*2); //日期设缓存时间的2倍,用于脏读。
                });
            }
            return cacheValue;
        }
复制代码
复制代码

从代码中看出,我们多使用了一个缓存标记key,并使用双检锁校验保证后面逻辑不会多次执行。

缓存标记key: 缓存标记key只是一个记录实际key过期时间的标记,它的缓存值可以是任意值,比如1。 它主要用来在实际key过期后,触发通知另外的线程在后台去更新实际key的缓存。

实际key:  它的过期时间会延长1倍,例:本来5分钟,现在设置为10分钟。 这样做的目的是,当缓存标记key过期后,实际缓存还能以脏数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。

关于实际key的过期时间延长1倍,还是2、3倍都是可以的。只要大于正常缓存过期时间,并且能保证在延长的时间内足够拉取数据即可。

还一个好处就是,如果突然db挂了,脏数据的存在可以保证前端系统不会拿不到数据。

这样做后,就可以一定程度上提高系统吞吐量。

总结

文中说的阻塞其他函数指的是,并发情况下锁同一对象,比如一个函数锁A对象,另外的函数就必须等待A对象的锁释放后才能再次进锁。

关于更新缓存,可以单开一个线程去专门跑缓存更新,图方便的话扔线程池里面即可。

实际项目中,缓存层框架的封装往往要复杂的多,如果并发量比较小,这样写反而会增加代码的复杂度,具体要根据实际情况来取舍。    

**************************************************************

缓存预热

上次有同学问过,在第一次加载时缓存都为空,怎么进行预热。

单机Web情况下一般使用RunTimeCache,这种情况下:

可以在启动事件里面刷新

void Application_Start(object sender, EventArgs e)
        {
            //刷新
        }

另外可以单写个刷新缓存页面,上线后手动刷新下或发布时自动调用刷新,再或者由用户自行触发。

分布式缓存(Redis、Memcached)情况下:

比如在几十台服务器缓存时,单刷满缓存都需要不少一段时间。

这种预热就复杂一些,有的会单写个应用程序去跑,也有的会单写套框架机制去处理(更智能化)。

其目的是在系统上线之前,所有的缓存都预先加载完毕。

多级缓存

计算机结构中CPU和内存之间一般都配有一级缓存、二级缓存来增加交换速度。

这样当CPU调用大量数据时,就可避开内存直接从CPU缓存中调用,加快读取速度。

根据CPU缓存得出多级缓存的特点:

  1:每一级缓存中储存的是下一级缓存的一部分。

  2:读取速度按级别依次递减,成本也依次递减,容量依次递增。

  3:当前级别未命中时,才会去下一级寻找。

而在企业应用级开发中,使用多级缓存是同样的目的及设计,只是粒度更粗,更灵活。

根据速度依次排列lv1-lv6的缓存类型图:

 

 3级缓存的命中流程图例子:

线程缓存

Web应用是天生的多线程开发,对于一些公共资源必须考虑线程安全,为止不得不通过锁来保证数据的完整性和正确性。

在实际当中一台web服务器至少也得处理成百上千的请求,想一想在业务复杂的处理流程,函数每调用一次都得锁一下,对服务器也是个不小的浪费。

而通过线程缓存,可以让当前处理用户请求的线程只拿自己需要的数据。

public static ThreadLocal<UserScore> localUserInfo = new ThreadLocal<UserScore>();

借助Net提供的线程本地变量,可以在请求入口去拉取当前用户的数据。

在之后线程整个生命周期里面,业务逻辑可以毫无顾虑的使用这些数据,而不需要考虑线程安全。

因为不用重新拿新缓存数据,所以也不用担心数据撕裂的问题。

其当前线程周期里面的数据是完整无误的,只有用户第二次发起请求才会重新去拿新数据。

这样就能提高不少服务器吞吐量,注意要在线程的出口处销毁数据。

内存缓存

无论是远程数据库读取,还是缓存服务器读取。避免不了要跨进程,跨网络通信,有的还跨机房。

而应用程序频繁读写,对Web、DB服务器都是个不小的消耗,速度相较内存也慢的多。

代码上加锁、异步,甚至加服务器在内,都不是一个很好的办法。因为加载速度,对用户体验非常重要。


 所以在有要求的项目中使用本地内存做二级缓存,是非常有必要的。目的就是1:抗并发,2:加快读取速度。

有个著名的缓存五分钟法则法则,就是说如果一个数据频繁被访问,那么就应该放内存中。

举个例子:  有100并发过来,加锁会导致前端99线程等候,这个99线程等候着,其实是一直在消耗Web服务器资源。不加就是缓存雪崩。

                如果每分钟拉取一份缓存,缓存到内存,这样99线程等候时间极大缩短。 

文件缓存

相对于内存,硬盘容量大,速度相较于走网络还更快。

所以我们完全可以把一些不经常变更,放在内存又比较浪费的数据缓存到本地硬盘。

比如使用sqlite一些文件数据库,我们很容易做到。

分布式缓存

基于内存缓存的redis、memcached等。

基于文件nosql的Casssandra、mongodb等。

redis、memcached是主流的分布式内存缓存,也是应用和DB中间最大的缓存层。

nosql这类的其实不单单只是做缓存用了,完全用在一些非核心业务的DB层了。

DB缓存

这一层DB主要是缓存由原始数据计算出的结果,从而避免由Web程序通过SQL或在使用中直接计算。

当然也可以把计算好的数据,存储到redis中当缓存。

多层缓存

多层缓存概念在很多地方都用到过:

1:上面介绍的多级缓存就是一种,把内容根据读取频率,分不同的等级、不同的层次进行存储,频率越高离查询越近。

2:还一种多层是缓存索引的做法,类似B树查找,这样能提高检索效率。

3:从架构上来说浏览器缓存、CDN缓存、反向代理缓存、服务端缓存、也是多层缓存。

总结

在使用上大家根据实际场景,进行各种组合搭配。本篇谈的比较理论些,有些内容细节没展开。

比如分布式缓存的使用,缓存置换策略及算法,缓存过期机制等。

***************************************************************

分析设计

假设有个项目有比较高的并发量,要用到多级缓存,如下:

在实际设计一个内存缓存前,需要考虑的问题:

1:内存与Redis的数据置换,尽可能在内存中提高数据命中率,减少下一级的压力。

2:内存容量的限制,需要控制缓存数量。

3:热点数据更新不同,需要可配置单个key过期时间。

4:良好的缓存过期删除策略。

5:缓存数据结构的复杂度尽可能的低。

关于置换及命中率:采用LRU算法,因为它实现简单,缓存key命中率也很好。

                          LRU即是:把最近最少访问的数据给淘汰掉,经常被访问到即是热点数据。

关于LRU数据结构:因为key优先级提升和key淘汰,所以需要顺序结构,网上大多实现都采用的这种链表结构。

                         即新数据插入到链表头部、被命中时的数据移动到头部,添加复杂度O(1),移动和获取复杂度O(N)。

有没复杂度更低的呢? 有Dictionary,复杂度为O(1),性能最好。 那如何保证缓存的优先级提升呢?

O(1)LRU实现

定义个LRUCache<TValue>类,构造参数maxKeySize 来控制缓存最大数量。

使用ConcurrentDictionary来作为我们的缓存容器,并能保证线程安全。

复制代码
复制代码
 public class LRUCache<TValue> : IEnumerable<KeyValuePair<string, TValue>>
    {
        private long ageToDiscard = 0;  //淘汰的年龄起点
        private long currentAge = 0;        //当前缓存最新年龄
        private int maxSize = 0;          //缓存最大容量
        private readonly ConcurrentDictionary<string, TrackValue> cache;
        public LRUCache(int maxKeySize)
        {
            cache = new ConcurrentDictionary<string, TrackValue>();
            maxSize = maxKeySize;
        }
    }
复制代码
复制代码

上面定义了 ageToDiscard、currentAge 这2个自增值参数,作用是标记缓存列表中各个key的新旧程度。

实现步骤如下:

每次添加key时,currentAge自增并将currentAge值分配给这个缓存值的age,currentAge一直自增。

复制代码
复制代码
 public void Add(string key, TValue value)
        {
            Adjust(key);
            var result = new TrackValue(this, value);
            cache.AddOrUpdate(key, result, (k, o) => result);
        }
        public class TrackValue
        {
            public readonly TValue Value;
            public long Age;
            public TrackValue(LRUCache<TValue> lv, TValue tv)
            {
                Age = Interlocked.Increment(ref lv.currentAge);
                Value = tv;
            }
        }
复制代码
复制代码

在添加时,如超过最大数量,检查字典里是否有ageToDiscard年龄的key,如没有循环自增检查,有则删除、添加成功。

其ageToDiscard+maxSize= currentAge ,这样设计就能在O(1)下保证可以淘汰旧数据,而不是使用链表移动。 

复制代码
复制代码
  public void Adjust(string key)
        {
            while (cache.Count >= maxSize)
            {
                long ageToDelete = Interlocked.Increment(ref ageToDiscard);
                var toDiscard =
                      cache.FirstOrDefault(p => p.Value.Age == ageToDelete);
                if (toDiscard.Key == null)
                    continue;
                TrackValue old;
                cache.TryRemove(toDiscard.Key, out old);
            }
        }
复制代码
复制代码

获取key的时候表示它又被人访问,将最新的currentAge赋值给它,增加它的年龄:

复制代码
复制代码
  public TValue Get(string key)
        {
            TrackValue value=null;
            if (cache.TryGetValue(key, out value))
            {
                value.Age = Interlocked.Increment(ref currentAge);
            }
            return value.Value;
        }
复制代码
复制代码

过期删除策略

大多数情况下,LRU算法对热点数据命中率是很高的。 但如果突然大量偶发性的数据访问,会让内存中存放大量冷数据,也即是缓存污染。

会引起LRU无法命中热点数据,导致缓存系统命中率急剧下降,也可以使用LRU-K、2Q、MQ等变种算法来提高命中率。

过期配置

通过设定最大过期时间来尽量避免冷数据常驻内存。

多数情况每个数据缓存的时间要求不一致的,所以需要再增加单个key的过期时间字段。

复制代码
复制代码
 private TimeSpan maxTime;
 public LRUCache(int maxKeySize,TimeSpan maxExpireTime){}

  //TrackValue增加创建时间和过期时间
 public readonly DateTime CreateTime;
 public readonly TimeSpan ExpireTime;
复制代码
复制代码

删除策略

关于key过期删除,最好的方式是使用定时删除,这样可以最快的释放被占用的内存,但很明显大量的定时器对CPU来说是非常不友好的。

所以需要采用惰性删除、在获取key的时检查是否过期,过期直接删除。

复制代码
复制代码
public Tuple<TrackValue, bool> CheckExpire(string key)
        {
            TrackValue result;
            if (cache.TryGetValue(key, out result))
            {
                var age = DateTime.Now.Subtract(result.CreateTime);
                if (age >= maxTime || age >= result.ExpireTime)
                {
                    TrackValue old;
                    cache.TryRemove(key, out old);
                    return Tuple.Create(default(TrackValue), false);
                }
            }
            return Tuple.Create(result, true);
        }
复制代码
复制代码

惰性删除虽然性能最好,但对于冷数据来说还是没解决缓存污染的问题,所以还需增加个定期清理和惰性删除配合使用。

比如单开个线程每5分钟去遍历检查key是否过期,这个时间策略是可配置的,如果缓存数量较多可分批遍历检查。

复制代码
复制代码
public void Inspection()
        {
            foreach (var item in this)
            {
                CheckExpire(item.Key);
            }
        }
复制代码
复制代码

惰性删除配合定期删除基本上能满足绝大多数要求了。

总结

本篇参考了redis、Orleans的相关实现。

如果继续完善下去就是内存数据库的雏形,类似redis,比如增加删除key的通知回调,支持更多的数据类型存储。

http://www.cnblogs.com/kevingrace/p/5575385.html

原文地址:https://www.cnblogs.com/soundcode/p/5795073.html