功能03-优惠券秒杀04

4.功能03-优惠券秒杀

4.7Redis优化秒杀

4.7.1优化分析

现在来回顾一下优惠券秒杀业务的两个主要问题:

(1)首先是对优惠券的扣减,需要防止库存超卖现象;

(2)其次,需要对每个用户下单数量进行限制,实现一人一单的功能。

处理秒杀优惠券的业务:

  1. 先根据获取到的优惠券id,先到数据库中判断是否存在,若存在;

  2. 再判断优惠券是否在设定的有效期,如果是,则进行一人一单的业务处理:

    • 2.1 利用分布式锁,key存储的是order+用户id:当同一时间,一个用户发起了多个线程请求,其中的某个线程获取到了锁,由于互斥性,无论这个用户发起了多少个请求,只有一个线程能进入接下来的业务。(不同用户发起的不同线程之间不影响)

    • 2.2 接下来,查询该用户是否已经买过这张秒杀券了,如果买过了,则不允许重复购买,如果是第一次购买,就进入到防止超卖的业务:

      • 2.2.1 到这一步可能会有多个用户的单个线程进入这个业务,为了防止超卖问题,这里使用乐观锁方案。乐观锁的关键是判断之前查询到的数据是否有被修改过,但缺点是失败率高,因此我们又使用了mysql的行锁解决。(详见day05-优惠券秒杀01)

因为整个过程有很多对数据库的操作(查询优惠券、查询订单、减库存、创建订单),因此这个业务的性能并不是很好:

优化前:

上述业务看似复杂,实际上只有两个过程:(1)对于用户资格的校验:库存够不够,该用户买没买过(一人一单)(2)然后才是真正的下单业务。

我们可以对这两个过程进行分离,别分使用两个线程进行操作:主线程负责对用户购买资格的校验,如果有购买的资格,再开启一个独立的线程,来处理耗时较久的减库存和创建订单操作。

为了提高效率,使用redis判断秒杀库存和校验一人一单,如果校验通过,则redis会记录优惠券信息、用户信息、订单信息到阻塞队列。一方面:tomcat服务器去读取这个队列的信息,完成下单。另一方面:redis给用户返回一个订单号,代表该用户抢单成功,用户可以根据这个订单号去付款。

优化后:

这样,整个秒杀流程就变为:直接在redis中判断用户的秒杀资格和库存,然后将信息保存到队列里。

秒杀业务的流程变短了,而且是基于Redis,性能得到很大的提升,整个业务的吞吐能力、并发能力可以大大提高了。

那么,如何在Redis中完成对秒杀库存的判断和一人一单的判断呢?

首先是对数据的存储:

  1. 使用String类型,key存储 业务前缀+秒杀券id,value保存优惠券对应的库存;
  2. 因为要保证一人一单,使用set类型,key保存业务前缀+秒杀券id,value保存下单的用户id,保证元素不可重复。

优化后,在Redis中需要执行的具体流程:

异步秒杀优化总结:

上述的优化操作,一方面缩短了秒杀业务的流程,从而大大提高了秒杀业务的并发;另一方面,redis的操作和数据库的操作是异步的,对数据库操作的时效性不再要求那么高了,减轻了数据库的压力。

4.7.2代码实现

改进秒杀业务,提高并发性能。需求:

  1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  2. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢占成功
  3. 如果抢占成功,将优惠券id和用户id封装后存入阻塞队列
  4. 开启线程任务,不断地从阻塞队列中获取信息,实现异步下单功能

需求1:新增秒杀优惠券的同时,将优惠券信息保存到Redis中

(1.1)修改IVoucherService

package com.hmdp.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.hmdp.dto.Result;
import com.hmdp.entity.Voucher; /**
* 服务类
*
* @author 李
* @version 1.0
*/
public interface IVoucherService extends IService<Voucher> {
void addSeckillVoucher(Voucher voucher);
}

(1.2)修改VoucherServiceImpl

package com.hmdp.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.entity.Voucher;
import com.hmdp.mapper.VoucherMapper;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherService;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import static com.hmdp.utils.RedisConstants.SECKILL_STOCK_KEY; /**
* 服务实现类
*
* @author 李
* @version 1.0
*/
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService { @Resource
private ISeckillVoucherService seckillVoucherService; @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中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
}

(1.3)修改VoucherController

package com.hmdp.controller;

