微服务高并发秒杀系统

微服务高并发秒杀系统

在做完乐优商城项目之后发现缺少秒杀未编写,打算上手实现一下这个基本电商都需要的功能,参考https://blog.youkuaiyun.com/lyj2018gyq/article/details/84261377,https://my.oschina.net/xianggao/blog/524943下面开始编写。

概念

什么是秒杀?通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动

比如说京东秒杀,就是一种定时定量秒杀,在规定的时间内,无论商品是否秒杀完毕,该场次的秒杀活动都会结束。这种秒杀,对时间不是特别严格,只要下手快点,秒中的概率还是比较大的。

淘宝以前就做过一元抢购,一般都是限量 1 件商品,同时价格低到「令人发齿」,这种秒杀一般都在开始时间 1 到 3 秒内就已经抢光了,参与这个秒杀一般都是看运气的,不必太强求

业务分析

秒杀业务的特性
  1. 低廉价格
  2. 一般是定时上架
  3. 时间短、瞬时并发量高,秒杀时会有大量用户在同一时间进行抢购,瞬时并发访问量突增 10 倍
  4. 库存量少,一般秒杀活动商品量都很少,这就导致了只有极少量用户能成功购买到商品。
  5. 页面流量突增,很多用户请求对应商品页面,会造成后台服务器的流量突增,同时对应的网络带宽增加,需要控制商品页面的流量不会对后台服务器、DB、Redis 等组件造成过大的压力

系统优化

页面缓存

​ 将不经常改动的页面直接缓存到redis中,然后用Thymeleaf视图解析器将缓存的页面直接渲染出来。

对象缓存

​ 将经常使用的对象信息放入redis中,比如说用户信息,抽插redis肯定比抽插数据库快。但是,这里面就涉及到一个数据同步问题,即如何保持redis中放入的是最新的数据。策略就是遇到数据更新的时候,先更新数据库中的信息,然后使缓存失效,当再次拉取数据的时候就会从数据库中获取,第一次获取成功后就放入缓存当中。

页面静态化

​ 将页面直接缓存到用户的浏览器上,或者将页面直接转化为静态网页。静态化是指把动态生成的HTML页面变为静态内容保存,以后用户的请求到来,直接访问静态页面,不再经过服务的渲染。而静态的HTML页面可以部署在nginx中,从而大大提高并发能力,减小tomcat压力。通过Thymeleaf模板引擎来生成静态网页。

静态资源优化

​ js/css压缩,减少流量;多个js/css组合,减少连接数,CDN优化。

数据库优化

​ 减少对数据库的访问,可以提高性能。秒杀时因为有大量用户进行下订单操作,所有可以使用消息队列来缓解数据库压力。同时也可以想办法优化对redis的访问,设置内存标记等。

秒杀地址隐藏

功能:防止秒杀地址被刷。

思路:秒杀开始之前,先去请求接口获取秒杀地址

  • 改造接口,带上PathVariable参数
  • 添加生成地址的接口
  • 秒杀收到请求,先验证PathVariable

创建秒杀路径

Controller
/**
 * 创建秒杀路径
 * @param goodsId
 * @return
 */
@GetMapping("get_path/{goodsId}")
public ResponseEntity<String> getSeckillPath(@PathVariable("goodsId") Long goodsId){
    UserInfo userInfo = LoginInterceptor.getLoginUser();
    if (userInfo == null){
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    String str = this.seckillService.createPath(goodsId,userInfo.getId());
    if (StringUtils.isEmpty(str)){
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }
    return ResponseEntity.ok(str);
}
service

将用户id和秒杀商品的id先进行加密,然后放入redis中,并且设置过期时间为60秒。

 /**
  * 创建秒杀地址
  * @param goodsId
  * @param id
  * @return
  */
  @Override
  public String createPath(Long goodsId, Long id) {
      String str = new BCryptPasswordEncoder().encode(goodsId.toString()+id);
      BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX_PATH);
      String key = id.toString() + "_" + goodsId;
      hashOperations.put(key,str);
      hashOperations.expire(60, TimeUnit.SECONDS);
      return str;
  }

路径验证

Controller,创建秒杀订单接口,让其先验证路径

    /**
     * 秒杀
     * @param path
     * @param seckillGoods
     * @return
     */
    @PostMapping("/{path}/seck")
    public ResponseEntity<String> seckillOrder(@RequestParam("path") String path, @RequestBody SeckillGoods seckillGoods){

        String result = "排队中";

        UserInfo userInfo = LoginInterceptor.getLoginUser();

        //1.验证路径
        boolean check = this.seckillService.checkSeckillPath(seckillGoods.getId(),userInfo.getId(),path);
        if (!check){
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }

        //2.内存标记,减少redis访问
        boolean over = localOverMap.get(seckillGoods.getSkuId());
        if (over){
            return ResponseEntity.ok(result);
        }

        //3.读取库存,减一后更新缓存
        BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX);
        Long stock = hashOperations.increment(seckillGoods.getSkuId().toString(), -1);

        //4.库存不足直接返回
        if (stock < 0){
            localOverMap.put(seckillGoods.getSkuId(),true);
            return ResponseEntity.ok(result);
        }

        //5.库存充足,请求入队
        //5.1 获取用户信息
        SeckillMessage seckillMessage = new SeckillMessage(userInfo,seckillGoods);
        //5.2 发送消息
        this.seckillService.sendMessage(seckillMessage);

        return ResponseEntity.ok(result);
    }

