在拼夕夕面试中,面试官问了一连串经典的问题:“优惠券库存是怎么扣减的?开发为了解决超发优惠券问题而设计的方案,你了解过吗?你又是如何测试的呢?”
当时听到这些问题还挺懵的,没遇到过超发问题啊?开发设计的方案我怎么知道?现在想起来还挺幼稚的,其实现在想想电商中有很多类似的问题,比如商品超卖,归根究底,就是一个问题,那就是并发安全问题。
问题引入
就拿领取优惠券的问题来说,
需求描述:A 优惠券一共发行 100张,每一个用户最多可以领取5张。
当一个用户领取优惠券成功的时候,把领取的记录写入另外一个表中(这张表我们暂且称为表 B)。
在领取优惠券的过程中,优惠券库存的扣减过程,一般操作如下:
1、select查询优惠券的库存。
2、计算优惠券库存是否足够,如果优惠券存库不足则抛出库存不足的异常,如果优惠券库存足够,则判断是否在领取时间、判断用户领取数量是否超过个人最高领取限制。
3、如果2成立,则减去扣除的库存得到最新的库存剩余值。
4、set设置最新的优惠券库存剩余值
伪代码如下:
扣减优惠券sql如下:
update coupon set stock = stock - 1 where id = #{coupon_id}
并发量比较低的时候,几乎看不出来有问题,可是当我们开启多线程,去请求这个抢优惠券的接口时,问题出现了,id为19的这个优惠券库存为负数。多发了一个,什么原因呢?
深入解读并发安全问题
为啥并发量高的时候会出现优惠券库存多发的问题呢?原因如下截图:
上图中出现问题的环节其实是判断优惠券库存那个步骤,重点来了:
高并发情况下,如果同时来了两个线程线程 A和线程 B(可以理解成是两个请求),比如先来的那个线程A请求通过了检查,这时线程 A 还没有扣减库存,这时经过一番操作,线程B也通过了这个检查优惠券是否可领取的方法,然后线程 A 和线程 B 依次扣减库存或者是同时扣减库存。所以就出现了刚刚数据库出现的现象,优惠券库存为-1个,就像下图。
怎么解决并发安全问题?
Java 代码加锁
synchronized (this){ LoginUser loginUser = LoginInterceptor.threadLocal.get(); CouponDO couponDO = couponMapper.selectOne(new QueryWrapper() .eq("id", couponId) .eq("category", categoryEnum.name())); if(couponDO == null){ throw new BizException(BizCodeEnum.COUPON_NO_EXITS); } this.checkCoupon(couponDO,loginUser.getId()); //构建领券记录 CouponRecordDO couponRecordDO = new CouponRecordDO(); BeanUtils.copyProperties(couponDO,couponRecordDO); couponRecordDO.setCreateTime(new Date()); couponRecordDO.setUseState(CouponStateEnum.NEW.name()); couponRecordDO.setUserId(loginUser.getId()); couponRecordDO.setUserName(loginUser.getName()); couponRecordDO.setCouponId(couponDO.getId()); couponRecordDO.setId(null); int row = couponMapper.reduceStock(couponId); if(row == 1){ couponRecordMapper.insert(couponRecordDO); }else{ log.info("发送优惠券失败:{},用户:{}",couponDO,loginUser); } }
加个synchronized关键字,这样每个请求都得排队执行这个扣减库存操作,可以一定程度解决并发安全问题,但由于synchronized关键字基于jvm级别加锁,当集群环境下,有多个jvm进程,所以这种方法仅适用于单机节点。
Sql版本号
update product set stock=stock-1 where stock=#{上一次的库存} and id = #{id} and stock>0
这种方法有个ABA的问题,我们可以加个version字段,每次修改数据的时候这个字段会加 1,这样就可以避免 ABA 问题。但是这种依靠数据库进行并发安全保障,会消耗数据库的资源,一定请求量内(需经过严格测试)可使用。
update product set stock=stock-1,versioin = version+1 where #{id} and stock>0 and version=#{上一次的版本号}
Redis分布式锁
引入 Redis 后,当领取优惠券时会先去 Redis 里面去获取锁,当锁获取成功后才可以对数据库进行操作。
在分布式锁中我们应该考虑如下:
- 排他性,在分布式集群中,同一个方法,在同一个时间只能被某一台机器上的一个线程执行;
- 容错性,当一个线程上锁后,如果机器突然的宕机,如果不释放锁,此时这条数据将会被锁死;
- 还要注意锁的粒度,锁的开销;
- 满足高可用,高性能,可重入。
伪代码如下:
@RestController public class IndexController { public static final String REDIS_LOCK = "coupon_lock"; @Autowired StringRedisTemplate template; @RequestMapping("/getCoupon") public String getCoupon(){ // 每个人进来先要进行加锁,key值为"good_lock" String value = UUID.randomUUID().toString().replace("-",""); try{ // 为key加一个过期时间 Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS); // 加锁失败 if(!flag){ return "抢锁失败!"; } System.out.println( value+ " 抢锁成功"); String result = template.opsForValue().get("coupon:001"); int total = result == null ? 0 : Integer.parseInt(result); if (total > 0) { // 在此处需要处理抢购优惠券业务,处理时间较长。。。 int realTotal = total - 1; template.opsForValue().set("coupon:001", String.valueOf(realTotal)); System.out.println("获取优惠券成功,库存还剩:" + realTotal + "件, 服务端口为8001"); return "获取优惠券成功,库存还剩:" + realTotal + "件, 服务端口为8001"; } else { System.out.println("获取优惠券失败,服务端口为8001"); } return "获取优惠券失败,服务端口为8001"; }finally { // 谁加的锁,谁才能删除,使用Lua脚本,进行锁的删除 Jedis jedis = null; try{ jedis = RedisUtils.getJedis(); String script = "if redis.call('get',KEYS[1]) == ARGV[1] " + "then " + "return redis.call('del',KEYS[1]) " + "else " + " return 0 " + "end"; Object eval = jedis.eval(script, Collections.singletonList(REDIS_LOCK), Collections.singletonList(value)); if("1".equals(eval.toString())){ System.out.println("-----del redis lock ok...."); }else{ System.out.println("-----del redis lock error ...."); } }catch (Exception e){ }finally { if(null != jedis){ jedis.close(); } } } } }
Redission 红锁