公众号merlinsea
- 背景
- 以优惠券扣减为例,当用户使用购物的时候可以使用优惠券来抵消一部分金额,不过存在如下可能情况,用户可能在pc端,app端同时下单而且同时使用优惠券,如果用户有多个优惠券,那自然是可以使用的,但倘若用户只有一个优惠券的话,那么就会存在优惠券不足扣减优惠券失败的情况,即在高并发场景下可能存在扣减到优惠券数量已经为负数的情况。
- 代码实现
- 核心service层的代码
/** * 领取优惠券 * 1、查询是否有这个优惠券 * 2、校验优惠券是否可以领取 时间,库存,是否超领 * 3、扣减库存 * 4、保存记录 * @param couponId * @param category * @return */ @Override public JsonData addCoupon(long couponId, CouponCategoryEnum category) { LoginUser loginUser = LoginInterceptor.threadLocal.get(); //查询coupon CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>() .eq("id",couponId) .eq("category",category.name())); //校验coupon this.check(couponDO,loginUser.getId()); //构建优惠券记录 CouponRecordDO couponRecordDO = new CouponRecordDO(); BeanUtils.copyProperties(couponDO,couponRecordDO); couponRecordDO.setUserId(loginUser.getId()); couponRecordDO.setCreateTime(new Date()); couponRecordDO.setUseState(CouponCategoryEnum.NEW_USER.name()); couponRecordDO.setCouponId(couponId); couponRecordDO.setUserName(loginUser.getName()); //扣减库存 TODO int rows = couponMapper.reduceStock(couponId); if(rows == 1){ couponRecordMapper.insert(couponRecordDO); }else{ //扣减库存失败 log.info("扣减库存失败,用户id:{},优惠券id:{}",loginUser.getId(),couponId); throw new BizException(BizCodeEnum.COUPON_NO_STOCK); } return JsonData.buildSuccess(); } private void check(CouponDO couponDO,long userId) { //能否查到这个优惠券 if (couponDO == null) { throw new BizException(BizCodeEnum.COUPON_NO_EXIST); } //库存是否足够 if (couponDO.getStock() <= 0) { throw new BizException(BizCodeEnum.COUPON_NO_STOCK); } //是否是发布状态 if (!couponDO.getPublish().equals(CouponPublishEnum.PUBLISH.name())) { throw new BizException(BizCodeEnum.COUPON_GET_FAIL); } //是否在领取的时间 long time = CommonUtil.getCurrentTimestamp(); long start = couponDO.getStartTime().getTime(); long end = couponDO.getEndTime().getTime(); if (time > end || time < start) { throw new BizException(BizCodeEnum.COUPON_OUT_OF_TIME); } //用户是否超出限制 int num = couponRecordMapper.selectCount(new QueryWrapper<CouponRecordDO>() .eq("coupon_id", couponDO.getId()) .eq("user_id", userId)); if (num >= couponDO.getUserLimit()) { throw new BizException(BizCodeEnum.COUPON_OUT_OF_LIMIT); } }
- 扣减库存xml中的sql语句
<update id="reduceStock"> update coupon set stock = stock-1 where id=#{couponId} </update>
- 存在的问题
- 只要请求到达数据库层就会执行库存减1,但显然当库存为0的时候是不能减1的,因此存在超发优惠券的问题。
- 为什么会存在上面的问题呢?
- 在上面这个xml中的sql语句和service层的业务逻辑中,可以发现只要用于的请求通过了check()逻辑,那么就一定会执行扣减库存的sql语句,但是由于check逻辑是非原子操作,因此在高并发的场景下很多线程会同时通过check逻辑,然后多个线程都会执行接下来的sql扣减库存语句导致优惠券超发问题!!
- 扣减券过程的时序图
- 解决方法1:同步代码块synchronized ,lock (集群部署情况下会失效)
- 这种方法在高并发场景下的实际工作中很少使用,因为syncronized和lock机制只能作用在一个jvm实例上(即本地锁),后续项目集群部署的话依旧不能解决优惠券超发问题!!!
- 这种方法核心思路是把校验逻辑和实际扣减券的操作放在一个同步代码块中。
public synchronized void reduceCouponStock(long couponId ,Integer num) { // 校验操作 // 扣减操作 }
- 解决方法2:分布式锁zookeeper或者redis
- 分布式锁:有一个独立的服务器单独存放redis,每当一个节点要访问数据库时,首先去redis看看锁是否被其他节点获取,如果没有获取,那么就把锁置为已经获取的状态然后再去操作数据库,否则不能操作数据库。
- 存在的问题是:过于笨重,虽然解决了高并发场景下数据一致性的问题,但是本来直接访问数据库就完事,现在需要先访问redis看看这个集中管理的锁是否空闲再决定是否访问数据库。
- 解决方法3:通过数据库本身来控制(添加更多where条件)推荐!!!
- 因为数据库有行级锁,我们在扣减库存的时候需要保证库存大于0才可以扣减库存。这样也保证了数据的一致性
- 和之前的比较区别在于:现在是当有多个扣减库存的请求到达数据库层面时,由于行级锁的存在,必须一个一个来执行这条sql语句,如果之前请求扣减完库存以后库存小于0了,那么后续的阻塞于这一行的请求就不会扣减成功。 但之前的sql语句可以发现,只要请求到达了数据库这一层,那么就一定都会扣减库存。
<update id="reduceStock"> update coupon set stock = stock-1 where id=#{couponId} and stock>0 </update>
- 总结
- 在操作数据库的时候,一定要注意我们这个操作是否符合实际情况,即如果任意一个请求到达这一步的时候,都可以执行这个操作,那么就可能存在不符合实际预期的情况,尤其是像这种扣减库存,是否会扣减到负值的情况的场景。