Spring Boot秒杀系统超卖问题,高并发问题深析(有压力测试的演示)


本系统流程图如下
在这里插入图片描述

1.超卖

1.1超卖背景

很多小伙伴可能觉得,我秒杀项目中的库存一个一个减,为什么会造成超卖呢?

当然,如果你是单线程的小系统当然不需要担心任何问题,但秒杀项目的特点是短时间内的超高并发,一堆用户去冲击很少量的商品库存,系统当然会反应不过来是谁买走了真正的库存商品,超卖情况也就随之发生,下面我们用代码详细展示一下!

1.2.情景再现

新建一个spring web项目,controller,dao,service层分别如下
Service层

package com.seckillsample.demo.service;

import com.seckillsample.demo.dao.SKDao;
import org.springframework.stereotype.Service;

@Service
public class SKService {
    
    
    private SKDao skDao = new SKDao();

    public void processSecKill() {
    
    
        Integer count = skDao.getCount();
        if (count > 0) {
    
    
            System.out.println("恭喜您,抢购成功!");
            count--;
            skDao.updateCount(count);
        } else {
    
    
            System.out.println("抱歉,已经卖完了");
        }
    }
}

Dao层

package com.seckillsample.demo.dao;

public class SKDao {
    
    
    public static Integer count = 10;

    public Integer getCount() {
    
    
        return SKDao.count;
    }

    public void updateCount(Integer count) {
    
    
        SKDao.count = count;
    }
}

Controller层

package com.seckillsample.demo.controller;

import com.seckillsample.demo.service.SKService;
import javafx.scene.control.Skin;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;

@Controller
public class SKController {
    
    
    @Resource
    private SKService skService;

    @ResponseBody
    @RequestMapping("/seckill")
    public String doSeckill() {
    
    
        skService.processSecKill();
        return "ok";
    }
}

我们没有连接数据库,用dao层中事先设定的10为库存量,当启动项目时,我们访问http://localhost:8080/seckill,每刷新一次页面,库存量减一。最终控制台打印出如下成果。在这里插入图片描述
可以看到,在单线程情况下,程序不会有任何问题,不仅系统不会崩溃,也不会发生超卖情况!

下面我们来模拟秒杀情景。这里我们用到了Apache JMeter压测工具,来给我们的小系统加点料!

启动Apache JMeter,Add一个Thread Group,设置线程100,循环100;再在Thread Group中设置HTTP Request,加上我们的地址http://localhost:8080/seckill。此时Jmeter就配置好了。现在我们重启项目,刷新回原始库存,开始测试! ![在这里插入图片描述](https://img-blog.csdnimg.cn/20200807101250366.png在这里插入图片描述
结果显而易见,仅在100个线程的压力下,系统就出现了错乱,多卖了数十个产品!如果这是 iPhone 11 Pro Max,那商家已经血亏,程序员小哥已经祭天。

1.3.原因分析

为什么如此简单的任务系统也会出错呢?让我们来看看系统底层是怎么看待这个项目的!

理想状态下,我们应该是一个用户减掉一个库存。在这里插入图片描述
但在我们并发情况下,每个用户读到的库存都是10,那么当他们购买成功后,系统库存减为9,但实际情况是卖了2个,库存却只减了1个,所以产生了超卖情况。

这个情况下如果商家同意超卖的发生,自己会有损失;不承认超卖,又会损失顾客的声誉。那么只能哑巴吃黄连!!但消费者对付不了,收拾程序员还是没问题的,此时设计系统的你下个月只能吃土了!!在这里插入图片描述

1.4.解决方案

Redis

我们为啥选择Redis呢,它有以下几个优点

  • 单线程模型
  • 内存存储高
  • 天生分布式支持

在秒杀活动开始前,我们将库存存入Redis,用到了List结构,单线程+List=稳稳地幸福,一个萝卜一个坑,当然就不会再发生超卖问题了。
在这里插入图片描述
在活动开始时,我们用Set结构来存储用户,Set的特点时不郧允许存相同元素,所以也就避免了一个用户抢购多次的问题。在这里插入图片描述
下面我们来实战演示!

按照上面图片所示,我们将库存线性的存入Redis的List结构中。
首先,我们需要序列化Redis,防止错乱格式的出现。

@Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
    
    
        RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();
        template.setConnectionFactory(factory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式采用jackson
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }

其次,我们声明一个@Component类,此类的目的是在运行程序后,自动将查询到的剩余库存线性加入Redis中,如有10个库存量,我们就添加10个进去。

@Component
public class SecKill {
    
    
    @Resource
    private PromotionSeckillService promotionSeckillService;
    @Resource
    private RedisTemplate<Object, Object> redis;

    @Scheduled(cron = "0/5 * * * * ?")
    public void startSeckill() {
    
    
        List<PromotionSeckill> PromotionSeckills = promotionSeckillService.findPromotionByStatus();
        for (PromotionSeckill tp : PromotionSeckills) {
    
    
            redis.delete("seckill:count:" + tp.getPsId());
            PromotionSeckill t = new PromotionSeckill();
            for (int i = 0; i < tp.getPsCount(); i++) {
    
    
                redis.opsForList().rightPush("seckill:count:" + tp.getPsId(), tp.getGoodsId());
            }
            t.setStatus(1);
            t.setPsId(tp.getPsId());
            promotionSeckillService.update(t);
            System.out.println(tp.getGoodsId() + "活动开始");
        }
    }
}

