缓存
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于高速存储媒介上。
缓存的本质就是用空间换时间,牺牲数据的实时性,以服务器内存中的数据暂时代替从数据库读取最新的数据,减少数据库IO,减轻服务器压力,减少网络延迟,加快页面打开速度。
缓存的优点及作用
降低后端负载,提高读写效率,降低响应时间。
缓存分类
浏览器缓存
主要是存在于浏览器端的缓存
应用层缓存
使用在代码层面的Map、List、Set等进行存储,实现对数据、页面、图片等资源的缓存
数据库缓存
早期的数据库,如Oracle、MySQL、SQL server等,数据都是存放在磁盘。虽然数据库层也有对应的缓存(例如,MySQL增改查数据都会先加载到数据库中的一片空间 buffer pool),但这种缓存一般针对的是查询内容,而且粒度太小,一般只有表中数据没有变更的时候,数据库对应的缓存才发挥作用。但这并不能减少业务系统对数据库产生的增、删、查、改的庞大IO压力。
redis、mamcached、mongodb是比较常见的缓存数据库,把经常需要从数据库查询的数据、或经常更新的数据放入到缓存中,这样下次查询时,直接从缓存直接返回,减轻数据库压力。但这些缓存数据库大多在系统关机后,数据就会丢失,所以大量的数据仍需存在早期的数据库中。
CPU缓存
当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存,介于CPU和内存之间。
服务器缓存
包括代理服务器缓存和CDN缓存
代理服务器缓存:代理服务器是浏览器和源服务器之间的中间服务器,浏览器先向这个中间服务器发起Web请求,经过处理后(比如权限验证,缓存匹配等),再将请求转发到源服务器。
代理服务器缓存的运作原理跟浏览器的运作原理差不多,只是规模更大。可以把它理解为一个共享缓存,不只为一个用户服务,一般为大量用户提供服务,因此在减少响应时间和带宽使用方面很有效,同一个副本会被重用多次。
CDN缓存:也叫网关缓存、反向代理缓存。CDN缓存一般是由网站管理员自己部署,为了让他们的网站更容易扩展并获得更好的性能。
浏览器先向CDN网关发起Web请求,网关服务器后面对应着一台或多台负载均衡源服务器,会根据它们的负载请求,动态将请求转发到合适的源服务器上。
虽然这种架构负载均衡源服务器之间的缓存没法共享,但却拥有更好的处扩展性。从浏览器角度来看,整个CDN就是一个源服务器。
缓存思路
基于Redis的缓存
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以需要淘汰掉部分过期的数据。
另外缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。
策略选择
低一致性要求——内存淘汰或过期淘汰
高一致性要求——主动更新为主、过期淘汰兜底
内存淘汰
redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
超时剔除
当我们给redis设置了过期时间ttl
之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
主动更新
我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
-
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案。
-
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理。
-
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致。
Cache Aside
缓存调用者在更新数据库的同时,完成对缓存的更新
一致性良好、实现难度一般
删除/更新缓存
更新缓存:每次更新数据库都更新缓存,会产生无效更新,并且存在较大的线程安全问题。
如果是先更新数据库,再更新缓存。理论上这种方式比先更新数据库再删缓存有着更高的读性能,因为它事先准备好数据。但由于要更新数据库和缓存两块数据,所以它的写性能就比较低,而且关键在于它也会出现脏数据,如下图,两个并发更新操作,分别出现一前一后写数据库、一后一前写缓存,则最终缓存的数据是二者中前一次写入的数据,不是最新的。
如果是先更新缓存,再更新数据库。这种方案见Write Back策略。
删除缓存:更新数据库时让缓存失效,查询时再更新缓存。本质是延迟更新,没有无效更新,线程安全问题相对较低。
先操作数据库/先删除缓存
先操作数据库,再删除缓存:在满足事务原子性的情况下,安全问题概率较低.
写操作先更新数据库,更新成功后使缓存失效。读操作先读缓存,缓存中读到了则直接返回,缓存中读不到再读数据库,之后再将数据库数据加载到缓存中。不过这同样存在问题:查询操作未命中缓存,接着读数据库老数据之后、写缓存之前,此时另一个用户发起了更新操作更新了数据库并清了缓存,接着查询操作将数据库中老数据更新到缓存。这就导致缓存中数据变成脏数据,并且会一直脏下去直到缓存过期或发起新的更新操作。不过这个情况出现的情况比较低,其需要具备以下情况:
- 读操作读缓存失效
- 有个并发的写操作
- 写操作比读操作更快
- 读操作早于写操作进入数据库,晚于写操作更新缓存
实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。并且即使出现这个问题还有一个缓存过期时间来自动兜底。
先删除缓存,再操作数据库:安全问题概率较低。
假设有两个并发操作,一个操作更新、另一个操作查询,更新操作删除缓存后还没来得及更新数据库,此时另一个用户发起了查询操作,它因没有命中缓存进而从数据库读,此时第一个操作还没到更新数据库的阶段,读取到的是老数据,接着写到缓存中,导致缓存中数据变成脏数据,并且会一直脏下去直到缓存过期或发起新的更新操作。
如何保证数据库与缓存操作原子性?
单体系统:利用事务机制 (比如方法体上添加注解 @Transactional
)
分布式系统:利用分布式事务机制
Read/With Through
缓存与数据库集成为一个服务,服务保证两者的一致性,对外暴露API接口,调用者调用API,无需知道操作的是缓存还是数据库,不关心一致性
一致性良好、实现复杂、性能一般
Write Back
缓存调用者的CRUD都针对缓存完成,由独立的异步线程将缓存数据写入数据库,实现最终一致。
一致性差、性能良好、实现复杂
缓存穿透
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
缓存空对象
当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
布隆过滤
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,假设布隆过滤器判断这个数据不存在,则直接返回。
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。
缓存雪崩
缓存雪崩是指在同一时段大量的缓存失效或者Redis等缓存服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值,避免了因为采用相同的过期时间导致的缓存雪崩
- 利用Redis集群提高服务的可用性,可以使用主从架构的集群提高服务的可用性,万一出现宕机可以使用从节点顶上,避免因为一台Redis缓存服务宕机影响整个业务,提高Redis的容灾性
- 给缓存业务添加降级限流策略,当redis发生故障的时候可以直接拒绝服务而不是继续访问数据库
- 给业务添加多级缓存,在浏览器、nginx、redis、jvm、数据库等一层层的添加缓存
- 使用熔断机制。当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
互斥锁
如果发生缓存击穿后,第一个请求查询数据库中该数据的时候,使用一个锁锁住,后续的所有请求在锁未放开之前访问这个数据就让它休眠一会重新查询缓存。缺点就是在第一个线程写缓存期间,其他访问该数据的线程拿不到锁就只能处于等待状态,影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
优点:没有额外的内存消耗;保证了缓存数据库的一致性;实现比较简单
缺点:线程需要等待,性能受影响;可能有死锁风险。
逻辑过期
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。
假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个新开的线程2去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回过期的数据,只有等到新开的线程2把重建数据构建完后,其他线程才能返回更新的数据。
优点:线程无需等待
缺点:不能保证缓存数据库一致性;有额外的内存消耗;实现比较复杂。