分布式锁

起因

之前遇到一些场景,使用了分布式锁,目前来做一个总结

redis

1. 单点redis

单点redis场景,主要容易存在单点故障。(分布式高可用的手段:1.单点故障转移 2.数据冗余)

// 获取锁
SET key value NX PX xmilliseconds 

// 删除锁
if redis.call("get", KEYS[1]) == ARGS[1] then  
    return redis.call("del", KEYS[1])
else  
    return 0
end  

设置key值,需要设置一下随机value,避免被其他竞争者删除
设置过期时间,避免客户端崩溃之后,该key永远无法被删除

unsafe-lock

删除需要使用lua脚本,因为redis单线程执行lua能保证原子性

2019年qconf大会  苏宁拼团讲到两点,非常现实  
1. 过期时间不好把控;不能太短,否则业务还没完(或者GC之类),锁就没了;如果过长,client一旦崩溃,就导致锁长时间停留  
2. failover 单点问题  

2. Redlock

原理上跟单机redis差不多,只是需要对所有节点都要发起请求

1. 获取当前时间戳 t1  
2. 依次向所有的redis,发起加锁过程(每次请求最好设置超时,远小于锁的过期时间),只要超过一半节点成功设置,就认为获取的到锁;反之获取锁失败;记下当前的时间戳 t2  
3. 如果获取成功,那么当前锁的有效时间值 = 设置过期时间 - (t2 - t1)  
4. 如果获取失败,需要向所有节点发送删除锁  

考虑一下 redis 某个节点崩溃场景

A、B、C、D、E 5个节点  
1. client甲 在节点A、B、C上面上锁成功  
2. C崩溃  
3. C重新启动,丧失了锁信息  
4. client乙 在节点C、D、E上面上锁成功  


另外也没有解决超时时间长短问题

3. fencing-tokens

fecing-tokens

  1. 其实对于这种方式需要 storage 支持乐观锁
  2. 并且从逻辑上来看Lock service并没有存在的必要,只需要一个能够生成递增 token服务即可

实际生产中是可以缓解 Storage 的竞争关系

4. zookeeper
zookeeper(etcd、consul等类似)等主要能解决一旦出现客户端崩溃,Lock service能够感知
就是锁本身是不需要做过期的,本质上是把锁上面的过期时间,扔给了长链session心跳包(etcd里面是lease

而且分布式协调器通常是带有watch能力的,也就是说其他client在竞争不到锁的时候,线程(协程)可以直接直观阻塞

(本质上来还是很难解决,当出现客户端gc、或者网络延迟,lock被删,导致client1正在操作storage这个时间点还是没有被锁)

看一个分布式锁的etcd例子

package main

import (  
    "context"
    "fmt"
    "github.com/coreos/etcd/clientv3"
    "github.com/coreos/etcd/clientv3/concurrency"
    "log"
    "os"
    "os/signal"
    "time"
)

func main() {  
    c := make(chan os.Signal)
    signal.Notify(c)

    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    lockKey := "/lock"

    go func () {
        session, err := concurrency.NewSession(cli)
        if err != nil {
            log.Fatal(err)
        }
        m := concurrency.NewMutex(session, lockKey)
        if err := m.Lock(context.TODO()); err != nil {
            log.Fatal("go1 get mutex failed " + err.Error())
        }
        fmt.Printf("go1 get mutex sucess\n")
        fmt.Println(m)
        time.Sleep(time.Duration(10) * time.Second)
        m.Unlock(context.TODO())
        fmt.Printf("go1 release lock\n")
    }()

    go func() {
        time.Sleep(time.Duration(2) * time.Second)
        session, err := concurrency.NewSession(cli)
        if err != nil {
            log.Fatal(err)
        }
        m := concurrency.NewMutex(session, lockKey)
        if err := m.Lock(context.TODO()); err != nil {
            log.Fatal("go2 get mutex failed " + err.Error())
        }
        fmt.Printf("go2 get mutex sucess\n")
        fmt.Println(m)
        time.Sleep(time.Duration(2) * time.Second)
        m.Unlock(context.TODO())
        fmt.Printf("go2 release lock\n")
    }()

    <-c
}

这里面需要注意一个惊群效应,每一个client在锁住/lock这个path的时候,实际都已经插入了自己的数据,类似/lock/LEASE_ID,并且返回了各自的index(就是raft算法里面的日志索引),而只有最小的才算是拿到了锁,其他的client需要watch等待。例如client1拿到了锁,client2client3在等待,而client2拿到的indexclient3的更小,那么对于client1删除锁之后,client3其实并不关心,并不需要去watch。所以综上,等待的节点只需要watch比自己index小并且差距最小的节点删除事件即可。