4、分布式锁
4.1 基本原理和实现方式对比
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
那么分布式锁他应该满足一些什么样的条件呢?
-
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
-
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
-
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
-
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
-
安全性:安全也是程序中必不可少的一环
常见的分布式锁有三种
-
Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
-
Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
-
Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,由于本套视频并不讲解zookeeper的原理和分布式锁的实现,所以不过多阐述
4.2 Redis分布式锁的实现核心思路
实现分布式锁时需要实现的两个基本方法:
-
获取锁:
- 互斥:确保只能有一个线程获取锁
- 原子性:加过期时间的目的是,避免服务宕机导致锁无法释放,但是如果还没来得及给锁加上过期时间就已经宕机了,锁依然没有办法释放,所以要保证setnx添加锁和添加锁过期时间要么都成功要么都失败,保证2者的原子行。------解决:
在一个set命令中通过设置参数同时实现nx和过期时间的效果,来保证原子行的操作
。
- 非阻塞:尝试一次,成功返回true,失败返回false
- 互斥:确保只能有一个线程获取锁
-
释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
核心思路:
我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可
4.3 实现分布式锁版本一
锁的基本接口:定义一个类,实现下面接口,利用Redis实现分布式锁功能。
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
SimpleRedisLock实现类
- 加锁逻辑:利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
- 释放锁逻辑:释放锁,防止删除别人的锁
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock{
//指定锁的名称:锁的key不能写死,写死代表不管任何一个业务来获取的都是同一把锁,
// 我们希望的是不同业务有不同的锁,锁的名称跟业务有关,因此锁的名称不能写死,而是
// 由使用的人传递给我们。
private String name;
//因为这个类没有交给spring容器管理是自己写的类,所以没办法注入
private StringRedisTemplate stringRedisTemplate; //执行redis操作
private static final String KEY_PREFIX = "lock:"; //指定锁的统一前缀,这样显得名称更加专业一些。
//这2个参数要求用户传递给我们,所以用构造函数赋值
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//获取锁
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程的唯一id作为标示
long threadId = Thread.currentThread().getId();
// 获取锁:k(前缀+业务名),v(需要加上线程的标识告诉那个线程拿到锁了 long类型转化为字符串类型),时间,单位
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId+"", timeoutSec, TimeUnit.SECONDS);
/*使用命令获取锁返回的结果是 ok nil,而上面使用代码获取锁返回的结果是true false,这是因为Spring底层帮我们
* 封装并做了个判断,所以我们直接返回success即可。
* 注意:获取锁的返回值类型为Boolean,需要的是boolean,会有一个自动拆箱的过程,如果success为null在拆箱时
* 可能会出现空指针----解决进行逻辑判断(true equals true=true,true equals false=false,true equals null=false)
* */
return Boolean.TRUE.equals(success);
}
//释放锁
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
- 修改业务代码
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
//注入的是秒杀卷的业务类
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private StringRedisTemplate stringRedisTemplate;
//注入id生成器类
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckilVoucher(Long voucherId) {
// 1.查询优惠券:秒杀优惠卷的id和普通优惠卷的id是一样的,所以使用voucherId是没问题的。
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.判断库存是否充足:秒杀一人买一个,只要大于1就代表充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码):锁的名称(业务标识+锁的范围,锁的范围是用户,同一个用户才需要限制不同的用户无所谓),注入stringRedisTemplate。
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象:超时时间 秒 (这个时长是为了打断点)
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if (!isLock) {
return Result.fail("不允许重复下单");
}
//下面代码可能出现异常,最终出不出现异常都要释放锁
try {
//获取事务有关的代理对象
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
//因为这是通过接口调用这个方法,接口中没有,现在是实现类中有这个方法,所以把这个方法暴漏到接口中,否测会报错。
return o.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
@Transactional //涉及到库存扣减 订单添加2张表,所以加上事务
@Override
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单逻辑
// 5.1.用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
// 5.2.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.3.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存:秒杀的库存值减1
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).gt("stock", 0).update(); //where id = ? and stock > 0
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
// 7.创建订单:就是订单表新增一条数据,订单表的其它参数都有默认值不用管,只需要设置这几个id即可。
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.2.代金券id:上面参数传递来的
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);//写入数据库
// 8.返回订单id
return Result.ok(orderId);
}
}
测试:
- 添加断点,启动2个服务
- 还原数据库
- 一个为true,一个为false:代表只有一个获取锁成功
4.4 Redis分布式锁误删情况说明
逻辑说明:线程1唤醒后把线程2的锁给误删了。
-
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明,同理线程3也可以获取锁,此时线程2和线程3都拿到了锁都在执行业务,又一次出现了并行执行的情况 线程安全问题就有可能再次发生。
-
解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。
4.5 解决Redis分布式锁误删问题
需求:修改之前的分布式锁实现,满足:
-
在获取锁时存入线程标示(可以用UUID表示)
-
在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
注意事项:为什么使用UUID来代替之前的线程id来作为线程标识???
- 线程id是一个递增的数字,在jvm内部每创建一个线程它的数字都会递增,在集群环境下有多个jvm,每个jvm内部都会维护一个递增的数字,这样就可能导致出现线程的id冲突,所以只用线程的id作为唯一标识是不够的。
- 解决:使用uuid来区分不同的jvm服务,在使用线程id区分不同的线程,2者结合就可以确保不同线程标识一定不一样,相同线程标识一定一样。
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
具体代码如下:加锁
//胡图工具包:true去掉uuid中的横线,后面这个横线用来区分线程的id。
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
释放锁
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);
}
}
有关代码实操说明:
在我们修改完此处代码后,我们重启工程,然后启动两个线程,第一个线程持有锁后,手动释放锁,第二个线程 此时进入到锁内部,再放行第一个线程,此时第一个线程由于锁的value值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释放锁,通过这个案例初步说明我们解决了锁误删的问题。
测试:
- 还原数据库数据:删减订单,还原库存为100
- 添加断点,debug启动项目,使用postman发送请求进行测试
- 第一个服务----断点点击下一步可以看到获取到了锁
- 查看redis:确实有这把锁,删除此锁模拟锁过期
- 8082服务执行下一步debug,由于锁过期了所以也能获取锁
- 8081放行释放锁,跟进去下一步查看,由于此时锁被线程2拿到了,此时比较8081线程1的线程标识和redis锁中的线程标识一定不一致(因为此时锁中的是线程2的线程标识),线程1就知道此时不是自己的锁就不能删除。
- 放行线程2释放锁-----跟进去-----点击下一步,可以看到是同一个线程锁,所以此时可以删除----放行8082查看redis发现锁已经被删除。
4.6 分布式锁的原子性问题
更为极端的误删逻辑说明:判断锁标识和释放锁之间出现了阻塞,由于线程1已经判断过了线程1唤醒会把线程2的锁给误删。
- 线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,
4.7 Lua脚本解决多条命令原子性问题
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html
,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了,作为Java程序员这一块并不作一个简单要求,并不需要大家过于精通,只需要知道他有什么作用即可。
这里重点介绍Redis提供的调用函数,语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
# 执行 set name jack
redis.call('set', 'name', 'jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
例如,我们要执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本,语法如下:
为什么是0?
- 这个脚本是写死的里面没有任何参数都是常量
- 参数:里面不写死是可以传参的。
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
注意:在lua语言中数组的角标是从1开始的。
接下来我们来回一下我们释放锁的逻辑:
释放锁的业务流程是这样的
1、获取锁中的线程标示
2、判断是否与指定的标示(当前线程标示)一致
3、如果一致则释放锁(删除)
4、如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样
--锁的key
local key = KEYS[1]
--当前线程标示
local threadId =ARGV[1]
-- 获取锁中的标示 get key
local id = redis.call('GET', KEYS[1])
-- 比较线程标示与锁中的标示是否一致
if ( id == ARGV[1]) then
-- 一致,则删除锁 del key
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
--优化:把前几步合在一块写
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
4.8 利用Java代码调用Lua脚本改造分布式锁
lua脚本本身并不需要大家花费太多时间去研究,只需要知道如何调用,大致是什么意思即可,所以在笔记中并不会详细的去解释这些lua表达式的含义。
我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图股
注意numkeys不用在写:list集合中的数量就是key的数量所以不用在指定。
脚本代码:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
Java代码
//提前加载脚本:泛型为返回值类型这里的脚本返回值为数字所以为Long,如果不关心返回值类型了可以写为object
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
//可以直接在创建对象的时候传递 脚本参数,但是这样是直接写死了,所以不建议
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//设置脚本的位置:spring提供的包,ClassPathResource默认在ClassPath目录下,resources就是ClassPath目录
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
//设置返回值
UNLOCK_SCRIPT.setResultType(Long.class);
}
//释放锁 (方式二:改用lua脚本方式)
@Override
public void unlock() {
// 调用lua脚本:不建议在代码中直接写死,如果以后想要对脚本做一些调整非常的不方便,可以把它写在一个文件中
//参数1:脚本,类型是RedisScript,通过这个类来加载我们写的脚本文件,每次释放锁读取文件影响性能(会产生io流),所以使用静态代码块提前加载文件
//参数2:key:需要放在集合中
//参数3:value,线程标识
//释放锁就不用再关心返回值了:释放成功那就成功,失败说明锁已经被别人删除点了或者超时释放
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~
小总结:
基于Redis的分布式锁实现思路:
- 利用set nx ex获取锁,并设置过期时间,保存线程标示
- 释放锁时先判断线程标示是否与自己一致,一致则删除锁
- 特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
- 特性:
笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦
测试逻辑:
第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。
说明:由于代码中是一行逻辑来调用Lua脚本的,所以相比之前的几行的判断锁释放锁逻辑可以保证原子行。
5、分布式锁-redission
5.1 分布式锁-redission功能介绍
基于setnx实现的分布式锁存在下面的问题:
重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
那么什么是Redission呢
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redission提供了分布式锁的多种多样的功能
5.2 分布式锁-Redission快速入门
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置Redisson客户端:
package com.hmdp.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
//地址 密码
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
如何使用Redission的分布式锁(这只是一个测试)
@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();
}
}
}
在 VoucherOrderServiceImpl
注入RedissonClient
@Resource
private RedissonClient redissonClient;
@Override
public Result seckilVoucher(Long voucherId) {
// 1.查询优惠券:秒杀优惠卷的id和普通优惠卷的id是一样的,所以使用voucherId是没问题的。
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.判断库存是否充足:秒杀一人买一个,只要大于1就代表充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码):锁的名称(业务标识+锁的范围,锁的范围是用户,同一个用户才需要限制不同的用户无所谓),注入stringRedisTemplate。
// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象:超时时间 秒 (这个时长是为了打断点)
//什么都不传:默认值是-1,代表不等待获取锁失败了立即返回
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
return Result.fail("不允许重复下单");
}
//下面代码可能出现异常,最终出不出现异常都要释放锁
try {
//获取事务有关的代理对象
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
//因为这是通过接口调用这个方法,接口中没有,现在是实现类中有这个方法,所以把这个方法暴漏到接口中,否测会报错。
return o.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
测试:使用postman发送请求测试,库存正确扣减。
5.3 分布式锁-redission可重入锁原理
-
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的
- 比如当前没有人持有这把锁,那么state=0
- 假如有人持有这把锁,那么state=1
- 如果持有这把锁的人再次持有这把锁,那么state就会+1
- 如果是对于synchronized而言,他在c语言代码中会有一个count
- 原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
-
在redission中,我们的也支持支持可重入锁
- 在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有
-
method1在方法内部调用method2,method1和method2出于同一个线程,那么method1已经拿到一把锁了,想进入method2中拿另外一把锁,必然是拿不到的,于是就出现了死锁
-
所以我们需要额外判断,method1和method2是否处于同一线程,如果是同一个线程,则可以拿到锁,但是state会+1,之后执行method2中的方法,释放锁,释放锁的时候也只是将state进行-1,只有减至0,才会真正释放锁
-
由于我们需要额外存储一个state,所以用字符串型SET NX EX是不行的,需要用到Hash结构,但是Hash结构又没有NX这种方法,所以我们需要将原有的逻辑拆开,进行手动判断
为了保证原子性,所以流程图中的业务逻辑也是需要我们用Lua来实现的
- 获取锁的逻辑
local 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; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
- 释放锁的逻辑
local 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;
获取锁源码
- 查看源码,跟我们的实现方式几乎一致
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
this.internalLockLeaseTime = unit.toMillis(leaseTime);
return this.evalWriteAsync(this.getName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);", Collections.singletonList(this.getName()), this.internalLockLeaseTime, this.getLockName(threadId));
}
- 释放锁源码
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return this.evalWriteAsync(this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;", Arrays.asList(this.getName(), this.getChannelName()), LockPubSub.UNLOCK_MESSAGE, this.internalLockLeaseTime, this.getLockName(threadId));
}
5.4 分布式锁-redission锁重试和WatchDog机制
说明:由于课程中已经说明了有关tryLock的源码解析以及其看门狗原理,所以笔者在这里给大家分析lock()方法的源码解析,希望大家在学习过程中,能够掌握更多的知识
抢锁过程中,获得当前线程,通过tryAcquire进行抢锁,该抢锁逻辑和之前逻辑相同
1、先判断当前这把锁是否存在,如果不存在,插入一把锁,返回null
2、判断当前这把锁是否是属于当前线程,如果是,则返回null
所以如果返回是null,则代表着当前这哥们已经抢锁完毕,或者可重入完毕,但是如果以上两个条件都不满足,则进入到第三个条件,返回的是锁的失效时间,同学们可以自行往下翻一点点,你能发现有个while( true) 再次进行tryAcquire进行抢锁
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return;
}
接下来会有一个条件分支,因为lock方法有重载方法,一个是带参数,一个是不带参数,如果带带参数传入的值是-1,如果传入参数,则leaseTime是他本身,所以如果传入了参数,此时leaseTime != -1 则会进去抢锁,抢锁的逻辑就是之前说的那三个逻辑
if (leaseTime != -1) {
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
如果是没有传入时间,则此时也会进行抢锁, 而且抢锁时间是默认看门狗时间 commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()
ttlRemainingFuture.onComplete((ttlRemaining, e) 这句话相当于对以上抢锁进行了监听,也就是说当上边抢锁完毕后,此方法会被调用,具体调用的逻辑就是去后台开启一个线程,进行续约逻辑,也就是看门狗线程
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
此逻辑就是续约逻辑,注意看commandExecutor.getConnectionManager().newTimeout() 此方法
Method( new TimerTask() {},参数2 ,参数3 )
指的是:通过参数2,参数3 去描述什么时候去做参数1的事情,现在的情况是:10s之后去做参数一的事情
因为锁的失效时间是30s,当10s之后,此时这个timeTask 就触发了,他就去进行续约,把当前这把锁续约成30s,如果操作成功,那么此时就会递归调用自己,再重新设置一个timeTask(),于是再过10s后又再设置一个timerTask,完成不停的续约
那么大家可以想一想,假设我们的线程出现了宕机他还会续约吗?当然不会,因为没有人再去调用renewExpiration这个方法,所以等到时间之后自然就释放了。
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
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;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
5.5 分布式锁-redission锁的MutiLock原理
为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例
此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。
为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
那么MutiLock 加锁原理是什么呢?笔者画了一幅图来说明
当我们去设置了多个锁时,redission会将多个锁添加到一个集合中,然后用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试.
5.6 小结
- 不可重入Redis分布式锁
- 原理:利用SETNX的互斥性;利用EX避免死锁;释放锁时判断线程标识
- 缺陷:不可重入、无法重试、锁超时失效
- 可重入Redis分布式锁
- 原理:利用Hash结构,记录线程标识与重入次数;利用WatchDog延续锁时间;利用信号量控制锁重试等待
- 缺陷:Redis宕机引起锁失效问题------多个独立的Redis节点解决
- Redisson的multiLock
- 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
6、秒杀优化
6.1 秒杀优化-异步秒杀思路
我们来回顾一下下单流程
当用户发起请求,此时会请求nginx,nginx会访问到tomcat,而tomcat中的程序,会进行串行操作,分成如下几个步骤
1、查询优惠卷
2、判断秒杀库存是否足够
3、查询订单
4、校验是否是一人一单
5、扣减库存
6、创建订单
在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行,那么如何加速呢?
在这里笔者想给大家分享一下课程内没有的思路,看看有没有小伙伴这么想,比如,我们可以不可以使用异步编排来做,或者说我开启N多线程,N多个线程,一个线程执行查询优惠卷,一个执行判断扣减库存,一个去创建订单等等,然后再统一做返回,这种做法和课程中有哪种好呢?答案是课程中的好,因为如果你采用我刚说的方式,如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且你使用上述方案,最大的特点在于,你觉得时效性会非常重要,但是你想想是吗?并不是,比如我只要确定他能做这件事,然后我后边慢慢做就可以了,我并不需要他一口气做完这件事,所以我们应当采用的是课程中,类似消息队列的方式来完成我们的需求,而不是使用线程池或者是异步编排的方式来完成这个需求
优化方案:我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点
-
第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断
- 首先要把优惠卷的库存信息和订单信息保存在Redis中,那么使用什么样的数据结构呢???
- 库存值就是一个数值:使用普通的String结构即可
- 购买过优惠卷的用户订单信息:一个优惠卷库存有很多,将来购买的人也很多,所以要满足一个key中可以保存多个值。一人一单说明在这个优惠卷里保存的用户id是不能重复的。--------set集合
- 首先要把优惠卷的库存信息和订单信息保存在Redis中,那么使用什么样的数据结构呢???
-
第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。
我们现在来看看整体思路:当用户下单之后,判断库存是否充足只需要导redis中去根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作
当以上判断逻辑走完之后,我们可以判断当前redis中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到到queue中去(相当于饭店点餐的小票
),然后返回,然后再来个线程异步的下单,前端可以通过返回的订单id来判断是否下单成功。
6.2 秒杀优化-Redis完成秒杀资格判断
需求:
-
新增秒杀优惠券的同时,将优惠券信息保存到Redis中
-
基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-
如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
-
开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
VoucherServiceImpl
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
@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中:k(前缀+优惠卷的id) v(库存,写入redis是String类型)
//SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
//private static final String SECKILL_STOCK_KEY ="seckill:stock:"
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
测试:
- 启动服务器,使用Postman测试
- 数据库中已经多了个优惠卷信息
- redis中也添加了优惠卷的信息
完整lua表达式
-- 1.参数列表
-- 1.1.优惠券id 他不是key 将来拼接id得到key所以从ARGV中取
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key lua拼接用 ..
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey:redis中调用得到的是String类型无法与数字进行比较,所以需要tonumber方法转化为数值类型
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId(redis的set命令,判断一个元素是否存在于set中,存在返回1不存在返回0)
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1 (加上-1相当于-1,redis的String命令)
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId (redis的Set命令)
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
当以上lua表达式执行完毕后,剩下的就是根据步骤3,4来执行我们接下来的任务了
VoucherOrderServiceImpl
//提前加载脚本:泛型为返回值类型这里的脚本返回值为数字所以为Long,如果不关心返回值类型了可以写为object
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
//可以直接在创建对象的时候传递 脚本参数,但是这样是直接写死了,所以不建议
SECKILL_SCRIPT = new DefaultRedisScript<>();
//设置脚本的位置:spring提供的包,ClassPathResource默认在ClassPath目录下,resources就是ClassPath目录
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
//设置返回值
SECKILL_SCRIPT.setResultType(Long.class);
}
//改为调用lua脚本方式判断是否有资格下单
@Override
public Result seckilVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
// 1.调用lua脚本:不建议在代码中直接写死,如果以后想要对脚本做一些调整非常的不方便,可以把它写在一个文件中
//参数1:脚本,类型是RedisScript,通过这个类来加载我们写的脚本文件,每次释放锁读取文件影响性能(会产生io流),所以使用静态代码块提前加载文件
//参数2:key:需要放在集合中,我们调用lua脚本时传递的是ARGV,所以key为null,Collections.emptyList()就代表空集合
//参数3:value,优惠券id(voucherId),用户id(userId)
//释放锁就不用再关心返回值了:释放成功那就成功,失败说明锁已经被别人删除点了或者超时释放
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//TODO 保存阻塞队列
long orderId = redisIdWorker.nextId("order"); //id生成器:根据key生成订单id
// 3.返回订单id
return Result.ok(orderId);
}
测试:
- 启动服务器,使用Postman测试,得到订单id说明下单成功。再次发送请求,不能重复下单说明逻辑正确。
- 查看数据库:因为现在操作的是redis还没有操作数据库所以不会有变化。
- 查看redis:库存正确扣减,添加了一条订单
6.3 秒杀优化-基于阻塞队列实现秒杀优化
IVoucherOrderService
//秒杀优化:订单信息提前创建好放到阻塞队列了,所以直接传订单信息对象即可(异步的也不需要返回值了)
//Result createVoucherOrder(Long voucherId);原来
void createVoucherOrder(VoucherOrder voucherOrder);//修改后的
VoucherOrderServiceImpl
修改下单动作,现在我们去下单时,是通过lua表达式去原子执行判断逻辑,如果判断我出来不为0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是0,则把下单的逻辑保存到队列中去,然后异步执行
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
//注入的是秒杀卷的业务类
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
//注入id生成器类
@Resource
private RedisIdWorker redisIdWorker;
IVoucherOrderService proxy;//代理对象
//提前加载脚本:泛型为返回值类型这里的脚本返回值为数字所以为Long,如果不关心返回值类型了可以写为object
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
//可以直接在创建对象的时候传递 脚本参数,但是这样是直接写死了,所以不建议
SECKILL_SCRIPT = new DefaultRedisScript<>();
//设置脚本的位置:spring提供的包,ClassPathResource默认在ClassPath目录下,resources就是ClassPath目录
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
//设置返回值
SECKILL_SCRIPT.setResultType(Long.class);
}
//阻塞队列BlockingQueue特点:当一个线程尝试从队列中获取元素的时候,如果没有元素线程就会被阻塞,直到队列中有元素线程
// 才会被唤醒并且获取元素-----当前订单也不是一直有,它是有人下单才有没人下单就没有,所以
// 使用阻塞队列刚好合适。
// 里面放的是VoucherOrder类型,ArrayBlockingQueue基于数组实现,需要指定初始化队列的大小
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
//异步处理线程池:处理订单也不需要多快,给一个单线程即可
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//改为调用lua脚本方式判断是否有资格下单 (方式一:)
@Override
public Result seckilVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
// 1.调用lua脚本:不建议在代码中直接写死,如果以后想要对脚本做一些调整非常的不方便,可以把它写在一个文件中
//参数1:脚本,类型是RedisScript,通过这个类来加载我们写的脚本文件,每次释放锁读取文件影响性能(会产生io流),所以使用静态代码块提前加载文件
//参数2:key:需要放在集合中,我们调用lua脚本时传递的是ARGV,所以key为null,Collections.emptyList()就代表空集合
//参数3:value,优惠券id(voucherId),用户id(userId)
//释放锁就不用再关心返回值了:释放成功那就成功,失败说明锁已经被别人删除点了或者超时释放
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 为0,有购买资格,把订单信息(订单id 用户id 优惠卷id)保存到阻塞队列中
// 创建封装对象
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
long orderId = redisIdWorker.nextId("order");//id生成器:根据key生成订单id
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
// 3.获取代理对象以后,想让后续线程可以拿到:放到阻塞队列或者放到成员变量中
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 4.返回订单id
return Result.ok(orderId);
}
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
//提交VoucherOrderHandler这个任务:也就是执行VoucherOrderHandler的run方法
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息
//什么时候执行任务??? 在用户秒杀抢购之前,用户一旦开始秒杀就会向这个阻塞队列里添加新的订单,那么
// 这个任务就应该取出订单信息了,所以必须在这之前执行。
// 事实上项目一启动用户随时能来去抢购,所以这个任务应该在这个类初始化后立刻执行。----利用spring提供的一个注解来做@PostConstruct
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true){
try {
// 1.获取队列中的订单信息
//获取阻塞队列中的头部元素,如果有需要则等待直到有元素可用,所以不用担心死循环 有元素才会执行没有会阻塞。
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户:用户id不能再通过登录拦截器获取,因为现在是从线程池当中获取一个全新的线程,只能从voucherOrder获取用户的id
Long userId = voucherOrder.getUserId();
// 2.创建锁对象:理论上这里也不需要在加锁了,因为已经在redis中做了并发判断了,这里加锁是为了兜底
// 防止redis宕机等没有办法判断成功。
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 3.尝试获取锁
boolean isLock = redisLock.tryLock();
// 4.判断是否获得锁成功
if (!isLock) {
// 获取锁失败,直接返回失败或者重试:现在是异步处理不需要返回给前端了
log.error("不允许重复下单!");
return;
}
try {
//获取代理对象:查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,
// 由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
// 解决:我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
//IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
@Transactional //涉及到库存扣减 订单添加2张表,所以加上事务
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) { //异步的不需要返回了
// 5.一人一单逻辑
// 5.1.用户id:通过之前写的登录拦截器获取-----同样要修改为从voucherOrder中获取
Long userId = voucherOrder.getUserId();
// 5.2.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 5.3.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("用户已经购买过了");
return ;
}
// 6.扣减库存:秒杀的库存值减1
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update(); //where id = ? and stock > 0
if (!success) {
//扣减库存
log.error("用户已经购买过了");
return ;
}
// 7.创建订单:就是订单表新增一条数据,订单表的其它参数都有默认值不用管,只需要设置这几个id即可。
save(voucherOrder);//写入数据库
}
/* 原始方式: (方式二:)
@Override
public Result seckilVoucher(Long voucherId) {
// 1.查询优惠券:秒杀优惠卷的id和普通优惠卷的id是一样的,所以使用voucherId是没问题的。
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.判断库存是否充足:秒杀一人买一个,只要大于1就代表充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码):锁的名称(业务标识+锁的范围,锁的范围是用户,同一个用户才需要限制不同的用户无所谓),注入stringRedisTemplate。
// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); 原先的
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象:超时时间 秒 (这个时长是为了打断点)
//什么都不传:默认值是-1,代表不等待获取锁失败了立即返回
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
return Result.fail("不允许重复下单");
}
//下面代码可能出现异常,最终出不出现异常都要释放锁
try {
//获取事务有关的代理对象
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
//因为这是通过接口调用这个方法,接口中没有,现在是实现类中有这个方法,所以把这个方法暴漏到接口中,否测会报错。
return o.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
@Transactional //涉及到库存扣减 订单添加2张表,所以加上事务
@Override
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单逻辑
// 5.1.用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
// 5.2.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.3.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存:秒杀的库存值减1
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).gt("stock", 0).update(); //where id = ? and stock > 0
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
// 7.创建订单:就是订单表新增一条数据,订单表的其它参数都有默认值不用管,只需要设置这几个id即可。
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.2.代金券id:上面参数传递来的
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);//写入数据库
// 8.返回订单id
return Result.ok(orderId);
}*/
}
测试:
- 启动服务。换元数据库和redis数据,Postman发送请求
- 查看数据库:扣减了库存,并有订单信息
- 查看redis:同样扣减了库存,并有订单信息
小总结:
秒杀业务的优化思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
- 基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题:我们现在使用的是JDK里的阻塞队列,它使用的是JVM的内存,如果在高并发的条件下,无数的订单都会放在阻塞队列里,可能就会造成内存溢出,所以我们在创建阻塞队列时,设置了一个长度,但是如果真的存满了,再有新的订单来往里塞,那就塞不进去了,存在内存限制问题
- 数据安全问题:经典服务器宕机了,用户明明下单了,但是数据库里没看到
7、Redis消息队列
7.1 Redis消息队列-认识消息队列
什么是消息队列:字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
使用队列的好处在于 解耦:所谓解耦,举一个生活中的例子就是:快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,如果耦合,那么这个快递员相当于直接把快递交给你,这事固然好,但是万一你不在家,那么快递员就会一直等你,这就浪费了快递员的时间,所以这种思想在我们日常开发中,是非常有必要的。
这种场景在我们秒杀中就变成了:我们下单之后,利用redis去进行校验下单条件,再通过队列把消息发送出去,然后再启动一个线程去消费这个消息,完成解耦,同时也加快我们的响应速度。
这里我们可以使用一些现成的mq,比如kafka,rabbitmq等等,但是呢,如果没有安装mq,我们也可以直接使用redis提供的mq方案,降低我们的部署和学习成本。
7.2 Redis消息队列-基于List实现消息队列
基于List结构模拟消息队列
消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
队列是入口和出口不在一边(先进先出
),因此我们可以利用:LPUSH (左侧插入元素)结合 RPOP(移除右侧元素)、或者 RPUSH (右侧插入元素)结合 LPOP(移除左侧元素)来实现。
不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP(移除并返回列表右侧的第一个元素)
或者 BLPOP(移除并返回列表左侧的第一个元素,没有则返回nil)
来实现阻塞效果。
基于List的消息队列有哪些优缺点?
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失(消息拿走了还没来得及处理,服务器宕机,消息就丢失了)
- 只支持单消费者(一个消费者把消息拿走了,其他消费者就看不到这条消息了)
7.3 Redis消息队列-基于PubSub的消息队列
PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSCRIBE channel [channel] :订阅一个或多个频道
- PUBLISH channel msg :向一个频道发送消息
- PSUBSCRIBE pattern[pattern] :订阅与pattern(通配符)格式匹配的所有频道
基于PubSub的消息队列有哪些优缺点?
优点:
- 采用发布订阅模型,支持多生产、多消费
- 可以让一条消息被多个消费者拿到或者只让一个消费者拿到,非常的灵活。不同生产者也可以发给多个消费者。
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 上面的list本质是一个链表,本来就是做数据存储的,只不过我们把它当做消息队列来用了,而redis中所有这些做数据存储的都支持持久化的。
- PubSub设计出来就是做发送消息的,因此发送一条消息如果这个频道没有被人订阅,消息就丢失了。
- 消息堆积有上限,超出时数据丢失
- 发送一条消息时如果有消费者监听,会在消费者哪里有一个缓存区域,如果消息处理比较慢这些消息都会缓存在消费者客户端里面,而消费者的缓存空间是有上限的。
7.4 Redis消息队列-基于Stream的消息队列
Stream 是 Redis 5.0 引入的一种新数据类型(redis中的数据类型都支持持久化),可以实现一个功能非常完善的消息队列。
发送消息的命令:xadd
例如:
读取消息的方式之一:XREAD
阻塞时长为0:代表永久阻塞
例如,使用XREAD读取第一个消息:
XREAD阻塞方式,读取最新的消息:
在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下
注意:当我们指定起始ID为$时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现
漏读消息
的问题
STREAM类型消息队列的XREAD命令特点:
- 消息可回溯
- 消息读完后不丢失,永久保存在队列中
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
7.5 Redis消息队列-基于Stream的消息队列-消费者组
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:
pending:待处理
创建消费者组:
- key:队列名称
- groupName:消费者组名称
- ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
- MKSTREAM:队列不存在时自动创建队列
其它常见命令:
删除指定的消费者组
XGROUP DESTORY key groupName
给指定的消费者组添加消费者
XGROUP CREATECONSUMER key groupname consumername
删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername
从消费者组读取消息:
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
- group:消费组名称
- consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
- count:本次查询的最大数量
- BLOCK milliseconds:当没有消息时最长等待时间
- NOACK:无需手动ACK,获取到消息后自动确认
- STREAMS key:指定队列名称
- ID:获取消息的起始ID:
- “>”:从下一个未消费的消息开始
- 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
消费者监听消息的基本思路:
STREAM类型消息队列的XREADGROUP命令特点:
- 消息可回溯
- 同一个消费者组,可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次
最后我们来个小对比
总结:
- 如果公司业务比较庞大,对于消息队列要求更加严格,建议还是使用更加专业的消息队列(比如rabbitmq。 RocketMQ等等)
- 因为Stream虽然支持消息的持久化,但是它是依赖于redis本身的,而redis的这种持久化也不能保证万无一失,还是有丢失风险
- 而且这种消息确认机制只支持消费者确认机制,而不支持生产者消息确认机制,如果在生产者发送消息的过程丢失了,那就没办法了。
- 如果是一些中小型企业对于队列要求没有那么高,Stream已经够用了。
7.6 基于Redis的Stream结构作为消息队列,实现异步秒杀下单
需求:
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单\
步骤一:创建一个Stream类型的消息队列,名为stream.orders
XGROUP CREATE stream.orders g1 0 MKSTREAM
步骤二:修改Lua脚本 新增3.6 ,新增orderId参数,并将订单信息加入到消息队列中
-- 1.参数列表
-- 1.1.优惠券id 他不是key 将来拼接id得到key所以从ARGV中取
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key lua拼接用 ..
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey:redis中调用得到的是String类型无法与数字进行比较,所以需要tonumber方法转化为数值类型
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId(redis的set命令,判断一个元素是否存在于set中,存在返回1不存在返回0)
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1 (加上-1相当于-1,redis的String命令)
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId (redis的Set命令)
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
步骤三:修改秒杀逻辑 VoucherOrderServiceImpl
修改:seckilVoucher
由于将下单数据发送到消息队列的功能,我们在Lua脚本中实现了,所以这里就不需要将下单数据加入到JVM的阻塞队列中去了,同时Lua脚本中我们新增了一个参数
//改为调用lua脚本方式---消息队列 判断是否有资格下单 (方式三:)
@Override
public Result seckilVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");//id生成器:根据key生成订单id
// 1.调用lua脚本:不建议在代码中直接写死,如果以后想要对脚本做一些调整非常的不方便,可以把它写在一个文件中
//参数1:脚本,类型是RedisScript,通过这个类来加载我们写的脚本文件,每次释放锁读取文件影响性能(会产生io流),所以使用静态代码块提前加载文件
//参数2:key:需要放在集合中,我们调用lua脚本时传递的是ARGV,所以key为null,Collections.emptyList()就代表空集合
//参数3:value,优惠券id(voucherId),用户id(userId)
//释放锁就不用再关心返回值了:释放成功那就成功,失败说明锁已经被别人删除点了或者超时释放
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
/* // 2.2 为0,有购买资格,把订单信息(订单id 用户id 优惠卷id)保存到阻塞队列中
// 创建封装对象
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);*/
// 3.获取代理对象以后,想让后续线程可以拿到:放到阻塞队列或者放到成员变量中
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 4.返回订单id
return Result.ok(orderId);
}
根据之前的伪代码逻辑来修改我们的VoucherOrderHandler
// 用于线程池处理的任务 (方式三:从消息队列中获取消息)
// 当初始化完毕后,就会去从对列中去拿信息
//什么时候执行任务??? 在用户秒杀抢购之前,用户一旦开始秒杀就会向这个阻塞队列里添加新的订单,那么
// 这个任务就应该取出订单信息了,所以必须在这之前执行。
// 事实上项目一启动用户随时能来去抢购,所以这个任务应该在这个类初始化后立刻执行。----利用spring提供的一个注解来做@PostConstruct
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true){
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), //组名称 消费者名称
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), //读取数量,阻塞时长为2秒
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())//队列名称, ">":从下一个未消费的消息开始
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 3.解析数据
//我们明确知道是COUNT 1获取一个数据:list集合里面只有一条消息,所以list.get(0)
//得到的结果是MapRecord类型:底层是map类型,K(消息的id:Stream中发送的消息都有id)
// v(和我们发的消息格式有关:查看seckill.lua-3.6,可以看到是一个个的键值对)
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();//拿到键值对
//把map转化为order对象:胡图工具包提供(map 实体类对象 出错忽略)
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4.如果获取成功,创建订单
handleVoucherOrder(voucherOrder);
// 5.确认消息 ACK:SACK stream.orders g1 id (队列名字 组 消息id)
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "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 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), //组名称 消费者名称
StreamReadOptions.empty().count(1), //读取数量
StreamOffset.create("stream.orders", ReadOffset.from("0"))//队列名称, 0:是从pending-list中的第一个消息开始
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明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.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
//说明:处理pendding订单又异常不需要递归再次调用本方法,因为出现异常后又没有跳出循环,所以会继续循环。
log.error("处理pendding订单异常", e);
//如果怕处理pendding订单异常多次出现出现多次死循环,可以在这里休眠一会儿
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
VoucherOrderServiceImpl总代码:
package com.hmdp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.stream.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
//注入的是秒杀卷的业务类
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
//注入id生成器类
@Resource
private RedisIdWorker redisIdWorker;
IVoucherOrderService proxy;//代理对象
//提前加载脚本:泛型为返回值类型这里的脚本返回值为数字所以为Long,如果不关心返回值类型了可以写为object
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
//可以直接在创建对象的时候传递 脚本参数,但是这样是直接写死了,所以不建议
SECKILL_SCRIPT = new DefaultRedisScript<>();
//设置脚本的位置:spring提供的包,ClassPathResource默认在ClassPath目录下,resources就是ClassPath目录
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
//设置返回值
SECKILL_SCRIPT.setResultType(Long.class);
}
/* 方式二:从阻塞队列中获取消息
//阻塞队列BlockingQueue特点:当一个线程尝试从队列中获取元素的时候,如果没有元素线程就会被阻塞,直到队列中有元素线程
// 才会被唤醒并且获取元素-----当前订单也不是一直有,它是有人下单才有没人下单就没有,所以
// 使用阻塞队列刚好合适。
// 里面放的是VoucherOrder类型,ArrayBlockingQueue基于数组实现,需要指定初始化队列的大小
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);*/
//异步处理线程池:处理订单也不需要多快,给一个单线程即可
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//改为调用lua脚本方式---消息队列 判断是否有资格下单 (方式三:)
@Override
public Result seckilVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");//id生成器:根据key生成订单id
// 1.调用lua脚本:不建议在代码中直接写死,如果以后想要对脚本做一些调整非常的不方便,可以把它写在一个文件中
//参数1:脚本,类型是RedisScript,通过这个类来加载我们写的脚本文件,每次释放锁读取文件影响性能(会产生io流),所以使用静态代码块提前加载文件
//参数2:key:需要放在集合中,我们调用lua脚本时传递的是ARGV,所以key为null,Collections.emptyList()就代表空集合
//参数3:value,优惠券id(voucherId),用户id(userId)
//释放锁就不用再关心返回值了:释放成功那就成功,失败说明锁已经被别人删除点了或者超时释放
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
/* // 2.2 为0,有购买资格,把订单信息(订单id 用户id 优惠卷id)保存到阻塞队列中
// 创建封装对象
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);*/
// 3.获取代理对象以后,想让后续线程可以拿到:放到阻塞队列或者放到成员变量中
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 4.返回订单id
return Result.ok(orderId);
}
/* //改为调用lua脚本方式判断是否有资格下单 (方式二:)
@Override
public Result seckilVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
// 1.调用lua脚本:不建议在代码中直接写死,如果以后想要对脚本做一些调整非常的不方便,可以把它写在一个文件中
//参数1:脚本,类型是RedisScript,通过这个类来加载我们写的脚本文件,每次释放锁读取文件影响性能(会产生io流),所以使用静态代码块提前加载文件
//参数2:key:需要放在集合中,我们调用lua脚本时传递的是ARGV,所以key为null,Collections.emptyList()就代表空集合
//参数3:value,优惠券id(voucherId),用户id(userId)
//释放锁就不用再关心返回值了:释放成功那就成功,失败说明锁已经被别人删除点了或者超时释放
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 2.2 为0,有购买资格,把订单信息(订单id 用户id 优惠卷id)保存到阻塞队列中
// 创建封装对象
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
long orderId = redisIdWorker.nextId("order");//id生成器:根据key生成订单id
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
// 3.获取代理对象以后,想让后续线程可以拿到:放到阻塞队列或者放到成员变量中
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 4.返回订单id
return Result.ok(orderId);
}*/
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
//提交VoucherOrderHandler这个任务:也就是执行VoucherOrderHandler的run方法
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务 (方式三:从消息队列中获取消息)
// 当初始化完毕后,就会去从对列中去拿信息
//什么时候执行任务??? 在用户秒杀抢购之前,用户一旦开始秒杀就会向这个阻塞队列里添加新的订单,那么
// 这个任务就应该取出订单信息了,所以必须在这之前执行。
// 事实上项目一启动用户随时能来去抢购,所以这个任务应该在这个类初始化后立刻执行。----利用spring提供的一个注解来做@PostConstruct
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true){
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), //组名称 消费者名称
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), //读取数量,阻塞时长为2秒
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())//队列名称, ">":从下一个未消费的消息开始
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 3.解析数据
//我们明确知道是COUNT 1获取一个数据:list集合里面只有一条消息,所以list.get(0)
//得到的结果是MapRecord类型:底层是map类型,K(消息的id:Stream中发送的消息都有id)
// v(和我们发的消息格式有关:查看seckill.lua-3.6,可以看到是一个个的键值对)
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();//拿到键值对
//把map转化为order对象:胡图工具包提供(map 实体类对象 出错忽略)
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4.如果获取成功,创建订单
handleVoucherOrder(voucherOrder);
// 5.确认消息 ACK:SACK stream.orders g1 id (队列名字 组 消息id)
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "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 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), //组名称 消费者名称
StreamReadOptions.empty().count(1), //读取数量
StreamOffset.create("stream.orders", ReadOffset.from("0"))//队列名称, 0:是从pending-list中的第一个消息开始
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明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.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
//说明:处理pendding订单又异常不需要递归再次调用本方法,因为出现异常后又没有跳出循环,所以会继续循环。
log.error("处理pendding订单异常", e);
//如果怕处理pendding订单异常多次出现出现多次死循环,可以在这里休眠一会儿
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
/* 方式二:从阻塞队列中获取消息
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息
//什么时候执行任务??? 在用户秒杀抢购之前,用户一旦开始秒杀就会向这个阻塞队列里添加新的订单,那么
// 这个任务就应该取出订单信息了,所以必须在这之前执行。
// 事实上项目一启动用户随时能来去抢购,所以这个任务应该在这个类初始化后立刻执行。----利用spring提供的一个注解来做@PostConstruct
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true){
try {
// 1.获取队列中的订单信息
//获取阻塞队列中的头部元素,如果有需要则等待直到有元素可用,所以不用担心死循环 有元素才会执行没有会阻塞。
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户:用户id不能再通过登录拦截器获取,因为现在是从线程池当中获取一个全新的线程,只能从voucherOrder获取用户的id
Long userId = voucherOrder.getUserId();
// 2.创建锁对象:理论上这里也不需要在加锁了,因为已经在redis中做了并发判断了,这里加锁是为了兜底
// 防止redis宕机等没有办法判断成功。
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 3.尝试获取锁
boolean isLock = redisLock.tryLock();
// 4.判断是否获得锁成功
if (!isLock) {
// 获取锁失败,直接返回失败或者重试:现在是异步处理不需要返回给前端了
log.error("不允许重复下单!");
return;
}
try {
//获取代理对象:查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,
// 由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
// 解决:我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
//IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
@Transactional //涉及到库存扣减 订单添加2张表,所以加上事务
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) { //异步的不需要返回了
// 5.一人一单逻辑
// 5.1.用户id:通过之前写的登录拦截器获取-----同样要修改为从voucherOrder中获取
Long userId = voucherOrder.getUserId();
// 5.2.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 5.3.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("用户已经购买过了");
return ;
}
// 6.扣减库存:秒杀的库存值减1
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update(); //where id = ? and stock > 0
if (!success) {
//扣减库存
log.error("用户已经购买过了");
return ;
}
// 7.创建订单:就是订单表新增一条数据,订单表的其它参数都有默认值不用管,只需要设置这几个id即可。
save(voucherOrder);//写入数据库
}
/* 原始方式: (方式一:)
@Override
public Result seckilVoucher(Long voucherId) {
// 1.查询优惠券:秒杀优惠卷的id和普通优惠卷的id是一样的,所以使用voucherId是没问题的。
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.判断库存是否充足:秒杀一人买一个,只要大于1就代表充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
// 用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码):锁的名称(业务标识+锁的范围,锁的范围是用户,同一个用户才需要限制不同的用户无所谓),注入stringRedisTemplate。
// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); 原先的
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象:超时时间 秒 (这个时长是为了打断点)
//什么都不传:默认值是-1,代表不等待获取锁失败了立即返回
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
return Result.fail("不允许重复下单");
}
//下面代码可能出现异常,最终出不出现异常都要释放锁
try {
//获取事务有关的代理对象
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
//因为这是通过接口调用这个方法,接口中没有,现在是实现类中有这个方法,所以把这个方法暴漏到接口中,否测会报错。
return o.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
@Transactional //涉及到库存扣减 订单添加2张表,所以加上事务
@Override
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单逻辑
// 5.1.用户id:通过之前写的登录拦截器获取
Long userId = UserHolder.getUser().getId();
// 5.2.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.3.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存:秒杀的库存值减1
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).gt("stock", 0).update(); //where id = ? and stock > 0
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
// 7.创建订单:就是订单表新增一条数据,订单表的其它参数都有默认值不用管,只需要设置这几个id即可。
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.2.代金券id:上面参数传递来的
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);//写入数据库
// 8.返回订单id
return Result.ok(orderId);
}*/
}
测试:
-
启动服务器,Postman发送请求进行测试
-
查看数据库:库存扣减,有了新订单
-
查看redis:库存99----98,出现了新的订单
8、达人探店
8.1 达人探店-发布探店笔记
发布探店笔记
探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:
-
tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
-
tb_blog_comments:其他用户对探店笔记的评价
具体发布流程
说明:
- 发布照片和发布笔记这2个功能是分立的,因为上传照片的功能不仅仅是在发布笔记的时候用,其它业务也有上传照片这个功能。
- 首先上传图片返回的是图片的地址,之后这个地址作为表单的参数一起提交到后台。
- 这2个接口已经写好了,因为这里和redis没什么关系,就是基本的增删改查。
上传图片接口:
@Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
}
- 注意:同学们在操作时,需要修改SystemConstants.IMAGE_UPLOAD_DIR 自己图片所在的地址,在实际开发中图片一般会放在nginx上或者是云存储上。
- 解释:企业里面一般会保存在文件服务器中,但是这里不关注上传本身只关注与redis相关的,所以这里保存在本地即可。
- 保存本地也不是随便保存的,需要保存到前端服务器上,这样将来保存完了我们就直接能够访问。
发布笔记接口:BlogController
@RestController
@RequestMapping("/blog")
public class BlogController {
@Resource
private IBlogService blogService;
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
//获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUpdateTime(user.getId());
//保存探店博文
blogService.saveBlog(blog);
//返回id
return Result.ok(blog.getId());
}
}
测试:
- 启动服务器,在页面上发布笔记
- 效果:
- 数据库中保存了上传的笔记信息
8.2 达人探店-查看探店笔记
实现查看发布探店笔记的接口
返回值说明:
- 发布的探店笔记都包含用户信息,然后才是图片笔记内容,所以在详情页面展示的时候除了图片标题以外,发布笔记的用户也应该展示出来,这样其它用户看到这篇博客如果感兴趣就可以直接关注该用户了。
- 返回结果如何包含2部分内容:
- 方式一:在blog加上一个user的成员变量
- 方式二:在blog类里面加上2个用户有关的字段,用户除了id 、图标、姓名,剩下的一些敏感字段就不返回了,需要加上注解(
@TableField(exist = false)
,代表当前字段不属于blog实体类对应数据库表中的字段,这2个字段需要我们自己维护。 - @TableField(exist = false):这个注解表示该字段在数据库表中不存在。当使用 MyBatis-Plus 的自动注入 SQL 语句功能时,会忽略这个字段,不会将其包含在 SQL 查询语句中。通常用于实体类中需要额外计算或处理的字段,而不需要与数据库表中的字段进行映射。
实现代码:
BlogController :
@GetMapping("/{id}")
public Result queryById(@PathVariable Integer id){
return blogService.queryById(id);
}
BlogServiceImpl
@Override
public Result queryById(Integer id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
// 2.查询blog有关的用户
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
return Result.ok(blog);
}
我们顺手将queryHotBlog也修改一下,原始代码将业务逻辑写到了Controller中,修改后的完整代码如下(和上面2个一样都在同一个controller、ServiceImpl层)
BlogController
@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
return blogService.queryHotBlog(current);
}
BlogServiceImpl
@Resource
private IUserService userService;
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog ->{
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
});
return Result.ok(records);
}
测试:
- 启动服务器,点击页面详情
8.3 达人探店-点赞功能
初始代码
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
// 修改点赞数量 update tb_blog set liked = liked + 1 where id = ?
// liked:点赞数量
blogService.update()
.setSql("liked = liked + 1").eq("id", id).update();
return Result.ok();
}
问题分析:这种方式会导致一个用户无限点赞,明显是不合理的
造成这个问题的原因是,我们现在的逻辑,发起请求只是给数据库+1,所以才会出现这个问题
完善点赞功能
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
- 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
- 利用数据库的表,记录blog的id,还有给blog点赞的user的id,每点一次赞表里面就记录一次,下次再来就先判断一下是否存在,存在就不点了。
- 缺点:数据库的性能不太好,点赞的用户比较多对数据库的压力也比较大。
- 解决:使用redis。
- 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 点击笔记详情的时候
- 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 点击首页的时候
为什么采用set集合:
因为我们的数据是不能重复的,当用户操作过之后,无论他怎么操作,都是
k:笔记的id,v:用户的:id
具体步骤:
1、在Blog 添加一个字段
/**
* 是否点赞过了
*/
@TableField(exist = false)
private Boolean isLike;
2、修改代码
controller:
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
//业务逻辑修改至业务层
return blogService.likeBlog(id);
}
service:
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + id; //前缀+笔记的id作为k
// opsForSet():操作set集合
// isMember(k, v):判断一个元素是否存在于set中,我们用的是StringRedisTemplate所以需要转化为字符串类型
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
if (BooleanUtil.isFalse(isMember)) {//false:不存在说明没有点赞
//3.如果未点赞,可以点赞
//3.1 数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//3.2 保存用户到Redis的set集合
if (isSuccess) {
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
} else {
//4.如果已点赞,取消点赞
//4.1 数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
//4.2 把用户从Redis的set集合移除
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}
修改完毕之后,页面上还不能立即显示点赞完毕的后果,我们还需要修改查询Blog业务,判断Blog是否被当前用户点赞过
控制层
业务层
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
//追加判断blog是否被当前用户点赞,逻辑封装到isBlogLiked方法中
isBlogLiked(blog);
});
return Result.ok(records);
}
@Override
public Result queryById(Integer id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
// 2.查询blog有关的用户
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
//追加判断blog是否被当前用户点赞,逻辑封装到isBlogLiked方法中
isBlogLiked(blog);
return Result.ok(blog);
}
//上面2个方法都要用封装成函数
private void isBlogLiked(Blog blog) {
//1. 获取当前用户信息
Long userId = UserHolder.getUser().getId();
//2. 判断当前用户是否点赞
String key = BLOG_LIKED_KEY + blog.getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
//3. 如果点赞了,则将isLike设置为true boolean默认是false
blog.setIsLike(BooleanUtil.isTrue(isMember));
}
测试:
- 一个用户只能点赞一次,点赞成功高亮显示,重复点赞取消,并且redis有点赞记录
8.4 达人探店-点赞排行榜
在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:
之前的点赞是放到set集合,但是set集合是不能排序的,所以这个时候,咱们可以采用一个可以排序的set集合,就是咱们的sortedSet
我们接下来来对比一下这些集合的区别是什么
所有点赞的人,需要是唯一的,所以我们应当使用set或者是sortedSet
其次我们需要排序,就可以直接锁定使用sortedSet啦
判断元素是否存在:获取sorted set中的指定元素的score分数值,元素存在返回的就是分数,元素不存在返回的为空。
修改代码
BlogServiceImpl
点赞逻辑代码
@Override
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + id; //前缀+笔记的id作为k
// opsForSet():操作set集合
// isMember(k, v):判断一个元素是否存在于set中,我们用的是StringRedisTemplate所以需要转化为字符串类型
//Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());//修改为sset方式
if (score == null) { //分数不存在证明没有点赞
//3.如果未点赞,可以点赞
//3.1 数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//3.2 保存用户到Redis的SortedSet集合 zadd key value score
if (isSuccess) {
//k v 时间戳
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
} else {
//4.如果已点赞,取消点赞
//4.1 数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
//4.2 把用户从Redis的sset集合移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
//上面2个方法都要用封装成函数
private void isBlogLiked(Blog blog) {
//1. 获取当前用户信息
Long userId = UserHolder.getUser().getId();
//2. 判断当前用户是否点赞
String key = BLOG_LIKED_KEY + blog.getId();
//Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());//修改为sset方式
//3. 如果点赞了,则将isLike设置为true boolean默认是false
//blog.setIsLike(BooleanUtil.isTrue(isMember));
blog.setIsLike(score != null);//修改为sset方式:分数不为null说明点赞了
}
点赞列表查询列表
BlogController
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
return blogService.queryBlogLikes(id);
}
BlogService
@Override
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 1.查询top5的点赞用户 zrange key 0 4: 查询zset中前5个元素
// 我们查询到的是sset集合里的用户id和分数,但是返回的结果是用户信息
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
//如果是空的(可能没人点赞),直接返回一个空集合,防止下面出现空指针
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2.解析出其中的用户id:java8新特性,不一定非要用这个的,你简单的for循环也没事
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
//将ids使用`,`拼接,SQL语句查询出来的结果并不是按照我们期望的方式进行排
//所以我们需要用order by field来指定排序方式,期望的排序方式就是按照查询出来的id进行排序
String idStr = StrUtil.join(",", ids); //把id拼接成字符串:5,1
// 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);
}
测试:
- 重启服务,点赞成功
- 换个号点赞测试:刷新首页,发现首页无法正常访问,查看控制台出现空指针异常
- 原因:用户没有登录获取用户信息出现问题,用户没有登录的情况下应该是不需要查询是否点赞的。
- 去数据库里找个用户:登录后再次点赞测试
- 点赞成功:但是发现先点赞的排在后面显示
- 原因:这是数据库使用in的原因,可以看到给的是5在前面1在后面,但是显示是1在前面5在后面,当使用in的时候查询的结果不会按照你给的顺序进行查询。
- 解决:使用order by手动指定顺序,后面的顺序要和你传入的顺序保持一致即可。
- 代码实现:
- 顺序改变成功:
9、好友关注
9.1 好友关注-关注和取消关注
针对用户的操作:可以对用户进行关注和取消关注功能。
实现思路:
需求:基于该表数据结构,实现两个接口:
- 关注和取关接口
- 关注:用户id和被关注的用户id,插入这张表。
- 取关:删除这条关系数据。
- 判断是否关注的接口
- 根据用户id和关联用户id查询,有说明已经关注了。
关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示:
多对多关系的中间表
注意: 这里需要把主键修改为自增长,简化开发。
FollowController
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
/**
* 关注/取消关注接口
* @param followUserId 被关注用户的id
* @param isFollow true:关注 false:取消关注
* @return
*/
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
/**
* 是否关注了
* @param followUserId 被关注用户的id
* @return
*/
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
}
FollowService
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 获取登录用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
// 1.判断到底是关注还是取关
if (isFollow) {
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = 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);
}
}
测试:
- 成功
9.2 好友关注-共同关注
想要去看共同关注的好友,需要首先进入到这个页面,这个页面会发起两个请求
1、去查询用户的详情
2、去查询用户的笔记
以上两个功能和共同关注没有什么关系,大家可以自行将笔记中的代码拷贝到idea中就可以实现这两个功能了,我们的重点在于共同关注功能。
// UserController 根据id查询用户
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId){
// 查询详情
User user = userService.getById(userId);
if (user == null) {
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 返回
return Result.ok(userDTO);
}
// BlogController 根据id查询博主的探店笔记
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
// 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}
接下来我们来看看共同关注如何实现:
需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同关注呢。
当然是使用我们之前学习过的set集合咯,在set集合中,有交集并集补集的api,我们可以把两人的关注的人分别放入到一个set集合中,然后再通过api去查看这两个set集合中的交集数据。
我们先来改造当前的关注列表
改造原因是因为我们需要在用户关注了某位用户后,需要将数据放入到set集合中,方便后续进行共同关注,同时当取消关注时,也需要从set集合中进行删除
k:当前用户的id
v:所有关注用户的id
FollowServiceImpl
@Resource
StringRedisTemplate stringRedisTemplate;
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 获取登录用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
// 1.判断到底是关注还是取关
if (isFollow) {
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
// 把关注用户的id,放入redis的set集合 sadd userId followerUserId
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
// 把关注用户的id从Redis集合中移除
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
具体的关注代码:
FollowController
//被关注用户的id
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable Long id){
return followService.followCommons(id);
}
FollowServiceImpl
//求目标用户和当前用户的交集
@Override
public Result followCommons(Long id) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId; //当前用户的key
// 2.求交集
String key2 = "follows:" + id; //目标用户的key
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);//得到的是2个交集用户所有的id
if (intersect == null || intersect.isEmpty()) {
// 无交集
return Result.ok(Collections.emptyList());
}
// 3.解析id集合:转化为Long型
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);
}
测试:
- 当前用户关注“可可今天不吃肉”
- 切换账号:关注“可可今天不吃肉”,“小鱼同学”
- 此时登录的是第二次账户,查看第一次登录的用户笔记:成功
9.3 好友关注-Feed流实现方案
当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。
Feed流的实现有两种模式:
Feed流产品有两种常见模式:
Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
我们本次针对好友的操作,采用的就是Timeline的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可,因此采用Timeline的模式。该模式的实现方案有三种:
- 拉模式
- 推模式
- 推拉结合
拉模式:也叫做读扩散
该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
推模式:也叫做写扩散。
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
- 对比:
- 总结:当前粉丝量比较少,所以使用推模式即可。
9.4 好友关注-推送到粉丝收件箱
需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 保存数据到数据库的逻辑仍需要保留,因为数据库相对的持久和安全一些
- 既然数据库已经有了完整的笔记数据内容,那么在推送到粉丝收件箱的时候只需要推送一个id即可,起到将来排序的作用,用户在查询详细信息的时候,就可以拿着id去数据库查询即可。这样可以进一步的节省内存空间。
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- redis中排序的结构:list,SortedSet
- 查询收件箱数据时,可以实现分页查询
- 分页需要指定起始的角标
- list:有角标,底层是链表所以可以分页
- SortedSet:没有角标,但是在排序后有一个排名的概念,可以按照排名来作为查询条件的,排名是从0开始起始是和角标是一个概念的。因此也可以分页。
Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。
传统了分页在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后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了
- 问题:每此记录上一次查询的最后一条,那么第一次查询该怎么办?
- 答:既然是倒序,那么可以把第一次的起始id指定为无穷大
- SortedSet会按照score值排序,会有一个排名,如果按照排名查询那么跟角标查询没有区别。
- SortedSet还支持按照score值范围查询,score值设置为时间戳,把时间戳按照从大小的范围排列,每一次查询的时候都记住最小的时间戳,然后下次查询时在找比这个时间戳更小的,这样就实现滚动分页了,数据也不会重复。
核心的意思:就是我们在保存完探店笔记后,获得到当前笔记的粉丝,然后把数据推送到粉丝的redis中去。
控制层:
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
业务层:
@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.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?
// 写博客的用户就是当前登录的用户userId,
// 粉丝:到用户粉丝关系表查找tb_follow,其中user_id是粉丝的id,follow_user_id是被关注人的id
// 现在想要找作者的所有粉丝:只需要找follow_user_id=作者id的所有数据
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_KEY + userId;
// SortedSet:新增的语法
// k:粉丝的id(每个粉丝都有SortedSet,所以k是粉丝的id)
// v:笔记的id(不要推送整个笔记)
// score :按时间排序,所以是时间戳
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5.返回id
return Result.ok(blog.getId());
}
9.5 好友关注-实现分页查询收邮箱
需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息:
分析:
-
score:设置为时间戳,降序排序,这样时间戳越大代表越新的数据。
-
ZREVRANGEBYSCORE key max min WITHSCORES LIMIT offset count
:按照score降序排序后,获取指定score范围内的元素,并且每次分页查询出那几个元素。- max:分数最大值,上一次查询最小的分数,如果是第一次则给当前时间戳的最大值,因为当前时间戳才是最新最大值。
- min:最小值,不关心,滚动查询的思路是记住上一次查询到哪了,从那开始数查几条即可。所以只关心从那开始、总共查几条,最小值不关心那就给个0,因为时间没有负数最小值就是0。
- WITHSCORES :要不到带分数
- offset :偏移量,最大值开始的第几个元素开始查询。这里的最大值最小值都是包含关系,所以如果是0则代表小于等于最大值的第一个元素开始查询,如果是就到表从小于等于最大值的第二个元素开始查询
- count:查几条
滚动分页查询参数初步总结:ZREVRANGEBYSCORE key max min WITHSCORES LIMIT offset count
- max:最大值
- 第一次:当前时间戳,因为对于时间来说当前时间就是最大值。
- 之后:上一次查询的最小值。
- min:最小值不用管,这里是时间戳所以设置为0
- offset:偏移量
- 第一次来:给0即可(如:8 7 6 5 4 3 2 1,直接从第一)
- count:跟前端约定查几条即可。
滚动分页查询参数最终总结:
- max
- min:最小值不用管,这里是时间戳所以设置为0
- offset
- count:跟前端约定查几条即可。
具体操作如下:
1、每次查询完成后,我们要分析出查询出数据的最小时间戳,这个值会作为下一次查询的条件
2、我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据
综上:我们的请求参数中就需要携带 lastId:上一次查询的最小时间戳 和偏移量这两个参数。
这两个参数第一次会由前端来指定,以后的查询就根据后台结果作为条件,再次传递到后台。
一、定义出来具体的返回值实体类
@Data
public class ScrollResult {
//泛型设置为?:比较通用,之后查询其它东西也能用滚动分页
private List<?> list;
private Long minTime;
private Integer offset;
}
BlogController
注意:RequestParam 表示接受url地址栏传参的注解,当方法上参数的名称和url地址栏不相同时,可以通过RequestParam 来进行指定
/**
*
* @param max:最大值,即上一次查询的最小值,第一次查询为当前时间戳,参数传递得有
* @param offset:偏移量,第一次为0,前端没有传递,自己设置默认值为0
* @return
*/
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){
return blogService.queryBlogOfFollow(max, offset);
}
BlogServiceImpl
/**
* 需求:查询收件箱里面的所有笔记,然后做一个滚动分页
* 第一步:先找到收件箱,之前设置的时一个用户一个收件箱,所以找收件箱就是找到当前用户。
* 第二步:收件箱是个SortedSet集合,k:用户的id,v笔记的id,分数:时间戳,然后进行解析。
* 第三步:封装
*
*/
@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;
// 滚动分页查询:key(前缀+用户id) min(最小值不关心,时间戳为0)
// max(第一次当前时间戳,之后上一次查询的最小值):前端传递了
// offset(第一次为0 ,之后为在上一次的结果中,与最小值一样的元素个数):由前端传递
// count:这里写死了,每页查询2条
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()); //保存id的集合:自动扩容影响性能,所以创建时最好指定大小
long minTime = 0; // 2
int os = 1; // 2
//这个for循环里面的逻辑:可以正确的计算出偏移量和最小时间戳,可以带入这个 5 4 4 2 2(分数time)测试
// 一个小算法
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2(分数time)
// 4.1.获取id
String idStr = tuple.getValue();
ids.add(Long.valueOf(idStr));
// 4.2.获取分数(时间戳):
long time = tuple.getScore().longValue();
// 4.3.偏移量:跟最小分数值一样的数最起码有一个,就是他自己,所以上面os初始化为1.
if(time == minTime){ // 5 0
//如果获取的时间戳=最小时间戳,则计数一次
os++;
}else{
// 最小时间:那么时间戳,集合中的最后一个是最小的(降序),每遍历一次后面的时间戳赋值给前面的,
// 遍历完后存的就是最后一个时间戳,即最小值。
//如果获取的时间戳!=最小时间戳,说明你当前这个最小时间不是最小时间,因为后取到的时间一定比先取到的小,
// 所以用现在的时间覆盖最小的时间,它就是新的最小时间,接收的偏移量要重置为1.
minTime = time;
os = 1;
}
}
// 5.根据id查询blog
// List<Long> ids 我们返回的id是有序的,返回给前端也需要有序,sql使用in查询不能保证顺序,所以需要手动指定排序
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
//现在查询的是blog笔记列表,笔记是可以点赞的,笔记关联的用户是谁,因此每次代码中查询博客笔记都要做这2件事
for (Blog blog : blogs) {
// 5.1.查询blog有关的用户
Long useId = blog.getUserId();
User user = userService.getById(useId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
//5.2.查询blog是否被点赞
isBlogLiked(blog);
}
// 6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
测试:
- 登录2不同的账号:user_jcdpgsmaul,user_rauyu4o6h
- 第一个账户先发送一条笔记,然后让第二个账户关注第一个账户
- 之后第一个账户在发布笔记,第二个账户也就是粉丝可以看到up第一个账户推送的笔记了。
10、附近商户
10.1 附近商户-GEO数据结构的基本用法
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息(点),包含:经度(longitude)、纬度(latitude)、值(member)
- 值(member):可以是任意值,比如地名 数据库的一个id
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
- SortedSet类型
练习Redis的GEO功能:
- 添加下面几条数据
- 北京南站(116.378248 39.865275)
- 北京站(116.42803 39.903738)
- 北京西站(116.322287 39.893729)
GEOADD g1 116.378248 39.865275 bjn 116.42803 39.903738 bjz 116.322287 39.893729 bjx
查看Redis:
- 计算北京西站到北京站的距离
#默认是米
GEODIST g1 bjn bjx
- 搜索天安门(116.397904 39.909005)附近10km内的所有火车站,并按照距离升序排序
#默认是升序
GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
10.2 附近商户-导入店铺数据到GEO
具体场景说明:
当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。
我们要做的事情是:将数据库表中的数据导入到redis中去(因为这里要按照距离排序,数据库不支持GEO类型所以要导入数据到Redis中),redis中的GEO,GEO在redis中就一个menber和一个经纬度,我们把x和y轴传入到redis做的经纬度位置去,但我们不能把所有的数据都放入到menber中去,毕竟作为redis是一个内存级数据库,如果存海量数据,redis还是力不从心,所以我们在这个地方存储他的id即可。之后拿到id到数据库里根据id查询数据库中存的店铺信息。
但是这个时候还有一个问题,就是在redis中并没有存储type,所以我们无法根据type来对数据进行筛选,所以我们可以按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中即可
代码
HmDianPingApplicationTests:直接使用单元测试注入数据
普一种方式:普通方式效率低,每一个店铺都要发一次请求,1000个请求就要发送1000次。
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void loadShopData() {
// 1.查询店铺信息:如果数据库数据比较多可以循环分批查询,比如每次查询1000条,这里数据比较少直接查询所有即可。
List<Shop> list = shopService.list();
// 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
// k:typeId v:list集合里面保存店铺的信息
// 可以使用普通的for循环,然后判断typeId是否和ket一致,如果一致则放到集合中。这里使用更简单的
// 方式stream流。(具体查看黑马程序员视频学习jdk8新特性)
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_KEY + typeId;
// 3.2.获取同类型的店铺的集合
List<Shop> value = entry.getValue();
// 3.3.写入redis GEOADD key 经度 纬度 member(存的是店铺id)
// 经纬度数据、店铺id都在List<Shop> value这个店铺集合中,所以需要先遍历后存储。
for (Shop shop : value) {
//方式一:普通方式效率低,每一个店铺都要发一次请求,1000个请求就要发送1000次。
stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
}
}
}
第二种方式:效率更高
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
void loadShopData() {
// 1.查询店铺信息:如果数据库数据比较多可以循环分批查询,比如每次查询1000条,这里数据比较少直接查询所有即可。
List<Shop> list = shopService.list();
// 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
// k:typeId v:list集合里面保存店铺的信息
// 可以使用普通的for循环,然后判断typeId是否和ket一致,如果一致则放到集合中。这里使用更简单的
// 方式stream流。(具体查看黑马程序员视频学习jdk8新特性)
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_KEY + typeId;
// 3.2.获取同类型的店铺的集合
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());//方式二
// 3.3.写入redis GEOADD key 经度 纬度 member(存的是店铺id)
// 经纬度数据、店铺id都在List<Shop> value这个店铺集合中,所以需要先遍历后存储。
for (Shop shop : value) {
//方式一:普通方式效率低,每一个店铺都要发一次请求,1000个请求就要发送1000次。
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
//方式二:把店铺的集合转化为locations类型的集合存起来,这个locations存的是(name,point)
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
//方式二:循环结束了每一个店铺shop都转化为一恶搞location,最后批量的往Redis中写,这样效率更高。
//key:不变还是 前缀+typeId
//value:locations的list集合,这个集合是RedisGeoCommands.GeoLocation类型,并且要指定member的类型(店铺id)
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
测试:
- 启动单元测试类
- 查看Redis:没有问题已经按照类型存储了
10.3 附近商户-实现附近商户功能
当前项目使用的Springboot版本并不是最新版本,所以它里面对应的SpringDataRedis版本也不是最新的是2.3.9,2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令(当然也可以直接使用老命令),因此我们需要提示其版本,修改自己的POM
第一步:导入pom
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-data-redis</artifactId>
<groupId>org.springframework.data</groupId>
</exclusion>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>
第二步:
ShopController:改造代码,因为之前是按照数据库查询的,没有根据地理位置坐标查询。
/**
* 根据商铺类型分页查询商铺信息
* @param typeId 商铺类型
* @param current 页码
* @return
* 注意:前端不一定按照地理坐标去查询和排序的,有可能按照人气评分之类的(具体看前端的排序方式),所以这2个
* 参数可能为空,所以加上quired = false代表可以有也可以没有。
* 如果没有传递则按照数据库查询,如果传递了按照Redis的GEO查询,2种不同的处理方案。
*/
@GetMapping("/of/type")
public Result queryShopByType(
@RequestParam("typeId") Integer typeId,
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam(value = "x", required = false) Double x,
@RequestParam(value = "y", required = false) Double y) {
return shopService.queryShopByType(typeId, current, x, y);
}
ShopServiceImpl
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否需要根据坐标查询
if (x == null || y == null) {
// 不需要坐标查询,按数据库查询
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2.计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; //从哪开始查
int end = current * SystemConstants.DEFAULT_PAGE_SIZE; //从哪结束
// 3.查询redis、按照距离排序、分页。结果:shopId、distance
String key = SHOP_GEO_KEY + typeId;
// key 圆心 半径 带距离
// GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key,//类型id
GeoReference.fromCoordinate(x, y),//按照传递的经纬度作为圆心
new Distance(5000),//米
//结果带上距离
//分页limit(end):范围的意思,我们想要要指定的是从哪开始到哪结束,而他只能指定一个end,也就是
// 说拿到结束,这样表示永远从第一条开始到结束。eg:limit(5)-->从0到5
// 那这样如何分页呢???先进行查询,查询完成后手动的从from部分开始截取即可
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
// 4.解析出id:因为最终要返回店铺的信息
if (results == null) {
return Result.ok(Collections.emptyList());
}
//得到真正的集合,结果是0~end,我们想要的是from~end
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
//即使判断了不为null,但是下面集合中仍有可能是空的,因为下面进行了skip跳跃,查的时候确实有数据但是
// 跳的时候可能把所有的数据都跳过了,比如:现在查到第二页,一共10条数据,之后第三页要开始从第11条开始查询,
// 所以它会skip(11),本来就10条现在跳过了后面就肯定没有了,没了之后再收集id (List<Long> ids)就收集不到,
// 收集不到在下面根据id查询店铺信息是就一定会出现问题。解决:当list的元素个数<from值时,就没必要走了。
if (list.size() <= from) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 4.1.截取 from ~ end的部分
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
//stream流跳过from之前的部分,不用拷贝整个集合更加节约内存,之后进行遍历结果是 位置location,距离distance
list.stream().skip(from).forEach(result -> {
// 4.2.获取店铺id:位置里面包含坐标和name,其中name存的就是member店铺的id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr)); //为了后面查询
// 4.3.获取距离
Distance distance = result.getDistance();
//这样每个店铺id就对应一个distance
distanceMap.put(shopIdStr, distance);
});
// 5.根据id查询Shop:要保证有序
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
//把店铺和距离对应起来,在shop实体类中有一个成员变量 private Double distance(@TableField(exist = false)不是数据库的变量)
// 可以直接遍历,在遍历的同时直接从上面存进去的map集合中取出距离在存入shop中即可,取出来的值是distance对象所以还要getValue()获取v
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6.返回
return Result.ok(shops);
}
测试:
- 启动项目,可以看到距离正确排序,并且每次查询5条。
11、用户签到
11.1 用户签到-BitMap功能演示
我们针对签到功能完全可以通过mysql来完成,比如说以下这张表
用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节
我们如何能够简化一点呢?其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了
我们可以采用类似这样的方案来实现我们的签到需求。
我们按月来统计用户签到信息,签到记录为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)的值
- 一般只做查询,因为修改命令非常的繁琐,想要修改直接使用 SETBIT插入命令即可。
- 可以一次查询多个值
- 返回值是十进制
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- 只能做查询
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
测试:
-
SETBIT key 角标 值:存入一个值
-
GETBIT k 角标:获取指定角标处的值
-
BITCOUNT k:统计k的出现次数
-
BITFIELD k GET u2 0(偏移量):获取2个bit位不带符号,从0开始获取
- u:不带符号,I:带符号 (因为最终的返回结果不是以二进制返回而是以10进制返回,10进制是有正数和负数的)
- u2:获取2个bit位
- u2 0:获取2个bit位,从0开始获取
-
BITPOS k 查的值 start end:查找bit数组中指定范围内第一个0或1出现的位置
- start end不指定:代表从头查到尾
- start end不指定:代表从头查到尾
11.2 用户签到-实现签到功能
需求:实现签到接口,将当前用户当天签到信息保存到Redis中
思路:我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。
我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。
说明:
- 用户结合年和月作为key,因为签到往往是以月为统计单位的,所以我们希望每个用户每个月的签到情况放到一个bitMap,方便统计。
- 所以想要实现用户签到功能,至少要知道用户信息,年和月的信息,还有签到的是第几个bit为,所以还要知道对应的日期时间。总之年月日都要知道。
- 问题:你什么参数也没有传递,那么该如何实现用户签到呢?
- 我们的需求是:将当前用户当天签到保存到Redis,当前用户就是我们现在登录的用户,当天可以通过代码计算出时间,所以不用传递。
- 所以签到功能是无参的,如果想要实现补签之类的在进行传递参数。
代码
UserController
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
UserServiceImpl
@Override
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.获取今天是本月的第几天:第一天放在第0位,第二天放在第1位
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1:k 偏移量 值(true代表1,false代表0)
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
测试:这个功能页面没有做,所以我们用Postman发送请求。
- 启动服务器,发送请求
- 查看Redis:签到成功
- 一个字节8位,今天是4号存在第4个比特位,一个字节用不完空余的用0补齐。
11.3 用户签到-签到统计
问题1:什么叫做连续签到天数?
从最后一次签到开始向前
统计,直到遇到第一次
签到为止,计算总的签到次数,就是连续签到天数。
Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。
问题3:如何从后向前遍历每个bit位?
- 注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么如何得到8的每一个比特位??
- 首先:让得到的这个10进制数据8与1做与运算,就能得到最后一个bit位。
- 什么是位与&运算:当符号两侧是逻辑运算符且结果为布尔类型,则为逻辑运算符。若符号两侧为整数且结果为整数,则是位运算符。
- 1 & 1=1,0 & 1=0,参与运算的一个数字已经是1了,另一个数字是1最终结果就是1,另一个数字是0最终结果就是0,那么就可以认为参与运算的另一个数字是几,它与1运算的最终结果就是几。
- 1是二进制中最小的bit位,所以它在最后一个bit位也就是最低bit位,现在拿着这个签到结果和1做运算就意味着,只有最后一个bit位在跟1做运算,任何数与1做计算结果都是它本身,所以把这个签到结果和1作比较就得到了最后一个比特位。
- 首先:让得到的这个10进制数据8与1做与运算,就能得到最后一个bit位。
- 其次:右移一位,下一个bit位就成为了最后一个bit位。
- 我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了
代码
UserController
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
UserServiceImpl
@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.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
// unsigned(dayOfMonth):u14
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);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1.让这个数字与1做与运算,得到数字的最后一个bit位 // 判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
}else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1; //num=num>>>1
}
return Result.ok(count);
}
测试:
-
发送请求:连续签到1天
-
查看Redis:没有问题
11.4 额外加餐-关于使用bitmap来解决缓存穿透的方案
回顾缓存穿透:
发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击
解决方案:
-
判断id<0
-
如果数据库是空,那么就可以直接往redis里边把这个空数据缓存起来
第一种解决方案:遇到的问题是如果用户访问的是id不存在的数据,则此时就无法生效
第二种解决方案:遇到的问题是:如果是不同的id那就可以防止下次过来直击数据
所以我们如何解决呢?
我们可以将数据库的数据,所对应的id写入到一个list集合中,当用户过来访问的时候,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据,则直接放行。
现在的问题是这个主键其实并没有那么短,而是很长的一个 主键
哪怕你单独去提取这个主键,但是在11年左右,淘宝的商品总量就已经超过10亿个
所以如果采用以上方案,这个list也会很大,所以我们可以使用bitmap来减少list的存储空间
我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:
id % bitmap.size = 算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在, 采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。
12、UV统计
12.1 UV统计-HyperLogLog
首先我们搞懂两个概念:
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
- 对应的3个命令:添加 统计 合并
- 并且重复的元素只会记录一次,所以他天生适合做uv统计
- 测试:
12.2 UV统计-测试百万数据的统计
测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何
查看当前redis内存占用情况:info memory
@Test
public void testHyperLogLog() {
//准备添加元素的数组
String[] users = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {//准备100万条数据
//数组长度为1000,那么角标是0~999,0%1000=0(商0,余数为0),1%1000=1(商0,余数为1),
// 999%1000=999(商0,余数为999),1000%1000=0(商1,余数为0),1001%1000=1(商1,余数为1),
// 所以对i取模,不管i是几得到的余数值永远是0~999
j = i % 1000;
users[j] = "user_" + i;
if (j == 999) { //数据插满了之后开始发送到redis
//添加多个元素
stringRedisTemplate.opsForHyperLogLog().add("HLL", users);
}
}
//统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("HLL");
System.out.println("count = " + count);
}
再次查看当前redis内存占用情况:info memory
-
插入100W条数据,得到的count为997593,误差率为0.002407%
-
去Redis图形化界面中查看占用情况为:14K字节
- 1552424-1538040=14384,14384/1024=14.046875
经过测试:我们会发生他的误差是在允许范围内,并且内存占用极小