秒杀项目
秒杀项目基本环境搭建
商品展示模块
请求执行秒杀模块
秒杀流程总结

后台Controller处理

1. 在GoodsController中定义seckill方法对秒杀请求进行处理,在处理的时候需要进行一些判断

//执行秒杀
@PostMapping("/seckill/goods/{random}/{id}")
public @ResponseBody ReturnObject seckill(@PathVariable("random") String random,@PathVariable("id") Integer id){
    ReturnObject returnObject = new ReturnObject();
        return returnObject;
}

2. 请求参数random合法性验证,我们这里采用的是长度判断,有些公司将random的某个位置值固定,判断是否为那个值

//1.random参数合法性验证,我们这里采用的是长度判断,有些公司将random的某个位置值固定,判断是否为那个值
if(random.length() != 36){
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("请求参数有误");
    return returnObject;
}

3. 根据商品id从Redis中查询出缓存的商品,判断请求参数random和商品的randomName是否匹配

//2.根据商品id从Redis中查询出缓存的商品,判断请求参数random和商品的randomName是否匹配
String goodsJSON = redisTemplate.opsForValue().get(Constants.REDIS_GOODS+id);
Goods goods = JSONObject.parseObject(goodsJSON,Goods.class);
if (!random.equals(goods.getRandomname())){
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("请求参数有误");
    return returnObject;
}

4. 为了保险起见,我们再次验证一下是否在秒杀时间内(这步可以省略)

这里既没有操作磁盘,也没有操作数据库,也没有走网络,所以不会对性能产生影响

//3.为了保险起见,我们再次验证一下是否在秒杀时间内
Long currentTime = System.currentTimeMillis();
Long startTime = goods.getStarttime().getTime();
Long endTime = goods.getEndtime().getTime();
if(currentTime < startTime){
    //秒杀尚未开始
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("秒杀尚未开始");
    return returnObject;
}else if(currentTime > endTime){
    //秒杀已经结束
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("秒杀已经结束");
    return returnObject;
}else{
    //如果秒杀已经开始,处理业务继续写在这里

    return returnObject;
}

5. 如果已经开始秒杀验证商品是否已经卖光

需求:

如果商品已经卖光,那么提示用户,不能参与秒杀了

常规思路

● 直接查询数据库中商品的库存,如果直接操作数据库,秒杀场景,高并发大流量会给数据库带来很大的压力。

● 从Redis中缓存的商品信息中获取,但是后续秒杀结束后,涉及对库存做修改,操作Redis的商品信息比较麻烦。另外,如果我们5秒缓存预热一次,数据库中商品的库存还没有修改,会被再次把数据库中的库存更新到Redis中。

解决方案

所以我们在缓存预热的时候,直接将商品的库存单独存放到Redis中。并且这个信息需要在缓存预热的时候生成,而且只能生成一次,因为我们减库存我的时候,也是操作Redis,数据库暂时不会变,如果每5秒初始化一次,那么会将数据库的原始库存又初始化到Redis中。

设置值的时候使用setIfAbsent方法

如果key不存在,那么设置值,如果已经存在,不对其进行设置值了

在15-seckill-service缓存预热的定时任务中缓存商品库存

Key的格式 redis:store:商品id Value的值:就是商品的库存

/**
 * 把数据库中商品的库存也预热到Redis
 * 注意:这里只能放一次,因为我们减库存我的时候,也是操作Redis,数据库暂时不会变
 * 如果每5秒初始化一次,那么会将数据库的原始库存又初始化到Redis中
 * setIfAbsent:如果key不存在,那么设置值,如果已经存在,不对其进行设置值了
 */
redisTemplate.opsForValue()
        .setIfAbsent(Constants.REDIS_STORE + goods.getId(),String.valueOf(goods.getStore()));

在15-seckill-interface的Constants常量类下定义商品库存key的前缀

/**
 * 定义Redis中商品库存的key的前缀
 * Redis中存放商品库存的格式:redis:goods:商品id
 */
public static final String REDIS_STORE = "redis:store:";

重新运行15-seckill-service,通过Redis DeskTop Manager查看Redis数据

在GoodsControll编写验证商品是否卖光代码

