关于创建订单一人一单问题2

本文最后更新于:2025年6月25日 晚上

上篇问题,解决了单机模式下关于一人一单问题,但是在集群的环境下,就还是会出现一人多单的情况,两个机子上产生的userId也会不同,处于不同的JVM中,产生的userId字符的地址不同,所以synchronized也锁不住。那么就需要修改代码,使用分布式锁。

上篇中代码:

@Override
public Result seckillCreate(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断到没到抢券的时间
    xxx
        
    Long userId = UserHolder.getUser().getId();
    String userIdIntern = userId.toString().intern();
    synchronized (userIdIntern){
        IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
        return iVoucherOrderService.createOrder(voucherId, seckillVoucher, userId);
    }
}

@Transactional
public Result createOrder(Long voucherId){
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("每人最多只能持有一张,不要贪心哦!");
        }

        // 创建订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
        xxx
        this.save(voucherOrder);
        return Result.ok(orderId);
}

为了解决集群所产生的锁失效问题,采用RedissetNx 来实现分布式锁。

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
  • XX :只在键已经存在时,才对键进行设置操作

修改1

创建接口 ILock.java,里面两个方法,分别是:尝试获取锁:boolean tryLock(long expireTime),释放锁:void unLock()

public interface ILock {
    boolean tryLock(long time);
    void unLock();
}

以及它的实现类:LockServiceImpl.java

public class LockServiceImpl implements ILock {
    private static StringRedisTemplate stringRedisTemplate;
    private static String key;

    private final String business_value;
    private static String lockKey = "lock:";


    public LockServiceImpl(StringRedisTemplate stringRedisTemplate, String key, String business_value) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.key = key;
        this.business_value = business_value;
    }


    @Override
    public boolean tryLock(long time) {
        String business_key = lockKey + key + ":" + business_value;
        Boolean absent = stringRedisTemplate.opsForValue().setIfAbsent(business_key,
                Thread.currentThread().getId(), time, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(absent);		// 不直接返回防止拆箱发生NullPointException
    }

    @Override
    public void unLock() {
        String business_key = lockKey + key + ":" + business_value;
        stringRedisTemplate.delete(business_key);
    }
}

创建了一个构造方法,用于传递 StringRedisTemplate 来进行redis的操作,以及一个业务Key值和Value值,用于写入Redis。

使用 setIfAbsent() 方法来塞值。如果为空就set值,并返回1;如果存在(不为空)不进行操作,并返回0。

设置的值是当前线程的Id。

但是! 代码这样存在一个问题!看图:

zLMReS.md.png

  1. 当线程1成功获取锁之后,业务执行阻塞导致执行之间过长(业务还没结束),redis中的数据超时释放了。
  2. 在线程1因业务阻塞过期释放的时候,线程2成功获取锁(因为key过期已经释放),在线程2执行业务的过程中,线程1业务执行完了,然后将锁删除。注意,此时删除的并不是线程1自己的锁,因为它自己的已经过期自动释放了,它删的是线程2的锁
  3. 线程2的锁被线程1删掉了,线程3可以成功获取到锁,然后继续执行业务。。。。。。

那么可以看出来,这样的代码会把别人的锁误删,需要怎么解决这个问题呢?哎,我们何不在删锁的时候看一看是不是自己锁再删呢,别人的锁我们就不动,所以要给这个值标识成是线程自己的锁,那么修改上述代码:

修改2

public class LockServiceImpl implements ILock {
    private static final String value_prefix = UUID.randomUUID().toString(true); // hutool工具类,true可以去掉横线
    private static final String lockKey = "lock:";
    
    private static StringRedisTemplate stringRedisTemplate;
    private static String key;
    private final String business_value;

    public LockServiceImpl(StringRedisTemplate stringRedisTemplate, String key, String business_value) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.key = key;
        this.business_value = business_value;
    }

    @Override
    public boolean tryLock(long time) {
        String business_key = lockKey + key + ":" + business_value;
        Boolean absent = stringRedisTemplate.opsForValue().setIfAbsent(business_key,
                value_prefix + "-" + Thread.currentThread().getId(), time, TimeUnit.SECONDS); // <X>
        return Boolean.TRUE.equals(absent);		// 不直接返回防止拆箱发生NullPointException
    }

    @Override
    public void unLock() {
        String business_key = lockKey + key + ":" + business_value;
        String redisLockValue = stringRedisTemplate.opsForValue().get(business_key); // redis中存储的value
        redisLockValue = redisLockValue == null ? "" : redisLockValue;
        String currentValue = value_prefix + "-" + Thread.currentThread().getId();  // 当前线程自己的value
        if(!StrUtil.equals(redisLockValue,currentValue)){	// 不等返回
            return;
        }
        stringRedisTemplate.delete(business_key);	// 确定是自己的才能删除锁
    }
}

哎,这样就没问题了。也能确保不会删别人的锁了,绝壁就线程安全了!!!

但, 真的是这样吗?我们再来看一张比较极端一点的图:

img

  1. 首先,线程1正常获取锁,执行业务,在 释放锁 的时候已经判定是自己的锁,然后在执行 stringRedisTemplate.delete(business_key) 的时候发生了阻塞,以致于都Key过期,自动释放了Key,虽然锁释放了,但是删除锁的代码还在阻塞中。
  2. 此时线程2是能正常获取锁的,线程2在执行自己业务的过程中,线程1恢复了,然后执行了delete(因为线程1前面已经判定过了,而且Key是一样的),这特喵又把线程2的锁释放了。(线程2:你咋老不干人事呢??)
  3. 同上,线程2的锁已经被线程1释放了,线程3是能正常获取锁的,然后执行自己的业务

发生这误删的问题在哪儿呢?是线程1在判定和删除的时候发生了阻塞,没有遵循原子性,以至于被别的线程趁虚而入了。

怎么解决呢??

  1. Redis 事务:
    • 支持原子性,不支持一致性
    • 批处理操作,最终一致性 (基于乐观锁)
  2. 我们使用LUA脚本来保证redis操作的原子性,Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Reids 命令,保证多条命令执行的原子性

修改3

在项目的resouce目录下新建目录lua,在lua目录下新建一个lua脚本:unlock.lua

-- 获取锁中的线程标识
local id = redis.call('get', KEYS[1])
-- Redis中存入的线程标识和传入的参数一致可以删除
if(id == ARGV[1]) then
	return redis.call('del', KEYS[1])
end
return 0

写一个静态代码块将目录下的lua脚本内容加载:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    // 设置脚本位置
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("/lua/unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

修改释放锁(unLock)的代码:

@Override
public void unLock() {
    String business_key = lockKey + key + ":" + business_value;
    stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(business_key),value_prefix + "-" + Thread.currentThread().getId());
}

关于创建订单一人一单问题2
https://codeofhh.cn/2023/05/04/关于创建订单一人一单问题2/
作者
hhu
发布于
2023年5月4日
许可协议