import com.hmdp.dto.Result;
import com.hmdp.entity.Voucher;
import com.hmdp.service.IVoucherService;
import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; /**
* 前端控制器
*
* @author 李
* @version 1.0
*/
@RestController
@RequestMapping("/voucher")
public class VoucherController { @Resource
private IVoucherService voucherService; /**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
}

(1.4)使用postman进行测试,返回结过显示插入成功,data为插入的秒杀券的id

数据库和Redis中也分别插入成功了:


需求2:基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢占成功

在resources目录下新建一个Lua脚本

seckill.lua:

-- 1.参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2] -- 2.数据key
-- 2.1 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key
local orderKey = 'seckill:order' .. voucherId -- 3.脚本业务
-- 3.1判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2库存不足,返回1
return 1
end
-- 3.3库存充足,判断用户是否下过单(判断用户id是否在订单key对应的集合中)
-- sismember orderKey userId
if (redis.call('sismember', orderKey, userId) == 1) then
-- 3.4 若存在,说明是重复下单,返回2
return 2
end
-- 3.5 扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.6 下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0

需求3:如果抢占成功,将优惠券id和用户id封装后存入阻塞队列

需求4:开启线程任务,不断地从阻塞队列中获取信息,实现异步下单功能

修改VoucherOrderServiceImpl:

  1. 请求先来到seckillVoucher()方法,该方法先调用lua脚本,尝试判断用户有没有购买资格、库存是否充足。如果有,创建订单,放到阻塞对列中。此时整个秒杀业务就结束了,用户可以得到结果。
  2. 创建阻塞队列和线程池,在类初始化的时候就执行线程池。线程池的业务就是不断地从阻塞队列中获取订单信息,然后创建订单(调用handleVoucherOrder()方法)
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
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.UserHolder;
import lombok.extern.slf4j.Slf4j;
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.util.Collections;
import java.util.concurrent.*; /**
* 服务实现类
*
* @author 李
* @version 1.0
*/
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService; @Resource
private RedisIdWorker redisIdWorker; @Resource
private StringRedisTemplate stringRedisTemplate; @Resource
private RedissonClient redissonClient; private static final DefaultRedisScript<Long> SECKILL_SCRIPT; //类一加载就初始化脚本
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
} //阻塞队列:当一个线程尝试从队列中获取元素时,如果队列中没有元素,那么该线程就会被阻塞,直到队列中有元素,线程才会被唤醒并获取元素
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024); //线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); //在当前类初始化完毕之后就执行
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
} //执行异步操作,从阻塞队列中获取订单
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
//1.获取队列中的订单信息
/* take()--获取和删除阻塞对列中的头部,如果需要则等待直到元素可用
(因此不必担心这里的死循环会增加cpu的负担) */
VoucherOrder voucherOrder = orderTasks.take();
//2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
} private IVoucherOrderService proxy; private void handleVoucherOrder(VoucherOrder voucherOrder) {
//获取用户(因为目前的是线程池对象,不是主线程,不能使用UserHolder从ThreadLocal中获取用户id)
Long userId = voucherOrder.getUserId();
//创建锁对象,指定锁的名称
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁(可重入锁)
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
//获取锁失败
log.error("不允许重复下单");
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
//释放锁
lock.unlock();
}
} @Override
public Result seckillVoucher(Long voucherId) {
//获取用户id
Long userId = UserHolder.getUser().getId();
//1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString()
);
//2.判断脚本执行结果是否为0
int r = result.intValue();
if (r != 0) {
//2.1如果不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//2.2如果为0,代表有购买资格,将下单信息保存到阻塞对列中
VoucherOrder voucherOrder = new VoucherOrder();
//设置订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//设置用户id
voucherOrder.setUserId(userId);
//设置秒杀券id
voucherOrder.setVoucherId(voucherId);
//将上述信息保存到阻塞队列
orderTasks.add(voucherOrder);
//3.获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy(); //4.返回订单id
return Result.ok(0);
} @Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
//一人一单
Long userId = voucherOrder.getUserId();
//查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) {//说明已经该用户已经对该优惠券下过单了
log.error("用户已经购买过一次!");
return;
}
//库存充足,则扣减库存(操作秒杀券表)
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")//set stock = stock -1
//where voucher_id =? and stock>0
.gt("stock", 0).eq("voucher_id", voucherOrder.getVoucherId()).update();
if (!success) {//操作失败
log.error("秒杀券库存不足!");
return;
}
//将订单写入数据库(操作优惠券订单表)
save(voucherOrder);
}
}

重启项目,进行测试:

(1)初始数据:


(2)使用jemeter进行测试:使用1000个不同的用户同时向服务器发送抢购秒杀券的请求

测试结果:可以看到平均响应实现为216毫秒,最小值为17毫秒,比之前平均500毫秒的响应时间缩短了一半。

4.7.3秒杀优化总结