//4.验证商品是否已经卖光了
//根据商品id,从Redis中获取商品库存
String redisStore = redisTemplate.opsForValue().get(Constants.REDIS_STORE + id);
//判断是否为空 如果不为空将redis存放的库存转换为整形
Integer store = StringUtils.isEmpty(redisStore)? 0 :Integer.valueOf(redisStore);
//其实不会出现小于0的情况
if(store <= 0 ){
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("来晚了,商品已经抢光了");
    return returnObject;
}

为了对String操作更加方便,在15-seckill-web中引入commons-lang的依赖

<!--对常用类操作的增强包-->
<dependency>
   <groupId>commons-lang</groupId>
   <artifactId>commons-lang</artifactId>
   <version>2.5</version>
</dependency>

6. 验证该用户是否已经秒杀过该商品

需求

同一件商品,同一个用户只能秒杀一次

常规思路

去数据库订单表中查询,是否有用户对该商品的下单信息,但是秒杀场景,高并发大流量下,会给数据库带来很大的压力

解决方案

我们这里还是查询采用Redis,如果用户秒杀了该商品,那就将用户信息及商品信息组合放到Redis中,生成一条秒杀记录,然后再秒杀的时候,从Redis中取数据进行判断

格式:redis:buy:id:uid

在15-seckill-interface的Constants常量中添加用户是否购买过商品的key的前缀

/**
 * 定义Redis中用户是否买过该商品的key的前缀
 * Redis中存放用户是否买过该商品的格式:redis:buy:商品id:用户id
 */
public static final String REDIS_BUY = "redis:buy:";

在15-seckill-web的GoodsController中编写验证是否买过该商品的代码

//5.验证用户是否买过该商品
//假设用户的id为888888,实际开发的使用用户的id可以从session中获取
Integer uid = 888888; 
String redisBuy = redisTemplate.opsForValue().get(Constants.REDIS_BUY + id +":"+ uid);
//这里我们不需要关心redisBuy中放了什么,只要不为空,就说明用户买个该商品
if(StringUtils.isNotEmpty(redisBuy)){
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("您已经秒杀过该商品了,请换个商品秒杀");
    return returnObject;
}

7. 限流

需求

在秒杀场景中,为每一个商品限制最大的参与抢购的人数为10w

不能为所有商品整体一个限流,否则会不平衡的问题,很多人都去秒杀一件商品,但是另一件商品在秒杀的时候,被限制了,误杀!

实现方式

一般有专门的限流算法

我们使用Redis的List类型或者Redis计数器实现

如果用户参与秒杀,向Redis的List中放一条记录,然后判断List的长度,Redis格式: redis:limit:商品id

在15-seckill-interface的Constants类中,添加限流最大值以及商品秒杀限流key的前缀常量

//商品限流最大值
public static final int MAX_LIMIT = 100000; 
/**
 * 定义Redis中商品秒杀限流key的前缀
 * Redis中存放当前商品的流量访问值的格式:redis:limit:商品id
 */
public static final String REDIS_LIMIT = "redis:limit:";

在15-seckill-web的GoodsController中编写限流代码

//6.限流
//从Redis中查询出当前商品的访问量
Long currentSize = redisTemplate.opsForList().size(Constants.REDIS_LIMIT + id);
if(currentSize > Constants.MAX_LIMIT){
    //超过最大限流值,拒绝访问
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("服务器繁忙,请稍后再试!~");
    return returnObject;
}else{
    //可以继续执行秒杀
    // 先向Redis的限流List中放一条数据  返回放完数据之后List的长度
    Long afterPushSize = redisTemplate.opsForList().leftPush(Constants.REDIS_LIMIT + id,String.valueOf(uid));
    /*放完元素之后再次判断List的长度是否大于限流值
    主要处理多线程情况下,很多线程都满足限流条件,都向Redis的List添加元素,避免List元素超出限流值
    */
    if(afterPushSize >Constants.MAX_LIMIT){
        redisTemplate.opsForList().rightPop(Constants.REDIS_LIMIT + id);
        //超过最大限流值,拒绝访问
        returnObject.setErrorCode(Constants.ZERO);
        returnObject.setErrorMessage("服务器繁忙,请稍后再试!~");
        return returnObject;
    }
}

8. 秒杀

减库存

需求

秒杀结束后,将商品的库存信息减1

常规的方式

减库存是直接操作数据库,高并发大流量的秒杀场景下,会给数据库瞬间带来极大的压力,可能数据库无法支撑(单台MySQL并发能力700左右,单台Redis并发能力5w左右)

