缓存小结

什么是缓存

所谓缓存,就是将程序或系统经常要调用的对象存在内存中,一遍其使用时可以快速调用,不必再去创建新的重复的实例。这样做可以减少系统开销,提高系统效率。

根据存储的位置不同分为两类:

  • 文件缓存:通过文件缓存, 顾名思义文件缓存是指把数据存储在磁盘上,不管你是以 XML 格式,序列化文件 DAT 格式还是其它文件格式 。
  • 内存缓存:创建一个静态内存区域,将数据存储进去,例如我们 B/S 架构的将数据存储在 Application 中或者存储在一个静态 Map 中 。

根据网络划分:

  • 本地缓存
  • 网络缓存

为什么需要缓存

在系统中,有些数据,数据量小,但是访问十分频繁(例如国家标准行政区域数据或者一些数据字典等),针对这种场景,需要将数据搞到应用的本地缓存中,以提升系统的访问效率,减少无谓的数据库访问(数据库访问占用数据库连接,同时网络消耗比较大),但是有一点需要注意,就是缓存的占用空间以及缓存的失效策略。

根据场景决定缓存方案

缓存使用有如下几阶段:

本地缓存

通过在Web应用端内嵌一个本地缓存。

优势

  • 访问比较快
  • 易于创建使用

劣势

  • 数据更新的一致性比较难保证

单机版的远程缓存

通过部署一套远程的缓存服务,然后应用端通过网络请求与缓存交互。

分布式缓存

缓存服务本身是一个大集群,能够提供给各种业务应用使用,并提供一些基本的分布式特性:水平扩展、容灾、数据一致性等。

自实现缓存的一些问题

  1. 没有缓存大小的设置,无法限定缓存提的大小以及存储数据的限制
  2. 没有缓存的失效策略
  3. 没有弱键引用,在内存占用吃紧的情况下,JVM 是无法回收的
  4. 没有监控统计
  5. 没有持久性存储

缓存更新的套路

缓存的更新操作是保证缓存数据一致性的关键。

先记录一个更新缓存不好的方案。

好些人在写更新缓存数据代码时,先删除缓存,然后再更新数据库,而后续的操作会把数据再装载的缓存中。然而,这个是逻辑是错误的。试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了 。

其实更新缓存也是有套路的,如下

Cache Aside Pattern

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。

至于为什么不是写完数据库后更新缓存,是为了防止两个并发的写操作导致脏数据。

那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

Read/Write Through Pattern

在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

Write Through套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作) 。

Read/Write Through Pattern

Write Behind Caching Pattern

Write Behind又叫Write Back。Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

Write Behind Caching Pattern

缓存失效策略

  • FIFO

    First In First Out

    先进先出,即存放时间最久的数据最先淘汰。

  • LRU

    Least Recently Used

    最近最少使用,即判断数据最近有没有被使用,使用时间比较早的数据淘汰。

  • LFU

    Least Frequently Used

    最不经常使用,即判断一段时间内数据使用的次数,使用次数少的数据淘汰。

缓存高并发下的问题

缓存穿透

描述

通常缓存都是根据key去查找value,如果缓存中不存在,则去DB中查找,如果查找到了则将此键值对写入缓存。但是,对于某些一直不存在的数据,每次都无法在缓存中查找到,所以每次都要去DB中查找,DB中也找不到所以没法写入缓存,如此往复,便失去了缓存的意义。 高并发下对DB造成极大的压力。

解决方案

  1. 对到DB中查询为空的情况也进行缓存(如约定一个特殊字符串来代替),但是这个数据的缓存过期时间需要设置得短一点。
  2. 将所有可能存在的数据根据key哈希到一个bitmap中,不可能存在的数据直接被bitmap过滤掉,不去查询DB。

缓存并发

描述

高并发下,多个线程查询到某个缓存失效(或者该key没有缓存),同时去DB中查找并设置缓存,引起的压力问题。

解决方案

  1. 当查询到key为空时,加锁,然后查DB,更新缓存,解锁

缓存失效

描述

高并发下,我们若某一时间同时给大量缓存设置了相同的失效时间,导致某一时刻缓存同时失效;或者机器重启导致的缓存全部同时失效。这会让大量请求同时去查找DB,给DB造成巨大的压力。

解决方案

  1. 控制读取DB的线程数量
  2. 随机设置缓存的失效时间,避免大量缓存同时失效

推荐缓存框架

  • Ehcache(也可以做分布式)
  • Redis
  • memcached

参考资料

jinhy wechat
微信扫一扫,欢迎关注我的订阅号~
坚持原创,您的支持将鼓励我继续创作,去追寻星空~