Bootstrap

一文学会基于 Redis 的分布式锁实现

什么是分布式锁?

分布式锁是一种机制,用于在分布式系统中控制对共享资源的访问。它确保在同一时间只有一个进程可以访问特定资源,从而避免数据不一致和竞争条件。分布式锁通常用于以下场景:

  • 限制对数据库的写入操作,防止出现脏读。
  • 控制对文件的并发访问,防止文件损坏。
  • 在微服务架构中协调服务之间的操作。
  • 分布式任务场景中只有一台机器执行。

Redis 分布式锁的实现原理

Redis 提供了简单而高效的分布式锁实现,主要依赖于其原子性操作和过期时间设置。基本的实现思路如下:

  1. 加锁:客户端尝试使用 SETNX 命令设置一个锁的键(例如 lock:resource)。如果该键不存在,则设置成功,返回 1,表示获得锁;如果该键已存在,则返回 0,表示锁已被其他客户端占用。

  2. 设置过期时间:为了防止死锁情况,通常在加锁时会同时设置一个过期时间。这样,如果持锁的客户端崩溃或未能释放锁,锁会在过期后自动释放。

  3. 释放锁:客户端在完成操作后,需要删除锁的键。释放锁时要确保只有持锁的客户端才能释放锁,这通常通过比较锁的值(如 UUID)来实现。

加锁、释放锁情况分析(接口定义)

加锁情况分析

加锁的结果有两种状态,成功或者失败。成功只有一种情况,就是与redis连接正常、并且能够成功执行加锁命令。而失败就有两种情况了,情况一是客户端与 redis 连接就失败了,更不用说成功加锁了;情况二就是与 redis 连接正常,但执行加锁命令返回结果失败。

对于加锁失败的两种情况,客户端可能会有不同的处理方案,因此在设计加锁的返回值时,应考虑到失败的这两种情况,让客户端做针对性处理。

比如在分布式任务的场景当中,为了避免服务器单点故障,通常由多个服务器定时执行一个任务,通过分布式锁保证同一时刻只有一个机器获取到锁执行任务。如果是由于连接异常而导致的失败,客户端可能会采取一定的重试策略,因为网络波动导致的问题重试可能会解决掉;而如果是连接正常、获取锁失败,表明有其他的客户端获取锁成功了,这种情况,就不应该重试。

释放锁情况分析

释放锁的情况稍微和加锁有些不一样,因为无论是客户端与 redis 连接异常,还是释放锁失败,都是没有成功释放锁。所以,只需返回 true 或者 false 即可。

总结

基于以上两种情况,接口定义如下:

import java.net.SocketException;  
/**  
 * 分布式锁  
 */  
public interface IDistributeLock {  
  
    /**  
     * 尝试加锁,在网络正常的情况下:加锁成功返回true,否则返回false  
     * @param releaseTimeOut 超时锁释放的时间,单位秒  
     * @return  
     * @throws SocketException 网络异常时,会抛出异常  
     */  
    boolean tryLock(int releaseTimeOut) throws SocketException;  
  
    /**  
     * 尝试释放锁:释放锁成功返回true,否则返回false  
     * @param lockName 锁名称  
     * @return  
     */    
     boolean tryRelease(String lockName);  
}

Redis 分布式锁实现推演

普通的加锁、释放锁

加锁

SETNX 是 Redis 中的一个命令,用于在键不存在的情况下设置一个键的值。其全名是 “SET if Not eXists”,意思是“如果不存在则设置”。这个命令在实现分布式锁时非常有用,因为它能够确保只有一个客户端可以成功设置某个键,从而获得锁。

  • 如果键成功设置(即键之前不存在),返回 1
  • 如果键已经存在,返回 0
127.0.0.1:6379> setnx lock 1
(integer) 1
127.0.0.1:6379> setnx lock 1
(integer) 0
解锁

通过 del 命令将锁删除即可。

127.0.0.1:6379> del lock
(integer) 1
总结

通过上述流程,即可实现基础的加锁、解锁操作,在没有出现意外的情况下,基本能够正常工作。但在一些异常情况下可能会存在问题:

  • 如果加锁成功但解锁失败,会导致锁一直无法释放,产生业务死锁问题。为了解决这个问题,可在加锁时设置超时时间,即便没有正常解锁,一段时间后也能自动释放。

加锁时设置超时时间

