Java秒殺系統方案優化 高性能高併發實戰

概述

最近在學習某課的《Java秒殺系統方案優化 高性能高併發實戰》,寫篇文章來記錄一下知識點前端

技術要點

  • 自定義@IsMobile註解 + springboot-validator校驗手機號格式是否正確
  • 自定義@AccessLimit註解 + redis實現接口防刷
  • 實現參數解析器,解析User參數校驗用戶登陸狀態,並從ThreadLocal取出用戶
  • 使用rabbitMq實現流量削峯

validator校驗器

springboot validation爲咱們提供了經常使用的校驗註解,好比@notNull,@notBlant等註解,但有時候這些並不能知足咱們的需求。
好比當用戶登陸的時候須要輸入手機號和密碼,那麼如何判斷手機號碼格式是否正確呢,這時就須要咱們自定義Validator來校驗手機號碼web

首先在pom.xml引入spring-boot-starter-validation依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

編寫ValidatorUtil工具類,使用正則表達式判斷試機號碼是否爲以1開頭的11位數字

/*判斷手機號格式是否正確*/
public class ValidatorUtil {

    //正則表達式:1\d{10}表示1後面接10位數字
    private static final Pattern mobile_pattern = Pattern.compile("1\\d{10}");

    public static boolean isMobile(String src) {
        if (StringUtils.isEmpty(src)) {
            return false;
        }
        Matcher m = mobile_pattern.matcher(src);
        //若是匹配則返回true
        return m.matches();
    }
}

自定義@IsMobile來校驗前端發送過來的手機號格式是否正確

/**
 * 配置 @IsMobile 註解
 */
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})//指定使用範圍
@Retention(RUNTIME)////該註解被保留的時間長短 RUNTIME:在運行時有效
@Documented
@Constraint(validatedBy = {IsMobileValidator.class})//指定校驗器
public @interface IsMobile {
    //是不是必需字段,默認爲true
    boolean required() default true;

    String message() default "手機號碼格式錯誤";
    //下面兩行是自定義註解須要添加的
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@Target :表示修飾的範圍
@Retention :表示註解被保留的時間長短
@Constraint:指定處理校驗規則的類
String message(): 必須有的註解屬性字段,該字段是用來定義不符合規則錯誤信息提示用的
boolean required():自定義的註解熟悉字段,該字段表示被@IsMobile標註的字段是不是必需字段正則表達式

能夠看到咱們在@Constraint(validatedBy = {IsMobileValidator.class})指定了校驗器,接下來看看校驗器的實現:redis

IsMobileValidator校驗器

/**
 * 參數校驗器
 */
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

    private boolean required = false;

    public void initialize(IsMobile constraintAnnotation) {
        required = constraintAnnotation.required();
    }
    //校驗
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (required) {
            //調用工具類對手機號碼進行校驗
            return ValidatorUtil.isMobile(value);
        } else {
            if (StringUtils.isEmpty(value)) {
                return true;
            } else {
                return ValidatorUtil.isMobile(value);
            }
        }
    }
}

能夠看到自定義的校驗器須要實現ConstraintValidator接口實現 initializeisValid 方法,邏輯很簡單這裏就很少說了,而後來看看如何使用@IsMobile註解:spring

在mobile字段上標註@IsMobile註解,在controller對須要校驗的參數標註@Valid註解json

/**
 * 實體類
 */
public class LoginVo {

    @NotNull
    //自定義註解
    @IsMobile
    private String mobile;

    @NotNull
    @Length(min = 32)
    private String password;


/**
 * 控制器
 */
@Controller
@RequestMapping("/login")
public class LoginController {

  
    @RequestMapping("/do_login")
    @ResponseBody
    public Result<Boolean> doLogin(@Valid LoginVo loginVo) {//在須要校驗的參數前添加@Valid註解
        //登陸
        userService.login(loginVo);
        return Result.success(true);
    }
}

validator校驗失敗會拋出BindExeption異常,捕獲後返回給前端錯誤信息緩存

clipboard.png

接口防刷

顧名思義,想讓某個接口某我的在某段時間內只能請求N次。springboot

原理

在你請求的時候,服務器經過redis 記錄下你請求的次數,若是次數超過限制就不給訪問。
在redis 保存的key 是有時效性的,過時就會刪除。服務器

實現

AccessLimit註解類cookie

//限制接口訪問
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {

    int seconds();//秒

    int maxCount();//次數

    boolean needLogin() default true;//是否須要登陸
}

AccessInterceptor攔截器

/**
 * 攔截器(接口防刷)
 */
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private UserService userService;

    @Autowired
    private RedisService redisService;

