缓存前世今生

故事从硬件开始

Cache 一词来源于 1967 年的一篇电子工程期刊论文。其作者将法语词“cache”赋予“safekeeping storage”的涵义,用于电脑工程领域。当时没有 Cache,CPU 和内存都很慢,CPU 直接访问内存。

  • Intel 80386芯片组增加了对可选的 Cache 的支持,高级主板带有 64KB,甚至高端的 128KB Write-Through Cache。
  • Intel 80486 CPU 里面加入了 8KB 的 L1 Unified Cache,当时也叫做内部 Cache,不分代码和数据,都存在一起;芯片组中的 Cache,变成了 L2,也被叫做外部 Cache,从 128KB 到 256KB 不等;增加了 Write-back 的 Cache 属性。
  • Pentium (奔腾) CPU 的 L1 Cache 分为 Code 和 data,各自 8KB;L2 还被放在主板上。
  • Pentium Pro(奔腾) 的 L2 被放入到 CPU 的 Package 上。
  • Pentium 3(奔腾) 开始,L2 Cache 被放入了 CPU 的 Die 中。
  • Intel Core CPU 开始,L2 Cache 为多核共享。

当 CPU 处理数据时,它会先到 Cache 中去寻找,如果数据因之前的操作已经读取而被暂存其中,就不需要再从 随机存取存储器(Main 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 第三级高速缓冲存储器)

概念的扩展

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

现在我们软件开发中常说的缓存,是指磁盘和 CPU 之间的,协调两者传输速度的结构。

缓存的特征

主要特征

  • 命中率:命中率=返回正确结果数/请求缓存次数,命中率越高,表明缓存的使用率也就越高。
  • 吞吐量:缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行并发读、写操作的效率,即缓存本身的工作效率高低。
  • 缓存淘汰策略:
    • FIFO (first in first out):先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。
    • LFU (less frequently used):最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。
    • LRU (least recently used):最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。

是否适合缓存的考虑

不是所有数据都适合缓存,我们使用缓存,是想用较小的成本换取较大的收益,在决定是否缓存之前,可以考虑以下的问题:

  • 是否有一致性的要求,缓存和底层存储是否需要强一致性
  • 缓存是不是高效的?命中率大概怎么样?
  • 缓存多久,是否需要设置 TTL
  • 数据结构是否适合缓存
  • 计算后缓存,亦或是缓存之后计算

惊群效益

如果许多不同的应用程序进程同时请求一个缓存键,但出现缓存未命中,随后所有应用程序进程都并行执行相同的数据库查询,此时就会发生惊群效应,也称作叠罗汉效应。此查询的代价越高,对数据库的影响就越大。一般可以通过缓存预热、缓存不存在的空值来减少。

缓存的分类

根据应用的耦合度,一般分为本地缓存和分布式缓存:

  • 本地缓存:在应用中的缓存组件,应用和 Cache 是在同一个进程内,请求特别快,没有网络开销。
  • 分布式缓存:与应用分离的缓存组件,可以认为是独立的服务,和应用分开,多个应用之间可以共享,但是会存在网络请求。

分布式缓存存在的必要性

先聊缓存的必要性,计算机的世界里,倘若有无法解决不了的问题,一般都可以再加一层来解决,而缓存从被提出开始,就是那个加了的一层。CPU的速度很快,数据库操作很慢,怎么办?CPU缓存很小很贵很快,但是数据库的磁盘很慢很大很便宜,怎么办?内存来解决!

可以提前将一些比较耗时的数据结果暂存到内存(如果有持久化,也会同时存储在磁盘中)中,如果有相同请求,可以直接返回,如果数据变更(更新或者删除),再处理掉缓存。大家平日里接触最多的,可能就是浏览器的缓存,有时候多次访问,有些数据根本不会再去请求,会优先使用浏览器的本地缓存。 除此之外,微博也是如此,

单机的缓存,可以满足大部分的场景,但是单节点的最大容量不能超过整个系统的内存,而且像 memcached 这种存储,断电内容就会彻底丢失,Redis 则有持久化的能力,只是通电之后需要花点时间从磁盘将数据 load 回内存中。

现在几乎应用服务器都是分布式的,如果只做单机缓存,意味着每个服务器的缓存,都存了一份,极大概率存在不一致的情况,比如 一个用户第一次请求命中机器 A,有缓存,第二次命中机器 B ,又没缓存,只能重新缓存了一份在机器 B 上。

分布式缓存设计可能需要考虑的几个问题

站在巨人(Redis)的肩膀上, 我们可以学到很多优秀的设计、理念,设计一个功能比较全面的分布式缓存,到底需要考虑哪些问题?

