原文:https://c1n.cn/Xz8kf

问题抛出

在近期的项目里面有一个功能是领取优惠券的功能。

问题描述:每一个优惠券一共发行多少张,每个用户可以领取多少张,如:A 优惠券一共发行 120 张,每一个用户可以领取 140 张。

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

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

上面的代码按照我们的逻辑是没有问题,我通过使用 PostMan 软件测试也是没有问题,但是上面的代码确实是有问题的。

往往我们写的一些业务功能,在低并发的时候很多的问题会体现不出来。所以这个领取优惠券的功能我通过 Jmeter 软件来进行压测。
优惠券超发事故 - 图3
这里配置了一下,大概会发送 500 次请求,那来验证下优惠券会不会出现超发的问题:
优惠券超发事故 - 图4
执行结果,里面没有出现异常什么的,样本为 500。包括在汇总报告里面也出现了一些返回信息是优惠券不足的信息,那来看下数据库里面的优惠券的总发行数量有没有变成负数呢?也就是有没有超发。
优惠券超发事故 - 图5
在测试的时候是测试的 id 为 19 的这条数据,测试完之后这里的总发行数量(stock)居然变成了 -1(也就是超发了一张)。

问题引发

在解决这个问题之前,先来看下这个问题是如何引发出来的:
优惠券超发事故 - 图6
上面这张图是整个领取优惠券的流程(上图并没有使用流程图来画,我觉的这样画可能表达更清楚一些),在蓝色的框那里就是出现超扣减库存的时候。为啥这样说呢?

如果同时来了两个线程(你可以理解成是两个请求),比如先来的那个请求通过了检查(线程 A),这时线程 A 还没有扣减库存,这时线程 B 经过一翻操作也通过了这个检查优惠券是否可领取的方法,然后线程 A 和线程 B 依次扣减库存或者是同时扣减库存。

这样就会出现优惠券超领的情况:
优惠券超发事故 - 图7
清楚了问题引发的原因,那就来看看如何解决它们。

问题解决

解决方案 1(Java 代码加锁)

在引起超发原因的那张图内可以看出,导致这一问题的根本原因是多个线程同时访问这个领取优惠券的方法,那只要保证在同一段只有一个线程进入到这个方法就可以了。

上面贴的代码就可以改成下面这样:

  1. synchronized (this){
  2. LoginUser loginUser = LoginInterceptor.threadLocal.get();
  3. CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
  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. }

优惠券超发事故 - 图8
这样,经过 Jmeter 的压测优惠券并没有出现超发的情况。

虽然这样可以解决超发的问题,但是在项目中我们不可以这样写,原因如下:

  • synchronized 的作用范围是单个 JVM 实例,如果是集群部署系统这里的加锁你可以理解成失效。
  • 在使用了 synchronized 加锁后,就会形成串行等待的问题,当一个线程 A 在领取优惠券方法内执行过久时,其它线程会等待直到线程 A 执行结束。

解决方案 2(SQL 层面解决超发)

  1. <update id="reduceStock">
  2. update coupon set stock = stock - 1 where id = #{coupon_id} and stock > 0
  3. </update>

MySQL 默认使用的是 InnoDB 引擎,使用 InnoDB 时在修改某一个记录的时候会将这条记录上锁,所以这个修改数据时不会出现多个线程同时修改数据。这样也可以避免优惠券超发。

如果在业务中只要有库存就可以发放优惠券的可以使用上面这种方式。

还有一种 SQL 的方式,可以将 stock 自身做为乐观锁。

  1. <update id="reduceStock">
  2. update product set stock=stock-1 where stock=#{上一次的库存} and id = 1 and stock>0
  3. </update>

上面这种方式会存在 ABA 的问题,当然如果业务不在意 ABA 问题可以使用上面的 sql,不过性能可能差一点,如果stock不匹配,这条sql也就失效了。

如果业务在意 ABA 问题的话也可以在表中加一个 version 的字段,每次修改数据的时候这个字段会加 1,这样就可以避免 ABA 问题。

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

上面的这三条 SQL 层面的代码都可以解决优惠券超发的问题,具体使用那种就根据业务来选择了。