我们可以通过expire 命令来设置超时时间,但这样就导致加锁、设置超时时间是一个非原子操作,在极端情况下,仍有可能存在加锁成功、设置超时时间失败的情况。

127.0.0.1:6379> set lock 1
OK
127.0.0.1:6379> expire lock 5000
(integer) 1

我们可以使用 redis 提供的 nx 条件来实现加锁、设置超时时间是一组原子操作。下面的命令表示:在赋值的同时、进行超时时间的设置,能够保证原子性,并且,使用了 nx 能够保证同一时刻仅有一个客户端加锁成功。

127.0.0.1:6379> set lock 1 ex 5000 nx
OK
127.0.0.1:6379> set lock 1 ex 5000 nx
(nil)
总结

通过给加锁设置超时时间,能够保证锁即便由于意外情况(如服务器宕机)也能够正常释放。现在看来加锁没有什么问题了,但解锁时候,在某些情况下可能会导致一定的异常情况。比如误删除锁,误删的情况是指由于业务代码执行时间过长而导致了锁的自动释放,由下图表格我们可以看到,在某一时刻,会存在t1、t2 两个线程同时执行业务逻辑代码,导致分布式问题。

锁持有情况t1t2
t1获取锁
t1执行业务逻辑
t1执行业务逻辑
执行业务逻辑(执行时间过长,到时锁释放)
t2执行业务逻辑获取锁
t2执行业务逻辑执行业务逻辑

防误删

上述导致误删除的原因为:没有对锁的值进行校验,直接进行了删除,这导致了误删。解决此问题,可以对锁的值进行校验,保证删除的锁是自己的锁,可以在加锁时设置 uuid 来实现这一点。

伪代码如下:

String lockName = "lock1";
String currentLockValue = "fdsafasfadsfd";
String value = jedis.get(lockName);
if(currentLockValue == value){
	jedis.del(lockName);
}

但是,我们发现代码中存在两次对 redis 的操作,并不能够保证原子性。这会导致什么问题呢?其实,导致的问题还是误删除,为什么呢?假设线程在执行完判断之后阻塞住,没有去释放锁,而锁达到了设置的超时自动释放时间,这个时候一个新线程获取到锁,那么阻塞结束后,原线程就会将新线程的锁给删除掉,造成误删的问题。

防误删-使用 lua 脚本保证原子性

Lua 脚本基础知识
  • Lua 语言:Lua 是一种轻量级的脚本语言,具有简单的语法和高效的执行速度,适合嵌入式应用。
  • 原子性:在 Redis 中执行 Lua 脚本是原子的,即整个脚本在执行过程中不会被其他命令打断。这确保了数据的一致性。
    执行脚本:可以使用 EVAL 命令来执行 Lua 脚本。语法如下:
EVAL <script> <numkeys> <key1> <key2> ... <keyN> <arg1> <arg2> ...
  • <script>:要执行的 Lua 脚本。

  • <numkeys>:后面跟随的键的数量。

  • <key1>, <key2>, ...:要操作的键。

  • <arg1>, <arg2>, ...:传递给脚本的额外参数。

  • set key value 使用 lua 脚本表示:

127.0.0.1:6379> set key value
OK
127.0.0.1:6379> eval "return redis.call('SET','KEY','VALUE')" 0
OK
  • get
127.0.0.1:6379> eval "return redis.call('GET','KEY')" 0
"VALUE"
  • 带参数的 get
127.0.0.1:6379> eval "return redis.call('GET',KEYS[1])" 1 lualua
"luavalue"
  • 带参数的 set
127.0.0.1:6379> eval "return redis.call('SET',KEYS[1],ARGV[1])" 1 lualua luavalue
OK
使用lua 脚本进行锁的释放
  • 锁释放流程
local key = KEYS[1]
local currentValue = ARGV[1]
local value = redis.call('GET',key)
if(value == currentValue) then
	return redis.call('DEL',key)
end
return 0
127.0.0.1:6379> set lock 123
OK
127.0.0.1:6379> eval "local key = KEYS[1] local currentValue = ARGV[1] local value = redis.call('GET',key) if(value == currentValue) then return redis.call('DEL',key) end return 0" 1 lock 12
(integer) 0
127.0.0.1:6379> eval "local key = KEYS[1] local currentValue = ARGV[1] local value = redis.call('GET',key) if(value == currentValue) then return redis.call('DEL',key) end return 0" 1 lock 123
(integer) 1
  • 可简化为:
