1. 程式人生 > >Java-秒殺系統的設計

Java-秒殺系統的設計

Java-秒殺系統的設計

1 緣起

經常看到 某寶, 某東, 還有各種平臺的秒殺 活動, 覺得很想學習一下秒殺技術,也順便學習在 在高併發下系統的設計,於是學習了慕課網的秒殺教程。 這裡寫部落格記錄一下。

2 思路 & 實現

2.1 資料庫

因為秒殺商品的經常變動所以設計了

  • 秒殺商品表
CREATE TABLE `miaosha_goods`
( `id` bigint(20) NOT NULL, `goods_id` bigint(20) NOT NULL, `miaosha_price` decimal(10,2) NOT NULL, `stock_count` int(11) NOT NULL, `start_date` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP, `end_date` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT
CHARSET=utf8;

  • 秒殺訂單表
CREATE TABLE `miaosha_order` (
  `id` bigint(20) NOT NULL,
  `user_id` bigint(20) NOT NULL,
  `order_id` bigint(20) NOT NULL,
  `goods_id` bigint(20) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.2 前端

2.2.1 前後端分離

這裡不推薦後端模板引擎的原因是因為 模板需要後端渲染 , 生成頁面,即使有快取服務端的壓力也過大。

2.2.2 儘量的快取前端 頁面,壓縮js

主要手段:1 cdn , 2 nginx 的快取,3 使用 壓縮後的js ,4 開啟 g-zip


2.3 服務端介面

2.3.1 物件快取

通過 redis 快取秒殺商品列表頁, 和詳情頁的資料 ,查詢出資料後交給前端模板引擎渲染

2.3.2 redis 預讀庫存 (重要)

  • 1 啟動啟動的時候 將需要秒殺商品的庫存讀入 redis 快取,並且放入 秒殺是否結束的標誌
    1 實現 InitializingBean 介面 重寫 afterPropertiesSet 方法(在預設構造方法執行完之後執行)


    /**
     * 系統初始化
     * */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> goodsList = goodsService.listGoodsVo();
        if(goodsList == null) {
            return;
        }
        for(GoodsVo goods : goodsList) {
            redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
            // 如果是分散式系統 可以交給 redis 去做
            localOverMap.put(goods.getId(), false);
        }
    }
  • 2 使用者下單的時候 decr 減少 redis 中的庫存(原子性操作) , 如果庫存不足,則返回秒殺失敗

2.3.3 使用 rabbitMq 進行非同步下單

1 構建秒殺訊息並且通過 mq 傳送, 然後同步返回 排隊中

@Autowired
MQSender sender;

... 省略業務程式碼

MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排隊中

1.1 傳送者的簡單實現

@Service
public class MQSender {

    @Autowired
    AmqpTemplate amqpTemplate ;

    public void sendMiaoshaMessage(MiaoshaMessage mm) {
        String msg = RedisService.beanToString(mm);
        log.info("send message:"+msg);
        amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
    }
}

2 使用 定義 reciver 處理訊息(監聽指定佇列, 接收訊息)

@RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
public void receive(String message) {
    log.info("receive message:"+message);
    MiaoshaMessage mm  = RedisService.stringToBean(message, MiaoshaMessage.class);
    MiaoshaUser user = mm.getUser();
    long goodsId = mm.getGoodsId();

    GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    int stock = goods.getStockCount();
    if(stock <= 0) {
        return;
    }
    //判斷是否已經秒殺到了
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    if(order != null) {
        return;
    }
    //減庫存 下訂單 寫入秒殺訂單
    miaoshaService.miaosha(user, goods);
}

3 使用 事物 保證 秒殺操作的資料一致性

這裡預設的隔離級別,使用了 行鎖(獨佔鎖) 保證庫存不會賣超

@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
    //減庫存 下訂單 寫入秒殺訂單, 
    boolean success = goodsService.reduceStock(goods);
    if(success) {
        //order_info maiosha_order
        return orderService.createOrder(user, goods);
    }else {
        setGoodsOver(goods.getId());
        return null;
    }
}

4 客戶端的處理

客戶端 接收到秒殺介面的返回後,判斷是否成功, 如果失敗 直接提示給使用者 秒殺失敗 如果返回排隊中, 則呼叫查詢介面查詢秒殺結果