(1)秒杀业务的优化思路是什么?

  1. 先利用Redis完成库存余量判断、一人一单判断,完成抢单业务
  2. 再将下单业务放入阻塞队列,利用独立线程异步下单

(2)基于阻塞队列的异步秒杀存在哪些问题?

  1. 内存限制问题:

    这里我们使用的是JDK里面的阻塞队列,它使用的是JVM里面的内存。如果不加以限制,在高并发的情况下,可能会有非常多的订单对象需要去创建,放入阻塞队列中,可能会导致内存溢出。虽然我们限制了队列的长度,但是如果队列存满了,再有新的订单来,就放不下了。

  2. 数据安全问题:

    现在的代码基于内存来保存订单信息,如果服务器宕机了,那么阻塞队列中的所有订单信息将会丢失

我们将在接下来的分析中对上述两个问题进行解决。

4.8Redis消息队列实现异步秒杀

要解决上面的两个问题,最佳的解决方案就是使用消息队列

4.8.1什么是消息队列

消息队列(Message Queue,简称MQ),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

4.8.2消息队列实现异步秒杀的优势

使用消息队列实现异步秒杀的优势:

  • 消息队列是JVM以外的独立服务,不受JVM内存的限制,这就解决了之前的内存限制问题
  • 消息队列不仅仅是做数据存储,它还要确保数据安全,即消息队列里的所有消息都要做持久化,这样不管是服务宕机还是重启,数据都不会丢失
  • 消息队列将消息投递给消费者之后,要求消费者做消息的确认。如果消息没有被确认,这个消息就会在队列中依然存在,下一次会再次投递给消费者,直到收到消息确认为止。

当下比较知名的消息引擎,包括:ActiveMQ、RabbitMQ、Kafka、RocketMQ、Artemis 等

这里使用Redis实现消息队列:

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

4.8.3基于List结构模拟的消息队列

Redis的List数据结构是一个双向链表,很容易模拟出队列效果。队列是入口和出口不在一边,我们可以利用:LPUSH结合RPOP、或者RPUSH结合LPOP来实现。

不过要注意的是,当队列中没有消息时,RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。

BRPOP key [key ...] timeout
summary: Remove and get the last element in a list, or block until one is available
since: 2.0.0 RPOP key
summary: Remove and get the last element in a list
since: 1.0.0

基于List的消息队列有哪些优缺点?

优点:

  1. 利用Redis存储,不受限于JVM内存上限
  2. 基于Redis的持久化机制,数据安全性有保证
  3. 可以满足消息有序性

缺点:

  1. 无法避免消息丢失
  2. 只支持单消费者

4.8.4基于PubSub的消息队列

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或者多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel [channel]:订阅一个或者多个频道
  • PUBLISH channel msg:向一个频道发送消息
  • PSUBSCRIBE pattern [pattern]:订阅与pattern格式相匹配的所有频道

基于PubSub的消息队列有哪些优缺点?

优点:采用发布订阅模型,支持多生产、多消费

缺点:

  1. 不支持数据持久化
  2. 无法避免消息丢失
  3. 消息堆积有上限,超出时数据丢失

4.8.5基于Stream的消息队列

Stream是Redis5.0引入的一种新的数据类型,可以实现一个功能非常完善的消息队列。

发送消息的命令:

