手搓分布式锁的思路(内附完整代码)

我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!

引子

领导:小柯啊,现在有个需求需要你写个分布式锁。

柯三:好哇,使用场景是什么哇?

领导:目前主要应用于定时任务中以及某些请求频次比较低但是数据一致性要求比较高的接口中, 同时不能消耗太多资源。

柯三:OK,我已经有思路了。

领导: 说来听听。

锁的本质

柯三: 锁的本质是一种进程间的协调机制,之所以需要协调大部分情况下都是发生对某个特定资源的竞争,如果不加锁让进程排队获取资源,很可能导致并发问题,如面试中经常被问到的商品超卖问题。

领导: 是的,补充一下,基于锁还还可以让线程按照特定的规则进行运行。

柯三:对,锁的应用地方确实很多。正因为如此,从其功能角度进行倒推其实现,以下几种特性是必须存在的:

  • 加锁: 线程加锁成功之后表示持有了该资源的某些权限。从是否允许多个线程同时加锁来说可以分为互斥锁和共享锁, 一般来说加锁失败的线程要么进入等待锁的状态,要么就立即返回。

  • 解锁: 只有锁的持有者可以进行解锁,非锁的持有者进行操作将触发失败

  • 状态同步机制: 即将锁的状态(加锁/解锁)同步给竞争线程的机制,一般由底层框架提供(Go/JVM)或者结合操作系统内核实现。

在操作系统上,锁的状态一般保存在内存中,比较典型的例子就是Java的AQS使用一个int类型的字段来保存锁的状态,对于互斥锁如ReentrantLock还会保存锁的持有者是哪个线程。其示意如下图所示:

领导:分析得很好, 那么分布式锁该如何实现呢?

解锁了但没完全解锁

柯三: 首先,锁是一个逻辑上的概念,只有在开发者遵循锁的规范情况下才可以正确的发挥作用,如:

  • 对于互斥锁,只有在取得锁的情况下才能目标资源进行操作(当然也可以只是look look一下),并且操作完之后需要进行释放, 获取不到锁的要么等待,要么直接放弃。

如果有开发任人员绕过锁直接对资源进行操作,这种情况是无法使锁的发挥作用的。

领导: 不会吧,不会吧,不会有人真这么干吧,如果有的话他的PR过不了我这一关,大可放心。

柯三: 嗯嗯。因此分布锁的核心实际上就是围绕锁的状态管理以及同步展开,并且由于是分布式锁,锁的竞争者分布在不同的机器上,因此必然需要将锁的状态集中到某台机子/集群上进行管理。

领导:你还少考虑了网络问题吧,但凡跟分布式挂钩的,网络问题是必须考虑的吧?

柯三:啊,对对对。网络问题可烦了。

领导:for example?

柯三:比如,有一个事务需要用到分布式锁,假设其开启了事务并且已经取到锁了,吭哧吭哧跑一段时间后提交了事务,并且准备释放锁,嘿,您猜怎么着?机房外面来了一辆挖掘机把网线给TM的挖断了,完蛋了,那锁就一直在那里了,其他竞争者就搁那里一直等啊等啊,我们管这个叫...

领导:《死 锁》

柯三: 啊,对对对。

领导:别,啊,对对对了。说说解决方案。

柯三: 好办啊,头疼医头脚痛医脚啊,咱给锁来个超时时间,叫啥来着,哦Timeout, 给锁来个Timeout, 如果锁的持有者在Timeout时间内没有主动给锁续期,则视为锁的持有者失去了锁的持有权,将自动触发锁的释放操作。

领导:为什么?

柯三:咱假设数据库和用于分布式锁的中间件处在同一区域的网络中,联系不上中间件自然也就提交不了事务,大可放心。除此之外,业务开发人员也必须保证接口的幂等性。

领导: 如果是GC导致的长时间的Stop the World呢?并且刚好卡在时间点上了,导致锁自己释放,等GC完成之后再回来一看锁已经被其他竞争者持有了,咋办?

柯三(内心OS):NTR???

看门狗机制

柯三:也不是不可以解决,小弟我有上、中、下三策供您选择

上策:优化代码,杜绝长时间Stop the World的现象或者更换垃圾收集器

中策:观测平均的Stop the world的时间,适当提升锁的Timeout时间

