手撕基于Redis的分布式锁——Golang,附可用代码DisGo

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、 抢锁流程图

手撕基于Redis的分布式锁——Golang,附可用代码DisGo_第1张图片

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

你可能感兴趣的:(框架,redis,golang,分布式锁,DisGo)