微服務高併發秒殺系統

微服務高併發秒殺系統

在做完樂優商城項目之後發現缺少秒殺未編寫,打算上手實現一下這個基本電商都需要的功能,參考https://blog.csdn.net/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:還需要繼續改進~~~~~~~~~~~~~!!!!!!!!!!!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章