Redis实战篇——黑马点评
Redis基础篇 续篇Redis基础篇
知识点参考博客
内容概述
- 短信登录
- 这部分会使用Redis共享session来实现
- 用Redis替换session来存储邮箱验证码
- 商户查询缓存
- 这部分要理解缓存击穿,缓存穿透,缓存雪崩等问题,对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
- 优惠券秒杀(重中之重)
- 这部分我们可以学会Redis的计数器功能,结合Lua完成高性能的Redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
- 好友关注
- 基于Set集合的关注、取消关注,共同关注等等功能,这部分在上篇的练习题中出现过,这次我们在项目中来使用一下
- 达人探店
短信登录
导入项目
导入SQL
黑马已经在资料中提供好了SQL文件,这里简单分析一下提供的表
表 | 说明 |
---|---|
tb_user | 用户表 |
tb_user_info | 用户详情表 |
tb_shop | 商户信息表 |
tb_shop_type | 商户类型表 |
tb_blog | 用户日记表(达人探店日记) |
tb_follow | 用户关注表 |
tb_voucher | 优惠券表 |
tb_voucher_order | 优惠券的订单表 |
开发模式
- 该项目采用的是前后端分离开发模式
- 手机或者app端发起请求,请求我们的Nginx服务器,Nginx基于七层模型走的是HTTP协议,可以实现基于Lua直接绕开Tomcat访问Redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游Tomcat服务器,打散流量,我们都知道一台4核8G的Tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过Nginx的负载均衡分流后,利用集群支撑起整个项目,同时Nginx在部署了前端项目后,更是可以做到动静分离,进一步降低Tomcat服务的压力,这些功能都得靠Nginx起作用,所以Nginx是整个项目中重要的一环。
- 在Tomcat支撑起并发流量后,我们如果让Tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。
导入后端项目
- 黑马已经提供好了后端项目源码压缩包,我们将其解压之后,放到自己的workspace里
- 然后修改MySQL和Reids的连接要素为自己的,随后启动项目
- 访问http://localhost:8081/shop-type/list ,如果可以看到JSON数据,则说明导入成功
导入前端工程
- 黑马已经提供好了前端项目源码压缩包,我们将其解压之后,放到自己的workspace里
- 然后在nginx所在目录打开一个cmd窗口,输入命令,即可启动项目
- 访问http://localhost:8080/ ,F12,选择手机模式,可以看到页面
基于Session实现登录流程
- 发送验证码
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码(session),同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户 - 短信验证码登录、注册
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息 - 校验登录状态
用户在请求的时候,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并放行
- 流程图
实现发送短信验证码功能
- 输入手机号,点击发送验证码按钮,查看发送的请求
请求网址: http://localhost:8080/api/user/code?phone=15832165478
请求方法: POST
- 看样子是调用UserController中的code方法,携带参数是phone,
1 | /** |
- 但是黑马这里并不会真的使用短信服务发送验证码,只是随机生成了一个验证码
- 由于黑马这里貌似没有设置前端的手机号正则判断,所以我们只需要去数据库中修改phone的字段类型,将varchar(11)改为varchar(100)
- 修改sendCode方法,逻辑如下
- 验证手机号/邮箱格式
- 不正确则返回错误信息
- 正确则发送验证码
- 验证手机号/邮箱格式
1 | /** |
- 前端输入手机号 点击发送验证码 看是否接收到了验证码
1 | com.hmdp.service.impl.UserServiceImpl : 发送验证码:381669 |
- 测试没有问题之后,我们继续来编写登录功能,点击登录按钮,查看发送的请求
请求网址: http://localhost:8080/api/user/login
请求方法: POST
黑马的代码,看样子是把电话号和验证码封装到了LoginFormDto中
1 | public class LoginFormDTO { |
- 修改login方法,逻辑如下
- 校验手机号
- 不正确则返回错误信息
- 正确则继续校验验证码
- 不一致则报错
- 一致则先根据手机号/邮箱查询用户
- 用户不存在则创建
- 存在则继续执行程序
- 保存用户信息到session中(无论之前是否存在都要保存)
- 校验手机号
1 | /** |
基于邮箱发送验证码功能(补充)
- 这部分内容是补充知识 不要按照本文顺序写代码 我这里用到redis了(redis是下一个模块解决session共享问题用到的学完那个再回头来看)
- 首先导入邮箱部分相关依赖 maven坐标如下
1 | <!--这部分是邮箱相关的依赖--> |
- 编写工具类 用于通过邮箱发送验证码
1 | public class MailUtils { |
- 如何开启QQ邮箱的SMTP服务和设置授权码
- 修改sendCode方法
1 | /** |
1 |
|
- 测试功能
- 修改login方法
1 | /** |
使用Redis中的ZSET数据结构+时间窗口思想,进行用户限流(补充)
1. ZSET数据结构
Redis的ZSET是一个字符串成员(member)与浮点数分数(score)之间的有序映射,并且成员是唯一的,但分数(score)可以重复。ZSET是根据元素的分数进行从小到大的排序。
2. 时间窗口思想
时间窗口通常用于限流算法中,它将时间划分为多个连续的、固定长度的区间(即窗口)。每个窗口内,只允许一定数量的请求通过。一旦某个窗口内的请求数量达到限制,后续的请求就会被拒绝或延迟处理。
3. 结合使用
当用户发起请求时,我们可以将用户ID和当前时间戳(或相对时间,如分钟数)作为ZSET的成员和分数。这样,每个时间窗口内,以用户ID为键的ZSET就会包含该用户在该时间窗口内的所有请求。
然后,我们可以使用ZSET的计数功能(如ZCOUNT命令)来查询某个用户在特定时间窗口内的请求数量。如果请求数量超过了设定的限流阈值,我们就拒绝或延迟处理该请求。
- 常量表示
1 | public static final String SENDCODE_SENDTIME_KEY = "sms:sendtime:"; |
- 实现代码
1 | public Result sendCode(String phone, HttpSession session) throws MessagingException { |
实现登录拦截功能
- 这部分需要用到拦截器的知识
- 创建一个LoginInterceptor类,实现HandlerInterceptor接口,重写其中的两个方法,前置拦截器和完成处理方法,前置拦截器主要用于我们登陆之前的权限校验,完成处理方法是用于处理登录后的信息,避免内存泄露
1 | public class LoginInterceptor implements HandlerInterceptor { |
- UserHolder类
1 | public class UserHolder { |
- MvcConfig 加入拦截器
1 |
|
- /me方法
1 |
|
用户数据脱敏
- 我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
- UserDto类如下,将User对象中的属性拷贝给UserDto,就可以避免暴露用户的隐藏信息
1 |
|
- 修改login方法
1 | //7. 保存用户信息到session中 |
- 修改拦截器
1 |
|
- 重启服务器,登录后查看此时的用户信息,敏感信息已经不存在了
1 | { |
session共享问题
- 每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
- 但是这种方案具有两个大问题
- 每台服务器中都有完整的一份session数据,服务器压力过大。
- session拷贝数据时,可能会出现延迟
- 所以我们后面都是基于Redis来完成,我们把session换成Redis,Redis数据本身就是共享的,就可以避免session共享的问题了
Redis替代session的业务流程
设计key结构
- 首先我们来思考一下该用什么数据结构来存储数据
- 由于存入的数据比较简单,我们可以使用String或者Hash
- 如果使用String,以JSON字符串来保存数据,会额外占用部分空间
- 如果使用Hash,则它的value中只会存储数据本身
- 如果不是特别在意内存,直接使用String就好了
设计key的具体细节
- 我们这里就采用的是简单的K-V键值对方式
- 但是对于key的处理,不能像session一样用phone或code来当做key
- 因为Redis的key是共享的,code可能会重复,phone这种敏感字段也不适合存储到Redis中
- 在设计key的时候,我们需要满足两点
- key要有唯一性
- key要方便携带
- 所以我们在后台随机生成一个token,然后让前端带着这个token就能完成我们的业务逻辑了
整体访问流程
- 当注册完成后,用户去登录,然后校验用户提交的手机号/邮箱和验证码是否一致
- 如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到Redis,并生成一个token作为Redis的key
- 当我们校验用户是否登录时,回去携带着token进行访问,从Redis中获取token对应的value,判断是否存在这个数据
基于Redis实现短信登录
- 由于前面已经分析过业务逻辑了,所以这里我们直接开始写代码,在此之前我们要在UserServiceImpl中注入RedisTemplate
1 |
|
- 修改sendCode方法
1 |
|
- 修改Login方法
1 | @Override |
解决状态登录刷新问题
初始方案
- 我们可以通过拦截器拦截到的请求,来证明用户是否在操作,如果用户没有任何操作30分钟,则token会消失,用户需要重新登录
- 通过查看请求,我们发现我们存的token在请求头里,那么我们就在拦截器里来刷新token的存活时间
authorization: 63bd917d-2c0a-49ac-9ed4-2ab73b9dc911
- 修改我们的登陆拦截器LoginInterceptor类
1 |
|
- 在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的
优化方案
- 既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
- 新建一个RefreshTokenInterceptor类,其业务逻辑与之前的LoginInterceptor类似,就算遇到用户未登录,也继续放行,交给LoginInterceptor处理
由于这个对象是我们手动在WebConfig里创建的,所以这里不能用@AutoWired自动装配,只能声明一个私有的,到了WebConfig里再自动装配
1 | public class RefreshTokenInterceptor implements HandlerInterceptor { |
- 修改我们之前的LoginInterceptor类,只需要判断用户是否存在,不存在,则拦截,存在则放行
1 | public class LoginInterceptor implements HandlerInterceptor { |
- 修改WebConfig配置类,拦截器的执行顺序可以由order来指定,如果未设置拦截路径,则默认是拦截所有路径
1 |
|
- 那么至此,大功告成,我们重启服务器,登录,然后去Redis的图形化界面查看token的ttl,如果每次切换界面之后,ttl都会重置,那么说明我们的代码没有问题
退出登录(补充)
- 这部分功能黑马的课程里没有实现 由于后期需要切换不同用户账户来测试功能 ,为了方便切换账户,我在这对这部分功能就行补充。
- 用户点击退出登录,我们需要清除ThreadLocal里存放的用户信息,这样前端发送的请求获取不到用户信息,登录拦截器从ThreadLocal中获取用户也为null,被拦截跳到登录界面。
- 还有一个问题 我们原来的token数据还没清除(redis中),那是否需要清除Redis中的token数据,这取决于你的系统设计和安全需求 ,下面是自己的一些考虑
- 安全性:如果用户已经退出登录,那么与之相关的token就不再有效。为了防止潜在的滥用(例如,攻击者尝试使用旧的token进行请求),从Redis中删除这些token是一个好的做法。
- 资源管理:Redis是一个内存数据库,存储的数据会占用内存空间。如果长时间保留不再使用的token,可能会导致内存资源的浪费。因此,从资源管理的角度来看,删除旧的token也是有益的
- 具体实现
1 | /** |
1 |
|
商户查询缓存
什么是缓存
- 什么是缓存?
- 缓存就像自行车、越野车的避震器
- 举个例子
- 越野车、山地自行车都有避震器,防止车体加速之后因惯性,在U型地形上飞跃硬着陆导致损坏,像个弹簧意义
- 同样,在实际开发中,系统也需要避震器,防止过高的数据量猛冲系统,导致其操作线程无法及时处理信息而瘫痪
- 在实际开发中,对企业来讲,产品口碑、用户评价都是致命的,所以企业非常重视缓存技术
- 缓存(Cache)就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地,例如
1 | Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); |
1 | static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); |
1 | Static final Map<K,V> map = new HashMap(); |
为什么要使用缓存
- 言简意赅:速度快,好用
- 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
- 实际开发中,企业的数据量,少则几十万,多则几千万,这么大的数据量,如果没有缓存来作为避震器系统是几乎撑不住的,所以企业会大量运用缓存技术
- 但是缓存也会增加代码复杂度和运营成本
- 缓存的作用
- 降低后端负载
- 提高读写效率,降低响应时间
- 缓存的成本
如何使用缓存
- 实际开发中,会构筑多级缓存来时系统运行速度进一步提升,例如:本地缓存与Redis中的缓存并发使用
- 浏览器缓存:主要是存在于浏览器端的缓存
- 应用层缓存:可以分为tomcat本地缓存,例如之前提到的map或者是使用Redis作为缓存
- 数据库缓存:在数据库中有一片空间是buffer pool,增改查数据都会先加载到mysql的缓存中
- CPU缓存:当代计算机最大的问题就是CPU性能提升了,但是内存读写速度没有跟上,所以为了适应当下的情况,增加了CPU的L1,L2,L3级的缓存
添加商户缓存
- 我们先启动前端和后端的项目,登陆之后随便访问一个商户,查看浏览器发送的请求
请求网址: http://localhost:8080/api/shop/10
请求方法: GET
- 在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库肯定慢
- 所以我们可以在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据
缓存模型和思路
- 标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入Redis。
代码实现
- 代码思路:如果Redis缓存里有数据,那么直接返回,如果缓存中没有,则去查询数据库,然后存入Redis
1 | /** |
1 |
|
- 重启服务器,访问商户信息,观察控制台日志输出,后续刷新页面,不会出现SQL语句查询商户信息,去Redis图形化界面中查看,可以看到缓存的商户信息数据
趁热打铁
- 完成了商户数据缓存之后,我们尝试做一下商户类型数据缓存
1 |
|
1 |
|
缓存更新策略
- 缓存更新是Redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们想Redis插入太多数据,此时就可能会导致缓存中数据过多,所以Redis会对部分数据进行更新,或者把它成为淘汰更合适
- 内存淘汰:Redis自动进行,当Redis内存大道我们设定的max-memery时,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
- 超时剔除:当我们给Redis设置了过期时间TTL之后,Redis会将超时的数据进行删除,方便我们继续使用缓存
- 主动更新:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护, 利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。 下次查询时更新缓存。 | 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存。 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
数据库和缓存不一致解决方案
- 由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是
- 用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等
- 那么如何解决这个问题呢?有如下三种方式
- Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
- Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
- Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了
数据库和缓存不一致采用什么方案
- 综上所述,在企业的实际应用中,还是方案一最可靠,但是方案一的调用者该如何处理呢?
- 如果采用方案一,假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大,所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来
- 对比删除缓存与更新缓存
- 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,再次查询时更新缓存
- 如何保证缓存与数据库的操作同时成功/同时失败 (原子性)
- 单体系统:将缓存与数据库操作放在同一个事务
- 分布式系统:利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?我们来仔细分析一下这两种方式的线程安全问题
- 先删除缓存,再操作数据库
异常情况:删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题
- 先操作数据库,再删除缓存
异常情况:线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题
- 虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以我们最终采用后者先操作数据库,再删除缓存的方案
实现商铺缓存与数据库双写一致
- 核心思路如下
- 修改ShopController中的业务逻辑,满足以下要求
- 根据id查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL
- 根据id修改店铺时,先修改数据库,再删除缓存
- 修改ShopService的queryById方法,写入缓存时设置一下TTL
1 |
|
- 修改updata方法
1 |
|
1 |
|
- 修改完毕之后我们重启服务器进行测试,首先随便挑一个顺眼的数据,我这里就是拿餐厅数据做测试,,我们先访问该餐厅,将该餐厅的数据缓存到Redis中,之后使用POSTMAN发送PUT请求,请求路径http://localhost:8080/api/shop/ ,携带JSON数据如下
1 | { |
- 之后再Redis图形化页面刷新数据,发现该餐厅的数据确实不在Redis中了,之后我们刷新网页,餐厅名会被改为476茶餐厅,然后我们再去Redis中刷新,发现新数据已经被缓存了
- 那么现在功能就实现完毕了,只有当我们刷新页面的时候,才会重新查询数据库,并将数据缓存到Redis,中途无论修改多少次,只要不刷新页面访问,Redis中都不会更新数据
缓存穿透问题的解决思路
- 缓存穿透:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库。
- 常见的结局方案有两种
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗,可能造成短期的不一致
- 布隆过滤
- 优点:内存占用少,没有多余的key
- 缺点:实现复杂,可能存在误判
- 缓存空对象
- 缓存空对象思路分析:当我们客户端访问不存在的数据时,会先请求redis,但是此时redis中也没有数据,就会直接访问数据库,但是数据库里也没有数据,那么这个数据就穿透了缓存,直击数据库。但是数据库能承载的并发不如redis这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库,简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个这个数据存在redis中去(这就是为啥说会有额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库。可能造成的短期不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据,不过这种情况很少见罢了
- 布隆过滤思路分析:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去判断当前这个要查询的数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库里一定会存在这个数据,从数据库中查询到数据之后,再将其放到redis中。如果布隆过滤器判断这个数据不存在,则直接返回。这种思想的优点在于节约内存空间,但存在误判,误判的原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希冲突。
编码解决商品查询的缓存穿透问题
缓存空对象方法
- 核心思路如下
- 在原来的逻辑中,我们如果发现这个数据在MySQL中不存在,就直接返回一个错误信息了,但是这样存在缓存穿透问题
- 现在的逻辑是:如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息
1 |
|
布隆过滤器方法
- 这里的BloomFilterFactory 是我自定义的布隆过滤器,封装成工具类,给定预期数据量 n 和误差率 p初始化(自定义的布隆过滤器文章参考)
- 这里也可以使用谷歌提供的布隆过滤器工具,导入Maven坐标,配置参数就可以用了
1 | BloomFilter myBloomFilter = BloomFilterFactory.createBloomFilter(1000,0.001); |
小结:
- 缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力
- 缓存穿透的解决方案有哪些?
- 缓存null值
- 布隆过滤
- 增强id复杂度,避免被猜测id规律(可以采用雪花算法)
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
缓存雪崩问题及解决思路
- 缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
- 解决方案
- 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
- 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax查询数据)时,访问服务端;请求到达Nginx后,优先读取Nginx本地缓存;如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat);如果Redis查询未命中,则查询Tomcat;请求进入Tomcat后,优先查询JVM进程缓存;如果JVM进程缓存未命中,则查询数据库)
缓存击穿问题及解决思路
- 缓存击穿也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击
- 举个不太恰当的例子:一件秒杀中的商品的key突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿
- 常见的解决方案有两种
- 互斥锁
- 逻辑过期
- 逻辑分析:假设线程1在查询缓存之后未命中,本来应该去查询数据库,重建缓存数据,完成这些之后,其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时,又进来了线程2、3、4同时来访问当前方法,那么这些线程都不能从缓存中查询到数据,那么他们就会在同一时刻访问数据库,执行SQL语句查询,对数据库访问压力过大
- 解决方案一:互斥锁
- 利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用tryLock方法+double check来解决这个问题
- 线程1在操作的时候,拿着锁把房门锁上了,那么线程2、3、4就不能都进来操作数据库,只有1操作完了,把房门打开了,此时缓存数据也重建好了,线程2、3、4直接从redis中就可以查询到数据。
- 解决方案二:逻辑过期方案
- 方案分析:我们之所以会出现缓存击穿问题,主要原因是在于我们对key设置了TTL,如果我们不设置TTL,那么就不会有缓存击穿问题,但是不设置TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案
- 我们之前是TTL设置在redis的value中,注意:这个过期时间并不会直接作用于Redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断当前数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成者逻辑之后,才会释放锁,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据
- 这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据
利用互斥锁解决缓存击穿问题
- 核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询
- 如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿
- 操作锁的代码
- 核心思路就是利用redis的setnx方法来表示获取锁,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁
- 在shopServiceImpl里定义两个方法
1 | /** |
1 | /** |
- 然后这里先把我们之前写的缓存穿透代码修改一下,提取成一个独立的方法
1 | /** |
1 |
|
使用Jmeter进行压测
- 我们先来模拟一下缓存击穿的情景,缓存击穿是指在某时刻,一个热点数据的TTL到期了,此时用户不能从Redis中获取热点商品数据,然后就都得去数据库里查询,造成数据库压力过大。
- 那么我们首先将Redis中的热点商品数据删除,模拟TTL到期,然后用Jmeter进行压力测试,开100个线程来访问这个没有缓存的热点数据
- 如果后台日志只输出了一条SQL语句,则说明我们的互斥锁是生效的,没有造成大量用户都去查询数据库,执行SQL语句
1 | : ==> Preparing: SELECT id,name,type_id,images,area,address,x,y,avg_price,sold,comments,score,open_hours,create_time,update_time FROM tb_shop WHERE id=? |
- 如果日志输出了好多SQL语句,则说明我们的代码有问题
利用逻辑过期解决缓存击穿问题
- 需求:根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
- 思路分析:当用户开始查询redis时,判断是否命中
- 如果没有命中则直接返回空数据,不查询数据库
- 如果命中,则将value取出,判断value中的过期时间是否满足
- 如果没有过期,则直接返回redis中的数据
- 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁
- 封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么新建一个类包含原有的数据和过期时间
- 步骤一
- 这里我们选择新建一个实体类,包含原有数据(用万能的Object)和过期时间,这样对原有的代码没有侵入性
1 |
|
- 步骤二
- 在ShopServiceImpl中新增方法,进行单元测试,看看能否写入数据
1 | public void saveShop2Redis(Long id, Long expirSeconds) { |
- 编写测试方法
- 运行测试方法,去Redis图形化页面看到存入的value,确实包含了data和expireTime1
- 步骤三:正式代码
正式代码我们就直接照着流程图写就好了
1 | //这里需要声明一个线程池,因为下面我们需要新建一个现成来完成重构缓存 |
使用Jmeter进行测试
- 先来复现一遍场景,当某个用户去Redis中访问缓存的数据时,发现该数据已经过期了,于是新开一个线程去重构缓存数据,但在重构完成之前,用户得到的数据都是脏数据,重构完成之后,才是新数据
- 之后去数据库把这个数据修改一下,这样逻辑过期前和逻辑过期后的数据就不一致,当用户来访问数据的时候,需要花时间来进行重构缓存数据,但是在重构完成之前,都只能获得脏数据(也就是我们修改前的数据),只有当重构完毕之后,才能获得新数据(我们修改后的数据)
- 测试结果如下,同样是开了100个线程去访问逻辑过期数据,前面的用户只能看到脏数据,后面的用户看到的才是新数据
封装Redis工具类
- 基于StringRedisTemplate封装一个缓存工具类,需满足下列要求
- 方法1:将任意Java对象序列化为JSON,并存储到String类型的Key中,并可以设置TTL过期时间
1 | public void set(String key, Object value, Long time, TimeUnit timeUnit) { |
- 方法2:将任意Java对象序列化为JSON,并存储在String类型的Key中,并可以设置逻辑过期时间,用于处理缓存击穿问题
1 | public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) { |
- 方法3:根据指定的Key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
- 改为通用方法,那么返回值就需要进行修改,不能返回Shop了,那我们直接设置一个泛型,同时ID的类型,也不一定都是Long类型,所以我们也采用泛型。
- Key的前缀也会随着业务需求的不同而修改,所以参数列表里还需要加入Key的前缀
- 通过id去数据库查询的具体业务需求我们也不清楚,所以我们也要在参数列表中加入一个查询数据库逻辑的函数
- 最后再加上设置TTL需要的两个参数
- 那么综上所述,我们的参数列表需要
- key前缀
- id(类型泛型)
- 返回值类型(泛型)
- 查询的函数
- TTL需要的两个参数
1 | public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) { |
1 | public Result queryById(Long id) { |
- 方法4:根据指定的Key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
1 | public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) { |
- 方法5:根据指定的Key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题
1 | public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) { |
优惠券秒杀
Redis实现全局唯一ID
- 在各类购物App中,都会遇到商家发放的优惠券
- 当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题
- id规律性太明显
- 受单表数据量的限制
- 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
- 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
- 那么这就引出我们的全局ID生成器了
全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
- 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
- ID组成部分
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
- 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
- 那我们就根据我们分析的ID生成策略,来编写代码
1 |
|
添加优惠券
- 每个店铺度可以发布优惠券,分为平价券和特价券,平价券可以任意购买,而特价券需要秒杀抢购
- tb_voucher:优惠券的基本信息,优惠金额、使用规则等
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
id | bigint unsigned | (NULL) | NO | PRI | (NULL) | auto_increment | 主键 |
shop_id | bigint unsigned | (NULL) | YES | (NULL) | 商铺id | ||
title | varchar(255) | utf8mb4_general_ci | NO | (NULL) | 代金券标题 | ||
sub_title | varchar(255) | utf8mb4_general_ci | YES | (NULL) | 副标题 | ||
rules | varchar(1024) | utf8mb4_general_ci | YES | (NULL) | 使用规则 | ||
pay_value | bigint unsigned | (NULL) | NO | (NULL) | 支付金额,单位是分。例如200代表2元 | ||
actual_value | bigint | (NULL) | NO | (NULL) | 抵扣金额,单位是分。例如200代表2元 | ||
type | tinyint unsigned | (NULL) | NO | 0 | 0,普通券;1,秒杀券 | ||
status | tinyint unsigned | (NULL) | NO | 1 | 1,上架; 2,下架; 3,过期 | ||
create_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 | |
update_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP | 更新时间 |
-
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
| Field | Type | Collation | Null | Key | Default | Extra | Comment |
| ----------- | --------------- | --------- | ---- | ---- | ----------------- | --------------------------------------------- | ---------------- |
| voucher_id | bigint unsigned | (NULL) | NO | PRI | (NULL) | | 关联的优惠券的id |
| stock | int | (NULL) | NO | | (NULL) | | 库存 |
| create_time | timestamp | (NULL) | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 |
| begin_time | timestamp | (NULL) | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 生效时间 |
| end_time | timestamp | (NULL) | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 失效时间 |
| update_time | timestamp | (NULL) | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP | 更新时间 | -
平价券由于优惠力度并不是很大,所以是可以任意领取
-
而代金券由于优惠力度大,所以像第二种券,就得限制数量,从表结构上也能看出,特价券除了具有优惠券的基本信息以外,还具有库存,抢购时间,结束时间等等字段
-
添加优惠券的代码已经提供好了
1 | /** |
1 | /** |
1 |
|
- 由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill, 请求方式POST,JSON数据如下,注意优惠券的截止日期设置,若优惠券过期,则不会在页面上显示。
1 | { |
效果如下 (ps:如果你们没在前端看到秒杀券的信息 请把json里endTime改为您当前时间之后)
实现秒杀下单
- 我们点击限时抢购,然后查看发送的请求
1 | 请求网址: http://localhost:8080/api/voucher-order/seckill/13 |
- 那我们现在来分析一下怎么抢优惠券
- 首先提交优惠券id,然后查询优惠券信息
- 之后判断秒杀时间是否开始
- 开始了,则判断是否有剩余库存
- 有库存,那么删减一个库存
- 然后创建订单
- 无库存,则返回一个错误信息
- 有库存,那么删减一个库存
- 没开始,则返回一个错误信息
- 开始了,则判断是否有剩余库存
- 流程图如下:
- 代码实现
1 |
|
1 |
|
超卖问题
- 我们之前的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景,URL为 localhost:8081/voucher-order/seckill/12,请求方式为POST
注意使用Jmeter进行压测时,需要携带我们登录的token
测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有200条数据,去优惠券表查看,库存为-100,超卖了100张
- 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
1 | //4. 判断库存是否充足 |
- 假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题
- 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案
- 悲观锁
- 悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
- 例如Synchronized、Lock等,都是悲观锁
- 乐观锁
- 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
- 如果没有修改,则认为自己是安全的,自己才可以更新数据
- 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常
- 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
- 悲观锁
- 悲观锁:悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁等等
- 乐观锁:乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如CAS
- 乐观锁的典型代表:就是CAS(Compare-And-Swap),利用CAS进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
1 | int var5; |
- 在该项目当中并不需要使用一个额外的版本号作为数据库字段 可以使用优惠券表中的stock字段充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同
1 | //5. 扣减库存 |
- 以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
- 那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作
1 | //5. 扣减库存 |
- 再用Jmeter测一下 就能发现优惠券不会出现超卖!
一人一单
- 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
- 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
- 如果已存在,则不能下单,返回错误信息
- 如果不存在,则继续下单,获取优惠券
1 | //4. 判断库存是否充足 |
- 存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题
- 初步代码,我们把一人一单逻辑之后的代码都提取到一个createVoucherOrder方法中,然后给这个方法加锁
- 不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
1 | private Result createVoucherOrder(Long voucherId) { |
- 但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是一人一单,所以这个锁,应该只加在单个用户上,用户标识可以用userId
1 |
|
- 由于toString的源码是new String,所以如果我们只用userId.toString()拿到的也不是同一个用户,(即使值相同但new出来的内存空间不同)需要使用intern(),如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。
1 | public static String toString(long i) { |
userId.toString().intern()保证了用户id的唯一性
- 但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题
1 |
|
- 但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService中创建createVoucherOrder方法
1 | Long userId = UserHolder.getUser().getId(); |
- 引入代理对象的依赖 导入maven坐标
1 | <dependency> |
- 配置启动类 暴露代理对象 供其调用 @EnableAspectJAutoProxy(exposeProxy = true)注解
1 |
|
- 重启服务器,再次使用Jmeter测试,200个线程并发,但是只能抢到一张优惠券,目的达成
令牌桶算法限流(补充)
- 导入maven坐标
1 | <!--实现令牌桶算法--> |
- 这行代码创建了一个RateLimiter对象,并设置其速率为每秒产生10个令牌。这意味着,在不受限制的情况下,每秒钟最多允许10个请求通过
1 | private RateLimiter rateLimiter = RateLimiter.create(10); |
集群环境下的并发问题
- 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
- 我们将服务启动两份,端口分别为8081和8082
- 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
- 具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。
- 失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥
分布式锁
基本原理和实现方式对比
- 分布式锁:满足分布式系统或集群模式下多线程可见并且可以互斥的锁
- 分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
- 那么分布式锁应该满足一些什么条件呢?
- 可见性:多个线程都能看到相同的结果。
- 注意:这里说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
- 互斥:互斥是分布式锁的最基本条件,使得程序串行执行
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
- 高性能:由于加锁本身就让性能降低,所以对于分布式锁需要他较高的加锁性能和释放锁性能
- 安全性:安全也是程序中必不可少的一环
- 常见的分布式锁有三种
- MySQL:MySQL本身就带有锁机制,但是由于MySQL的性能一般,所以采用分布式锁的情况下,使用MySQL作为分布式锁比较少见
- Redis:Redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都是用Redis或者Zookeeper作为分布式锁,利用SETNX这个方法,如果插入Key成功,则表示获得到了锁,如果有人插入成功,那么其他人就回插入失败,无法获取到锁,利用这套逻辑完成互斥,从而实现分布式锁
- Zookeeper:Zookeeper也是企业级开发中较好的一种实现分布式锁的方案,但本文是学Redis的,所以这里就不过多阐述了
| | MySQL | Redis | Zookeeper |
| — | — | — | — |
| 互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
| 高可用 | 好 | 好 | 好 |
| 高性能 | 一般 | 好 | 一般 |
| 安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
Redis分布式锁的实现核心思路
- 实现分布式锁时需要实现两个基本方法
- 获取锁
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 获取锁
1 | SET lock thread01 NX EX 10 |
释放锁
- 手动释放
- 超时释放:获取锁的时候添加一个超时时间
1 | DEL lock |
- 核心思路
- 我们利用redis的SETNX方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试
实现分布式锁
- 锁的基本接口
1 | public interface ILock { |
- 然后创建一个SimpleRedisLock类实现接口
1 | public class SimpleRedisLock implements ILock{ |
- 修改业务代码
1 | public Result seckillVoucher(Long voucherId) { |
Redis分布式锁误删情况说明
- 逻辑说明
- 持有锁的线程1在锁的内部出现了阻塞,导致他的锁TTL到期,自动释放
- 此时线程2也来尝试获取锁,由于线程1已经释放了锁,所以线程2可以拿到
- 但是现在线程1阻塞完了,继续往下执行,要开始释放锁了
- 那么此时就会将属于线程2的锁释放,这就是误删别人锁的情况
- 解决方案
- 解决方案就是在每个线程释放锁的时候,都判断一下这个锁是不是自己的,如果不属于自己,则不进行删除操作。
- 假设还是上面的情况,线程1阻塞,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1阻塞完了,继续往下执行,开始删除锁,但是线程1发现这把锁不是自己的,所以不进行删除锁的逻辑,当线程2执行到删除锁的逻辑时,如果TTL还未到期,则判断当前这把锁是自己的,于是删除这把锁
解决Redis分布式锁误删问题
- 需求:修改之前的分布式锁实现
- 满足:在获取锁的时候存入线程标识(用UUID标识,在一个JVM中,ThreadId一般不会重复,但是我们现在是集群模式,有多个JVM,多个JVM之间可能会出现ThreadId重复的情况),在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致
- 如果一致则释放锁
- 如果不一致则不释放锁
- 核心逻辑:在存入锁的时候,放入自己的线程标识,在删除锁的时候,判断当前这把锁是不是自己存入的
- 如果是,则进行删除
- 如果不是,则不进行删除
- 具体实现代码如下
1 | //用UUID 来标识不同的JVM 避免不同的JVM 出现相同threadId的情况导致锁误删 |
分布式锁的原子性问题
- 更为极端的误删逻辑说明
- 假设线程1已经获取了锁,在判断标识一致之后,准备释放锁的时候,又出现了阻塞(例如JVM垃圾回收机制)
- 于是锁的TTL到期了,自动释放了
- 那么现在线程2趁虚而入,拿到了一把锁
- 但是线程1的逻辑还没执行完,那么线程1就会执行删除锁的逻辑
- 但是在阻塞前线程1已经判断了标识一致,所以现在线程1把线程2的锁给删了
- 那么就相当于判断标识那行代码没有起到作用
- 这就是删锁时的原子性问题
- 因为线程1的拿锁,判断标识,删锁,不是原子操作(两个动作),所以我们要防止刚刚的情况
Lua脚本解决多条命令原子性问题
- Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
- Lua是一种编程语言,它的基本语法可以上菜鸟教程看看,链接:https://www.runoob.com/lua/lua-tutorial.html
- 这里重点介绍Redis提供的调用函数,我们可以使用Lua去操作Redis,而且还能保证它的原子性,这样就可以实现拿锁,判断标识,删锁是一个原子性动作了
- Redis提供的调用函数语法如下:
1 | redis.call('命令名称','key','其他参数', ...) |
- 例如我们要执行set name Kyle,则脚本是这样
1 | redis.call('set','name','Kyle') |
- 例如我我们要执行set name David,在执行get name,则脚本如下
1 | ## 先执行set name David |
- 写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下
1 | EVAL script numkeys key [key ...] arg [arg ...] |
- 例如,我们要调用redis.call(‘set’, ‘name’, ‘Kyle’) 0这个脚本,语法如下
1 | EVAL "return redis.call('set', 'name', 'Kyle')" 0 |
- 如果脚本中的key和value不想写死,可以作为参数传递,key类型参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组中获取这些参数
注意:在Lua中,数组下标从1开始
1 | EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Lucy |
- 那现在我们来使用Lua脚本来代替我们释放锁的逻辑
1 | -- 线程标识 |
1 | -- 这里的KEYS[1]就是传入锁的key |
利用Java代码调用Lua脚本改造分布式锁
- 新建Lua文件 复制上面脚本
- 在RedisTemplate中,可以利用execute方法去执行lua脚本
1 | public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) { |
- 实现代码
1 | private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; |
- 但是现在的分布式锁还存在一个问题:锁不住
- 那什么是锁不住呢?
- 如果锁的TTL快到期的时候,我们可以给它续期一下,比如续个30s,就好像是网吧上网,快没网费了的时候,让网管再给你续50块钱的,然后该玩玩,程序也继续往下执行
- 那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission了
总结
- 基于redis的分布式锁的实现思路:
- 利用ser nx ex 获取锁,并设置TTL,保存线程标识;
- 释放锁时先判断线程标识是否和自己保存一致,一致则删除锁
- 特性:
分布式锁-Redisson
- 基于SETNX实现的分布式锁存在以下问题:
- 不可重入
- 重入问题是指获取锁的线程,可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,例如在HashTable这样的代码中,它的方法都是使用synchronized修饰的,加入它在一个方法内调用另一个方法,如果此时是不可重入的,那就死锁了。所以可重入锁的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的
- 不可重试
- 我们编写的分布式锁只能尝试一次,失败了就返回false,没有重试机制。但合理的情况应该是:当线程获取锁失败后,他应该能再次尝试获取锁
- 超时释放
- 我们在加锁的时候增加了TTL,这样我们可以防止死锁,但是如果卡顿(阻塞)时间太长,也会导致锁的释放。虽然我们采用Lua脚本来防止删锁的时候,误删别人的锁,但现在的新问题是没锁住,也有安全隐患
- 主从一致性
- 如果Redis提供了主从集群,那么当我们向集群写数据时,主机需要异步的将数据同步给从机,万一在同步之前,主机宕机了(主从同步存在延迟,虽然时间很短,但还是发生了),那么又会出现死锁问题
- 那么什么是Redisson呢
- Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现
- Redis提供了分布式锁的多种多样功能
Redisson入门
- 依赖导入
1 | <dependency> |
- 配置Redisson客户端,在config包下新建RedissonConfig类
1 | import org.redisson.Redisson; |
- 使用Redisson的分布式锁
1 |
|
- 替换之前写的分布式锁 tip:注入RedissonClient
1 | + @Autowired |
- 完整代码
1 |
|
- 成没成功 自己用Jmete测一下 看看能不能实现一人一单
- 那我这边是成功了哈
Redisson可重入锁原理
- 在Lock锁中,他是借助于等曾的一个voaltile的一个state变量来记录重入的状态的
- 如果当前没有人持有这把锁,那么state = 0
- 如果有人持有这把锁,那么state = 1
- 如果持有者把锁的人(同一线程下)再次持有这把锁,那么state会+1
- 如果对于synchronize而言,他在c语言代码中会有一个count
- 原理与state类似,也是重入一次就+1,释放一次就-1,直至减到0,表示这把锁没有被人持有
- 在redisson中,我们也支持可重入锁
- 在分布式锁中,它采用hash结构来存储锁,其中外层key表示这把锁是否存在,内层key则记录当前这把锁被哪个线程持有
- method1在方法内部调用method2,method1和method2出于同一个线程,那么method1已经拿到一把锁了,想进入method2中拿另外一把锁,必然是拿不到的,于是就出现了死锁(按照之前我们采用SETNX EX 方法实现分布式锁的情况)
1 |
|
- 所以我们需要额外判断,method1和method2是否处于同一线程,如果是同一个线程,则可以拿到锁,但是state会+1,之后执行method2中的方法,释放锁,释放锁的时候也只是将state进行-1,只有减至0,才会真正释放锁
- 由于我们需要额外存储一个state,所以用字符串型SET NX EX是不行的,需要用到Hash结构,但是Hash结构又没有NX这种方法,所以我们需要将原有的逻辑拆开,进行手动判断(expire)
- 看这个流程图 是不是很复杂(很多的判锁 删锁操作)
- 为了保证原子性——流程图中的业务逻辑也是需要我们用Lua来实现
- 获取锁的逻辑
1 | local key = KEYS[1]; -- 锁的key |
- 释放锁的逻辑
1 | local key = KEYS[1]; |
-
获取锁源码
查看源码 ,和我们实现的方式几乎一致
1 | <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { |
- 释放锁源码
1 | protected RFuture<Boolean> unlockInnerAsync(long threadId) { |
Redisson锁重试和WatchDog机制
- tips:源码部分比较难理解(我是小菜鸡),建议多看几遍
- 前面我们分析的是空参的tryLock方法,现在我们来分析一下这个带参数的
1 | <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { |
- 源码分析
- tryAcquireAsync
1 | private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { |
- tryLock
1 | public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { |
- scheduleExpirationRenewal
1 | private void scheduleExpirationRenewal(long threadId) { |
- renewExpiration
1 | private void renewExpiration() { |
- renewExpirationAsync
重点看lua脚本,先判断锁是不是自己的,然后更新有效时间
1 | protected RFuture<Boolean> renewExpirationAsync(long threadId) { |
- 那么之前的重置有效期的行为该怎么终止呢?当然是释放锁的时候会终止
- cancelExpirationRenewal
1 | void cancelExpirationRenewal(Long threadId) { |
Redisson锁的MutiLock原理
- 为了提高Redis的可用性,我们会搭建集群或者主从,现在以主从为例
- 此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设主机还没来得及把数据写入到从机去的时候,主机宕机了
- 哨兵会发现主机宕机了,于是选举一个slave(从机)变成master(主机),而此时新的master(主机)上并没有锁的信息(锁失效),那么其他线程就可以获取锁,又会引发安全问题
- 为了解决这个问题。Redisson提出来了MutiLock锁,使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机,那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性
- 我们先使用虚拟机额外搭建两个Redis节点
1 |
|
- 使用联锁,我们首先要注入三个RedissonClient对象
1 |
|
- 源码分析
- 当我们没有传入锁对象来创建联锁的时候,则会抛出一个异常,反之则将我们传入的可变参数锁对象封装成一个集合
1 | public RedissonMultiLock(RLock... locks) { |
- 联锁的tryLock
1 | public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { |
小结
- 不可重入Redis分布式锁
- 原理:利用SETNX的互斥性;利用EX避免死锁;释放锁时判断线程标识
- 缺陷:不可重入、无法重试、锁超时失效
- 可重入Redis分布式锁
- 原理:利用Hash结构,记录线程标识与重入次数;利用WatchDog延续锁时间;利用信号量控制锁重试等待
- 缺陷:Redis宕机引起锁失效问题
- Redisson的multiLock
秒杀优化
异步秒杀思路
- 我们先来回顾一下下单流程
- 当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤
- 查询优惠券
- 判断秒杀库存是否足够
- 查询订单
- 校验是否一人一单
- 扣减库存
- 创建订单
- 在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行很慢,所以我们需要异步程序执行,那么如何加速呢?
- 优化方案:我们将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。
- 两个难点
- 如何在redis中完成一人一单,还有库存判断
- 我们校验一人一单和将下单数据写入数据库,这是两个线程,怎么知道下单是否完成。
- 我们需要将一些信息返回给前端,同时也将这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询下单逻辑是否完成
- 我们现在来看整体思路:当用户下单之后,判断库存是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。如果充足,则在Redis中判断用户是否可以下单,如果set集合中没有该用户的下单数据,则可以下单,并将userId和优惠券存入到Redis中,并且返回0,整个过程需要保证是原子性的,所以我们要用Lua来操作,同时由于我们需要在Redis中查询优惠券信息,所以在我们新增秒杀优惠券的同时,需要将优惠券信息保存到Redis中
- 完成以上逻辑判断时,我们只需要判断当前Redis中的返回值是否为0,如果是0,则表示可以下单,将信息保存到queue中去,然后返回,开一个线程来异步下单,其阿奴单可以通过返回订单的id来判断是否下单成功
Redis完成秒杀资格判断
- 需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否秒杀成功
- 步骤一:修改保存优惠券相关代码
- 别忘记加注解
1 |
|
1 |
|
- 使用PostMan发送请求,添加优惠券
请求路径:http://localhost:8080/api/voucher/seckill
请求方式:POST
1 | { |
- 添加成功后,数据库和redis中都能看到优惠券信息
- 步骤二:编写Lua脚本
- lua的字符串拼接使用…,字符串转数字是tonumber()
1 | -- 订单id |
- 修改业务逻辑
1 |
|
基于阻塞队列实现秒杀优化
- 修改下单的操作,我们在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行
- 需求
- 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
- 步骤一:创建阻塞队列
阻塞队列有一个特点:当一个线程尝试从阻塞队列里获取元素的时候,如果没有元素,那么该线程就会被阻塞,直到队列中有元素,才会被唤醒,并去获取元素
阻塞队列的创建需要指定一个大小
1 | private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024); |
- 那么把优惠券id和用户id封装后存入阻塞队列
1 |
|
步骤二:实现异步下单功能
- 先创建一个线程池
1 | private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); |
- 创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到@PostConstruct注解
1 |
|
- 查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
1 | private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal("Current AOP proxy"); |
- 但是我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
1 |
|
- 完整代码
1 | package com.hmdp.service.impl; |
小结
- 秒杀业务的优化思路是什么?——同步下单变异步下单
- 先利用Redis完成库存容量、一人一单的判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
- 基于阻塞队列的异步秒杀存在哪些问题?
Redis消息队列
认识消息队列
- 什么是消息队列?字面意思就是存放消息的队列,最简单的消息队列模型包括3个角色
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
- 使用队列的好处在于解耦:举个例子,快递员(生产者)把快递放到驿站/快递柜里去(Message Queue)去,我们(消费者)从快递柜/驿站去拿快递,这就是一个异步,如果耦合,那么快递员必须亲自上楼把快递递到你手里,服务当然好,但是万一我不在家,快递员就得一直等我,浪费了快递员的时间。所以解耦还是非常有必要的
- 那么在这种场景下我们的秒杀就变成了:在我们下单之后,利用Redis去进行校验下单的结果,然后在通过队列把消息发送出去,然后在启动一个线程去拿到这个消息,完成解耦,同时也加快我们的响应速度
- 这里我们可以直接使用一些现成的(MQ)消息队列,如kafka,rabbitmq等,但是如果没有安装MQ,我们也可以使用Redis提供的MQ方案
基于List实现消息队列
- 基于List结构模拟消息队列
- 消息队列(Message Queue),字面意思就是存放消息的队列,而Redis的list数据结构是一个双向链表,很容易模拟出队列的效果
- 队列的入口和出口不在同一边,所以我们可以利用:LPUSH结合RPOP或者RPUSH结合LPOP来实现消息队列。
- 不过需要注意的是,当队列中没有消息时,RPOP和LPOP操作会返回NULL,而不像JVM阻塞队列那样会阻塞,并等待消息,所以我们这里应该使用BRPOP或者BLPOP来实现阻塞效果
- 基于List的消息队列有哪些优缺点?
基于PubSub的消息队列
- PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费和可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息
- SUBSCRIBE channel [channel]:订阅一个或多个频道
- PUBLISH channel msg:向一个频道发送消息
- PSUBSCRIBE pattern [pattern]:订阅与pattern格式匹配的所有频道
Subscribes the client to the given patterns.
Supported glob-style patterns:
- h?flo subscribes to hello, hallo and hxllo
- h*llo subscribes to hllo and heeeello
- h[ae]llo subscribes to hello and hallo, but not hillo
Use \ to escape special characters if you want to match them verbatim.
- 基于PubSub的消息队列有哪些优缺点
基于Stream的消息队列
- Stream是Redis 5.0引入的一种新数据类型,可以时间一个功能非常完善的消息队列
- 发送消息的命令
1 | XADD key [NOMKSTREAM] [MAXLEN|MINID [=!~] threshold [LIMIT count]] *|ID field value [field value ...] |
- NOMKSTREAM
- 如果队列不存在,是否自动创建队列,默认是自动创建
- [MAXLEN|MINID [=!~] threshold [LIMIT count]]
- 设置消息队列的最大消息数量,不设置则无上限
- *|ID
- 消息的唯一id,*代表由Redis自动生成。格式是"时间戳-递增数字",例如"114514114514-0"
- field value [field value …]
- 发送到队列中的消息,称为Entry。格式就是多个key-value键值对
- 最简单的例子
1 | ## 创建名为users的队列,并向其中发送一个消息,内容是{name=jack, age=21},并且使用Redis自动生成ID |
- 读取消息的方式之一:XREAD
1 | XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...] |
- [COUNT count]
- 每次读取消息的最大数量
- [BLOCK milliseconds]
- 当没有消息时,是否阻塞,阻塞时长
- STREAMS key [key …]
- 要从哪个队列读取消息,key就是队列名
- ID [ID …]
- 起始ID,只返回大于该ID的消息
- 0:表示从第一个消息开始
- $:表示从最新的消息开始始
- 例如:使用XREAD读取第一个消息
1 | XREAD COUNT 1 STREAMS users 0 |
- 例如:XREAD阻塞方式,读取最新消息
1 | XREAD COUNT 2 BLOCK 10000 STREAMS users $ |
- 在业务开发中,我们可以使用循环调用的XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,代码如下
1 | while (true){ |
注意:当我们指定其实ID为$时,代表只能读取到最新消息,如果当我们在处理一条消息的过程中,又有超过1条以上的消息到达队列,那么下次获取的时候,也只能获取到最新的一条,会出现漏读消息的问题
- STREAM类型消息队列的XREAD命令特点
基于Stream的消息队列–消费者组
- 消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列,具备以下特点
- 消息分流
- 队列中的消息会分留给组内的不同消费者,而不是重复消费者,从而加快消息处理的速度
- 消息标识
- 消费者会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每一个消息都会被消费
- 消息确认
- 消费者获取消息后,消息处于pending状态,并存入一个pending-list,当处理完成后,需要通过XACK来确认消息,标记消息为已处理,才会从pending-list中移除
- 创建消费者组
1 | XGROUP CREATE key groupName ID [MKSTREAM] |
- key
- 队列名称
- groupName
- 消费者组名称
- ID
- 起始ID标识,$代表队列中的最后一个消息,0代表队列中的第一个消息
- MKSTREAM
- 队列不存在时自动创建队列
- 其他常见命令
- 删除指定的消费者组
1 | XGROUP DESTORY key groupName |
- 为指定的消费者组添加消费者
1 | XGROUP DESTORY key groupName |
- 删除消费者组中指定的消费者
1 | XGROUP DELCONSUMER key groupName consumerName |
- 从 消费者组中读取消息
1 | XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [keys ...] ID [ID ...] |
- group
- 消费者组名称
- consumer
- 消费者名,如果消费者不存在,会自动创建一个消费者
- count
- 本次查询的最大数量
- BLOCK milliseconds
- 当前没有消息时的最大等待时间
- NOACK
- 无需手动ACK,获取到消息后自动确认(一般不用,我们都是手动确认)
- STREAMS key
- 指定队列名称
- ID
- 获取消息的起始ID
:从下一个未消费的消息开始(pending-list中)
- 其他:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
- 消费者监听消息的基本思路
1 | while(true){ |
- STREAM类型消息队列的XREADGROUP命令的特点
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度 避免出现消息堆积
- 可以阻塞读取
- 没有消息漏读风险
- 有消息确认机制,保证消息至少被消费一次 ack
| | List | PubSub | Stream |
| — | — | — | — |
| 消息持久化 | 支持 | 不支持 | 支持 |
| 阻塞读取 | 支持 | 支持 | 支持 |
| 消息堆积处理 | 受限于内存空间,
可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,
可以利用消费者组提高消费速度,减少堆积 |
| 消息确认机制 | 不支持 | 不支持 | 支持 |
| 消息回溯 | 不支持 | 不支持 | 支持 |
Stream消息队列实现异步秒杀下单 (这部分我没实现 redis版本要在5.0以上)
- 需求:
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
- 步骤一:创建一个Stream类型的消息队列,名为stream.orders
1 | XGROUP CREATE stream.orders g1 0 MKSTREAM |
- 步骤二:修改Lua脚本,新增orderId参数,并将订单信息加入到消息队列中
1 | -- 订单id |
- 步骤三:修改秒杀逻辑
- 由于将下单数据加入到消息队列的功能,我们在Lua脚本中实现了,所以这里就不需要将下单数据加入到JVM的阻塞队列中去了,同时Lua脚本中我们新增了一个参数,
1 |
|
- 根据上面思路来修改我们的VoucherOrderHandler
1 | String queueName = "stream.orders"; |
RabbitMQ实现消息队列(补充)
- 这部分是新增功能 使用rabbitmq中间件 优化秒杀
- RabbitMQ我是在CentOS 中利用docker创建的容器(不会的自行学习,再来本章节)
- 首先导入maven坐标
1 | <dependency> |
- 在Config目录下创建RabbitMQTopicConfig类
1 |
|
- 在MQ目录下创建MQ生产者和消费者
1 |
|
- 转移业务代码到消费者中
1 |
|
- 修改VoucherOrderServiceImpl
1 |
|
达人探店
发布探店笔记
- 数据库表这里就不展示了哈 大家自己去看看有哪些字段就好了
- 对应的实体类,数据表中并没有用户头像和用户昵称,但是对应的实体类里却有,这是因为使用了@TableField(exist = false) 用来解决实体类中有的属性但是数据表中没有的字段
1 |
|
- 效果图如下
- 代码如下
1 |
|
- 上传图片代码
1 |
|
注意:这里我们需要修改SystemConstants.IMAGE_UPLOAD_DIR 为自己图片所在的地址,在实际开发中图片一般会放在nginx上或者是云存储上。
查看探店笔记
- 需求:点击首页的探店笔记,会进入详情页面,我们现在需要实现页面的查询接口
- 随便点击一张图片,查看发送的请求
请求网址: http://localhost:8080/api/blog/6
请求方法: GET
- 看样子是BlogController下的方法,请求方式为GET,那我们直接来编写对应的方法
1 |
|
在Service类中创建对应方法之后,在Impl类中实现,我们查看用户探店笔记的时候,需要额外设置用户名和其头像,由于设置用户信息这个操作比较通用,所以这里封装成了一个方法
1 |
|
- 我们将queryHotBlog也修改一下,原始代码将业务逻辑写到了Controller中,修改后的完整代码如下
1 |
|
1 |
|
点赞功能
- 点击点赞按钮,查看发送的请求
请求网址: http://localhost:8080/api/blog/like/4
请求方法: PUT
- BlogController中的like方法
1 |
|
- 问题分析:这种方式会导致一个用户无限点赞,明显是不合理的
- 造成这个问题的原因是,我们现在的逻辑,发起请求只是给数据库+1,所以才会出现这个问题
- 需求
- 同一个用户只能对同一篇笔记点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
- 实现步骤
- 修改点赞功能,利用Redis中的set集合来判断是否点赞过,未点赞则点赞数+1,已点赞则点赞数-1
- 修改根据id查询的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 具体实现
- Controller
1 |
|
- Service
- 修改查询Blog业务,判断Blog是否被当前用户点赞过
1 | @Override |
1 |
|
点赞排行榜
- 当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的TOP5,形成点赞排行榜,就跟QQ空间发的说说一样,可以看到有哪些人点了赞
- 之前的点赞是放到Set集合中,但是Set集合又不能排序,所以这个时候,我们就可以改用SortedSet(Zset)
- 修改BlogServiceImpl
由于ZSet没有isMember方法,所以这里只能通过查询score来判断集合中是否有该元素,如果有该元素,则返回值是对应的score,如果没有该元素,则返回值为null
1 |
|
- 同时修改isBlogLiked方法,在原有逻辑上,判断用户是否已登录,登录状态下才会继续判断用户是否点赞
1 | private void isBlogLiked(Blog blog) { |
- 继续来完善显示点赞列表功能,查看浏览器请求,这个请求目前应该是404的,因为我们还没有写,他需要一个list返回值,显示top5点赞的用户-
请求网址: http://localhost:8080/api/blog/likes/4
请求方法: GET
- 在Controller层中编写对应的方法,点赞查询列表,具体逻辑写到BlogServiceImpl中
1 |
|
- 效果如下
好友关注
关注和取消关注
- 当我们进入到笔记详情页面时,会发送一个请求,判断当前登录用户是否关注了笔记博主
请求网址: http://localhost:8080/api/follow/or/not/2
请求方法: GET
- 当我们点击关注按钮时,会发送一个请求,实现关注/取关
请求网址: http://localhost:8080/api/follow/2/true
请求方法: PUT
- 同样的数据库表和user实体类 这里就不放了 大家自己去看下吧
- 我们直接来实现代码
1 |
|
1 |
|
共同关注
- 点击用户头像,进入到用户详情页,可以查看用户发布的笔记,和共同关注列表
- 但现在我们还没写具体的业务逻辑,所以现在暂时看不到数据
- 网络选项卡,查看发送的请求
- 查询用户信息
请求网址: http://localhost:8080/api/user/2
请求方法: GET
- 查看共同关注
请求网址: http://localhost:8080/api/follow/common/undefined
请求方法: GET
- 编写查询用户信息方法
1 |
|
- 重启服务器,现在可以看到用户信息,但是不能看到用户发布的笔记信息,查看网络检测的请求,我们还需要完成这个需求
请求网址: http://localhost:8080/api/blog/of/user?&id=2¤t=1
请求方法: GET
- 编写查询用户笔记方法
1 | // BlogController 根据id查询博主的探店笔记 |
- 效果图如下:
- 如何实现共同关注
需求:利用Redis中恰当的数据结构,实现共同关注功能,在博主个人页面展示出当前用户与博主的共同关注
- 实现方式当然是我们之前学过的set集合,在set集合中,有交集并集补集的api,可以把二者关注的人放入到set集合中,然后通过api查询两个set集合的交集
- 那我们就得先修改我们之前的关注逻辑,在关注博主的同时,需要将数据放到set集合中,方便后期我们实现共同关注,当取消关注时,也需要将数据从set集合中删除
1 |
|
- 那么接下来,我们实现共同关注代码
1 |
|
- 效果图
Feed流实现方案
- 当我们关注了用户之后,这个用户发布了动态,那我们应该把这些数据推送给用户,这个需求,我们又称其为Feed流,关注推送也叫作Feed流,直译为投喂,为用户提供沉浸式体验,通过无限下拉刷新获取新的信息,
- 对于传统的模式内容检索:用户需要主动通过搜索引擎或者是其他方式去查找想看的内容
- 对于新型Feed流的效果:系统分析用户到底想看什么,然后直接把内容推送给用户,从而使用户能更加节约时间,不用去主动搜素
- Feed流的实现有两种模式
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(B站关注的up,朋友圈等)
- 优点:信息全面,不会有缺失,并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的信息来吸引用户
- 优点:投喂用户感兴趣的信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能会起到反作用(给你推的你都不爱看)
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(B站关注的up,朋友圈等)
- 那我们这里针对好友的操作,采用的是Timeline方式,只需要拿到我们关注用户的信息,然后按照时间排序即可
- 采用Timeline模式,有三种具体的实现方案
- 拉模式
- 推模式
- 推拉结合
拉模式:也叫读扩散
- 该模式的核心含义是:当张三和李四、王五发了消息之后,都会保存到自己的发件箱中,如果赵六要读取消息,那么他会读取他自己的收件箱,此时系统会从他关注的人群中,将他关注人的信息全都进行拉取,然后进行排序
- 优点:比较节约空间,因为赵六在读取信息时,并没有重复读取,并且读取完之后,可以将他的收件箱清除
- 缺点:有延迟,当用户读取数据时,才会去关注的人的时发件箱中拉取信息,假设该用户关注了海量用户,那么此时就会拉取很多信息,对服务器压力巨大
推模式:也叫写扩散
- 推模式是没有写邮箱的,当张三写了一个内容,此时会主动把张三写的内容发送到它粉丝的收件箱中,假设此时李四再来读取,就不用再去临时拉取了
- 优点:时效快,不用临时拉取
- 缺点:内存压力大,假设一个大V发了一个动态,很多人关注他,那么就会写很多份数据到粉丝那边去
推拉结合:页脚读写混合,兼具推和拉两种模式的优点
- 推拉模式是一个折中的方案,站在发件人这一边,如果是普通人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝收件箱中,因为普通人的粉丝数量较少,所以这样不会产生太大压力。但如果是大V,那么他是直接将数据写入一份到发件箱中去,在直接写一份到活跃粉丝的收件箱中,站在收件人这边来看,如果是活跃粉丝,那么大V和普通人发的都会写到自己的收件箱里,但如果是普通粉丝,由于上线不是很频繁,所以等他们上线的时候,再从发件箱中去拉取信息。
推送到粉丝收件箱
- 需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须使用Redis的数据结构实现
- 查询收件箱数据时,可实现分页查询
- Feed流中的数据会不断更新,所以数据的角标也会不断变化,所以我们不能使用传统的分页模式
- 假设在t1时刻,我们取读取第一页,此时page = 1,size = 5,那么我们拿到的就是10~6这几条记录,假设t2时刻有发布了一条新纪录,那么在t3时刻,我们来读取第二页,此时page = 2,size = 5,那么此时读取的数据是从6开始的,读到的是6~2,那么我们就读到了重复的数据,所以我们要使用Feed流的分页,不能使用传统的分页
Feed流的滚动分页
- 我们需要记录每次操作的最后一条,然后从这个位置去开始读数据
- 举个例子:我们从t1时刻开始,拿到第一页数据,拿到了10~6,然后记录下当前最后一次读取的记录,就是6,t2时刻发布了新纪录,此时这个11在最上面,但不会影响我们之前拿到的6,此时t3时刻来读取第二页,第二页读数据的时候,从6-1=5开始读,这样就拿到了5~1的记录。我们在这个地方可以使用SortedSet来做,使用时间戳来充当表中的1~10
- 核心思路:我们保存完探店笔记后,获取当前用户的粉丝列表,然后将数据推送给粉丝
- 那现在我们就需要修改保存笔记的方法
1 |
|
实现分页查询收件箱
- 需求:在个人主页的关注栏中,查询并展示推送的Blog信息
- 具体步骤如下
- 每次查询完成之后,我们要分析出查询出的最小时间戳,这个值会作为下一次的查询条件
- 我们需要找到与上一次查询相同的查询个数,并作为偏移量,下次查询的时候,跳过这些查询过的数据,拿到我们需要的数据(例如时间戳8 6 6 5 5 4,我们每次查询3个,第一次是8 6 6,此时最小时间戳是6,如果不设置偏移量,会从第一个6之后开始查询,那么查询到的就是6 5 5,而不是5 5 4,如果这里说的不清楚,那就看后续的代码)
- 综上:我们的请求参数中需要携带lastId和offset,即上一次查询时的最小时间戳和偏移量,这两个参数
- 编写一个通用的实体类,不一定只对blog进行分页查询,这里用泛型做一个通用的分页查询,list是封装返回的结果,minTime是记录的最小时间戳,offset是记录偏移量
1 |
|
- 点击个人主页中的关注栏,查看发送的请求
请求网址: http://localhost:8080/api/blog/of/follow?&lastId=1667472294526
请求方法: GET
- 在BlogController中创建对应的方法,具体实现去ServiceImpl中完成
1 |
|
1 |
|
结语
- 再次感谢黑马程序员的精品课程!
- 由于本课程是针对redis的实战项目课程,原视频中还有的功能(附近的商户、用户统计、UV签到)我这就没实现,有兴趣的小伙伴可以前往观看
- 收获最大的地方在于 通过这个项目让我更加深入理解了redis这个非关系型数据库的作用,以及在一些常见生产场景下会遇到的问题和解决措施,建议小伙伴们多次反复学习查询缓存、优惠券秒杀、分布式锁等章节,把项目吃透,掌握的本领才是自己的
- 本项目还有许多地方可以优化,初步想法是:
- 把短信验证登录模块,由后台发送验证码->使用个人用户邮箱发送短信验证码->(后续使用阿里云短信服务实现短信登录功能)
- 学习RabbitMQ消息队列实现高并发场景下的秒杀优化,减轻数据库压力
- 高并发的场景下使用令牌桶算法进行一定程度上的限流
更新:上述优化功能已实现
- 至此,感谢您的阅览,个人能力有限,文中不足之处诸位多多包涵,也欢迎您的指正。