潍坊网站建设公司排名,做鞋原料网站,博兴专业做网站,wordpress dragon问题抛出
在近期的项目里面有一个功能是领取优惠券的功能#xff0c;
问题描述#xff1a;
每一个优惠券一共发行多少张#xff0c;每个用户可以领取多少张#xff1a;
如#xff1a;A优惠券一共发行120张#xff0c;每一个用户可以领取140张#xff0c;当一个用户领…问题抛出
在近期的项目里面有一个功能是领取优惠券的功能
问题描述
每一个优惠券一共发行多少张每个用户可以领取多少张
如A优惠券一共发行120张每一个用户可以领取140张当一个用户领取优惠券成功的时候把领取的记录写入到另外一个表中(这张表我们暂且称为表B) !--减优惠券库存的SQL--
update idreduceStockupdate coupon set stock stock - 1 where id #{coupon_id}
/update上面的代码按照我们的逻辑是没有问题我通过使用PostMan软件测试也是没有问题但是上面的代码确实是有问题的。
**往往我们写的一些业务功能在低并发的时候很多的问题会体现不出来。**所以这个领取优惠券的功能我通过Jmeter软件来进行压测。 这里配置了一下大概会发送500次请求那来验证下优惠券会不会出现超发的问题 执行结果里面没有出现异常什么的样本为500。包括在汇总报告里面也出现了一些返回信息是优惠券不足的信息那来看下数据库里面的优惠券的总发行数量有没有变成负数呢也就是有没有超发。 在测试的时候是测试的id为19的这条数据测试完之后这里的总发行数量(stock)居然变成了-1(也就是超发了一张)。
问题引发
在解决这个问题之前先来看下这个问题是如何引发出来的。 上面这张图是整个领取优惠券的流程(上图并没有使用流程图来画我觉的这样画可能表达更清楚一些)在蓝色的框那里就是出现超扣减库存的时候。为啥这样说呢
如果同时来了两个线程(你可以理解成是两个请求)比如先来的那个请求通过了检查(线程A)这时线程A还没有扣减库存这时线程B经过一翻操作也通过了这个检查优惠券是否可领取的方法然后线程A和线程B依次扣减库存或者是同时扣减库存这样就会出现优惠券超领的情况。 清楚了问题引发的原因那就来看看如何解决它们。
解决方案一(Java代码加锁)
在引起超发原因的那张图内可以看出导致这一问题的根本原因是多个线程同时访问这个领取优惠券的方法那只要保证在同一段只有一个线程进入到这个方法就可以了。
上面贴的代码就可以改成下面这样 synchronized (this){LoginUser loginUser LoginInterceptor.threadLocal.get();CouponDO couponDO couponMapper.selectOne(new QueryWrapperCouponDO().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);}
}这样经过Jmeter的压测优惠券并没有出现超发的情况。
虽然这样可以解决超发的问题但是在项目中我们不可以这样写原因如下
synchronized的作用范围是单个JVM实例如果是集群部署系统这里的加锁你可以理解成失效在使用了synchronized加锁后就会形成串行等待的问题当一个线程A在领取优惠券方法内执行过久时其它线程会等待直到线程A执行结束
解决方案二(Sql层面解决超发)
update idreduceStockupdate coupon set stock stock - 1 where id #{coupon_id} and stock 0
/updateMysql默认使用的是InnoDB引擎使用InnoDB时在修改某一个记录的时候会将这条记录上锁所以这个修改数据时不会出现多个线程同时修改数据。这样也可以避免优惠券超发。
如果在业务中只要有库存就可以发放优惠券的可以使用上面这种方式。
还有一种Sql的方式可以将stock自身做为乐观锁。 update idreduceStockupdate product set stockstock-1 where stock#{上一次的库存} and id 1 and stock0
/update上面这种方式会存在ABA的问题当然如果业务不在意ABA问题可以使用上面的sql不过性能可能差一点如果stock不匹配这条sql也就失效了。
如果业务在意ABA问题的话也可以在表中加一个version的字段每次修改数据的时候这个字段会加1这样就可以避免ABA问题
update idreduceStockupdate product set stockstock-1,versioin version1 where id 1 and stock0 and version#{上一次的版本号}
/update上面的这三条Sql层面的代码都可以解决优惠券超发的问题具体使用那种就根据业务来选择了
解决方案三(通过Redis分布式锁来解决问题)
引入Redis后当领取优惠券时会先去Redis里面去获取锁当锁获取成功后才可以对数据库进行操作 在分布式锁中我们应该考滤如下
排他性在分布式集群中同一个方法在同一个时间只能被某一台机器上的一个线程执行容错性当一个线程上锁后如果机器突然的宕机如果不释放锁此时这条数据将会被锁死还要注意锁的粒度锁的开销满足高可用高性能可重入
我们可以使用Redis里面的setnx命令来设置锁因为setnx是原子性的操作不可被打断 当这个命令执行成功的时候会返回1执行失败会返回0,我们就可以通过这个特性来判断是否获取到了锁。
先看下伪代码
String key lock:coupon: couponId;
try{if(setnx(key,1)){//获取到锁//设置Key的时期时间exp(key,30,TimeUnit.MILLISECONDS);try{//业务逻辑}finally{del(key);}}else{//获取锁失败递归调用这个方法或者使用for进行自旋获取锁}
}这方法里面设置key的过期时间的原因是当机器突然的宕机后即使没有释放掉锁他也会在一段时间后将这个锁释放避免导致死锁。
虽然看上面的代码是没有问题的但是它是存在一个误删除key的问题 为了避免这个问题可以将setnx命令设置的那个值设置成当前线程的ID,在删除的时候判断这个线程ID是不是与当前线程的Id相同就可以了。
String key lock:coupon: couponId;
String threadId Thread.currentThread().getId();
try{if(setnx(key,threadId)){//获取到锁//设置Key的时期时间exp(key,30,TimeUnit.MILLISECONDS);try{//业务逻辑}finally{if(get(key) threadId){del(key);}}}else{//获取锁失败递归调用这个方法或者使用for进行自旋获取锁}
}通过上面这种方法就可以解决误删除key的问题。
在finally中的这个判断和删除key的代码不是原子性的我们可以通过lua脚本的方式来实现它们之间的原子性将删除key的代码修改成如下
String script if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end;redisTemplate.execute(new DefaultRedisScript(script, Integer.class), Arrays.asList(key), threadId);这里的threadId其实也可以不用写成uuid也可以但是在上面setnx的时候那个值也要写成uuid 但是这样还要存在一个锁自动续期的问题你可以开一个守护线程每隔多久给他续期一次或者是直接将这个过期时间延长一些。
在Redis中也有一些官方推荐的分布式锁的方式。我最后是使用的这种方式
解决方案四(使用Redis推荐的方式)
官网地址redis.io/docs/refere…
这个有多种实现方式比如Golang,Java,Php
引入Redisson包 dependencygroupIdorg.redisson/groupIdartifactIdredisson/artifactIdversion3.17.4/version
/dependency 配置RedissoneClient
Configuration
public class AppConfig {Value(${spring.redis.host})private String redisHost;Value(${spring.redis.port})private String redisPort;Beanpublic RedissonClient redisson(){Config config new Config();config.useSingleServer().setAddress(redis:// redisHost : redisPort);return Redisson.create(config);}
}配置好RedissonClient后通过getLock方法获取到锁对象后在我们的Service层中就可以通过lock和unlock来进行加锁和释放锁了这样还是很方便的。
public JsonData addCoupon(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();
}通过这种方法也可以解决优惠券超发的问题 这也是Rediss官网推荐的一种方式。
使用这种方式也无需关心key过期时间续期的问题因为在Redisson一旦加锁成功就会启动一个watch dog你可以将它理解成一个守护线程它默认会每隔30秒检查一下如果当前客户端还占有这把锁它会自动对这个锁的过期时间进行延长。
也可以通过下面的方法设置watch dog的检测时间间隔
Config config new Config();
config.setLockWatchdogTimeout();