下面聊聊几点比较常见的:

1 、断电了怎么办?(持久化)

必须支持 持久化,可以异步的将数据刷盘,落到磁盘中,重新启动的时候能够加载已有的数据。那刷盘的时机是怎么样的?只要改一个数据就刷一次盘么?还是修改数据到达某个阈值,才进行刷盘,这些都是策略,最好是可以支持配置,这些规则其实我们都可以从 Redis 这些优秀的缓存中间件中学习到。 当然,如果在一定场景下,能接受数据完全丢失,不需要持久化,那么可以设置为关闭,可以节约性能开销。

2、内存不足怎么办?(缓存淘汰策略)

单机内存不足,可以删除一些数据。但是到底删除哪些数据,这必须有一个决策的算法,这就是缓存淘汰策略。 常见的缓存淘汰策略有以下几种:

  • FIFO:先进先出(First In,First Out),如同队列,新数据在尾部加入,内存不足的时候,淘汰的数据从队列头部移除。
  • LFU:最低频率使用淘汰算法(Least Frequently Used),也称为最近最不常使用,将使用频率最低的数据淘汰。
  • LRU:最近时间未使用(Least Recently used),也称为最近最少使用,内存不足的时候,总是淘汰最长时间未被使用得数据。

3、需不需要自定义协议?

一个稳定的分布式缓存系统,还需要一套序列化协议,怎么设计一个简单而又高效的协议,是个值得思考的问题。

比如 Redis 使用得就是 RESP(REdis Serialization Protocol) 协议,这是专门为 Redis 设计的,属于应用层的通信协议,本质上和 HTTP 是同一层级,而 Redis 的传输层使用的是 TCP。如果是服务器接收请求的场景,那么服务端从 TCPsocket 缓存区里面读取数据,然后经过了 RESP 协议解码知乎,会得到我们所需的指令。

简单讲一下,RESP 主要就是 想用更少的数据,表达所需的更丰富的内容,也就是压缩数据量,增加信息量。 比如第一个字节,决定了数据类型:

  • 简单字符串 :Simple Strings,第一个字节响应 +
  • 错误:Errors,第一个字节响应 -
  • 整型:Integers,第一个字节响应 :
  • 批量字符串:Bulk Strings,第一个字节响应 $
  • 数组:Arrays,第一个字节响应 *

4、一台机器存储不够怎么办?(可拓展)

不能一直增加单台机器的容量,抛开成本不讲,单机大容量,网络带宽,磁盘 IO,计算资源等都可能成为较大的瓶颈,肯定需要支持横向拓展(水平拓展),比如 Redis 集群模式。与横向拓展对应的是垂直拓展,也就是增加单个节点的容量,性能。互联网发展的这些年,已经证明了分布式系统是一个更优的选项。

5、如果有一台机器宕机了怎么办?(高可用)

如果多台机器中,有机器宕机怎么办?从事前、事中、事后来看:

  • 事前:需要可监控,需要有监控节点(比如 Redis 中的哨兵),并且有可以切换的节点(从节点)。
  • 事中:怎么切换,哪一个机器作为“主持人“角色进行切换,切换哪一个机器,都是需要抉择的。
  • 事后:切换之后,下线机器怎么处理。

6、是否支持并发?(高并发)

并发写入怎么办?Redis 采取的是队列的方式,内部不允许并发执行,也就不需要加锁,解锁的操作,如果考虑使用锁来实现,需要同时考虑上下文切换的成本,而我们简单的版本可以使用加锁的方式来实现。

使用分布式缓存可能会遇到的几个问题

1、一致性问题

如何保证缓存和数据库的一致性问题,是一个比较大的话题,我们除了保证数据库和缓存一致,分布式缓存的 master 和 slave 也需要保持一致。一般一致性分为以下几种:

  • 强一致性:数据库更新操作与缓存更新操作是原子性的,缓存与数据库的数据在任何时刻都是一致的,很难实现。
  • 弱一致性:当数据更新后,缓存中的数据可能是更新前的值,也可能是更新后的值,这种更新是异步的。
  • 最终一致性:一种特殊的弱一致性,在一定时间后,数据会达到一致的状态。最终一致性是弱一致性的理想状态,也是分布式系统的数据一致性解决方案上比较推崇的。

根据 CAP 原理,分布式系统在可用性、一致性和分区容错性上无法兼得,通常由于分区容错无法避免,所以一致性和可用性难以同时成立。