public long getMiaoshaResult(Long userId, long goodsId) {
    // 通過 redis 查詢 該使用者是否秒殺了指定產品
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
    if(order != null) {//秒殺成功
        return order.getOrderId();
    }else {
        // 獲取該商品是否秒殺完的記憶體標記, 建議使用 redis
        boolean isOver = getGoodsOver(goodsId);
        if(isOver) {
            return -1;
        }else {
            // 返回處理中 客戶端隔一段時間以後繼續發起查詢
            return 0;
        }
    }
}

2.4 其他優化手段

2.4.1 秒殺驗證碼

通過驗證碼 可以有效分散使用者請求,大大降低系統瞬間的併發, 大概思路就是 建立一個驗證碼圖片,寫給客戶端,並且在服務端儲存結果

@RequestMapping(value="/verifyCode", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaVerifyCod(HttpServletResponse response,MiaoshaUser user,
        @RequestParam("goodsId")long goodsId) {
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    try {
        BufferedImage image  = miaoshaService.createVerifyCode(user, goodsId);
        OutputStream out = response.getOutputStream();
        ImageIO.write(image, "JPEG", out);
        out.flush();
        out.close();
        return null;
    }catch(Exception e) {
        e.printStackTrace();
        return Result.error(CodeMsg.MIAOSHA_FAIL);
    }
}

2.4.2 隱藏秒殺地址

通過動態的秒殺地址,並且在商品開始秒殺之前 無法獲取, 增加別人的破解難度

@AccessLimit(seconds=5, maxCount=5, needLogin=true)
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
        @RequestParam("goodsId")long goodsId,
        @RequestParam(value="verifyCode", defaultValue="0")int verifyCode
        ) {
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
    if(!check) {
        return Result.error(CodeMsg.REQUEST_ILLEGAL);
    }
    String path  =miaoshaService.createMiaoshaPath(user, goodsId);
    return Result.success(path);
}

2.4.3 通過自定義註解限流

  • 1 定義註解 @AccessLimit(seconds=5, maxCount=5, needLogin=true)
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
    int seconds();
    int maxCount();
    boolean needLogin() default true;
}

-2 通過 ThreadLocal 來保證每一個執行緒 持有一個秒殺物件

public class UserContext {

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

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

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

}
  • 3 通過 HandlerInterceptorAdapter 實現介面的限流

大概思路 通過方法攔截,獲取方法上面的自定義註解, 然後 根據業務邏輯 自定義限流規則

@Service
public class AccessInterceptor  extends HandlerInterceptorAdapter{

    @Autowired
    MiaoshaUserService userService;

    @Autowired
    RedisService redisService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if(handler instanceof HandlerMethod) {
            MiaoshaUser user = getUser(request, response);
            UserContext.setUser(user);
            HandlerMethod hm = (HandlerMethod)handler;
            AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
            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) {
                    render(response, CodeMsg.SESSION_ERROR);
                    return false;
                }
                key += "_" + user.getId();
            }else {
                //do nothing
            }
            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;
    }

    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();
    }

    private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {
        String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
        String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return null;
        }
        String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
        return userService.getByToken(response, token);
    }

    private String getCookieValue(HttpServletRequest request, String cookiName) {
        Cookie[]  cookies = request.getCookies();
        if(cookies == null || cookies.length <= 0){
            return null;
        }
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals(cookiName)) {
                return cookie.getValue();
            }
        }
        return null;
    }

}
  • 4 新增引數解析者
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    MiaoshaUserService userService;

    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> clazz = parameter.getParameterType();
        return clazz==MiaoshaUser.class;
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return UserContext.getUser();
    }
}
  • 5 註冊攔截器和引數解析者
@Configuration
public class WebConfig  extends WebMvcConfigurerAdapter{

    @Autowired
    UserArgumentResolver userArgumentResolver;

    @Autowired
    AccessInterceptor accessInterceptor;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(userArgumentResolver);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accessInterceptor);
    }

}

3 結束

搞完收工,如果你喜歡博主的文章的話麻煩點一下關注,如果發現博主文章的錯誤的話 麻煩指出, 謝謝大家。