• 博客园logo
  • 会员
  • 周边
  • 新闻
  • 博问
  • 闪存
  • 众包
  • 赞助商
  • Chat2DB
    • 搜索
      所有博客
    • 搜索
      当前博客
  • 写随笔 我的博客 短消息 简洁模式
    用户头像
    我的博客 我的园子 账号设置 会员中心 简洁模式 ... 退出登录
    注册 登录
道阻且长,行而将至
博客园    首页    新随笔    联系   管理    订阅  订阅
分布式锁

 分布式锁的使用场景

为了保证一个方法 或属性在高并发情况下的同一时间只能被同一个线程执行,在单机部署的情况下,可以使用java并发处理相关的API(ReentrantLock或Synchronized)进行互斥控制。但是,随着业务发展的需要,原单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,单纯的java Api并不能提供分布式的能力。为了解决这个问题就分布式系统之间的共享资源的访问 ,需要引入分布式锁。
  • 库存超卖

    两个订单系统同时下单-->查询库存-->并发扣减库存-->创建订单,导致库存超卖

  •  

     

       分布式锁接口库存超卖:下单-->加分布式锁成功-->查询库存-->扣减库存 --> 创建订单--> 释放锁

 

 

 

  • 重复下单
同一个下单页面,手速过快点了多次下单,在毫秒级别触发了多次下单接口,会导致下了多笔重复订单,为了避免这种毫秒级别的重复下单,可以使用分布式锁,对乘客id进行加锁,过期时间设为毫秒级别。
  • 司机重复抢单

 

实现方式

总的来说,可以使用redis或者zookeeper来实现分布式,以下分别就两种方式的不同实现方法进行介绍。

 

第一种:使用redis的setnx命令进行实现

redis命令:SET my:lock 随机值 NX PX 30000

如果此时redis中没有my:lock这个key,命令返回成功,表示设置加锁成功,并对key设置一个过期时间,若此时已有该key,返回nil,不做任何操作,表示加锁失败

 

加锁代码

正确加锁代码 

public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);

        if ("OK".equals(result)) {
            return true;
        }
        return false;

    }

}

 

 我们加锁的代码就是使用jedis执行setnx命令:String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);

  • lockKey表示加锁的名称。

  • requestId表示value,是一个随机值,可以作为解锁的依据,解锁时校验下该值是否和客户端传过来的值一致,一致才可以删除该锁,因为如果客户端A获取到了锁,但是阻塞了很长时间才执行完,此时可能已经自动释放锁了,此时可能别的客户端B已经获取到了这个锁,要是这个时候客户端A直接删除key的话就会出行问题。

    requestId可以使用UUID.randomUUID().toString()方法生成。

  • NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

  • PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。

  • expireTime,与第四个参数相呼应,代表key的过期时间。

 

错误加锁代码

比较常见的错误示例就是使用jedis.setnx()和jedis.expire()组合实现加锁,代码如下: 

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {

    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }

}

首先setnx()方法就是执行SET IF NOT EXIST,expire()方法就是给锁加一个过期时间,这种方式加锁存在的问题是,因为是两条redis命令,不具有原子性,setnx命令执行成功后,可能expire方法会执行失败,这样锁就没有过期时间,会发生死锁。

 

 解锁代码

正确解锁代码

 

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

这段代码使用eval()方法执行了一段lua脚本,这段lua脚本做了什么呢?

  • 首先获取key对应的value,判断value和传入的requestId是否一致
  • 如果一致,则删除key
  • 如果不一致,则直接返回

在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。所以以上三步是一个原子性的操作,可以避免非原子性带来的一些线程安全问题。

 

错误解锁方式1

最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。

 
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    jedis.del(lockKey);
}

 

错误解锁方式2

这个方式和正确的方式很类似,但是是分成两个命令执行的,也就是说两步操作不是原子性的,会带来一些问题,比如线程A执行完第一个命令之后,锁到期了自动释放,线程B加锁成功,这时线程A执行到第二个命令直接把线程B的锁删除了,这显然是有问题的。

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
        
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }

}

 