这里的几种方案就不展开讲了,几种更新策略:

  • 1、先更新缓存,再更新数据库:
    • 在两个线程一起更新的场景下,如果先更新缓存的线程后更新数据库,很容易出现一致性问题。
  • 2、先更新数据库,再更新缓存
    • 在两个线程一起更新的场景下,如果先更新数据库的线程由于执行慢了一些,后更新缓存,很容易出现一致性问题。
  • 3、先删除缓存,再更新数据库
    • 先删除缓存的线程,后更新数据库,仍然有一致性问题
  • 4、先更新数据库,再删除缓存
    • 先更新数据库的线程,后删除缓存,没有问题!删除缓存之后,会回源到数据库。
    • 但是没删除缓存之前,数据库更新了,读取会读到脏数据。所以我们一般推荐双删,更新之前删一次,更新之后删一次。
    • 这个时候有人会问,如果同时有个读请求,读的是写之前的脏数据,但是写入到缓存是比较慢的,刚刚好在删除之后,那缓存数据就还是脏数据?是的,这个时候一般靠第二次删除延迟来处理,延迟删除。
    • 这个时候肯定有人问,那要是删除失败了怎么办?
      • 直接补偿重试
      • 消息队列,异步重试
      • 基于 mysql binlog 增量订阅消费补偿

这个问题我们在这个分布式缓存的里面就不详细聊了,之后单独聊这个话题,串行化是我们最后的倔强,但是高并发就难了,所以我们一般是保证最终一致性即可。

2、缓存穿透

缓存穿透是指,缓存和数据库都没有的数据,被大量请求,比如订单号不可能为 -1,但是用户请求了大量订单号为 -1 的数据,由于数据不存在,缓存就也不会存在该数据,所有的请求都会直接穿透到数据库。 如果被恶意用户利用,疯狂请求不存在的数据,就会导致数据库压力过大,甚至垮掉。 注意:穿透的意思是,都没有,直接一路打到数据库。

那对于这种情况,我们该如何解决呢?

  1. 接口增加业务层级的Filter,进行合法校验,这可以有效拦截大部分不合法的请求。

  2. 作为第一点的补充,最常见的是使用布隆过滤器,针对一个或者多个维度,把可能存在的数据值 hash 到 bitmap 中,bitmap 证明该数据不存在则该数据一定不存在,但是 bitmap 证明该数据存在也只能是可能存在,因为不同的数值 hash 到的 bit 位很有可能是一样的,hash 冲突会导致误判,多个 hash 方法也只能是降低冲突的概率,无法做到避免。

  3. 另外一个常见的方法,则是针对数据库与缓存都没有的数据,对空的结果进行缓存,但是过期时间设置得较短,一般五分钟内。而这种数据,如果数据库有写入,或者更新,必须同时刷新缓存,否则会导致不一致的问题存在。

3、缓存雪崩

缓存雪崩是指缓存中有大量的数据,在同一个时间点,或者较短的时间段内,全部过期了,这个时候请求过来,缓存没有数据,都会请求数据库,则数据库的压力就会突增,扛不住就会宕机。 针对这种情况,一般我们都是使用以下方案:

  1. 如果是热点数据,先预热,而且可以考虑设置永远不过期。
  2. 缓存的过期时间除非比较严格,要不考虑设置一个波动随机值,比如理论十分钟,那这类key的缓存时间都加上一个13分钟,过期时间在713分钟内波动,有效防止都在同一个时间点上大量过期。
  3. 方法1避免了有效过期的情况,但是要是所有的热点数据在一台redis服务器上,也是极其危险的,如果网络有问题,或者redis服务器挂了,那么所有的热点数据也会雪崩(查询不到),因此将热点数据打散分不到不同的机房中,也可以有效减少这种情况。
  4. 也可以考虑双缓存的方式,数据库数据同步到缓存 A 和 B,A 设置过期时间,B 不设置过期时间,如果 A 为空的时候去读 B,同时异步去更新缓存,但是更新的时候需要同时更新两个缓存。
  5. 使用缓存组件时,可以设置为异步回源,或者允许读取未物理删除的数据。

比如设置产品的缓存时间:


redis.set(id,value,60*60 + Math.random()*1000);

4、缓存击穿

缓存击穿是指数据库原本有得数据,但是缓存中没有,一般是缓存突然失效了,这时候如果有大量用户请求该数据,缓存没有则会去数据库请求,会引发数据库压力增大,可能会瞬间打垮。

针对这类问题,一般有以下做法:

  1. 如果是热点数据,那么可以考虑设置永远不过期。
  2. 如果数据一定会过期,那么就需要在数据为空的时候,设置一个互斥的锁,只让一个请求通过,只有一个请求去数据库拉取数据,取完数据,不管如何都需要释放锁,异常的时候也需要释放锁,要不其他线程会一直拿不到锁。

