软件测试商城项目优惠券超发问题该怎么测试?

在拼夕夕面试中,面试官问了一连串经典的问题:“优惠券库存是怎么扣减的?开发为了解决超发优惠券问题而设计的方案,你了解过吗?你又是如何测试的呢?”

当时听到这些问题还挺懵的,没遇到过超发问题啊?开发设计的方案我怎么知道?现在想起来还挺幼稚的,其实现在想想电商中有很多类似的问题,比如商品超卖,归根究底,就是一个问题,那就是并发安全问题。

问题引入

就拿领取优惠券的问题来说,

需求描述: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 代码加锁

 
  1. synchronized (this){

  2. LoginUser loginUser = LoginInterceptor.threadLocal.get();

  3. CouponDO couponDO = couponMapper.selectOne(new QueryWrapper()

  4. .eq("id", couponId)

  5. .eq("category", categoryEnum.name()));

  6. if(couponDO == null){

  7. throw new BizException(BizCodeEnum.COUPON_NO_EXITS);

  8. }

  9. this.checkCoupon(couponDO,loginUser.getId());

  10. //构建领券记录

  11. CouponRecordDO couponRecordDO = new CouponRecordDO();

  12. BeanUtils.copyProperties(couponDO,couponRecordDO);

  13. couponRecordDO.setCreateTime(new Date());

  14. couponRecordDO.setUseState(CouponStateEnum.NEW.name());

  15. couponRecordDO.setUserId(loginUser.getId());

  16. couponRecordDO.setUserName(loginUser.getName());

  17. couponRecordDO.setCouponId(couponDO.getId());

  18. couponRecordDO.setId(null);

  19. int row = couponMapper.reduceStock(couponId);

  20. if(row == 1){

  21. couponRecordMapper.insert(couponRecordDO);

  22. }else{

  23. log.info("发送优惠券失败:{},用户:{}",couponDO,loginUser);

  24. }

  25. }

加个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 里面去获取锁,当锁获取成功后才可以对数据库进行操作。

在分布式锁中我们应该考虑如下:

排他性,在分布式集群中,同一个方法,在同一个时间只能被某一台机器上的一个线程执行;
容错性,当一个线程上锁后,如果机器突然的宕机,如果不释放锁,此时这条数据将会被锁死;
还要注意锁的粒度,锁的开销;
满足高可用,高性能,可重入。
伪代码如下:

 
  1. @RestController

  2. public class IndexController {

  3. public static final String REDIS_LOCK = "coupon_lock";

  4. @Autowired

  5. StringRedisTemplate template;

  6. @RequestMapping("/getCoupon")

  7. public String getCoupon(){

  8. // 每个人进来先要进行加锁,key值为"good_lock"

  9. String value = UUID.randomUUID().toString().replace("-","");

  10. try{

  11. // 为key加一个过期时间

  12. Boolean flag = template.opsForValue().setIfAbsent(REDIS_LOCK, value,10L,TimeUnit.SECONDS);

  13. // 加锁失败

  14. if(!flag){

  15. return "抢锁失败!";

  16. }

  17. System.out.println( value+ " 抢锁成功");

  18. String result = template.opsForValue().get("coupon:001");

  19. int total = result == null ? 0 : Integer.parseInt(result);

  20. if (total > 0) {

  21. // 在此处需要处理抢购优惠券业务,处理时间较长。。。

  22. int realTotal = total - 1;

  23. template.opsForValue().set("coupon:001", String.valueOf(realTotal));

  24. System.out.println("获取优惠券成功,库存还剩:" + realTotal + "件, 服务端口为8001");

  25. return "获取优惠券成功,库存还剩:" + realTotal + "件, 服务端口为8001";

  26. } else {

  27. System.out.println("获取优惠券失败,服务端口为8001");

  28. }

  29. return "获取优惠券失败,服务端口为8001";

  30. }finally {

  31. // 谁加的锁,谁才能删除,使用Lua脚本,进行锁的删除

  32. Jedis jedis = null;

  33. try{

  34. jedis = RedisUtils.getJedis();

  35. String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +

  36. "then " +

  37. "return redis.call('del',KEYS[1]) " +

  38. "else " +

  39. " return 0 " +

  40. "end";

  41. Object eval = jedis.eval(script, Collections.singletonList(REDIS_LOCK), Collections.singletonList(value));

  42. if("1".equals(eval.toString())){

  43. System.out.println("-----del redis lock ok....");

  44. }else{

  45. System.out.println("-----del redis lock error ....");

  46. }

  47. }catch (Exception e){

  48. }finally {

  49. if(null != jedis){

  50. jedis.close();

  51. }

  52. }

  53. }

  54. }

  55. }