Service

 	/**
     * 验证秒杀地址
     * @param goodsId
     * @param id
     * @param path
     * @return
     */
    @Override
    public boolean checkSeckillPath(Long goodsId, Long id, String path) {
        String key = id.toString() + "_" + goodsId;
        BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX_PATH);
        String encodePath = (String) hashOperations.get(key);
        return new BCryptPasswordEncoder().matches(path,encodePath);
    }

接口限流

功能:限定用户在某一段时间内有限次的访问地址。

思路:将用户访问地址的次数写入redis当中,同时设置过期时间。当用户每次访问,该值就加一,当访问次数超出限定数值时,那么就直接返回。

实现:为了具有通用性,以注解的形式调用该方法。

package com.leyou.seckill.access;
 
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
/**
 * 接口限流注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
 
    /**
     * 限流时间
     * @return
     */
    int seconds();
 
    /**
     * 最大请求次数
     * @return
     */
    int maxCount();
 
    /**
     * 是否需要登录
     * @return
     */
    boolean needLogin() default true;
}

添加拦截器

通过拦截器,拦截AccessLimit注解,然后进行接口限流。主要是使用redis的自增机制。

package com.leyou.seckill.interceptor;

import com.leyou.common.pojo.UserInfo;
import com.leyou.common.response.CodeMsg;
import com.leyou.common.response.Result;
import com.leyou.common.utils.JsonUtils;
import com.leyou.seckill.access.AccessLimit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.TimeUnit;

/**
 * 接口限流拦截器
 */
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private RedisTemplate<String,String> redisTemplate;


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod){
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
            if (accessLimit == null){
                return true;
            }

            //获取用户信息
            UserInfo userInfo = LoginInterceptor.getLoginUser();
            int seconds = accessLimit.seconds();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();
            String key = request.getRequestURI();
            if (needLogin){
                if (userInfo == null){
                    render(response, CodeMsg.LOGIN_ERROR);
                    return false;
                }
                key += "_" + userInfo.getId();
            }else {
                //不需要登录,则什么也不做
            }
            String count = redisTemplate.opsForValue().get(key);
            if (count == null){
                redisTemplate.opsForValue().set(key,"1",seconds, TimeUnit.SECONDS);
            }else if(Integer.valueOf(count) < maxCount){
                redisTemplate.opsForValue().increment(key,1);
            }else {
                render(response,CodeMsg.ACCESS_LIMIT_REACHED);
            }

        }

        return super.preHandle(request, response, handler);
    }

    private void render(HttpServletResponse response, CodeMsg cm) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        OutputStream out = response.getOutputStream();
        String str  = JsonUtils.serialize(Result.error(cm));
        out.write(str.getBytes("UTF-8"));
        out.flush();
        out.close();
    }
}

配置拦截器

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private JwtProperties jwtProperties;

    @Bean
    public LoginInterceptor loginInterceptor() {
        return new LoginInterceptor(jwtProperties);
    }

    @Autowired
    public AccessInterceptor accessInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        List<String> excludePath = new ArrayList<>();
        excludePath.add("/list");
        excludePath.add("/addSeckill");
        registry.addInterceptor(loginInterceptor())
                .addPathPatterns("/**").excludePathPatterns(excludePath);
        registry.addInterceptor(accessInterceptor);
    }
 }

使用

在需要限流的方法上,直接使用注解即可。

在这里插入图片描述

之后使用消息队列,调用订单微服务执行下单操作。(地址信息暂时定死)

package com.leyou.order.listener;

import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.IdWorker;
import com.leyou.common.utils.JsonUtils;
import com.leyou.item.pojo.SeckillGoods;
import com.leyou.item.pojo.Stock;
import com.leyou.order.mapper.*;
import com.leyou.order.pojo.Order;
import com.leyou.order.pojo.OrderDetail;
import com.leyou.order.pojo.OrderStatus;
import com.leyou.order.pojo.SeckillOrder;
import com.leyou.order.service.OrderService;
import com.leyou.seckill.vo.SeckillMessage;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import tk.mybatis.mapper.entity.Example;

import java.util.Arrays;
import java.util.Date;
import java.util.List;

/**
 * 秒杀消息队列监听器
 */
@Component
public class SeckillListener {

    @Autowired
    private IdWorker idWorker;

    @Autowired
    private OrderService orderService;

    @Autowired
    private SkuMapper skuMapper;

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private SeckillOrderMapper seckillOrderMapper;

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private OrderStatusMapper orderStatusMapper;

    @Autowired
    private OrderDetailMapper orderDetailMapper;

    @Autowired
    private SeckillMapper seckillMapper;

