缓存

Posted by Liber Sun on September 28, 2018

缓存是一种保存资源副本并在下次请求时直接使用该副本的技术。缓存不是必须的,但重用缓存的资源通常是必要的。

在实际的开发中,我们经常需要对数据库进行读取、添加以及修改。但是当数据访问量大凭此的增大时,过度的数据库操作会称为系统的性能瓶颈,甚至会压垮数据仓库,从而使整个程序卡死。

缓存处理机制

cache的处理机制大概包括以下的几种:

  • Cache Aside
  • Read Through
  • Write Through
  • Write Behind Caching

当然引入了cache,并将增加系统的复杂性,也会衍生出各样的问题。

Cache Aside

这种模式处理缓存通常都是先从数据库缓存查询,如果缓存没有命中则从数据库中进行查找。

当查询发现存在缓存时,直接从缓存中提取。 当查询发现缓存没有数据时,从DB中读取,并将数据更新到cache中。

当写、更新操作时,直接操作数据库,然后修改缓存中对应的值

Read Through

Read Through 强调每次都从缓存中请求数据。

如果缓存没有数据,则它负责使用底层提供程序插件从数据库中检索数据。 检索数据后,缓存会自行更新并将数据返回给调用应用程序。 使用Read Through 有一个好处。我们总是从key中获取数据,调用的Application是不知道数据库的存在的,由数据库来进行缓存的处理。这使代码更加清晰,简单,但是这需要开发人员给编写相关的程序插件,增加了开发的难度性。

Write Through

Write Through模式和Read Through模式类似,当数据发生更新的时候,先去Cache里面进行更新,如果命中了,则先更新缓存再由Cache方来更新database。如果没有命中的话,就直接更新Cache里面的数据。

Write Behind Caching

Write Behind Caching 这种模式通常是先将数据写入到缓存里面,然后再异步的写入到database中进行数据同步,这样的设计既可以直接的减少我们对于数据的database里面的直接访问,降低压力,同时对于database的多次修改可以进行合并操作,极大的提升了系统的承载能力。

但是这种模式处理缓存数据具有一定的风险性,例如说当cache机器出现宕机的时候,数据会有丢失的可能。

缓存特征

  • 命中率,当某个请求能够通过缓存访问而得到响应时,我们称之为缓存命中
  • 最大空间,缓存通常是位于内存中的,内存有限,当缓存存放的数据达到最大空间时,需要淘汰部分数据以接收新的数据
  • 淘汰策略
    • FIFO,先进先出策略,在实时性的场景下,需要访问最新的数据,可以使用FIFO。
    • LRU,最近最久未使用的策略,优先淘汰最久的未使用的数据。该策略保证缓存中的数据都是热点数据,也就是经常被访问的数据
    • LFU,最不经常使用的策略,优先淘汰一段时间内使用最少的数据。

LRU

LRU最好的实现方式就是LinkedHashMap实现,解释如下:

LinkedHashMap本身可以设置访问顺序为存储顺序,我们只需要继承该类,@OverrideremoveEldestEntry方法完成删除策略即可。

class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private int MAX_ENTRIES = 3;
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }

    LRUCache() {
        super(MAX_ENTRIES, 0.75f, true);//true开启访问顺序排序
    }

    LRUCache(int cacheSize) {
        super(MAX_ENTRIES, 0.75f, true);//true开启访问顺序排序
        MAX_ENTRIES=cacheSize;
    }
}

缓存位置

浏览器

当 HTTP 响应允许进行缓存时,浏览器会将 HTML、CSS、JavaScript、图片等静态资源进行缓存。

ISP

网络服务提供商(ISP)是网络访问的第一跳,通过将数据缓存在 ISP 中能够大大提高用户的访问速度。

反向代理

反向代理位于服务器之前,请求与响应都需要经过反向代理。通过将数据缓存在反向代理,在用户请求反向代理时就可以直接使用缓存进行响应。

本地缓存

将数据缓存在服务器本地内存中,服务器代码可以直接读取本地内存中的缓存,速度非常快。

分布式缓存

使用 Redis、Memcache 等分布式缓存将数据缓存在分布式缓存系统中。

相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。不仅如此,服务器集群都可以访问分布式缓存,而本地缓存需要在服务器集群之间进行同步,实现难度和性能开销上都非常大。

数据库缓存

数据库管理系统具有自己的查询缓存机制来提高查询效率。

Java 内部的缓存

Java 为了优化空间,提高字符串、基本数据类型包装类的创建效率,设计了字符串常量池及 Byte、Short、Character、Integer、Long、Boolean 这六种包装类缓冲池。

CPU 多级缓存

CPU 为了解决运算速度与主存 IO 速度不匹配的问题,引入了多级缓存结构,同时使用 MESI 等缓存一致性协议来解决多核 CPU 缓存数据一致性的问题。

缓存问题

缓存击穿

指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库,而数据库中也确实不存在该数据。

解决方案:

  • 对这些不存在的数据缓存一个空数据;
  • 对这类请求进行过滤。

缓存雪崩

指的是由于数据没有被加载到缓存中,或者缓存数据在同一时间大面积失效(过期),又或者缓存服务器宕机,导致大量的请求都到达数据库。

在有缓存的系统中,系统非常依赖于缓存,缓存分担了很大一部分的数据请求。当发生缓存雪崩时,数据库无法处理这么大的请求,导致数据库崩溃。

解决方案:

  • 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以通过观察用户行为,合理设置缓存过期时间来实现;
  • 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用。
  • 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩。

缓存一致性

缓存一致性要求数据更新的同时缓存数据也能够实时更新。

解决方案:

  • 在数据更新的同时立即去更新缓存;
  • 在读缓存之前先判断缓存是否是最新的,如果不是最新的先进行更新。

要保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据。

缓存”无底洞”

指的是为了满足业务要求添加了大量缓存节点,但是性能不但没有好转反而下降了的现象。

产生原因:缓存系统通常采用 hash 函数将 key 映射到对应的缓存节点,随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作,这意味着批量操作的耗时会随着节点数目的增加而不断增大。此外,网络连接数变多,对节点的性能也有一定影响。

解决方案:

  • 优化批量数据操作命令;
  • 减少网络通信次数;(GraphQL)
  • 降低接入成本,使用长连接 / 连接池,NIO 等。

数据分布

哈希分布

哈希分布就是将数据计算哈希值之后,按照哈希值分配到不同的节点上。例如有 N 个节点,数据的主键为 key,则将该数据分配的节点序号为:hash(key)%N。

传统的哈希分布算法存在一个问题:当节点数量变化时,也就是 N 值变化,那么几乎所有的数据都需要重新分布,将导致大量的数据迁移。

顺序分布

将数据划分为多个连续的部分,按数据的 ID 或者时间分布到不同节点上。例如 User 表的 ID 范围为 1 ~ 7000,使用顺序分布可以将其划分成多个子表,对应的主键范围为 1 ~ 1000,1001 ~ 2000,…,6001 ~ 7000。

顺序分布相比于哈希分布的主要优点如下:

  • 能保持数据原有的顺序;
  • 并且能够准确控制每台服务器存储的数据量,从而使得存储空间的利用率最大。