下策: 实在无法避免可以启用看门狗机制,用Rust/C/C++编写,检测目标进程的存活,并且锁的续期由看门狗进程来完成

领导:小伙子,还挺会整活的哇。我看还是选上策和中策结合在一起弄,到时候你顺便搞一下压测看看GC表现。

柯三: 好哇

领导:那你准备使用哪个中间件来实现分布式锁呢?

技术选型

Redis

柯三: 吾观世之开源中间件,首推Redis,功能多,速度快,好使用,省内存,简而言之多.快.好.省。

领导:你搁这儿跟我煮酒论中间件是把?说说具体做法,怎么落地?

柯三: 用Redis的Key来表示某个锁,Value存储锁的持有者信息,同时给Key设置超时时间,用来解决上文中提到的死锁问题。

领导:就这?竞态条件怎么解决呢?

柯三(内心OS): 用Redis实现锁乃后端面试八股文必背之一,怎能不了然于胸,正中下怀,哈哈哈哈哈哈哈。

柯三:要解决敌人,就必须分清谁是敌人,同样要解决问题,就需要分析出问题的原因是什么。

我们以一个不正确的加锁实现来说明竞态条件是什么:

image.png 上图中进程A/B加锁的伪代码如下所示:

const proccessName = ""

function lock(key) {
    let val = redis.get(keuy)
    if (val == null) {
        redis.set(key, processname)
        return true
    }
    return val == processName
}
复制代码

以上代码暂未考虑可重入性问题, 下节将进行探讨

按照时间的角度来看:

  • T1时 进程A的执行读取操作发现锁没有被任何持有, 于是进入加锁流程, 即此刻在进程A的内存中val的值为null
  • T2时刻 进程A进入Stop the world状态,T2时刻进程B也发现锁没有被任何进程持有,此时进程B的val的值为null
  • T3时刻 进程A执行逻辑判断,认为此刻锁没有被任何进程进程于是向redis发起加锁操作, 也就是将Lock的值设置为A;与此同时进程B进入了Stop The World状态
  • T4时刻 进程A认为取得了锁于是开始干活了, 在进程B的视角中认为锁没有被任何其他进程获取(val值为null)于是也发起了加锁操作, 于是将Lock的值设置为B
  • T5时刻,进程A/B均认为自己抢到了锁,愉快干活中...

一个互斥锁同时被两个竞争者持有,这好吗? 这不好。

到此,我们对于竞态条件的认识似乎有点感觉了,即加锁的结果取决于进程A/B的执行顺序,即如果任意一方先跑完了加锁流程,并且让其他进程恰好读到了加锁后的结果,那么加锁流程就是正确的。但是大多数情况下加锁流程都是错误, 加锁结果会产生相互覆盖的现象导致互斥锁同时被多个竞争者持有。

领导:那么解决方案是什么?

柯三: 由于Redis只有一个数据处理线程,意味着我们可以将加/解锁的代码移到Redis中, Redis的机制会保证按请求到达的先后顺序处理加/解锁请求。也就是说,所有竞争者的加解锁代码将以串行的方式进行运行。

加锁:通过setnx lock value的方式或者lua脚本进行加锁操作

if redis.call("GET", "lock") == nil then
    redis.call("SET", "lock", "ProcessA")
end
复制代码

解锁:解锁必须保证仅锁的持有者进行解锁,因此采用lua脚本进行解锁操作

if redis.call("GET", "lock") == "ProcessA" then
    redis.call("DEL", "lock")
end
复制代码

领导:那么如何将释放锁的操作通知给其他竞争者呢?难道要让人一直轮询吗?这样子做会导致CPU使用率飙升吧。

柯三: 确实,这也是通过Redis实现锁的缺点之一,不过我们可以适当将轮询的间隔提高一点。

领导:那么锁续期怎么解决呢?

柯三: 之前提到过了可以起一个看门狗线程/进程专门用作与锁需求

领导:还没完,我问你Redis集群中主从节点数据的同步方式是怎样的?

柯三:异步。

领导:如果主节点崩溃了,发生了主从切换,锁的数据却没有及时同步到从节点上,是不是也会发生上文中提到的互斥锁被多个线程持有的问题?

柯三:确实,这问题确实是存在的,我想想有没有其他方案。

领导:不用想了, etcd有听说过没?

柯三:false

etcd

