分布式锁

1.什么是分布式锁

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

2.分布式锁的实现

分布式锁的核心是实现多线程之间的互斥,而满足这一点的方式有很多,常见的有三种:

Mysql Redis Zookeeper
互斥 利用mysql本身互斥锁机制 利用setnx这样的互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接,自动释放锁 利用锁超时的时间,到期释放 临时节点,断开连接自动释放

实现 分布式锁时需要实现的两个基本方法:

  • 获取锁:
    • 互斥:确保只能有一个线程获取锁
      # 添加锁,利用setnx的互斥特性
      SETNX lock thread1
      # 添加锁过期时间,避免服务宕机引起的死锁现象
      EXPIRE lock 10
      # 改善应该放在一起写,分开写的话在  SETNX lock thread1执行后, EXPIRE lock 10执行前,如果服务器发生宕机,lock将无法释放
        # 添加锁,NX是互斥,EX是设置过期时间
        SET lock thread1 NX EX 10
  • 释放锁:
    • 手动释放
    • 超时释放: 获取锁时添加一个超时时间
      # 释放锁,删除即可
      DEL key
      流程图

2.1 Redis实现分布式锁的初级版本

public interface ILock{
    //尝试获取锁
    boolean tryLock(long timeoutSec);
    //释放锁
    void unLock();
}
//接口的实现
package com.zhn.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {
    private StringRedisTemplate stringRedisTemplate;
    //定义业务名称
    private String name;
    private static final String KEY_PREFIX = "lock:";

    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    String key = KEY_PREFIX+name;
    @Override
    public boolean tryLock(long timeoutSec) {
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key,String.valueOf(threadId), timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //防止自动拆箱产生空指针风险
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(key);
    }
}

2.2 Redis实现分布式锁的误删(锁)问题

2.2.1 误删问题1

误删(锁)问题

**问题产生原因:**业务阻塞超过锁的超时时间导致锁提前释放,业务执行完释放的锁不是自己线程的锁,造成误删锁

改进分布式锁:
需求 :修改之前的分布式锁实现

解决思路:

  1. 在获取锁时存入线程标示(可以用UUID表示)
  2. 在释放锁的时候先获取锁中的线程标示,判断是否与当前线程标示一致
    • 如果一致则释放锁
    • 如果不一致则不释放锁
      解决思路
      流程图

2.2.1 误删问题2(原子性问题)

线程1判断锁的标识符一致后,由于JVM的垃圾回收机制,导致业务阻塞,当阻塞时间超过了锁的超时释放时间,会产生误删(锁)现象

实现原子性方案:
Redis的lua脚本:
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令的原子性.Lua是一种编程语言,参考网站Lua教程
这里重点介绍Redis提供的调用函数,语法如下:

# 执行redis命令
redis.call('命令名称','key','其他参数',...)
```java
例如,当执行set name jack,则脚本是这样的:
```java
redis.call('set','name','jack')

例如,我们要先执行set name Rose,再执行get name,则脚本如下:

//先执行 set name jack
redis.call('set','name','jack')
local name = redis.call('get','name')
return name

写好脚本后,需要使用redis命令来调用脚本,调用脚本的常用命令如下:

127.0.0.1:6379> help @scripting

  EVAL script numkeys key [key ...] arg [arg ...]
  summary: Execute a Lua script server side
  since: 2.6.0

  EVALSHA sha1 numkeys key [key ...] arg [arg ...]
  summary: Execute a Lua script server side
  since: 2.6.0

  SCRIPT DEBUG YES|SYNC|NO
  summary: Set the debug mode for executed scripts.
  since: 3.2.0

  SCRIPT EXISTS sha1 [sha1 ...]
  summary: Check existence of scripts in the script cache.
  since: 2.6.0

  SCRIPT FLUSH [ASYNC|SYNC]
  summary: Remove all the scripts from the script cache.
  since: 2.6.0

  SCRIPT KILL -
  summary: Kill the script currently in execution.
  since: 2.6.0

  SCRIPT LOAD script
  summary: Load the specified Lua script into the script cache.
  since: 2.6.0

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

127.0.0.1:6379> eval "return redis.call('set','name','jack')" 0
OK

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

释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指示的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做
Lua改进分布式方案