解决方案

所以减库存在Redis中减

A、 15-seckill-web减库存代码

//减库存
Long leftStore = redisTemplate.opsForValue().decrement(Constants.REDIS_STORE +"id",1);

下订单(仅仅是将订单发送给MQ)

需求

秒杀之后,仅仅将订单发送给MQ,暂时不想数据库订单表中插入数据

常规的做法

直接是向数据库中插入订单信息

秒杀场景,可能有很多订单可以插入到数据库,而且主要是瞬间的操作,例如:1s,5s内向数据库插入10w条数据。

所以下单的时候不能直接操作数据库

解决方案

我们采用MQ,进行异步下单

同步是阻塞的,是需要等结果的,是可以拿到结果的

异步是非阻塞的,不需要等结果,但是有可能马上拿不到结果

让MQ接收瞬间的巨大的下单请求,但并不是马上瞬间处理完毕,而是一个个处理,插入数据库的频率的降低。这个频率的降低,我们叫做流量削峰,将单位时刻内,对数据库的操作降缓

MQ处理完毕之后,仅仅是将消息发送到了ActiveMQ的消息队列中,并没有真正的同步数据库,所以不能马上给前台结果,那么这个时候我们可以告诉前台页面一个中间结果,秒杀请求提交成功,正在处理……或一个图片转动

A、 在15-seckill-web的pom.xml文件添加ActiveMQ起步依赖

<!--SpringBoot集成ActiveMQ的起步依赖-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-activemq</artifactId>
</dependency>

B、 在15-seckill-web的核心配置文件中配置ActiveMQ信息

#配置activemq的连接信息
spring.activemq.broker-url=tcp://192.168.235.128:61616
# 用户名
spring.activemq.user=system
# 密码
spring.activemq.password=123456
#目的地
spring.jms.template.default-destination=seckillQueue

C、 在15-seckill-web的GoodsController类中注入JmsTemplate

@Autowired
private JmsTemplate jmsTemplate;

D、 在15-seckill-web的GoodsController类中编写下订单代码

//7.减库存
Long leftStore = redisTemplate.opsForValue().decrement(Constants.REDIS_STORE +id,1);
//8.下单到MQ
if(leftStore >= 0){
    //可以秒杀,执行下单操作
    //标记用户已经买过该商品
    redisTemplate.opsForValue().set(Constants.REDIS_BUY + id +":" +uid,String.valueOf(uid));
    //创建订单对象
    Orders orders = new Orders();
    orders.setBuynum(1);
    orders.setBuyprice(goods.getPrice());
    orders.setCreatetime(new Date());
    orders.setGoodsid(id);
    orders.setOrdermoney(goods.getPrice().multiply(new BigDecimal(1)));
    orders.setStatus(1);//待支付
    orders.setUid(uid);
    //将订单对象转换为json字符串
    String ordersJSON = JSONObject.toJSONString(orders);
    //通过JmsTemplate向ActiveMQ发送消息
    jmsTemplate.send(new MessageCreator() {
        @Override
        public Message createMessage(Session session) throws JMSException {
            return session.createTextMessage(ordersJSON);
        }
    });
    returnObject.setErrorCode(Constants.ONE);
    returnObject.setErrorMessage("秒杀请求提交成功,正在处理....");
    return returnObject;
}else{
    //不可以卖了,不能执行下单操作
    /*
    此时Redis中的商品库存可能已经减成负数了,但是对我们业务的处理没有任何影响
    但为了保持数据的一致性,我们将值再恢复一下
     */
    redisTemplate.opsForValue().increment(Constants.REDIS_STORE + id,1);
    returnObject.setErrorCode(Constants.ZERO);
    returnObject.setErrorMessage("来晚了,商品已经抢光了");
    return returnObject;
}

E、在15-seckill-web的seckill.js的execseckill函数中处理返回信息

