解决方案一(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); } }
这样,经过Jmeter的压测优惠券并没有出现超发的情况。
虽然这样可以解决超发的问题,但是在项目中我们不可以这样写,原因如下:
synchronized的作用范围是单个JVM实例,如果是集群部署系统这里的加锁你可以理解成失效
在使用了synchronized加锁后,就会形成串行等待的问题,当一个线程A在领取优惠券方法内执行过久时,其它线程会等待直到线程A执行结束
解决方案二(Sql层面解决超发)
<update id="reduceStock"> update coupon set stock = stock - 1 where id = #{coupon_id} and stock > 0 </update>
Mysql默认使用的是InnoDB引擎,使用InnoDB时在修改某一个记录的时候会将这条记录上锁,所以这个修改数据时不会出现多个线程同时修改数据。这样也可以避免优惠券超发。
如果在业务中只要有库存就可以发放优惠券的可以使用上面这种方式。
还有一种Sql的方式,可以将stock自身做为乐观锁。
update product set stock=stock-1 where stock=#{上一次的库存} and id = 1 and stock>0
上面这种方式会存在ABA的问题,当然如果业务不在意ABA问题可以使用上面的sql,不过性能可能差一点,如果stock不匹配,这条sql也就失效了。
如果业务在意ABA问题的话也可以在表中加一个version的字段,每次修改数据的时候这个字段会加1,这样就可以避免ABA问题
<update id="reduceStock"> update product set stock=stock-1,versioin = version+1 where id = 1 and stock>0 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推荐的方式)
官网地址:
https://redis.io/docs/manual/patterns/distributed-locks/
这个有多种实现方式,比如:Golang,Java,Php
引入Redisson包
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.17.4</version> </dependency>
配置RedissoneClient
@Configuration public class AppConfig { @Value("${spring.redis.host}") private String redisHost; @Value("${spring.redis.port}") private String redisPort; @Bean public 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();
如上就是我在解决优惠券超发时的一个思路。