[01] 缓存概述

159 阅读9分钟

一、缓存概述

1.1 缓存原理

Cache一词来源于1967年的一篇电子工程期刊论文。其作者将法语词“cache”赋予“safekeeping storage”的涵义,用于计算机工程领域。

当CPU处理数据时,它会先到Cache中去寻找,如果数据因之前的操作已经读取而被暂存其中,就不需要再从随机存取存储器(Random Access Memory)中读取数据——由于CPU的运行速度一般比主内存的读取速度快,主存储器周期(访问主存储器所需要的时间)为数个时钟周期。因此若要访问主内存的话,就必须等待数个CPU周期从而造成浪费。

提供“缓存”的目的是为了让数据访问的速度适应CPU的处理速度,其基于的原理是内存中“程序执行与数据访问的局域性行为”,即一定程序执行时间和空间内,被访问的代码集中于一部分。为了充分发挥缓存的作用,不仅依靠“暂存刚刚访问过的数据”,还要使用硬件实现的指令预测与数据预取技术——尽可能把将要使用的数据预先从内存中取到缓存里。

CPU的缓存曾经是用在超级计算机上的一种高级技术,不过现今计算机上使用的的AMD或Intel微处理器都在芯片内部集成了大小不等的数据缓存和指令缓存,通称为L1缓存(L1 Cache即Level 1 On-die Cache,第一级片上高速缓冲存储器);而比L1更大容量的L2缓存曾经被放在CPU外部(主板或者CPU接口卡上),但是现在已经成为CPU内部的标准组件;更昂贵的CPU会配备比L2缓存还要大的L3缓存(level 3 On-die Cache第三级高速缓冲存储器)。

image.png

1.2 概念扩充

