本系统流程图如下
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测试结果如下,大大提高了系统并发能力,也大大减少了用户的等待时间!!