Redis 分布式锁的实现

在 Redis 的常见应用中,分布式锁是一个老生常谈的问题,本文主要讲讲怎么去实现一个分布式锁(最近真·写了不少 Lua 脚本)。

加锁

对于加锁操作,理论上应该是:

  1. 尝试加锁,如果成功,则记录锁,并且返回 true
  2. 如果失败,则不更新锁,返回 false

另外,1 或者 2 应该都是原子的。而 Redis 中针对这个操作只要一个 Set 就能搞定。

为此,我们先复习一下 SET

SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
  EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]

其中 Options:

  • EX:多少秒后过期
  • PX:多少毫秒后过期
  • EXAT:什么时候过期(时间戳-秒)
  • PXAT:什么时候过期(时间戳-毫秒)
  • NX:只有当 key 不存在时才 SET
  • XX:只有 key 存在时才能 SET
  • KEEPTTL:意味着更新时过期时间保持不变
  • GET:返回更新前的旧字符串

换言之,假设我们把锁作为一个 redis key,那么加锁只要使用 SET key value NX 就行了。

另外,为了避免程序错误导致锁没释放,还需要加入一个超时时间,比如我们预估一个请求 timeout = 800ms,那么锁的时间可以设计为 1s,进行一些容错 SET key value NX EX 1

释放锁

因为加锁 = SET key,那么解锁自然是 DEL。但是问题是,不是谁都能释放锁的,只有那个拥有锁的对象可以释放锁。

因此对象匹配和释放锁需要是原子的:

if redis.call("GET", KEYS[1]) == ARGV[1] then  
    return redis.call("DEL", KEYS[1])
else   
    return 0  
end

结合 Golang 的实现

  
type Lock struct {  
    redis   *redis.Client  
    name    string  
    timeout time.Duration  
    uuid    string  
}  
  
type Options struct {  
    Uuid string  
}  
  
func (o *Options) GetUuid() string {  
    if o.Uuid == "" {  
       return ""  
    } else {  
       return o.Uuid  
    }  
}  
  
// KEYS[1] = lockName  
// ARGV[1] = uuid  
const lockLua = `  
if redis.call("GET", KEYS[1]) == ARGV[1] then  
    return redis.call("DEL", KEYS[1])else   
    return 0  
end  
`  
  
func NewLock(name string, redis *redis.Client, timeout time.Duration, options *Options) *Lock {  
    if name == "" {  
       panic("lock name is empty")  
    }    return &Lock{  
       redis:   redis,  
       name:    name,  
       timeout: timeout,  
       uuid:    options.GetUuid(),  
    }}  
  
func (l *Lock) Uuid() string {  
    return l.uuid  
}  
  
func (l *Lock) Lock(ctx context.Context, options *Options) (bool, error) {  
    uuid := l.uuid  
    if options.GetUuid() != "" {  
       uuid = options.GetUuid()  
    }    res, err := l.redis.SetNX(ctx, l.name, uuid, l.timeout).Result()  
    if err != nil {  
       fmt.Printf("SetNX failed: %v\n", err)  
       return false, err  
    }  
    return res, nil  
}  
  
func (l *Lock) Unlock(ctx context.Context, options *Options) (bool, error) {  
    uuid := l.uuid  
    if options.GetUuid() != "" {  
       uuid = options.GetUuid()  
    }    result, err := l.redis.Eval(ctx, lockLua, []string{l.name}, uuid).Result()  
    if err != nil {  
       return false, err  
    }  
    return result.(int64) == 1, nil  
}

基于 Redis 的分布式锁的优缺点

优点很明显:

  1. 相比 DB 来当锁,拥有更好的性能
  2. 实现简单,因为实现原子操作的成本更低
  3. 避免了单点故障,因为 Redis 本身是分布式的

当然,也不全是优点,比如:

  1. 超时时间的设置:这个问题也不能说是 Redis 的问题,但是需要避免程序还在运行但锁超时了的情况发生
  2. 主从并非强一致,可能会导致其实上锁时主节点宕机了,但是还没来得及同步到其他节点,因此数据不一致:

    1. 客户端 A 从 master 中获取到锁
    2. 同步期间 master crash
    3. 从节点被提升为 master,但缺乏相应数据
    4. 客户端 B 从新 master 获取到锁,就产生了两条不同的记录

