你好 我是懂java的测试


前言

刚毕业那会,本人意外获得拼夕夕的面试机会,在聊到优惠券库存扣减场景时,面试官问了一连串经典的问题:“优惠券库存怎么扣减的?开发为了解决超发优惠券问题而设计的方案,了解过吗?你又是如何测试的呢?”。

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

问题引入

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

需求描述:A 优惠券一共发行 100张,每一个用户最多可以领取5张。

当一个用户领取优惠券成功的时候,把领取的记录写入另外一个表中(这张表我们暂且称为表 B)。

在领取优惠券的过程中,优惠券库存的扣减过程,一般操作如下:

1、select查询优惠券的库存。

2、计算优惠券库存是否足够,如果优惠券存库不足则抛出库存不足的异常,如果优惠券库存足够,则判断是否在领取时间、判断用户领取数量是否超过个人最高领取限制。

3、如果2成立,则减去扣除的库存得到最新的库存剩余值。

4、set设置最新的优惠券库存剩余值

伪代码如下:

扣减优惠券sql如下:

<!--减优惠券库存的SQL-->
<update id="reduceStock">update coupon set stock = stock - 1 where id = #{coupon_id}
</update>

并发量比较低的时候,几乎看不出来有问题,可是当我们开启多线程,去请求这个抢优惠券的接口时,问题出现了,id为19的这个优惠券库存为负数。多发了一个,什么原因呢?

深入解读并发安全问题

为啥并发量高的时候会出现优惠券库存多发的问题呢?原因如下截图:

上图中出现问题的环节其实是判断优惠券库存那个步骤,重点来了:

高并发情况下,如果同时来了两个线程线程 A和线程 B(可以理解成是两个请求),比如先来的那个线程A请求通过了检查,这时线程 A 还没有扣减库存,这时经过一番操作,线程B也通过了这个检查优惠券是否可领取的方法,然后线程 A 和线程 B 依次扣减库存或者是同时扣减库存。所以就出现了刚刚数据库出现的现象,优惠券库存为-1个,就像下图。

怎么解决并发安全问题?

| Java 代码加锁