    /**
     * 接收秒杀信息
     *
     * @param seck
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(value = "leyou.order.seckill.queue", durable = "true"), //队列持久化
            exchange = @Exchange(
                    value = "leyou.order.exchange",
                    ignoreDeclarationExceptions = "true",
                    type = ExchangeTypes.TOPIC
            ),
            key = {"order.seckill"}
    ))
    @Transactional(rollbackFor = Exception.class)
    public void listenSeckill(String seck) {

        SeckillMessage seckillMessage = JsonUtils.parse(seck, SeckillMessage.class);
        UserInfo userInfo = seckillMessage.getUserInfo();
        SeckillGoods seckillGoods = seckillMessage.getSeckillGoods();


        //1.首先判断库存是否充足
        Stock stock = stockMapper.selectByPrimaryKey(seckillGoods.getSkuId());
        if (stock.getSeckillStock() <= 0 || stock.getStock() <= 0) {
            //如果库存不足的话修改秒杀商品的enable字段
            Example example = new Example(SeckillGoods.class);
            example.createCriteria().andEqualTo("skuId", seckillGoods.getSkuId());
            List<SeckillGoods> list = this.seckillMapper.selectByExample(example);
            for (SeckillGoods temp : list) {
                if (temp.getEnable()) {
                    temp.setEnable(false);
                    this.seckillMapper.updateByPrimaryKeySelective(temp);
                }
            }
            return;
        }
        //2.判断此用户是否已经秒杀到了
        Example example = new Example(SeckillOrder.class);
        example.createCriteria().andEqualTo("userId", userInfo.getId()).andEqualTo("skuId", seckillGoods.getSkuId());
        List<SeckillOrder> list = this.seckillOrderMapper.selectByExample(example);
        if (list.size() > 0) {
            return;
        }
        //3.下订单
        //构造order对象
        Order order = new Order();
        order.setPaymentType(1);
        order.setTotalPay(seckillGoods.getSeckillPrice().doubleValue());
        order.setActualPay(seckillGoods.getSeckillPrice().doubleValue());
        order.setPostFee(0 + "");
        order.setReceiver("李四");
        order.setReceiverMobile("15812312312");
        order.setReceiverCity("西安");
        order.setReceiverDistrict("碑林区");
        order.setReceiverState("陕西");
        order.setReceiverZip("000000000");
        order.setInvoiceType(0);
        order.setSourceType(2);

        OrderDetail orderDetail = new OrderDetail();
        orderDetail.setSkuId(seckillGoods.getSkuId());
        orderDetail.setNum(1);
        orderDetail.setTitle(seckillGoods.getTitle());
        orderDetail.setImage(seckillGoods.getImage());
        orderDetail.setPrice(seckillGoods.getSeckillPrice().doubleValue());
        orderDetail.setOwnSpec(this.skuMapper.selectByPrimaryKey(seckillGoods.getSkuId()).getOwnSpec());

        order.setOrderDetails(Arrays.asList(orderDetail));

        //3.1 生成orderId
        long orderId = idWorker.nextId();
        //3.2 初始化数据
        order.setBuyerNick(userInfo.getUsername());
        order.setBuyerRate(false);
        order.setCreateTime(new Date());
        order.setOrderId(orderId);
        order.setUserId(userInfo.getId());
        //3.3 保存数据
        this.orderMapper.insertSelective(order);

        //3.4 保存订单状态
        OrderStatus orderStatus = new OrderStatus();
        orderStatus.setOrderId(orderId);
        orderStatus.setCreateTime(order.getCreateTime());
        //初始状态未未付款:1
        orderStatus.setStatus(1);
        //3.5 保存数据
        this.orderStatusMapper.insertSelective(orderStatus);

        //3.6 在订单详情中添加orderId
        order.getOrderDetails().forEach(od -> {
            //添加订单
            od.setOrderId(orderId);
        });

        //3.7 保存订单详情,使用批量插入功能
        this.orderDetailMapper.insertList(order.getOrderDetails());

        //3.8 修改库存
        order.getOrderDetails().forEach(ord -> {
            Stock stock1 = this.stockMapper.selectByPrimaryKey(ord.getSkuId());
            stock1.setStock(stock1.getStock() - ord.getNum());
            stock1.setSeckillStock(stock1.getSeckillStock() - ord.getNum());
            this.stockMapper.updateByPrimaryKeySelective(stock1);

            //新建秒杀订单
            SeckillOrder seckillOrder = new SeckillOrder();
            seckillOrder.setOrderId(orderId);
            seckillOrder.setSkuId(ord.getSkuId());
            seckillOrder.setUserId(userInfo.getId());
            this.seckillOrderMapper.insert(seckillOrder);

        });


    }
}

主要接口

  1. 添加参加秒杀的商品

  2. 查询秒杀商品

  3. 创建秒杀地址

  4. 验证秒杀地址

  5. 秒杀

  6. 秒杀的实现及其优化:

    前端:秒杀地址的隐藏、使用图形验证码

    后端:接口限流,使用消息队列,调用订单微服务执行下单操作。

    TODO:还需要继续改进~~~~~~~~~~~~~!!!!!!!!!!!!!

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值