//执行秒杀请求
execSeckill:function (random,id) {
    $.ajax({
        //url格式:   /15-seckill-web/seckill/goods/Ffdaskfjkadlsjklfa/1
        url: seckillObj.url.seckillURL() + random +"/" +id,
        type:"post",
        dataType:"json",
        success:function (rtnMessage) {
            //处理响应结果
            if(rtnMessage.errorCode == 1){
                //秒杀成功,已经下单到MQ,返回中间结果  可以做动画处理
                $("#seckillTip").html("<span style='color:red;'>"+ rtnMessage.errorMessage +"</span>");
                //接下来再发送一个请求获取最终秒杀的结果
            }else{
                //秒杀失败 展示失败信息
                $("#seckillTip").html("<span style='color:red;'>"+ rtnMessage.errorMessage +"</span>");
            }
        }
    });
}

F、 启动ActiveMQ,Redis,MySQL,15-seckill-service,15-seckill-web测试

 

在15-seckill-service中的RedisTask中同步MySQL数据库库存

/**
 * 每3秒同步一次Redis中的库存到数据库
 */
@Scheduled(cron = "0/3 * * * * *")
public void syncRedisStoreToDB(){
    System.out.println("同步Redis中的库存到数据库...........");
    //1.查询出所有秒杀商品在Redis中的库存值
    Set<String> keys = redisTemplate.keys(Constants.REDIS_STORE + "*");
    for (String key : keys) {
        //根据Redis的商品库存key,获取商品的库存
        int store = Integer.valueOf(redisTemplate.opsForValue().get(key));
        //获取商品的id   在Redis中存放商品库存的格式  redis:store:id
        int goodsId = Integer.valueOf(key.split(":")[2]);
        //同步到数据库
        Goods goods = new Goods();
        goods.setId(goodsId);
        goods.setStore(store);
        goodsMapper.updateByPrimaryKeySelective(goods);
    }
}

9. 异步下单的处理

需求

将MQ中的订单同步到数据库

实现思路

● 在15-seckill-service中使用异步接收消息的方式对秒杀的订单消息进行消费

● 为了方便对事务的处理,我们在消息消费者MyMessageListener中不直接调用Mapper,而是调用订单的Service

● 如果下单成功

在Service中将秒杀的最终结果返回给前台页面,这里存在一个问题,就是如何将秒杀的结果响应给前台页面?

传统的做法,前台页面可以直接查询数据库的订单表,获取最终的秒杀结果,但是会对数据库造成压力,我们这里借助第三方Redis,将返回的结果保存到Redis中,然后让前台页面到Redis中进行查询。

● 如果下单失败

在Service层中抛出异常,在MyMessageListener中捕获异常,对之前做的处理进行恢复,主要包括库存恢复、购买标记、限流列表中删除一个元素

恢复的操作我们也专门在Service中封装方法

在15-seckill-service中添加ActiveMQ相关依赖

<!--SpringBoot集成ActiveMQ的起步依赖-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-activemq</artifactId>
</dependency>

在15-seckill-service中的pom.xml文件中添加ActiveMQ配置信息

#配置activemq的连接信息
spring.activemq.broker-url=tcp://192.168.235.128:61616
spring.activemq.user=system
spring.activemq.password=123456
#目的地
spring.jms.template.default-destination=seckillQueue
# 消息发送模式 true发布订阅 false点对点  默认false点对点
spring.jms.pub-sub-domain=false
# SpringBoot 2.1.3之后需要配置
spring.jms.cache.enabled=false

从13-activemq-boot-receiver-async-02中拷贝ActiveMQ异步接收的代码config和listener目录下的内容

ActiveMQConfig代码(不需要修改)

@Configuration//相当于applicationContext-jms.xml文件
public class ActiveMQConfig {

    @Autowired
    private ActiveMQConnectionFactory connectionFactory;

    @Autowired
    private MyMessageListener myMessageListener;
    
    @Value("${spring.jms.template.default-destination}")
    private String destination;

    @Value("${spring.jms.pub-sub-domain}")
    private boolean pubSubDomain;
    
    @Bean //@Bean注解就相当于配置文件的bean标签
    public DefaultMessageListenerContainer defaultMessageListenerContainer(){
        DefaultMessageListenerContainer listenerContainer = new DefaultMessageListenerContainer();
        listenerContainer.setConnectionFactory(connectionFactory);
        listenerContainer.setDestinationName(destination);
        listenerContainer.setMessageListener(myMessageListener);
        //设置消息发送模式方式为发布订阅
        listenerContainer.setPubSubDomain(pubSubDomain);
        return listenerContainer;
    }
}

修改15-seckill-service中的MyMessageListener消费消息