    //調用方法前
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if (handler instanceof HandlerMethod) {
            //從cookie中的token獲取用戶對象
            User user = getUser(request, response);
            //將user存到ThreadLocal中
            UserContext.setUser(user);
            HandlerMethod hm = (HandlerMethod) handler;
            //判斷方法是否有@AccessLimit註解(用來接口限流)
            AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
            //若是沒有該註解則不須要校驗,直接返回就能夠了
            // 注意:這裏不能返回false,不然會中斷請求
            if (accessLimit == null) {
                return true;
            }
            int seconds = accessLimit.seconds();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();
            //獲取請求路徑
            String key = request.getRequestURI();
            if (needLogin) {
                if (user == null) {
                    //注意:這裏須要返回false,不能直接返回CodeMsg.error,所以把錯誤信息放到response
                    render(response, CodeMsg.SESSION_ERROR);
                    return false;
                }
                key += "_" + user.getId();
            }
            //設置AccessKey鍵並設置緩存時間(接口防刷)
            AccessKey ak = AccessKey.withExpire(seconds);
            Integer count = redisService.get(ak, key, Integer.class);
            if (count == null) {
                redisService.set(ak, key, 1);
            } else if (count < maxCount) {
                redisService.incr(ak, key);
            } else {
                render(response, CodeMsg.ACCESS_LIMIT_REACHED);//訪問太頻繁
                return false;
            }
        }
        return true;
    }

    //把錯誤信息放到response
    private void render(HttpServletResponse response, CodeMsg cm) throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        OutputStream out = response.getOutputStream();
        String str = JSON.toJSONString(Result.error(cm));
        out.write(str.getBytes("UTF-8"));
        out.flush();
        out.close();
    }

    //根據token獲取用戶信息
    //注意:若前端直接傳送token過來則使用該token,不然使用cookie中的token
    private User getUser(HttpServletRequest request, HttpServletResponse response) {

        String paramToken = request.getParameter(UserService.COOKIE_NAME_TOKEN);
        String cookieToken = getCookieValue(request, UserService.COOKIE_NAME_TOKEN);
        if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return null;
        }
        String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
        return userService.getUserByToken(token, response);
    }


    //從cookie中獲取token
    private String getCookieValue(HttpServletRequest request, String cookieName) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null || cookies.length <= 0) {
            return null;
        }
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(cookieName)) {
                return cookie.getValue();
            }
        }
        return null;
    }

}

總結:AccessInterceptor攔截器做用:
(1)限制了接口的訪問次數
(2)每次訪問接口都會從cookie中取出token從redis中獲取用戶信息

配置攔截器

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private AccessInterceptor accessInterceptor;

    //添加攔截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accessInterceptor);
    }
}

接口實現

只要在須要限流的接口上加上@AccessLimit就好了

@AccessLimit(seconds = 10, maxCount = 5, needLogin = true)//在10秒內最多隻能訪問5次

參數解析器

參數解析器的做用是用於將前端請求中的的參數根據自定義規則映射到Controller中的方法的參數上

UserArgumentResolver :若是前端傳過來的參數是User類型,就取出在調用攔截器時存進ThreadLocal中的User對象

/**
 * 參數解析器
 */
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    private UserService userService;

    //判斷前端傳過來的參數是不是User類型,若是是則執行resolveArgument方法
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> clazz = parameter.getParameterType();
        return clazz == User.class;
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return UserContext.getUser();
    }

}


//把user信息存到本地線程中
public class UserContext {

    private static ThreadLocal<User> userHolder = new ThreadLocal<>();

    public static void setUser(User user) {
        userHolder.set(user);
    }

    public static User getUser() {
        return userHolder.get();
    }
}

記得在WebConfig類配置參數解析器

@Autowired
    private UserArgumentResolver userArgumentResolver;

    //添加參數解析器
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers){
        argumentResolvers.add(userArgumentResolver);
    }

這樣的話當參數爲User類型時,SpringMVC會從ThreadLocal中取出User對象映射到參數上,
注意:攔截器比參數解析器先執行

流量削峯

背景介紹和使用場景

RabbitMQ是一個由erlang開發的AMQP(Advanved Message Queue)的開源實現。

1.用戶的請求,服務器收到以後,首先寫入消息隊列,加入消息隊列長度超過最大值,則直接拋棄用戶請求或跳轉到錯誤頁面
2.秒殺業務根據消息隊列中的請求信息,再作後續處理

RabbitMQ各個組件的功能以下:

生產者:發送消息
交換機:將收到的消息根據路由規則路由到特定隊列
隊列:用於存儲消息
消費者:收到消息並消費

請求入隊

MiaoshaMessage:封裝消息類型

public class MiaoshaMessage {
    private User user;
    private long goodsId;
    public User getUser() {
        return user;
    }
    public void setUser(User user) {
        this.user = user;
    }
    public long getGoodsId() {
        return goodsId;
    }
    public void setGoodsId(long goodsId) {
        this.goodsId = goodsId;
    }
}

MQConfig:配置交換機

@Configuration
public class MQConfig {

    public static final String MIAOSHA_QUEUE = "miaosha.queue";
 
    /**
     * Direct模式 交換機Exchange(其它模式感興趣的本身去了解一下)
     * */
    @Bean
    public Queue queue() {
        return new Queue(MIAOSHA_QUEUE, true);//true表示重啓後會從新鏈接
    }
 }

MQSender:controller收到秒殺請求後判斷庫存數量,若>0則調用該方法將請求放進消息隊列

@Service
public class MQSender {

    @Autowired
    private AmqpTemplate amqpTemplate;

    /**
     * 發送秒殺請求
     * @param mm
     */
    public void sendMiaoshaMessage(MiaoshaMessage mm) {
        String msg = RedisService.beanToString(mm);
        amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
    }
}

MQReceiver : 處理消息隊列中的秒殺請求

@Service
public class MQReceiver {

    private static Logger log = LoggerFactory.getLogger(MQReceiver.class);

    @Autowired
    RedisService redisService;

    @Autowired
    GoodsService goodsService;

    @Autowired
    OrderService orderService;

    @Autowired
    MiaoshaService miaoshaService;

    /**
     * 接受秒殺請求
     * @param message
     */
    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void receive(String message) {
        MiaoshaMessage mm = RedisService.stringToBean(message, MiaoshaMessage.class);
        User user = mm.getUser();
        long goodsId = mm.getGoodsId();

        GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        //若是庫存不足,直接返回
        int stock = goods.getStockCount();
        if (stock <= 0) {
            return;
        }
        //判斷是否重複秒殺(order不爲空說明已經被秒殺過了)
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdAndGoodsId(user.getId(), goodsId);
        if (order != null) {
            return;
        }
        //減庫存 下訂單 寫入秒殺訂單
        miaoshaService.kill(user, goods);
    }
相關文章
相關標籤/搜索