1. 什么是缓存?
缓存(Cache)一般指的是一种具备高效读写能力的暂存区域,目的是让数据更接近用户,使得用户的访问速度更快或者访问成本更低。缓存的工作原理是先从缓存中获取数据,如果有数据则直接返回给用户,如果没有数据就从更远的设备上读取数据返回。
缓存在不同的场景下有不同的应用,例如:
- 客户端缓存:指的是存放在客户端的缓存,例如在秒杀活动之前提前讲一些素材缓存在客户端,以此来降低活动开启瞬间对服务器的请求压力,来保证服务器不崩
- 本地缓存:本地缓存又有磁盘缓存(读缓存,写缓存),CPU缓存
- 磁盘读缓存:在操作系统读取文件数据之后,在内存空闲的情况下可以将一部分数据保留在内存中,下次软件或者用户读取同一文件时就不必从磁盘读取,可以直接从内存读取,从而提高读取速度。
- 磁盘写缓存:将要写入磁盘的数据先写入内存中,达到相应阈值之后一起写入磁盘中
- CPU缓存:指的是CPU需要读取数据时,先从L1 Cache查找,再从L2/L3 Cache中查找,然后是内存和磁盘,越靠后的数据读取成本越高。
- Redis缓存:例如在高并发访问数据库的时候,为了降低数据库的压力,往往会使用Redis存储高热度的数据,数据访问时会让请求先到Redis,对于访问不到Redis的数据才会访问数据库,这么做可以大大降低数据库压力。
本文重点讲的是高并发下的缓存架构可能遇到的问题,主要以Redis作为缓存来降低数据库负载的场景为例。
2. 缓存更新策略
Redis常见的缓存更新模式有三种,分别为
- Cache Aside Pattern 旁路缓存模式
- Read/Write Through Pattern 读写穿透模式
- Write Behind Pattern 异步缓存写入模式
2.1 Cache Aside Pattern(旁路缓存模式)
旁路缓存模式适合读请求比较多的场景,服务端需要同时维持db和cache,并且以db结果为准。
写请求步骤:
读请求步骤:
为什么是删除缓存而不是更新缓存呢?
- 删除相比于更新的成本更低,如果有相同数据频繁写入,就会导致同一条数据被频繁更新,会浪费缓存服务器的资源
- 分布式场景下,更新缓存更容易导致数据不一致,例如有两条请求1、2先后写入同一条数据,按以下顺序就会导致db和缓存中的值不同
- 请求1更新db
- 请求2更新db
- 请求2更新缓存
- 请求1更新缓存
写请求时时先更新数据还是先删缓存?
由于两个步骤分开执行,在分布式场景下都存在数据不一致的风险
- 若先更新数据库,可能出现在更新完数据库->删除缓存这期间有其他请求读取了缓存中的数据,导致读取到的数据和db中的数据不一致,其风险时间与更新缓存的时间有关
- 若先删除缓存,可能出现在删除缓存->更新完数据库这期间有其他请求读取了数据库中的旧数据,也会引发一致性问题,风险时间与更新db的时间有关
两者相比较,更新缓存的效率要明显高于更新db的效率,因此先更新数据库可以有效降低数据不一致的风险
旁路缓存模式是比较常用的读写模式,其中有两个主要的缺点
- 首次请求的数据一定不在Cache中,同时写操作频繁的话会导致Cache会不断被删除,影响性能
- 由于数据更新操作并不是原子操作,存在数据不一致的可能,这点可以通过加上分布式锁解决,这么做同样会影响性能,使用场景下如果允许短暂的数据不一致,可以给缓存加上过期时间。
2.2 Read/Write Through Pattern 读写穿透模式
读写穿透模式中将Cache作为主要存储,从中读取数据并且写入,cache负责将数据读取和写入db。
更新数据步骤:
读取数据步骤
2.3 Write Behind Pattern 异步缓存写入模式
异步缓存写入模式和读写穿透模式基本相同,最大的区别在于缓存更新db时不采用同步的方式,而是异步批量的更新db,这么做会带来额外的数据一致性问题(比如缓存存了一批数据之后挂了),但能够提高写入性能,适合一些不要求数据强一致性的场景。
3. 缓存问题
俗话说理想很丰满,现实很骨感。虽说缓存的工作原理很简单,听上去可行性也很高,但是在具体实施的时候也会遇到各种各样的问题。
3.1 缓存穿透
缓存穿透指的是请求中包含了大量缓存和数据库中都没有的数据,由于命中不了缓存的数据最终都会请求数据库,因此对于这些不存在数据的请求,缓存不仅无法降低数据库的压力,反而会因为多访问了一层缓存而造成一些额外的开销,当这类请求的占比过高时,缓存的存在甚至可能成为一种负担。
针对缓存穿透的情况,一般也有两种解法:缓存空对象与布隆过滤器拦截
3.1.1 缓存空对象
所谓缓存空对象,指的是在缓存没有命中时,对访问的key设空值(key, null)。这么做可以行之有效的过滤掉大部分重复的无效请求(例如有大量请求访问了-1, 在第一次查询到-1没有值之后就能通过缓存返回值)。同样的,如果无效的请求分散并且key各有各的不同的话,这么做不仅不能有效的过滤掉无效数据,反而会增加缓存服务的内存压力(显然存储空值也需要内存)。
3.1.2 布隆过滤器拦截
如果我们能够高效的判断出请求的key是否在数据库中存在,那缓存穿透的问题就能迎刃而解,若不考虑时间与空间的复杂度,能够使用一个set来存储所有的key,是最容易想到的做法。在大数据量级下,布隆过滤器专门为解决这类问题而生。
布隆过滤器是一种空间效率很高的随机数据结构,专门用来检测集合中是否存在特定的数据结构,其最主要的特性有两个。
- 判断key是否存在于集合当中,有一定的误判率(视数据量和内存大小而定),但能够保证布隆过滤器认为不在的一定不在集合中,认为在集合中的不一定存在。
- 极高的空间利用率和插入效率,相比于直接使用Set节省90%以上的空间,同时变更是基于hash函数的随机插入,复杂度为O(1), 也要优于红黑树的O(logn)
因此在请求访问缓存之前,先通过布隆过滤器对请求加以拦截,可以有效缓解缓存穿透的问题。
3.2 缓存击穿
缓存击穿的出现有三个前提条件
- 有大量请求集中访问单个热点key
- 更新缓存所需的时间或者成本比较高,可能涉及到复杂计算或者依赖多路IO
- 所有缓存数据自带过期时间
在这种前提条件下,大量请求访问的热点key失效的瞬间,所有缓存服务器就会重新更新缓存数据,同时大量请求透过缓存直接访问数据库,由于缓存重建(更新)时间较长,在这期间数据库的压力就会很大,形成缓存穿透。
针对缓存击穿的解决方案主要有两种
- 使用分布式锁:即同一时间只允许一个缓存服务器进行更新(重建),这么做可以保证大多数缓存服务器包含热点数据,但这么做会影响更新性能,降低吞吐。
- 调整过期时间的策略,对于热点数据设置永不过期,在后台异步更新缓存,这么做会导致数据不一致,代码复杂度也会上升
3.3 缓存雪崩
缓存雪崩是缓存击穿的衍生,当大量的热点数据集中在一段时间内失效,就会发生大量的缓存击穿,使得所有查询落在数据库上,造成数据库压力骤升,从而可能出现崩溃等情况。
缓存雪崩的解决方式可以分为预防措施和事后补救
预防措施
- 在key的过期时间后加上一个随机数,让所有数据的key过期的时间更加平缓,防止一段时间内大量key过期
- 热点数据不过期,后台异步更新,这也是防止缓存击穿的手段
- 做二级缓存,不同级别的缓存超时时间不同,在缓存雪崩后也有下一层缓存兜底
- 构建缓存的高可用集群,防止缓存服务本身的故障
事后补救
- 缓存失效后,考虑采用服务熔断、限流、降级等措施保障数据库的安全