分布式锁的实现方式

  目前市面上很多网站及应用都是分布式部署的,分布式场景中,数据一致性问题一直都是比较重要的话题。在单机环境中,Java提供了很多方法去处理多线程并发方面的情况,例如synchronized,ReentrantLock,ReentrantReadWriteLock等等,而在分布式场景中,这些API就无能为力了,所以引入了分布式锁这个概念。

  

一、为什么要用分布式锁

  要想弄清楚分布式锁,前提就得知道什么是分布式,通俗点说就是创建了很多个服务器(这里不一定指服务器,也有可能是数据库),来分散用户的请求,让系统更快更稳定。从单机应用上来说,运用锁是为了保证在单机环境下多线程能一个一个的来执行代码,那分布式就可以说,运用锁就是为了保证即使在很多服务器下,多线程也能一个接着一个且同一时间只能一个的来执行业务代码。

  目的只有一个:保证数据的最终统一!

二、分布式锁的实现方式

  基于数据库

  基于缓存(这里主讲Redis)

  基于Zookeeper

  这是分布式锁的三种实现方式,但是目前应用大多是基于缓存来设计分布式锁的,因为Redis性能好,而且它的命令对此实现方便,而且老程序员都有句江湖话:缓存为大!可见缓存这门技术是多么受我们程序员的青睐。

三、基于数据库的实现方式

  基于数据库的实现一种方式为:创建一张表,其中有个字段专门去存被锁定的方法名(我们一般存的是类名.方法名.参数类型,多个参数类型用逗号隔开,虽然较长,但是能保证排除重载情况),要锁住某个资源或方法时,新增一条数据,一旦想解开,删除即可。

  创建表:

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

  锁定资源时,插入数据:

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

  因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

  当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:

delete from methodLock where method_name ='method_name'

  

四、基于Redis缓存的实现方式

  基于Redis缓存的实现主要判断依据是依靠Redis的key是唯一这个特性,以获取key成功与否(key是否存在)为依据来进行加锁与解锁。

  加锁解锁工具类:

@Component
public class RedisDisLock {
  private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     * @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, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

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

    }
}

  

 

  可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  • 第一个为key,我们使用key来当锁,因为key是唯一的。

  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。

  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

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

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

  总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

  心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

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;

    }

}

  可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

  那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。那么为什么执行eval()方法可以确保原子性,简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

  前几天,被问到并发操作如何解决一条数据被请求多次处理事件的技术解决方案,当时只有一点印象,利用锁机制,但是读写锁对于分布式部署的项目来说是无法实现的。事后去查看博友们提供的技术方案。决定基于redis自己尝试写一个分布式锁来实现多线程操作。类似一个秒杀功能吧,这里有个问题就是其实并不是线程抢到了 就一定能秒杀成功。有可能此时正在被写,处于锁的状态。当执行结束,下个线程再赋予一个自己的锁进行写操作。所以秒杀活动并不是越快越好,也有一定的随机机制。

五、基于Zookeeper实现分布式锁

  基于zookeeper临时有序节点可以实现的分布式锁。

  大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。
判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。
当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

来看下Zookeeper能不能解决前面提到的问题。

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。

  • 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。

  • 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

  本文主要介绍了如何使用Java代码正确实现Redis分布式锁,对于加锁和解锁也分别给出了两个比较经典的错误示例。其实想要通过Redis实现分布式锁并不难,只要保证能满足可靠性里的四个条件。互联网虽然给我们带来了方便,只要有问题就可以google,然而网上的答案一定是对的吗?其实不然,所以我们更应该时刻保持着质疑精神,多想多验证。

原文地址:https://www.cnblogs.com/jiangbotao233/p/10182403.html