分布式锁

1. 分布式锁原理

  • 分布式锁场景

有些业务场景是不适合使用多线程并发的模式的(多线程容易导致数据不一致的问题出现),只能使用单线程进行执行。但是这样又会引发单点故障的问题,因此需要引入如分布式锁来解决单点故障的问题。

  • 分布式锁和单机锁

什么是锁,主要是可以实现达到对资源的互斥访问, 可以实现排他性的状态。也就是:

锁 = 资源 + 并发控制 + 所有权展示

常见的单机锁主要是以原子操作为基础,保障一个进程只能实现一次有效操作。

spinlock = BOOL + CAS (乐观锁)
Mutex = BOOL + CAS + 通知 (悲观锁)

但是到了分布式环境,这就会带来比较多的挑战。为了应对各种机器故障、宕机等,就需要给锁提供了一个新的特性:可用性。分布式锁的核心点是:资源、互斥访问、可用性。

2. 分布式锁选型

基于锁资源本身的安全性,分为:

  • 基于异步复制的分布式系统,例如mysql,tair,redis等。
  • 基于paxos协议的分布式一致性系统,例如zookeeper、etcd、consul等

基于异步复制的分布式系统,存在数据丢失(丢锁)的风险,不够安全,往往通过 TTL 的机制承担细粒度的锁服务,该系统接入简单,适用于对时间很敏感,期望设置一个较短的有效期,执行短期任务,丢锁对业务影响相对可控的服务。

基于 paxos 协议的分布式系统,通过一致性协议保证数据的多副本,数据安全性高,往往通过租约(会话)的机制承担粗粒度的锁服务,该系统需要一定的门槛,适用于对安全性很敏感,希望长期持有锁,不期望发生丢锁现象的服务。

3. etcd 分布式锁实现

实际上etcd的分布式锁,就是一个kv,然后客户端保持一个心跳,进行续期即可实现选主加锁的目的。

逻辑就是,大家一起抢,谁抢到谁就一直续,要是不续了就另外的老哥上,能者居之。

package main

import (
	"fmt"
	"context"
	"time"
	//"reflect"

	"go.etcd.io/etcd/clientv3"
)

var (
	lease                  clientv3.Lease
	ctx                    context.Context
	cancelFunc             context.CancelFunc
	leaseId                clientv3.LeaseID
	leaseGrantResponse     *clientv3.LeaseGrantResponse
	leaseKeepAliveChan     <-chan *clientv3.LeaseKeepAliveResponse
	leaseKeepAliveResponse *clientv3.LeaseKeepAliveResponse
	txn                    clientv3.Txn
	txnResponse            *clientv3.TxnResponse
	kv                     clientv3.KV
)

type ETCD struct {
	client                 *clientv3.Client
	cfg                    clientv3.Config
	err                    error
}

// 创建ETCD连接服务
func New(endpoints ...string) (*ETCD, error) {
	cfg := clientv3.Config{
		Endpoints: endpoints,
		DialTimeout: time.Second * 5,
	}

	client, err := clientv3.New(cfg)
	if err != nil {
		fmt.Println("连接ETCD失败")
		return nil, err
	}

	etcd := &ETCD{
		cfg: cfg,
		client: client,
	}

	fmt.Println("连接ETCD成功")
	return etcd, nil
}

// 抢锁逻辑
func (etcd *ETCD) Newleases_lock(ip string) (error) {
	lease := clientv3.NewLease(etcd.client)
	leaseGrantResponse, err := lease.Grant(context.TODO(), 5)
	if err != nil {
		fmt.Println(err)
		return err
	}
	leaseId := leaseGrantResponse.ID
	ctx, cancelFunc := context.WithCancel(context.TODO())
	defer cancelFunc()
	defer lease.Revoke(context.TODO(), leaseId)
	leaseKeepAliveChan, err := lease.KeepAlive(ctx, leaseId)
	if err != nil {
		fmt.Println(err)
		return err
	}

    // 初始化锁
	kv := clientv3.NewKV(etcd.client)
	txn := kv.Txn(context.TODO())
	txn.If(clientv3.Compare(clientv3.CreateRevision("/dev/lock"), "=", 0)).Then(
		clientv3.OpPut("/dev/lock", ip, clientv3.WithLease(leaseId))).Else(
		clientv3.OpGet("/dev/lock"))
	txnResponse, err := txn.Commit()
	if err != nil {
		fmt.Println(err)
		return err
    }
    // 判断是否抢锁成功
	if txnResponse.Succeeded {
		fmt.Println("抢到锁了")
        fmt.Println("选定主节点", ip)
            // 续租节点
			for {
				select {
				case leaseKeepAliveResponse = <-leaseKeepAliveChan:
					if leaseKeepAliveResponse != nil {
						fmt.Println("续租成功,leaseID :", leaseKeepAliveResponse.ID)
					} else {
						fmt.Println("续租失败")
					}
	
				}
			}
	} else {
        // 继续回头去抢,不停请求
		fmt.Println("没抢到锁", txnResponse.Responses[0].GetResponseRange().Kvs[0].Value)
		fmt.Println("继续抢")
		time.Sleep(time.Second * 1)
	}
	return nil
}

func main(){
    // 连接ETCD
	etcd, err := New("xxxxxxxx:2379")
	if err != nil {
		fmt.Println(err)
    }
    // 设定无限循环
	for {
		etcd.Newleases_lock("node1")
	}
}

4. redis 分布式锁实现

  • setnx
1. setnx key value
2. set key value [EX seconds|Px milliseconds] [NX|XX] [KEEPTTL]
3. set test value nx px 30000

setnx原理是,主要依托它的key不存在才能set成功的特性。进程A拿到锁,在没有删除锁的key时,进程B自然获取锁失败。设置(px 30000), 主要是怕线程不释放锁,导致谁都拿不到锁。所以设置超时时间。

另外value可以使用pid之类的参数,保障可以对比对应的锁是否是该进程所实施的。

为了保障删除锁的原子性,使用lua脚本实现,通过redis的eval/evalsha命令来运行:

-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的Test和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1]
    then
 -- 执行删除操作
        return redis.call('del', KEYS[1])
    else
 -- 不成功,返回0
        return 0
  • Redlock

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端应该执行以下操作:

(1) 获取当前Unix时间,以毫秒为单位。

(2) 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。

(3) 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
(4) 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。

(5) 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

golang实现:https://github.com/hjr265/redsync.go

4. 参考文献

我们的系统需要什么样的分布式锁?

Redlock:Redis分布式锁最牛逼的实现

那些问哭你的Redis分布式锁

你可能感兴趣的:(Docker)