Redis 分布式锁

一、简介

       分布式锁,即支持分布式集群环境下的锁:查询DB,只有一个线程能访问,其他线程都需要等待第一个线程释放完锁资源后,竞争获取锁后才能继续执行。

二、本地锁

        假设微服务被拆分为4个,前端发起10w请求,被转发到不同的微服务,每个微服务接收2.5w个请求。假如缓存失效,每个微服务在访问数据库时加锁,通过锁(synchronizied或者lock)来锁住自己的线程资源,从而防止缓存击穿。

       问题:分布式情况下会带来数据不一致问题。如服务A获取数据后,更新缓存Key = 100,服务B不受服务A的锁限制,并发去更新缓存Key = 99,最后结果可能是99或者100,是一种未知状态,与期望结果不一致。

三、Redis的SETNX

       用Redis实现分布式锁,都是用SETNX命令。高阶方案的参数不一样而已。

      SETNX:set if not exist。当key不存在,设置key的值,如存在,啥也不做。

      在Redis命令行执行命令:      

set <key> <value> NX

     在Redis容器中执行SETNX命令:

docker exec -it <容器 id> redid-cli

    返回ok,表示设置成功。返回nil表示设置失败。

四、青铜方案      

       4.1、流程图

        4.2、步骤:

    1. 多个并发线程都去Redis中申请锁,也就是执行setnx命令。假设线程A执行成功,说明当前线程A获取了锁。
    2. 其他线程执行setnx命令都会失败,所以需要等待线程A释放锁。
    3. 线程A执行完自己的业务后,删除锁。
    4. 其他线程继续抢占锁。因为线程A已经删除,所以其他线程可以抢到锁。

      4.3、代码:

       以java为例,java中setnx命令对应代码为setifAbsent,方法第一个参数代表key,第二个参数代表value。

// 1.抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if(lock) {
  // 2.如抢占成功,执行业务
  List<TypeEntity> typeEntityListFromDb = getDataFromDB();
  // 3.释放锁
  redisTemplate.delete("lock");
  return typeEntityListFromDb;
} else {
  // 4.休眠一段时间
  sleep(100);
  // 5.抢占失败,等待锁释放
  return getTypeEntityListByRedisDistributedLock();
}

     Tips:为何要sleep一段时间?因为该程序存在递归调用,可能会导致栈空间溢出。

     4.4、缺陷与解决方案

             【缺陷】:setnx抢占锁成功,业务代码出现异常或者服务器宕机,没有执行删除锁的逻辑,就会导致死锁

              【解决方案】:设置锁自动过期时间。过一段时间后,自动删除锁,其他线程就能获取到锁。

五、白银方案

       5.1、流程图

       5.2、代码

               清理redis key代码如下:

// 在 10s 以后,自动清理 lock
redisTemplate.expire("lock", 10, TimeUnit.SECONDS);

              完整代码如下:

// 1.先抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if(lock) {
    // 2.在 10s 以后,自动清理 lock
    redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
    // 3.抢占成功,执行业务
    List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    // 4.解锁
    redisTemplate.delete("lock");
    return typeEntityListFromDb;
}

       5.3、缺陷和解决方案

                白银方案看似解决了线程异常或服务器宕机导致的锁未释放问题,但还有其他问题存在。

               【缺陷】:占锁和设置过期是分两步执行的,所以如果在这两步之间发生异常,则锁的过期时间根本就没有设置成功。所以和青铜方案一样问题:锁永远不能过期。

六、黄金方案

       6.1、原子指令

                原子性:多条指令要么都执行成功,要么都不执行。

                合并两步放在一步中执行:占锁+设置过期时间。

                Redis正好支持这种操作:

# 设置某个 key 的值并设置多少毫秒或秒 过期。
set <key> <value> PX <多少毫秒> NX
或
set <key> <value> EX <多少秒> NX

                 之后通过命令查看key变化

ttl <key>

                整体指令

# 设置 key=wukong,value=1111,过期时间=5000ms
set wukong 1111 PX 5000 NX
# 查看 key 的状态
ttl wukong

   6.2、流程图

 

       代码:

      设置lock的值等于123,过期时间10秒,10秒后,lock还存在,则清理lock。  

