Redis项目实战:记录与思考

前言

项目来源于黑马点评demo,主要内容有:

  • 基础的缓存实践,包括短信验证码存取,商户信息存取与同步
    • 短信验证码存取
    • 商户信息存取与同步
  • 缓存时的难题:
    • 缓存穿透问题
    • 缓存雪崩问题
    • 缓存击穿问题
  • 进阶缓存实践
    • 优惠券秒杀
    • 秒杀业务优化
    • Redis的Stream消息队列
    • Feed流(投喂)

正文

1 基础的缓存实践

包括短信验证码存取,商户信息存取与同步

1.1 短信验证码存取

  • 注意点1——用户隐私保护:利用随机生成的token代替手机号作为存储的key,该token通过JSON返回前端,并由前端代码缓存在sessionStorage[1]里,在发起请求时,前端通过拦截器取出token并放入请求
  • 注意点2——token有效期刷新:token在用户登陆成功后的每一次请求下,都应重新刷新其TTL,最好的实践是拆分为两种拦截器,一种进行全面拦截并做token刷新,另一种进行精准拦截并做业务逻辑

1.2 商户信息存取与同步

  • 可参考我的另一篇博客[2],里面有针对多级缓存同步问题的总结

2 缓存时的难题

2.1 缓存穿透问题

  • 形成原因:用户请求不存在记录时,Redis会一直放行,数据库存在被高并发攻击的风险
    • 解决方案:
      • 缓存空值:当数据库返回null时,Redis缓存一条对应的null记录,阻断对应请求的接连访问。该方案会带来额外的内存空间消耗,以及短期的数据不一致
      • 布隆过滤器:通过多个哈希函数将数据库记录映射到一个长字节数组上的对应位置,请求进来后判定对应位置是否全为1,如果存在0,则记录一定不存在,直接拒绝请求,更为详细的解读可参考[3]。该方案无法精确拒绝所有记录为null的请求,因此仍有缓存穿透的可能

2.2 缓存雪崩问题

  • 形成原因:同时间大批缓存key失效Redis宕机,数据库直面浏览器请求
    • 解决方案:
      • 为TTL值增加一定的随机性——解决大批缓存key失效
      • 建立Redis集群/建立Sentinel流控/添加多级缓存——缓解Redis宕机,增强可用性

2.3 缓存击穿问题

  • 形成原因:一个被高并发访问并且缓存重建业务较复杂的key突然失效了,数据库存在被高并发攻击的风险
    • 解决方案:
      • 互斥锁:在线程访问缓存key时,如果未命中,尝试获取互斥锁,如果获取成功,查询数据库并写入缓存,释放锁;否则重新尝试访问缓存key,直到访问成功。该方案会导致多个并发线程阻塞,性能大受影响
      • 逻辑过期:在线程访问缓存key时,如果发现key已逻辑过期(即当前时间超过记录的过期时间字段),尝试获取互斥锁,如果获取成功,新建线程进行数据库查询与缓存写入工作,当前线程直接返回逻辑过期的记录;如果获取失败,不新建线程而直接返回逻辑过期的记录。该方案会导致返回数据与数据库的短期不一致

PS:由于缓存穿透要求存储时使用TTL开启定时过期,而缓存击穿则要求存储时使用逻辑过期,因此两者在原理上是有冲突的,如何在同一个Redis中同时保证二者的实现?
Q:缓存穿透和缓存击穿如何同时解决?
A:增加额外判断,由主程序控制哪些ID需要考虑缓存击穿(热点key),对这些热点key采取逻辑过期,进行缓存击穿保护(这些key是由主程序主动控制的,因此提前已确保了存在性,不担心穿透威胁),并在使用完成后从Redis中回收记录,避免永久残留;而对于另外的ID,我们则只进行缓存穿透保护

3 进阶缓存实践