synchronized (this){LoginUser loginUser = LoginInterceptor.threadLocal.get();CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().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 id="reduceStock">update product set stock=stock-1 where stock=#{上一次的库存}  and id = #{id} and stock>0
</update>

这种方法有个ABA的问题,我们可以加个version字段,每次修改数据的时候这个字段会加 1,这样就可以避免 ABA 问题。但是这种依靠数据库进行并发安全保障,会消耗数据库的资源,一定请求量内(需经过严格测试)可使用。

<update id="reduceStock">update product set stock=stock-1,versioin = version+1 where   #{id} and stock>0 and version=#{上一次的版本号}
</update>

| Redis分布式锁

引入 Redis 后,当领取优惠券时会先去 Redis 里面去获取锁,当锁获取成功后才可以对数据库进行操作。

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

  • 排他性,在分布式集群中,同一个方法,在同一个时间只能被某一台机器上的一个线程执行

  • 容错性,当一个线程上锁后,如果机器突然的宕机,如果不释放锁,此时这条数据将会被锁死

  • 还要注意锁的粒度,锁的开销

  • 满足高可用,高性能,可重入

伪代码如下:

@RestController
public class IndexController {public static final String REDIS_LOCK = "coupon_lock";@AutowiredStringRedisTemplate 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 红锁

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

Maven引入:

<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.17.4</version>
</dependency>

伪代码如下:

public JsonData getCoupon(long couponId, CouponCategoryEnum categoryEnum) {String key = "lock:coupon:" + couponId;RLock rLock = redisson.getLock(key);LoginUser loginUser = LoginInterceptor.threadLocal.get();rLock.lock();try{//业务逻辑}finally {rLock.unlock();}return JsonData.buildSuccess();
}

使用这种方式也无需关心 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 > 数据库。

欢迎加我微信lvceshikaifa,免费面试辅导、学习资料、简历模板获取。也可加入学习群,聊面试,聊技术,一起学习,一起加薪。

关注公众号,测试干货及时送达

懂Java的测试

软件测试技术分享,包括功能、自动化、性能、测试开发等,简历指点、面试助攻等

36篇原创内容

公众号

懂Java的测试精品文章

  • 接口自动化相关面试题

  • 测试工程师都能看懂的Redis 上

  • 模拟面试之高级测试要具备的知广度和深度

  • 面试题之什么叫微服务?

  • 测试开发面试、学习笔记

  • 近期学员遇到的面试题分享

面试题之优惠券超发怎么测试?相关推荐

  1. 拼夕夕面试题之优惠券超发怎么去测试?

    刚毕业那会,本人意外获得拼夕夕的面试机会,在聊到优惠券库存扣减场景时,面试官问了一连串经典的问题:"优惠券库存怎么扣减的?开发为了解决超发优惠券问题而设计的方案,了解过吗?你又是如何测试的呢 ...

  2. 电商项目如何解决线上优惠券超发(排错+解决方案)(荣耀典藏)

    目录 1.问题抛出 2.问题引发 3.问题解决 3.1.解决方案 1(Java 代码加锁) 3.2.解决方案 2(SQL 层面解决超发) 3.3.解决方案 3(通过 Redis 分布式锁来解决问题) ...

  3. 高并发超发优惠券问题

    优惠券领劵接口-Jmeter压测扣超发优惠券问题暴露 简介:Jmeter压测领劵接口-超发优惠券问题暴露 新建接口压测计划 压测领劵接口 完成xml编写 <!--扣减库存--><up ...

  4. 高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug

    文章目录 导读 乐观锁 CAS 原理 ABA问题 库表改造 代码改造 RedPacketDao新增接口方法及Mapper映射文件 UserRedPacketServic接口及实现类的改造 Contro ...

  5. 高并发-【抢红包案例】之二:使用悲观锁方式修复红包超发的bug

    文章目录 概述 超发问题分析 使用数据库锁的解决方案 使用悲观锁(排它锁 for update) 使用乐观锁(依靠表的设计和代码) 总结 悲观锁(抽象的描述,不真实存在这个锁) 共享锁(S锁) 排他锁 ...

  6. 高并发-【抢红包案例】之一:SSM环境搭建及复现红包超发问题

    文章目录 概述 抢红包案例 案例关注点 工程结构 库表设计 Domain Dao层实现 Service层实现 使用全注解搭建SSM 开发环境 Controller层 View层 运行测试 超量发送的B ...

  7. php如何防止超发,PHP+redis实现的限制抢购防止商品超发功能详解

    本文实例讲述了php+redis实现的限制抢购防止商品超发功能.分享给大家供大家参考,具体如下: redis不仅仅是单纯的缓存,它还有一些特殊的功能,在一些特殊场景上很好用.redis中key的原子自 ...

  8. 【机试题】2014大疆嵌入式笔试题(附超详细解答,下篇)

    上一篇主要是对<2014大疆嵌入式笔试题>的前一部分进行了解答,本文主要是对接下来的几道题进行解答.想要参考上一篇的点击链接:[机试题]2014大疆嵌入式笔试题(附超详细解答,上篇). 嵌 ...

  9. 商品是否会超发?(UPDATE ... remained = remained - 1 WHERE ...)

    原文网址:商品是否会超发?(UPDATE ... remained = remained - 1 WHERE ...)_IT利刃出鞘的博客-CSDN博客 简介 说明 商品的超发是下订单业务中需要考虑的 ...

最新文章

  1. 内存分配策略(二):JMM,Java Memory Model
  2. iOS 获取本地图片URL
  3. MySQL - 使用trace工具来窥探MySQL是如何选择执行计划的
  4. Latex论文排版技巧再总结
  5. 笨办法学linux dhcp,了解网关、DNS、子网掩码、MAC地址、DHCP
  6. 大学学好高数的爆炸性意义!
  7. python常用api_[原创]IDAPython常用API整理
  8. 【空间分析】5 空间目标形态量测
  9. windows.2003.应用网络负载平衡管理器实现均衡负载与双机热备
  10. 向对象编程:Java collection更有效管理elements(转)
  11. 关于Oracle表访问方式的总结
  12. requestparam的作用_POST/GET请求中RequestBody和RequestParam的应用场景
  13. 无法保存此文档。它可能是只读的,或者正在被其他应用程序使用
  14. 中国企业云计算应用现状及需求调研报告
  15. 谈谈QQ 4.6版本的语音通话功能
  16. 这10个问题帮你搞清楚SaaS是什么!
  17. MySQL 5.7 OCP考试大纲
  18. Linux的数字签名,Ubuntu系统下-加密和数字签名Email
  19. LaTex论文排版 | (4) LaTex的字体设置
  20. c++ 进制转换 十六进制转八进制

热门文章

  1. 直流电源滤波电容选择
  2. 【转】约翰。克里斯朵夫的评论
  3. 顶尖IT高手培养——大牛招徒
  4. Android 游戏教程:让人物动起来
  5. 软件实施工程师职位大揭密
  6. python语言基础项目报告书_Python语言基础01-初识Python
  7. Apple Watch苹果手表无线充电方案浅谈
  8. 一位交易巨匠的十年心得:如何使用MACD判断后期趋势,把握买卖点
  9. 查快递单号物流,如何分析揽收后没有物流的单号
  10. Mysql 时间字段(加上或者减去一段时间)