【问题记录】分布式锁实现问题小记

问题发现

业务场景:消费端轮询服务端接口,服务端发现任务完成,则触发事件。首先这是有一定并发量的场景,为了避免并发场景下,服务端重复触发事件,在触发事件之前必须加分布式锁。我是这样设计的:本次请求先查询事件是否已经触发,如果已经触发,那么解锁;否则尝试加分布式锁,如果获取锁成功,触发事件,如果获取锁失败,直接返回

问题产生了,我发现分布式锁没有互斥性,上一个请求获取锁成功,没等到有请求来解锁,下一个请求居然又能成功获取锁

问题定位

首先我排查了业务代码中的加锁逻辑, 并发场景下尝试获取的确实是同一把锁。然后排查分布式锁的实现,我们项目中使用 Redis 来实现分布式锁

加锁逻辑

Lua 脚本:

String LUA_SCRIPT_LOCK=
        "if (redis.call('exists', KEYS[1]) == 0) then" + LUA_LINE_SEPARATOR
        + "    redis.call('hincrby', KEYS[1], ARGV[2], 1);" + LUA_LINE_SEPARATOR
        + "    redis.call('pexpire', KEYS[1], ARGV[1]);" + LUA_LINE_SEPARATOR
        + "    return -1;" + LUA_LINE_SEPARATOR
        + "end;" + LUA_LINE_SEPARATOR
        + "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then" + LUA_LINE_SEPARATOR
        + "    redis.call('hincrby', KEYS[1], ARGV[2], 1);" + LUA_LINE_SEPARATOR
        + "    redis.call('pexpire', KEYS[1], ARGV[1]);" + LUA_LINE_SEPARATOR
        + "    return -1;" + LUA_LINE_SEPARATOR
        + "end;" + LUA_LINE_SEPARATOR
        + "return redis.call('pttl', KEYS[1]);";

其中 KEYS[1] 是锁对应的键,ARGV[1] 是锁的超时时间,ARGV[2] 是当前线程 id

加锁逻辑解读如下,如果 key 不存在,给 key 的当前线程 id 对应值 + 1,设置 key 过期时间,返回-1,获取锁成功;如果 key 存在,且 key 的当前线程 id 对应值为1,设置 key 过期时间,返回-1,获取锁成功;否则返回锁的过期时间,获取锁失败

解锁逻辑

Lua 脚本:

String LUA_SCRIPT_UNLOCK=
        "if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then" + LUA_LINE_SEPARATOR
        + "    return -1;" + LUA_LINE_SEPARATOR
        + "end;" + LUA_LINE_SEPARATOR
        + "local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);" + LUA_LINE_SEPARATOR
        + "if (counter > 0) then" + LUA_LINE_SEPARATOR
        + "    return 0;" + LUA_LINE_SEPARATOR
        + "else" + LUA_LINE_SEPARATOR
        + "    redis.call('del', KEYS[1]);" + LUA_LINE_SEPARATOR
        + "    return 1;" + LUA_LINE_SEPARATOR
        + "end;";

其中 KEYS[1] 是锁对应的键,ARGV[1] 是当前线程 id

解锁逻辑解读如下,如果当前 key 对应的线程 id 的值为0,返回-1,解锁失败;否则,将当前 key 对应的线程 id 的值减1,保存为 counter 变量,如果 counter 变量的值大于0,返回0,解锁失败;否则,删除 key,解锁成功

问题分析

由于整个问题的产生并未走到解锁逻辑,我重点分析了加锁逻辑。从加锁逻辑中,不难发现锁用了  hash 类型,因为考虑了重入性,在并发场景下允许同一个线程重入地获取锁,于是以线程 id 为键,获取锁的次数为值,每次获取锁把次数 +1。保存的数据结构如下:

myLock: {

"999": 1
}

通过分析,我发现这样的分布式锁实现存在很多问题

1、异步解锁场景

异步解锁场景即这次请求先加锁,等到下次请求解锁,也就是问题产生的场景

首先,spring mvc 处理 http 请求会使用线程池让线程复用。请求1到来,分配线程A加锁,任务完成,线程A回池。注意,此时锁没有释放。下个请求2到来,如果再次分配线程A,即使没有解锁,通过重入性,它就又能获得锁

2、多实例场景
在多实例场景,线程 id 不能作为客户端唯一标识

实例1获取了锁,开始执行业务逻辑,实例2以同样的线程 id 获得了锁,相当于重入了一次,也开始执行业务逻辑,此时锁没有互斥性

从设计上来说,这个分布式锁期望加锁和解锁由同一线程完成,如果业务代码不得不遵循这个规则,未免丧失灵活性
比如这样的场景:服务A写一个加锁接口和解锁接口,让服务B调用,这样就会产生问题1

问题改进

实际上,我们项目中加锁和解锁的 Lua 脚本参考了开源软件 Redisson,它的 Lua 脚本确实是这么写的,但入参不同。在 Redisson 的加锁逻辑中 ARGV[2] 代表了加锁的唯一标识,由 UUID 和线程 id 组成。一个 Redisson 客户端一个 UUID,UUID 代表了一个唯一的客户端。所以由 UUID 和线程 id 组成了加锁的唯一标识,可以理解为某个客户端的某个线程加锁

Redisson 的分布式锁实现是一个整体,它还有 watchdog 机制来给锁续约、自动过期,我们当然不能通过简单的引入它的 Lua 脚本来实现自己的分布式锁

改进的方向有两个:

1、直接引入开源软件 Redisson,直接用它的 api 来加锁解锁

2、舍弃重入性,使用 setnx + 过期时间加锁,判断客户端唯一标识解锁

  • 21
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值