Redis实战篇
在此特别感谢黑马程序员提供的Redis课程
内容概述
-
短信登录
- 这部分会使用Redis共享session来实现
- 但其实我在之前的瑞吉外卖的项目优化部分就做过了,用Redis替换session来存储邮箱验证码
-
商户查询缓存
- 这部分要理解缓存击穿,缓存穿透,缓存雪崩等问题,对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
-
优惠券秒杀
- 这部分我们可以学会Redis的计数器功能,结合Lua(之前一直想学Lua然后写饥荒mod)完成高性能的Redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
-
附近的商户
- 利用Redis的GEOHash(新数据结构,前面没有应用场景就没介绍)来完成对于地理坐标的操作
-
UV统计
- 主要是使用Redis来完成统计功能
-
用户签到
- 使用Redis的BitMap数据统计功能
-
好友关注
- 基于Set集合的关注、取消关注,共同关注等等功能,这部分在上篇的练习题中出现过,这次我们在项目中来使用一下
-
达人探店
- 基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能
短信登录
导入项目
在实现功能之前,我们先来导入项目,让项目跑起来
导入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窗口,输入命令,即可启动项目
1 | start nginx.exe |
- 访问http://localhost:8080/ ,打开开发者模式,可以看到页面
基于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)
- 导入邮箱验证需要的maven坐标
1 | <!-- https://mvnrepository.com/artifact/javax.activation/activation --> |
- 然后编写一个工具类,用于发送邮件验证码
1 | import java.util.Arrays; |
- 修改sendCode方法,逻辑如下
- 验证手机号/邮箱格式
- 不正确则返回错误信息
- 正确则发送验证码
- 验证手机号/邮箱格式
1 | /** |
-
然后输入邮箱,发送验证码,看看能否接收到验证码
-
测试没有问题之后,我们继续来编写登录功能,点击登录按钮,查看发送的请求
请求网址: http://localhost:8080/api/user/login
请求方法: POST
- 看样子是UserController中的login方法,携带的参数也就是我们的邮箱和验证码
1 | {phone: "1586385296@qq.com", code: "iMPKc"} |
- 黑马提供的代码如下,看样子是把邮箱和验证码封装到了LoginFormDto中
1 | /** |
1 |
|
- 修改login方法,逻辑如下
- 校验手机号/邮箱
- 不正确则返回错误信息
- 正确则继续校验验证码
- 不一致则报错
- 一致则先根据手机号/邮箱查询用户
- 用户不存在则创建
- 存在则继续执行程序
- 保存用户信息到session中
- 校验手机号/邮箱
1 | /** |
1 | private User createUserWithPhone(String phone) { |
实现登录拦截功能
- 这部分需要用到拦截器的知识,我在前面的SSM整合篇做过详细介绍
- 创建一个LoginInterceptor类,实现HandlerInterceptor接口,重写其中的两个方法,前置拦截器和完成处理方法,前置拦截器主要用于我们登陆之前的权限校验,完成处理方法是用于处理登录后的信息,避免内存泄露
1 | public class LoginInterceptor implements HandlerInterceptor { |
这是黑马已经提供好了的一个工具类
1 | public class UserHolder { |
让拦截器生效
1 |
|
- 顺便再写一下me方法
1 |
|
隐藏用户敏感信息
- 我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
1 | { |
- UserDto类如下,将User对象中的属性拷贝给UserDto,就可以避免暴露用户的隐藏信息
1 |
|
- 修改UserHolder,将其User类型都换为UserDto
1 | public class UserHolder { |
- 修改login方法
1 | @PostMapping("/login") |
- 修改拦截器
1 | @Override |
- 重启服务器,登录后查看此时的用户信息,敏感信息已经不存在了
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,判断是否存在这个数据
- 如果不存在,则拦截
- 如果存在,则将其用户信息(userDto)保存到threadLocal中,并放行
基于Redis实现短信登录
- 由于前面已经分析过业务逻辑了,所以这里我们直接开始写代码,在此之前我们要在UserController中注入
StringRedisTemplate
1 |
|
- 修改sendCode方法
这里的key使用用login:code:email
的形式,并设置有效期2分钟,我们也可以定义一个常量类来替换这里的login:code:
和2
,让代码显得更专业一点
1 | @PostMapping("/code") |
定义的常量类
1 | public class RedisConstants { |
- 修改login方法
1 | @PostMapping("/login") |
1 |
|
解决状态登录刷新问题
初始方案
- 我们可以通过拦截器拦截到的请求,来证明用户是否在操作,如果用户没有任何操作30分钟,则token会消失,用户需要重新登录
- 通过查看请求,我们发现我们存的token在请求头里,那么我们就在拦截器里来刷新token的存活时间
authorization: 6867061d-a8d0-4e60-b92f-97f7d698a1ca
- 修改我们的登陆拦截器
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都会重置,那么说明我们的代码没有问题
商户查询缓存
什么是缓存
- 什么是缓存?
- 缓存就像自行车、越野车的避震器
- 举个例子
- 越野车、山地自行车都有
避震器
,防止车体加速之后因惯性,在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(); |
- 由于其被
static
修饰,所以随着类的加载而加载到内存之中,作为本地缓存,由于其又被final
修饰,所以其引用之间的关系是固定的,不能改变,因此不用担心复制导致缓存失败
为什么要使用缓存
- 言简意赅:速度快,好用
- 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
- 实际开发中,企业的数据量,少则几十万,多则几千万,这么大的数据量,如果没有缓存来作为
避震器
系统是几乎撑不住的,所以企业会大量运用缓存技术 - 但是缓存也会增加代码复杂度和运营成本
缓存的作用
- 降低后端负载
- 提高读写效率,降低响应时间
缓存的成本
- 数据一致性成本
- 代码维护成本
- 运维成本(一般采用服务器集群,需要多加机器,机器就是钱)
如何使用缓存
- 实际开发中,会构筑多级缓存来时系统运行速度进一步提升,例如:本地缓存与Redis中的缓存并发使用
浏览器缓存:
主要是存在于浏览器端的缓存应用层缓存:
可以分为toncat本地缓存,例如之前提到的map或者是使用Redis作为缓存数据库缓存:
在数据库中有一片空间是buffer pool,增改查数据都会先加载到mysql的缓存中CPU缓存:
当代计算机最大的问题就是CPU性能提升了,但是内存读写速度没有跟上,所以为了适应当下的情况,增加了CPU的L1,L2,L3级的缓存
添加商户缓存
- 我们先启动前端和后端的项目,登陆之后随便访问一个商户,查看浏览器发送的请求
请求网址: http://localhost:8080/api/shop/10
请求方法: GET
-
不出意外是
ShopController
里的业务逻辑,而且restFul风格的 -
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库肯定慢
1 | /** |
- 所以我们可以在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据
缓存模型和思路
- 标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入Redis。
代码实现
- 代码思路:如果Redis缓存里有数据,那么直接返回,如果缓存中没有,则去查询数据库,然后存入Redis
业务逻辑我们写到Service中,需要在Service层创建这个queryById
方法,然后去ServiceImpl中实现
1 |
|
1 | public interface IShopService extends IService<Shop> { |
1 |
|
- 重启服务器,访问商户信息,观察控制台日志输出,后续刷新页面,不会出现SQL语句查询商户信息,去Redis图形化界面中查看,可以看到缓存的商户信息数据
趁热打铁
- 完成了商户数据缓存之后,我们尝试做一下商户类型数据缓存
业务逻辑依旧是写在Service中
1 |
|
1 | public interface IShopTypeService extends IService<ShopType> { |
- 整体代码都是类似的,前面只需要将单个店铺信息从JSON和Bean之间相互转换
- 这里只不过是将查询到的多个店铺类型信息从JSON和Bean之间相互转换,只是多了一个foreach循环
1 |
|
- 可以用stream流来简化代码
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 |
|
- 修改update方法
1 | /** |
业务逻辑我们依旧是放在Service层去写
1 | /** |
新增一个方法,Impl里去实现
1 | Result update(Shop shop); |
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中不存在,就直接返回一个错误信息了,但是这样存在缓存穿透问题
1 |
|
- 现在的逻辑是:如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息
1 |
|
缓存雪崩问题及解决思路
- 缓存雪崩是指在同一时间段,大量缓存的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,我们可以根据返回值来判断是否有线程成功获取到了锁
1 | private boolean tryLock(String key) { |
1 | private void unlock(String key) { |
- 然后这里先把我们之前写的缓存穿透代码修改一下,提取成一个独立的方法
1 |
|
- 之后编写我们的互斥锁代码,其实与缓存穿透代码类似,只需要在上面稍加修改即可
1 | @Override |
在上面的基础上,使用try/catch/finally包裹,因为不管前面是否会有异常,最终都必须释放锁
1 |
|
- 最终修改
queryById
方法
1 |
|
- 使用Jmeter进行测试
- 我们先来模拟一下缓存击穿的情景,缓存击穿是指在某时刻,一个热点数据的TTL到期了,此时用户不能从Redis中获取热点商品数据,然后就都得去数据库里查询,造成数据库压力过大。
- 那么我们首先将Redis中的热点商品数据删除,模拟TTL到期,然后用Jmeter进行压力测试,开100个线程来访问这个没有缓存的热点数据
- 如果后台日志只输出了一条SQL语句,则说明我们的互斥锁是生效的,没有造成大量用户都去查询数据库,执行SQL语句
1
2
3SELECT 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=?
2(Long)
1- 如果日志输出了好多SQL语句,则说明我们的代码有问题
利用逻辑过期解决缓存击穿问题
- 需求:根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
- 思路分析:当用户开始查询redis时,判断是否命中
- 如果没有命中则直接返回空数据,不查询数据库
- 如果命中,则将value取出,判断value中的过期时间是否满足
- 如果没有过期,则直接返回redis中的数据
- 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁
- 封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么新建一个类包含原有的数据和过期时间
步骤一
- 这里我们选择新建一个实体类,包含原有数据(用万能的Object)和过期时间,这样对原有的代码没有侵入性
1 |
|
步骤二
- 在ShopServiceImpl中新增方法,进行单元测试,看看能否写入数据
1 | public void saveShop2Redis(Long id, Long expirSeconds) { |
- 编写测试方法
1 |
|
- 运行测试方法,去Redis图形化页面看到存入的value,确实包含了data和expireTime1
1 | { |
步骤三
:正式代码
正式代码我们就直接照着流程图写就好了
1 | //这里需要声明一个线程池,因为下面我们需要新建一个现成来完成重构缓存 |
- 使用Jmeter进行测试
- 先来复现一遍场景,当某个用户去Redis中访问缓存的数据时,发现该数据已经过期了,于是新开一个线程去重构缓存数据,但在重构完成之前,用户得到的数据都是脏数据,重构完成之后,才是新数据
- 那我们先使用
saveShop2Redis
方法,向redis中添加一个逻辑过期数据,设置过期时间为2秒,这样很快就过期了,
1
2
3
4
5
6
7public void saveShop2Redis(Long id, Long expirSeconds) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}1
2
3
4
public void test(){
shopService.saveShop2Redis(2L,2L);
}- 之后去数据库把这个数据修改一下,这样逻辑过期前和逻辑过期后的数据就不一致,当用户来访问数据的时候,需要花时间来进行重构缓存数据,但是在重构完成之前,都只能获得脏数据(也就是我们修改前的数据),只有当重构完毕之后,才能获得新数据(我们修改后的数据)
- 测试结果如下,同样是开了100个线程去访问逻辑过期数据,前面的用户只能看到脏数据,后面的用户看到的才是新数据
封装Redis工具类
- 基于StringRedisTemplate封装一个缓存工具类,需满足下列要求
- 方法1:将任意Java对象序列化为JSON,并存储到String类型的Key中,并可以设置TTL过期时间
1
2
3public void set(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
}- 方法2:将任意Java对象序列化为JSON,并存储在String类型的Key中,并可以设置逻辑过期时间,用于处理缓存击穿问题
1
2
3
4
5
6
7
8
9
10public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
//由于需要设置逻辑过期时间,所以我们需要用到RedisData
RedisData<Object> redisData = new RedisData<>();
//redisData的data就是传进来的value对象
redisData.setData(value);
//逻辑过期时间就是当前时间加上传进来的参数时间,用TimeUnit可以将时间转为秒,随后与当前时间相加
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
//由于是逻辑过期,所以这里不需要设置过期时间,只存一下key和value就好了,同时注意value是ridisData类型
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}- 方法3:根据指定的Key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public Shop queryWithPassThrough(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopjson != null) {
return null;
}
//否则去数据库中查
Shop shop = getById(id);
//查不到,则将空值写入Redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return shop;
}- 改为通用方法,那么返回值就需要进行修改,不能返回
Shop
了,那我们直接设置一个泛型,同时ID的类型,也不一定都是Long
类型,所以我们也采用泛型。 - Key的前缀也会随着业务需求的不同而修改,所以参数列表里还需要加入Key的前缀
- 通过id去数据库查询的具体业务需求我们也不清楚,所以我们也要在参数列表中加入一个查询数据库逻辑的函数
- 最后再加上设置TTL需要的两个参数
- 那么综上所述,我们的参数列表需要
- key前缀
- id(类型泛型)
- 返回值类型(泛型)
- 查询的函数
- TTL需要的两个参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//如果不为空(查询到了),则转为R类型直接返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null;
}
//否则去数据库中查,查询逻辑用我们参数中注入的函数
R r = dbFallback.apply(id);
//查不到,则将空值写入Redis
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(r);
//并存入redis,设置TTL
this.set(key, jsonStr, time, timeUnit);
//最终把查询到的商户信息返回给前端
return r;
}1
2
3
4
5
6
7
8public Result queryById(Long id) {
Shop shop = cacheClient.
queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
if (shop == null) {
return Result.fail("店铺不存在!!");
}
return Result.ok(shop);
}- 方法4:根据指定的Key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
//1. 从redis中查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//2. 如果未命中,则返回空
if (StrUtil.isBlank(json)) {
return null;
}
//3. 命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//5. 未过期,直接返回商铺信息
return r;
}
//6. 过期,尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
//7. 获取到了锁
if (flag) {
//8. 开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R tmp = dbFallback.apply(id);
this.setWithLogicExpire(key, tmp, time, timeUnit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
//9. 直接返回商铺信息
return r;
}
//10. 未获取到锁,直接返回商铺信息
return r;
}- 方法5:根据指定的Key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit timeUnit) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null;
}
R r = null;
String lockKey = LOCK_SHOP_KEY + id;
try {
//否则去数据库中查
boolean flag = tryLock(lockKey);
if (!flag) {
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, timeUnit);
}
r = dbFallback.apply(id);
//查不到,则将空值写入Redis
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//并存入redis,设置TTL
this.set(key, r, time, timeUnit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return r;
} - 完整代码如下
1 | import cn.hutool.core.util.BooleanUtil; |
优惠券秒杀
Redis实现全局唯一ID
- 在各类购物App中,都会遇到商家发放的优惠券
- 当用户抢购商品时,生成的订单会保存到
tb_voucher_order
表中,而订单表如果使用数据库自增ID就会存在一些问题- id规律性太明显
- 受单表数据量的限制
- 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
- 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
- 那么这就引出我们的
全局ID生成器
了- 全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
- 全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
- 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
- ID组成部分
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
- 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
- 那我们就根据我们分析的ID生成策略,来编写代码
1 | public static void main(String[] args) { |
- 完整代码如下
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 | /** |
新增秒杀券主要看addSeckillVoucher
中的业务逻辑
1 | /** |
秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联
1 |
|
- 由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径
http://localhost:8081/voucher/seckill
, 请求方式POST,JSON数据如下,注意优惠券的截止日期设置,若优惠券过期,则不会在页面上显示。
1 | { |
- 效果如下
实现秒杀下单
- 我们点击
限时抢购
,然后查看发送的请求
1 | 请求网址: http://localhost:8080/api/voucher-order/seckill/13 |
- 看样子是
VoucherOrderController
里的方法
1 |
|
- 那我们现在来分析一下怎么抢优惠券
- 首先提交优惠券id,然后查询优惠券信息
- 之后判断秒杀时间是否开始
- 开始了,则判断是否有剩余库存
- 有库存,那么删减一个库存
- 然后创建订单
- 无库存,则返回一个错误信息
- 有库存,那么删减一个库存
- 没开始,则返回一个错误信息
- 开始了,则判断是否有剩余库存
- 对应的流程图如下
- 那现在我们就根据我们刚刚的分析和流程图,来编写对应的代码
具体的业务逻辑我们还是放到Service层里写,在Service层创建seckillVoucher方法
1 |
|
1 | public interface IVoucherOrderService extends IService<VoucherOrder> { |
1 |
|
超卖问题
- 我们之前的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景,URL为 localhost:8081/voucher-order/seckill/13,请求方式为POST
- 测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张
- 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
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; |
- 其中do while是为了操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次
- 该项目中的具体解决方式
- 这里并不需要真的来指定一下
版本号
,完全可以使用stock
来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同
1 | @Override |
- 以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
- 那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作
1 | @Override |
- 重启服务器,继续使用Jmeter进行测试,这次就能顺利将优惠券刚好抢空了
一人一单
- 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
- 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
- 如果已存在,则不能下单,返回错误信息
- 如果不存在,则继续下单,获取优惠券
- 初步代码
1 | @Override |
存在问题
:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题- 初步代码,我们把一人一单逻辑之后的代码都提取到一个
createVoucherOrder
方法中,然后给这个方法加锁 - 不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
1 | private Result createVoucherOrder(Long voucherId) { |
- 但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是
一人一单
,所以这个锁,应该只加在单个用户上,用户标识可以用userId
1 |
|
- 由于toString的源码是new String,所以如果我们只用
userId.toString()
拿到的也不是同一个用户,需要使用intern()
,如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。
1 | public static String toString(long i) { |
- 但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题
1 |
|
- 但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用
AopContext.currentProxy()
来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService
中创建createVoucherOrder
方法
1 | Long userId = UserHolder.getUser().getId(); |
- 但是该方法会用到一个依赖,我们需要导入一下
1 | <dependency> |
- 同时在启动类上加上
@EnableAspectJAutoProxy(exposeProxy = true)
注解
1 |
|
- 重启服务器,再次使用Jmeter测试,200个线程并发,但是只能抢到一张优惠券,目的达成
集群环境下的并发问题
-
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
- 我们将服务启动两份,端口分别为8081和8082
- 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
-
具体操作,我们使用
POSTMAN
发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。 -
失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥
-
这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)
分布式锁
基本原理和实现方式对比
-
分布式锁:满足分布式系统或集群模式下多线程课件并且可以互斥的锁
-
分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
-
那么分布式锁应该满足一些什么条件呢?
- 可见性:多个线程都能看到相同的结果。
- 互斥:互斥是分布式锁的最基本条件,使得程序串行执行
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
- 高性能:由于加锁本身就让性能降低,所以对于分布式锁需要他较高的加锁性能和释放锁性能
- 安全性:安全也是程序中必不可少的一环
-
常见的分布式锁有三种
- 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)的线程,等待一定时间之后重试
- 我们利用redis的
实现分布式锁
- 锁的基本接口
1 | public interface ILock { |
- 然后创建一个SimpleRedisLock类实现接口
1 | public class SimpleRedisLock implements ILock { |
- 修改业务代码
1 |
|
- 使用Jmeter进行压力测试,请求头中携带登录用户的token,最终只能抢到一张优惠券
Redis分布式锁误删情况说明
逻辑说明
- 持有锁的线程1在锁的内部出现了阻塞,导致他的锁TTL到期,自动释放
- 此时线程2也来尝试获取锁,由于线程1已经释放了锁,所以线程2可以拿到
- 但是现在线程1阻塞完了,继续往下执行,要开始释放锁了
- 那么此时就会将属于线程2的锁释放,这就是误删别人锁的情况
解决方案
- 解决方案就是在每个线程释放锁的时候,都判断一下这个锁是不是自己的,如果不属于自己,则不进行删除操作。
- 假设还是上面的情况,线程1阻塞,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1阻塞完了,继续往下执行,开始删除锁,但是线程1发现这把锁不是自己的,所以不进行删除锁的逻辑,当线程2执行到删除锁的逻辑时,如果TTL还未到期,则判断当前这把锁是自己的,于是删除这把锁
解决Redis分布式锁误删问题
- 需求:修改之前的分布式锁实现
- 满足:在获取锁的时候存入线程标识(用UUID标识,在一个JVM中,ThreadId一般不会重复,但是我们现在是集群模式,有多个JVM,多个JVM之间可能会出现ThreadId重复的情况),在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致
- 如果一致则释放锁
- 如果不一致则不释放锁
- 核心逻辑:在存入锁的时候,放入自己的线程标识,在删除锁的时候,判断当前这把锁是不是自己存入的
- 如果是,则进行删除
- 如果不是,则不进行删除
- 具体实现代码如下
1 | private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; |
分布式锁的原子性问题
- 更为极端的误删逻辑说明
- 假设线程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数组中获取这些参数
1 | EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Lucy |
- 那现在我们来使用Lua脚本来代替我们释放锁的逻辑
1 |
|
但是现在是写死了的,我们可以通过传参的方式来变成动态的Lua脚本
1 | -- 线程标识 |
但是现在是写死了的,我们可以通过传参的方式来变成动态的Lua脚本
1 | -- 这里的KEYS[1]就是传入锁的key |
利用Java代码调用Lua脚本改造分布式锁
- 在RedisTemplate中,可以利用execute方法去执行lua脚本
1 | public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) { |
- 对应的Java代码如下
1 | private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; |
- 但是现在的分布式锁还存在一个问题:锁不住
- 那什么是锁不住呢?
- 如果锁的TTL快到期的时候,我们可以给它续期一下,比如续个30s,就好像是网吧上网,快没网费了的时候,让网管再给你续50块钱的,然后该玩玩,程序也继续往下执行
- 那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission了
- 那什么是锁不住呢?
分布式锁-Redisson
- 基于SETNX实现的分布式锁存在以下问题
- 重入问题
- 重入问题是指获取锁的线程,可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,例如在HashTable这样的代码中,它的方法都是使用synchronized修饰的,加入它在一个方法内调用另一个方法,如果此时是不可重入的,那就死锁了。所以可重入锁的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的
- 不可重试
- 我们编写的分布式锁只能尝试一次,失败了就返回false,没有重试机制。但合理的情况应该是:当线程获取锁失败后,他应该能再次尝试获取锁
- 超时释放
- 我们在加锁的时候增加了TTL,这样我们可以防止死锁,但是如果卡顿(阻塞)时间太长,也会导致锁的释放。虽然我们采用Lua脚本来防止删锁的时候,误删别人的锁,但现在的新问题是没锁住,也有安全隐患
- 主从一致性
- 如果Redis提供了主从集群,那么当我们向集群写数据时,主机需要异步的将数据同步给从机,万一在同步之前,主机宕机了(主从同步存在延迟,虽然时间很短,但还是发生了),那么又会出现死锁问题
- 重入问题
- 那么什么是Redisson呢
- Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现
- Redis提供了分布式锁的多种多样功能
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock)
- 读写锁(ReadWriteLock)
- 信号量(Semaphore)
- 可过期性信号量(PermitExpirableSemaphore)
- 闭锁(CountDownLatch)
Redisson入门
- 导入依赖
1 | <dependency> |
- 配置Redisson客户端,在config包下新建
RedissonConfig
类
1 | import org.redisson.Redisson; |
- 使用Redisson的分布式锁
1 |
|
- 替换我们之前自己写的分布式锁
这里要注入一下RedissonClient
1 | + @Resource |
1 |
|
- 使用Jmeter进行压力测试,依旧是只能抢到一张优惠券,满足我们的需求
Redisson可重入锁原理
-
在Lock锁中,他是借助于等曾的一个voaltile的一个state变量来记录重入的状态的
- 如果当前
没有
人持有这把锁,那么state = 0
- 如果
有
人持有这把锁,那么state = 1
- 如果持有者把锁的人再次持有这把锁,那么state会
+1
- 如果持有者把锁的人再次持有这把锁,那么state会
- 如果对于
synchronize
而言,他在c语言代码中会有一个count - 原理与
state
类似,也是重入一次就+1
,释放一次就-1
,直至减到0,表示这把锁没有被人持有
- 如果当前
-
在redisson中,我们也支持可重入锁
- 在分布式锁中,它采用hash结构来存储锁,其中外层key表示这把锁是否存在,内层key则记录当前这把锁被哪个线程持有
-
method1在方法内部调用method2,method1和method2出于同一个线程,那么method1已经拿到一把锁了,想进入method2中拿另外一把锁,必然是拿不到的,于是就出现了死锁
1 |
|
- 所以我们需要额外判断,method1和method2是否处于同一线程,如果是同一个线程,则可以拿到锁,但是state会
+1
,之后执行method2中的方法,释放锁,释放锁的时候也只是将state进行-1
,只有减至0,才会真正释放锁 - 由于我们需要额外存储一个state,所以用字符串型
SET NX EX
是不行的,需要用到Hash
结构,但是Hash
结构又没有NX
这种方法,所以我们需要将原有的逻辑拆开,进行手动判断
- 为了保证原子性,所以流程图中的业务逻辑也是需要我们用Lua来实现的
- 获取锁的逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 锁不存在
if (redis.call('exists', key) == 0) then
-- 获取锁并添加线程标识,state设为1
redis.call('hset', key, threadId, '1');
-- 设置锁有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁存在,判断threadId是否为自己
if (redis.call('hexists', key, threadId) == 1) then
-- 锁存在,重入次数 +1,这里用的是hash结构的incrby增长
redis.call('hincrby', key, thread, 1);
-- 设置锁的有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败- 释放锁的逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];
-- 如果锁不是自己的
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 直接返回
end;
-- 锁是自己的,锁计数-1,还是用hincrby,不过自增长的值为-1
local count = redis.call('hincrby', key, threadId, -1);
-- 判断重入次数为多少
if (count > 0) then
-- 大于0,重置有效期
redis.call('expire', key, releaseTime);
return nil;
else
-- 否则直接释放锁
redis.call('del', key);
return nil;
end; - 获取锁源码
查看源码,跟我们的实现方式几乎一致
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机制
- 前面我们分析的是空参的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
- 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
秒杀优化
异步秒杀思路
-
我们先来回顾一下下单流程
-
当用户发起请求,此时会先请求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 |
|
- 使用PostMan发送请求,添加优惠券
请求路径:http://localhost:8080/api/voucher/seckill
请求方式:POST
1 | { |
- 添加成功后,数据库中和Redis中都能看到优惠券信息
步骤二:
编写Lua脚本
lua的字符串拼接使用..
,字符串转数字是tonumber()
1 | -- 订单id |
- 修改业务逻辑
1 |
|
- 现在我们使用PostMan发送请求,redis中的数据会变动,而且不能重复下单,但是数据库中的数据并没有变化
基于阻塞队列实现秒杀优化
- 修改下单的操作,我们在下单时,是通过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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
public void run() {
while (true) {
try {
//1. 获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
//2. 创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("订单处理异常", e);
}
}
}
}- 编写创建订单的业务逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20private IVoucherOrderService proxy;
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1. 获取用户
Long userId = voucherOrder.getUserId();
//2. 创建锁对象,作为兜底方案
RLock redisLock = redissonClient.getLock("order:" + userId);
//3. 获取锁
boolean isLock = redisLock.tryLock();
//4. 判断是否获取锁成功
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
//5. 使用代理对象,由于这里是另外一个线程,
proxy.createVoucherOrder(voucherOrder);
} finally {
redisLock.unlock();
}
}- 查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
1
private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal("Current AOP proxy");
- 但是我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Result seckillVoucher(Long voucherId) {
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(), voucherId.toString(),
UserHolder.getUser().getId().toString());
if (result.intValue() != 0) {
return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
}
long orderId = redisIdWorker.nextId("order");
//封装到voucherOrder中
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setId(orderId);
//加入到阻塞队列
orderTasks.add(voucherOrder);
//主线程获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}- 完整代码如下
1 | package com.hmdp.service.impl; |
小结
-
秒杀业务的优化思路是什么?
- 先利用Redis完成库存容量、一人一单的判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
-
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题:
- 我们现在使用的是JDK里的阻塞队列,它使用的是JVM的内存,如果在高并发的条件下,无数的订单都会放在阻塞队列里,可能就会造成内存溢出,所以我们在创建阻塞队列时,设置了一个长度,但是如果真的存满了,再有新的订单来往里塞,那就塞不进去了,存在内存限制问题
- 数据安全问题:
- 经典服务器宕机了,用户明明下单了,但是数据库里没看到
- 内存限制问题:
Redis消息队列
认识消息队列
- 什么是消息队列?字面意思就是存放消息的队列,最简单的消息队列模型包括3个角色
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
- 使用队列的好处在于
解耦
:举个例子,快递员(生产者)把快递放到驿站/快递柜里去(Message Queue)去,我们(消费者)从快递柜/驿站去拿快递,这就是一个异步,如果耦合,那么快递员必须亲自上楼把快递递到你手里,服务当然好,但是万一我不在家,快递员就得一直等我,浪费了快递员的时间。所以解耦还是非常有必要的 - 那么在这种场景下我们的秒杀就变成了:在我们下单之后,利用Redis去进行校验下单的结果,然后在通过队列把消息发送出去,然后在启动一个线程去拿到这个消息,完成解耦,同时也加快我们的响应速度
- 这里我们可以直接使用一些现成的(MQ)消息队列,如kafka,rabbitmq等,但是如果没有安装MQ,我们也可以使用Redis提供的MQ方案(学完Redis我就去学微服务)
基于List实现消息队列
-
基于List结构模拟消息队列
-
消息队列(Message Queue),字面意思就是存放消息的队列,而Redis的list数据结构是一个双向链表,很容易模拟出队列的效果
-
队列的入口和出口不在同一边,所以我们可以利用:LPUSH结合RPOP或者RPUSH结合LPOP来实现消息队列。
-
不过需要注意的是,当队列中没有消息时,RPOP和LPOP操作会返回NULL,而不像JVM阻塞队列那样会阻塞,并等待消息,所以我们这里应该使用BRPOP或者BLPOP来实现阻塞效果
-
基于List的消息队列有哪些优缺点?
- 优点
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保障
- 可以满足消息有序性
- 缺点
- 无法避免消息丢失(经典服务器宕机)
- 只支持单消费者(一个消费者把消息拿走了,其他消费者就看不到这条消息了)
- 优点
基于PubSub的消息队列
- PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费和可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息
SUBSCRIBE channel [channel]
:订阅一个或多个频道PUBLISH channel msg
:向一个频道发送消息PSUBSCRIBE pattern [pattern]
:订阅与pattern格式匹配的所有频道
- 基于PubSub的消息队列有哪些优缺点
- 优点:
- 采用发布订阅模型,支持多生产,多消费
- 缺点:
- 不支持数据持久化
- 无法避免消息丢失(如果向频道发送了消息,却没有人订阅该频道,那发送的这条消息就丢失了)
- 消息堆积有上限,超出时数据丢失(消费者拿到数据的时候处理的太慢,而发送消息发的太快)
- 优点:
基于Stream的消息队列
- Stream是Redis 5.0引入的一种新数据类型,可以时间一个功能非常完善的消息队列
- 发送消息的命令
1 | XADD key [NOMKSTREAM] [MAXLEN|MINID [=!~] threshold [LIMIT count]] *|ID field value [field value ...] |
- 举例
1 | ## 创建名为users的队列,并向其中发送一个消息,内容是{name=jack, age=21},并且使用Redis自动生成ID |
- 读取消息的方式之一:XREAD
1 | XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...] |
- 例如:使用XREAD读取第一个消息
1 | 云服务器:0>XREAD COUNT 1 STREAMS users 0 |
- 例如:XREAD阻塞方式,读取最新消息
1 | XREAD COUNT 2 BLOCK 10000 STREAMS users $ |
- 在业务开发中,我们可以使用循环调用的XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下
1 | while (true){ |
- STREAM类型消息队列的XREAD命令特点
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有漏读消息的风险
基于Stream的消息队列–消费者组
- 消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列,具备以下特点
- 消息分流
- 队列中的消息会分留给组内的不同消费者,而不是重复消费者,从而加快消息处理的速度
- 消息标识
- 消费者会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每一个消息都会被消费
- 消息确认
- 消费者获取消息后,消息处于pending状态,并存入一个pending-list,当处理完成后,需要通过XACK来确认消息,标记消息为已处理,才会从pending-list中移除
- 消息分流
- 创建消费者组
1 | XGROUP CREATE key groupName ID [MKSTREAM] |
- 其他常见命令
- 删除指定的消费者组
1
XGROUP DESTORY key groupName
- 给指定的消费者组添加消费者
1
XGROUP CREATECONSUMER key groupName consumerName
- 删除消费者组中指定的消费者
1
XGROUP DELCONSUMER key groupName consumerName
- 从消费者组中读取消息
1 | XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [keys ...] ID [ID ...] |
- 消费者监听消息的基本思路
1 | while(true){ |
- STREAM类型消息队列的XREADGROUP命令的特点
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读风险
- 有消息确认机制,保证消息至少被消费一次
List | PubSub | Stream | |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间, 可以利用多消费者加快处理 |
受限于消费者缓冲区 | 受限于队列长度, 可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |
Stream消息队列实现异步秒杀下单
- 需求:
- 创建一个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 | @Override |
1 |
|
- 根据伪代码来修改我们的
VoucherOrderHandler
1 | while(true){ |
1 | String queueName = "stream.orders"; |
达人探店
发布探店笔记
这部分代码已经提供好了,我们来看看对应的数据表
探店店笔记表,包含笔记中的标题、文字、图片等
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
id | bigint unsigned | (NULL) | NO | PRI | (NULL) | auto_increment | 主键 |
shop_id | bigint | (NULL) | NO | (NULL) | 商户id | ||
user_id | bigint unsigned | (NULL) | NO | (NULL) | 用户id | ||
title | varchar(255) | utf8mb4_unicode_ci | NO | (NULL) | 标题 | ||
images | varchar(2048) | utf8mb4_general_ci | NO | (NULL) | 探店的照片,最多9张,多张以","隔开 | ||
content | varchar(2048) | utf8mb4_unicode_ci | NO | (NULL) | 探店的文字描述 | ||
liked | int unsigned | (NULL) | YES | 0 | 点赞数量 | ||
comments | int unsigned | (NULL) | YES | (NULL) | 评论数量 | ||
create_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 | |
update_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP | 更新时间 |
- 对应的实体类,数据表中并没有用户头像和用户昵称,但是对应的实体类里却有,这是因为使用了
@TableField(exist = false)
用来解决实体类中有的属性但是数据表中没有的字段
1 |
|
- 其他用户对探店笔记的评价
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
id | bigint unsigned | (NULL) | NO | PRI | (NULL) | auto_increment | 主键 |
user_id | bigint unsigned | (NULL) | NO | (NULL) | 用户id | ||
blog_id | bigint unsigned | (NULL) | NO | (NULL) | 探店id | ||
parent_id | bigint unsigned | (NULL) | NO | (NULL) | 关联的1级评论id,如果是一级评论,则值为0 | ||
answer_id | bigint unsigned | (NULL) | NO | (NULL) | 回复的评论id | ||
content | varchar(255) | utf8mb4_general_ci | NO | (NULL) | 回复的内容 | ||
liked | int unsigned | (NULL) | YES | (NULL) | 点赞数 | ||
status | tinyint unsigned | (NULL) | YES | (NULL) | 状态,0:正常,1:被举报,2:禁止查看 | ||
create_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 | |
update_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP | 更新时间 |
- 对应的实体类
1 |
|
- 效果图如下
- 对应的代码
1 |
|
- 上传图片的代码
1 |
|
查看探店笔记
- 需求:点击首页的探店笔记,会进入详情页面,我们现在需要实现页面的查询接口
- 随便点击一张图片,查看发送的请求
请求网址: http://localhost:8080/api/blog/6
请求方法: GET
- 看样子是
BlogController
下的方法,请求方式为GET,那我们直接来编写对应的方法
业务逻辑我们要写在Service层,Controller层只调用
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字段
- 修改点赞功能,利用Redis中的set集合来判断是否点赞过,未点赞则点赞数
-
具体实现
业务逻辑卸载Service层
1 |
|
在BlogService接口中创建对应方法,在Impl中实现
1 |
|
- 修改完毕之后,页面上还不能立即显示点赞完毕的后果,我们还需要修改查询Blog业务,判断Blog是否被当前用户点赞过
1 |
|
点赞排行榜
- 当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的TOP5,形成点赞排行榜,就跟QQ空间发的说说一样,可以看到有哪些人点了赞
- 之前的点赞是放到Set集合中,但是Set集合又不能排序,所以这个时候,我们就可以改用SortedSet(Zset)
- 那我们这里顺便就来对比一下这些集合的区别
List | Set | SortedSet | |
---|---|---|---|
排序方式 | 按添加顺序排序 | 无法排序 | 根据score值排序 |
唯一性 | 不唯一 | 唯一 | 唯一 |
查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |
- 修改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 |
|
- 具体逻辑如下
1 |
|
- 重启服务器,查看效果
好友关注
关注和取消关注
- 当我们进入到笔记详情页面时,会发送一个请求,判断当前登录用户是否关注了笔记博主
请求网址: http://localhost:8080/api/follow/or/not/2
请求方法: GET
- 当我们点击关注按钮时,会发送一个请求,实现关注/取关
请求网址: http://localhost:8080/api/follow/2/true
请求方法: PUT
- 关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
id | bigint | (NULL) | NO | PRI | (NULL) | auto_increment | 主键 |
user_id | bigint unsigned | (NULL) | NO | (NULL) | 用户id | ||
follow_user_id | bigint unsigned | (NULL) | NO | (NULL) | 关联的用户id | ||
create_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 |
- 对应的实体类如下
1 |
|
- 那我们现在来Controller层中编写对应的两个方法
1 |
|
- 具体的业务逻辑我们还是放在FellowServiceImpl中来编写
1 |
|
- 测试效果如下
共同关注
-
点击用户头像,进入到用户详情页,可以查看用户发布的笔记,和共同关注列表
-
但现在我们还没写具体的业务逻辑,所以现在暂时看不到数据
-
检测NetWork选项卡,查看发送的请求
- 查询用户信息
请求网址: http://localhost:8080/api/user/2
请求方法: GET- 查看共同关注
请求网址: http://localhost:8080/api/follow/common/undefined
请求方法: GET -
编写
查询用户信息
方法
1 |
|
- 重启服务器,现在可以看到用户信息,但是不能看到用户发布的笔记信息,查看NetWork检测的请求,我们还需要完成这个需求
请求网址: http://localhost:8080/api/blog/of/user?&id=2¤t=1
请求方法: GET
- 编写
查询用户笔记
方法
1 |
|
-
效果如下
-
接下来我们来看看怎么实现共同关注
- 实现方式当然是我们之前学过的set集合,在set集合中,有交集并集补集的api,可以把二者关注的人放入到set集合中,然后通过api查询两个set集合的交集
- 那我们就得先修改我们之前的关注逻辑,在关注博主的同时,需要将数据放到set集合中,方便后期我们实现共同关注,当取消关注时,也需要将数据从set集合中删除
1 |
|
- 那么接下来,我们实现共同关注代码
业务逻辑写在Impl中
1 |
|
1 |
|
- 最终效果如下
Feed流实现方案
- 当我们关注了用户之后,这个用户发布了动态,那我们应该把这些数据推送给用户,这个需求,我们又称其为Feed流,关注推送也叫作Feed流,直译为投喂,为用户提供沉浸式体验,通过无限下拉刷新获取新的信息,
- 对于传统的模式内容检索:用户需要主动通过搜索引擎或者是其他方式去查找想看的内容
- 对于新型Feed流的效果:系统分析用户到底想看什么,然后直接把内容推送给用户,从而使用户能更加节约时间,不用去主动搜素
- Feed流的实现有两种模式
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(B站关注的up,朋友圈等)
- 优点:信息全面,不会有缺失,并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的信息来吸引用户
- 优点:投喂用户感兴趣的信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能会起到反作用(给你推的你都不爱看)
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(B站关注的up,朋友圈等)
- 那我们这里针对好友的操作,采用的是Timeline方式,只需要拿到我们关注用户的信息,然后按照时间排序即可
- 采用Timeline模式,有三种具体的实现方案
- 拉模式
- 推模式
- 推拉结合
拉模式
:也叫读扩散- 该模式的核心含义是:当张三和李四、王五发了消息之后,都会保存到自己的发件箱中,如果赵六要读取消息,那么他会读取他自己的收件箱,此时系统会从他关注的人群中,将他关注人的信息全都进行拉取,然后进行排序
- 优点:比较节约空间,因为赵六在读取信息时,并没有重复读取,并且读取完之后,可以将他的收件箱清除
- 缺点:有延迟,当用户读取数据时,才会去关注的人的时发件箱中拉取信息,假设该用户关注了海量用户,那么此时就会拉取很多信息,对服务器压力巨大
推模式
:也叫写扩散- 推模式是没有写邮箱的,当张三写了一个内容,此时会主动把张三写的内容发送到它粉丝的收件箱中,假设此时李四再来读取,就不用再去临时拉取了
- 优点:时效快,不用临时拉取
- 缺点:内存压力大,假设一个大V发了一个动态,很多人关注他,那么就会写很多份数据到粉丝那边去
推拉结合
:页脚读写混合,兼具推和拉两种模式的优点- 推拉模式是一个折中的方案,站在发件人这一边,如果是普通人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝收件箱中,因为普通人的粉丝数量较少,所以这样不会产生太大压力。但如果是大V,那么他是直接将数据写入一份到发件箱中去,在直接写一份到活跃粉丝的收件箱中,站在收件人这边来看,如果是活跃粉丝,那么大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 |
|
- 最终效果如下,在最上方显示的都是我们最新发布的动态
附近商户
GEO数据结构的基本用法
- GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据,常见的命令有
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转化为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEOGADIUS:指定圆心、半径,找到该园内包含的所有member,并按照与圆心之间的距离排序后返回,
6.2之后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与制定点之间的距离排序后返回,范围可以使圆形或矩形,6.2的新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key,也是6.2的新功能
导入店铺数据到GEO
- 具体场景说明,例如美团/饿了么这种外卖App,你是可以看到商家离你有多远的,那我们现在也要实现这个功能。
- 我们可以使用GEO来实现该功能,以当前坐标为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件插入后台,后台查询出对应的数据再返回
- 那现在我们要做的就是:将数据库中的数据导入到Redis中去,GEO在Redis中就是一个member和一个经纬度,经纬度对应的就是tb_shop中的x和y,而member,我们用shop_id来存,因为Redis只是一个内存级数据库,如果存海量的数据,还是力不从心,所以我们只存一个id,用的时候再拿id去SQL数据库中查询shop信息
- 但是此时还有一个问题,我们在redis中没有存储shop_type,无法根据店铺类型来对数据进行筛选,解决办法就是将type_id作为key,存入同一个GEO集合即可
Key | Value | Score |
shop:geo:美食 | 海底捞 | 40691512240174598 |
吉野家 | 40691519846517915 | |
shop:geo:KTV | KTV 01 | 40691165486458787 |
KTV 02 | 40691514154651657 |
- 代码如下
1 |
|
- 但是上面的代码不够优雅,是一条一条写入的,效率较低,那我们现在来改进一下,这样只需要写入等同于type_id数量的次数
1 |
|
- 代码编写完毕,我们启动测试方法,然后去Redis图形化界面中查看是否有对应的数据
实现附近商户功能
- SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的pom.xml文件
1 | <dependency> |
- 点击距离分类,查看发送的请求
请求网址: http://localhost:8080/api/shop/of/type?&typeId=1¤t=1&x=120.149993&y=30.334229
请求方法: GET
- 看样子是ShopController中的方法,那我们现在来修改其代码,除了typeId,分页码,我们还需要其坐标
1 |
|
- 具体业务逻辑依旧是写在ShopServiceImpl中
1 |
|
- 最终效果如下,可以显示出距离
用户签到
BitMap功能演示
- 我们针对签到功能完全可以通过MySQL来完成,例如下面这张表
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
id | bigint unsigned | (NULL) | NO | PRI | (NULL) | auto_increment | 主键 |
user_id | bigint unsigned | (NULL) | NO | (NULL) | 用户id | ||
year | year | (NULL) | NO | (NULL) | 签到的年 | ||
month | tinyint | (NULL) | NO | (NULL) | 签到的月 | ||
date | date | (NULL) | NO | (NULL) | 签到的日期 | ||
is_backup | tinyint unsigned | (NULL) | YES | (NULL) | 是否补签 |
-
用户签到一次,就是一条记录,假如有1000W用户,平均没人每年签到10次,那这张表一年的数据量就有1亿条
-
那有没有方法能简化一点呢?我们可以使用二进制位来记录每个月的签到情况,签到记录为1,未签到记录为0
-
把每一个bit位对应当月的每一天,形成映射关系,用0和1标识业务状态,这种思路就成为位图(BitMap)。这样我们就能用极小的空间,来实现大量数据的表示
-
Redis中是利用String类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2^32个bit位
-
BitMap的操作命令有
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT:获取指定位置(offset)的bit值
- BITCOUNT:统计BitMap中值为1的bit位的数量
- BITFIELD:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO:获取BitMap中bit数组,并以十进制形式返回
- BITOP:将多个BitMap的结果做位运算(与、或、异或)
- BITPOS:查找bit数组中指定范围内第一个0或1出现的位置
实现签到功能
- 需求:实现签到接口,将当前用户当天签到信息保存到Redis中
说明 | |
---|---|
请求方式 | Post |
请求路径 | /user/sign |
请求参数 | 无 |
返回值 | 无 |
- 思路:我们可以把年和月作为BitMap的key,然后保存到一个BitMap中,每次签到就把对应位上的0变成1,只要是1就说明这一天已经签到了,反之则没有签到
- 由于BitMap底层是基于String数据结构,因此其操作也都封装在字符串相关操作中了
- 在UserController中编写对应的方法
1 |
|
- 具体实现
1 |
|
- 使用PostMan发送请求测试,注意请求头中需携带登录用户的token,否则无效(又浪费我五分钟找这个问题)
- 发送成功之后,在Redis图形化界面中是可以看到的
签到统计
- 如何获取本月到今天为止的所有签到数据?
- BITFIELD key GET u[dayOfMonth] 0
- 如何从后往前遍历每个bit位,获取连续签到天数
- 连续签到天数,就是从末尾往前数,看有多少个1
- 简单的位运算算法
1
2
3
4
5
6
7
8
9int count = 0;
while(true) {
if((num & 1) == 0)
break;
else
count++;
num >>>= 1;
}
return count; - 需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
说明 | |
---|---|
请求方式 | GET |
请求路径 | /user/sign/count |
请求参数 | 无 |
返回值 | 连续签到天数 |
- 在UserController中创建对应的方法
1 |
|
- 在UserServiceImpl中实现方法
1 |
|
- 使用PostMan发送请求,可以手动修改redis中的签到数据多次测试,发请求的时候还是要注意携带登录用户的token
UV统计
HyperLogLog
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
- 本博客的首页侧边栏就有本站访客量和本站总访问量,对应的就是UV和PV
- 通常来说PV会比UV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素。
- UV统计在服务端做会很麻烦,因为要判断该用户是否已经统计过了,需要将统计过的信息保存,但是如果每个访问的用户都保存到Redis中,那么数据库会非常恐怖,那么该如何处理呢?
- HyperLogLog(HLL)是从Loglog算法派生的概率算法,用户确定非常大的集合基数,而不需要存储其所有值,算法相关原理可以参考下面这篇文章:https://juejin.cn/post/6844903785744056333#heading-0
- Redis中的HLL是基于string结构实现的,单个HLL的内存
永远小于16kb
,内存占用低
的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差
。不过对于UV统计来说,这完全可以忽略。 - 常用的三个方法
1 | PFADD key element [element...] |
测试百万数据的统计
- 使用单元测试,向HyperLogLog中添加100万条数据,看看内存占用是否真的那么低,以及统计误差如何
1 |
|
- 插入100W条数据,得到的count为997593,误差率为0.002407%
- 去Redis图形化界面中查看占用情况为:12.3K字节