总结

  • setnx命令的方式可以保证分布式锁的互斥性, 在任一时刻,只有一个客户端加锁
  • 如果客户端给锁加上过期时间的话,即使客户端在持有锁期间突然崩溃而没有解锁,也不会发生死锁,锁会到期自动释放
  • 存在的问题:由于加锁时间是需要程序设置的,这个过期时间可能会把握不好,如果线程A加锁成功后,代码还没有执行完,锁就到期了被自动释放,别的客户端就会获取到锁,导致程序发生问题。

 

 

第二种:使用redis的redisson类库进行实现

redisson是一款优秀的redis开源框架,对分布式锁的支持非常不错,支持可重入锁、公平锁、红锁、读写锁、信号量等,可参考:

https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95

  • redisson内部封装了复杂的加锁逻辑,加锁和解锁只需要一行代码就能搞定,非常简单方便
  • redisson对分布式锁的支持很好,支持可重入锁、公平锁、红锁、读写锁、信号量等
  • redisson可以解决上面提到的setnx存在的问题:如果线程A加锁成功后,代码还没有执行完,锁就到期了被自动释放,别的客户端就会获取到锁,导致程序发生问题
  • redisson不会发生死锁

 接下来我们来看下redisson加锁代码以及它的原理,

 

代码实现

首先引入pom

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.8.1</version>
</dependency> 

 

创建redisson客户端实例

 @Value("${config.redis.host}")
    private String host;
    @Value("${config.redis.port}")
    private String port;

    @Bean
    public Redisson redisson() {
        // 此为单机模式,当然也可以使用cluster模式
        Config config = new Config();
        config.useSingleServer().setAddress("redis://"+host+":"+port).setDatabase(0);
        return (Redisson) Redisson.create(config);
        
    }

加锁代码

 @Autowired
    private StringRedisTemplate template;

    @Autowired
    private Redisson redisson;

    @GetMapping("/prod_stock")
    public String stock(){
        String lock_key = "anyLock";
        RLock lock = redisson.getLock(lock_key);//第一步,获取锁
        try {
            lock.lock();//第二步,加锁
            int stock = Integer.parseInt(template.opsForValue().get("stock"));
            if (stock>0) {
                template.boundValueOps("stock").increment(-1);
                log.info("库存扣减成功,剩余库存:{}",stock - 1);
            }else {
                log.info("库存扣减失败,库存不足");
            }
        } finally {
            lock.unlock();//第三步,释放锁
        }
        return "hi";
    }

可以看到,使用redisson加锁和解锁非常简单,只需要一行代码,加锁:lock.lock();    解锁:lock.unlock();  注意解锁的代码一定要放到finally里,这样即使代码发生异常也能执行解锁代码

 

原理

我们来解读下lock()方法的原理,看下redisson是如何做到可以避免死锁发生,以及避免任务没有执行完锁就到期自动释放, 怎么实现的可重入锁等问题。

首先来看下redisson的执行加锁和解锁的流程:

加锁机制:

  1.  客户端A根据hash算法,计算key所在的slot(hash值对16384取模),选择slot所在的master
  2.  执行加锁逻辑:在目标master上执行一段lua脚本,代码如下:
  <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "return redis.call('pttl', KEYS[1]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

ARGV[2] 表示的是客户端线程的唯一标识,格式是这样的:   UUID:线程id ,例:8743c9c0-0795-4907-87fd-6c719a6b4586:1  ,UUID代表的是Redisson客户端标识,是一开始初始化客户端的时候就生成的。

ARGV[1] 代表的是锁key的生命周期,默认是30s

KEYS[1] 代表的是锁的值

这段脚本首先判断key是否存在,不存在的话执行hset命令,命令执行完之后,会出现这样的一个Hash数据结构:"8743c9c0-0795-4907-87fd-6c719a6b4586:1 "代表了客户端的某一线程,1代表的是加锁次数

 

 

