上面的过程中,我们进行秒杀操作的基本步骤为:
所以这时候整个过程就耗费较长的时间,因为我们要判断用户是否已经购买了商品,需要查询数据库中表,同时也需要查询数据库得知库存数量,从而进行判断库存是否,每次查询都需要执行这2步,所以我们需要对这个过程进行优化,将商品的库存数量保存到redis中,同时将购买过这个商品的用户保存到redis中的set集合中,如果用户并没有存在这个商品订单的set集合中,说明没有购买。通过将这些数据保存到redis中,从而减少了数据库的查询次数。
同时在上面的判断中可以得知用户是否有资格进行秒杀操作,如果有,那么就生成秒杀订单,这时候我们需要开启异步线程来生成订单。
所以整个过程为:
这时候就可以通过消息队列来实现异步线程中的任务了,如果消息队列中没有存在数据,那么不会生成订单,处于阻塞的状态,否则,就从消息队列中取出数据,然后生成订单。这里可以将介绍2种方式来实现消息队列:
通过阻塞队列来实现异步秒杀的时候,上面的步骤中并不需要将用户的id,订单的id,以及商品的id保存到redis中,而是直接在扣减了库存,并且将当前用户添加到set集合中之后,直接返回0,表示当前的用户有资格进行秒杀此时就可以生成秒杀订单,然后修改数据库中的商品库存数量。
因为在我们执行了Lua脚本之后,根据它的返回值来判断当前的用户是否有资格进行秒杀,如果有(返回值为0),那么我们就将新建一个VoucherOrder对象,然后添加到阻塞队列中,此时异步线程就可以从消息队列中取出一个VoucherOrder对象,来生成秒杀订单,同时更新数据库中的商品库存数量。
所以对应的代码为:
秒杀接口优化后的代码为:
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//1、执行lua脚本,从而判断库存数量以及用户是否重复购买
//如果返回的是0,说明用户秒杀成功,否则如果是1,说明库存不足
//返回是2,表示用户重复购买
Long result = stringRedisTemplate.execute(redisScript,
Collections.emptyList(),
//注意需要将voucherId,userId转成String,
//因为stringRedisTemplate的key,value都是字符串类型的,否则
//就抛出long cannot be cast to String
voucherId.toString(),
userId.toString()
);
if(result != 0){
return Result.fail(result == 1 ? "库存不足" : "每个用户限购一件商品");
}
//2、返回的是0,那么将另外开启线程进行生成订单,并将订单id返回
Long orderId = redisWorker.nextId("order");
//3、创建VoucherOrder对象,并将其添加到阻塞队列中
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
queue.add(voucherOrder);
return Result.ok(orderId);
}
阻塞队列生成秒杀订单代码:
private static final BlockingQueue<VoucherOrder> queue = new ArrayBlockingQueue<>(1024 * 1024);
//线程池,用于生成秒杀订单
private final ExecutorService service = Executors.newCachedThreadPool();
@PostConstruct
public void init(){
//当构造方法执行完毕之后,就会执行这一步,来初始化线程任务
//这样就可以保证一加载这个类,就可以执行线程任务了
service.execute(new SeckillRunnable());
}
private class SeckillRunnable implements Runnable{
@Override
public void run() {
while(true){
try {
//从阻塞队列中出去订单对象
VoucherOrder voucherOrder = queue.take();
//执行方法,来生成秒杀订单
voucherOrderHandler(voucherOrder);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
/**
* 通过阻塞队列来生成秒杀订单,为了避免redis服务器发生宕机,从而避免
* 在seckill.lua脚本中对库存数量,以及用户是否已经购买过的判断失效,
* 因此在当前的方法中利用redisson来实现分布式锁
*
* 然后可以获取分布式锁,就去生成订单,这时候需要注意商品超卖的问题,因此
* 需要利用乐观锁来解决商品超卖的问题
* @param voucherOrder
*/
@Transactional
public void voucherOrderHandler(VoucherOrder voucherOrder) {
RLock lock = redissonClient.getLock("lock:voucherOrdre:userId:" + voucherOrder.getUserId());
boolean isLock = lock.tryLock();
try {
if (!isLock) {
//获取锁失败,那么直接返回
log.error("每个用户限购1件商品");
return;
}
Integer stock = seckillVoucherService.getById(voucherOrder.getVoucherId()).getStock();
if (stock <= 0) {
log.error("库存不足");
return;
}
//获取锁成功之后,就可以进行秒杀商品了
boolean isUpdate = seckillVoucherService.update(new UpdateWrapper<SeckillVoucher>().setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0));
if (!isUpdate) {
log.error("库存不足");
return;
}
//生成秒杀订单
voucherOrderService.save(voucherOrder);
}finally {
lock.unlock();//释放锁
}
}
对应的seckill.lua脚本的内容为:
--- 1、获取商品的id以及保存到redis中的商品的key
local voucherId = ARGV[1]
--- lua脚本中通过..进行拼接字符串的
local voucherKey = "hm_dianping:seckill:voucher:stock:"..voucherId
--- 2、获取用户的id以及商品订单的key
local userId = ARGV[2]
local orderId = ARGV[3]
local orderKey = "hm_dianping:seckill:order:voucher:"..voucherId
--- 3、获取商品的库存数量,判断是否充足
---这里需要利用tonumber,将返回值变成number类型,否则就会抛出异常attempt to compare boolean with number
if(tonumber (redis.call('get', voucherKey)) <= 0) then
--- 库存不足
return 1
end
--- 4、判断用户是否已经购买过这个商品了
if(tonumber(redis.call('sismember', orderKey, userId)) == 1) then
--- 用户已经购买过了这个商品
return 2
end
--- 5、更新库存,同时将这个用户添加到商品订单中,表示这个用户购买了这个商品
redis.call('incrby',voucherKey, -1)
redis.call('sadd', orderKey, userId)
return 0
但是通过阻塞队列来实现消息队列有缺陷:
①内存限制问题:因为创建阻塞队列是,需要初始大小,一旦在高并发的环境下,阻塞队列一下子就满了,那么这时候如果有订单到来,那么不会将这个订单存放到阻塞队列中
②数据安全的问题,因为是基于JVM的,如果服务器如果发生了宕机或者需要重启的时候,那么阻塞队列中的数据就会丢失(或者可以从阻塞队列是一个成员变量,每次重新启动,数据都是从0开始)。所以就有了下面的通过Redis来实现消息队列。
Redis中可以通过List,PubSub(发布-订阅模式),Stream这3种方式来实现消息队列。
其中List可以通过命令BLPOP或者BRPOP
来获取元素,并且如果List为空的时候,可以在等待指定的时间之后才会返回null.从而实现阻塞,这样就可以实现了阻塞队列,同时解决了阻塞队列中存在的几个问题。
但是通过List实现消息队列存在几个问题:
①数据丢失异常:当从List中取出消息之后,那么就是将这个消息从List中删除,此时如果没有正确处理这一条消息,或者消息在中途丢失了,那么这时候我们是没有办法再从List中获取这一条数据
②只支持但消费者: 也即每一条消息,只能由一个人来获取,其他人不可以在获取
所以基于List实现的消息队列并不是我们的最优解。
PubSub(发布-订阅):消费者可以订阅一个或者多个Channel(频道),生产者向对应的channel发布信息之后,那么订阅这个channel的消费者就可以收到消息,对应的命令有:
考虑到这些缺点,PubSub依旧不是解决我们问题的最优解。
Stream是Redis 5.0的一种新的数据结构,可以支持消息的持久化,并且拥有消费者组,以及消息确认机制,是一种功能比较完善的消息队列。
而通过Stream实现消息队列,常见的命令有:
* | ID
表示的是消息的唯一ID,*
表示由Redis来自动生成的,对应的格式为"时间戳-递增数字",而field value则为消息的内容,因为一条消息的内容可能有很多,所以消息内容是由消息体组成,而消息体则是以键值对的形式存在。所以通过XREAD来读取Stream中的消息时,存在的特点为:
①消息可回溯,因为我们并不像List那样,在获取消息的同时将这个消息从列表中删除。Stream获取消息,仅仅时读取消息,而不是删除消息
②一个消息可以支持多个消费者
③可以阻塞读取(XREAD COUNT BLOCK STREAM key ID)
④存在漏读风险(如果ID为$,那么读取到的时最新的消息,此时在调加多条数据的时候,就可能出现漏读)
所以就有了消费者组的形式,他将多个消费者分配给同一个消息队列,因此具有以下特点:
①多个消费者分配个同一个消息队列,那么消费者就会竞争队列中的消息,从而加快了消息处理的过程
②读取消息不再是从最新一条消息读起,消费者组会维护一个标识,记录最后一个被处理的消息,那么我们就从这个标识之后开始读取消息,从而确保每一条消息被读取,避免了漏读的情况。
③消费者获取消息之后,消息处于pending状态,并存入到pendingList中,当消费者处理完,并且通过命令XACK
发送确认之后,表示这一条消息被确认之后,那么就将这条消息从pendingList中移除。
创建消费者组通过XGROUP CREATE
来创建,然后通过XREADGROUP
来读取,对应的命令为:
XGROUP CREATE key group_name ID mkstream
其中key表示的是消息队列的名字,mkstrea则表示如果这个消息队列不存在,那么就会自动创建这个消息队列。group_name则是消费者组的名字
ID是起始标示
XREADGROUP GROUP group_name consumer_name COUNT count Block milisecond STREAM key ID
表示从key这个消息队列中的group_name消费者组读取count条消息,并且名字是consumer_name的消费者.如果消息队列为空,那么就阻塞milisecond毫秒。
ID表示读取消息的起始,如果是>
,表示从下一个未处理的消息开始读起,如果是其他,则表示从pendingList中获取已经处理但是没有确认的消息
XACK key group_name ID: 表示向ID这个消息发送确认,表示这个消息已经被处理完毕了
所以通过XGROUP来实现消息队列的特点为:
①消息可回溯(数据持久化)
②一个消息可以支持多个消费者,并且消息队列中存在多个消费者,消费者之间竞争消息,加快消息处理过程
③可以支持阻塞读取
④解决了漏读的风险,消费者组会维护一个标识(标记最后一个已经处理的消息),那么就会从这个标识后的消息开始读起,从而避免漏读
⑤解决了消息丢失异常,因为可以通过XACK发送确认
所以Stream来实现消息队列时,我们在Lua脚本中判断了当前用户有资格进行秒杀的时候,需要将当前的订单id,用户id,以及商品id保存到消息队列中,通过XADD
来添加。之后我们在异步线程中获取消息,然后生成秒杀订单,对应的代码为:
Stream实现秒杀接口代码为:
public Result seckillVoucher(Long voucherId) {
//1、获取当前用户的登录id
Long userId = UserHolder.getUser().getId();
Long orderId = redisWorker.nextId("order");
//2、获取订单id
Long result = stringRedisTemplate.execute(
redisScript,
Collections.emptyList(),
voucherId.toString(),userId.toString(), orderId.toString()
);
//3、如果result不等于0,说明抛出了异常
if(result != 0){
return Result.fail(result == 1 ? "库存不足" : "每个用户限购一件");
}
return Result.ok(orderId);
}
Stream实现的异步线程代码为:
//线程池,用于生成秒杀订单
private final ExecutorService service = Executors.newCachedThreadPool();
@PostConstruct
public void init(){
//当构造方法执行完毕之后,就会执行这一步,来初始化线程任务
//这样就可以保证一加载这个类,就可以执行线程任务了
service.execute(new SeckillRunnable());
}
private class SeckillRunnable implements Runnable{
@Override
public void run() {
while(true){
String group_name = "g1";
try{
//1、从stream中取出消息XREADGROUP GROUP group_name consumer_name count 1 block 200 streams key >
List<MapRecord<String, Object, Object>> msgs = stringRedisTemplate.opsForStream().read(
//指定组名以及消费者的名字
Consumer.from(group_name, "c1"),
//指定获取1条消息,并且如果没有消息的时候,等待2秒
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
//指定读取的是哪一个key的消息,并且是从哪一条消息开始读
StreamOffset.create(RedisConstants.STREAM_ORDER_KEY, ReadOffset.lastConsumed())
);
if(msgs == null || msgs.isEmpty()){
//如果不存在消息,那么重新获取消息
continue;
}
//2、存在消息,解析数据
//key是一个消息的标识,而值是一个哈希值,因为一条消息不只一个消息体
MapRecord<String, Object, Object> msg = msgs.get(0);
//消息体
Map<Object, Object> msg_entries = msg.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(msg_entries, new VoucherOrder(), false);
//3、生成订单,同时扣减数据库中的库存量
voucherOrderHandler(voucherOrder);
//4、发送确认 XACK key group_name 消息的id
stringRedisTemplate.opsForStream().acknowledge(RedisConstants.STREAM_ORDER_KEY, group_name, msg.getId());
} catch (Exception e){
//5、如果获取消息失败,那么这时候需要从pendingList中获取消息
handleMsgFromPendingList();
}
}
}
}
/**
* 从pendingList中获取没有确认的消息,对应的步骤为:
*/
public void handleMsgFromPendingList() {
while(true){
String group_name = "g1";
try{
//1、从pendingList中获取未确认的消息
List<MapRecord<String, Object, Object>> msgs = stringRedisTemplate.opsForStream().read(
//指定消费者组的名字以及消费者的名字
Consumer.from(group_name, "c1"),
//指定读取消息数量以及等待的时间
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
//指定读取的key以及从哪一条消息开始读起,由于是读取pendingList,所以从0
StreamOffset.create(RedisConstants.STREAM_ORDER_KEY, ReadOffset.from("0"))
);
if(msgs == null || msgs.isEmpty()){
//1.1 消息为空,那么直接退出循环
break;
}
//2、获取第一条消息(只获取1条)
MapRecord<String, Object, Object> msg = msgs.get(0);
//获取消息体
Map<Object, Object> msg_entries = msg.getValue();
//3、解析消息体,生成秒杀订单
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(msg_entries, new VoucherOrder(), false);
voucherOrderHandler(voucherOrder);
//4、发送确认
stringRedisTemplate.opsForStream().acknowledge(RedisConstants.STREAM_ORDER_KEY, group_name, msg.getId());
} catch (Exception e){
log.info("处理pendingList异常....");
try {
Thread.sleep(200);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
}
Lua脚本为:
--- 1、获取商品的id以及保存到redis中的商品的key
local voucherId = ARGV[1]
--- lua脚本中通过..进行拼接字符串的
local voucherKey = "hm_dianping:seckill:voucher:stock:"..voucherId
--- 2、获取用户的id以及商品订单的key
local userId = ARGV[2]
local orderId = ARGV[3]
local orderKey = "hm_dianping:seckill:order:voucher:"..voucherId
--- 3、获取商品的库存数量,判断是否充足
---这里需要利用tonumber,将返回值变成number类型,否则就会抛出异常attempt to compare boolean with number
if(tonumber (redis.call('get', voucherKey)) <= 0) then
--- 库存不足
return 1
end
--- 4、判断用户是否已经购买过这个商品了
if(tonumber(redis.call('sismember', orderKey, userId)) == 1) then
--- 用户已经购买过了这个商品
return 2
end
--- 5、更新库存,同时将这个用户添加到商品订单中,表示这个用户购买了这个商品
redis.call('incrby',voucherKey, -1)
redis.call('sadd', orderKey, userId)
--- 6、将userId,voucherId,以及orderId保存到消息队列中
--- 因为将读取到的数据利用BeanUtil.fillBeanWithMap方法封装到VoucherOrder中
--- 所以消息体的名字和VoucherOrder的属性相同
local msg_key = "hm_dianping:stream:orders"
redis.call('xadd', msg_key,'*','userId',userId,'voucherId', voucherId, 'id', orderId)
return 0
发布探店笔记,那么首要需要保证blog中存在title,content以及关联商户,如下所示:
所以当我们点击发布按钮之后,就需要将这个新的blog添加到数据库中,所以对应的代码为:
发布Blog的接口代码:
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
BlogService接口对应的代码:
Result saveBlog(Blog blog);
BlogServiceImpl实现的对应方法:
@Override
public Result saveBlog(Blog blog) {
// 1、获取登录用户
UserDTO user = UserHolder.getUser();
Long userId = user.getId();
blog.setUserId(userId);
// 2、保存探店博文
Boolean isSuccess = save(blog);
if(BooleanUtil.isFalse(isSuccess)){
return Result.fail("发布blog失败");
}
// 4、返回id
return Result.ok(blog.getId());
}
查看blog,可以根据点赞比较高的blog,也可以点击查看某一篇blog,如下所示:
可以看到,无论是哪一种方式查看blog,都需要知道blog的作者,并且显示作者的头像,其次还会显示点赞按钮,此时如果当前的blog被当前的用户点赞,那么点赞按钮需要高亮,所以在获取blog数据之后,还需要查询blog的作者以及判断当前的blog是否被当前用户点赞,从而是否需要将点赞按钮变成高亮。
所以对应的代码为:
//获取所有的blog,并且根据点赞数降序排序
@Override
public Result queryHotBlog(Integer current) {
// 根据点赞数降序排序,然后获取第current页的blog
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取第current页数据
List<Blog> records = page.getRecords();
records.forEach(blog ->{
//对于每一篇blog,需要获取它的作者信息以及判断是否被当前的用户点赞
this.queryBlogUser(blog);
this.isLikeByCurrentUser(blog);
});
return Result.ok(records);
}
//根据id来获取blog
@Override
public Result queryById(Long id) {
Blog blog = getById(id);
if(blog == null){
return Result.fail("博客不存在");
}
//获取当前博客的作者
queryBlogUser(blog);
isLikeByCurrentUser(blog);
return Result.ok(blog);
}
//获取blog的作者
public void queryBlogUser(Blog blog){
User user = userService.getById(blog.getUserId());
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
//判断blog是否被当前的用户点赞,如果用户没有登录,默认没有被点赞
/**
* 判断当前的博客是否已经被当前的用户点赞了
* @param blog
*/
public void isLikeByCurrentUser(Blog blog) {
UserDTO userDTO = UserHolder.getUser();
if(userDTO == null){
//用户没有登录,那么默认这个博客没有被当前访客点赞
return;
}
Long userId = userDTO.getId();
//判断当前的用户是否已经点赞过这个博客
Double score = stringRedisTemplate.opsForZSet().score(RedisConstants.BLOG_LIKED_KEY + blog.getId(), userId.toString());
blog.setIsLike(score != null);
}
要实现blog的点赞,那么我们需要明确点赞的需求:
对于第一条,不可以重复点赞,如果在已经点赞的前提下,再次点赞,那么就是取消点赞,此时我们将利用到redis中的Set数据结构,从而保证了用户不会重复点赞。
同时,如果用户已经点赞,那么点赞按钮需要实现高亮,那么这时候我们可以给Blog定义一个属性isLiked,表示当前这篇blog是否已经被当前的用户点赞了,如果为true,说明已经被点赞,所以高亮,否则不需要。
//点赞或者取消点赞某一篇blog
@Override
public Result likeBlog(Long id) {
String userId = UserHolder.getUser().getId().toString();
String blog_key = RedisConstants.BLOG_LIKED_KEY + id;
//注意将userId转成String类型,因为使用的是StringRedisTemplate
Boolean isMember = stringRedisTemplate.opsForSet().isMember(blog_key, userId);
if(Boolean.TRUE.equals(isMember)){
//2.1 如果已经点赞过了,那么再次点击的时候,需要将点赞数减1,并将当前的用户从set中移除
Boolean isSuccess = update(new UpdateWrapper<Blog>().setSql("liked = liked - 1").eq("id", id));
if(BooleanUtil.isTrue(isSuccess)){
//更新redis中的set,将当前用户从set中删除
stringRedisTemplate.opsForSet().remove(blog_key, userId);
}
}else{
//2.2 没有点赞过,那么将当前的用户添加到set中,并且更新数据库的点赞数
boolean isSuccess = update(new UpdateWrapper<Blog>().setSql("liked = liked + 1").eq("id", id));
if(BooleanUtil.isTrue(isSuccess)){
//数据库操作成功之后,才可以更新redis
stringRedisTemplate.opsForSet().add(blog_key, userId);
}
}
return Result.ok();
}
//判断blog是否被当前的用户点赞了,如果没有登录,那么默认没有点赞
public void isLikeByCurrentUser(Blog blog) {
UserDTO userDTO = UserHolder.getUser();
if(userDTO == null){
//用户没有登录,那么默认这个博客没有被当前访客点赞
return;
}
Long userId = userDTO.getId();
//判断当前的用户是否已经点赞过这个博客
Boolean isMember = stringRedisTemplate.opsForSet().isMember(RedisConstants.BLOG_LIKED_KEY + blog.getId(), userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isMember));
}
如果需要实现点赞排行榜,那么需要不仅仅需要保证点赞的用户不是重复的,同时需要保证点赞的用户是根据点赞的时间排序,那么这时候就可以利用到了Redis中的排序集合ZSet,此时score属性就是点赞的时间。如果在点赞按钮的旁边还需要显示点赞的前5名,那么需要获取前5名点赞的人,然后获取这些用户的相关信息,然后在返回,所以对应的步骤是:
对应的代码为:
@Override
public Result likesBlogTop5(Long id) {
String blog_key = RedisConstants.BLOG_LIKED_KEY + id;
List<Long> userIds = stringRedisTemplate.opsForZSet().range(blog_key, 0, 4).stream()
.map(Long::valueOf) //将zset中的string类型的值转成Long类型
.collect(Collectors.toList());
if(userIds == null || userIds.isEmpty()){
//1.1 没有用户点赞过这个博客
return Result.ok(Collections.emptyList());
}
//根据userIds,来查询用户,但是这时候listByIds是根据in子句查询的
//所以在mysql中根据in子句查询的时候,得到的users对象并不是根据
//上面的userIds排序的,也即导致users不是根据时间戳先后顺序排序
//所以需要自定义排序顺序,使得是根据userIds排序的
String idStr = StrUtil.join( ",",userIds);
List<User> users = userService.query().in("id", userIds)
//自定义排序顺序,使得id是根据idStr进行排序的,而idStr就是userIds中元素顺序
.last("ORDER BY Field(id," + idStr + ")")
.list()
.stream()
.collect(Collectors.toList());
//2、由于User对象涉及到一些隐私信息,所以需要转成UserDTO
List<UserDTO> userDTOs = users.stream()
.map(user -> {
return BeanUtil.copyProperties(user, UserDTO.class);
})
.collect(Collectors.toList());
return Result.ok(userDTOs);
}