关于创建订单一人一单问题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);
}
为了解决集群所产生的锁失效问题,采用Redis
的setNx
来实现分布式锁。
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。
但是! 代码这样存在一个问题!看图:
- 当线程1成功获取锁之后,业务执行阻塞导致执行之间过长(业务还没结束),redis中的数据超时释放了。
- 在线程1因业务阻塞过期释放的时候,线程2成功获取锁(因为key过期已经释放),在线程2执行业务的过程中,线程1业务执行完了,然后将锁删除。注意,此时删除的并不是线程1自己的锁,因为它自己的已经过期自动释放了,它删的是线程2的锁。
- 线程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); // 确定是自己的才能删除锁
}
}
哎,这样就没问题了。也能确保不会删别人的锁了,绝壁就线程安全了!!!
但, 真的是这样吗?我们再来看一张比较极端一点的图:
- 首先,线程1正常获取锁,执行业务,在 释放锁 的时候已经判定是自己的锁,然后在执行
stringRedisTemplate.delete(business_key)
的时候发生了阻塞,以致于都Key过期,自动释放了Key,虽然锁释放了,但是删除锁的代码还在阻塞中。- 此时线程2是能正常获取锁的,线程2在执行自己业务的过程中,线程1恢复了,然后执行了
delete
(因为线程1前面已经判定过了,而且Key是一样的),这特喵又把线程2的锁释放了。(线程2:你咋老不干人事呢??)- 同上,线程2的锁已经被线程1释放了,线程3是能正常获取锁的,然后执行自己的业务
发生这误删的问题在哪儿呢?是线程1在判定和删除的时候发生了阻塞,没有遵循原子性,以至于被别的线程趁虚而入了。
怎么解决呢??
- Redis 事务:
- 支持原子性,不支持一致性
- 批处理操作,最终一致性 (基于乐观锁)
- 我们使用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());
}