if(redis.call('GET', KEYS[1]) == ARGV[1]) then
	return redis.call('DEL', KEYS[1])
end
return 0
127.0.0.1:6379> set lock 123
OK
127.0.0.1:6379> eval "if(redis.call('GET', KEYS[1])  == ARGV[1]) then return redis.call('DEL', KEYS[1]) end return 0" 1 lock 12
(integer) 0
127.0.0.1:6379> eval "if(redis.call('GET', KEYS[1])  == ARGV[1]) then return redis.call('DEL', KEYS[1]) end return 0" 1 lock 123
(integer) 1
127.0.0.1:6379> get lock
(nil)

最终版本

基于Jedis

在 Jedis 中,SetParams 是一个配置对象,用于指定 SET 命令的各种选项。这些选项包括 NX(仅当键不存在时设置)、XX(仅当键已存在时设置)、EX(设置键的过期时间,以秒为单位)和 PX(设置键的过期时间,以毫秒为单位)等。

当你调用 jedis.set(lockKey, "LOCK", params) 时,Jedis 会将这些参数转换为对应的 Redis 命令选项,并将其作为参数传递给 Redis 服务器。Redis 服务器会解析并执行这个命令,根据传入的选项来决定是否设置键值对。

接口定义

import java.net.SocketException;  
/**  
 * 分布式锁  
 */  
public interface IDistributeLock {  
  
    /**  
     * 尝试加锁,在网络正常的情况下:加锁成功返回true,否则返回false  
     * @param releaseTimeOut 超时锁释放的时间,单位秒  
     * @return  
     * @throws SocketException 网络异常时,会抛出异常  
     */  
    boolean tryLock(int releaseTimeOut) throws SocketException;  
  
    /**  
     * 尝试释放锁:释放锁成功返回true,否则返回false  
     * @param lockName 锁名称  
     * @return  
     */    
     boolean tryRelease(String lockName);  
}

基于 jedis 的实现

import redis.clients.jedis.Jedis;  
import redis.clients.jedis.params.SetParams;  
import java.net.SocketException;  
import java.util.UUID;  
  
public class JedisDistributeLock implements IDistributeLock {  
  
    private Jedis jedis;  
  
    private final String LOCK_PRE = "lock:";  
  
    private final String LOCK_VALUE;  
  
    private final String lockName;  
  
  
  
    public JedisDistributeLock(Jedis jedis,String lockName) {  
        this.jedis = jedis;  
        this.lockName = lockName;  
        this.LOCK_VALUE = UUID.randomUUID().toString().replaceAll("-","");  
    }  
    @Override  
    public boolean tryLock(int releaseTimeOut) throws SocketException {  
        if(jedis == null){  
            throw new SocketException("jedis is null!");  
        }  
        try {  
            SetParams params = new SetParams().nx().ex(releaseTimeOut);  
            String result = jedis.set(LOCK_PRE + lockName, this.LOCK_VALUE, params);  
            return "OK".equals(result);  
        }catch (Exception e){  
            e.printStackTrace();  
            throw new SocketException("jedis connection error!");  
        }  
    }  
  
    @Override  
    public boolean tryRelease(String lockName) {  
       try {  
           String lua = "if(redis.call('GET', KEYS[1])  == ARGV[1]) then return redis.call('DEL', KEYS[1]) end return 0";  
           jedis.eval(lua, 1, LOCK_PRE + lockName, this.LOCK_VALUE);  
           return true;  
       }catch (Exception e){  
           e.printStackTrace();  
           return false;  
       }  
    }  
}

工具类

import com.gis.components.redis.api.IDistributeLock;  
import redis.clients.jedis.Jedis;  
import redis.clients.jedis.JedisPool;  
  
public class LockTools {  
    private JedisPool jedisPool = new JedisPool("localhost",6379);  
    public IDistributeLock getLock(String lockName){  
        try(Jedis jedis = jedisPool.getResource()){  
            JedisDistributeLock jedisDistributeLock = new JedisDistributeLock(jedis,lockName);  
            return jedisDistributeLock;  
        }catch (Exception e){  
            e.printStackTrace();  
            return new JedisDistributeLock(null,null);  
        }  
    }  
}

结语

通过本文,可以实现基于 Jedis 的分布式锁,该锁适用于大多数场景。

;