解决方案 3(通过 Redis 分布式锁来解决问题)

引入 Redis 后,当领取优惠券时会先去 Redis 里面去获取锁,当锁获取成功后才可以对数据库进行操作。
优惠券超发事故 - 图9
在分布式锁中我们应该考滤如下:

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

我们可以使用 Redis 里面的 setnx 命令来设置锁,因为 setnx 是原子性的操作不可被打断。
优惠券超发事故 - 图10
当这个命令执行成功的时候会返回 1,执行失败会返回 0,我们就可以通过这个特性来判断是否获取到了锁。

先看下伪代码:

  1. String key = "lock:coupon:" + couponId;
  2. try{
  3. if(setnx(key,"1")){
  4. //获取到锁
  5. //设置Key的时期时间
  6. exp(key,30,TimeUnit.MILLISECONDS);
  7. try{
  8. //业务逻辑
  9. }finally{
  10. del(key);
  11. }
  12. }else{
  13. //获取锁失败,递归调用这个方法,或者使用for进行自旋获取锁
  14. }
  15. }

这方法里面设置 key 的过期时间的原因是,当机器突然的宕机后,即使没有释放掉锁,他也会在一段时间后将这个锁释放,避免导致死锁。

虽然看上面的代码是没有问题的,但是它是存在一个误删除 key 的问题。
优惠券超发事故 - 图11
为了避免这个问题,可以将 setnx 命令设置的那个值,设置成当前线程的 ID,在删除的时候判断这个线程 ID 是不是与当前线程的 Id 相同就可以了。

  1. String key = "lock:coupon:" + couponId;
  2. String threadId = Thread.currentThread().getId();
  3. try{
  4. if(setnx(key,threadId)){
  5. //获取到锁
  6. //设置Key的时期时间
  7. exp(key,30,TimeUnit.MILLISECONDS);
  8. try{
  9. //业务逻辑
  10. }finally{
  11. if(get(key) == threadId){
  12. del(key);
  13. }
  14. }
  15. }else{
  16. //获取锁失败,递归调用这个方法,或者使用for进行自旋获取锁
  17. }
  18. }

通过上面这种方法就可以解决误删除 key 的问题。

在 finally 中的这个判断和删除 key 的代码不是原子性的,我们可以通过 lua 脚本的方式来实现它们之间的原子性,将删除 key 的代码修改成如下:

  1. String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
  2. redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(key), threadId);

这里的 threadId 其实也可以不用,写成 uuid 也可以,但是在上面 setnx 的时候,那个值也要写成 uuid。

但是这样还要存在一个锁自动续期的问题,你可以开一个守护线程,每隔多久给他续期一次,或者是直接将这个过期时间延长一些。

在 Redis 中也有一些官方推荐的分布式锁的方式。我最后是使用的这种方式。

解决方案 4(使用 Redis 推荐的方式)

官网地址:
https://redis.io/docs/reference/patterns/distributed-locks/

这个有多种实现方式,比如:Golang,Java,PHP。

引入 Redisson 包:

  1. <dependency>
  2. <groupId>org.redisson</groupId>
  3. <artifactId>redisson</artifactId>
  4. <version>3.17.4</version>
  5. </dependency>

配置 RedissoneClient:

  1. @Configuration
  2. public class AppConfig {
  3. @Value("${spring.redis.host}")
  4. private String redisHost;
  5. @Value("${spring.redis.port}")
  6. private String redisPort;
  7. @Bean
  8. public RedissonClient redisson(){
  9. Config config = new Config();
  10. config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
  11. return Redisson.create(config);
  12. }
  13. }

配置好 RedissonClient 后,通过 getLock 方法获取到锁对象后,在我们的 Service 层中就可以通过 lock 和 unlock 来进行加锁和释放锁了,这样还是很方便的。

  1. public JsonData addCoupon(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. }

通过这种方法也可以解决优惠券超发的问题 ,这也是 Rediss 官网推荐的一种方式。

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

也可以通过下面的方法设置 watch dog 的检测时间间隔:

  1. Config config = new Config();
  2. config.setLockWatchdogTimeout();

如上就是我在解决优惠券超发时的一个思路。