SpringBoot秒殺系統設計

SpringBoot秒殺系統設計

1. 設計方案

​ 秒殺系統比較複雜,它一般要要求前端、後端、MySql、Nginx、Redis等聯合工作才能達到最好的效果,如衆所周知的,前端的按鈕防抖、資源靜態化,nginx的長連接優化、壓縮優化、配置緩存,MySql的樂觀鎖/悲觀鎖,Redis的數據預熱、分佈式鎖等設計。接下來讓我們刨析一下後端服務在秒殺系統中能做什麼事情。

如圖所示,我在秒殺系統中設置了10個步驟:

  1. 攔截器校驗登錄狀態:絕大部分情況下,秒殺接口是需要用戶登錄才能操作的,在系統通用攔截器裏進行用戶登錄校驗。
  2. 自定義註解限制請求頻率:正常情況下秒殺接口不被允許頻繁訪問,在秒殺接口上添加自定義註解,使用分佈式鎖的方式控制單用戶的全局訪問頻率。
  3. 秒殺結束校驗:全局校驗秒殺結束,結束後直接拒絕訪問。
  4. 秒殺鏈接加鹽校驗:當用戶在頁面中點擊秒殺按鈕時,前端需要先向後端請求秒殺鏈接,後端返回一個加鹽的連接,並在秒殺請求中進行加鹽校驗。
  5. 分佈式重複秒殺校驗 :當用戶秒殺成功後向Redis中添加一條成功記錄,當該用戶再次返送秒殺請求時,系統直接拒絕請求。
  6. 本地緩存商品賣完校驗:當商品秒殺完後,在本地緩存添加一條記錄,用戶秒殺時先校驗這個記錄,如果賣完則直接拒絕請求。
  7. 令牌桶限流:以上步驟校驗通過後,說明這個請求是一個合法的請求,接下來使用本地令牌桶對請求進行限流處理。
  8. Redis庫存預減:秒殺開始時把商品總數提前預熱到Redis中,合法請求過來時,首先在Redis中預減庫存,預減成功後就可以返回用戶秒殺成功。
  9. 發送msg到訂單服務:當Redis預減成功後,向下遊真正處理訂單的服務發送一條下單消息,在這裏使用了最大努力通知型分佈式事務(上游服務保證自己發送消息成功,下游服務保自己證消費消息成功)。
  10. 訂單服務創建訂單/真減庫存:訂單服務收到下單消息後,處理真正的下單和減庫存操作,相對於用戶收到秒殺成功消息來說,此部操作是異步的。