当访问目标地址时,会进行出队列的操作,一次请求出一个,当Redis中的值被取完时,则发送“已卖完”的通知。

 @GetMapping("selectOne")
    public String selectOne(Long id) {
    
    
        PromotionSeckill goods = promotionSeckillService.queryById(id);
        Integer count = goods.getInventory();
        Integer goodsId = (Integer) redisTemplate.opsForList().leftPop("seckill:count:" + goods.getPsId());
        if (goodsId == null) {
    
    
            System.out.println("抱歉,商品已被抢光");
        } else {
    
    
            System.out.println("恭喜您,抢购成功!");
        }
        count--;
        goods.setInventory(count);
        promotionSeckillDao.update(goods);
        return "OK";
    }

此时我们再次进行Jmeter压测,结果如下。一个都没有多卖有木有!
在这里插入图片描述

2.解决高并发带来的问题

现在我们的超卖问题解决了,但此时我们看看压测数据
在这里插入图片描述
可以看到,系统的并发量并不高,每秒只能处理150多个任务,这在秒杀场景中,数十万人涌进来系统势必会出现问题。

而且一个请求的等待时长最高可达22秒,这么长的等待时间一定会让用户心力憔悴,那么这次秒杀活动的效果可能事而起反,造成客户的流失!!

2.1消息队列

消息队列的意义不在于提高系统的并发量,而是在于不让系统在短时间内受到过大的冲击,从而导致系统的崩溃而设定的。

有了消息队列,可以把系统从与用户直接对战的局面中脱离出来,让消息队列去与用户冲锋。它就像一个大坝,拦住洪峰,然后以系统可以承受的流量去与系统交互。
在这里插入图片描述
首先,下载安装好RabbitMQ,登录http://localhost:15672对MQ进行配置。

将消息获取并传到RabbitMQ(此方法调用在下处

public String SeckillOrder(String userid) {
    
    
        //随机生成orderNo
        String orderNo = UUID.randomUUID().toString();
        ConcurrentHashMap data = new ConcurrentHashMap();
        data.put("userid", userid);
        data.put("orderNo", orderNo);
        rabbitTemplate.convertAndSend("exchange-order", null, data);
        return orderNo;
    }

上述方法在本方法体中第一行调用,目的是一开始就将数据传入MQ

@GetMapping("selectOne")
    public String selectOne(Long id) {
    
    
        promotionSeckillService.SeckillOrder(String.valueOf(id));
        PromotionSeckill goods = promotionSeckillService.queryById(id);
        Integer count = goods.getInventory();
        Integer goodsId = (Integer) redisTemplate.opsForList().leftPop("seckill:count:" + goods.getPsId());
        if (goodsId == null) {
    
    
            System.out.println("抱歉,商品已被抢光");
        } else {
    
    
            System.out.println("恭喜您,抢购成功!");
        }
        count--;
        goods.setInventory(count);
        promotionSeckillDao.update(goods);
        return "OK";
    }

在项目中创建组件类(可以自动装配),目的是将RabbitMQ中数据传到数据库进行操作。

@Component
class OrderConsumer {
    
    
    @Resource
    private OrderDao orderDao;

    @RabbitListener(
            bindings = @QueueBinding(
                    value = @Queue(value = "queue-order"),
                    exchange = @Exchange(value = "exchange-order", type = "fanout")))

    @RabbitHandler
    private void handleMessage(@Payload Map data, Channel channel,
                               @Headers Map<String, Object> headers) {
    
    
        System.out.println("获取到订单数据");
        Order order = new Order();
        order.setCreateTime(new Date());
        order.setOrderNo(data.get("orderNo").toString());
        order.setOrderStatus(0);
        order.setUserid(data.get("userid").toString());
        order.setAmout(11);
        order.setPostge(0f);
        order.setRecvAddress("Dadad");
        order.setRecvName("wxz");
        order.setRecvMobile("1231412414");
        orderDao.insert(order);
        Long tag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
        try {
    
    
            channel.basicAck(tag, false);
        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
    }
}

总的流程图如下所示,OrderConsumer将数据推到MQ,Controller再将数据读取到数据库进行减库存,记录订单的操作。
在这里插入图片描述
至此,系统应该不会因为超大的流量而崩溃了,那么下面我们来解决系统处理效率的问题。

2.2负载均衡

在这里插入图片描述负载均衡的意思就是讲不通的前端请求分发给不同的服务端口,以此提高并发量从而减少系统处理任务的时间。

下载好nginx后,我们修改其配置文件


#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;
#pid        logs/nginx.pid;
events {
    
    
    worker_connections  1024;
}
http {
    
    
    include       mime.types;
    default_type  application/octet-stream;

  log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"'
				  '$upstream_addr "$upstream_status" ${
    
    upstream_response_time}';
 #
 #  access_log  logs/access.log  main;
    sendfile        on;
    #tcp_nopush     on;
    #keepalive_timeout  0;
    keepalive_timeout  65;
    #将需要被负载均衡的端口写在下面
    upstream babytun{
    
    
		server 192.168.3.19:8001;
		server 192.168.3.19:8002;
		server 192.168.3.19:8003;	
		server 192.168.3.19:8004;
	}	
	server {
    
    
		listen 80;#通过80端口提供服务
		location /{
    
    
			proxy_pass http://babytun;#此处须于上面配置一致
			proxy_set_header Host $host;
			proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;	
		}       
    } 
}

在项目启动处进行配置,分置为4个不同的端口在这里插入图片描述
这样在启动后,可通过4个端口访问目标路径
通过nginx的负载均衡策略,将大量请求分给不同端口,减小了每个端口的压力,也加大了系统整体处理能力。

最后通过Jemter测试结果如下,大大提高了系统并发能力,也大大减少了用户的等待时间!!
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_40485391/article/details/107855853
今日推荐