分布式锁

分享
起因

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

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拿到了锁,client2和client3在等待,而client2拿到的index比client3的更小,那么对于client1删除锁之后,client3其实并不关心,并不需要去watch。所以综上,等待的节点只需要watch比自己index小并且差距最小的节点删除事件即可。