领导: etcd是一个基于Raft协议开发的分布式配置中心, 在go生态中得到了丰富得应用, 比如:Kubernetes就将其做为配置中心用于存储集群的元数据

柯三:跟Zookeeper一样吗?

领导: 不太一样, 放张对比表格你看一下

对比项 \ 中间件 Zookeeper etcd
一致性协议 Paxos Raft
数据模型 目录结构 多版本Key-Value(MVVC)
事务原子性 支持 支持
Key变化监听机制(Watch) 支持 支持
Key绑定客户端机制 支持(即ZK中的临时节点) 支持(即etcd中的租约)

柯三: 那etcd服务端和客户端协议之间的通信方式是什么

领导:HTTP

柯三: 那锁释放的事件不也还是需要主动调用HTTP接口去轮询吗?

领导:etcd v3以前的版本是这样的,不过现在的协议已经切换成了grpc, 并且grpc是基于HTTP/2协议开发可以实现服务端主动推送的功能。

柯三:呃,etcd的MVVC和MySQL的MVVC是不是一回事啊?

领导:概念上是一致的,在etcd中每次对Key进行更新操作都会产生一个全局版本号reivsion, 删除操作也是。

image.png

如上图所示kesan这个Key的全局创建/修改版本号是347, 其自身的版本号是1。

我们将kesan的值修改为test,发现全局修改版本号变成了348, 自身的版本号也变成了2.

image.png

此外,etcd还可以根据历史版本号获取对应的值, 如下图所示我们将kesan删除之后再获取其对应版本号的值如下所示:

image.png

柯三:也就是说,我们可以通过etcd的版本号机制来实现锁咯,那么MySQL是不是也是可以的呢?咱直接把主从同步调整为强一致,再把MySQL的事务级别调整为读已提交,岂不美哉?

领导:是这样没错,此举虽然解决了Redis主从切换的问题,但是没解决锁状态变更同步的问题,而且你续约不得再开看门狗进程去MySQL跑事务续约啊?

柯三:确实。那个租约和Watch是啥情况哇?

领导: 当你Watch一个Key的时候,如果Key的版本号发生变化, 会将变化事件(更新/删除)推送给你。

动画0.gif

租约类似于Redis中Key的TTL(Time to live),不过一个租约可以和多个Key进行绑定, 在创建Key的时候可以将其和某个租约进行绑定,如果TTL内没有发起续租的操作和租约关联的Key将会被删除。

image.png

柯三:明白了,我有思路了。

方案对比

项目\中间件 Redis Etcd
加锁实现 setnx 或者 lua脚本 事务 + 对比Key的版本号
解锁实现 lua脚本 事务
锁状态同步 轮询 Watch Key 变化
单点故障容错性 数据同步方式为异步,无法保证数据同步 主从切换通过Raft协议保证数据同步

Go + etcd 实现分布式锁

需求整理

除了加解锁之外, 基于J.U.C包中锁的使用体验,笔者认为分布式锁可以提供以下功能来优化体验:

  • TryLock机制:可进行一次尝试加锁,如果加锁失败则立即返回
  • 可重入性,锁的持有者可以重复加锁,但需要解锁对应次数才会触发锁的释放
  • 加锁超时

实现思路

由以上需求可以确定,我们可以用Key来表示一个锁,用JSON来记录锁的持有者信息以及加锁的次数, 该结构体定义如下所示

type lock struct {
	Owner   string `json:"owner,omitempty"`
	Count   int    `json:"count,omitempty"`
	Version int64  `json:"-"`
}
复制代码

原子操作

为了保证加锁和解锁过程的原子性, 同Redis一样我们需要将加解锁的过程交由etcd执行。

相比redis内嵌的lua脚本可以实现丰富的流程控制,etcd事务提供了相对简陋的流程控制功能, 当然大多数情况下是够用的。

其事务API由 IF THEN ELSE 三部分组成, 如果IF条件为True则执行THEN中的操作,否则执行ELSE操作,并且比较条件可以是

  • 版本号比较(Version, ModRevision, CreateRevision)
  • 值比较
  • 租约id比较

根据其版本号和事务特性我们编写compareAndPut用于实现加锁操作。

其核心思想是:执行写操作(Put)时检测Key的版本号是否发生变化, 如果未变化则执行写操作,并提交事务, 否则返回。

