作者:秦怀
Cache 一词来源于 1967 年的一篇电子工程期刊论文。其作者将法语词“cache”赋予“safekeeping storage”的涵义,用于电脑工程领域。当时没有 Cache,CPU 和内存都很慢,CPU 直接访问内存。
当 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 之间的,协调两者传输速度的结构。
不是所有数据都适合缓存,我们使用缓存,是想用较小的成本换取较大的收益,在决定是否缓存之前,可以考虑以下的问题:
如果许多不同的应用程序进程同时请求一个缓存键,但出现缓存未命中,随后所有应用程序进程都并行执行相同的数据库查询,此时就会发生惊群效应,也称作叠罗汉效应。此查询的代价越高,对数据库的影响就越大。一般可以通过缓存预热、缓存不存在的空值来减少。
根据应用的耦合度,一般分为本地缓存和分布式缓存:
先聊缓存的必要性,计算机的世界里,倘若有无法解决不了的问题,一般都可以再加一层来解决,而缓存从被提出开始,就是那个加了的一层。CPU的速度很快,数据库操作很慢,怎么办?CPU缓存很小很贵很快,但是数据库的磁盘很慢很大很便宜,怎么办?内存来解决!
可以提前将一些比较耗时的数据结果暂存到内存(如果有持久化,也会同时存储在磁盘中)中,如果有相同请求,可以直接返回,如果数据变更(更新或者删除),再处理掉缓存。大家平日里接触最多的,可能就是浏览器的缓存,有时候多次访问,有些数据根本不会再去请求,会优先使用浏览器的本地缓存。
除此之外,微博也是如此,
单机的缓存,可以满足大部分的场景,但是单节点的最大容量不能超过整个系统的内存,而且像 memcached 这种存储,断电内容就会彻底丢失,Redis 则有持久化的能力,只是通电之后需要花点时间从磁盘将数据 load 回内存中。
现在几乎应用服务器都是分布式的,如果只做单机缓存,意味着每个服务器的缓存,都存了一份,极大概率存在不一致的情况,比如 一个用户第一次请求命中机器 A,有缓存,第二次命中机器 B ,又没缓存,只能重新缓存了一份在机器 B 上。
站在巨人(Redis)的肩膀上, 我们可以学到很多优秀的设计、理念,设计一个功能比较全面的分布式缓存,到底需要考虑哪些问题?
下面聊聊几点比较常见的:
必须支持 持久化,可以异步的将数据刷盘,落到磁盘中,重新启动的时候能够加载已有的数据。那刷盘的时机是怎么样的?只要改一个数据就刷一次盘么?还是修改数据到达某个阈值,才进行刷盘,这些都是策略,最好是可以支持配置,这些规则其实我们都可以从 Redis 这些优秀的缓存中间件中学习到。
当然,如果在一定场景下,能接受数据完全丢失,不需要持久化,那么可以设置为关闭,可以节约性能开销。
单机内存不足,可以删除一些数据。但是到底删除哪些数据,这必须有一个决策的算法,这就是缓存淘汰策略。
常见的缓存淘汰策略有以下几种:
一个稳定的分布式缓存系统,还需要一套序列化协议,怎么设计一个简单而又高效的协议,是个值得思考的问题。
比如 Redis 使用得就是 RESP(REdis Serialization Protocol)
协议,这是专门为 Redis 设计的,属于应用层的通信协议,本质上和 HTTP 是同一层级,而 Redis 的传输层使用的是 TCP。如果是服务器接收请求的场景,那么服务端从 TCPsocket 缓存区里面读取数据,然后经过了 RESP 协议解码知乎,会得到我们所需的指令。
简单讲一下,RESP 主要就是 想用更少的数据,表达所需的更丰富的内容,也就是压缩数据量,增加信息量。
比如第一个字节,决定了数据类型:
简单字符串
:Simple Strings,第一个字节响应 +
错误
:Errors,第一个字节响应 -
整型
:Integers,第一个字节响应 :
批量字符串
:Bulk Strings,第一个字节响应 $
数组
:Arrays,第一个字节响应 *
不能一直增加单台机器的容量,抛开成本不讲,单机大容量,网络带宽,磁盘 IO,计算资源等都可能成为较大的瓶颈,肯定需要支持横向拓展(水平拓展),比如 Redis 集群模式。与横向拓展对应的是垂直拓展,也就是增加单个节点的容量,性能。互联网发展的这些年,已经证明了分布式系统是一个更优的选项。
如果多台机器中,有机器宕机怎么办?从事前、事中、事后来看:
并发写入怎么办?Redis 采取的是队列的方式,内部不允许并发执行,也就不需要加锁,解锁的操作,如果考虑使用锁来实现,需要同时考虑上下文切换的成本,而我们简单的版本可以使用加锁的方式来实现。
如何保证缓存和数据库的一致性问题,是一个比较大的话题,我们除了保证数据库和缓存一致,分布式缓存的 master 和 slave 也需要保持一致。一般一致性分为以下几种:
根据 CAP 原理,分布式系统在可用性、一致性和分区容错性上无法兼得,通常由于分区容错无法避免,所以一致性和可用性难以同时成立。
这里的几种方案就不展开讲了,几种更新策略:
这个问题我们在这个分布式缓存的里面就不详细聊了,之后单独聊这个话题,串行化是我们最后的倔强,但是高并发就难了,所以我们一般是保证最终一致性即可。
缓存穿透是指,缓存和数据库都没有的数据,被大量请求,比如订单号不可能为 -1
,但是用户请求了大量订单号为 -1
的数据,由于数据不存在,缓存就也不会存在该数据,所有的请求都会直接穿透到数据库。
如果被恶意用户利用,疯狂请求不存在的数据,就会导致数据库压力过大,甚至垮掉。
注意:穿透的意思是,都没有,直接一路打到数据库。
那对于这种情况,我们该如何解决呢?
Filter
,进行合法校验,这可以有效拦截大部分不合法的请求。缓存雪崩是指缓存中有大量的数据,在同一个时间点,或者较短的时间段内,全部过期了,这个时候请求过来,缓存没有数据,都会请求数据库,则数据库的压力就会突增,扛不住就会宕机。
针对这种情况,一般我们都是使用以下方案:
比如设置产品的缓存时间:
redis.set(id,value,60*60 + Math.random()*1000);
缓存击穿是指数据库原本有得数据,但是缓存中没有,一般是缓存突然失效了,这时候如果有大量用户请求该数据,缓存没有则会去数据库请求,会引发数据库压力增大,可能会瞬间打垮。
针对这类问题,一般有以下做法:
下面是缓存击穿的时候互斥锁的写法,注意:获取锁之后操作,不管成功或者失败,都应该释放锁,而其他的请求,如果没有获取到锁,应该等待,再重试。当然,如果是需要更加全面一点,应该加上一个等待次数,比如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);
}
}
}
像微博这种,有些热点新闻,突然爆了,大量用户访问同一个 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在整个应用集群内保持一致性。
缓存大 key 是指缓存的值 value 特别大,如果同一时间大量请求访问了同一个大 key,带宽很容易被占满,其他请求进不来。
大 key 定义参考如下:
如何判断是不是大 key,一般看网络的出流量,如果突增特别厉害,但是入流量变化不大的情况下,基本可以判断为大 key。
STRING
、LIST
、HASH
、SET
、ZSET
、STREAM
), 命令示例为 redis-cli -h 127.0.0.1 -p 6379 --bigkeys
缓存不是银弹,是一把刀,用得好,可以乱杀(夸大),用不好,得包扎(一点不夸大,得提桶跑路那种)。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。