前言
系统为了保证高可用,通常会部署多实例,并且会存在同时对共享资源并发读写,这时候为了保证读写的安全,常规手段是会引入分布式锁,本文将介绍如何使用redis设计一个优雅的Go分布式锁。
设计
redis分布式锁是借助SETNX来实现,可能会遇到一下两个场景:
- 加锁后没正确解锁:当一个协程获取到锁后,还未执行解锁操作时,因为服务重启等原因导致死锁。
 - 解除别人的锁:为了避免死锁,会引入超时机制,如果锁时间较短,但是执行时间过长,就会导致锁超时,其他协程就会获取,这个时候第一个协程执行完后,会将第二个协程获取的锁提前释放了。
 
解决方案其实也挺简单:
- 加锁时记录锁定协程的标识,解锁的时候校验是否是自己的锁。
 - 设置合理超时时间,并且锁定期间增加一个续约协程,延长超时时间。
 
实现
接口定义
首先,需要考虑定义一个抽象接口,来作为防腐层解耦业务和具体技术细节。
接口定义的原则:职责单一、功能抽象,不要与具体实现的技术细节挂钩,可以设计如下:
sync/locker.go
            
            
              go
              
              
            
          
          package sync
import (
	"context"
	"errors"
)
type UnlockFunc func(ctx context.Context) error // 解锁函数
type Locker interface {
	Lock(ctx context.Context, key string) (UnlockFunc, error) // 加锁
}
        接口只有一个方法,加锁成功后,返回一个解锁的方法,这样设计好处是可以巧妙利用闭包来保存加锁人信息,并且封装在具体的实现中,使用方无感知,使用起来也非常简单:
            
            
              go
              
              
            
          
          var locker = ...
unlock, err:= locker.Lock(ctx, ..)
if err!=nil {
	 return err
}
defer func{
   err = unlock(ctx)
   ...
}()
        另外,为了简单起见,"加/解锁"失败或是网络异常等未知异常,都是返回一个error,为了区分通常会预定义两个"加/解锁"失败的异常:
            
            
              go
              
              
            
          
          var ErrLockFail = errors.New("get lock get fail")
var ErrUnlockFail = errors.New("unlock lock get fail")
        接口实现
接着我们开始实现接口
            
            
              go
              
              
            
          
          package redis
import (
	"context"
	"fmt"
	"time"
	"itart.top/internal/pkg/sync"
	"github.com/go-redis/redis/v8"
	"github.com/gofrs/uuid"
)
var lockerTimeout = time.Minute   // 默认锁定1分钟
var renewalTime = lockerTimeout / 2 // 时间过一半就续期
var delLockerScript = redis.NewScript(`
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end`) // 删除锁的lua脚本,先判断是否是自己的锁,再删除
type Locker struct {
	client *redis.Client
	ns     string
}
func NewLocker(client *redis.Client, ns string) *Locker {
	return &Locker{
		client: client,
		ns:     ns,
	}
}
func (r *Locker) Lock(ctx context.Context, key string) (sync.UnlockFunc, error) {
	lockKey := fmt.Sprintf("%s:%s", r.ns, key)
	uuid, _ := uuid.NewV4()
	id := uuid.String() // 锁定人标识
	cmd := r.client.SetNX(ctx, lockKey, id, lockerTimeout)
	if !cmd.Val() { // 已经被锁住
		return nil, sync.ErrLockFail
	}
	ctx, cancel := context.WithCancel(ctx)
	go r.renewal(ctx, lockKey) // 续期
	return func(ctx context.Context) error {
		defer cancel()
		return r.unlock(ctx, lockKey, id)
	}, nil
}
// 续约: 时间过半后续约
func (r *Locker) renewal(ctx context.Context, key string) {
	ticker := time.NewTicker(renewalTime)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			r.client.Expire(ctx, key, lockerTimeout)
		}
	}
}
// 解锁
func (r *Locker) unlock(ctx context.Context, lockKey string, id string) error {
	num, err := delLockerScript.Run(ctx, r.client,
		[]string{lockKey}, id).
		Int()
	if err != nil {
		return err
	}
	if num == 0 {
		return sync.ErrUnlockFail
	}
	return nil
}
        说明:
- 通过Lua来保证原子性:校验"解锁"和"加锁"是同一个协程,是的话才执行删除锁。
 - 通过ns标识来隔离业务:不同的业务分配不同的实例和ns命名空间。
 - 通过renewal方法续期:如果业务执行时间超过加锁时间,则可以自动续期,另外,因为有这个续期的存在,所以"锁超时时间"没必要设置过长。
 - 异常自动释放锁:由于续期是通过协程,存在内存中,如果程序异常中止,就不会续期,加锁时间超时后就会自动解锁。
 
使用
我们可以手动NewLocker方式直接使用,为不同的业务都实例化一个不同ns的实例。
也可以通过wire管理,由于wire是单例方式管理,如果要实现多实例,只能为不同业务定义不同的别名,假设我们需要实现一个部署锁:
首先,需要为实现类定义一个别名DeployLocker,然后增加一个实例方法 NewDeployLocker,指定一个命名空间"deploy":
redis/deploy_locker.go
            
            
              go
              
              
            
          
          type DeployLocker = Locker
func NewDeployLocker(cache *cache.Cache) *DeployLocker {
	return (*DeployLocker)(NewLocker(cache, "deploy"))
}
        接着,给接口也定义一个别名:
biz/deploy_locker.go
            
            
              go
              
              
            
          
          type DeployLocker sync.Locker
        最后,通过wire绑定接口和实现类
            
            
              go
              
              
            
          
          redis.NewDeployLocker,
wire.Bind(new(biz.DeployLocker), new(*redis.DeployLocker)
        这样就定义好一个部署的锁,需要地方就可以定义一个biz.DeployLocker参数,有wire来注入实现。
如果还有其他业务也需要锁时,可以和Deploy类似,再定义一套来实现。
原文地址:https://itart.cn/blogs/2025/practice/go-redis-locker.html