什么是分布式锁
在学习 Java 多线程编程的时候, 锁是一个很重要也很基础的概念, 锁可以看做是多线程情况下访问共享资源的一种线程同步机制. 这是对于单进程应用而言的, 即所有线程都在同一个 JVM 进程里的时候, 使用 Java 语言提供的锁机制可以起到对共享资源进行同步的作用. 如果分布式环境下多个不同线程需要对共享资源进行同步, 那么用 Java 的锁机制就无法实现了, 这个时候就必须借助分布式锁来解决分布式环境下共享资源的同步问题. 分布式锁有很多种解决方案, 今天我们要讲的是怎么使用缓存数据库 Redis 来实现分布式锁.
Redis 分布式锁方案一
使用 Redis 实现分布式锁最简单的方案是在获取锁之前先查询一下以该锁为 key 对应的 value 存不存在, 如果存在, 则说明该锁被其他客户端获取了, 否则的话就尝试获取锁, 获取锁的方法很简单, 只要以该锁为 key, 设置一个随机的值就行了. 比如, 我们有一批任务需要由多个分布式线程处理, 每个任务都有一个 taskId, 为了保证每个任务只被执行一次, 在工作线程执行任务之前, 先获取该任务的锁, 锁的 key 可以为 taskId. 因此, 获取锁的过程可以用如下伪代码实现:
- function boolean getLock(taskId) {
- if (existsKey(taskId)) {
- return false;
- } else {
- setKey(taskId);
- return true;
- }
- }
上述就是最简单的获取锁的方案了, 但是大家可以想想这个方案有什么问题呢? 有没有什么潜在的坑? 在分析这种方案的优缺点之前, 先说一下获取锁后我们一般是怎么使用锁, 并且又是如何释放锁的, 以 Java 语言为例, 我们一般获取锁后会将释放锁的代码放在 finally 块中, 这样做的好处是即使在使用锁的过程中出现异常, 也能顺利将锁释放掉. 用伪代码描述如下:
- boolean lock = false;
- try {
- lcok = getLock(taskId); // 获取锁 if(lock){ doSomething(); // 业务逻辑 } }finally{
- if (lock) {
- releaseLock(taskId); // 释放锁
- }
- }
其中, getLock 方法的伪代码上文已经给出, releaseLock 方法是释放锁的方法, 在该方案中, 只是简单地删除掉 key, 就不给出伪代码了.
上述使用锁的代码咋一看是没有什么问题的, 学过 Java 的人都知道, 在 try...finally... 代码块中, 即使 try 代码块中抛出异常, 最终也会执行 finally 代码块, 然而这样就能保证锁一定会被释放吗? 考虑这样一种情况: 代码执行到 doSomething() 方法的时候, 服务器宕机了, 这个时候 finally 代码块就没法被执行了, 因此在这种情况下, 该锁不会被正常释放, 在上述案例中, 可能会导致任务漏算. 因此, 这种方案的第一个问题是会出现锁无法正常释放的风险, 解决这个问题的方法也很简单, Redis 设置 key 的时候可以指定一个过期时间, 只要获取锁的时候设置一个合理的过期时间, 那么即使服务器宕机了, 也能保证锁被正确释放.
该方案的另外一个问题是, 获取到的锁不一定是排他锁, 也就是说同一把锁同一时间可能被不同客户端获取到. 仔细分析一下 getLock 方法, 该方法并不是原子性的, 当一个客户端检查到某个锁不存在, 并在执行 setKey 方法之前, 别的客户端可能也会检查到该锁不存在, 并也会执行 setKey 方法, 这样一来, 同一把锁就有可能被不同的客户端获取到了.
既然这种方案有以上缺点, 那么该如何改进呢? 且听我慢慢道来.
Redis 分布式锁方案二
上一小节的方案有 2 个缺点, 一个是获取的锁可能无法释放, 另一个是同一把锁在同一时间可能被不同线程获取到. 通过查看 Redis 文档, 可以找到 Redis 提供了一个只有在某个 key 不存在的情况下才会设置 key 的值的原子命令, 该命令也能设置 key 值过期时间, 因此使用该命令, 不存在上述方案出现的问题, 该命令为:
SET my_key my_value NX PX milliseconds
其中, NX 表示只有当键 key 不存在的时候才会设置 key 的值, PX 表示设置键 key 的过期时间, 单位是毫秒.
如此一来, 获取锁的过程可以用如下伪代码描述:
function boolean getLock(taskId,timeout){ return setKeyOnlyIfNotExists(taskId,timeout); }
其中, setKeyOnlyIfNotExists 方法表示的是原子命令 SET my_key my_value NX PX milliseconds.
如此一来, 获取锁的代码应该就没什么问题了, 但是这种方案还是会有其他问题. 大家再仔细研究下释放锁的代码. 因为现在我们设置 key 的时候也设置了过期时间, 所以原来的释放锁的代码现在看来就有问题了. 考虑这样一种情况: 客户端 A 获取锁的时候设置了 key 的过期时间为 2 秒, 然后客户端 A 在获取到锁之后, 业务逻辑方法 doSomething 执行了 3 秒 (大于 2 秒), 当执行完业务逻辑方法的时候, 客户端 A 获取的锁已经被 Redis 过期机制自动释放了, 因此客户端 A 在获取锁经过 2 秒之后, 该锁可能已经被其他客户端获取到了. 当客户端 A 执行完 doSomething 方法之后接下来就是执行 releaseLock 方法释放锁了, 由于前面说了, 该锁可能已经被其他客户端获取到了, 因此这个时候释放锁就有可能释放的是其他客户端获取到的锁.
Redis 分布式锁方案三
既然方案二可能会出现释放了别的客户端申请的锁的问题, 那么该如何进行改进呢? 有一个很简单的方法是, 我们设置 key 的时候, 将 value 设置为一个随机值 r, 当释放锁, 也就是删除 key 的时候, 不是直接删除, 而是先判断该 key 对应的 value 是否等于先前设置的随机值, 只有当两者相等的时候才删除该 key, 由于每个客户端产生的随机值是不一样的, 这样一来就不会误释放别的客户端申请的锁了. 新的释放锁的方案用伪代码描述如下:
function void releaseLock(taskId,random_value){ if(getKey(taskId)==random_value){ deleteKey(taskId); } }
其中, getKey 方法就是 Redis 的查询 key 值的方法, deleteKey 就是 Redis 的删除 key 值的方法, 在此不给出伪代码了.
那么这种方案就没有问题了吗? 很遗憾地说, 这种方案也是有问题的. 原因在于上述释放锁的操作不是原子性的, 不是原子性操作意味着当一个客户端执行完 getKey 方法并在执行 deleteKey 方法之前, 也就是在这 2 个方法执行之间, 其他客户端是可以执行其他命令的. 考虑这样一种情况, 在客户端 A 执行完 getKey 方法, 并且该 key 对应的值也等于先前的随机值的时候, 接下来客户端 A 将会执行 deleteKey 方法. 假设由于网络或其他原因, 客户端 A 执行 getKey 方法之后过了 1 秒钟才执行 deleteKey 方法, 那么在这 1 秒钟里, 该 key 有可能也会因为过期而被 Redis 清除了, 这样一来另一个客户端, 姑且称之为客户端 B, 就有可能在这期间获取到锁, 然后接下来客户端 A 就执行到 deleteKey 方法了, 如此一来就又出现误释放别的客户端申请的锁的问题了.
Redis 分布式锁方案四
既然方案三的问题是因为释放锁的方法不是原子操作导致的, 那么我们只要保证释放锁的代码是原子性的就能解决该问题了. 很遗憾的是, 查阅 Redis 开发文档, 并没有发现相关的原子操作. 不过幸运的是, 在 Redis 中执行原子操作不止有通过官方提供的命令的方式, 还有另外一种方式, 就是 Lua 脚本. 因此, 方案三中的释放锁的代码可以用以下 Lua 脚本来实现:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
其中 ARGV[1] 表示设置 key 时指定的随机值.
由于 Lua 脚本的原子性, 在 Redis 执行该脚本的过程中, 其他客户端的命令都需要等待该 Lua 脚本执行完才能执行, 所以不会出现方案三所说的问题. 至此, 使用 Redis 实现分布式锁的方案就相对完善了.
总结
上述分布式锁的实现方案中, 都是针对单节点 Redis 而言的, 然而在生产环境中, 我们使用的通常是 Redis 集群, 并且每个主节点还会有从节点. 由于 Redis 的主从复制是异步的, 因此上述方案在 Redis 集群的环境下也是有问题的. 关于在 Redis 集群中如何优雅地实现分布式锁, 后续再写文章详述.
来源: http://www.bubuko.com/infodetail-2773144.html