优惠券服务的迭代

发布时间 2023-10-09 00:27:01作者: 冷扑星

优惠券服务真的是个多灾多难的发展史:

中间出现了好多的问题

首先是领取优惠券的流程上:

1.用户进来,查看优惠券库存是否足够,

2.检查是否重复领取

3.加锁领取优惠券并且记录在数据库上

4.最后释放锁。

伪代码:

//1.用户进来,查看优惠券库存是否足够,
checkInventory(couponId);
//2.检查是否重复领取
checkRepeatReceive(userId,couponId);
synchronized (couponId){
//3.加锁领取优惠券并且记录在数据库上
    receiveCoupon(userId,couponId);
}

当时也没太多的高并发经验,在现在看来这段代码就无法解决2的问题,就用户在短时间内请求多次接口的时候,领取的记录来不及记录到数据库上,那么就会出现用户多次领取优惠券的情况发生。当然当时我在数据库里面有个保底的措施就是设置了userId 和 couponId的唯一索引,所以用户重复领取的现象没有出现。

但是在运营过程中,其实还是发生了一些的事情,就是针对于代码的完善下考虑的其实不够

具体的问题表现:

1.单用户的超领

这个就是前面提到的问题,虽然我用数据库层面做了个保底措施,但是这种措施其实不好,这种的措施是基于数据库异常来实现的,这个问题其实发生的原因可以说是操作的幂等性,于是基于此,我用拦截器的形式做了个幂等性的校验,在preHandle 中把用户的userId 和 requestURI 作为 key 和value 保存到map 里面,在校验幂等方法checkRepertClick()中根据userId 来获取值,如果有值并且值和requestURI 一样那就代表了多次点击,同时在afterCompletion 删除用户数据

public class UserInterceptor implements HandlerInterceptor {
    private static final String AUTHORIZATION = "accessToken";
    private Map<String, String> userRequestMap = new HashMap();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String authorization = request.getHeader(AUTHORIZATION);
        TokenUser tokenUser = null;
        try {
            tokenUser = parseToken(authorization);
        } catch (Exception e) {
            throw new BusinessException(ApiConsts.LOGIN_ERROR, "token 解析错误");
        }
        String requestURI = request.getRequestURI();
        UserUtils.setUser(tokenUser);
        checkRepertClick(tokenUser.getUnionId(),requestURI);
        return true;
    }
    private void checkRepertClick(String unionId, String requestURI) {
        String value = userRequestMap.get(unionId);
        if (StrUtil.isNotBlank(value)&&value.equals(requestURI)){
            throw new BusinessException("请勿多次点击");
        }
        userRequestMap.put(unionId,requestURI);
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        userRequestMap.remove(UserUtils.getUserUnionId());
        UserUtils.clearUser();
    }
}

ps,在现在看来其实这代码确实还有了很多的问题,就打个比方,用户在极短的时间内,先访问A接口,此时在幂等的map中的数据就是(userA,requestA),然后又访问了B接口,那么此刻map被覆盖成为(userA,requestB),然后紧接着又访问了A接口,于是又变成了(userA,requestA) ,但是这样子就避开了幂等的校验,所以还存在着这个漏洞

2.用户撞库领取

在领取日志上我们发现有部分未上架的优惠券结果却有领取的记录,

打个比方,运营配置了id =1/2/3 3种优惠券,但是id=2 的优惠券还没到领取日期,结果在日志中却有用户在尝试领取,后续我们判断,有用户通过撞库的方式在领取优惠券,因为能通过抓包的方式来获取优惠券id 于是就有用户通过写程序,并且通过暴露的优惠券id的范围,来进行遍历id 获取,这边的话会有2个问题,其中之一因为本身优惠券不存在或者已下架下,那么用户的请求就会打穿redis 从数据库里面查询,再加上又是通过程序的方式,对数据库造成很大的压力,于是我们就用Redis 缓存穿透的解决方式来进行解决这个问题,就是我们会在redis 中缓存一份有效优惠券id,假如查询数据不在这里面,那就直接结束此次请求,通过为了防止这种情况,我们也在后续的更新过程中限制了所有优惠券的每日领取次数,具体的方式是通过redis 计数器的形式来进行实现的,也就是通过在redis 里面维持一组数据,key 为用户 id value 则是数字,通过redis 的increment 来进行增加,因为这个increment 是原子性操作,

3.优惠券异步领取

这个功能是很后面的功能了,而且也不是在一个项目里面

因为在领取优惠券的时候我们做了很多的限制,所以导致他的响应速度比较慢。因此我们在校验通过了之后就开始异步领取了优惠券,这样做就是为了提高领券的响应速度,但是会出现一个问题,就是用户领取了优惠券后立刻返回查看优惠券此刻优惠券的异步派发还没有完成,因此后续做了兜底的措施,就是为了这种情况专门配置了个快速的通道,在这个通道下,发券的操作改成了同步操作。这样,在页面检测到用户领取优惠券后立刻返回商品页面后,前端就会调用这个快速通道接口,然后我们就会提前处理该领券动作