功能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. C++ 几款IDE和编程平台的选择分析

    最近闲来无事,就研究了一下几个编程平台和IDE.首先,我必须强调一下,这些方案研究并不一定适用于商业公司内部编程平台选择,而是给个人学习或者闲暇之余把玩用的.主要从以下几个指标考量:使用体验.跨平台. ...

  2. 网络数据请求get&post

  3. Oracle表主键作为外键都用在哪些表查询

    Oracle中,如果设置了外键,删除数据时,必须将外键关联一并删除,但是如果对项目不是很熟悉时,我们无法判断到底都在哪些表中有外键关联,以下提供了一个查询的SQL,可以通过数据库查询,查找到所有的外键 ...

  4. I - Cloud Retainer's Game

    I - Cloud Retainer's Game 传送门: I. Cloud Retainer's Game (codeforces.com) 题意: 在坐标轴上有2个边界:y=0和y=H.有n个质 ...

  5. 在 Kubernetes 集群上安装/升级 Rancher

    https://ranchermanager.docs.rancher.com/zh/pages-for-subheaders/install-upgrade-on-a-kubernetes-clus ...

  6. Repeater 绑定数据如何根据数据列的内容排序

    可指定Repeater的数据源 从数据库查询时直接排序,而后绑定数据这样

  7. 关于Go语言的底层,你想知道的都在这里!

    目录 1. GoLang语言 1.1 Slice 1.2 Map 1.3 Channel 1.4 Goroutine 1.5 GMP调度 1.6 垃圾回收机制 1.7 其他知识点 2. Web框架Gi ...

  8. IDEA下Maven项目中通过JDBC连接MySQL数据库

    ### 1. 在当前Maven项目的pom.xml文件中导入数据库依赖: ```<dependency> <groupId>mysql</groupId> < ...

  9. Command Injection-命令注入(命令执行)

    什么是命令注入? 命令注入是滥用应用程序的行为在操作系统上执行命令,使用与设备上的应用程序运行时相同的权限.例如,在作为名为joe的用户运行的web服务器上实现命令注入将在该joe用户下执行命令,从而 ...

  10. DVWA-Weak Session IDs(弱会话ID) 不安全的会话

    在登录服务器之后,服务器会返回给用户一个会话(session),这个会话只会存在一段时间,拥有这个会话下次登录就不用输入密码就可以登录到网站,如果返回的这个会话很弱,容易被猜解到,就很不安全,照成会话 ...