缓存写法总结
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了缓存写法总结相关的知识,希望对你有一定的参考价值。
基本写法
为了方便演示,这里使用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的通知回调,支持更多的数据类型存储。
以上是关于缓存写法总结的主要内容,如果未能解决你的问题,请参考以下文章