分布式锁

今天让我们聊聊分布式锁。

基本性质

一个分布式锁要实现以下基本特性:

互斥性: 任意时刻,只有一个客户端能持有锁。

安全性:锁只能被持有的客户端删除,不能被其他客户端删除

可选:可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。

为什么要实现可重入性?

可重入性意味着在同一个线程(或处理单元)内,一个已经获得锁的实体可以多次重新获取并释放该锁,而不会被锁住。实现可重入性有以下几个原因:

  1. 避免死锁:可重入性的主要好处之一是防止死锁。考虑一个场景,其中一个线程已经获取了一个锁,然后在其持有的锁的保护下再次尝试获取同一个锁。如果分布式锁不是可重入的,该线程将会被阻塞,因为它在等待一个已经被其自己持有的锁,从而导致死锁。
  2. 提高灵活性:在复杂的操作中,一个操作可能需要多次获取锁。如果锁是可重入的,这样的操作就可以在不释放锁的情况下多次获取它,这为复杂的事务提供了更大的灵活性。
  3. 减少错误:可重入性减少了因意外再次获取已持有的锁而导致的错误。

但是不支持可重入性的锁的实现也是有的,这跟语言特性、性能考虑、并发模型都有关系,比如Golang的锁就不支持可重入,有同学来聊聊为什么吗?

具体实现

在本节中,将用Redis实现一个分布式锁~

上锁

上锁是互斥行为,在Redis中 ,可以使用SETNX实现,如果设置键值成功,则表示成功拿到锁。以下是SET命令的详细介绍

SET key value[EX seconds][PX milliseconds][NX|XX]

NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。

EX seconds :设定key的过期时间,时间单位是秒。

PX milliseconds: 设定key的过期时间,单位为毫秒

XX: 仅当key存在时设置值

我们看到,SET命令还可以同时设置过期时间,可以保证在异常情况下锁的释放。value的值可以设置为客户端的唯一标识,用于解锁的时候进行校验

解锁

可以用redis删除键值的命令代表解锁。但是上文提到过分布式锁要保证"安全性",不能出现客户端解锁了其他人持有的锁的情况。这就要求解锁操作进行校验了。也就是说解锁操作需要分两步进行:锁的所有者验证+解锁。

因此我们需要通过lua脚本保证锁的所有者验证和解锁操作的原子性

kotlin 复制代码
if redis.call('get',KEYS[1]) == ARGV[1] then // 先校验锁的持有人
   return redis.call('del',KEYS[1])  // 校验通过删除锁
else
   return 0
end;

锁续期

上文在实现上锁的时候,提到过上锁的同时可以设置一个过期时间。但是这个过期时间是很不好把握的。如果设置的太短了,那么事务没有执行完毕锁就会过期失效,反之如果设置的太长了,那么如果获取到锁的节点因为异常奔溃了,那么就会导致资源被长时间占用,造成性能问题和资源的浪费。因此,需要一个锁续期的机制来弥补这个不足。锁续期的机制是这样的:

  1. 上锁的时候加上一个预估可以完成事务的过期时间
  2. 加锁的同时启动一个协程对事务进行监控,定时轮询(周期通常为锁过期时间的一半)事务是否完成,如还没有完成则对锁的过期时间进行续期。
  3. 解锁的时候释放协程 以下是一个Golang的分布式锁的简单实现:
go 复制代码
type RedisLock struct {
    client   *redis.Client
    key      string
    value    string
    ttl      time.Duration
    stopChan chan bool
}

func NewRedisLock(client *redis.Client, key string, value string, ttl time.Duration) *RedisLock {
    return &RedisLock{
        client:   client,
        key:      key,
        value:    value,
        ttl:      ttl,
        stopChan: make(chan bool),
    }
}

func (r *RedisLock) Lock() bool {
    // 使用SET命令来尝试获取锁
    success, err := r.client.SetNX(r.key, r.value, r.ttl).Result()
    if err != nil || !success {
        return false
    }

    // 启动背景任务来定期续期锁
    go r.renewLock()

    return true
}

func (r *RedisLock) Unlock() {
    // 发送停止信号到续期背景任务
    r.stopChan <- true

    // 释放锁
    r.client.Del(r.key)
}

func (r *RedisLock) renewLock() {
    ticker := time.NewTicker(r.ttl / 2)
    for {
        select {
        case <-ticker.C:
            r.client.Expire(r.key, r.ttl)
        case <-r.stopChan:
            ticker.Stop()
            return
        }
    }
}

题外话,Golang的协程实在是太方便了,其他语言的同学不要羡慕哈~

以上实现基于单个Redis节点来获取和释放锁。如果这个Redis节点出现故障,整个锁服务将不可用,这就产生了单点故障的风险。为了提高分布式锁的可用性,可以使用Redis集群,以上代码也只需要略作修改,这里不再给出对应代码~

性能优化

分布式锁的性能优化又是一个说来话长的话题了,之后会再专门出一篇文章来讲讲。有经验的同学可以留言你们是怎么做的呢?

相关推荐
FIN技术铺3 小时前
Redis集群模式之Redis Sentinel vs. Redis Cluster
数据库·redis·sentinel
程序员曦曦7 小时前
一文熟悉redis安装和字符串基本操作
自动化测试·软件测试·数据库·redis·功能测试·程序人生·缓存
风再云巅7 小时前
Redis下载历史版本
redis
Java 第一深情9 小时前
Redis经典面试题-深度剖析
数据库·redis·缓存
蜜獾云10 小时前
redis 三种持久化对比
数据库·redis·缓存
ktkiko1113 小时前
Redis中的数据结构
数据结构·数据库·redis
basic_code14 小时前
Docker使用docker-compose一键部署nacos、Mysql、redis
运维·redis·mysql·docker·nacos
sunyanchun16 小时前
如何保证Redis与MySQL双写一致性
redis·mysql
ktkiko1116 小时前
Redis中的过期删除与内存淘汰
数据库·redis·缓存
大数据编程之光17 小时前
Redis五种数据类型剖析
数据库·redis·bootstrap