setIfAbsent("lock", "123", 10, TimeUnit.SECONDS);

     6.3、缺陷与解决方案

    【缺陷】: 如果用户A处理任务所需时间大于锁自动释放时间,爱自动开锁后,又有其他用户抢到了锁。且都是锁编码为“123”,就会发生·冲突。

    【解决方案】:给锁设定不同编码。

七、铂金方案

       7.1、流程图

 

 

  1. 设置锁的过期时间时,还需要设置唯一编码;
  2. 主动删除锁的时候,需要判断编号是否和设置的一致,如果一致,则认为是自己设置的锁,就可以主动删除。

        7.2、代码

// 1.生成唯一 id
string uuid = UUID.randomUUID().toString();
// 2. 抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
if(lock) {
    System.out.println("抢占成功:" + uuid);
    // 3.抢占成功,执行业务
    List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    // 4.获取当前锁的值
    String lockValue = redisTemplate.opsForValue().get("lock");
    // 5.如果锁的值和设置的值相等,则清理自己的锁
    if(uuid.equals(lockValue)) {
        System.out.println("清理锁:" + lockValue);
        redisTemplate.delete("lock");
    }
    return typeEntityListFromDb;
} else {
    System.out.println("抢占失败,等待锁释放");
    // 4.休眠一段时间
    sleep(100);
    // 5.抢占失败,等待锁释放
    return getTypeEntityListByRedisDistributedLock();
}

   步骤:

  1. 生成随机唯一id,给锁加上唯一值;
  2. 抢占做,设置过期时间为10s,且锁具有随机唯一id;
  3. 抢占成功,执行业务;
  4. 执行完业务后,获取当前锁的值;
  5. 如果值和设置的值相等,这清理自己的锁。

    7.3、缺陷

         第4步获取锁和第5步释放锁并非原子性。

         假设线程A获取到锁,然后向Redis查询到当前的Key值。之后锁过期释放,线程B抢到了锁。线程A在查询中耗时长,最后拿到了锁,然后比较值,发现相等,清理锁,但是实际这个锁是线程B的。

八、钻石方案

       只要保证线程查询锁和删除锁的逻辑是原子性即可,我们吧可以采用lua脚本。Redis本身也支持lua脚本,lua脚本本身就是原子性的。

 

       8.1、代码示例   

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

      这段脚本在java执行:先定义脚本,再用redisTemplate.execute 方法执行脚本。   

// 脚本解锁
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<Long>(script, Long.class), Arrays.asList("lock"), uuid);

    上面代码中,KEYS[1]对应“lock”,ARGV[1]对应“uuid”,如果lock的value等于uuid则删除lock。因为这段Redis脚本是由Redis内嵌到Lua环境执行的,所以又称为Lua脚本。

九、王者

       分布式锁的王者:Redision。优点是基本比较完美,缺陷是只能在java环境使用,而且是高级功能付费使用。

十、总结    

青铜方案

    • 缺陷:业务代码出现异常或者服务器宕机,没有执行主动删除锁的逻辑,就造成了死锁。
    • 改进:设置锁的自动过期时间,过一段时间后,自动删除锁,这样其他线程就能获取到锁了。

白银方案

    • 缺陷:占锁和设置锁过期时间是分步两步执行的,不是原子操作。
    • 改进:占锁和设置锁过期时间保证原子操作。

黄金方案

    • 缺陷:主动删除锁时,因锁的值都是相同的,将其他客户端占用的锁删除了。
    • 改进:每次占用的锁,随机设为较大的值,主动删除锁时,比较锁的值和自己设置的值是否相等。

铂金方案

    • 缺陷:获取锁、比较锁的值、删除锁,这三步是非原子性的。中途又可能锁自动过期了,又被其他客户端抢占了锁,导致删锁时把其他客户端占用的锁删了。
    • 改进:使用 Lua 脚本进行获取锁、比较锁、删除锁的原子操作。

钻石方案

    • 缺陷:非专业的分布式锁方案。
    • 改进:Redission 分布式锁。

 十一、推荐值

           Redis设置超时时间30s,重试次数300次,重试等待时间10ms,可以根据实际需要灵活修改。

posted on 2024-02-20 16:42  木乃伊人  阅读(67)  评论(0编辑  收藏  举报

导航