一、redis简介
Redis诞生于2009年全称是Remote Dictionary Server 远程词典服务器,是一个基于内存的键值型NoSQL数据库。因为是储存在内存当中的,所以读写速度非常的快
特征:
键值(key-value)型,value支持多种不同数据结构,功能丰富
单线程,每个命令具备原子性
低延迟,速度快(基于内存、IO多路复用、良好的编码)。
支持数据持久化
支持主从集群、分片集群
支持多语言客户端
作者:Antirez
Redis的官方网站地址:Redis - The Real-time Data Platform
2、linux安装redis
a、redis是基于c语言的所以需要安装redis所需gcc依赖
yum install -y gcc tcl
b、我使用的是redis6.2.6
安装位置 /usr/local/src/redis
c、将redis安装包解压之后,redis运行安装命令
make && make install
如果要修改配置文件的话
d、然后我们可以编写系统命令 让下次启动起来不那么麻烦
下次启动停止就可以使用
systemctl start redis 启动
systemctl status redis 查看状态
systrmctl stop redis 停止
二、redis五大存储类型命令
1、string类型
场景:如计数器,文章的阅读量等
2、hash类型
场景:电商购物车等
3、list列表
场景:微博,朋友圈,公众号,关注文章展示等
list类型与一个队列一个栈
4、set集合
5、zest有序集合
6、点评中拦截器判断是否登录的问题
一开始的拦截器(判断是否登录)
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的
三、redis缓存
1、什么是缓存
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代
2、缓存的作用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
3、使用缓存的思路
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
实例:
/*商户查询缓存
* 实现缓存的原理就说
* */
@Override
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY+id;
//1.尝试从redis查询数据
String shopjson = stringRedisTemplate.opsForValue().get(key);
//2。判断数据是否存在 存在直接返回
if (StringUtils.isNotBlank(shopjson)) {
Shop shop = JSONUtil.toBean(shopjson,Shop.class);
return Result.ok(shop);
}
//3、不存在从数据库当中查询数据,然后存储到redis当中
Shop shop = shopMapper.selectById(id);
if(shop==null){
return Result.fail("店铺不存在");
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
四、缓存的更新策略
最佳实践
1、为什么使用缓存
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
2、缓存更新的几种方式
3、缓存跟新数据不一致的解决方案
a、缘由
缘由: 由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。
后果: 用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等
b、 主动跟新 解决方案
c、 实现方案选择
综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新缓存(***)
如何保证缓存与数据库的操作的同时成功或失败?
单体系统,将缓存与数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
先操作缓存还是先操作数据库?
先删除缓存,再操作数据库
先操作数据库,再删除缓存 (***)
d、实例代码
/*商户查询缓存
* 实现缓存的原理就说
* */
@Override
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY+id;
//1.尝试从redis查询数据
String shopjson = stringRedisTemplate.opsForValue().get(key);
//2。判断数据是否存在 存在直接返回
if (StringUtils.isNotBlank(shopjson)) {
Shop shop = JSONUtil.toBean(shopjson,Shop.class);
return Result.ok(shop);
}
//3、不存在从数据库当中查询数据,然后存储到redis当中
Shop shop = shopMapper.selectById(id);
if(shop==null){
return Result.fail("店铺不存在");
}
//4、将数据写入到缓存当中,设置超时剔除 提高数据的一致性
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key,24L, TimeUnit.HOURS);
return Result.ok(shop);
}
//修改数据 更新缓存
//这里我们使用的是删除缓存
//减少并发时候的数据不一致 采取先操作数据库操作 在删除缓存
@Override
@Transactional
public Result updateShop(Shop shop) {
String key = RedisConstants.CACHE_SHOP_KEY+shop.getId();
if(shop.getId()==null){
return Result.fail("店铺的id不能为空");
}
//修改数据库操作
updateById(shop);
//数据库更新完毕,删除缓存
stringRedisTemplate.delete(key);
return Result.ok();
}
4、缓存穿透
a、缘由
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。高并发的情况下,数据库承受不住压力容易宕机。
b、常见的解决方案
缓存空对象
优点:实现简单,维护方便
缺点:
额外的内存消耗
可能造成短期的不一致
布隆过滤
优点:内存占用较少,没有多余key
缺点:
实现复杂
存在误判可能
缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于::节约内存空间,存在误判,
误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突
图片展示::
c、实例代码
/*商户查询缓存
* 实现缓存的原理就是查询到的数据存入redis当中 下次访问从缓存当中查询到数据的话直接返回
* */
@Override
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY+id;
//1.尝试从redis查询数据
String shopjson = stringRedisTemplate.opsForValue().get(key);
//2。判断数据是否存在 存在直接返回
if (StringUtils.isNotBlank(shopjson)) {
Shop shop = JSONUtil.toBean(shopjson,Shop.class);
return Result.ok(shop);
}
//3、数据不为null,是我们后面定义的“”空值 那么返回前端为缓存数据为空
if(shopjson!=null){
return Result.fail("缓存当中数据为空");
}
//3、不存在从数据库当中查询数据,然后存储到redis当中
Shop shop = shopMapper.selectById(id);
if(shop==null){
// *****缓存击穿问题 我们使用的是设置数据为空到缓存当中,这个会出现短期的数据不一致问题。所以可以将过期时间设置的短一些
stringRedisTemplate.opsForValue().set(key,"",5L,TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
//4、将数据写入到缓存当中,设置超时剔除 提高数据的一致性
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
stringRedisTemplate.expire(key,24L, TimeUnit.HOURS);
return Result.ok(shop);
}
e、总结
重点:
如果redis当中没有键,我们查询redis,返回的就是null
如果是缓存穿透使用的空值缓存 “”空字符串 ,返回的就是空字符串
缓存穿透产生的原因是什么?
用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
缓存null值
布隆过滤
增强id的复杂度,避免被猜测id规律
做好数据的基础格式校验
加强用户权限校验
做好热点参数的限流
5、缓存雪崩
a、缓存雪崩问题以及思路
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
-
给不同的Key的TTL添加随机值
-
利用Redis集群提高服务的可用性
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存
6、缓存击穿
a、缘由
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
逻辑分析: 假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
b、解决方案
常见的解决方案:
互斥锁
逻辑过期
解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
c、具体实现
互斥锁实现::
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿
实现代码
//缓存击穿方法抽取,重构缓存的时候只能有一个人在数据
//互斥锁的方法
public Shop queryWithMutex(Long id){
String key = RedisConstants.CACHE_SHOP_KEY+id;
//1.从redis当中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.命中数据,直接返回
if (StringUtils.isNotBlank(shopJson)) {
return JSON.parseObject(shopJson,Shop.class);
}
//3.缓存穿透空值判断 不为null,表示为空 我们缓存穿透使用的就是赋空方式
if (shopJson!=null){
return null;
}
//4.尝试重构缓存
String lockKey = RedisConstants.LOCK_SHOP_KEY+id;
Shop shop = null;
try {
//4.1获取锁
boolean lock = tryLock(lockKey);
if (!lock){
//4.2获取锁失败,休眠一段时间
//然后再次尝试获取缓存
Thread.sleep(100);
return queryWithMutex(id);
}
//4.3 获成功取到锁,查询数据库
shop = getById(id);
if (shop==null){
//4.4 数据库不存在,将空值写入redis 并设置过期时间
stringRedisTemplate.opsForValue().set(key,"",2L,TimeUnit.MINUTES);
return null;
}
//4.5 数据库查询到数据 将数据写入redis当中,并设置过期时间
stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(shop));
stringRedisTemplate.expire(key,30L,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//5不管是出现异常还是方法成功执行 我们都需要让锁去释放
unlock(lockKey);
}
//6 数据返回
return shop;
}
/*获取锁*/
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 30L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/*释放锁*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
逻辑过期实现::
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。
代码实现(其中的RedisData类就两个数据 一个过期时间 一个Object类型的data)
//缓存击穿策略2 使用逻辑过期时间解决---》》 查询数据写入redis中
public void saveShop2Redis(Long id,Long expireSeconds) {
String key = RedisConstants.CACHE_SHOP_KEY+id;
System.out.println("开始构建缓存");
//1查询店铺数据
Shop shop = getById(id);
//2.封装过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入redis
stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(redisData));
}
提示:: 因为这种方案的话是基于我们进行了缓存预热的情况 redis当中有数据 我们才会进行下面的逻辑判断 如果redis当中没有数据 缓存未命中 我们是直接返回空数据
//创建一个大小为10的线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/*缓存击穿策略2 使用逻辑过期时间解决*/
public Shop queryWithLogicalExpire(Long id){
String key = RedisConstants.CACHE_SHOP_KEY+id;
//1从redis缓存当中查询
String redisDataJson = stringRedisTemplate.opsForValue().get(key);
if(StringUtils.isBlank(redisDataJson)){
//2、缓存没有命中 直接返回空
return null;
}
//3、缓存命中 判断缓存是否过期 需要先把数据
RedisData redisData = JSON.parseObject(redisDataJson, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
Shop shop = JSON.parseObject(JSON.toJSONString(redisData.getData()),Shop.class);
if(expireTime.isAfter(LocalDateTime.now())){
//4 缓存没有过期 直接返回
return shop;
}
//5、缓存过期,需要缓存重建
String localKey = RedisConstants.LOCK_SHOP_KEY+id;
//5.1 尝试获取锁
boolean lock = tryLock(localKey);
if(lock){
System.out.println("获取到锁");
//5.2 获取锁成功 异步线程去实现缓存构建,结束之后释放锁
CACHE_REBUILD_EXECUTOR.submit( ()->{
System.out.println("开始构建");
try {
//开启独立线程去重构缓存
this.saveShop2Redis(id,60L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(localKey);
}
});
}
//6没有获取到锁 和获取到锁都是直接返回之前的缓存数据
//只不过获取到锁的已经去修改缓存数据了
return shop;
}
/*获取锁*/
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 30L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/*释放锁*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
五、优惠卷秒杀
1、全局唯一ID
a、缘由
一般的店铺都会发放优惠卷等 当用户抢购时,订单就会保存到数据当中 如果使用数据库自增id的话,会产生一些问题
-
id的规律性太明显
-
受单表数据量的限制
场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。
b、全局唯一id特性
为了增加id的唯一性 我们一般不会直接使用redisid自增 而是会拼接一些其他信息
c、代码实现
/*全局id生成*/
@Component
public class RedisWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/* ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID*/
public long nextId(String keyPrefix){
//1.生成时间错
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long time = nowSecond-BEGIN_TIMESTAMP;
System.out.println(time);
//2生成序列号
//2.1 获取当前时间,精确到天
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//调用redis的key自增方式
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3拼接并返回 将时间戳左移32位数 然后+1
return time<< COUNT_BITS | count;
}
}
2、实现优惠卷秒杀下单
a、思路
秒杀下单应该思考的内容:
下单时需要判断两点:
-
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
-
库存是否充足,不足则无法下单
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件
比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。
b、并发情况下引发的超卖问题
常见情况: 假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
常见的解决方式
重点:: 悲观锁的话是串行运行程序,效率低,性能差,所以我们一般使用的是乐观锁
c、代码实现
这里返回的是我们订单的id,可以根据实际情况进行返回;
超卖问题解决方案
//5.库存充足 扣减库存 setsql是扣减库存
但是高并发的情况下 可能会出现超卖问题,所以我们采用的是乐观锁, 本来应该有一个版本号进行判断 ,但是实际库存也可以当版本号去使用,
要是使用库存查询和修改数据时候相等的话,并发情况下只有一个能够成功 所以不满足我们的需求 我们让它修改时候只要库存大于0就可以修改了
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0).update();
/*
* 秒杀下单实现
* 返回的是订单id
* */
@Override
public Result seckillVoucher(Long voucherId) {
log.info("秒杀下单实现开始执行");
//1.查询秒杀优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀时间是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
//秒杀开始时间在当前时间之前
return Result.fail("秒杀还没有开始");
}
//3.判断秒杀时间是否已经过
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
//秒杀时间已经过去
return Result.fail("秒杀时间已经过期");
}
//4.判断库存是否充足
if(seckillVoucher.getStock()<1){
//库存不足
return Result.fail("库存不足");
}
//5.库存充足 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0).update();
if (!success){
return Result.fail("库存扣减失败");
}
//6创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//下单用户id
voucherOrder.setUserId(UserHolder.getUser().getId());
//订单id
long id = redisWorker.nextId("order");
voucherOrder.setId(id);
//优惠卷id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(id);
}
3、实现一个人一单
重点:
适合单机项目 ,只有一个tomcat的情况,集群下可能会使得synchronzied失效,因为集群的情况下,synchronzied的监视器记住的是jvm,所以还是会开两个
a、缘由
这里的意思就是一个用户只能下一单我们的秒杀价优惠卷,不然一个人可以买多个不符合业务需求。
并发情况下,如果我们按下面所写,直接在扣减库存之前判断,但是并发情况下,我们还没修改数据库,多个线程进行了判断,还是会出现一人多单的情况,所以想到的就是加锁,但是乐观锁时候查询数据,这里使用悲观锁去实现
b、具体代码实现
实现一人一单: 问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题。
但是你要想事务生效,还得利用代理对象来做。
对于锁的粒度的话,如果加在createVoucherOrder方法上的话,会导致每个线程进来都会被锁住,如果在createVoucherOrder方法内部加锁的话,会导致当前事务还没有提交,我们的锁就被释放了,再没提交的过程当中还有可能会被其他线程获得锁并执行。
第一步::
这里我们需要先添加代理对象所需依赖 然后在开启代理对象
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
然后启动类加上注解,将我们的代理对象暴露出来 ,上面才可以
@EnableAspectJAutoProxy(exposeProxy = true) //**将代理对象暴露
/*
* 秒杀下单实现
* 返回的是订单id
* */
@Override
public Result seckillVoucher(Long voucherId) {
log.info("秒杀下单实现开始执行");
//1.查询秒杀优惠卷信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀时间是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
//秒杀开始时间在当前时间之前
return Result.fail("秒杀还没有开始");
}
//3.判断秒杀时间是否已经过
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
//秒杀时间已经过去
return Result.fail("秒杀时间已经过期");
}
//4.判断库存是否充足
if(seckillVoucher.getStock()<1){
//库存不足
return Result.fail("库存不足");
}
//5.实现一人一单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()){
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
/*創建用戶訂單*/
@Transactional
@Override
public Result createVoucherOrder(Long voucherId){
//5、一个用户只能下一单
Long userId = UserHolder.getUser().getId();
int count = this.query().eq("user_id",userId).eq("voucher_id",voucherId).count();
if (count>0){
return Result.fail("你已经购买过秒杀优惠卷了");
}
//5.库存充足 扣减库存 setsql是扣减库存 但是高并发的情况下 可能会出现超卖问题
//所以我们采用的是乐观锁 本来应该有一个版本号进行判断 但是实际库存也可以当版本号去使用
//要是使用库存查询和修改数据时候相等的话,并发情况下只有一个能够成功 所以不满足我们的需求 我们让它修改时候只要库存大于0就可以修改了
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0).update();
if (!success){
return Result.fail("库存扣减失败");
}
//6创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//下单用户id
voucherOrder.setUserId(UserHolder.getUser().getId());
//订单id
long id = redisWorker.nextId("order");
voucherOrder.setId(id);
//优惠卷id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(id);
}
隐患::
六、分布式锁的实现
1、基本原理
实现一人一单当中的我们实现的加锁判断,在单个tomcat下有用,但是集群下面多个tomcat就会失效,还是会有两个获得锁,然后下单,所以不行。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
2、分布式锁满足条件、常见的分布式锁
那么分布式锁他应该满足一些什么样的条件呢?
可见性: 多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥: 互斥是分布式锁的最基本的条件,使得程序串行执行
高可用: 程序不易崩溃,时时刻刻都保证较高的可用性
高性能: 由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性: 安全也是程序中必不可少的一环
常见的分布式锁
3、redis分布式锁实现方式
实现分布式锁时需要实现的两个基本方法:
获取锁:
互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回true,失败返回false
释放锁:
手动释放
超时释放:获取锁时添加一个超时时间
a、版本一
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性(锁会出现误删,删除锁的原子性问题)
出现误删的情况::
逻辑说明:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明
解决方案:
解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。
代码实现
/*一人一单的分布式锁*/
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/*锁的前缀*/
private static final String KEY_PREFIX = "lock:";
/*锁的标识前缀,防止误删
* uuid拼接后面添加的线程id
* */
private static final String ID_PREFIX = UUID.fastUUID().toString(true)+"--";
/*获取锁*/
@Override
public boolean tryLock(long timeout) {
//获得当前线程的唯一标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁
Boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeout, TimeUnit.SECONDS);
//直接返回的话,会做一个自动拆箱的过程,为了防止自动拆箱;出现空指针异常 所以可以做一个判断去返回
return Boolean.TRUE.equals(isLock);
}
/*释放锁*/
@Override
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);
//如果之前存储的锁标识和现在获取的锁标识相等 那就说明是同一把锁
//可以删除
if(threadId.equals(value)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
b、版本二(解决删除原子性问题)
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生
使用lua解决原子性问题
1.lua脚本
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
2、代码实现
/*一人一单的分布式锁*/
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/*锁的前缀*/
private static final String KEY_PREFIX = "lock:";
/*锁的标识前缀,防止误删
* uuid拼接后面添加的线程id
* */
private static final String ID_PREFIX = UUID.fastUUID().toString(true)+"--";
/*获取锁*/
@Override
public boolean tryLock(long timeout) {
//获得当前线程的唯一标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁
Boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeout, TimeUnit.SECONDS);
//直接返回的话,会做一个自动拆箱的过程,为了防止自动拆箱;出现空指针异常 所以可以做一个判断去返回
return Boolean.TRUE.equals(isLock);
}
//解决释放锁的原子性操作
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
/*释放锁(解决防止因为业务获取标识,判断期间出现事务问题)
* 使用lua是execute 需要传入三个参数
* 执行的脚本--指定的key--指定的value
* */
@Override
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
Collections.singletonList(ID_PREFIX+Thread.currentThread().getId())
);
}
}
4、总结
基于Redis的分布式锁实现思路:
利用set nx ex获取锁,并设置过期时间,保存线程标示
释放锁时先判断线程标示是否与自己一致,一致则删除锁
特性:
利用set nx满足互斥性
利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
利用Redis集群保证高可用和高并发特性
笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
测试逻辑:
第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。
七、分布式锁-redission
基于setnx实现的分布式锁存在下面的问题:
重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
1、redission入门案例
a、什么是redission
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现
b、依赖导入
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
c、配置redission客户端
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.200.129:6379")
.setPassword("Xx20011128");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
d、使用redission分布式锁
lock.tryLock(1,10,TimeUnit.SECONDS);
- KEYS[1]:锁的名称
- ARGV[1]:设置的锁的过期时间 默认为30秒
- ARGV[2]:id + “:” + threadId, 锁的field
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
2、redission可重入锁原理
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
在redission中,我们的也支持支持可重入锁
在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有,所以接下来我们一起分析一下当前的这个lua表达式
a、获取锁的lua脚本
b、释放锁的lua脚本
3、锁重试机制
waitTime:锁的最大重试时间
leaseTime::锁的过期时间,如果我们不传参数的话,它默认就是-1,就会触发我们的看门狗机制,每过internalLockLeaseTime / 3 秒会续期,将锁设置为internalLockLeaseTime秒过期, internalLockLeaseTime默认为30,可通过setLockWatchdogTimeout()方法自定义
TimeUnit::时间单位
源码解释:
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 尝试获取锁 获取失败会返回锁的ttl 成功返回null
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired 获取锁成功 直接返回 无需重试
if (ttl == null) {
return true;
}
// 获取锁失败判断一下设置的等待时间是否还有剩余
time -= System.currentTimeMillis() - current;
// 剩余时间小于0 则说明等待超时 不需要再重试 直接返回获取锁失败
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅拿到锁的线程,该线程释放锁后会发布通知,其他锁得到消息就可以开始抢锁
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 在time时间内等待订阅结果
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
// 如果time时间耗尽还未等到锁释放的消息 则尝试取消任务
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
// 取消任务失败则取消订阅任务
unsubscribe(subscribeFuture, threadId);
}
});
}
// 返回获取锁失败的消息
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
// 走到这里说明在超时时间内等到了锁释放的信号
// 判断设定的等待时间是否还有剩余
time -= System.currentTimeMillis() - current;
if (time <= 0) {
// 等待时间已经耗尽 直接返回获取锁失败的结果
acquireFailed(waitTime, unit, threadId);
return false;
}
// 循环尝试获取锁
while (true) {
long currentTime = System.currentTimeMillis();
// 尝试获取锁 获取失败会返回锁的ttl 成功返回null
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
// 获取到锁 直接返回
return true;
}
// 获取锁失败 再次判断等待时间是否还有剩余
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
// 等待时间已经耗尽 直接返回获取锁失败的结果
acquireFailed(waitTime, unit, threadId);
return false;
}
// 等待时间还有剩余 继续尝试获取锁
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
// 如果锁的剩余时间小于等待的时间,则在锁的剩余时间内等待锁的释放消息
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
// 反之 则在剩余等待时间内 尝试获取锁释放的信号
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 再次判断等待时间是否还有剩余
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
// 返回获取锁失败
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
Redisson锁的超时重试代码尝试在给定的等待时间内获取锁,通过订阅锁状态变化的方式实现异步等待,如果在等待过程中锁未能成功获取,则通过取消订阅和执行获取锁失败的操作进行超时处理。在获取锁后,通过循环不断尝试续租锁,同时在等待期间通过异步消息通知机制等待锁释放或续租成功,确保在给定的总等待时间内获取或续租锁,最终返回获取锁的成功或失败状态。
4、redission的看门狗机制
如上文所说的,Redisson获取锁的方法tryLock中有个参数leaseTime,该参数定义了锁的超时时间,该值默认为-1,如果未设置leaseTime,会触发看门狗机制,每过internalLockLeaseTime / 3 秒会续期,将锁设置为internalLockLeaseTime秒过期, internalLockLeaseTime默认为30,可通过setLockWatchdogTimeout()方法自定义。
private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
// 如果leaseTime不为-1 则说明指定了锁的超时时间 直接获取锁然后返回
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
// 如果leaseTime为-1,则通过getLockWatchdogTimeout()方法获取锁的超时时间,也就是internalLockLeaseTime成员变量
RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
// 获取锁的操作完成后调用
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining) {
// 如果获取到了锁,则开启一个定时任务为锁续约
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
private void scheduleExpirationRenewal(long threadId) {
// 创建一个新的续约entry
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
// key已经存在,说明是锁的重入 直接将线程id放入entry
oldEntry.addThreadId(threadId);
} else {
// key不存在,说明是第一次获取到锁 将线程id放入entry 并开启定时任务续约
entry.addThreadId(threadId);
renewExpiration();
}
}
// 续约逻辑
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
//创建延时任务 在internalLockLeaseTime / 3毫秒之后执行
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 在renewExpirationAsync方法中执行续约脚本重新将锁的过期时间设置为internalLockLeaseTime
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// 续约成功 递归调用自己续约
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
// 将task与entry绑定 解锁的时候需要用来取消任务
ee.setTimeout(task);
}
在成功获取锁后,通过异步操作定期更新锁的超时时间,确保锁在使用期间不会过期。通过 scheduleExpirationRenewal方法调度续约任务,而 renewExpiration 方法负责执行异步续约操作。递归调用 renewExpiration在每次续约成功后继续下一次续约。
5、总结
八、点赞排序实现
我们点赞一般都是同一文章,一个用户只可以点赞一次,而且通常都会展示出先点赞的几个人。所有点赞的人都是唯一的,而且需要排序,我们就可以选择我们的zset数据类型来完成
代码实现
存储在redis当中,我们以当前时间的毫秒值作为score来存储,zset数据默认是从小到大排序的
大的在后面,所以我们拿去前五 直接score(0-4)就可以那取到
/*修改点赞数量
* id 文章id
* */
@Override
public Result updateLikedBlog(Long id) {
//1.获取当前用户id
Long userId = UserHolder.getUser().getId();
//2.判断当前用户是否已经点赞过
String key = RedisConstants.BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if(score == null){
//3.没有点赞 可以点赞
//3.1 数据库赞数+1
boolean istrue = update().eq("id", id).setSql("liked = liked +1").update();
if (istrue){
//3.2 保存用户到Redis的zet集合 zadd key value score
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
}
}else {
//4.如果已点赞,取消点赞
//4.1 数据库点赞数-1
boolean istrue = update().eq("id", id).setSql("liked = liked -1").update();
//4.2 把用户从Redis的set集合移除
if (istrue){
//3.2 保存用户到Redis的set集合
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
}
}
return Result.ok();
}
/*查询点赞的前五名*/
@Override
public Result queryByLikes(Long id) {
String key = RedisConstants.BLOG_LIKED_KEY+id;
//1.查询top5的点赞用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5==null || top5.isEmpty()){
//如果没有人点赞 就返回一个空的list集合
return Result.ok(Collections.emptyList());
}
//2.解析出其中用户的id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
List<UserDTO> userDTOS = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
System.out.println(userDTOS.toString());
// 4.返回
return Result.ok(userDTOS);
}
九、关注 共同关注 文章推送
1、关注,共同关注
关注 由于你关注的每个人id都是唯一的,而且共同关注的思路就是当前用户关注用户的集合与另一个用户关注用户的集合的交集
而redis当中刚好有类似的数据结构去实现,set集合,在set集合中,有交集并集补集的api,我们可以把两人的关注的人分别放入到一个set集合中,然后再通过api去查看这两个set集合中的交集数据。
2、代码实现
可以看到的是,我们在关注用户的时候 以当前用户的id作为key,将关注的人的id作为value存储到redis当中,当我们需要求共同关注的时候,就可以求两个用户的set交集,来实现共同关注。
//尝试关注此用户 followUserId被关注用户的id
@Override
public Result follow(Long followUserId, Boolean isFollow) {
if(followUserId==null || isFollow == null){
return Result.fail("不可以传空值");
}
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
if (isFollow){
//1关注 ,创建关注信息表
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
//2保存关注信息
boolean isTrue = save(follow);
//3.将关系信息保存到redis当中 为共同关注准备 使用set集合
if (isTrue){
stringRedisTemplate.opsForSet().add(key,followUserId.toString());
}
}else {
//4.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
boolean remove = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
if(remove){
// 把关注用户的id从Redis集合中移除
stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
}
}
return Result.ok();
}
/*共同关注*/
@Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
//当前用户关注key
String key1 = "follows:" + userId;
//所查看用户关注的key
String key2 = "follows:" + id;
//1.获取交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if(intersect==null || intersect.isEmpty()){
//2.没有交集
return Result.ok();
}
//3.有交集 解析id
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//4.查询用户
List<UserDTO> userDTOList = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOList);
}
3、文章的推送 -- feed流方案
a、简介
当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。
b、feed流的两种实现模式
Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
-
优点:信息全面,不会有缺失。并且实现也相对简单
-
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
-
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
-
缺点:如果算法不精准,可能起到反作用
c、Timeline的三种实现方式(推 拉 推拉)
-
拉模式
-
推模式
-
推拉结合
拉模式:也叫做读扩散
该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
推模式:也叫做写扩散。
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
d、关注好友文章推模式实现
代码实现
实现滚动分页在于记住上次查询的分数 利用分数来查询
而滚动分页的命令就是ZREVRANGEBYSCORE key Max Min LIMIT offset count(max min就是区间,你从大到小的话 max就是上次查询的最小分数 offset偏移量 第一次查询一般偏移量为0,后面如果没有重复的值的话偏移量为1 他是在从大到小的基础上的话max的后面偏移 count分页大小 也就是需要查询几个)