1 前提介绍
1.1 分布式锁类型
- 主动轮询型:该模型有点类似go的sync.Mutex中主动轮询+cas乐观锁模型,取锁方会持续对分布式锁发出尝试取锁的动作,如果锁已经被占用则会不停地发起重试,直到取锁成功
- watch回调型:在取锁方发现锁被他人占用时,会创建watcher监视器订阅锁的施放事件,随后不在发起主动取锁的尝试,当锁被释放后,取锁方能通过之前创建的watcher感知到这一个变化,然后在重新发起取锁的尝试动作
个人看法:在分布式场景下,我比较偏向watch回调型的实现策略。因为在分布式场景中,主动轮询的尝试背后可能是一次甚至多次网络IO请求,这一成本有点高,在这种情况,取锁方基于watch回调的方式取锁,会在确保锁被释放,自身有机会取锁的情况下才会重新尝试取锁的请求,这样很大程度的避免了无意义的尝试损耗。(纯个人看法)
不是说主动没有好处,主动轮询型的分布式锁能够保证使用方始终占据流程的主动权,整个流程也可以更加轻便灵活,此外,watch机制在实现过程中需要建立长连接完成watch监听动作,也会存在一定的资源消耗。所以,我觉得哪个好得看具体场景,例如在并发激烈程度比较高的场景下用watch回调型分布式锁效果可能比较好,反过来就是主动轮循型分布式锁。
1.2 watch回调型实现思路
- 同一把分布式锁,使用一条相同的数据进行标识(例如唯一且明确的key)
- 如果成功在存储介质插入该条数据成功(key 之前不存在), 就代表加锁成功。如果插入失败,就表明锁已经被他人持有,然后监听这条数据的删除事件,当删除事件发生了说明锁被释放了,这时候才会重新尝试插入数据(加锁)
- 解锁就直接删除数据
惊群效应:又称羊群效应,羊群是一种纪律性很差的组织,平时就处在一种散漫无秩序地移动模式之下。需要注意的是,在羊群中一旦有某只羊出现异动,其他的羊也会不假思索地一哄而上跑动起来,全然不顾附近可能有狼或者何处由更好的草原等客观问题。
watch回调型分布式锁也可能有这种情况。例如多个尝试上锁的协程加锁失败之后监听这条数据的删除事件,也就是如果这个分布式锁竞争激烈,可能会出现多个取锁方同时监听同一个锁的释放事件,这个时候如果锁释放了,所有的取锁方都会蜂拥而上进行尝试取锁,也就是造成了“惊群问题”,因此这个过程会存在大量没有意义的性能损耗,而且释放锁的瞬间激增的请求流量也可能会对系统稳定性产生负面效应。(下面介绍解决方案)
1.3 etcd
etcd 官方文档:etcd
etcd是一款适合用于共享配置和服务发现的分布式kv存储组件,底层基于分布式共识算法raft协议保证了存储服务的强一致和高可用。在etcd中提供了watch监听器的功能,及针对于指定范围的数据通过etcd服务端节点创建grpc长连接的方式持续监听变更事件。此外,etcd中写入数据时,还支持通过修订版本revision机制进行取锁秩序的统筹协调,是一款很适合用于实现分布式锁的组件。
1.4 sdk介绍
etcd 开源地址:https://github.com/etcd-io/etcd
本文使用的版本为3.5.8
1.5 死锁问题
为了避免死锁,etcd中提供了租约lease机制,租约,顾名思义就是一份具有时效性的协议,一旦达到租约上规定的截止时间,租约就会失效,同时,etcd还提供了续约机制(keepAlive),用户可以通过续约操作赖延迟租约的过期时间。
利用此机制解锁分布式锁中可能存在的死锁问题思路如下:
- 用户申请一份租约,设定好租约的截止时间
- 异步起动一个协程,主要负责在业务逻辑完成之前,按照一定时间解咒持续进行续约操作
- 在上锁时,将锁对应的kv数据很租约绑定关联,让锁的数据跟租约拥有相同的过期时间属性
在这样设定下,即使分布式锁的拥有者出现异常无法解锁,则可以通过租约的机制完成对分布式锁的释放,死锁问题也因此规避
1.6 etcd如何避免惊群效应
为了避免惊群效应,etcd中提供了前缀prefix机制以及版本revision机制,具体如下:
- 对于同一把分布式锁,锁记录数据的key拥有共同的公共前缀prefix,作为锁的标识
- 每个取锁方取锁时,会以锁前缀prefix拼接上自身的标识(租约id),生成完成的lock key。因此每个取锁方完成的lock key都是不相同的(只有一个共同的公共前缀),所以说理论上每个取锁方都能把锁记录数据插入到etcd(插入数据不代表加锁成功)
- 每个取锁方插入锁记录数据的时候,会获得自身lock key 处在锁前缀prefix范围下唯一且递增的修订版本号revision
- 取锁方插入锁数据记录成功不代表加锁成功,而是需要在插入数据后查询一次锁前缀prefix的纪录列表,如果自身的lock key对应的revision版本时最小的,则表示加锁成功
- 如果锁被他人占用,取锁方就会监听revision小于自己并且最接近自己的那个lock key的删除事件
这样所有的取锁方会按照revision机制的协调,根据取锁序号(revision)的大小顺序排成一条队列,每当锁被释放,只会惊动到下一位顺位的取锁方,惊群问题得以解决。
2 实现源码
文件在etcd-3.5.8\client\v3\concurrency路径下
2.1 数据结构
2.2 Session
session指的是一次对话,背后对应的是一笔租约lease,用户调用NewSession方法构造session实例,执行步骤如下:
- 通过client.Grant方法申请一个lease id
- 调用client.KeepAlive方法持续对租约进行续期
- 构造一个会话session实例
- 异步开启一个守护协程,进行租约续期相应参数的处理(keepAlive)
如果用户执行完业务逻辑,可以通过session.Close方法完成会话关闭,在Close方法中会通过context的cancel函数终止续约的协程(续约的协程依赖于session的context)
package concurrency
import (
"context"
"time"
v3 "go.etcd.io/etcd/client/v3"
)
const defaultSessionTTL = 60
// Session represents a lease kept alive for the lifetime of a client.
// Fault-tolerant applications may use sessions to reason about liveness.
type Session struct {
client *v3.Client
opts *sessionOptions
id v3.LeaseID
cancel context.CancelFunc
donec <-chan struct{}
}
// NewSession gets the leased session for a client.
func NewSession(client *v3.Client, opts ...SessionOption) (*Session, error) {
ops := &sessionOptions{ttl: defaultSessionTTL, ctx: client.Ctx()}
for _, opt := range opts {
opt(ops)
}
id := ops.leaseID
if id == v3.NoLease {
resp, err := client.Grant(ops.ctx, int64(ops.ttl))
if err != nil {
return nil, err
}
id = resp.ID
}
ctx, cancel := context.WithCancel(ops.ctx)
keepAlive, err := client.KeepAlive(ctx, id)
if err != nil || keepAlive == nil {
cancel()
return nil, err
}
donec := make(chan struct{})
s := &Session{client: client, opts: ops, id: id, cancel: cancel, donec: donec}
// keep the lease alive until client error or cancelled context
go func() {
defer close(donec)
for range keepAlive {
// eat messages until keep alive channel closes
}
}()
return s, nil
}
// Client is the etcd client that is attached to the session.
func (s *Session) Client() *v3.Client {
return s.client
}
// Lease is the lease ID for keys bound to the session.
func (s *Session) Lease() v3.LeaseID { return s.id }
// Done returns a channel that closes when the lease is orphaned, expires, or
// is otherwise no longer being refreshed.
func (s *Session) Done() <-chan struct{} { return s.donec }
// Orphan ends the refresh for the session lease. This is useful
// in case the state of the client connection is indeterminate (revoke
// would fail) or when transferring lease ownership.
func (s *Session) Orphan() {
s.cancel()
<-s.donec
}
// Close orphans the session and revokes the session lease.
func (s *Session) Close() error {
s.Orphan()
// if revoke takes longer than the ttl, lease is expired anyway
ctx, cancel := context.WithTimeout(s.opts.ctx, time.Duration(s.opts.ttl)*time.Second)
_, err := s.client.Revoke(ctx, s.id)
cancel()
return err
}
type sessionOptions struct {
ttl int
leaseID v3.LeaseID
ctx context.Context
}
// SessionOption configures Session.
type SessionOption func(*sessionOptions)
// WithTTL configures the session's TTL in seconds.
// If TTL is <= 0, the default 60 seconds TTL will be used.
func WithTTL(ttl int) SessionOption {
return func(so *sessionOptions) {
if ttl > 0 {
so.ttl = ttl
}
}
}
// WithLease specifies the existing leaseID to be used for the session.
// This is useful in process restart scenario, for example, to reclaim
// leadership from an election prior to restart.
func WithLease(leaseID v3.LeaseID) SessionOption {
return func(so *sessionOptions) {
so.leaseID = leaseID
}
}
// WithContext assigns a context to the session instead of defaulting to
// using the client context. This is useful for canceling NewSession and
// Close operations immediately without having to close the client. If the
// context is canceled before Close() completes, the session's lease will be
// abandoned and left to expire instead of being revoked.
func WithContext(ctx context.Context) SessionOption {
return func(so *sessionOptions) {
so.ctx = ctx
}
}
2.3 Mutex
// Mutex implements the sync Locker interface with etcd
type Mutex struct {
s *Session
pfx string
myKey string
myRev int64
hdr *pb.ResponseHeader
}
func NewMutex(s *Session, pfx string) *Mutex {
return &Mutex{s, pfx + "/", "", -1, nil}
}
Mutex是etcd分布式锁的类型,核心字段如下:
- s:内置一个会话session
- pfx:分布式锁的公共前缀
- myKey:pfx+lease id
- myRev:当前锁使用方的lock key(myKey)在公共前缀pfx对应的版本
2.3.1 TryLock方法
Mutex.TryLock方法会执行一次尝试加锁的操作,如果加锁失败直接返回错误,不会阻塞。具体流程如下:
- 调用Mutex.tryAcquire方法(用途主要是看 myKey又没由插入,没有就插入,有就查询),获取到myKey对应的revision以及当前锁的持有者
- 如果pfx没被占用过,或者pfx下对应的最小revision的值等于Mutex的myRev,这说明加锁成功
- 如果锁被占用,则删除自己加锁创建的kv记录(因为是一次尝试),然后返回锁已经被他人占用的错误
// TryLock locks the mutex if not already locked by another session.
// If lock is held by another session, return immediately after attempting necessary cleanup
// The ctx argument is used for the sending/receiving Txn RPC.
func (m *Mutex) TryLock(ctx context.Context) error {
resp, err := m.tryAcquire(ctx)
if err != nil {
return err
}
// if no key on prefix / the minimum rev is key, already hold the lock
ownerKey := resp.Responses[1].GetResponseRange().Kvs
if len(ownerKey) == 0 || ownerKey[0].CreateRevision == m.myRev {
m.hdr = resp.Header
return nil
}
client := m.s.Client()
// Cannot lock, so delete the key
if _, err := client.Delete(ctx, m.myKey); err != nil {
return err
}
m.myKey = "\x00"
m.myRev = -1
return ErrLocked
}
2.3.2 Lock方法
Mutex.Lock方法采用的是阻塞加锁的处理模式,如果锁被别人占用,则会持续阻塞等待时机,直到自己取锁成功:
- 调用Mutex.tryAcquire方法(用途主要是看 myKey又没由插入,没有就插入,有就查询),获取到myKey对应的revision以及当前锁的持有者
- 如果pfx没被占用过,或者pfx下对应的最小revision的值等于Mutex的myRev,这说明加锁成功
- 如果锁被占用,则进入阻塞模式,调用waitDeletes方法,watch监听revision小于并且最靠近自己的那个锁记录的删除事件
- 当接收到那个锁记录的删除事件,会检查自身的租约有没有过期,如果没有就说明加锁成功
// Lock locks the mutex with a cancelable context. If the context is canceled
// while trying to acquire the lock, the mutex tries to clean its stale lock entry.
func (m *Mutex) Lock(ctx context.Context) error {
resp, err := m.tryAcquire(ctx)
if err != nil {
return err
}
// if no key on prefix / the minimum rev is key, already hold the lock
ownerKey := resp.Responses[1].GetResponseRange().Kvs
if len(ownerKey) == 0 || ownerKey[0].CreateRevision == m.myRev {
m.hdr = resp.Header
return nil
}
client := m.s.Client()
// wait for deletion revisions prior to myKey
// TODO: early termination if the session key is deleted before other session keys with smaller revisions.
_, werr := waitDeletes(ctx, client, m.pfx, m.myRev-1)
// release lock key if wait failed
if werr != nil {
m.Unlock(client.Ctx())
return werr
}
// make sure the session is not expired, and the owner key still exists.
gresp, werr := client.Get(ctx, m.myKey)
if werr != nil {
m.Unlock(client.Ctx())
return werr
}
if len(gresp.Kvs) == 0 { // is the session key lost?
return ErrSessionExpired
}
m.hdr = gresp.Header
return nil
}
2.3.3 Mutex.tryAcquire()
Mutex.tryAcquire方法,使用方会完成所数据的插入以及获取revision
- 基于etcd的事务操作,判断如果当前myKey没有创建,就创建kv记录并且执行getOwner方法获取当前锁的持有者;如果已经创建,就查询处对应的kv记录并且执行getOwner方法获取当前锁的持有者;
- 更新当前myKey的revision的值,然后返回myKey对应的revision和当前锁的revision,提供上层的TryLock方法和Lock方法使用
func (m *Mutex) tryAcquire(ctx context.Context) (*v3.TxnResponse, error) {
s := m.s
client := m.s.Client()
m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0)
// put self in lock waiters via myKey; oldest waiter holds lock
put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease()))
// reuse key in case this session already holds the lock
get := v3.OpGet(m.myKey)
// fetch current holder to complete uncontended path with only one RPC
getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...)
resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()
if err != nil {
return nil, err
}
m.myRev = resp.Header.Revision
if !resp.Succeeded {
m.myRev = resp.Responses[0].GetResponseRange().Kvs[0].CreateRevision
}
return resp, nil
}
2.3.4 waitDeletes方法
- 基于for循环实现自旋
- 每次循环获取revision小于并且最靠近自己的所的持有者的key
- 如果key不存在,则说明自己的revision已经是最小的,直接加锁成功
- 如果key存在,则调用waitDelete方法阻塞监听这个key的删除事件
// waitDeletes efficiently waits until all keys matching the prefix and no greater
// than the create revision.
func waitDeletes(ctx context.Context, client *v3.Client, pfx string, maxCreateRev int64) (*pb.ResponseHeader, error) {
getOpts := append(v3.WithLastCreate(), v3.WithMaxCreateRev(maxCreateRev))
for {
resp, err := client.Get(ctx, pfx, getOpts...)
if err != nil {
return nil, err
}
if len(resp.Kvs) == 0 {
return resp.Header, nil
}
lastKey := string(resp.Kvs[0].Key)
if err = waitDelete(ctx, client, lastKey, resp.Header.Revision); err != nil {
return nil, err
}
}
}
func waitDelete(ctx context.Context, client *v3.Client, key string, rev int64) error {
cctx, cancel := context.WithCancel(ctx)
defer cancel()
var wr v3.WatchResponse
wch := client.Watch(cctx, key, v3.WithRev(rev))
for wr = range wch {
for _, ev := range wr.Events {
if ev.Type == mvccpb.DELETE {
return nil
}
}
}
if err := wr.Err(); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return fmt.Errorf("lost watcher waiting for delete")
}
2.3.5 Unlock方法
解锁时直接删除自己的kv对记录即可。如果自己是锁的持有者,那删除kv记录就是真正的解锁;如果自己不是锁的持有者,删除kv记录意味着自己退出抢锁的流程,不会有啥太大影响(不用担心这个操作会造成误唤醒队列中的下一个取锁方从而造成秩序混乱,因为waitDeletes方法监听到删除事件后,会重新获取小于并且最靠近自己的revision的kv对,存在则继续监听,一直到成功取锁(自己的revision最小))
func (m *Mutex) Unlock(ctx context.Context) error {
client := m.s.Client()
if _, err := client.Delete(ctx, m.myKey); err != nil {
return err
}
m.myKey = "\x00"
m.myRev = -1
return nil
}