自己动手用 Redis 撸一个分布式锁

自己动手用 Redis 撸一个分布式锁

薛定谔的汪

什么是分布式锁?

分布式锁,是控制分布式系统之间同步访问共享资源的一种方式,在分布式环境中,如果不同的系统或是同一系统的不同服务器之间共享了某些资源,那么访问这些资源的时候需要互斥防止彼此干扰,以此来保证数据一致性,由于单机环境的 Lock 和 synchronize 等锁在分布式环境中不可用,这时候需要用到分布式锁,如秒杀场景。

分布式锁特点:

高可用、高性能地获取锁和释放锁

具备锁可重入特性

锁具有失效时间,防止死锁

具备阻塞和非阻塞锁特性,以满足不同业务需求。

分布式锁的实现

实现分布式锁的方式大致有三种:

基于数据库

基于 zookeeper

基于 Redis

基于数据库和 zookeeper 不再此次总结范围,在项目中用到的是redis。

基于 Redis 实现分布式锁

setnx 命令

setnx 是 “set if not exist”,如果不存在才set

用法:setnx key value

当 key 不存在时,设置 key 的值为value,当 key 存在时,不设置。

设置成功返回1,设置失败返回0

Redis 实现分布式锁原理

Redis实现分布式锁的核心原理就是基于setnx命令,并给 key 设置失效时间。

当获取某个锁 key 时,先判断 Redis 中 key 是否存在,不存在则设置 key-value并设置 key 的失效时间,存在则不设置。设置失效时间的好处是到时间后 锁 key 自动删除防止死锁。

解锁的时候,根据锁 key和 value,去 Redis 中查找到匹配的 key-value 删除掉即可。

虽然原理不难,但是具体实现的时候有些地方还是需要注意下

获取锁

Jedis 客户端实现:

1
2
3
4
public static boolean tryLock(String lockKey, String requestTag, int expireTime){
String result = jedis.set(lockKey, requestTag, "NX", "EX", expireTime);
return "OK".equalsIgnoreCase(result);
}

获取锁的代码就这么简单,不搞花里胡哨,两行甚至一行搞定。这里利用 jedis原子性的 set 方法:

第一个参数是 key;第二个是 value;第三个可以是”NX”或 “XX”,”NX”代表setnx 命令,”XX“代表 setxx,setxx是只有当 key 存在的时候才更新 key,与 setnx 相反;第四个参数值可为“EX”或”PX”,”EX“代表失效时间单位是秒,”PX“为毫秒,第五个参数”expireTime”代表失效时间具体值。

lockKey 是公共资源的锁,requestTag 是每次请求的唯一标签,不重复,区分每次的请求,在解锁时会用到。

注意:此 set 方法在 Redis 里执行是一个原子操作,这个方法整合了 setnx、expire两个命令,在我们编写获取锁的时候切勿将 setnx 和 expire 分开执行,比如先 setnx,判断是否成功,成功后再 expire 失效时间,这样做存在原子问题:当 setnx 成功后但执行expire失败了,导致lockKey一直存在,发生死锁。

结合 RestTemplete 实现的 lua 获取锁的脚本:

1
2
3
4
5
6
7
8
local expire = tonumber(ARGV[2])
local result = redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', expire)
local str = tostring(retult)
if strret == 'false' then
return false
else
return true
end

正确解锁

解锁的操作就是把对应的 lockKey 删掉即可,但是如何删呢?有什么需要注意的呢?

在加锁的时候我们设置了”requestTag“,”requestTag” 保证加锁和解锁是统一请求,即”解铃还须系铃人“的意思。代码示例:

1
2
3
4
5
6
7
8
9
public static boolean unLock(String lockKey, String requestTag){

String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestTag));
//可抽取成静态常量
Long unLockSuccess = 1L;

return unLockSuccess.equals(result);
}

解锁操作是利用了 LUA 脚本,利用 eval 函数将 lua 脚本解析成原子性的 Redis 命令执行。

luaScript含义:获取 lockKey 的 value 是否与requestTag相等,相等则执行”del“命令删除 lockKey 否则返回0,结束。

因为”del“命令返回被删除key 的数量,且我们的 lockKey 就一个,所以删除成功返回1。

这是解锁的正确姿势。

错误解锁

1
2
3
4
5
6
7
8
9
public static boolean wrongUnLock(String lockKey, String requestTag){
//判断requestTag
if(requestTag.equals(jedis.get(lockKey))){
//注意:这里如果刚好lockKey失效同时又有新的锁加上,那这里就会把别人的锁给删除了
jedis.del(lockKey);
return true;
}
return false;
}

还是存在原子问题。

优秀的开源工具 Redisson

Redisson 提供了使用 Redis 的最简单和最便捷的方法,参考 github

示例代码:

1
2
3
4
5
6
7
8
9
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}

疑虑

  1. 基于 Redis 的分布式锁已实现,其中的失效时间我们该如何设置最佳值呢?设置过小的话会导致对共享资源操作完成之间就自动释放锁,失去互斥保护;如果设置过大的话可能会导致系统资源的浪费,这个合适的值应该如何确定呢?我想这需要大量针对此业务的测试。
  2. 如果 Redis 是单点的,一旦 Redis 挂掉,分布式锁自然就不能用了,存在单点故障问题。如果是主从 master-slave 模式,主从同步是异步的且需要时间,万一在主从同步完成之前 master 挂掉,那就存在安全可靠性问题。Redis 集群情况下这种状况\应该会好些。

对于分布式锁,Redis官网给出了更好的基于Redis多节点的 RedLock 算法,地址:https://redis.io/topics/distlock

Redis 和 Zookeeper 实现分布式锁对比

ZK实现分布式锁链接 链接: http://zhengyk.cn/2018/04/01/zk/zookeeper/

  • Redis 实现分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
    • 客户端挂掉后,只能等待超时时间过后才能释放锁。
  • ZK 实现分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。
    • ZK 实现分布式锁是基于临时节点+ Wacth 机制的,客户端挂了后,ZK 可以感知到并自动删除临时节点,自然就同时释放锁。
  • Redis 分布式锁代码实现要比 ZK 简洁、易懂。
  • Title: 自己动手用 Redis 撸一个分布式锁
  • Author: 薛定谔的汪
  • Created at : 2018-09-02 18:01:54
  • Updated at : 2023-11-17 19:37:37
  • Link: https://www.zhengyk.cn/2018/09/02/redis/redis-distributed-lock/
  • License: This work is licensed under CC BY-NC-SA 4.0.