3.1 优惠券秒杀

  • 注意点1——全局唯一性ID:诸如雪花算法[4],通过时间戳+机器码+递增序列号的思路,保证全局唯一ID
  • 注意点2——超卖问题:线程在读取库存逻辑和执行库存削减逻辑间有延时,高并发情形下读取的库存与削减时的真实库存不匹配,从而产生超卖问题
    • 解决思路1——悲观锁:为读取逻辑和执行削减逻辑加一个总的锁,令线程间变成串行执行
    • 解决思路2——乐观锁:有诸如版本号法,CAS法等,通过在sql语句中设立条件,只有满足对应条件时,才执行削减逻辑避免误更改。如超卖问题,我们就可以以set stock = stock -1 where stock > 0来避免超卖
    • 解决思路3——乐观锁进阶:Freeze表:这个思路来源于分布式事务Seata中的TCC模式,Freeze表不仅可以实现事务回滚,也直接避免了超卖问题的发生,因此TCC模式可以直接在分布式架构下解决超卖问题。freeze表的sql逻辑与思路2是类似的,采用set freeze = freeze -1 where freeze > 0
  • 注意点3——单人单卖限制:要实现单人单卖,最好的实践是从判断user是否买过的逻辑到执行库存衰减逻辑加锁(可以是单机锁,也可以是基于Mysql, Redis, Zookeeper等的分布式锁),实现时应注意是否需要从代理对象取得方法,以进行Spring的事务管理
  • 注意点4——Redis分布式锁:手写实现参考我另一篇文章[5]所写;还有成熟的框架Redisson
    • 解决不可重入问题——添加计数器,同一线程每多一次调用,计数器加一,每释放一次,计数器减一,当计数器减至0时删除锁记录(具体实现就不能采用String类型和SETNX,而只能采用Hash类型并通过Lua脚本实现SETNX功能)
    • 解决无法重试问题——添加重试循环,并通过发布订阅和信息量机制,在原先的锁持有者释放锁之后才采取尝试获取锁
    • 解决超时失效问题——添加看门狗定时为锁记录续约(定时时间默认为超时时间的1/3)
    • 解决主节点宕机,锁失效问题——添加联合锁,布置多台主节点,每台主节点都需要存储同一锁记录,客户端在获取锁时,必须从主节点上拿到多数锁记录,才算锁成功。原本当主节点宕机时,该主从结构配置的锁失效,就会导致当前线程锁住的业务未完成,但其他线程已经可以获取锁。而添加联合锁后,某个主节点宕机,当前线程仍可拿到多数锁记录从而维持锁,其他线程因无法拿到多数锁记录从而构建锁失败(回滚)

3.2 秒杀业务优化

  • 下单通过Redis中的Lua脚本完成,保持原子性,避免了大量锁操作,性能更高
  • 存入数据库通过异步线程完成,下单成功后,信息存入消息队列,由另外的线程异步写入数据库

3.3 Redis的Stream消息队列

  • 与RabbitMQ基本功能一致(但RabbitMQ有丰富的插件,如延时交换机,更全面的持久化与同步协议。其对比见[6]),在保证消息安全性上,仅表现出不支持生产者确认的缺点。因此,Stream基本可以满足并发量中等,消息业务较为简单时的开发需求

3.4 Feed流(投喂)

  • 拉方式:发布者将消息发送至发件箱,由订阅者在访问前一刻,从发件箱拉取至订阅者的收件箱(并排序)。优势:内存压力小,访问前才读取;劣势:写入压力大,速度慢
  • 推方式:发布者直接将消息发送至订阅者的收件箱,并提前排序。优势:提前写入,速度快;劣势:内存占用大
  • 推拉结合:将发布者与订阅者分类,发布者分为活跃发布者(大V)与普通发布者,订阅者分为活跃订阅者(经常访问某个大型发布者)与普通订阅者。对于普通发布者的消息将采用推方式发送给两类订阅者;对于活跃发布者的消息,采用推方式发送给活跃订阅者,采用拉方式发送给普通发布者。优势:结合了推拉的优势,并通过区分不同发布者和订阅者避免了劣势

PS:Spring事务失效的可能分析
可能1:直接调用内部方法,而不是利用Spring的Bean获取方法
解决方法1:将内部方法抽取为外部类,声明为Bean对象,再通过@Autoware注入
解决方法2:将当前对象在Spring中的代理对象(即Bean)获取出来

你可能感兴趣的:(Redis专栏,docker,redis,java)