总结:本文系统介绍了Redis缓存的原理、应用场景及常见问题的解决方案。缓存通过数据交换缓冲区提高读写效率,降低后端负载,但会带来数据一致性和运维成本问题。文章详细阐述了缓存穿透、雪崩和击穿三大问题的成因及解决方案:缓存穿透可通过空对象缓存或布隆过滤器解决;缓存雪崩可通过随机TTL和集群部署缓解;缓存击穿可采用互斥锁或逻辑过期策略处理。同时,文章提供了Java代码示例说明如何实现商户缓存查询和更新操作,并比较了不同缓存更新策略的优劣,为开发者提供了实用的Redis缓存实践指南。
Redis的缓存
缓存
缓存(cache)就是数据交换的缓冲区,是存贮临时数据的地方,读写性能较高。
在整个web开发阶段都可以添加缓存(多级缓存):浏览器缓存(访问那些静态资源)、应用层tomcat缓存(直接在缓存里查到数据返回给前端)、数据库缓存(mysql会给id创建索引,我们可以给索引缓存,查询时候就可以在内存里快速检索)、CPU缓存、磁盘缓存。
缓存的作用:降低后端负载,提高读写效率,降低响应时间
缓存的成本:数据一致性成本(在读取数据时虽然从缓存里面读取很快,但要是数据变更,会导致数据库的数据还是旧的),要解决一致性成本问题就会导致代码复杂度提高很多,以后维护成本提高,还有为了避免缓存雪崩等,保证缓存的高可用,缓存往往会搭建成集群模式,提高了运维成本。
添加Redis商户缓存:客户端发起请求,先在redis缓存中查找,如果未命中则往数据库查找,将其写到redis缓存中,确保下次查询能在redis缓存中找到,如果直接命中则将数据直接返回给客户端。
@Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryById(Long id){ String key = “cache:shop:”+id; //1.从redis查询商铺缓存 String shopJson= stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 If(StrUtil.isNotBlank(shopJson)){ //3.存在,直接返回 Shop shop = JSONUtil.toBean(shopJson , Shop.class); return Result.ok(shop); } //4.不存在,根据id查询数据库 Shop shop = getById(id); //5.不存在,返回错误信息 if(shop==null){ return Result.fail(“店铺不存在”); } //6.存在,写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop), timeout:30L , TimeUnit.MINUTES); //7.返回 return Result.ok(shop); }缓存更新策略:内存淘汰、超时剔除、主动更新。
低一致性需求使用内存淘汰,使用Redis自带的内存淘汰机制。
高一致性需求使用主动更新,并以超时剔除作为兜底方案。
主动更新策略:有3种,最常用的是cache aside pattern,由缓存的调用者,在更新数据库的同时更新缓存。
操作缓存和数据库时需要考虑的3个问题
1.删除缓存还是更新缓存?
删除缓存。更新数据库时删掉让缓存失效,查询时再更新缓存。
2.如何保证缓存和数据库的操作同时成功或失败?
单体系统,将缓存和数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案
3.先操作缓存还是数据库?
不论是先删除缓存再操作数据,还是先操作数据库再删除缓存(更优)都可以,而且它们都存在线程安全问题。但是先操作数据库再删除缓存出现线程安全问题的可能性更低,因为操作缓存的速度是远远大于操作数据库的,假设在查询缓存时可能缓存刚好失效未命中,会往数据库查询,查询数据库后在将数据写入缓存时,另一个线程对数据库进行了更新操作,然后删除缓存,这时候可能本来查的是旧数据,却可能查到新数据然后缓存中写入了更新后的数据,但是出现这种线程安全问题概率很低,所以先操作数据库再删除缓存出现安全问题更低。虽然这个可能性很低但是还是要解决这个安全问题的,需要加上缓存超时时间即可。
先更新再删除缓存操作步骤:
@Override @Transactional public Result update(Shop shop){ Long id = shop.getId(); if(id==null){ return Result.fail(”商铺id不能为空”); } updateById(shop); stringRedisTemplate.delete(key+id); return Result.ok(); }Redis常见缓存问题
缓存穿透
缓存穿透指客户端请求的数据在缓存和数据库中都不存在的情况,这样缓存永远都不会生效,这些请求都会打到数据库。
那有些人会恶意反复请求不存在的数据,导致数据库崩溃,常见的缓存穿透两种解决方案:1.缓存空对象2.布隆过滤
1、缓存空对象:redis缓存和数据库都为空时,请求到数据库后,将null写入缓存。
优点:实现简单,维护方便。
缺点:1.额外的内存消耗(每请求一个就写一个null),可以通过设置TTL(生存时间)来解决。2.可能造成短期的不一致(写入null后,这时传入一个有效的数据,但是TTL还没过期,就会导致读到的还是null),可以通过设置足够短的TTL来缓解或者缓存修改写入一个数据就覆盖原本数据来解决。
2、布隆过滤:在客户端和redis之间会有一个布隆过滤器,如果请求的数据存在则放行,不存在则拒绝,布隆过滤器其实算是算法,是根据哈希的计算出数据库二进制来判断请求的数据是否存在,如果是不存在那么一定不存在,如果存在,却不一定存在。优点:内存占用少,没有多余的key。
缺点:1.实现复杂,2.存在误判的可能。
所以呢,开发过程中一般使用缓存空对象来的解决缓存穿透的问题。然后这两种方案其实是属于被动方案,也就是已经穿透你了然后来想办法弥补。事实上呢,不止这两种方案,还可以采取主动方案来解决,1.可以增加id的复杂度,来避免被猜测id规律,加强做好数据的基础格式校验,2.加强用户权限校验3.做好热点参数的限流。
缓存雪崩
缓存雪崩指在同一时段大量的缓存key同时失效或者redis服务端宕机,导致大量请求到达数据库,带来巨大压力。
缓存雪崩针对key同时失效的解决方案:给不同的key的TTL添加随机值。
缓存雪崩针对redis宕机的解决方案:
1.利用redis集群提高服务的可用性
2.给缓存业务添加降级限流策略(比如当整个服务全崩了,请求是服务失败,而不是全部压到数据库)
3.给业务添加多级缓存(可以给nginx添加缓存、jvm建立缓存)
缓存击穿
缓存击穿也叫热点key,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大压力。
缓存击穿的解决方案:1.互斥锁 2.逻辑过期
1、互斥锁:一个线程查询缓存未命中,然后获取互斥锁来锁住,这时如果别的线程也要来查询就会获取锁失败从而查询失败可能需要休眠过一会重试,只有当拥有这个锁的线程在数据库查询完写入缓存后才会释放锁,然后别的线程才可以访问缓存。
互斥锁的优点:1.没有额外的内存消耗、2.保持一致性、3.实现简单
互斥锁的缺点:1.线程需要等待,性能受影响、2.可能有死锁的风险
互斥锁是需要自己定义的锁,它不同于synchronized Lock同步锁,同步锁是按规定好的执行顺序执行,比如要执行C,A和B必须要先执行完才能执行C。而互斥锁是如A访问了数据,别的就不能访问数据,必须等A访问完释放锁。
简单说就是互斥是通过竞争资源,彼此不知道对方的存在,是乱序的,而同步锁是协调多个进程合作完成任务,彼此知道对方的存在,是有序的。
互斥锁的实现:获取锁使用redis的String类型的setnx命令就可以了,原理是一样的比如setnx lock 1因为setnx就是当它设置的lock已经存在了值1,是不能改变成别的值,只有当它的值删了才能加进来,也就是释放了锁才能进来新的线程。所以自定义互斥锁可以用setnx。为了严谨还要给锁设置有效期,要是正常释放锁那就正常,要是之间服务出问题没法正常释放,如果没有有效期那锁就一直锁住是有问题的。
2、逻辑过期:一个线程查询缓存发现逻辑时间过期了,然后获取互斥锁来锁住,为了避免等待锁的过程太久,会开启新的线程由这个线程来查询数据库重建缓存数据,然后写入缓存重置过期时间,释放锁。那么原本的那个线程做什么呢?它就直接返回旧的数据。那么也就是说,这个新的线程锁住了在更新数据,这时要是再来一个线程来访问,获取锁失败,它就知道哎这个线程在更新数据,那我就佛系一点就先用查到的旧数据。
逻辑过期的优点:1.线程无需等待、性能好
逻辑过期的缺点:1.不保证一致性、2.有额外的内存消耗、3.实现复杂
逻辑过期的实现:设置逻辑过期的实现方法,假设缓存命中然后才能判断缓存是否过期,将json反序列化为对象,判断是否过期,未过期直接返回对象,过期需要缓存重建,获取互斥锁,判断是否获取锁成功,如果成功开启独立线程,实现缓存重建,释放锁,不论成功或失败都是返回过期的商铺信息。