分布式锁

68 阅读4分钟

分布式锁主要的实现方法主要有3种:

  • MySQL
  • Redis
  • Zookeeper 生产环境中对性能一般要求很高,因此主要使用Redis,本篇主要讨论Redis分布式锁的实现方法,其余只作原理性讨论

MySQL

使用MySQL实现有两种思路

  • MySQL的悲观锁机制
  • MySQL的唯一键

悲观锁

锁的code预先写到数据库中,抢锁的时候,使用select for update查询锁对应的key,也就是这里的code,阻塞就说明别人在使用锁。

唯一键

使用唯一键作为限制,插入一条数据,其他待执行的SQL就会失败,当数据删除之后再去获取锁 ,这是利用了唯一索引的排他性。

ZooKeeper

zookeeper 瞬时znode节点 + watcher监听机制

临时节点具备数据自动删除的功能。当client与ZooKeeper连接和session断掉时,相应的临时节点就会被删除。zk有瞬时和持久节点,瞬时节点不可以有子节点。会话结束之后瞬时节点就会消失,基于zk的瞬时有序节点实现分布式锁:

  • 多线程并发创建瞬时节点的时候,得到有序的序列,序号最小的线程可以获得锁;
  • 其他的线程监听自己序号的前一个序号。前一个线程执行结束之后删除自己序号的节点;
  • 下一个序号的线程得到通知,继续执行;
  • 以此类推,创建节点的时候,就确认了线程执行的顺序。

image.png

Redis

上锁

SET lockKey requestId NX PX 30000

为什么必须设置超时时间

避免死锁:当一个客户端获取锁成功之后,假如它崩溃了,或者它忘记释放锁,或者由于发生了网络分割(network partition)导致它再也无法和 Redis 节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了

requestId作用

防止锁被误释放:requestId用于标记唯一的加锁请求,需要在释放锁的时候校验requestId

可重入

可重入实现是长尾需求,一般不需要考虑,重入时需要校验requestId,必须使用lua来保证操作的原子性

-- 校验请求
if redis.call("get", KEYS[1]) == ARGV[1]
then
    -- 刷新过期时间
    return redis.call("pexpire", KEYS[1], ARGV[2])
else
    -- 设置 key value 和过期时间
    return redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])
end

解锁

如上所述,解锁时需要先校验requestId,因此需要用lua保证原子性

if redis.call("get",KEYS[1]) == ARGV[1then  
      return redis.call("del",KEYS[1])  
  else  
      return 0  
  end

否则可能会有下面这种情况 Image

  1. 客户端 1 获取锁成功;
  2. 客户端 1 进行业务操作;
  3. 客户端 1 为了释放锁,先执行’GET’操作获取随机字符串的值。
  4. 客户端 1 判断随机字符串的值,与预期的值相等。
  5. 客户端 1 由于某个原因阻塞住了很长时间。
  6. 过期时间到了,锁自动释放了。
  7. 客户端 2 获取到了对应同一个资源的锁。
  8. 客户端 1 从阻塞中恢复过来,执行 DEL 操纵,释放掉了客户端 2 持有的锁。

自动续期

任务处理的时间总是很难预估准确,肯定会有超出过期时间的而任务没有结束的情况,因此需要异步线程定时给锁续期

代码封装

用golang实现一个简单的分布式锁sdk

func Lock(ctx context.Context, key string,
	expiration time.Duration, retry rlock.RetryStrategy, timeout time.Duration) (*Lock, error) {
	value := uuid.New().String()
	for {
		lock, err := TryLock(ctx, key, expiration)
                if lock != nil {
                    return lock, nil
                }
		interval, ok := retry.Next()
		if !ok {
                    // 不用重试
                    return nil, ErrFailedToPreemptLock
		}
		timer = time.NewTimer(interval)
		timer.Reset(interval)
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-timer.C:
		}
	}
}

// TryLock (ctx, key, time.Second * 10)
func TryLock(ctx context.Context, key string,
	expiration time.Duration) (*Lock, error) {
	value := uuid.New().String()
	res, err := c.client.SetNX(ctx, key, value, expiration).Result()
	if err != nil {
		return nil, err
	}
	if !res {
		return nil, ErrFailedToPreemptLock
	}
	return newLock(c.client, key, value, expiration), nil
}

type Lock struct {
	client     redis.Cmdable
	key        string
	value      string
	expiration time.Duration
	unlock     chan struct{}
}

func newLock(client redis.Cmdable, key string, value string, expiration time.Duration) *Lock {
	return &Lock{
		client:     client,
		key:        key,
		value:      value,
		expiration: expiration,
		unlock:     make(chan struct{}, 1),
	}
}

func (l *Lock) AutoRefresh(interval time.Duration, timeout time.Duration) error {
	ticker := time.NewTicker(interval)
	for {
		select {
		case <-ticker.C:
			ctx, cancel := context.WithTimeout(context.Background(), timeout)
			err := l.Refresh(ctx)
			cancel()
			if err != nil {
                            logs.Info("")
			}
		case <-l.unlock:
			return nil
		}
	}
}

func (l *Lock) Refresh(ctx context.Context) error {
	res, err := l.client.PExpire(ctx, l.key, l.value, l.expiration.Milliseconds()).Int64()
	if err == redis.Nil {
		return ErrLockNotHold
	}
	if err != nil {
		return err
	}
	if res != 1 {
		return ErrLockNotHold
	}
	return nil
}

func (l *Lock) Unlock(ctx context.Context) error {
	defer func() {
		l.unlock <- struct{}{}
		close(l.unlock)
	}()
	res, err := l.client.Eval(ctx, luaUnlock, []string{l.key}, l.value).Int64()

	if err == redis.Nil {
		return ErrLockNotHold
	}

	if err != nil {
		return err
	}
	// 要判断 res 是不是 1
	if res == 0 {
		// 这把锁不是你的,或者这个 key 不存在
		return ErrLockNotHold
	}
	return nil
}

RedLock