day08-优惠券秒杀04的更多相关文章

  1. PHP+Redis实现高并发下商品超卖问题

    对于一些有一定用户量的电商网站,如果只是单纯的使用关系型数据库(如MySQL.Oracle)来做抢购,对数据库的压力是非常大的,而且如果不使用好数据库的锁机制,还会导致商品.优惠券超卖的问题.我所在的 ...

  2. PHP+Redis链表解决高并发下商品超卖问题

    目录 实现原理 实现步骤 上一篇文章聊了一下使用Redis事务来解决高并发商品超卖问题,今天我们来聊一下使用Redis链表来解决高并发商品超卖问题. 实现原理 使用redis链表来做,因为pop操作是 ...

  3. Java生鲜电商平台-积分,优惠券,会员折扣,签到、预售、拼团、砍价、秒杀及抽奖等促销模块架构设计

    Java生鲜电商平台-积分,优惠券,会员折扣,签到.预售.拼团.砍价.秒杀及抽奖等促销模块架构设计 说明:本标题列举了所有目前社会上常见的促销方案,目前贴出实际的业务运营手段以及架构设计,包括业务说明 ...

  4. newbee-mall 开源商城新计划:秒杀功能、优惠券、对接支付宝

    新项目是 newbee-mall 的升级版本,暂时就叫它 newbee-mall-plus 吧,第一阶段会开发秒杀功能.优惠券.对接支付宝这些功能,也会慢慢加入 Redis. Elastic Sear ...

  5. 新增秒杀功能、优惠券、支付宝、Docker,newbee-mall升级版开源啦!

    最近是非常非常非常忙,一方面是公司的事情比较多,另外⼀点是最近在准备诉讼材料.⾄于诉讼的是谁,⼤家可以去看我之前写的几篇文章,所以本来这周是不打算更新文章的.不过,昨天慕课网的法务联系我的律师了,终于 ...

  6. 04 整合IDEA+Maven+SSM框架的高并发的商品秒杀项目之高并发优化

    Github:https://github.com/nnngu 项目源代码:https://github.com/nnngu/nguSeckill 关于并发 并发性上不去是因为当多个线程同时访问一行数 ...

  7. (转)使用JMeter对秒杀示例进行性能测试

    背景 秒杀是我们ServiceComb开源团队以领域驱动设计(DDD)为背景,从零开始构建一个微服务架构的示例项目:在<秒杀开发历程>系列博文中提到它作为一个高并发压力场景的应用,采用了C ...

  8. .NetCore+Jexus代理+Redis模拟秒杀商品活动

    开篇叙 本篇将和大家分享一下秒杀商品活动架构,采用的架构方案正如标题名称.NetCore+Jexus代理+Redis,由于精力有限所以这里只设计到商品添加,抢购,订单查询,处理队列抢购订单的功能:有不 ...

  9. [原创]在HP DL380 G7服务器上部署基于Ubuntu Server 16.04 和 VirtualBox的云平台

    对于一线开发人员来说,一提到虚拟机平台,往往会让人联想到在价格昂贵的服务器上部署VMware vSphere之类软件来实现. 笔者作为一个资深码农,也是一直梦寐着在自己家中打造一个真正的家庭私有云,秒 ...

  10. 01 整合IDEA+Maven+SSM框架的高并发的商品秒杀项目之业务分析与DAO层

    作者:nnngu 项目源代码:https://github.com/nnngu/nguSeckill 这是一个整合IDEA+Maven+SSM框架的高并发的商品秒杀项目.我们将分为以下几篇文章来进行详 ...

随机推荐

  1. 持续集成环境(4)-Jenkins凭证管理

    凭据可以用来存储需要密文保护的数据库密码.Gitlab密码信息.Docker私有仓库密码等,以便 Jenkins可以和这些第三方的应用进行交互. 安装Credentials Binding插件 要在J ...

  2. Windows平台在当前文件夹下打开CMD

    Windows平台在当前文件夹下打开CMD的方法: 在路径栏中输入 cmd.exe ,然后敲回车.

  3. Docker 容器的备份和迁移

    Docker的Save和Export的区别 Docker的镜像和容器有两种方式导出 Docker Save镜像方法,会保存该镜像的所有历史记录,包括数据 1.创建快照 使用 docker commit ...

  4. git提交的时候,报错yarn run v1.21.1 ,SyntaxError: Cannot use import statement outside a module 解决

    原因是 lint-staged这个依赖中,需要的node的版本是, 而我使用的node版本是12.13.1 ,切换成14.17.0就可以了

  5. Android笔记--文本输入

    编辑框EditText 相关内部部件取下: inputType的类型如下: 具体实现: 不同边框的实现: 焦点变更监听器 具体实现: 文本变化监听器 具体实现:

  6. find和filter有什么区别

    JavaScript 在 ES6 上有很多数组方法,每种方法都有独特的用途和好处. 在开发应用程序时,大多使用数组方法来获取特定的值列表并获取单个或多个匹配项. 在列出这两种方法的区别之前,我们先来一 ...

  7. ThreadLocal部分源码分析和应用场景

    结构演进 早起JDK版本中,ThreadLocal内部结构是一个Map,线程为key,线程在"线程本地变量"中绑定的值为Value.每一个ThreadLocal实例拥有一个Map实 ...

  8. 在Vue中发起axios请求成功,却被catch捕捉返回Network Error

    前端发起请求成功,后台接收处理返回,却被axios的catch捕获,没有走then函数. 最后添加了headers配置成功解决,如上,附上axios接口配置中文文档:axios中文文档|axios中文 ...

  9. Seata锁等待超时问题排查

    问题描述 生产环境,一个简单的事务方法,提交失败,报 Global lock wait timeout 伪代码如下: @GlobalTransactional(rollbackFor = Except ...

  10. Django笔记十五之in查询及date日期相关过滤操作

    这一篇介绍关于范围,日期的筛选 in range date year week weekday quarter hour 1.in in 对应于 MySQL 中的 in 操作,可以接受数组.元组等类型 ...