下面是缓存击穿的时候互斥锁的写法,注意:获取锁之后操作,不管成功或者失败,都应该释放锁,而其他的请求,如果没有获取到锁,应该等待,再重试。当然,如果是需要更加全面一点,应该加上一个等待次数,比如1s中,那么也就是睡眠五次,达到这个阈值,则直接返回空,不应该过度消耗机器,以免当个不可用的场景把整个应用的服务器带挂了。

    public static String getProductDescById(String id) {
        String desc = redis.get(id);
        // 缓存为空,过期了
        if (desc == null) {
            // 互斥锁,只有一个请求可以成功
            if (redis.setnx(lock_id, 1, 60) == 1) {
                try {
                    // 从数据库取出数据
                    desc = getFromDB(id);
                    redis.set(id, desc, 60 * 60 * 24);
                } catch (Exception ex) {
                    LogHelper.error(ex);
                } finally {
                    // 确保最后删除,释放锁
                    redis.del(lock_id);
                    return desc;
                }
            } else {
                // 否则睡眠200ms,接着获取锁
                Thread.sleep(200);
                return getProductDescById(id);
            }
        }
    }

5、缓存热点

像微博这种,有些热点新闻,突然爆了,大量用户访问同一个 key,key 在同一个缓存节点,很容易就过载,节点会卡顿甚至挂掉,这种我们就叫缓存热点。

解决方案一般是通过实时数据流比如 Spark ,分析热点 Key ,一般都有一个增长的过程,然后在 Key 后面加上一些随机的编号,比如明星出轨_01, 明星出轨_02...,目的是让这些 key 分布在不同的机器上,而客户端获取的时候,带上随机的 key,随机访问一个就可以。

想要探测热 Key,除了实时数据流,也可以在 redis 之上的 proxy 上面做,一般我们在公司都不是直接连接 redis ,而是连接的 proxy,因此我们也可以通过在 proxy 中使用滑动时间窗口,对每个 key 进行计数,超过一定的阈值,就设置为热 key。

那如何快速针对热 key 进行动态处理呢?弄一个独立的缓存数据服务,根据流量来动态拆分热 key,动态的增长成为热 key 我们可以通过分析发现,但是如果是秒杀等业务呢?需要支持实时拆分热 key,用分布式配置中心来配置热 key,感知到配置热 key 则进行需要的处理,这里因业务而异,可以降级成读取本地内存,可以进行拆分等等。

当然,如果能够正对秒杀等活动,或者大促活动,拉出独立的集群进行路由,隔离影响,那也是一种方案。

这是京东的处理方案: https://gitee.com/jd-platform-opensource/hotkey ,对任意突发性的无法预先感知的热点请求,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如爬虫、刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。 然后对这些热数据、热用户等,推送到该应用部署的所有机器JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由客户端决定如何使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。 这些热key在整个应用集群内保持一致性。

6、缓存大 Key

缓存大 key 是指缓存的值 value 特别大,如果同一时间大量请求访问了同一个大 key,带宽很容易被占满,其他请求进不来。

大 key 定义参考如下:

  • string类型的key超过10KB
  • hash/set/zset/list 等数据结构中元素个数大于 5k/整体占用内存大于 10MB

如何判断是不是大 key,一般看网络的出流量,如果突增特别厉害,但是入流量变化不大的情况下,基本可以判断为大 key

  • 事前我们可以在代码 review 的时候就得判断 value 是不是特别大,不能写这种代码。或者封装一层 redis 操作切面,异步对 key 的 value 做监控,进行打点告警。
  • 其次,写代码的时候如果发现要 set 这种大的 value 值,那就得想办法拆分,把对象拆成属性,或者按照属性分类。如果是一个不可分割的整体,那就得考虑一下技术方案是不是要推翻重来了,一般我们不太可能把几 M 的图片直接二进制存 redis。
  • dump RDB 数据,进行离线数据分析,给出告警,但是不够实时。
  • Redis 提供了 bigkeys 参数能够使 redis-cli 以遍历的方式分析 Redis 实例中的所有 Key,并返回 Key 的整体统计信息与每个数据类型中 Top1 的大 Key,bigkeys 仅能分析并输入六种数据类型(STRING LISTHASHSETZSETSTREAM), 命令示例为 redis-cli -h 127.0.0.1 -p 6379 --bigkeys

总结

缓存不是银弹,是一把刀,用得好,可以乱杀(夸大),用不好,得包扎(一点不夸大,得提桶跑路那种)。