RedisTemplate调用Lua脚本的API如下:

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
   static {
       UNLOCK_SCRIPT = new DefaultRedisScript<>();
       UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
       UNLOCK_SCRIPT.setResultType(Long.class);
   }
   ......
       @Override
   public void unlock() {
       stringRedisTemplate.execute(UNLOCK_SCRIPT,
               Collections.singletonList(key),
               ID_PREFIX + Thread.currentThread().getId());
   }

2.3 基于Redis的分布式锁优化

基于setnx实现的分布式锁存在下面的问题:

  1. 不可重入
    • 同一个线程无法多次获取同一把锁
  2. 不可重试
    • 获取锁只尝试一次就返回false,没有重试机制
  3. 超时释放
    • 锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁被释放,存在安全隐患
  4. 主从一致性
    • 如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现

2.3.1 Redisson

Redisson是一个在Redis的基础上实现的Java驻内数据网络。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
点击跳转Redisson Github地址

  1. 引入依赖:
    <!--redisson-->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.13.6</version>
    </dependency>
  2. 配置Redisson客户端
    @Configuration
    public class RedisConfig {
        @Bean
        public RedissonClient redissonClient(){
            Config config = new Config();
            config.useSingleServer().setAddress("redis://192.168.6.129:6379").setPassword("101011");
            return Redisson.create(config);
        }
    }
    
  3. 使用Redisson的分布式锁
    @Resource
    RedissonClient redissonClient;
     RLock lock = redissonClient.getLock("lock:order:" + userId);
            //获取锁
            boolean isLock = lock.tryLock();
            if(!isLock)
            {
                //获取锁失败,返回错误信息或重试
                return Result.fail("不允许重复下单");
            }
            //获取代理对象(事务)
            try {
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                return proxy.createVoucherOrder(voucherId);
            }finally {
                //释放锁
                lock.unlock();
            }

2.3.2 Redisson可重入锁原理

概述:
什么是 “可重入”,可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁
可重入锁原理
利用Hash结构,记录线程标识,和获取锁的次数,引入了一个计数器;
方法A里面调方法B,A、B都要同一把锁,A一拿到锁,计数器+1 ,B拿到锁,计数器也+1,B执行完逻辑,计数器-1;A当业务执行完成之后,计数器-1,最后判断计数器的数是否为0,为0 ,说明所有业务执行完成,最后释放锁;
由于代码逻辑复杂,为了保证原子性,所以最后用lua脚本编写

总结

  1. 可重入:基于Hash结构,hash里field存储线程标识threaId,value存储重入次数,每一次获取锁的时候,先判断锁是否存在,不存在直接获取锁,如果存在,不代表获取锁失败了,再去判断线程的标识是不是当前线程threaId,是当前线程,可以再次获取,重入次数+1,释放锁的时候重入次数-1,直到重入次数为0,所有业务结束,再真正释放锁;实现锁的可重入,类似jdk的ReetrantLock;
  2. 可重试:利用信号量和消息订阅Pubsub机制,如果第一次获取锁失败,不是立即失败,而是等待释放锁的消息,获取锁成功的线程释放锁的时候会发送消息,从而被捕获到;当线程得到消息时,就可以重新获取锁,如此反复;超过了等待时间,就不会重试了;由于使用了等待、唤醒这样的方案,cpu的性能也不会过多的消耗;
  3. 锁超时释放:基于看门狗机制,获取锁成功之后开启一个定时 任务,每隔一段时间重置超时时间;

2.3.3 Redisson如何解决主从一致性问题

利用MultiLock —-联锁;
1、redis主从一致性发生的原因:Redis主节点处理写操作,从节点处理读操作,主从节点需要进行数据的同步,但是因为主从不在一个机器,同步会有延时,如果主节点突然故障了,同步没有完成,redis就会从从节点选出一个新的主节点,但由于主节点的锁没有及时同步,所以新的主节点没有锁,此时其他线程来获取锁也能成功,引发线程安全问题;
2、必须依次向redis多个节点都获取锁,全部获取了才算成功

总结
①不可重入Redis 分布式锁
原理:利用 setnx 的互斥性;利用 ex 避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效

②可重入的 Redis 分布式锁
原理:利用 hash 结构,记录线程标示和重入次数;利用 watchDog 延续锁时间;利用信号量控制锁重试等待
缺陷:Redis 宕机引起锁失效问题

③Redisson 的 multiLock连锁
原理:多个独立的 Redis 节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