要解决这个问题,Redis 提供了一个 Redlock 算法来实现分布式锁。

Redlock

核心逻辑

Redlock 的核心理念和投票类似,也就是,既然一个 master 可能会存在问题,那我多加几个 master,不就不会出现问题了吗?

假设我们准备了五个 Redis master 节点,那么客户端在获取锁时会往五个实例申请持有锁。这里需要注意的是:

  1. 超时处理:超时时间的配置应该要 < 自动过期时间,避免节点阻塞,也不能最后一个节点申请完第一个已经过期了
  2. 异常处理:如果节点出现异常,应该尽快下一个

因此我们记录拿第一个锁的开始时间,和最后一个锁的结束时间来判断锁的持有情况:

  1. 多数实例持有(>= 3 个)
  2. t(申请结束)-t(申请开始) > t(锁有效期)

如果最终只有少数持有了锁,我们还需要释放资源。

对于释放资源来说,可能存在「其实我成功了,但是网络失败」的情况,因此不应当只针对成功的节点发释放请求,而应该广播给每一个 master。

Pasted image 20240930123153.png

快速重试

如果失败时会先尝试重试,避免同时有多个客户端都在申请获取资源产生脑裂问题,最终没有人可以持有锁,也因此客户端的总体响应速度越快,出现这种情况的概率就越小。

延迟重启

要保证崩溃恢复,我们必然会考虑将数据持久化,如果不进行持久化,那么节点重启时就可能会遇到当时我们单 Redis 中遇到的问题。

如果持久化了,那么问题将会改善很多,但改善并不代表着解决,如果实例崩溃后一直不可用,那只是参与投票的人少了,似乎没什么问题。

但如果实例崩溃后快速的恢复了,而此时 AOF 的数据没有来得及刷到磁盘中,就仍然会遇到相同的问题。解决方案就是将恢复时间拉长,这个恢复重启时间需要大于锁的有效时间,这样重启时所有的锁都到期了,就不会存在问题了。

时钟同步

上一步我们提到要考虑过期时间,但即使时钟是近似同步的,可能每个 master 中的 time 也会存在一定误差,因此我们可以设置一个漂移量来修复这个问题。

扩展锁

如果锁本身有效期较短,且得到时已经快到期了,可以尝试发送一个指令来进行续期。在 go 的 redlock 包中已有实现:

var touchWithSetNXScript = redis.NewScript(1, `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("PEXPIRE", KEYS[1], ARGV[2])
    elseif redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2], "NX") then
        return 1
    else
        return 0
    end
`)

var touchScript = redis.NewScript(1, `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("PEXPIRE", KEYS[1], ARGV[2])
    else
        return 0
    end
`)

可能存在的问题

RedLock 算法相比单机来说更加可靠,但是实际应用中仍然会受到一些挑战:

  1. 需要更多的资源,且引入了更多的网络 IO 和耗时
  2. 依赖时钟:如果机器中的时间被人工修改造成较大偏差,那可能会存在灾难性问题
  3. 延迟重启对系统的入侵:作为一个业务系统,往往不是独享 Redis 的,重启时间不一定可控

下图是其中一个问题出现的例子:

Pasted image 20240930150059.png

在例子中提到了 GC 导致的 pause,当然实际上,可能也会有其他原因导致类似的效果,比如 CPU 资源竞争,网络延迟。此时光看时间判断就没什么用了。

要解决这个问题,可以在存储侧引入版本号校对,有点类似于一些业务的更新策略,如果发现是老的版本,则不允许更新。

Pasted image 20240930150333.png

但问题是,Redlock 本身并没有这样的机制去保证这一设计,我们也很难保证计数器的一致性。而如果真的在业务侧计入了版本,那么相当于有序写入,似乎和「互斥锁」也没多大关系。

这也成为了一个 Redlock 高不成低不就的漏洞。因此也有文章抨击这一算法没什么卵用。

总结

综上来看,选择怎么样的锁也是一个问题,在现实程序中,我们经常看到单 master 的锁实现,因为他相比 RedLock 来说更加轻量,如果并不需要强一致性和可靠性,允许少量误差的前提下,用它可能更方便。

除了 Redis 以外,我们也可以用 etcd 或者 zookeeper 来实现分布式锁,这个我们下次再研究。

参考资料

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: redis

添加新评论