@Component
public class MyMessageListener implements MessageListener{
    @Autowired
    private OrdersService ordersService;

    public void onMessage(Message message) {
        if(message instanceof TextMessage){
            try {
                String ordersJSON = ((TextMessage) message).getText();
                System.out.println("SpringBoot监听器异步接收到的消息为:" + ordersJSON);
                Orders orders = JSONObject.parseObject(ordersJSON,Orders.class);
                try {
                    //接收到消息,下订单
                    ordersService.addOrders(orders);
                } catch (Exception e) {
                    e.printStackTrace();
                    //下单失败了,要将之前的一些处理恢复一下
                    ordersService.processException(orders);
                }
            } catch (JMSException e) {
                e.printStackTrace();
            }
        }
    }
}

在15-seckill-interface的com.sxbdqn.seckill.service包下创建订单接口OrdersService

public interface OrdersService {
    /**
     * 下订单
     */
    int addOrders(Orders orders);
    /**
     * 下单失败对异常的处理
     */
    void processException(Orders orders);
}

在15-seckill-service的com.sxbdqn.seckill.service.impl包下中创建订单接口实现类OrdersServiceImpl

@Service
public class OrdersServiceImpl implements OrdersService{
    @Autowired
    private OrdersMapper ordersMapper;

    @Autowired
    private RedisTemplate<String,String> redisTemplate;

    @Transactional
    @Override
    public int addOrders(Orders  orders) {
        int addRow = ordersMapper.insertSelective(orders);
        if(addRow >0){
            /*下单成功,告知前台秒杀最终结果,我们这是service项目,由消息消费者调用,不能
            直接和前台打交道,所以需要前台重新发送请求,去数据库订单表中查询结果,但是这样
            对数据库带来压力,所以我们将秒杀最终结果放到Redis中,然后前台页面去Redis中查询
             */
            //用我们自定义我的RTO对象封装秒杀结果
            ReturnObject returnObject = new ReturnObject();
            returnObject.setErrorCode(Constants.ONE);
            returnObject.setErrorMessage("秒杀成功");
            returnObject.setData(orders);
            String returnJSON = JSONObject.toJSONString(returnObject);
            redisTemplate.opsForValue().set(Constants.REDIS_RESULT +
                    orders.getGoodsid() +":" + orders.getUid(),returnJSON);
            //当前这个人秒杀全部结束,应该把当前这个人从限流列表中删除,让后面的人再进来秒杀
            redisTemplate.opsForList().rightPop(Constants.REDIS_LIMIT + orders.getGoodsid());
        }else{
            //下单失败,抛出运行时异常
            throw new RuntimeException("秒杀下单失败");
        }
        return addRow;
    }
    /**
     下单失败之后,进行之前处理数据的恢复
     */
    @Override
    public void processException(Orders orders) {
       // 1.库存恢复
       redisTemplate.opsForValue().increment(Constants.REDIS_STORE + orders.getGoodsid(),1);
       //2.购买标记清除
       redisTemplate.delete(Constants.REDIS_BUY + orders.getGoodsid() +":" + orders.getUid());
       // 3.限流列表中删除一个元素
       redisTemplate.opsForList().rightPop(Constants.REDIS_LIMIT + orders.getGoodsid());
	//4.将失败信息放到Redis中,便于前台页面再次获取
	ReturnObject returnObject = new ReturnObject();
	returnObject.setErrorCode(Constants.ZERO);
	returnObject.setErrorMessage("秒杀失败");
	returnObject.setData(orders);	
	String returnJSON = JSONObject.toJSONString(returnObject);
	redisTemplate.opsForValue().set(Constants.REDIS_RESULT +
	orders.getGoodsid() + ":" + orders.getUid(), returnJSON);
    }
}

在15-seckill-interface的Constants类中添加存放最终秒杀结果Key的前缀

/**
 * 定义Redis中商品秒杀秒杀结果key的前缀
 * Redis中存放当前商品的流量访问值的格式:redis:result:商品id:用户id
 */
public static final String REDIS_RESULT  = "redis:result:";

在15-seckill-service的Application类上开启事务

@EnableTransactionManagement
@SpringBootApplication
public class Application {
   public static void main(String[] args) {
      SpringApplication.run(Application.class, args);
   }
}

10.  库存超卖的解读

参照面试题11-Summary\互联网金融项目-面试.docx