redis项目
- 课程目录
- 一、基础篇
- 二、实战篇
- 2.1 短信登陆
- 2.2 商户查询缓存
- 2.3 优惠券秒杀
- 2.4 分布式锁
- 2.5 秒杀优化
- 2.5.1 异步秒杀思路
- 2.5.2 基于Redis完成秒杀资格判断
- 2.5.3 基于阻塞队列实现秒杀异步下单
- 2.6 Redis消息队列
- 2.6.1 认识消息队列
- 2.6.2 基于List实现消息队列
- 2.6.3 PubSub实现消息队列(publish subscribe)
- 2.6.4 Stream的单消费模式
- 2.6.5 Stream的消费者组模式
- 2.6.6 基于Stream消息队列实现异步秒杀
- 2.7 达人探店
- 2.7.1 发布探店笔记
- 2.7.2 查看探店笔记
- 2.7.3 点赞功能
- 2.7.4 点赞排行榜
- 2.8 好友关注
- 2.8.1 关注和取关
- 2.8.2 共同关注
- 2.8.3 Feed流实现方案分析(关注推送)
- 2.8.4 推送到粉丝收件箱
- 2.8.5 滚动分页查询收件箱的思路
- 2.8.6 实现滚动分页查询
- 2.9 附近商铺
- 2.9.1 GEO数据结构的基本用法
- 2.9.2 导入店铺数据到GEO
- 2.9.3 实现附近商户功能
- 2.10 用户签到
- 2.10.1 BitMap功能演示
- 2.10.2 实现签到功能
- 2.10.3 统计连续签到
- 2.11 UV统计
- 2.11.1 HyperLogLog的用法
- 2.11.2 测试百万数据的统计
课程目录
一、基础篇
下载Virtual Box
https://blog.csdn.net/m0_46983541/article/details/124578424
利用vagrant可以快速获取镜像(类似于docker仓库)
vagrant镜像仓库
https://app.vagrantup.com/boxes/search
下载VMware
https://blog.csdn.net/weixin_45014379/article/details/126102088
下载Centos
https://www.bilibili.com/video/BV1MX4y1L7LT?p=2&spm_id_from=pageDriver&vd_source=a94c87b379edd5a7d7d8b35d15935c0f
Xshell是进行服务器的连接的
Xftp是把文件传到服务器上的
为了把VMware中的ip定为静态,可以进行设置,如下
https://www.pudn.com/news/63154a6f39e81527acf69529.html
redis操作
1.1 redis数据结构
1.2 redis命令
1.2.1 通用命令
1.2.2 String类型
1.2.3 String类型 - Key的层级格式
如何区分不同类型的key呢
1.2.4 Hash类型
1.2.5 List类型
总结
1.2.6 Set类型
1.2.7 SortedSet类型
如ZREVRANK ZREVRANGE
1.3 redis的java客户端
1.3.1 客户端对比
1.3.2 jedis快速入门
@BeforeEach和@AfterEach
这两个注解标识在测试类的实例方法上。分别用在四阶段测试setup-exercise-verify-teardown的setup阶段和teardown阶段上。
标识为@BeforeEach的方法,会在测试类中的每个测试方法执行之前执行一次。标识为@AfterEach的方法,会在测试类中的每个测试方法执行之后执行一次。
jedis使用的基本步骤
1.引入依赖
2.创建jedis对象,建立连接
3.使用jedis,方法名与redis命令一致
4.释放资源
1.3.3 jedis的连接池
利用测试类进行测试
1.3.4 认识SpringDataRedis
1.3.5 RedisTemplate快速入门
1.3.6 RedisTemplate的RedisSerializer(序列化)
承接上一节最后的问题,SpringDataRedis会把所有对象作为object进行序列化
自己设置时,key一般是字符串,所以用StringRedisSerializer方式
value一般是对象,用jsonRedisSerializer方式
通过上面的这种配置,key将用string序列化,value将用json序列化,因此可以把一个类的对象作为值
可见,redisTemplate还可以在get时反序列化(根据存入redis的@class信息,进行反射),从而在存的时候序列化为json,取的时候反序列化为对象。
1.3.7 StringRedisTemplate
使用StringRedisTemplate,也就是统一StringSerializer,虽然代码上麻烦了些,但节省了空间
总结
1.3.8 RedisTemplate操作Hash类型
二、实战篇
2.1 短信登陆
2.1.1 导入黑马点评项目
即可查到数据
即可看到界面
2.1.2 基于session实现短信登陆的流程
三步
1.发送短信验证码
2.短信验证码登录、注册
3.校验登录状态
2.1.3 实现发送短信验证码功能
可以观察到这里发到了8080(前端)nginx反向代理,它会把你的请求转发到8081,前端做了处理
首先完成短信验证码的发送功能如下,也就是流程图里的第一模块
2.1.4 实现短信验证码登录和注册功能
2.1.5 实现登录校验拦截器
2.1.6 隐藏用户敏感信息
就是将User改为UserDTO
此时登陆后就可以获取到用户信息的相关内容
2.1.7 session共享的问题分析-即Redis可以代替session
2.1.8 Redis代替session的业务流程
- 不能用code作为key,每一个手机的验证码都应该是不一样的
- session会由tomcat自己维护id,redis则由我们自己维护
选择String或者hash保存用户都可以
之前的登录校验是通过session
拦截器的逻辑
2.1.9 基于Redis实现短信登陆
对上面的业务流程进行实现,主要过程见代码,重点是如何将数据存入redis,并利用token进行校验
需要注意的几个问题:
- 简单的数据,比如验证码,完全可以采用String类型,对象类型可以hash,存储空间更小,对单个字段修改更加灵活
- key一方面考虑唯一性,另外方便找到
- 存储过程设置有效期,避免数据存储过长时间
- 合适的存储粒度,不用存储完整的用户信息,敏感信息不用传,只用传页面需要的数据,还可以节省内存空间
2.1.10 解决状态登录刷新的问题
拦截器,只拦截了部分界面,假如用户一直在访问未拦截的部分,则token不会持续刷新,那么显然存在问题
把拦截器的功能分为两部分分开写,然后进行配置
2.2 商户查询缓存
2.2.1 什么是缓存
2.2.2 添加商户缓存(redis)
2.2.3 缓存练习题分析
2.2.4 缓存更新策略-数据库和缓存一致性问题
对于主动更新策略
实际线程执行过程中可能会存在问题,先删缓存,再操作数据库的正常情况如下:
先删缓存,再操作数据库的异常情况如下
先操作数据库、再删除缓存的正常情况如下:
先操作数据库、再删除缓存的异常情况如下:
总结
2.2.5 实现商铺缓存与数据库的双写一致(对上一节的实现)
此时进入redis进行查询,发现该缓存已被删除(也就是只要一更新数据库,就会删除,直到再次查询的时候才会写进缓存)
2.2.6 缓存穿透的解决思路
2.2.7 编码解决商铺查询的缓存穿透问题
采用第一种方案
查询一条存在的信息
查询一条不存在的信息
查询第二遍时,查看IDEA日志,发现没有对数据库进行查询,此时查看redis发现,存入了一条空数据,也就实现了我们“缓存空对象”的策略
总结
2.2.8 缓存雪崩问题及解决思路
通常在最开始可能用mysql进行预热,将一些数据先存入redis,因为是几乎同时存入的,所以也可能会同时到期,造成缓存雪崩
2.2.9 缓存击穿问题及解决方案
2.2.10 利用互斥锁解决缓存击穿问题
不能用平时的那种普通锁,因为就算没有拿到锁,也不是等待,而是去做其他操作
可以用setnx来模拟互斥锁,setnx是string类型的方法
写好新的代码后,利用Jmeter生成1000个线程,5秒内全部进入,进行测试,发现吞吐量非常好,而且IDEA只显示进行了一次mysql查询
2.2.11 利用逻辑过期解决缓存击穿问题
针对于每一个热点key都会加入缓存,且没有TTL过期时间,过期与否由程序员逻辑判定
对于不属于热点key的查询,查不到直接返回空就行
测试
Redis里有一个过期数据,为202茶餐厅
但此时mysql里是101
利用Jmeter进行测试,由于二者信息此时不同,且redis数据已经属于过期数据,因此按照流程图,会进行缓存重建,缓存重建在代码里设置了200ms的延迟
利用Jmeter进行1秒100个线程的测试,由于前200ms在进行缓存重建,因此按理来说大概率在第20个线程附近,redis里的202会被更新为101,事实证明也确实如此,本节完毕
2.2.12 封装Redis工具类
进代码看即可,主要难点在于泛型,封装后使用更方便
2.2.13 缓存总结
见桌面"思维导图"的xmind
2.3 优惠券秒杀
2.3.1 全局唯一ID
这里是秒,雪花算法41位是毫秒
跟雪花有区别。雪花:1符号位|41时间戳|10机器码|12序列号
2.3.2 Redis实现全局唯一id
redis可以实现id自增,因此全局唯一id可以使用redis的方法去实现
通过测试发现,2秒产生了30000个id,可见还是比较有效的,具体见代码
实现核心步骤就是
1.生成时间戳
2.生成序列号
3.拼接
2.3.3 添加优惠券
注意这里需要把查询shop的代码从缓存击穿改为普通的缓存穿透,不然redis里没有对应的商铺数据,也无法显示商铺数据
注意这里设置秒杀券的时间时,一定要在当前时间或者之后,倘若已经结束,就看不见了(应该是前端代码设置的)
2.3.4 实现秒杀下单
需要完成两步:下单、去库存
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
//尚未开始
return Result.fail("秒杀尚未开始!");
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
//已经结束
return Result.fail("秒杀已经结束!");
}
//4.判断库存是否充足
if (voucher.getStock()<1){
//库存不足
return Result.fail("库存不足!");
}
//5.扣减库存,包括设置sql语句和where条件
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId).update();
if (!success){
//扣减失败
return Result.fail("库存不足");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1订单id,用id生成器生成
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//6.2用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单Id
return Result.ok(orderId);
}
点击一次抢券可以发现,数据库订单信息增加一条,优惠券数量-1
2.3.5 库存超卖问题分析
刚才的实验是自己点了一下,实际上可能会有很多用户同时点击,需要实现高并发
首先jmeter里有两个地方设置,一个是增加BeanShell PostProcessor,使返回值中文不乱码,其次是增加Http信息头管理器,增加token,否则测试时全是401,查不到数据
此时我们用jmeter中的200个线程同时抢券,进行测试,发现订单有101个(有一次还有109个),显然是有超卖问题的,此时seckill_voucher的stock也变为了-1
错误原因如下
- 正常情况应当是:
- 错误情况如下:
解决方法:
悲观锁在处理多并发问题时效率太低,这里我们使用乐观锁的方法
版本号法
CAS法 CompareAndSwap
和版本号思路其实一样,只是直接用stock判断,省去了版本号
2.3.6 乐观锁(CAS法)解决超卖
之后重新jmeter测试,发现通过了69个,也就是stock还剩31个,此时安全问题解决了,但是200个线程,100个资源,却只通过了69个,说明乐观锁存在成功率低的问题
出现这种情况原因是:很多线程判断时,正好都是有上一个线程改变了库存值,导致被乐观锁判错
把乐观锁的判断条件改为stock >0即可,此时再测试,则100个stock全部生成订单,且之后stock值为0
总结
目前的方案还是需要访问数据库,对数据库压力比较大,后面还需要改进
2.3.7 实现一人一单功能
现在所有的优惠券都是被同一个用户买走了,但促销活动应该一人一单
一人一单部分的代码也需要加锁,由于是新增问题,不能加乐观锁(没有东西可以判断),因此要用悲观锁
难点:
1.synchronized加锁位置和范围
2.如何获得代理对象,从而与事务同步
这一块代码需要考虑到加锁范围以及代理对象的问题
以代码和视频为准
详见VoucherOrderServiceImpl代码
测试发现,订单雀氏只增加了一条
2.3.8 集群下的线程并发安全问题
上面的方案对于集群情况,存在问题
ctrl+d,创建新的服务
在更改nginx配置文件后就可以发现,默认是轮询的,两个服务都会被访问到
在下面图中,左右各是一个集群,但是每个集群会单独有一个jvm,管理一个锁监视器,所以倘若有10个集群,用之前的方案就会同时有10个线程在运行
2.4 分布式锁
2.4.1 基本原理和不同实现方式对比
redis不会自己释放锁,可能还没释放,就宕机了,因此需要设置超时时间
2.4.2 Redis的分布式锁实现思路
总结如下
2.4.3 实现Redis分布式锁版本1
首先修改代码
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if(!isLock){
//获取锁失败,返回错误或重试
return Result.fail("一个人只允许下一单");
}
try {
//获得当前代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unLock();
}
然后在postman里用同一个用户进行两次抢券(分别对两个服务器8081和8082)
最后打断点测试发现8081为true,8082为false,说明后者没有通过锁的申请,解决了问题!
2.4.4 Redis分布式锁误删问题
应当进行判断
2.4.5 解决Redis分布式锁误删问题(这一节只解决误删)
本来的线程后就是递增的数字,每个JVM都会进行维护递增数字,两个JVM之间就很有可能出现冲突,因此线程标识应该用UUID区分不同JVM,后面再加上递增的数字
对获取锁和释放锁代码进行修改
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程的标识,用来传入作为value,redis语句为set lock thread1,也就是这里的thread1
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//获取线程标示
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标识是否一致
if (threadId.equals(id)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
通过打断点测试,发现确实当线程标识一致时才可以进行删除锁
在这里弹幕说可能会存在超卖问题,也就是阻塞的线程和后一个线程都进入了锁,但是实际上,阻塞的线程应该可以理解为已经进行了数据库订单的存储,后一个线程再进行判断时,先查询数据库,判断count已经>0,就不会再新增订单了,而且实际测试过程中我也并没有发现超卖问题,弹幕里也有人说没有超卖,这里就当没有超卖问题
这里可能会有疑惑,前面讲的,不同服务器进程,假如不用分布式锁,为何会能突破count>0的检查,当时老师是给了一种假设,也就是两个进程同时到达count>0的判断,此时都判断count=0,因此就都通过了,和本节还不一样,本节的情况,显然(我的理解)阻塞进程和后一个获取锁的进程不是同时执行的。
2.4.6 分布式锁的原子性问题
因此需要确保判断锁标识和释放锁 两个操作的原子性,不然中间可能存在阻塞问题
2.4.7 Lua脚本解决多条命令原子性问题
关于redis的原子性和一致性,现在还不太了解,之后再慢慢学
现在脚本写好了,但是该如何用java代码执行呢,见下一节
2.4.8 java调用Lua脚本改造分布式锁
需要安装一个插件,emmyLua,可以创建lua脚本文件
总结
2.4.9 Redisson功能介绍
2.4.10 Redisson快速入门
用Redisson代替原来的代码
Long userId = UserHolder.getUser().getId();
//创建锁对象
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//使用Redisson的锁就不用自己再定义了,而且同样可以达到之前的效果
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if(!isLock){
//获取锁失败,返回错误或重试
return Result.fail("一个人只允许下一单");
}
try {
//获得当前代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
2.4.11 Redisson的可重入锁原理
获取锁的脚本
释放锁的脚本
在redisson源码里,也包含了同样了lua脚本
2.4.12 Redisson的锁重试和WatchDog机制
上一节解决了可重入问题,接下来解决重试、超时问题
锁重试问题
下面是redisson的源码(tryLock和unlock)
后面就不贴了,可以自己看,主要解决锁重试的问题
锁超时释放问题
锁应该是业务执行完释放,如果阻塞超时导致释放,可能存在安全问题
总结
2.4.13 Redisson的multiLock原理(主从一致性)
主从职责往往不一样,读写分离,主节点处理所有发向redis的写操作,从节点处理读节点,二者需要做数据同步
倘若主节点突然崩溃,被哨兵发现,就会由另一个从节点变为主节点,但此时数据未同步
解决方式:独立节点连锁
分布式锁总结
2.5 秒杀优化
2.5.1 异步秒杀思路
库存是否充足和查询是否一人一单,之前都是通过mysql查询,速度较慢,因此应该采取redis缓存的方式
2.5.2 基于Redis完成秒杀资格判断
对于①
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
//保存秒杀库存到redis当中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
}
对于②
一人一单可以通过set实现,对于同一优惠券,同一用户的互斥
对于之后,在代码中使用lua脚本
//提前定义好lua脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT=new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
//2.判断结果是否为0
int r = result.intValue();
if (r!=0){
//2.1 不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//2.2 为0,有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
//TODO 保存阻塞队列
//3.返回订单id
return Result.ok(orderId);
}
此时用同一用户反复获取同一优惠券,发现雀氏只能通过一次,数据存储到redis(还没有存储到mysql,因为还没写)
2.5.3 基于阻塞队列实现秒杀异步下单
上一节课实现了前两个需求,接下来实现后两个需求
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
对代码进行了大改
1.在seckillVoucher函数里使用lua脚本,将存入redis的数据判断是否抢购成功,(成功则redis 已经扣减)成功则存入阻塞队列(注意代理对象需要在方法外定义,方便阻塞队列引用)
2.创建线程池和线程任务,当该类生成时,立刻加载线程任务监测阻塞队列中有无内容,当有的时候创建订单,对于创建订单新定义了方法handleVoucherOrder
3.在handleVoucherOrder中调用createVoucherOrderNew方法,实现一人一单的扣减库存(mysql),订单新建(mysql)
经测试如下图
总结
2.6 Redis消息队列
之前提出的内存中存储阻塞队列的方法,存在两个问题:
1.内存限制问题,内存有限
2.数据安全问题,jvm没有持久化机制的,宕机时数据都会丢失
通过消息队列解决上面的问题
2.6.1 认识消息队列
解除耦合
秒杀的一边,不用写数据库,并发能力大大提高
数据库的一边,慢慢地拿过来写就可以了,没有感觉到太大压力
和阻塞队列区别:
1.消息队列是独立于jvm之外的服务,不受内存限制
2.消息队列不仅仅做数据存储,还确保数据安全,做持久化
2.6.2 基于List实现消息队列
总结:
2.6.3 PubSub实现消息队列(publish subscribe)
一个生产者发消息,可以被多个消费者消费
subscribe 天生就是阻塞式的,右下角是PSUBSCRIBE
总结
list之所以之吃数据持久化,是因为list是redis的一种数据类型,redis都支持数据持久化
2.6.4 Stream的单消费模式
stream也是redis的一种数据类型,因此也支持数据持久化
总结
2.6.5 Stream的消费者组模式
总结
2.6.6 基于Stream消息队列实现异步秒杀
基于该伪代码逻辑
首先修改lua脚本,增加"发送消息到消息队列"的功能
-- 1.参数列表
-- 1.1 优惠券id
local voucherId=ARGV[1]
-- 1.2 用户id
local userId=ARGV[2]
-- 1.3 订单id
local orderId=ARGV[3]
-- 2.数据key
-- 2.1库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1判断库存是否充足 ,get stockKey
if(tonumber(redis.call('get',stockKey))<=0) then
-- 3.2库存不足,返回1
return 1
end
-- 3.2判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId)==1) then
--3.3 存在,说明是重复下单,返回2
return 2
end
-- 3.4扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 3.5下单(保存用户)sadd orderKey userId
redis.call('sadd',orderKey,userId)
-- 3.6 发送消息到队列当中,XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
然后根据伪代码逻辑修改代码
private class VoucherOrderHandler implements Runnable {
String queueName="stream.orders";
@Override
public void run() {
while (true) {
try {
//1.获取消息队列中得订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
//2.判断消息获取是否成功
if (list == null || list.isEmpty()) {
//2.1如果获取失败,说明没有消息,继续下一次循环
continue;
}
//解析消息中的订单消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
//3.创建订单
handleVoucherOrder(voucherOrder);
//4.ACK确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
//从pending list中取出异常消息
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
//1.获取pending-list中得订单信息 XREADGROUP GROUP g1 c1 COUNT 1 STREAMS streams.order 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
//2.判断消息获取是否成功
if (list == null || list.isEmpty()) {
//2.1如果获取失败,说明pending-list没有异常消息,结束循环
break;
}
//解析消息中的订单消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
//3.创建订单
handleVoucherOrder(voucherOrder);
//4.ACK确认 SACK stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.error("处理pending-list异常", e);
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
}
}
seckillVoucher修改如下
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//订单id,用id生成器生成
long orderId = redisIdWorker.nextId("order");
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(),String.valueOf(orderId)
);
//2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
//2.1 不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//拿到代理对象,阻塞队列由另一个子线程进行,没法拿代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
//3.返回订单id,到这里为止,业务就结束了,用户得到了结果,异步下单的任务再单独进行即可
return Result.ok(orderId);
//到这里为止,仅仅是用户端判断完毕,已经往队列中发送了消息
//接下来需要开启一个线程任务,去尝试获取消息队列中的信息
}
经postman测试,仍然生效
2.7 达人探店
2.7.1 发布探店笔记
对于保存图片,这里为了简便,直接存在本地nginx服务器目录下,需要规定一下存储路径
发布一条新笔记,由于无点赞,所以排在最后
不过目前只能发布,不能查看,下一节实现
2.7.2 查看探店笔记
主要是由于没有写根据id返回笔记数据的功能,加上即可
@Override
public Result queryBlogById(Long id) {
//1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
//2.查询blog有关的用户
queryBlogUser(blog);
return Result.ok(blog);
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
2.7.3 点赞功能
此时可以无限点赞,需要修改
对于①:在Blog类里修改字段
对于②:在BlogServiceImpl里编写isBlogLiked函数
对于③:queryBlogById函数中引入isBlogLiked
对于④:queryHotBlog函数中引入isBlogLiked
2.7.4 点赞排行榜
Redis集合对比
选用SortedSet,即ZSET
把时间作为score进行排序,显示前五名点赞的,并按前后顺序
不过需要注意的是sql语句的书写,不然顺序会是反的
//实现点赞top榜
@Override
public Result queryBlogLikes(Long id) {
String key=BLOG_LIKED_KEY+id;
//1.查询top5点赞用户 zrange key 0 4
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
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());
//4.返回
return Result.ok(userDTOS);
}
效果如下:
2.8 好友关注
- 关注和取关
- 共同关注
- 关注推送
2.8.1 关注和取关
用户与用户之间的关注关系是一种多对多的关系,需要一张中间表tb_follow
//关注和取关
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//获取登录用户
Long userId = UserHolder.getUser().getId();
//1.判断到底是关注还是取关
if (isFollow) {
//2.关注,新增数据
Follow follow = new Follow();
follow.setFollowUserId(followUserId);
follow.setUserId(userId);
save(follow);
}else {
//3.取关,删除 delete from tb_follow where user_id=? and follow_user_id=?
remove(new QueryWrapper<Follow>()
.eq("user_id",userId).eq("follow_user_id",followUserId));
}
return Result.ok();
}
//判断是否关注
@Override
public Result isFollow(Long followUserId) {
//1.获取登录用户
Long userId = UserHolder.getUser().getId();
//2.查询是否关注 select count(*) from tb_follow where user_id=? and follow_user_id=?
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
//3.判断
return Result.ok(count>0);
}
最终测试符合预期,关注时,关注信息写入数据库表,取消关注时,从数据库中删除。
2.8.2 共同关注
想要看共同关注,首先得能够进入其他用户的个人主页,实现如下
共同关注最重要的是取交集,Redis的set结构的SINTER可以实现
可以看到此时对于不同用户的关注,存入redis之后,会有共同关注,之后需要通过set的方法,提取共同关注
此时要实现共同关注的接口
@Override
public Result followCommons(Long id) {
//1.获取登录用户
Long userId = UserHolder.getUser().getId();
String key="follows:" + userId;
//2.求交集
String key2="follows:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
//无交集
return Result.ok(Collections.emptyList());
}
//3.解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//4.查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
关注的意义,就是为了收到相关推送,接下来实现推送
2.8.3 Feed流实现方案分析(关注推送)
拉模式
推模式
优点:粉丝看数据,不需要再临时拉,延时低
缺点:内存占用高
推拉结合
对于普通up,都推模式就行了,毕竟粉丝不多
对于大V,粉丝很多,因此对活跃粉丝(很少)进行推模式,对普通粉丝/僵尸粉进行拉模式
总结
下面实验基于简单的推模式进行
2.8.4 推送到粉丝收件箱
list不支持这样的滚动分页,因为必须基于角标或者首尾
sortedSet可以实现
首先需要做到发布blog时,以sortedSet的形式存入redis
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店博文
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增笔记失败!");
}
//3.查询笔记作者的所有粉丝,followuser是被关注的人
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
//4.推送笔记id给所有粉丝
for (Follow follow : follows) {
//4.1获取粉丝id
Long userId = follow.getUserId();
//4.2推送到每一个粉丝收件箱,收件箱就是sortedSet
String key = "feed:" + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5.返回id
return Result.ok(blog.getId());
}
现在就相当于已经把blog信息推到粉丝的收件箱里,接下来需要实现粉丝对收件箱的查询
2.8.5 滚动分页查询收件箱的思路
“最小值”和“查几个数”是固定的,另外两个是变化的,
最大值要选上次查询的最小值,偏移量第一次是0,之后得取决于上次查询最小值的数量
滚动分页查询参数:
max:当前时间戳 | 上一次查询的最小时间戳
min:0(固定值)
offset: 0 | 在上一次的结果中,与最小值一样的元素的个数
count: 3(查几个就是几,固定的)
2.8.6 实现滚动分页查询
//用户从sortedSet收件箱里查询出blog
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
//1.获取当前用户
Long userId = UserHolder.getUser().getId();
//2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
String key=FEED_KEY+userId;
//得到元组,元组里存的就是redis里的blogid和score\时间戳
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
//3.非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
//4.解析数据:blogId、minTime(时间戳)、offset(也就是最小值的数量)
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime=0;
int os=1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
//4.1获取id
String idStr = tuple.getValue();
ids.add(Long.valueOf(idStr));
//4.2获取分数(时间戳),最后一次拿到的就是上一次最小的
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
}else{
minTime = time;
os=1;
}
}
//5.根据id查询blog
//List<Blog> blogs = listByIds(ids);不能这么写,因为listByIds是无序的
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Blog blog : blogs) {
//5.1.查询blog有关的用户
queryBlogUser(blog);
//5.2.查询blog是否被点赞
isBlogLiked(blog);
}
//6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setMinTime(minTime);
r.setOffset(os);
return Result.ok(r);
}
提前定义好了ScrollResult类,用于查看关注用户的blog信息
之后便可以滚动查询
注意:滚动条发生滚动时,就会发生请求,对数据进行更新
2.9 附近商铺
2.9.1 GEO数据结构的基本用法
底层存储是ZSet
2.9.2 导入店铺数据到GEO
首先需要做的是把商户按类型存入redis
如下
利用测试代码进行数据的添加即可
@Test
void loadShopData() {
//1.查询店铺信息
List<Shop> list = shopService.list();
//2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//3.分批完成写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
//3.1获取类型id
Long typeId = entry.getKey();
String key = "shop:geo:" + typeId;
//3.2获取同类型店铺的集合
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
//3.3写入Redis GEOADD key 经度 纬度 member
for (Shop shop : value) {
//也可以向下面注释这样去写,但每次都对redis做操作不太好,最好是先放到一个集合里
//stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(),shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
这样之后就不用再去数据库里根据类型做过滤了,直接来搜就行了
下一节实现附近商户的功能
2.9.3 实现附近商户功能
最终实现按距离排序效果(需要先点一下”距离“两字)
见ShopServiceImpl文件的queryShopByType函数
2.10 用户签到
2.10.1 BitMap功能演示
2.10.2 实现签到功能
public Result sign() {
//1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
//2.获取日期
LocalDateTime now = LocalDateTime.now();
//3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key=USER_SIGN_KEY+userId+keySuffix;
//4.获取今天是本月的第几天,获得的是从1-31,需要-1
int dayOfMonth = now.getDayOfMonth();
//5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth-1, true);
return Result.ok();
}
用postman进行测试
2.10.3 统计连续签到
@Override
public Result signCount() {
//1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
//2.获取日期
LocalDateTime now = LocalDateTime.now();
//3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key=USER_SIGN_KEY+userId+keySuffix;
//4.获取今天是本月的第几天,获得的是从1-31,需要-1
int dayOfMonth = now.getDayOfMonth();
//5.获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
//没有任何签到结果
return Result.ok(0);
}
//这里只有一个操作get,index为0
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
//6.循环遍历
int count=0;
while (true) {
//6.1让这个数字与1做 与运算 ,得到数字的最后一个bit位
//6.2判断这个bit位是否为0
if ((num&1)==0) {
//6.3如果为0,说明未签到,结束
break;
}else {
//6.4如果不为0,说明已签到,计数器+1
count++;
}
//6.5把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num=num>>1;
}
return Result.ok(count);
}
测试如下
2.11 UV统计
2.11.1 HyperLogLog的用法
2.11.2 测试百万数据的统计
测试发现,100万数据,几乎没有丢失
在redis里也进行了存储
查询内存发现,redis内存的变化不超过16kb,雀氏很小