Redission 红锁

Redission红锁其实是上述redis分布式锁的升级版,主要是框架已经封装好了我们需要的方法,实际过程中只要引入相应的jar包,使用对应的api即可。

Maven引入:

   org.redisson
   redisson
   3.17.4
伪代码如下:

 
  1. public JsonData getCoupon(long couponId, CouponCategoryEnum categoryEnum) {

  2. String key = "lock:coupon:" + couponId;

  3. RLock rLock = redisson.getLock(key);

  4. LoginUser loginUser = LoginInterceptor.threadLocal.get();

  5. rLock.lock();

  6. try{

  7. //业务逻辑

  8. }finally {

  9. rLock.unlock();

  10. }

  11. return JsonData.buildSuccess();

  12. }

使用这种方式也无需关心 key 过期时间续期的问题,因为在 Redisson 一旦加锁成功,就会启动一个 watch dog,你可以将它理解成一个守护线程,它默认会每隔 30 秒(可灵活配置)检查一下,如果当前客户端还占有这把锁,它会自动对这个锁的过期时间进行延长。

Zookeeper分布式锁

Zookeeper分布式锁应用了临时顺序节点。具体如何实现呢?让我们来看一看详细步骤:

获取锁:

首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端(Client1)想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。

之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。

Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。

于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。

这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。

Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最靠前的。

于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。

这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的AQS。

怎么测试并发安全问题?

首先我们要保证测试环境的项目是分布式、集群部署,其次可以根据线上获取优惠券接口的实际QPS,在测试环境使用工具jmeter并发请求优惠券接口,运行一段时间后,再去看下数据库相应的数据,譬如优惠券库存信息,抢购优惠券信息等等,反复多次运行看下效果。

总结

本篇文章主要分享了电商项目中一种常见的并发安全问题,以及相应的解决方案,如果从性能的角度去考虑应该是Redis > zookeeper > 数据库。从可靠性(安全)性角度:zookeeper > Redis > 数据库。

  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
下面是一些电商项目优惠券测试用示例: 1. 测试优惠券的创建: - 验证管理员能否成功创建一个新的优惠券。 - 验证创建的优惠券是否包含正确的信息,如折扣金额、折扣比例、有效期等。 - 验证创建的优惠券是否可以设定适用范围,如全站商品、特定商品类别或品牌等。 2. 测试优惠券的领取: - 验证用户是否能够成功领取优惠券。 - 验证用户是否只能领取符合条件的优惠券,如注册新用户、首次购买等。 - 验证用户是否能够多次领取同一优惠券。 3. 测试优惠券的使用: - 验证用户是否可以将优惠券成功应用于购物车中的商品。 - 验证用户是否只能在符合条件的情况下使用优惠券,如满足最低消费金额、适用商品类别等。 - 验证用户是否可以同时使用多个优惠券。 - 验证用户是否可以成功取消已应用的优惠券。 4. 测试优惠券的有效性: - 验证已过期的优惠券是否能够被识别为无效状态。 - 验证已使用的优惠券是否能够被标记为已消费状态。 - 验证已取消的优惠券是否能够被标记为无效状态。 5. 测试优惠券的限制: - 验证用户是否可以在同一订单中同时使用多个优惠券。 - 验证用户是否可以在不符合条件的情况下领取优惠券。 - 验证用户是否可以在不符合条件的情况下使用优惠券。 6. 性能测试: - 验证在大量用户同时领取优惠券时,系统的性能和响应时间是否正常。 - 验证在大量用户同时使用优惠券时,系统的性能和响应时间是否正常。 这些是一些常见的电商项目优惠券测试用例示例,具体的测试用例设计还应根据实际项目需求进行调整和补充。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值