文章目录
- [1 :peach:什么是分布式锁?:peach:](#1 :peach:什么是分布式锁?:peach:)
- [2 :peach:分布式锁的基础实现:peach:](#2 :peach:分布式锁的基础实现:peach:)
- [3 :peach:引入过期时间:peach:](#3 :peach:引入过期时间:peach:)
- [4 :peach:引入校验 id:peach:](#4 :peach:引入校验 id:peach:)
- [5 :peach:引入 lua 脚本:peach:](#5 :peach:引入 lua 脚本:peach:)
- [6 :peach:引入 watch dog:peach:](#6 :peach:引入 watch dog:peach:)
1 🍑什么是分布式锁?🍑
在⼀个分布式的系统中,也会涉及到多个节点访问同⼀个公共资源的情况,此时就需要通过 锁 来做互斥控制,避免出现类似于 "线程安全" 的问题。
⽽ java
的 synchronized
或者 C++
的 std::mutex
, 这样的锁都是只能在当前进程中⽣效,在分布式的这种多个进程多个主机的场景下就⽆能为⼒了,此时就需要使⽤到分布式锁。
分布式锁本质上就是使⽤⼀个公共的服务器来记录加锁状态,这个公共的服务器可以是 Redis, 也可以是其他组件(⽐如 MySQL 或者 ZooKeeper 等), 还可以是我们⾃⼰写的⼀个服务等。
2 🍑分布式锁的基础实现🍑
思路⾮常简单,本质上就是通过⼀个键值对来标识锁的状态。
举个例⼦: 考虑买票的场景,现在⻋站提供了若⼲个⻋次,每个⻋次的票数都是固定的,现在存在多个服务器节点, 都可能需要处理这个买票的逻辑: 先查询指定⻋次的余票, 如果余票 > 0, 则设置余票值 -= 1。
显然上面就可能出现 "超卖" 的情况,此时如何进⾏加锁呢? 我们可以在上述架构中引⼊⼀个 Redis , 作为分布式锁的管理器。
此时, 如果买票服务器1尝试买票,就需要先访问 Redis,在 Redis 上设置⼀个键值对。⽐如 key 就是⻋次, value 随便设置个值 。
如果这个操作设置成功,就视为当前没有节点对该 001 ⻋次加锁, 就可以进⾏数据库的读写操作。操作完成之后, 再把 Redis 上刚才的这个键值对给删除掉。如果在买票服务器1操作数据库的过程中, 买票服务器2也想买票, 也会尝试给 Redis 上写⼀个键值对,key 同样是⻋次,但是此时设置的时候发现该⻋次的 key 已经存在了, 则认为已经有其他服务器正在持有锁, 此时服务器2就需要等待或者暂时放弃。
Redis 中提供了
setnx
操作, 正好适合这个场景。即key 不存在就设置, 存在则直接失败
但是上述方案还存在着一个问题:当服务器1加锁之后,开始处理买票的过程中, 如果服务器1意外宕机了, 就会导致解锁操作 (删除key) 不能执⾏,就可能引起其他服务器始终⽆法获取到锁的情况。
那么如何解决呢?
3 🍑引入过期时间🍑
为了解决上面存在的问题,我们可以引入过期时间,即这个锁最多持有多久, 就应该被释放。
可以使⽤
set ex nx
的⽅式, 在设置锁的同时把过期时间设置进去。
但是要注意,此处的过期时间只能使用一个命令 的⽅式设置。如果分开多个操作, ⽐如 setnx
之后, 再来⼀个单独的 expire
,由于 Redis 的多个指令之间不存在关联,并且即使使⽤了事务也不能保证这两个操作都⼀定成功, 因此就可能出现 setnx
成功, 但是 expire
失败的情况,此时仍然会出现⽆法正确释放锁的问题。
我们引入了过期时间后可以解决锁不被释放的问题,但是现在新的问题又来了,⽐如服务器1写⼊⼀个 "001": 1 这样的键值对,服务器2是完全可以把 "001" 给删除掉的,这样的操作就可能会导致一些问题,有什么办法可以解决吗?
4 🍑引入校验 id🍑
为了解决其他服务器可能会误删键值对的问题,我们引入了一个校验ID,我们就可以将键值对的val设置成服务器的身份编号,这样当要进行del
时,要先进行判断,只有该服务器的身份编号与键值对的val相同时才允许删除。
逻辑⽤伪代码描述如下:
cpp
String key = [要加锁的资源 id];
String serverId = [服务器的编号];
// 加锁, 设置过期时间为 10s
redis.set(key, serverId, "NX", "EX", "10s");
doSomeThing();
// 解锁, 删除 key. 但是删除前要检验下 serverId 是否匹配.
if (redis.get(key) == serverId)
{
redis.del(key);
}
通过伪代码我们不难发现当前设计还存在着一些问题: 解锁逻辑是两步操作 get
和del
, 这样做并⾮是原⼦的。那应该怎么解决呢?
5 🍑引入 lua 脚本🍑
为了使解锁操作原⼦,可以使⽤ Redis 的 Lua
脚本功能。
Lua 也是⼀个编程语⾔,读作 "撸啊",是葡萄⽛语中的 "⽉亮" 的意思。Lua 的语法类似于 JS,是⼀个动态弱类型的语⾔。Lua 的解释器⼀般使⽤ C 语⾔实现,Lua 语法简单精炼, 执⾏速度快, 解释器也⽐较轻量(Lua 解释器的可执⾏程序体积只有 200KB 左右)。
因此 Lua 经常作为其他程序内部嵌⼊的脚本语⾔。Redis 本⾝就⽀持 Lua 作为内嵌脚本,很多程序都⽀持内嵌脚本, ⽐如 MySQL 8 ⽀持 JS 作为内嵌脚本, ⽐如 Vim ⽀持 VimScript和 Python 作为内嵌脚本... 通过内嵌脚本来实现更复杂的功能, 提供更强的扩展性。Lua 除了和 Redis 搭伙之外, 在很多场景也会作为内嵌脚本,⽐如在游戏开发领域常常作为编写逻辑的语⾔。 (⽐如魔兽世界, ⼤话西游等)
使⽤ Lua 脚本完成上述解锁功能:
lua
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
上述代码可以编写成⼀个 .lua
后缀的⽂件, 由 redis-cli 或者 redis-plus-plus 或者 jedis 等客⼾端加载,并发送给 Redis 服务器,由 Redis 服务器来执⾏这段逻辑。
⼀个 lua 脚本会被 Redis 服务器以原⼦的⽅式来执行
引入了 lua 脚本脚本后还有一个问题: 当我们设置了 key 过期时间之后,仍然存在⼀定的可能性当任务还没执⾏完,key 就先过期了,这就导致锁提前失效。那么过期时间应该如何设置呢?
6 🍑引入 watch dog🍑
watch dog 也被称作看门狗。所谓 watch dog,本质上是加锁的服务器上的⼀个单独的线程, 通过这个线程来对锁过期时间进⾏ "续约"。
注意, 这个线程是业务服务器上的, 不是 Redis 服务器的。
举个具体的例⼦:
假设初始情况下设置过期时间为 10s,同时设定看⻔狗线程每隔 3s 检测⼀次,那么当 3s 时间到的时候, 看⻔狗就会判定当前任务是否完成。
- 如果任务已经完成,则直接通过 lua 脚本的⽅式,释放锁。(删除 key)
- 如果任务未完成,则把过期时间重写设置为 10s。(即 "续约")
注意上面动态调整过期时间要根据实际的业务场景进行设置,我这里只是随便设置了个数据方便大家理解而已。
这样就不担⼼锁提前失效的问题了,⽽且另⼀⽅⾯如果该服务器挂了,看⻔狗线程也就随之挂了,此时⽆⼈续约, 这个 key ⾃然就可以迅速过期,让其他服务器能够获取到锁了。