对于我们电商的购物系统来说,每逢大促或者类似秒杀场景的时候,若是活动异常火爆,我们的系统就得抗住所有来之外部所有浪涌似的压力,若是没有保护措施,我们的系统很难保证不被冲垮。这个时候我们就需要在活动之前提前对我们的系统做好保护措施。
下面我们围绕其中的一点:【下单】操作做一个安全保护。
其实我们的系统之前对于商品的查看浏览,热点商品的访问等都做了优化,例如之前提到的本地热点缓存+redis缓存,从而大大提高我们系统的QPS。但是对于【下单】这种操作,是真正执行数据库写的,相当于系统来说才是最耗费性能的一部分。
因为直接执行数据库,所以对于mysql数据库的性能耗费比较高。针对这一块我先提供一个简单的思路,毕竟我们这一小节讲的是大流量情况下的削峰保护措施,重点不在于此。
针对下单减库存,我们其实可以将库存信息提前存入redis,也就是比方我们要做一个大促活动,活动开始之前将活动的商品提前通过发布的方式将库存存入redis。当用户下单的时候直接操作redis中的库存,避免直接操作mysql数据库带来的性能消耗。而我们redis的库存可以通过异步消息的方式与mysql数据库的库存进行数据一致性的更新,为了保证数据的一致性,我们可以使用rocketmq的事务性消息,具体细节不再多说。
好了上面我们解决了库存下单带来的数据库层面的压力,现在看一看,若当用户在知道了自己的登陆信息(包括登陆的token,这个token相当于用户下单的凭证)之后去利用脚本频繁刷我们的下单接口该如何应对?
这里就需要引入我们的令牌了。
1)使用令牌
当我们的用户登陆系统成功之后,会根据登陆凭证token去调用生成秒杀令牌的方法生成令牌并存入redis当中,然后我们的用户依靠生成的临牌才允许进行参与互动,参与活动必须获得此令牌。下面是前端用户请求逻辑:
$.ajax({
type:"POST",
url:"http://myserver/order/generatetoken?token="+token,
data:{},
success:function(data){
if(data.status == "success"){
var promoToken = data.data;
$.ajax({
type:"POST",
url:"http://myserver/order/createorder?token="+token,
data:{
"itemId":g_itemVO.id,
"amount":1,
"promoId":g_itemVO.promoId,
"promoToken":promoToken
}
.....
然后下面是我们生成令牌的方法逻辑:
public String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId) {
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
PromoModel promoModel = convertFromDataObject(promoDO);
if(promoModel == null){
return null;
}
//判断当前时间是否秒杀活动即将开始或正在进行
if(promoModel.getStartDate().isAfterNow()){
promoModel.setStatus(1);
}else if(promoModel.getEndDate().isBeforeNow()){
promoModel.setStatus(3);
}else{
promoModel.setStatus(2);
}
//判断活动是否正在进行
if(promoModel.getStatus().intValue() != 2){
return null;
}
//判断item信息是否存在
ItemModel itemModel = itemService.getItemByIdInCache(itemId);
if(itemModel == null){
return null;
}
//判断用户信息是否存在
UserModel userModel = userService.getUserByIdInCache(userId);
if(userModel == null){
return null;
}
//生成token并且存入redis内并给一个5分钟的有效期
String token = UUID.randomUUID().toString().replace("-","");
redisTemplate.opsForValue().set("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,token);
redisTemplate.expire("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,5, TimeUnit.MINUTES);
return token;
}
我们将之前下单之前的风控校验都前置到了这令牌的生成当中,例如活动的校验,是否正在进行,商品信息校验,用户身份校验等等,一切通过之后我们生成令牌,这里通过UUID的方式。令牌key 的维度使用了活动ID+用户ID+商品ID,并设置了过期时间,最后存入我们的redis。
然后当我们用户调用下单操作的时候,回去校验传入的令牌是否与之前存入的redis中的令牌一致,若一致真正执行下单操作
//校验秒杀令牌是否正确
if(promoId != null){
String inRedisPromoToken = (String) redisTemplate.opsForValue().get("promo_token_"+promoId+"_userid_"+userModel.getId()+"_itemid_"+itemId);
if(inRedisPromoToken == null){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"令牌校验失败");
}
if(!org.apache.commons.lang3.StringUtils.equals(promoToken,inRedisPromoToken)){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"令牌校验失败");
}
}
//执行真正的下单操作
....
抛出缺陷:令牌无限制生成,随着合法登陆用户的下单,无限制生成,对系统的性能也是有损耗的,这时候就需要大闸来进行控制了
2)使用大闸
原理很简单,就是我们起初会根据库存的数量预置一个大闸的数量,例如库存100,我们的大闸就设置为100*4,这样我们的系统最多可以生成400个令牌,就不会无限制的生成了。
//将库存同步到redis内
redisTemplate.opsForValue().set("promo_item_stock_"+itemModel.getId(), itemModel.getStock());
//将大闸的限制数字设到redis内
redisTemplate.opsForValue().set("promo_door_count_"+promoId,itemModel.getStock().intValue() * 4);
当我们发布活动将库存同步到redis的时候也一同将大闸的数量写入redis。然后在生成令牌的时候在做一个判断,若是大闸数量还有,便可以继续生成令牌,若是没有则返回null。
//获取秒杀大闸的count数量
long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
if(result < 0){
return null;
}
//生成token并且存入redis内并给一个5分钟的有效期
String token = UUID.randomUUID().toString().replace("-","");
3)队列泄洪
当我们面对多商品多库存的情况下,其实我们上面的令牌以及大闸都是不能进行有效的保护的,比如我们的活动比较大型,活动商品几万件,甚至几十万件,面对这么多商品去生成令牌访问我们的系统的话无疑也是一种很大的并发量。为了我们系统不被冲垮,我们可以使用队列的方式泄掉大的流量。就比方说,我们系统每秒只能处理20个下单请求,若是突然来100个,肯定是处理不过来的,我们使用队列使其进行排队,从而泄掉这种大的流量,而排队的下游我们使用拥塞窗口的机制原理来处理请求。
这里我们使用线程池的方式,部分代码如下:
private ExecutorService executorService;
@PostConstruct
public void init(){
executorService = Executors.newFixedThreadPool(20);
}
......
//同步调用线程池的submit方法
//拥塞窗口为20的等待队列,用来队列化泄洪
Future
这样就达到了我们泄洪的目的。
其实针对大量访问的并发流量问题,防止恶意刷单的情况,我们在下单的时候还可以借助人工识别二维码的方式,这样也能进一步保护我们的接口和系统