1. 背景介绍
近期接到任务,需要用Golang开发一个基于Redis的分布式锁,因为目前网上已存在的golang分布式锁要么是性能都不够,要么就是功能不全,根据网上收集到的资料,最终决定参考Redisson的设计思想来设计Go语言的Redis分布式锁。
完整代码可以点这里:
外网:GitHub DisGo 内网:Gitee DisGo
2. 难点分析
主流分布式锁的对比
MySQL | Zookeeper | Redis | |
优点 | 基于硬盘读写,数据稳定 | 自带封装好的框架,开发速度快。有等待锁的队列,先进先出。 | 基于内存操作,访问速度快。 |
缺点 | IO开销大,性能低下 | 需要频繁增删节点,效率不算高 | 需要自己实现代码,考虑因素比较多(例如自旋、死锁、过期等),开发周期比较长,需要自己维护加解锁代码。 |
因为分布式锁主要应用于分布式场景,也就是高并发场景,因此为了满足快速读写,目前基于Redis的分布式锁更受欢迎。
序号 | 特性 | 解释 |
1 | 高可用 | 高可用可以定义为稳定性高和读写速度快,目前来说Redis基于内存的操作,是支持高并发的分布式锁的不二选择。 |
2 | 可重入 | 递归调用的时候,如果没有可重入,将会发生死锁,业务将无法继续执行。 |
3 | 可自旋 | 为了减少业务层的代码,可以将原地等待抢锁的功能归入锁自身的代码中。即第一次没有抢到锁,将会在原地等待一段时间,直到抢锁成功或者等待超时。 |
4 | 可避免死锁 | 如果某个线程抢锁之后发生了异常,导致锁没有主动释放,其他的线程将无法抢到锁。因为有一个过期机制,防止线程异常导致锁一直被占用。 |
5 | 可防止提前解锁 | 在比较复杂的业务中,为了避免业务没有执行完毕就因为锁过期而提前释放了锁,导致数据异常,分布式锁应该具有自动续期机制。 |
问题解决方案
序号 | 特性 | 解决方案 |
1 | 高可用 | 使用Redis,读写性能高。 |
2 | 可重入 | 使用Redis的hash数据结构,key记录当前线程id,value记录重入次数,加锁时如果key相同则value+1,解锁的时候也需要根据key判断是否为自己的线程,避免了释放别人的锁,然后决定是否value-1操作。当value=0的时候表示锁释放完成。 |
3 | 可自旋 | 在代码中使用 ‘死循环 + timer’ 结合,构成一个简易的自旋机制,间隔一定时间之后再重试抢锁。 |
4 | 可避免死锁 | 使用Redis 的expire指令,对锁加上一个过期时间,如果时间到了之后还没有主动释放锁,锁将会自动失效。 |
5 | 可防止提前解锁 | 因为expire的存在,锁会有一个自动过期的时间,但是为了避免业务还没有完成锁却过期了,需要开启一个守护线程(goroutine)对锁进行定期扫描并且续期,又称之为watchdog。 |
3. 代码实现
3.0、 抢锁流程图
3.1、 抢锁
TryLock 是主动调用的方法,另一个类似的方法TryLockWithSchedule抢锁成功之后将会开启一个守护线程,防止锁提前过期。抢锁结果以bool类型告知。首先调用func tryAcquire进行抢锁,如果抢锁成功则直接返回true,抢锁失败则会把自己加入队列,并且订阅Redis频道,等待解锁的通知(收到通知表示锁已释放,可以进行抢锁)。如果长时间没有收到通知,将会进入到自旋阶段,根据抢锁时返回的剩余时间,决定再次重试的时机,如果超过等待时间将会返回false。
func (dl *DistributedLock) TryLock(ctx context.Context, expiryTime, waitTime time.Duration) (bool, error) {
dl.distLock.expiry = expiryTime
dl.distLock.timeout = waitTime
ttl, err := dl.tryAcquire(ctx, dl.distLock.lockName, dl.distLock.field, expiryTime, false)
if err != nil {
return false, err
}
if ttl == 0 {
return true, nil
}
// Enter the waiting queue, waiting to be woken up
succ := dl.subscribe(ctx, dl.distLock.lockName, dl.distLock.field, expiryTime, false)
if succ {
return true, nil
}
// CAS
return dl.cas(ctx, expiryTime, waitTime, false)
}
主要加锁的执行代码如下:
func (dl DistributedLock) tryAcquire(ctx context.Context, key, value string, releaseTime time.Duration, isNeedScheduled bool) (int64, error) {
cmd := luaAcquire.Run(ctx, dl.redisClient, []string{key}, int(releaseTime/time.Millisecond), value)
ttl, err := cmd.Int64()
if err != nil {
// int64 is not important
return -500, err
}
// Successfully locked, open guard
if isNeedScheduled && ttl == 0 {
dl.scheduleExpirationRenewal(ctx, key, value, 30*time.Second)
}
return ttl, nil
}
为了保证抢锁时指令的原子性,采用执行lua脚本进行抢锁。
抢锁会判断当前抢锁的线程id与已存在的锁的线程id是否一致,如果不一致将返回false。
如果一致表示重入锁,需要将value的值+1
"判断锁是否已存在,不存在表示锁可用,新增锁和设置过期时间"
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1; end;
"锁已存在,是当前线程的锁,对value进行+1操作,刷新过期时间"
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1; end;
"锁已存在,不是当前线程的锁,直接返回锁剩余的过期时间"
return redis.call('pttl', KEYS[1]);
3.2、 解锁
释放锁的操作很简单,直接执行lua脚本即可,如果返回值是0,表示解锁完成,然后查看是否有需要关闭的守护线程(scheduleExpirationRenewal)。
func (dl DistributedLock) Release(ctx context.Context) (bool, error) {
cmd := luaRelease.Run(ctx, dl.redisClient, []string{dl.distLock.lockName, dl.config.lockZSetName}, int(dl.distLock.expiry/time.Millisecond), dl.distLock.field)
res, err := cmd.Int64()
if err != nil {
return false, err
} else if res > 0 {
log.Println("The current lock has ", res, " levels left.")
} else {
// If the unlock is successful or does not need to be unlocked, close the thread
if f, ok := theFutureOfSchedule.Load(dl.distLock.field); ok {
err = f.(*promise.Future).Cancel()
if err != nil {
log.Println("Failed to close Future, field:", dl.distLock.field)
return false, err
}
}
}
return true, nil
}
使用lua脚本执行解锁操作,如果解锁成功,判断value的值是否等于0,如果为0表示全部解锁,如果不为零表示当前锁为重入锁,还需要等待其他层级解锁,为了防止过期,将会刷新过期时间。
"判断锁是否存在,不存再,直接返回0,并且发布锁可用的消息给订阅频道"
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
redis.call('publish', KEYS[2], 'next');
return 0;
end;
"锁为重入锁,对value-1,然后判断value是否大于0"
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);
"value>0表示锁没有完全解锁,还有其他层级需要解锁"
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return counter;
"锁已全部解锁,删除当前的锁,并且发布锁可用的消息给订阅频道"
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], 'next');
end;
return 0
3.3、 自旋等待
自旋操作,根据tryAcquire返回的ttl时间(当前已被占用的锁的剩余时间),使用timer进行sleep操作,唤醒后再次尝试抢锁,直到成功或者超过设置的等待时间。
func (dl DistributedLock) cas(ctx context.Context, expiryTime, waitTime time.Duration, isNeedScheduled bool) (bool, error) {
deadlinectx, cancel := context.WithDeadline(ctx, time.Now().Add(waitTime))
defer cancel()
var timer *time.Timer
for {
ttl, err := dl.tryAcquire(deadlinectx, dl.distLock.lockName, dl.distLock.field, expiryTime, isNeedScheduled)
if err != nil {
return false, err
} else if ttl == 0 {
return true, nil
}
var sleepTime time.Duration
if ttl < 300 {
sleepTime = time.Duration(ttl)
} else {
sleepTime = time.Duration(ttl / 3)
}
if timer == nil {
timer = time.NewTimer(sleepTime * time.Microsecond)
defer timer.Stop()
} else {
timer.Reset(sleepTime)
}
select {
case <-deadlinectx.Done():
return false, errors.New("waiting time out")
case <-timer.C:
}
}
}
3.4、 自动续期
续期相当于开启了一个goroutine,每隔一段时间扫描自己守护的锁是否存在,如果存在则会重置锁的过期时间。该方法被tryAcquire方法在抢锁成功的时候调用。
这个地方采用releaseTime/3的间隔时间。使用了go-promise异步组件。
在开启线程前先判断当前线程是否已经被取消,防止主线程加锁后立刻解锁,主线程已经释放锁,但是协程还要查询Redis造成的资源浪费(CPU可能直接执行主线程,还没有开启guard,就已经执行释放锁的操作,然后切换时间片开启guard,实际上这时候guard已经不需要被开启了)。
func (dl DistributedLock) scheduleExpirationRenewal(ctx context.Context, key, field string, releaseTime time.Duration) {
if _, ok := theFutureOfSchedule.Load(field); ok {
return
}
f := promise.Start(func(canceller promise.Canceller) {
var count = 0
for {
time.Sleep(releaseTime / 3)
if canceller.IsCancelled() {
log.Println(field, "'s guard is closed, count = ", count)
return
}
if count == 0 {
log.Println(field, " open a guard")
}
cmd := luaExpire.Run(ctx, dl.redisClient, []string{key}, int(releaseTime/time.Millisecond), field)
res, err := cmd.Int64()
if err != nil {
log.Fatal(field, "'s guard has err: ", err)
return
}
if res == 1 {
count += 1
log.Println(field, "'s guard renewal successfully, count = ", count)
continue
} else {
log.Println(field, "'s guard is closed, count = ", count)
return
}
}
}).OnComplete(func(v interface{}) {
// It completes the asynchronous operation by itself and ends the life of the guard thread
theFutureOfSchedule.Delete(field)
}).OnCancel(func() {
// It has been cancelled by Release() before executing this function
theFutureOfSchedule.Delete(field)
})
theFutureOfSchedule.Store(field, f)
}
为了保证指令的原子性,也使用了lua脚本。
"判断当前守护线程守护的id和已存在锁的id是否一致,如果一致则进行续期,如果不存在返回0"
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
return redis.call('pexpire', KEYS[1], ARGV[1]);
else return 0; end;
3.5、 等待队列+发布订阅
为什么需要等待队列?
有这么一种情况:
线程A和线程B同时抢锁,过期时间设置为10秒,A成功,B直接进入自旋等待,B因为是根据A的ttl决定睡眠时间。A的ttl是10秒,因此B需要睡眠10/3秒 才会被唤醒再次抢锁。然而A从加锁到解锁就花费了10毫秒,但是B依然需要等待3秒多,这就浪费了3秒多的时间,这样的效率是很低的。因此引入一个等待队列,抢锁失败就原地等待,收到通知后立刻去抢锁,直到抢到为止。
为了防止线程A异常挂掉,没有执行publish通知操作,B不能一直原地等待,因此加入了过期时间,等待一定时间之后没有收到通知就直接退出等待,进入自旋抢锁阶段。也就是说,CAS抢锁实际上是对等待队列的一个补偿机制。
subscribe方法使用了Redis的subscribe功能,抢锁失败则把自己放入队列,然后原地等待被唤醒。被唤醒的时候将会拿到队列队首的线程id和自己的id进行对比,对比一致的线程(有且只有一个)才会执行加锁功能,否则继续原地等待(Redis的Subscribe指令是一个类似于UDP的操作,即所有订阅的线程都会收到通知,因此需要进行对比操作来保证先进先出)。
func (dl DistributedLock) subscribe(ctx context.Context, lockKey, field string, releaseTime time.Duration, isNeedScheduled bool) bool {
// Push your own id to the message queue and queue
cmd := luaZSet.Run(ctx, dl.redisClient, []string{dl.config.lockZSetName}, time.Now().Add(dl.distLock.timeout/3*2).UnixMicro(), field, time.Now().UnixMicro())
if cmd.Err() != nil {
log.Fatal(cmd.Err())
return false
}
// Subscribe to the channel, block the thread waiting for the message
pub := dl.redisClient.Subscribe(ctx, dl.config.lockPublishName)
f := promise.Start(func() (v interface{}, err error) {
for range pub.Channel() {
cmd := dl.redisClient.ZRevRange(ctx, dl.config.lockZSetName, -1, -1)
if cmd != nil && cmd.Val()[0] == field {
ttl, _ := dl.tryAcquire(ctx, lockKey, field, releaseTime, isNeedScheduled)
if ttl == 0 {
cmd := dl.redisClient.ZRem(ctx, dl.config.lockZSetName, field)
if cmd.Err() != nil {
log.Fatal(cmd.Err())
}
return true, nil
} else {
continue
}
}
}
return false, nil
})
v, err, _ := f.GetOrTimeout(uint((dl.distLock.timeout / 3 * 2) / time.Millisecond))
if err != nil {
log.Fatal(err)
return false
}
err = pub.Unsubscribe(ctx)
if err != nil {
log.Fatal(err)
return false
}
err = pub.Close()
if err != nil {
log.Fatal(err)
return false
}
if v != nil && v.(bool) {
return true
} else {
return false
}
}
队列使用了Redis的ZSet数据结构,socre保存为超时时间的timestamp,member保存的是线程id,每一个新入队的线程都会检查队首的id是否已经超时,如果超时则会删除队首,避免了队首无法弹出(详见subscribe方法)。
redis.call('zadd', KEYS[1], ARGV[1], ARGV[2]);
redis.call('zremrangebyscore', KEYS[1], 0, ARGV[3]);
为什么不使用Redis的list或者使用本地队列?
因为golang中没有提供线程安全的队列,需要自己实现,开发量比较大,因此暂时使用Redis的队列保证先进先出比较省事。但是Redis的list不能针对每一个元素设置过期时间,只能对整个list设置。如果排在队首的线程已经挂掉,没有执行对应的出队操作,后进来的线程也无法判断排在队首的线程是否已经超时,如果后续的线程一直源源不断的新增进来,每一次新增将会刷新一次list的过期时间,那么这个list永远不会过期,队首已经挂掉的线程id永远无法被删除。后续进来的线程将会一直等到订阅超时,然后进入CAS,这也就失去了等待队列的意义。
3.6、 其他说明
scheduleExpirationRenewal和subscribe都是用了一个第三方组件——go-promise。该组件异步创建了一个goroutine,并且可以主动取消和设置超时时间。因为Redis的subscribe没有超时退出功能,因此go-promise起到了很重要的作用。如果没有它,某一个线程可能因为没有其他线程publish“锁可用的”通知,则一直在等待通知的地方阻塞。
4. 写在最后
本文提供了基于Redis锁的一个简单实现。本作者为该代码的拥有者,如果你有更好的实现方式或者愿意做一个贡献者,欢迎提出意见或者发起PR。
完整代码可以点这里:
外网:GitHub DisGo 内网:Gitee DisGo