可重入锁机制:

key已存在会执行第二个if,表示有客户端加锁成功,继续判断线程唯一标识(UUID:线程id)是否存在,存在则表示是同一线程加锁,则执行hincrby将线程的加锁次数加1,这段逻辑就保证了可重入锁,同一个线程可以重复加锁,加一次锁,加锁次数就增加1 

 

锁互斥机制:

当锁已经被一个线程占住时,此时有别的客户端线程来加锁,那么

锁已存在,所以第一个if不成立;不是同一个线程加的锁,所以第二个if不成立

所以会走到pttl key这个命令,返回key的剩余生命周期。而且之后会进入一个while死循环,每隔一段时间(这个时间正是lua脚本返回的锁过期时间)就进行加锁尝试;

 

锁自动延期机制

我们上面讨论了一种异常情况:如果线程A加锁成功后,代码还没有执行完,锁就到期了被自动释放,别的客户端就会获取到锁,导致程序发生问题。

那么redisson是怎么解决这个问题的呢?依赖的watch dog机制:

 客户端加锁成功后,redisson会启动一个后台定时任务,叫做watch dog,每隔10s就会判断客户端是否还持有锁,如果持有,会自动延长锁的过期时间,重新设置为30秒,只要客户端没有释放这个锁,watch dog就会不断地去重复延长key的生存周期;

 当然了,如果这个key已经被客户端删除,就停止调度任务。

 

 

 解锁原理:

 unlock方法也是执行了一段lua脚本:如果锁的key不存在,直接返回;如果存在,将加锁次数减1,再判断加锁次数是不是0,是0的话直接删除key,不是0的话证明客户端还持有锁,将锁的过期时间延长30s。

                "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; " +
                "end;" +
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                "end; " +
                "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "return 0; " +
                "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; "+
                "end; " +
                "return nil;",

 

防死锁机制:

如果一个客户端加锁成功后,在解锁之前就发生了宕机,会不会发生死锁呢?

答案是否定的,因为如果持有锁的机器宕机了,就会导致该机器上的watch dog任务,也就是每10s执行一次的定时任务,也就不会执行了,那么anyLock这把锁对应的key会在30s以内自动过期,锁就释放了。

 

总结:

这种方式非常简单,加锁和解锁一行代码就可以搞定,另外还支持可重入锁,可防止死锁,锁到期后自动延期。不过使用这种方式也会有问题,因为是redis的master-slave主从架构,主从之间是异步复制的,如果客户端成功将key写入其中一个master,但还没来得及复制到slave,master就宕机了,这时候发生主从切换,slave变成了新的master,但是这时候上面并没有key,这时别的客户端来加锁时就成功了,这样就导致了多个客户端同时加锁的问题。

 

第三种方式:RedLock算法 

这其实是一种算法思想,为了解决上面说的master节点宕机导致的多个客户端同时加锁成功问题 ,简单介绍一下这种算法的思想,redisson也有关于红锁相关的api,感兴趣的同学可以了解下

红锁采用主节点过半机制,即获取锁或者释放锁成功的标志为:在过半的节点上操作成功。

有N个Redis master节点,这些节点都是完全独立的,执行如下步骤获取一把锁

1)获取当前时间戳,单位是毫秒

2)跟上面类似,轮流尝试在每个master节点上创建锁,过期时间较短,一般就几十毫秒

3)尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1)

4)客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了

5)要是锁建立失败了,那么就依次删除这个锁

6)只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁

 

第四种方式:zookeeper

可以利用zookeeper的临时顺序节点来实现一套分布式锁的逻辑,另外zk的开源框架curator也对分布式锁做了很好的支持

====未完待续====

posted on 2021-11-30 11:35  须臾静静  阅读(114)  评论(0)    收藏  举报
刷新页面返回顶部
博客园  ©  2004-2026
浙公网安备 33010602011771号 浙ICP备2021040463号-3