前言
回想起来,已经半年没有更了,当然也从没停止过打工人的脚步。。。
锁的含义
单机环境下,锁是用来控制多个线程访问共享资源的方式,通过防止多个线程同时访问共享资源影响最终结果。
实际开发过程中,开发者面对的通常是分布式环境,而在分布式系统的设计与实现当中,通常会遇到多个进程同时访问共享资源的场景,为了保证数据最终一致性(BASE理论),分布式锁成为分布式系统设计中的重要组成部分。
为什么使用分布式锁
分布式锁通常用于保证位于分布式环境的多个节点,对共享资源的访问或操作仅进行一次(at least once)。 使用分布式锁的两个主要原因有:
1、效率性(Efficiency):减少了对于共享资源的重复访问及操作,节省了资源。
2、正确性(Correctness):预防多个进程同时对共享资源进行访问及操作,影响最终结果的正确性。
分布式锁的实现方式主要分为以下2类:
1、基于Redis的实现,例如RedLock算法等
2、基于分布式协调服务实现,例如etcd、ZooKeeper实现
基于Redis实现
使用Redis实现分布式锁的关键在于决定并发控制的维度,也就是如何拼装lock key。
1、共享资源维度的控制
2、业务唯一键维度(uid)等的控制
- 加锁
SET resource_name my_random_value NX PX 30000
- 解锁
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
共享资源维度的控制需要保证redis的lock value为全局唯一的随机值:
释放锁需要删除对应的lock key,而删除前需要校验当前进程是否为该锁的持有进程。
为什么setnx全局唯一值?
上图就解释了为什么lock key的值要设置成全局唯一值用来标识哪个client获取了分布式锁。 可以看到,Client1获取到锁后,由于处理时间超出锁过期时间,Client2获取到锁;Client1恢复执行完逻辑释放锁,其实已经吧Client2的锁给释放了,后续会导致一系列并发问题。
锁过期释放导致的对共享资源的同时访问
解决上述问题,有以下几种思路进行切入:
1、过期时间设置要充分(当然不能太久),满足比绝大部分(除极少数异常情况)业务处理时间要大,使上述情况出现的尽可能少。
2、如果分布式锁控制的是MySQL,可通过先查询对应行数据(使用uid、instanceID等,需注意主从延时),后加锁的方式,结合索引约束,实现对应业务逻辑。
2、fencing token(分布式自增唯一ID,例如ZooKeeper的zxid)来保证共享资源访问的先后顺序,在共享资源判断token的是否是最新的来进行访问控制。
3、lock key对应的唯一随机值在对共享资源进行访问时,通过check and set机制校验进程对共享资源访问的合法性。
fencing token
指的是每次有Client获取到锁时,就会递增的全局唯一id。这样就可以根据Client获取锁的顺序来对共享资源进行操作(类似乐观锁的实现),具体如下图所示。
In this context, a fencing token is simply a number that increases (e.g. incremented by the lock service) every time a client acquires the lock.
但fencing token的方式存在一个问题,获取锁的先后顺序,就决定了后续取哪个操作的结果,但大部分情况下,并发请求之间的时序性很难保证。
Redlock
Redlock算法是简单来讲就是上述单机实现在分布式系统版本的扩展;算法需要N个Redis master节点,获取锁成功的条件为N/2 + 1个节点setnx操作成功,具体如下图所示。
go-redsync
- 配置信息
m := &Mutex{
name: name,
expiry: 8 * time.Second,
tries: 32,
delayFunc: func(tries int) time.Duration { return 500 * time.Millisecond },
genValueFunc: genValue,
factor: 0.01,
quorum: len(r.pools)/2 + 1,
pools: r.pools,
}
- 单节点加锁方法
func (m *Mutex) acquire(ctx context.Context, pool redis.Pool, value string) (bool, error) {
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
reply, err := conn.SetNX(m.name, value, m.expiry)
if err != nil {
return false, err
}
return reply, nil
}
- 加锁方法
func (m *Mutex) LockContext(ctx context.Context) error {
value, err := m.genValueFunc()
if err != nil {
return err
}
for i := 0; i < m.tries; i++ {
if i != 0 {
time.Sleep(m.delayFunc(i))
}
start := time.Now()
n, err := m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
return m.acquire(ctx, pool, value)
})
if n == 0 && err != nil {
return err
}
now := time.Now()
until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.factor)))
if n >= m.quorum && now.Before(until) {
m.value = value
m.until = until
return nil
}
_, _ = m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
return m.release(ctx, pool, value)
})
}
return ErrFailed
}
基于分布式一致性服务的实现
除借助Redis的能力外,还可以通过引入分布式一致性服务实现,如下:
1、ZooKeeper
2、etcd
3、Google Chubby(但ZooKeeper作为Chubby的开源实现,本文介绍Zookeeper、etcd的实现)
ZooKeeper
ZooKeeper是一个典型的分布式数据一致性解决方案,主要可以用于以下场景。
1、数据发布/订阅(动态配置等)
2、负载均衡
3、命名服务
4、分布式协调/通知
5、集群管理
6、master节点选举
7、分布式锁
8、分布式队列\
本文主要聚焦使用ZooKeeper实现分布式锁的原理及实现,其中以排他锁为例。
- 锁被定义为一个临时顺序节点,例如/exclusive_lock/lock(可以为任何ZooKeeper路径)。
- 获取锁的过程为所有客户端都试图通过调用create()接口,创建/exclusive_lock/lock节点,ZooKeeper会保证只有一个客户端创建成功,那么就可以认为该客户端获取了锁。
- 释放锁:已经提到,/exclusive_lock/lock是一个临时节点,因此在以下情况下,都有可能释放锁。
1、当前获取锁的客户端机器发生宕机,临时节点被删除。
2、正常执行完业务逻辑后,客户端主动将创建的临时节点删除。
- 通知:由于通过
watch
机制watch
了/exclusive节点,一旦/exclusive的子节点发生变化,都会通知到客户端,客户端接到通知后,就会重复“获取锁”的流程。
go-zk-lock
- 加锁
// for循环
func (this *Dlocker) Lock() {
for !this.lock() {
}
}
// 获取锁
func (this *Dlocker) lock() (isSuccess bool) {
isSuccess = false
defer func() {
e := recover()
if e == zk.ErrConnectionClosed {
//try reconnect the zk server
log.Println("connection closed, reconnect to the zk server")
reConnectZk()
}
}()
this.innerLock.Lock()
defer this.innerLock.Unlock()
//create a znode for the locker path
var err error
this.lockerPath, err = this.createZnodePath()
this.checkErr(err)
//get the znode which get the lock
minZnodePath, err := this.getMinZnodePath()
this.checkErr(err)
if minZnodePath == this.lockerPath {
// if the created node is the minimum znode, getLock success
isSuccess = true
} else {
// if the created znode is not the minimum znode,
// listen for the last znode delete notification
lastNodeName := this.getLastZnodePath()
watchPath := this.basePath + "/" + lastNodeName
isExist, _, watch, err := getZkConn().ExistsW(watchPath)
this.checkErr(err)
if isExist {
select {
//get lastNode been deleted event
case event := <-watch:
if event.Type == zk.EventNodeDeleted {
//check out the lockerPath existence
isExist, _, err = getZkConn().Exists(this.lockerPath)
this.checkErr(err)
if isExist {
//checkout the minZnodePath is equal to the lockerPath
minZnodePath, err := this.getMinZnodePath()
this.checkErr(err)
if minZnodePath == this.lockerPath {
isSuccess = true
}
}
}
//time out
case <-time.After(this.timeout):
// if timeout, delete the timeout znode
children, err := this.getPathChildren()
this.checkErr(err)
for _, child := range children {
data, _, err := getZkConn().Get(this.basePath + "/" + child)
if err != nil {
continue
}
if modules.CheckOutTimeOut(data, this.timeout) {
err := getZkConn().Delete(this.basePath+"/"+child, 0)
if err == nil {
log.Println("timeout delete:", this.basePath+"/"+child)
}
}
}
}
} else {
// recheck the min znode
// the last znode may be deleted too fast to let the next znode cannot listen to it deletion
minZnodePath, err := this.getMinZnodePath()
this.checkErr(err)
if minZnodePath == this.lockerPath {
isSuccess = true
}
}
}
return
}
- 释放锁
func (this *Dlocker) unlock() (isSuccess bool) {
isSuccess = false
defer func() {
e := recover()
if e == zk.ErrConnectionClosed {
//try reconnect the zk server
log.Println("connection closed, reconnect to the zk server")
reConnectZk()
}
}()
err := getZkConn().Delete(this.lockerPath, 0)
if err == zk.ErrNoNode {
isSuccess = false
return
} else {
this.checkErr(err)
}
isSuccess = true
return
}
etcd
etcd是具有强一致性的key-value存储服务,其实现方式与ZooKeeper大同小异,可以参考etcdsync
附录
martin kleppmann与Redlock作者之间的争论