注意:此处我们比较的是ModRevision即修改版本号。

  • 当Key被删除后我们取到的ModRevision为0
  • 当然你也可以用Value进行比较
func (m *MutexFactory) compareAndPut(ctx context.Context, key string, info lock) (ok bool, err error) {
	bytes, err := json.Marshal(&info)
	if err != nil {
		return
	}
    resp, err := m.client.Txn(ctx).
		If(etcd.Compare(etcd.ModRevision(key),  "=", info.Version)).
		Then(etcd.OpPut(key, string(bytes), etcd.WithLease(m.lease))).
		Commit()
	if err != nil {
		return
	}
	ok = resp.Succeeded
	return
}
复制代码

解锁同理,检测版本号是否发生变更,如未发生变更则执行删除操作。

func (m *MutexFactory) compareAndDel(ctx context.Context, key string, info lock) (ok bool, err error) {
	resp, err := m.client.Txn(ctx).
		If(etcd.Compare(etcd.ModRevision(key), "=", info.Version)).
		Then(etcd.OpDelete(key)).Commit()
	if err != nil {
		return
	}
	ok = resp.Succeeded
	return
}
复制代码

Lock 加锁

加锁的流程:

  • 检测锁是否存在, 如果存在检测是否为锁的持有者, 如果是则触发可重入的流程
  • 如果锁不存在则触发加锁流程
  • 如果加锁失败或者/不是锁的持有者,则执行Watch操作,当检测到删除事件时则执行尝试加锁操作,若加锁成功则返回否则继续Watch

对于加锁超时的功能,可以由调用者传入一个带有超时事件的上下文的context来实现。

func (m *mutex) Lock(ctx context.Context) (ok bool, err error) {
	info, has, err := m.ops.get(ctx, m.lockLey)
	if err != nil {
		return
	}
	if has && info.Owner == m.id {
		info.Owner = m.id
		info.Count++
		ok, err =  m.ops.compareAndPut(ctx, m.lockLey, info)
		return
	}
	if !has { // fast path
		trySuccess, putErr := m.ops.compareAndPut(ctx, m.lockLey, lock{Owner: m.id, Count: 1})
		if putErr != nil {
			err = putErr
			return
		}
		if trySuccess {
			ok = trySuccess
			return
		}
	}
	client := m.ops.client
	watchCtx, cancelWatch := context.WithCancel(ctx)
	defer cancelWatch()
	change := client.Watch(watchCtx, m.lockLey)
	for {
		select {
		case resp := <- change:
			for _, v  := range resp.Events {
				if v.Type == mvccpb.DELETE {
					goto TryLock
				}
			}
		case <- ctx.Done():
			return
		}
TryLock:
		ok, err = m.ops.compareAndPut(ctx, m.lockLey, lock{Owner: m.id, Count: 1})
		if err != nil {
			return
		}
		if ok {
			return
		}
	}
}

复制代码

解锁

解锁的流程:

  • 获取锁信息,检测自己是否为锁的持有者,如果不是则报错
  • 如果是锁的持有者则对加锁次数减1
  • 如果加锁次数小于1,则触发解锁流程级调用compareAndDel方法
func (m *mutex) Unlock(ctx context.Context) (err error) {
	info, has, err := m.ops.get(ctx, m.lockLey)
	if err != nil {
		return
	}
	if !has { // lock not exists
		err = errors.New("lock not exists")
		return
	}
	if info.Owner != m.id { // illegal operate
		err = errors.New("illegal operation")
		return
	}
	info.Count--
	if info.Count < 1 {
		_, err = m.ops.compareAndDel(ctx, m.lockLey, info)
		return
	}
	updated, err := m.ops.compareAndPut(ctx, m.lockLey, info)
	if err != nil {
		return
	}
	if !updated { //
		err = errors.New("updated lock failed")
	}
	return
}
复制代码

续约

那么该如何进行续约呢?

笔者抽象出了一个MutexFactory的概念,创建一个MutexFactory时侯都会开启一个协程专门用于续约,由同一个MutexFactory创建的Mutex共享同一个租约id。

并且每个Mutex绑定的Key是在创建的时侯就指定的。

image.png

其代码如下所示:

