缓存的作用与场景
- 缓存的收益
- 加速读写
- 缓存通常都是全内存的,而存储层通常读写性能不够强悍
- 降低后端负载
- 帮助后端减少访问量和复杂计算
- 提高系统可用性
- 避免源站被大量请求击穿(例如数据库)
- 加速读写
- 缓存的代价
- 数据不一致
- 缓存层和数据层有时间窗口不一致,和更新策略有关
- 代码维护成本
- 加入缓存后,需要同时处理缓存层和存储层的逻辑, 增大了开发者维护代码的成本
- 运维成本
- 依赖了更多的组件
- 数据不一致
- 缓存应用场景
- 对高消耗的SQL结果集/分组统计结果缓存
- 利用 Redis/Memcache 优化 IO 响应时间
- 大量写合并为批量写:比如计数器先 Redis 累加再批量写入 DB
- 不应该使用缓存的场景
- 数据被频繁修改
- 没有热点的访问
缓存应用模式
cache-aside模式
- 数据更新时必须先更新数据库,而不是先令缓存失效,这个顺序不能倒过来
- 如果先令缓存失效,那么在数据库更新成功前,如果有另外一个请求访问了缓存,发现缓存数据库已经失效,于是就会按照数据获取策略,从数据库中使用这个已经陈旧的数值去更新缓存中的数据
- 这就导致这个过期的数据会长期存在于缓存中,最终导致数据不一致的严重问题。
- 数据库更新后,需要令缓存失效,而不是更新缓存
- 如果两个几乎同时发出的请求分别要更新数据库中的值为 A 和 B,如果结果是 B 的更新晚于 A,那么数据库中的最终值是 B。但是,如果在数据库更新后去更新缓存,而不是令缓存失效,那么缓存中的数据就有可能是 A,而不是 B
- 因为数据库虽然是“更新为 A”在“更新为 B”之前发生,但如果不做特殊的跨存储系统的事务控制,缓存的更新顺序就未必会遵从“A 先于 B”这个规则,这就会导致这个缓存中的数据会是一个长期错误的值 A
- 如果是令缓存失效,这个问题就消失了。因为 B 是后写入数据库的,那么在 B 写入数据库以后,无论是写入 B 的请求让缓存失效,还是并发的竞争情形下写入 A 的请求让缓存失效,缓存反正都是失效了。那么下一次的访问就会从数据库中取得最新的值,并写入缓存,这个值就一定是 B
- 如果两个几乎同时发出的请求分别要更新数据库中的值为 A 和 B,如果结果是 B 的更新晚于 A,那么数据库中的最终值是 B。但是,如果在数据库更新后去更新缓存,而不是令缓存失效,那么缓存中的数据就有可能是 A,而不是 B
read/write-through模式
- 好处在于对(ORM)框架的使用者来说,并不需要关心缓存和数据库之间的一致性是怎么维护的
缓存使用的问题
缓存穿透
- 缓存穿透是在大量对于同一个数据的访问,经过了缓存屏障,但是缓存却未能起到应有的保护作用
- 例如对某一个 key 的查询,如果数据库里没有这个数据,那么缓存中也没有数据的存放,每次请求到来都会去查询数据库,缓存根本起不到应有的作用
- 要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞
- 解决方案:
- 可以在缓存中存放key不存在这个结果
- 也可以使用布隆过滤器,在数据库查询前过滤不存在的结果
- 如果缓存失效,而数据库查询过程比较慢,大量同一数据的请求几乎同时到来,就会全部穿透缓存,一并落到了数据库上,而最早的那个请求引发的缓存回填还没有发生,在这种情况下数据库直接就挂掉了,虽然缓存的机制本身看起来并没有任何问题
- 解决方案:
- 可以采用流量控制的方式,限制对于同一数据的访问,必须等到前一个完成以后,下一个才能进行,即如果缓存失效而引发的数据库查询正在进行,其它请求就得等着
- 等待机制可能较为复杂,且有可能影响用户体验
- 另一种方法是缓存预热,在大批量请求到来以前,先主动将该缓存填充好
- 局限性是需要提前知道哪些数据可能引发缓存穿透的问题
- 可以采用流量控制的方式,限制对于同一数据的访问,必须等到前一个完成以后,下一个才能进行,即如果缓存失效而引发的数据库查询正在进行,其它请求就得等着
- 解决方案:
缓存雪崩
- 原本起屏障作用的缓存,如果在一定的时间段内,对于大量的请求访问失效,即失去了屏障作用,造成它后方的系统压力过大,引起系统过载、宕机等问题,就叫做缓存雪崩
- 例如机房突然断电,在恢复的时候把网页服务器都通上了电,这时缓存服务几乎还没有缓存数据,缓存命中率几乎为零
- 于是大量的请求冲向数据库,直接把数据库冲垮了。外在的表现就是,断电导致网站无法提供服务,短期内访问恢复,随后又丧失服务能力
- 对于上述类型雪崩,最常见的解决方案就是限流和预热
- 前者保证了请求大量落到数据库的时候,系统只接纳能够承载的数量
- 后者则在请求访问前,先主动地往内存中加载一定的热点数据,这样请求到来的时候,缓存不是空的,已经具有一定的保护能力了
- 例如机房突然断电,在恢复的时候把网页服务器都通上了电,这时缓存服务几乎还没有缓存数据,缓存命中率几乎为零
- 另外一个常见的缓存雪崩场景是:缓存数据通常都有过期时间的,如果缓存加载的时间比较集中,那么很可能到了某一时间点,大量的缓存就会同时过期,于是对应这些数据的请求全部落到了后面的数据库上,从而造成系统崩溃
- 解决方案:
- 避免缓存集中写入的时间,如果无法避免,就使用一个范围随机数来均匀地分散过期时间,从而打散缓存过期对系统造成的压力
- 解决方案:
缓存更新策略
分类
- 算法剔除
- 例如
LRU, LFU, FIFO
等算法 - 通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除
- 例如
- 超时剔除
- 给缓存数据设置过期时间,让其在过期时间后自动删除
- 主动剔除
- 真实数据更新后, 立即更新缓存数据
- 例如可以利用消息系统或者其他方式通知缓存更新
LRU缓存算法的缺陷
- 如果用户有意无意地访问一些错误信息,就会破坏掉这个 LRU 队列中最近访问数据的真实性
- 例如由于搜索引擎的多个并行爬虫在短时间内访问网站并抓取一些冷门页面,这时候这个 LRU 队列中就存储了相关的冷门数据信息。接着网站活动开启的时间到了,用户量很快就上来了,这时候大量的数据访问全部穿透缓存,导致数据库压力剧增,网站响应时间一下就飙升到了告警线之上
- 解决方法:
- 主缓存队列排的是“第 K 次访问的元素”,也就是说,如果访问次数小于 K,则在另外的一个“低级”队列中维护,这样就保证了只有到达一定的访问下限才会被送到主 LRU 队列中
- 这种方法保证了偶然的页面访问不会影响网站在 LRU 队列中应有的数据分布
- 再进一步优化,可以将两级队列变成更多级,或者是将低级队列的策略变成 FIFO(2Q 算法)等等