转载

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左右,性能不算太好,没有在服务器上测试过,下次再试。

{{o.name}}
{{m.name}}
  • 转载自:https://my.oschina.net/u/2378709/blog/5479825
  • 留言