func NewFactory(ctx context.Context, client *etcd.Client, opts ...Opt) (factory *MutexFactory, err error) {
	option := defaultOption
	for _, v := range opts {
		v(&option)
	}
	factory = &MutexFactory{
		ctx:           ctx,
		client:        client,
		FactoryOption: &option,
	}
	if option.ttl > 0 {
		lease, grantErr := client.Grant(ctx, option.ttl)
		if grantErr != nil {
			err = grantErr
			return
		}
		factory.lease = lease.ID
		err = factory.keepAlive(ctx, factory.lease)
		if err != nil {
			return
		}
	}
	return
}

func (m *MutexFactory) keepAlive(ctx context.Context, leaseId etcd.LeaseID) error {
	respCh, err := m.client.KeepAlive(ctx, leaseId)
	if err != nil {
		return err
	}
	go func() {
		for {
			select {
			case resp := <-respCh:
			    //输出响应, 仅开发测试时使用
				color.Blue("Keepalive %d\n", resp.ID)
			case <-ctx.Done():
				return
			}
		}
	}()
	return nil
}
复制代码

测试

基准测试

Benchmark测试用例代码如下:

func Benchmark_Mutex(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		client, err := etcd.New(etcd.Config{
			Endpoints:   []string{"你的ETCD地址"},
			DialTimeout: 5 * time.Second,
		})
		if err != nil {
			panic(err)
		}
		ctx := context.Background()
		mutexFactory, err :=NewFactory(ctx, client, WithTTL(10))
		if err != nil {
			panic(err)
		}
		mux := mutexFactory.Create("KeSanGo")
		for pb.Next() {
			ok, err := mux.Lock(ctx)
			if err != nil {
				panic(err)
			}
			if !ok {
				b.Errorf("lock failed")
				return
			}
			err = mux.Unlock(ctx)
			if err != nil {
				panic(err)
			}
		}
	})
}
复制代码

动画.gif 报告如下所示

goos: windows
goarch: amd64
pkg: etcdmutex
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
Benchmark_Mutex
Benchmark_Mutex-12    	     127	   7983505 ns/op
PASS

Process finished with the exit code 0
复制代码

死锁测试

测试目标:进程获取锁后会随机崩溃, 检测是否会产生死锁的现象

代码如下:

func main() {
	client, err := etcd.New(etcd.Config{
		Endpoints:   []string{"ETCD地址"},
		DialTimeout: 5 * time.Second,
	})
	if err != nil {
		panic(err)
	}
	ctx := context.Background()
	mutexFactory, err := etcdmutex.NewFactory(ctx, client, etcdmutex.WithTTL(10))
	if err != nil {
		panic(err)
	}
	mutex := mutexFactory.Create("Kovogo")
	waitStart := time.Now()
	ok, err := mutex.Lock(ctx)
	if err != nil {
		panic(err)
	}
	if !ok {
		logFail("Lock failed")
		return
	}
	logSuccess("Lock success, after waiting %d ms\n", time.Since(waitStart).Milliseconds())
	time.Sleep(time.Second * time.Duration(rand.Intn(3) + 5))
	rand.Seed(time.Now().Unix())
	if rand.Intn(10) >= 5 {
		panic("Random panic")
	}
	err = mutex.Unlock(ctx)
	if err != nil {
		panic(err)
	}
	logSuccess("Unlock success")
}
复制代码

录屏Gif如下: 动画1.gif

观察可以看出,由于租约得时间被调得太长,导致进程崩溃后锁隔了一段时间才释放,读者可以根据实际情况进行调整。

目前来说测试项目还不完全,诸多情况还没测试到,测试完成后再回来与各位分享。

总结

分布式锁的本质上是一种跨进程的协调机制。

无论你选用何种中间件实现分布式锁,以下CheckList是你必须要注意的:

检查项目 说明
原子性 加/解锁需要目标中间件支持原子操作
节点崩溃容错性 如果目标中间件采用集群进行部署,发生主从切换发生时,注意数据能否及时同步
Watch机制 如果目标中间件没有推送锁状态(目标Key)变化的功能,注意能否通过其他方式实现,如无法实现注意轮询锁状态代理的消耗
死锁处理 是否支持将Key与客户端存活进行绑定的机制, 如果没有就起WatchDog进行续期

微信搜 柯三Go 一起整活!

附录

おすすめ

転載: juejin.im/post/7067182305025982501