如今缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和硬盘之间也有Cache(磁盘缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。

以上是维基百科对缓存的相关描述,可以简单概况为:缓存最早是为了解决CPU运算速度和内存读写速度不匹配的矛盾,现在我们把能协调两种数据传输速度有差异的硬件的结构都称为缓存。 如寄存器->L1/L2/L3->内存->磁盘->远程服务器就是一个典型的例子。

image.png

1.3 缓存分类

缓存可分为以下几种类型

  • CDN
  • 反向代理
  • 本地缓存
  • 分布式缓存

1.3.1 CDN

即内容分发网络(Content Delivery Network),部署在距离终端用户最近的网络服务商,用户的网络请求总是先到达他的网络服务商哪里,在这里缓存网站的一些静态资源(较少变化的数据),可以就近以最快速度返回给用户,如视频网站和门户网站会将用户访问量最大的热点内容缓存在CDN。

1.3.2 反向代理

反向代理属于网站前端架构的一部分,部署在网站的前端,当用户请求到达网站的数据中心时,最先访问到就是反向代理服务器,这里缓存网站的静态资源(如js,css,图片),无需将请求继续转发给应用服务器就能返回给用户。

如Nginx可以被当作反向代理服务器。(Oceanus也是基于OpenResty,OpenResty基于Nginx和Lua)

1.3.3 本地缓存

在应用服务器本地缓存着热点数据,应用程序可以在本机内存中直接访问数据,而无需访问数据库。

如 Spring 使用 ConcurrentHashMap 缓存了单例 Bean。

image.png 还比如JVM使用堆内存缓存Java对象。

1.3.4 分布式缓存

大型网站的数据量非常庞大,即使只缓存一小部分,需要的内存空间也不是单机能承受的,所以除了本地缓存,还需要分布式缓存,将数据缓存在一个专门的分布式缓存集群中,应用程序通过网络通信访问缓存数据。

如 Pigeon 的注册中心基于 Zookeeper 存储了服务提供者。

1.4 缓存的其他概念

1.4.1 缓存命中率

缓存命中率=从缓存中读取次数/总读取次数。命中率越高越好。这是一个非常重要的监控指标。

1.4.3 缓存回收策略

1.基于空间

指缓存设置了存储空间,如设置为 100MB,当达到存储空间上限时,按照一定的策略移除数据。

以 JVM 为例,当 Eden 区空间不足时,触发 MinorGC 回收内存;当老年代空间不足时,触发 MajorGC 回收内存。

2.基于容量

指缓存设置了最大大小,当缓存的条目超过最大大小时,按照一定的策略移除数据。

如 Gauva Cache 可以通过 maximumSize 参数设置缓存容量,当超出 maximumSize 时,按照 LRU 算法进行缓存回收。

public class GuavaCacheTest {

    @Test
    public void testMaximumSize() {
        Cache<String, String> objectCache = CacheBuilder.newBuilder()
                .maximumSize(1)
                .build();

        objectCache.put("key1", "value1");
        String value1 = objectCache.getIfPresent("key1");
      
        System.out.println("key:key1" + " value:" + value1);//value1

        objectCache.put("key2", "value2");
        String value1AfterExpired = objectCache.getIfPresent("key1");
        String value2 = objectCache.getIfPresent("key2");

        System.out.println("key:key1" + " value:" + value1AfterExpired);//null
        System.out.println("key:key2" + " value:" + value2);//value2
    }
}

3.基于时间

TTL(Time To Live) :存活期,即缓存数据从创建开始直到到期的一个时间段(不管在这个时间段内有没有被访问,缓存数据都将过期)。

如Redis可以给key设置过期时间。

127.0.0.1:6379> set key 'value' EX 10
OK
127.0.0.1:6379> get key
"value"
127.0.0.1:6379> get key #10s后
(nil)

如 Gauva Cache 可以通过 expireAfterWrite 参数设置过期时间。

public class GuavaCacheTest {

    @Test
    public void testExpireAfterWrite() {
        Cache<String, String> objectCache = CacheBuilder.newBuilder()
                .expireAfterWrite(1, TimeUnit.SECONDS)
                .build();

        objectCache.put("key1", "value1");

        String value1 = objectCache.getIfPresent("key1");
        System.out.println("key:key1" + " value:" + value1);//value1

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        String value1AfterExpired = objectCache.getIfPresent("key1");
        System.out.println("key:key1" + " value:" + value1AfterExpired);//null
    }
}

TTI(Time To Idle) :空闲期,即缓存数据多久没被访问后移除缓存的时间。

如 Gauva Cache 可以通过 expireAfterAccess 参数设置空闲时间。

public class GuavaCacheTest {
  
    @Test
    public void testExpireAfterAccess() {
        Cache<String, String> objectCache = CacheBuilder.newBuilder()
                .expireAfterAccess(1, TimeUnit.SECONDS)
                .build();

        objectCache.put("key1", "value1");

        String value1 = objectCache.getIfPresent("key1");
        System.out.println("key:key1" + " value:" + value1);//value1

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        String value1AfterExpired = objectCache.getIfPresent("key1");
        System.out.println("key:key1" + " value:" + value1AfterExpired);//null
    }
}

4.回收算法

FIFO(First In First Out): 先进先出算法,即先放入缓存的先被移除。

LRU(Least Recently Used): 最近最少使用算法,使用时间距离现在最久的那个被移除。

LFU(Least Frequently Used): 最不常用算法,一定时间段内使用次数(频率)最少的那个被移除。

1.4.2 缓存更新策略

主要有两大类:Cache-Aside 和 Cache-As-SoR(Read-Through、Write-Through、Write-Behind)。

名词介绍:

SoR(system-of-record):记录系统,或者可以叫数据源,即实际存储原始数据的系统。通常是指数据库。

Cache:缓存,是 SoR 的快照数据,Cache 的访问速度比 SoR 要快,放入 Cache 的目的是提升访问速度,减少回源到 SoR 的次数。

回源:即回到数据源头获取数据,Cache 没有命中时,需要从 SoR 读取数据,这叫做回源。

1.4.2.1 Cache-Aside

该模式对缓存的关注点主要在于业务代码,即缓存的更新,删除与数据库的操作,以及他们之间的先后顺序在业务代码中实现。

读操作:

  1. 先读缓存,缓存命中,则直接返回
  2. 缓存未命中,则回源到数据库获取源数据
  3. 将数据重新放入缓存,下次即可从缓存中获取数据

读操作这里有几个风险点,在后面两期中会详细说明,这里先简单描述下

  1. 在第二步查数据库时,由于主从延迟,可能读到从库数据,导致数据不一致。
  2. 缓存失效后,可能存在大量请求并发读数据库,导致数据库压力太大。
  3. 在第二步读操作事务开始后,有一个并发写操作开启了事务,并先于读操作结束了事务,导致读操作读到旧的数据。(理论上会出现,但实际发生的概率很低,因为数据库的写操作要比读操作慢很多)

写操作:

有两个方案

  1. 先操作缓存,再操作数据库
  2. 先操作数据库,再操作缓存

本质上这是一个分布式事务问题,要保证原子性十分困难。假如出现一个操作成功,一个操作失败,哪个方案更好呢?

方案一

操作缓存又分为两种情况:set 和 delete。

假如先 set 缓存成功,再写数据库失败,这会导致数据不一致。

假如先 delete 缓存成功,再写数据库失败,这不会导致数据不一致,仅仅会多一次缓存 miss 的成本。

方案二

假如先操作数据库成功,再操作(delete或set)缓存失败,这会导致数据不一致,程序读到旧的缓存数据!

因此可以得出结论,

  • 对于读请求,先读缓存,如果未命中,再读数据库,并将数据 set 回缓存。
  • 对于写请求,先删缓存,再写数据库。

即使是先删缓存,再写数据库,也可能存在数据不一致风险,在后面两期会详细说明

Cache-Aside 需要业务代码维护两个数据源,一个是缓存,一个是数据库,比较繁琐。而 Read/Write-through 就是把对数据库的操作让缓存来代理了。

1.4.2.2 Read-Through

Read-Through 也是在查询操作中更新缓存,和 Cache-Aside 相比,唯一的区别就是,当缓存失效的时候(过期或LRU换出),Cache-Aside 是由业务代码负责把数据加载入缓存,而 Read-Through 则用缓存服务自己来加载,从而对业务代码是透明的

比如 Guava Cache 中的 CacheLoader

public class CacheLoaderTest {

    CacheLoader<String, String> loader = new CacheLoader<String, String>() {
        public String load(String key) {
            System.out.println("getFromDatabase,key:" + key);
            return getFromDatabase(key);
        }
    };

    @Test
    public void test() {
        LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder()
                .maximumSize(10)
                .build(loader);

        String key = "key1";

        try {
            String result = loadingCache.get(key); //第一次查询,缓存中无数据,需要从数据库加载
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        try {
            String result2 = loadingCache.get(key); //第二次查询,缓存有数据,直接从缓存获取
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
  
    // 模拟从数据库中获取数据
    private String getFromDatabase(String key) {
        return key;
    }
}

1.4.2.3 Write-Through

Write-Through 和 Read-Through类似,只不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存自己同步更新数据库。

1.4.2.4 Write-Behind

Write-Behind 也叫 Write-Back。就是在更新数据的时候,只更新缓存,不同步更新数据库,而是异步地更新数据库(可以实现批量写、合并写、延时写)。

如 MySQL 可以通过设置参数 innodb_flush_log_at_trx_commit、sync_binlog,将 redo log、binlog 的刷盘时间延迟。