物来顺应,未来不迎,当时不杂,既过不恋;|

万事胜意k

园龄:2年8个月粉丝:11关注:4

Redis学习之分布式锁

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

image-20231009183433160

分布式锁的两个基本特征:

  1. 多线程(进程)可见(读写)

  2. 互斥

还应具备的特征:

  1. 高可用:不能挂机

  2. 高性能:读写要快

  3. 安全性:不能出现死锁

实现方式

主要有以下三种:

image-20231009183722225

其中,MySQL 的实现成本相对最低、Redis 性能最高、Zookeeper 可以实现但不推荐使用(Zk 重点在于保证强一致性而不是性能和高可用性,CP 模型)

实现

获取锁:

  • 使用 setnx 命令设置 lock(本质是创建一个 redis 键值对),保证只有一个线程获取锁成功(满足互斥)并执行业务逻辑,其他线程可以重试或返回失败。

  • 必须 setex 指定 lock 的过期时间(满足安全性)

注意事项:

  • 为了防止 setnx 后就宕机了导致 lock 永久存在,必须使用 set [key] ex nx 的原子命令,保证每个 lock 都有过期时间。

  • 锁的 key 值建议设计为包含 userId 的,保证多个用户可以并发执行操作,而不是多个用户抢同一把锁。

释放锁:

  • 主动释放:业务执行完成删掉 key,注意需要把释放锁的逻辑放到 finally 里保证一定执行

  • 超时自动释放(key 过期)

流程图:

image-20231009184015775

代码

接口

public interface ILock {
   /**
    * 尝试获取锁
    * @param timeoutSec 锁持有的超时时间,过期后自动释放
    * @return true代表获取锁成功;false代表获取锁失败
    */
   boolean tryLock(long timeoutSec);
   /**
    * 释放锁
    */
   void unlock();
}

SimpleRedisLock

加锁

利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性

   @Override
   public boolean tryLock(long timeoutSec) {
       // 获取线程标示
       long threadId = Thread.currentThread().getId();
       // 获取锁
       Boolean success = stringRedisTemplate.opsForValue()
              .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
       return Boolean.TRUE.equals(success);
  }

释放锁

public void unlock() {
   //通过del删除锁
   stringRedisTemplate.delete(KEY_PREFIX + name);
}

完整代码

public class SimpleRedisLock implements ILock {
   private String name;
   private StringRedisTemplate stringRedisTemplate;
   private static final String KEY_PREFIX="lock:";
   public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
       this.name = name;
       this.stringRedisTemplate = stringRedisTemplate;
  }
   @Override
   public boolean tryLock(long timeoutSec) {
       // 获取线程标示
       long threadId = Thread.currentThread().getId();
       // 获取锁
       Boolean success = stringRedisTemplate.opsForValue()
              .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
       return Boolean.TRUE.equals(success);
  }
   @Override
   public void unlock() {
       //通过del删除锁
       stringRedisTemplate.delete(KEY_PREFIX + name);
  }
}

修改业务代码

   @Resource
   private StringRedisTemplate stringRedisTemplate;
@Override
   public Result seckillVoucher(Long voucherId) {
       // 1.查询优惠券
       SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
       // 2.判断秒杀是否开始
       if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
           // 尚未开始
           return Result.fail("秒杀尚未开始!");
      }
       // 3.判断秒杀是否已经结束
       if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
           // 尚未开始
           return Result.fail("秒杀已经结束!");
      }
       // 4.判断库存是否充足
       if (voucher.getStock() < 1) {
           // 库存不足
           return Result.fail("库存不足!");
      }
       Long userId = UserHolder.getUser().getId();
       //创建锁对象(新增代码)
       SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
       //获取锁对象
       boolean isLock = lock.tryLock(1200);
//加锁失败
       if (!isLock) {
           return Result.fail("不允许重复下单");
      }
       try {
           //获取代理对象(事务)
           IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
           return proxy.createVoucherOrder(voucherId);
      } finally {
           //释放锁
           lock.unlock();
      }
  }

误删问题

情况1

如果线程 A 执行业务时间过长,锁提前过期了,另一个线程 B 会拿到锁并执行业务流程,这时 A 又突然执行完了,结果误删了线程 B 加的锁,会导致新的线程 C 又能拿到锁,从而出现线程安全问题。

如图:

image-20231009201600762

情况1 - 解决方案

获取锁的时候在 value 中存入【本机标识 + 当前线程 id】,释放锁时检测 value 必须等于该值,是自己的锁才能释放。 注意,不能只在 value 中存入线程 id,因为多个机器的线程 id 可能是一样的,仍然可能会出现误删。因此可以给每个机器生成一个唯一标识(比如 UUID),再拼接 id。

流程图:

image-20231009201654786

具体代码

加锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
  // 获取线程标示
  String threadId = ID_PREFIX + Thread.currentThread().getId();
  // 获取锁
  Boolean success = stringRedisTemplate.opsForValue()
              .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
  return Boolean.TRUE.equals(success);
}
释放锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
  // 获取线程标示
  String threadId = ID_PREFIX + Thread.currentThread().getId();
  // 获取锁
  Boolean success = stringRedisTemplate.opsForValue()
              .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
  return Boolean.TRUE.equals(success);
}

情况2

假设线程 A 释放锁时已经判断了是自己加的锁,但就在这时,JVM 触发了 Stop The World,线程 A 卡住了,然后锁超时释放了,线程 B 就拿到了锁并执行业务。这时,线程 A 又 “醒了”,删除了锁 key,线程 C 又可以拿到锁并执行了,又出现了线程安全问题。

如下图:

1653387764938

情况2 - 解决方案

问题的本质是判断锁value和删除锁是两个动作,不具备原子性!

原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

一个很经典的例子就是银行账户转账问题:

比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

可以使用Redis lua脚本,将多个Redis命令放到一个脚本中,整个脚本执行具备原子性。

Lua 语言是轻量脚本语言,可以很方便地嵌入到各种应用程序中。 不用刻意去学习,随用随查:https://www.runoob.com/lua/lua-tutorial.html

Lua脚本可以使用redis.call调用redis命令,并支持传递动态参数:

语法如下:

redis.call('命令名称', 'key', '其它参数', ...)

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

1653392181413

例如,我们要执行 redis.call('set', 'name', 'jack') 这个脚本,语法如下:

1653392218531

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

1653392438917

Lua脚本代码:

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
 -- 一致,则删除锁
 return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

利用Java代码调用Lua脚本改造分布式锁

在RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图

1653393304844

Java代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
   static {
       UNLOCK_SCRIPT = new DefaultRedisScript<>();
       UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
       UNLOCK_SCRIPT.setResultType(Long.class);
  }
public void unlock() {
   // 调用lua脚本
   stringRedisTemplate.execute(
           UNLOCK_SCRIPT,
           Collections.singletonList(KEY_PREFIX + name),
           ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~

其他问题

除了误删之外,现在的分布式锁实现还存在以下几个问题:

  1. 不可重入:同一个线程无法获取同一把锁(递归调用或调用的子函数抢同一把锁时就会出现死锁)

  2. 不可重试:没抢到锁就失败了

  3. 超时释放:业务未执行完,锁就超时释放了

  4. 主从一致性:主节点设置锁成功,还未及时同步到从节点,这时主节点宕机,从节点被选为主节点。但此时从节点还没有锁,仍可以抢锁成功。

要自己解决这些问题,非常麻烦,所以我们一般会选择现成的类库,比如 Redisson。

本文作者:万事胜意k

本文链接:https://www.cnblogs.com/ysk0904/p/17753152.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   万事胜意k  阅读(59)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起