SpringBoot秒殺系統設計
1. 設計方案
秒殺系統比較複雜,它一般要要求前端、後端、MySql、Nginx、Redis等聯合工作才能達到最好的效果,如衆所周知的,前端的按鈕防抖、資源靜態化,nginx的長連接優化、壓縮優化、配置緩存,MySql的樂觀鎖/悲觀鎖,Redis的數據預熱、分佈式鎖等設計。接下來讓我們刨析一下後端服務在秒殺系統中能做什麼事情。
如圖所示,我在秒殺系統中設置了10個步驟:
- 攔截器校驗登錄狀態:絕大部分情況下,秒殺接口是需要用戶登錄才能操作的,在系統通用攔截器裏進行用戶登錄校驗。
- 自定義註解限制請求頻率:正常情況下秒殺接口不被允許頻繁訪問,在秒殺接口上添加自定義註解,使用分佈式鎖的方式控制單用戶的全局訪問頻率。
- 秒殺結束校驗:全局校驗秒殺結束,結束後直接拒絕訪問。
- 秒殺鏈接加鹽校驗:當用戶在頁面中點擊秒殺按鈕時,前端需要先向後端請求秒殺鏈接,後端返回一個加鹽的連接,並在秒殺請求中進行加鹽校驗。
- 分佈式重複秒殺校驗 :當用戶秒殺成功後向Redis中添加一條成功記錄,當該用戶再次返送秒殺請求時,系統直接拒絕請求。
- 本地緩存商品賣完校驗:當商品秒殺完後,在本地緩存添加一條記錄,用戶秒殺時先校驗這個記錄,如果賣完則直接拒絕請求。
- 令牌桶限流:以上步驟校驗通過後,說明這個請求是一個合法的請求,接下來使用本地令牌桶對請求進行限流處理。
- Redis庫存預減:秒殺開始時把商品總數提前預熱到Redis中,合法請求過來時,首先在Redis中預減庫存,預減成功後就可以返回用戶秒殺成功。
- 發送msg到訂單服務:當Redis預減成功後,向下遊真正處理訂單的服務發送一條下單消息,在這裏使用了最大努力通知型分佈式事務(上游服務保證自己發送消息成功,下游服務保自己證消費消息成功)。
- 訂單服務創建訂單/真減庫存:訂單服務收到下單消息後,處理真正的下單和減庫存操作,相對於用戶收到秒殺成功消息來說,此部操作是異步的。
2. 實現步驟
-
攔截器校驗登錄狀態
創建線程內部緩存,存儲當前登錄用戶信息
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(); } }
-
自定義註解限制請求頻率
創建自定義註解和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{}
-
添加秒殺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); } }
-
壓測
測試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左右,性能不算太好,沒有在服務器上測試過,下次再試。