视频可看:实战篇-商户查询缓存-04.缓存更新策略_哔哩哔哩_bilibili
一、为什么用缓存?速度快
在我们查询信息时,如果直接查询数据库,速度肯定慢。
可以在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据。
二、缓存更新策略
1. 缓存更新的目的:节约内存、保证一致性
因为内存数据宝贵,当Redis插入太多数据,导致缓存中数据过多,所以Redis会对部分数据进行更新,或者把它成为淘汰更合适。
缓存数据来自数据库,而数据库的数据会发生变化。因此,如果当数据库中数据发生变化,而缓存却没有同步更新,此时就会有一致性问题存在,其后果是用户使用缓存中的过时数据,就会产生类似多线程数据安全问题。
2. 缓存更新的三种策略:
内存淘汰:由Redis自动进行,当Redis内存大于我们设定的max-memory时,自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
超时剔除:给Redis设置过期时间TTL,Redis会将超时的数据进行删除,方便我们继续使用缓存
主动更新:手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题
前两种方式缓存与数据库的一致性不好,第三种也不能完全保证。
业务场景选择
低一致性需求:使用内存淘汰机制,最多加个超时剔除(很长一段时间都不需要更新的)
高一致性需求:主动更新,并以超时剔除作为兜底方案
3. 主动更新策略
难点在于:解决数据库和缓存的不一致问题
Cache Aside Pattern 人工编码方式:缓存调用者同时更新数据库和缓存(实际是先更新数据库,再更新缓存),也称之为双写方案
Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了
4. Cache Aside Pattern
4.1 删除缓存还是更新缓存?
更新缓存:每次更新数据库都需要更新缓存,无效写操作较多(更新了但没有查询,造成浪费)
删除缓存:更新数据库时让缓存失效,再次查询时更新缓存
4.2 如何保证缓存与数据库的操作同时成功或失败?
单体系统:将缓存与数据库操作放在同一个事务
分布式系统:利用TCC等分布式事务方案
4.3 先更新缓存还是先更新数据库?
关键在于:线程安全问题
-
先删除缓存,再操作数据库
删除缓存的操作很快,但是更新数据库的操作相对较慢。如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题。
假设更新前缓存和数据库中的值为10,要将其更新为20。
左侧正常情况:最终数据库和缓存保持一致,都为20
右侧异常情况:缓存为10,数据库为20,不一致
由于删除缓存的操作很快,但是更新数据库的操作相对较慢。并且线程2是查询和写缓存操作,速度也较快。这种异常情况发生的概率还是很高的。
-
先操作数据库,再删除缓存
线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题。
假设更新前缓存和数据库中的值为10,要将其更新为20。
左侧正常情况:最终数据库和缓存保持一致,都为20
右侧异常情况:缓存为10,数据库为20,不一致
这种异常情况发生概率不高,因为需要线程1查询时,缓存恰好失效(这样才能查不到缓存去查数据库),而查到数据库后写缓存的速度非常快,微秒级别。同时在这个微秒级别中,有线程2来更新数据库,而更新数据库很慢。可能性很低。
所以先更新数据库,再删除缓存更安全。就算发生这种情况,可以加一个超时剔除
总结:
在高一致性需求下:主动更新,以超时剔除作为兜底方案
读操作(查询):
缓存命中则直接返回
缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作(更新):
先写数据库,然后再删除缓存
要确保数据库与缓存操作的原子性
三、缓存三大热点问题
1. 缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让redis缓存),会频繁的去访问数据库。
当我们客户端访问不存在的数据时,会先请求redis,但是此时redis中也没有数据,就会直接访问数据库,但是数据库里也没有数据,也就是说这个数据穿透了缓存,直击数据库。
但是数据库能承载的并发不如redis这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库,搞垮数据库。
常见的结局方案有两种
1. 缓存空对象:从数据库返回一个null到缓存
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致
简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个数据存到redis中去(额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库,但也会导致可能缓存了很多空对象(垃圾)。解决方法是给空对象加一个比较短的TTL,但又存在短期不一致。
可能造成的短期的不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据。不过这种情况很少见,可以通过设置合理的较短的TTL来规避,可以接受。或在数据库更新后,主动插入缓存实现覆盖。
2. 布隆过滤:在客户端和redis中间添加布隆过滤器,判断数据是否存在
优点:内存占用少,没有多余的key
缺点:实现复杂(不过redis中提供了map简化),可能存在误判(哈希冲突)
布隆过滤器采用的是哈希思想,通过一个庞大的二进制数组,根据哈希思想去判断当前这个要查询的数据是否存在。判断数据是否存在并不是真的将数据存在布隆过滤器中,而是将数据库的数据基于某种哈希算法计算出哈希值,将哈希值转为二进制位,保存到布隆过滤器中。判断数据是否存在即判断对应位置是0还是1。
如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库里一定会存在这个数据,从数据库中查询到数据之后,再将其放到redis中。如果布隆过滤器判断这个数据不存在,则直接返回。
这种思想的优点在于节约内存空间,但存在与否是一种概率统计,并不是百分百准确的,所以会造成误判,原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希冲突。不存在是真的不存在,存在却并不一定真的存在,仍然存在一定的缓存穿透风险。
3. 还可以增加查询字段的复杂度,避免被猜到规律(雪花算法)
4. 做好数据的格式验证
5. 做好热点参数的限流
6. 加强用户权限校验
2. 缓存雪崩
缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机(可以认为是所有key失效),导致大量请求到达数据库,带来巨大压力。
常见的解决方法:
给不同的Key的TTL添加随机值,让其在不同时间段分批失效
利用Redis集群提高服务的可用性(redis宕机:使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
给缓存业务添加降级限流策略:事先做容错处理,服务降级(快速失败),牺牲部分服务,保证数据库的健康
给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax查询数据)时,访问服务端;请求到达Nginx后,优先读取Nginx本地缓存;如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat);如果Redis查询未命中,则查询Tomcat;请求进入Tomcat后,优先查询JVM进程缓存;如果JVM进程缓存未命中,则查询数据库)
3. 缓存击穿
缓存击穿也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击。
例如:一件秒杀中的商品的key突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿。
假设线程1在查询缓存之后未命中,本来应该去查询数据库,重建缓存数据,完成这些之后,其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时(缓存重建复杂,耗时长),又进来了线程2、3、4同时来访问当前方法,那么这些线程都不能从缓存中查询到数据,那么他们就会在同一时刻访问数据库,执行SQL语句查询,对数据库访问压力过大。
常见的解决方案有两种
1. 互斥锁
利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,造成了等待。可以采用tryLock方法+double check来解决这个问题。
线程1在操作的时候,拿着锁把房门锁上了,那么线程2、3、4就不能都进来操作数据库,只有1操作完了,把房门打开了,此时缓存数据也重建好了,线程2、3、4直接从redis中就可以查询到数据。
2. 逻辑过期
之所以会出现缓存击穿问题,是因为我们对key设置了TTL。如果我们不设置TTL,那么这个key就不会过期,就不会在redis中未命中,不会重建,也就不会有缓存击穿问题。但是不设置TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案(不是真的过期失效,只是需要更新)。
即在redis的value中,设置一个键值对(expire),作为逻辑上的过期时间(不是TTL)。因为没有设置TTL,所以这个key永不过期。可以在热点过去后再移除。这个过期时间并不会直接作用于Redis,而是我们后续通过逻辑去处理。如果逻辑上过期了,说明这个key比较旧了,可能需要更新,进行缓存重建。
假设线程1去查询缓存,从value中判断当前数据已经过期,此时线程1去获得互斥锁,那么其他线程会进行阻塞。线程1开启一个新线程(线程2)去进行重建缓存数据的逻辑,直到线程2完成重建之后,才会释放锁,而线程1直接进行返回。假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据(线程4)。
这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据。
互斥锁:由于保证了互斥性,所以数据一致,且实现简单,只是加了一把锁而已,也没有其他的事情需要操心,所以没有额外的内存消耗。缺点在于有锁的情况,就可能死锁,所以只能串行执行,性能会受到影响
逻辑过期:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构缓存数据,但是在重构数据完成之前,其他线程只能返回脏数据,且实现起来比较麻烦
互斥锁选择了一致性,逻辑过期选择了可用性。