2. 實現步驟

  1. 攔截器校驗登錄狀態

    創建線程內部緩存,存儲當前登錄用戶信息

    public class SessionHelper {
        /**
         * 用戶信息
         */
        private static final ThreadLocal<AccountInfoDTO> CURRENT_USER = new InheritableThreadLocal<>();
    
        public static AccountInfoDTO getUser() {
            return CURRENT_USER.get();
        }
    
        public static void setUser(AccountInfoDTO user) {
            CURRENT_USER.set(user);
        }
    
        public static void removeUser() {
            CURRENT_USER.remove();
        }
    
    }
    

    創建通用攔截器,攔截和校驗當前登錄用戶信息

    @Slf4j
    @Component
    public class SessionInterceptor implements HandlerInterceptor {
    
        @Resource
        private RedisTemplate<String, String> redisTemplate;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
            //登錄可訪問
            String accessToken = request.getHeader("access_token");
            if (StringUtils.isEmpty(accessToken)) {
                throw new BaseException("請登錄");
            }
            // TODO 在redis或賬戶中心通過access_token查詢用戶信息,並緩存到本地
            String accountJson = redisTemplate.opsForValue().get(accessToken);
            if (StringUtils.isEmpty(accountJson)) {
                throw new BaseException("請登錄");
            }
            AccountInfoDTO accountInfo = JSON.parseObject(accountJson, AccountInfoDTO.class);
            SessionHelper.setUser(accountInfo);
    
            return true;
        }
    
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            // TODO 注意,http請求結束時手動必須釋放,否則會造成內存泄漏
            SessionHelper.removeUser();
        }
    
    }
    
  2. 自定義註解限制請求頻率

    創建自定義註解和AOP類,控制用戶訪問同一個API的頻率在2s以上,在Controller層的API方法上面添加限流注解

    /**
     * 防止重複提交標記註解
     */
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface NoRepeatSubmit {
    }
    
    /**
     * 限流注解AOP類
     */
    @Slf4j
    @Aspect
    @Component
    public class NoRepeatSubmitAop {
    
        private final Cache<String, Boolean> limiterCache = CacheBuilder.newBuilder().maximumSize(100000)
                .expireAfterWrite(2000, TimeUnit.MILLISECONDS).build();
    
        @Before("@annotation(NoRepeatSubmit)")
        public void before(JoinPoint point) {
            try {
                ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                        .getRequestAttributes();
                if (attributes == null) {
                    throw new BaseException("訪問異常");
                }
                if (SessionHelper.getUser() == null) {
                    throw new BaseException("請登錄");
                }
                NoRepeatSubmit noRepeatSubmit = ((MethodSignature) point.getSignature()).getMethod()
                        .getAnnotation(NoRepeatSubmit.class);
                if (noRepeatSubmit == null) {
                    return;
                }
    
                String key = SessionHelper.getUser().getUserId() + "_" + attributes.getRequest().getServletPath();
                // 如果緩存中有這個url視爲重複提交
                if (limiterCache.getIfPresent(key) == null) {
                    limiterCache.put(key, true);
                } else {
                    throw new BaseException("訪問太頻繁");
                }
            } catch (BaseException ex) {
                throw ex;
            } catch (Throwable ex) {
                throw new BaseException(ex);
            }
        }
    }
    
        @NoRepeatSubmit
        @PostMapping("/{path}/seckill")
        public ResultInfo<Void> seckill(@PathVariable String path,
                                        @RequestBody @Valid SeckillVO data) throws BaseException{}
    
  3. 添加秒殺Controller類,並添加兩個API,獲取秒殺鏈接和秒殺

    注:示例中所有的緩存都放在了本地,實際生產環境中一般都是集羣部署,需要把緩存移到Redis中。

    @Slf4j
    @RestController
    @RequestMapping("/v1/seckill")
    public class SeckillController {
    
        private static volatile long SeckillEndTime = 0L;
        /**
         * 商品庫存本地緩存
         */
        private static final AtomicLong GOODS_STOCK = new AtomicLong(1000L);
        /**
         * 商品賣完狀態本地緩存
         */
        private static volatile boolean GOODS_OVER = false;
        /**
         * 用戶重複秒殺本地緩存
         */
        private static final Map<Long, Boolean> USER_REPEAT_MAP = new ConcurrentHashMap<>();
        /**
         * 用戶秒殺鏈接加鹽本地緩存
         */
        private static final Map<Long, String> USER_PATH_MAP = new ConcurrentHashMap<>();
        /**
         * 令牌桶
         */
        private static final RateLimiter RATE_LIMITER = RateLimiter.create(100);
    
        @NoRepeatSubmit
        @ApiOperation(value = "秒殺")
        @PostMapping("/{path}/seckill")
        public ResultInfo<Void> seckill(@PathVariable String path,
                                        @RequestBody @Valid SeckillVO data) throws BaseException {
            long startTime = System.currentTimeMillis();
            // 秒殺是否結束
            if (startTime > SeckillEndTime) {
                return ResultInfo.fail("秒殺活動已經結束");
            }
    
            // 秒殺鏈接加鹽校驗
            if (!USER_PATH_MAP.containsKey(SessionHelper.getUser().getUserId())
                    || !USER_PATH_MAP.get(SessionHelper.getUser().getUserId()).equalsIgnoreCase(path)) {
                return ResultInfo.fail(String.format("非法請求:userId=%s", SessionHelper.getUser().getUserId()));
            }
    
            // 重複秒殺校驗
            if (USER_REPEAT_MAP.containsKey(SessionHelper.getUser().getUserId())) {
                return ResultInfo.fail(String.format("重複購買:userId=%s", SessionHelper.getUser().getUserId()));
            }
    
            // 本地緩存商品賣完校驗
            if (GOODS_OVER) {
                return ResultInfo.fail(String.format("商品售完:userId=%s", SessionHelper.getUser().getUserId()));
            }
    
            // 令牌桶限流
            if (!RATE_LIMITER.tryAcquire()) {
                return ResultInfo.fail(String.format("請重新排隊:userId=%s", SessionHelper.getUser().getUserId()));
            }
    
            // Redis庫存預減
            long goodsStock = GOODS_STOCK.getAndDecrement();
            if (goodsStock <= 0L) {
                GOODS_OVER = true;
                return ResultInfo.fail(String.format("商品售完:userId=%s", SessionHelper.getUser().getUserId()));
            } else {
                USER_REPEAT_MAP.put(SessionHelper.getUser().getUserId(), true);
                // TODO 模擬發送mq消息,發送msg到訂單服務,訂單服務創建訂單/真減庫存
                log.info("下單成功:userId={},goodsStock={}", SessionHelper.getUser().getUserId(), goodsStock);
            }
            return ResultInfo.success();
        }
    
        @NoRepeatSubmit
        @ApiOperation(value = "獲取秒殺鏈接")
        @GetMapping("/getPath")
        public ResultInfo<String> getPath() {
            String path = UUID.randomUUID().toString().toLowerCase().replaceAll("-", "");
            USER_PATH_MAP.put(SessionHelper.getUser().getUserId(), path);
            return ResultInfo.success(path);
        }
    }
    
  4. 壓測

    測試1000個人搶購100個商品,平均每個人連續刷新10次。

    @Slf4j
    public class SeckillMockApp {
        private static final ThreadPoolExecutor threadPoolExecutor
                = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors()
                , Runtime.getRuntime().availableProcessors()*2
                , 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10000));
        private static final String host = "localhost:80";
    
        public static void main(String[] args) throws InterruptedException, IOException {
    
            CountDownLatch countDownLatch = new CountDownLatch(10000);
            long startTime = System.currentTimeMillis();
            int count = 0;
            while (count < 10000) {
                count++;
                int finalCount = 1 + count % 1000;
                int finalCount1 = count;
                threadPoolExecutor.execute(() -> {
                    try {
                        seckill(finalCount, finalCount1);
                        countDownLatch.countDown();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                });
            }
    
            countDownLatch.await();
            log.info("YCYC:Seckill Over,time {} ms", (System.currentTimeMillis() - startTime));
        }
    
        private static void seckill(int userId, int count) throws IOException {
            long startTime = System.currentTimeMillis();
            String getResult = HttpHelper.get("http://" + host + "/YcService/v1/seckill/getPath",
                    new HashMap<String, String>() {{
                        put("X-Account-Info", "{\"userId\":" + userId + "}");
                        put("X-Biz-Id", "koneapp");
                        put("X-Request-From", "External");
                    }}, null);
            JSONObject getObj = JSON.parseObject(getResult);
            String path = getObj.getString("biz");
    
            JSONObject body = new JSONObject();
            body.put("goodsId", 10000);
            String postResult = HttpHelper.post("http://" + host + "/YcService/v1/seckill/" + path + "/seckill",
                    new HashMap<String, String>() {{
                        put("access_token", "abcd");
                    }}, null, body);
            JSONObject postObj = JSON.parseObject(postResult);
            String code = postObj.getString("code");
            if (code.equalsIgnoreCase("000000")) {
                log.info("YCYC:order success:userId={},count={},timeTaken={}", userId, count, System.currentTimeMillis() - startTime);
            } else {
                log.info("YCYC:{}:userId={},count={},timeTaken={}", postObj.getString("desc"), userId, count, System.currentTimeMillis() - startTime);
            }
    
        }
    }
    
    17:48:05.780 [pool-1-thread-7] INFO org.yc.test.http.SeckillMockApp - YCYC:重複購買:userId=997:userId=997,count=9996,timeTaken=28
    17:48:05.782 [pool-1-thread-5] INFO org.yc.test.http.SeckillMockApp - YCYC:重複購買:userId=998:userId=998,count=9997,timeTaken=21
    17:48:05.783 [pool-1-thread-8] INFO org.yc.test.http.SeckillMockApp - YCYC:重複購買:userId=999:userId=999,count=9998,timeTaken=20
    17:48:05.787 [pool-1-thread-3] INFO org.yc.test.http.SeckillMockApp - YCYC:重複購買:userId=1000:userId=1000,count=9999,timeTaken=23
    17:48:05.788 [pool-1-thread-6] INFO org.yc.test.http.SeckillMockApp - YCYC:重複購買:userId=1:userId=1,count=10000,timeTaken=21
    17:48:05.788 [main] INFO org.yc.test.http.SeckillMockApp - YCYC:Seckill Over,time 46130 ms
    

    在普通電腦上,10000個請求在46130ms內處理完成,平均QPS在216左右,性能不算太好,沒有在服務器上測試過,下次再試。

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