什么是缓存?
狭义:加速CPU交换的存储器
广义:用于数据高速交换的存储介质,可以是硬件也可以是软件。
缓存存在的意义就是通过开辟一个新的数据交换缓冲区,来解决原始数据获取代价太大的问题,提高读写性能。
缓存的基本思想
利用时间局限性原理,通过空间换时间来达到加速数据获取的目的。
- 时间局限性原理,即被获取过一次的数据在未来会被多次引用
- 以空间换时间,开辟一块高速独立空间,提供高效访问
- 性能成本 Tradeoff,访问延迟越低/性能越高,等容量成本越大
优势:
- 提升访问性能
- 降低网络拥堵
- 减轻服务负载
- 增强可扩展性
代价:
- 增加系统的复杂度。
- 系统部署及运行的费用也会更高。
- 存在一致性问题
缓存读写模式
Cache Aside(旁路缓存)
业务应用方对于写,是更新 DB 后,直接将 key 从 cache 中删除,然后由 DB 驱动缓存数据的更新;而对于读,是先读 cache,如果 cache 没有,则读 DB,同时将从 DB 中读取的数据回写到 cache。
特点:业务端处理所有数据访问细节,同时利用 Lazy 计算的思想,更新 DB 后,直接删除 cache 并通过 DB 更新,确保数据以 DB 结果为准,大幅降低 cache 和 DB 中数据不一致的概率。
适用于对数据一致性要求比较高的业务,或者是缓存数据更新比较复杂的业务
Read/Write Through(读写穿透)
对于 Cache Aside 模式,业务应用需要同时维护 cache 和 DB 两个数据存储方,过于繁琐,于是就有了 Read/Write Through 模式。业务应用只关注一个存储服务即可,业务方的读写 cache 和 DB 的操作,都由存储服务代理。
存储服务收到业务应用的写请求时,会首先查 cache,如果数据在 cache 中不存在,则只更新 DB,如果数据在 cache 中存在,则先更新 cache,然后更新 DB。而存储服务收到读请求时,如果命中 cache 直接返回,否则先从 DB 加载,回写到 cache 后返回响应。
特点:存储服务封装了所有的数据处理细节,业务应用端代码只用关注业务逻辑本身,系统的隔离性更佳。另外,进行写操作时,如果 cache 中没有数据则不更新,有缓存数据才更新,内存效率更高。
Write Behind Caching(异步缓存写入)
Write Behind Caching 模式与 Read/Write Through 模式类似,也由数据存储服务来管理 cache 和 DB 的读写。不同点是,数据更新时,Read/write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。
该模式的特点是,数据存储的写性能最高,非常适合一些变更特别频繁的业务,特别是可以合并写请求的业务,比如对一些计数业务。
缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。比如系统 Crash、机器宕机时,如果有数据还没保存到 DB,则会存在丢失的风险。所以这种读写模式适合变更频率特别高,但对一致性要求不太高的业务,这样写操作可以异步批量写入 DB,减小 DB 压力。
三大缓存问题的解决方案
缓存穿透
定义:频繁查询数据库中不存在的数据,导致每次请求直达数据库。
造成的原因:在系统设计时,更多考虑的是正常访问路径,对特殊访问路径、异常访问路径考虑相对欠缺。大概率是遭到了攻击。
解决方案:
- 在接口层增加校验,比如用户鉴权校验,参数做校验,不合法的参数直接代码Return
- 缓存空对象:对不存在的数据也在Redis建立缓存,值为空,并设一个较短的过期时间TTL,实现简单,维护方便,但有额外内存消耗
- 布隆过滤器:构建一个 BloomFilter 缓存过滤器,缓存全量的 key,在请求进入Redis之前先判断是否存在,如果不存在直接拒绝请求,内存占用少,但实现复杂,存在误判的可能性。
布隆过滤器: BloomFilter是一个非常有意思的数据结构,不仅仅可以挡住非法 key 攻击,还可以低成本、高性能地对海量数据进行判断。
算法: 首先分配一块内存空间去初始化一个比较大数组 bitMap,初始值全部设为 0,加入元素时,采用 k(=3) 个相互独立的 Hash 函数计算,然后将元素 Hash 映射的 K个位置全部设置为 1。检测 key 时,仍然用这 k 个 Hash 函数计算出 k 个位置,如果位置全部为 1,则表明 key 存在,否则不存在。
不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。布隆过滤器说某个元素不在,那么这个元素一定不在。 反证法,如果这个 key 存在,那它每次 Hash 后对应的 Hash 值位置肯定是 1,而不会是 0。布隆过滤器说某个元素存在,小概率会误判。 因为它存的是 key 的 Hash 值,而非 key 的值,所以有概率存在这样的 key,它们内容不同,但多次 Hash 后的 Hash 值都相同。
优势: 全内存操作,性能很高。另外空间效率非常高. 缺点: 布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,5%以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。
缓存击穿
定义:某个热点Key在缓存中过期的瞬间,有大量请求并发访问数据库。
造成的原因:,跟我们日常写缓存的过期时间有关。
解决方案:
- 热点key缓存永不过期
- 设置 key逻辑过期
- 查询到数据时通过对逻辑过期时间判断,来决定是否需要重建缓存
- 如果过期则开启一个独立线程进行重建缓存,通过互斥锁保证单线程执行,其他线程无需等待,直接返回查询到的旧数据
- 高可用,性能好,不保证数据强一致性,有额外内存消耗,实现复杂
- 使用互斥锁:
- 当缓存失效时,不立即去load db,先使用 Redis 的 setnx 去设置一个互斥锁,确保重建过程只有一个线程执行,其他线程等待,当操作成功返回时再进行 load db的操作并回设缓存,否则重试get缓存的方法。
- 数据强一致性好,但有死锁风险,实现简单,没有额外内存消耗
缓存雪崩
定义:某一段时间内,大量缓存key同时失效或者Redis服务宕机,导致大量请求同时到达DB,DB 瞬时压力过重雪崩。
造成的原因:
- 缓存不支持 rehash,一般是由于较多缓存节点不可用。
- 缓存支持 rehash 时,则大多跟流量洪峰有关。流量洪峰到达,引发部分缓存节点过载 Crash,然后因 rehash 扩散到其他缓存节点,最终整个缓存体系异常。
- 采用 rehash 策略,即把异常节点请求平均分散到其他缓存节点。在一般情况下,一致性 Hash 分布+rehash 策略可以很好得运行。
解决方案:
- 每个Key的过期时间都加上一个随机值
- 如果Redis是集群部署,将热点数据均匀分布在不同的Redis库中也能避免全部失效的问题
- 给业务添加多级缓存 (浏览器缓存,反向代理服务器nignx,jvm内部建立本地缓存等等多个层面)
- 给缓存业务添加降级限流策略(当redis有问题了,快速降级,比如服务失败,拒绝服务
缓存击穿跟缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。
缓存数据不一致以及并发竞争问题
数据不一致
不一致的问题大多跟缓存更新异常有关。
造成原因:
- 更新 DB 后,写缓存失败,从而导致缓存中存的是老数据。
- 缓存有多个副本时,更新某个副本失败,也会导致这个副本的数据是老数据。
- 系统采用一致性 Hash 分布,同时采用 rehash 自动漂移策略,在节点多次上下线之后,也会产生脏数据。 解决方案:
- cache 更新失败后,先重试,如果重试失败,则将失败的 key 写入队列机服务,待缓存访问恢复后,将这些 key 从缓存删除。这些 key 在再次被查询时,重新从 DB 加载,从而保证数据的一致性。
- 不采用 rehash 漂移策略,而采用缓存分层策略,尽量避免脏数据产生。
- 缓存时间适当调短,让缓存数据及早过期后,然后从 DB 重新加载,确保数据的最终一致性。
数据并发竞争
在高并发访问场景,一旦缓存访问没有找到数据,大量请求就会并发查询 DB,导致 DB 压力大增的现象。
造成原因:主要是由于多个进程/线程中,有大量并发请求获取相同的数据,而这个数据 key 因为正好过期、被剔除等各种原因在缓存中不存在,这些进程/线程之间没有任何协调,然后一起并发查询 DB,请求那个相同的 key,最终导致 DB 压力大增。
解决方案:
- 使用全局锁,即当缓存请求 miss 后,先尝试加全局锁,只有加全局锁成功的线程,才可以到 DB 去加载数据。其他进程/线程在读取缓存数据 miss 时,如果发现这个 key 有全局锁,就进行等待,待之前的线程将数据从 DB 回种到缓存后,再从缓存获取。
- 对缓存数据保持多个备份,即便其中一个备份中的数据过期或被剔除了,还可以访问其他备份,从而减少数据并发竞争的情况。
Hot Key和Big Key
Hot key
造成原因:突发热门事件发生时,超大量的请求访问热点事件对应的 key
解决方案:
- 先找到热 key
- 对于计划或是提前已知的事情,可以提前评估出可能的热 key
- 突发事件,可以通过 Spark,对应流任务进行实时分析,及时发现新发布的热点 key。
- 对于之前的事情,逐步发酵成为热 key 的,可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。
- 这些热 key 分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的 hotkey,这样就可以把热 key 的请求打散,避免一个缓存节点过载
- key 的名字不变,对缓存提前进行多副本+多级结合的缓存架构设计
- 如果热 key 较多,还可以通过监控体系对缓存的 SLA 实时监控,通过快速扩容来减少热 key 的冲击
- 业务端还可以使用本地缓存,将这些热 key 记录在本地缓存,来减少对远程缓存的冲击
Big key
在缓存访问时,部分 Key 的 Value 过大,读写、加载易超时的现象。
造成这些大 key 慢查询的原因:
- 大 key 很少时,存 Mc,对应的 slab 较少,导致很容易被频繁剔除,DB 反复加载。
- 大 key 很多,且被大量访问时,缓存组件的网卡、带宽很容易被打满。
- 大 key 缓存的字段较多,每个字段的变更都会引发对这个缓存数据的变更,同时这些 key 也会被频繁地读取,读写相互影响,也会导致慢查现象。
- 大 key 一旦被缓存淘汰,DB 加载可能需要花费很多时间。
解决方案:
- 存在 Mc 中,可以设计一个缓存阀值,当 value 的长度超过阀值,则对内容启用压缩;其次评估大 key 所占的比例,在 Mc 启动之初,让 Mc 预先分配足够多的 trunk size 较大的 slab。确保后面系统运行时,大 key 有足够的空间来进行缓存。
- 存在 Redis 中,让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入。
- 将大 key 分拆为多个 key。对这些大 key 设置较长的过期时间,比如缓存内部在淘汰 key 时,同等条件下,尽量不淘汰这些大 key。
Redist网络模型
用户空间 内核空间
Linux系统中一个进程使用的内存分为两部分:内核空间 用户空间
用户空间:不能直接调用系统资源,必须通过内核提供的接口来访问
内核空间:调用一切系统资源
Linux为提高IO效率,会在用户空间 内核空间都加入缓冲区:
写数据时, 把用户缓冲数据拷贝到内核缓冲区 再写入设备
读数据时, 从设备读数据到内核缓冲区 再拷贝到用户缓冲区
网络模型-阻塞IO
顾名思义,就是两个阶段都必须阻塞等待。
阶段一:用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,此时用户进程也处于阻塞状态。即应用程序等待内核操作硬件拿到数据。
阶段二:内核加载出数据后,将数据拷贝到内核缓冲区,代表已就绪;将内核数据拷贝到用户缓冲区;拷贝过程中,用户进程依然阻塞等待。
网络模型-非阻塞IO
第一个阶段直接返回 不阻塞,但会一直重试 CPU空转 ,第二个阶段阻塞
阶段二:
- 将内核数据拷贝到用户缓冲区
- 拷贝过程中,用户进程依然阻塞等待拷贝完成,用户进程解除阻塞,处理数据
网络模型-IO多路复用
无论是阻塞IO还是非阻塞IO,都不能充分发挥CPU的作用。
I/O多路复用是指利用单个线程来同时监听多个FD(文件描述符,Linux中 视频硬件 socket都是文件),并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
监听FD的方式、通知的方式又有多种实现,常见的有: select、poll、epoll
其中select和pool相当于是当被监听的数据准备好之后,他会把你监听的FD整个数据都发给你,你需要到整个FD中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好。
select模式存在的三个问题:
- 能监听的FD最大不超过1024
- 每次select都需要把所有要监听的FD都拷贝到内核空间
- 每次都要遍历所有FD来判断就绪状态
poll模式的问题:
- poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
epoll模式中如何解决这些问题的?
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
- 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
- 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD, 因此性能不会随监听的FD数量增多而下降。
它提供了三个函数:
- eventpoll的函数,包含红黑树(记录要监听的FD) 以及链表(记录已经就绪的FD)(内核空间)
- epoll_ctl,添加要监听的数据到红黑树上,关联回调函数,就是把就绪的 FD 数据添加到 list_head 中去。(内核空间)
- epoll_wait函数,在用户态创建一个空的events数组,接收就绪的 FD。当调用这个函数的时候,会去检查list_head是否为空,当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等。如果不为空,将数据放入到events数组中,并且返回对应的操作数量。用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。(用户空间)
目前的I/O多路复用都是采用的 epoll 模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的 FD 写入用户空间,不需要挨个遍历 FD 来判断是否就绪,提升了性能。
网络模型-信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
网络模型-异步IO
不仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞
他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
五种网络模型对比
阻塞IO与非阻塞IO的区别在第一阶段(用户程序等待内核操作硬盘拿到数据)中是否阻塞。
除了异步IO在第二阶段(数据拷贝)是非阻塞的,其他网络模型在第二阶段都是阻塞的。
Redis网路模型
其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;
在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程,网络IO 是限制效率的主要点。
Redis是单线程的吗?
- 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
- 如果是聊整个Redis,那么答案就是多线程
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
- Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
- Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率
因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。
为什么使用单线程
- 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
- 多线程会导致过多的上下文切换,带来不必要的开销
- 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣
缓存设计架构的常见考量点
读写方式
是全部整体读写,还是只部分读写及变更?是否需要内部计算?比如,获取粉丝列表肯定不能采用整体读写的方式,只能部分获取。另外在判断某用户是否关注了另外一个用户时,也不需要拉取该用户的全部关注列表,直接在关注列表上进行检查判断,然后返回 True/False 或 0/1 的方式更为高效。
KV size
不同业务数据缓存 KV 的 size。如果单个业务的 KV size 过大,需要分拆成多个 KV 来缓存。但是,不同缓存数据的 KV size 如果差异过大,也不能缓存在一起,避免缓存效率的低下和相互影响。
key 的数量
如果 key 数量不大,可以在缓存中存下全量数据,把缓存当 DB 存储来用。如果数据量巨大,则在缓存中尽可能只保留频繁访问的热数据,对于冷数据直接访问 DB。
读写峰值
对缓存数据的读写峰值,如果小于 10万 级别,简单分拆到独立 Cache 池即可。而一旦数据的读写峰值超过 10万 甚至到达 100万 级的QPS,则需要对 Cache 进行分层处理,可以同时使用 Local-Cache 配合远程 cache,甚至远程缓存内部继续分层叠加分池进行处理。
命中率
对于核心高并发访问的业务,需要预留足够的容量,确保核心业务缓存维持较高的命中率。为了持续保持缓存的命中率,缓存体系需要持续监控,及时进行故障处理或故障转移。同时在部分缓存节点异常、命中率下降时,故障转移方案,需要考虑是采用一致性 Hash 分布的访问漂移策略,还是采用数据多层备份策略。
过期策略
- 可以设置较短的过期时间,让冷 key 自动过期;
- 也可以让 key 带上时间戳,同时设置较长的过期时间,比如 key_20190801。