今天让我们聊聊分布式锁。
基本性质
一个分布式锁要实现以下基本特性:
互斥性: 任意时刻,只有一个客户端能持有锁。
安全性:锁只能被持有的客户端删除,不能被其他客户端删除
可选:可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。
为什么要实现可重入性?
可重入性意味着在同一个线程(或处理单元)内,一个已经获得锁的实体可以多次重新获取并释放该锁,而不会被锁住。实现可重入性有以下几个原因:
- 避免死锁:可重入性的主要好处之一是防止死锁。考虑一个场景,其中一个线程已经获取了一个锁,然后在其持有的锁的保护下再次尝试获取同一个锁。如果分布式锁不是可重入的,该线程将会被阻塞,因为它在等待一个已经被其自己持有的锁,从而导致死锁。
- 提高灵活性:在复杂的操作中,一个操作可能需要多次获取锁。如果锁是可重入的,这样的操作就可以在不释放锁的情况下多次获取它,这为复杂的事务提供了更大的灵活性。
- 减少错误:可重入性减少了因意外再次获取已持有的锁而导致的错误。
但是不支持可重入性的锁的实现也是有的,这跟语言特性、性能考虑、并发模型都有关系,比如Golang的锁就不支持可重入,有同学来聊聊为什么吗?
具体实现
在本节中,将用Redis实现一个分布式锁~
上锁
上锁是互斥行为,在Redis中 ,可以使用SETNX实现,如果设置键值成功,则表示成功拿到锁。以下是SET命令的详细介绍
SET key value[EX seconds][PX milliseconds][NX|XX]
NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
EX seconds :设定key的过期时间,时间单位是秒。
PX milliseconds: 设定key的过期时间,单位为毫秒
XX: 仅当key存在时设置值
我们看到,SET命令还可以同时设置过期时间,可以保证在异常情况下锁的释放。value的值可以设置为客户端的唯一标识,用于解锁的时候进行校验
解锁
可以用redis删除键值的命令代表解锁。但是上文提到过分布式锁要保证"安全性",不能出现客户端解锁了其他人持有的锁的情况。这就要求解锁操作进行校验了。也就是说解锁操作需要分两步进行:锁的所有者验证+解锁。
因此我们需要通过lua脚本保证锁的所有者验证和解锁操作的原子性
kotlin
if redis.call('get',KEYS[1]) == ARGV[1] then // 先校验锁的持有人
return redis.call('del',KEYS[1]) // 校验通过删除锁
else
return 0
end;
锁续期
上文在实现上锁的时候,提到过上锁的同时可以设置一个过期时间。但是这个过期时间是很不好把握的。如果设置的太短了,那么事务没有执行完毕锁就会过期失效,反之如果设置的太长了,那么如果获取到锁的节点因为异常奔溃了,那么就会导致资源被长时间占用,造成性能问题和资源的浪费。因此,需要一个锁续期的机制来弥补这个不足。锁续期的机制是这样的:
- 上锁的时候加上一个预估可以完成事务的过期时间
- 加锁的同时启动一个协程对事务进行监控,定时轮询(周期通常为锁过期时间的一半)事务是否完成,如还没有完成则对锁的过期时间进行续期。
- 解锁的时候释放协程 以下是一个Golang的分布式锁的简单实现:
go
type RedisLock struct {
client *redis.Client
key string
value string
ttl time.Duration
stopChan chan bool
}
func NewRedisLock(client *redis.Client, key string, value string, ttl time.Duration) *RedisLock {
return &RedisLock{
client: client,
key: key,
value: value,
ttl: ttl,
stopChan: make(chan bool),
}
}
func (r *RedisLock) Lock() bool {
// 使用SET命令来尝试获取锁
success, err := r.client.SetNX(r.key, r.value, r.ttl).Result()
if err != nil || !success {
return false
}
// 启动背景任务来定期续期锁
go r.renewLock()
return true
}
func (r *RedisLock) Unlock() {
// 发送停止信号到续期背景任务
r.stopChan <- true
// 释放锁
r.client.Del(r.key)
}
func (r *RedisLock) renewLock() {
ticker := time.NewTicker(r.ttl / 2)
for {
select {
case <-ticker.C:
r.client.Expire(r.key, r.ttl)
case <-r.stopChan:
ticker.Stop()
return
}
}
}
题外话,Golang的协程实在是太方便了,其他语言的同学不要羡慕哈~
以上实现基于单个Redis节点来获取和释放锁。如果这个Redis节点出现故障,整个锁服务将不可用,这就产生了单点故障的风险。为了提高分布式锁的可用性,可以使用Redis集群,以上代码也只需要略作修改,这里不再给出对应代码~
性能优化
分布式锁的性能优化又是一个说来话长的话题了,之后会再专门出一篇文章来讲讲。有经验的同学可以留言你们是怎么做的呢?