--- layout: post title: "redis分布式锁3种实现方式对比分析总结" date: 2020-1-28 tags: [后台开发] comments: true author: lemonchann --- 大家春节在家抢红包玩的不亦乐乎,抢红包服务看起来非常简单,实际上要做好这个服务,特别是money相关服务是不允许出错的,想想看每个红包的数字都是真金白银,要求服务的鲁棒性非常高,背后包含着很多后台服务技术细节。 ## 什么是锁 后台开发中锁的概念是「实现多个进程或线程互斥的访问共享资源的一种机制」,这里的计算机术语我举个栗子你就能理解: > 小王家只有卧室一台电视机。小王他爸喜欢看篮球NBA,小王他妈喜欢追综艺,如果小王他爸妈一起抢着看就会打架谁都看不好,这就是「死锁」。 > > 怎么办?小王他爸每次进入房间看电视第一件事就是把房门锁上,同样的小王他妈每次进房间看综艺第一件事也是把房门锁上,这就是「加锁」。 在计算机中公共资源可以是一块公共的内存,或者是一个公共的文件,对于这类共享资源的访问都是需要「加锁」保证各个进程或线程的资源访问互相不干扰。 ## 什么是分布式锁 分布式锁是在分布式系统中提出的概念,所谓分布式是指由很多功能对等的节点,提供相同的服务,各个节点如果需要访问「共享资源」,为了保证数据一致性也需要「加锁」,这个锁可以放在「公共存储数据库」,访问共享资源之前先去公共存储数据库拿锁,拿到锁才能访问共享资源。 还是拿上面的小王来举例子: > 现在小王的村里只有一个电视(小王村真穷),现在这个电视不是属于小王家,整个村的人都看这一个电视,并且要求一家在看的时候其他家不能看(这是看的啥电视),以前小王家的锁不能锁村里的电视,那怎么办呢? > > 村里每个家庭就是一个「分布式节点」,一个解决方案是把电视放在村长家「公共存储数据库」,各家轮流去村长家看电视,并且在进去看的时候让村长关门「加锁」,这就是分布式锁。 ## 分布式锁实现 今天就来说说其中一个技术细节,也是在我另一篇文章[Linux后台开发C++学习路线技能加点](https://zhuanlan.zhihu.com/p/102048769)中提到但没展开讲的,高并发服务编程中的**redis分布式锁**。 这里罗列出**3种redis实现的分布式锁**,并分别对比说明各自特点。 ## Redis单实例分布式锁 ### 实现一: SETNX实现的分布式锁 setnx用法参考redis[官方文档](https://redis.io/commands/setnx) #### 语法 `SETNX key value` 将`key`设置值为`value`,如果`key`不存在,这种情况下等同SET命令。 当`key`存在时,什么也不做。`SETNX`是”**SET** if **N**ot e**X**ists”的简写。 返回值: - 1 设置key成功 - 0 设置key失败 #### 加锁步骤 1. ```SETNX lock.foo ``` 如果客户端获得锁,`SETNX`返回`1`,加锁成功。 如果`SETNX`返回`0`,那么该键已经被其他的客户端锁定。 2. 接上一步,`SETNX`返回`0`加锁失败,此时,调用`GET lock.foo`获取时间戳检查该锁是否已经过期: - 如果没有过期,则休眠一会重试。 - 如果已经过期,则可以获取该锁。具体的:调用`GETSET lock.foo `基于当前时间设置新的过期时间。 **注意**: 这里设置的时候因为在`SETNX`与`GETSET`之间有个窗口期,在这期间锁可能已被其他客户端抢去,所以这里需要判断`GETSET`的返回值,他的返回值是SET之前旧的时间戳: - 若旧的时间戳已过期,则表示加锁成功。 - 若旧的时间戳还未过期(说明被其他客户端抢去并设置了时间戳),代表加锁失败,需要等待重试。 #### 解锁步骤 解锁相对简单,只需`GET lock.foo`时间戳,判断是否过期,过期就调用删除`DEL lock.foo` ### 实现二:SET实现的分布式锁 set用法参考[官方文档](https://redis.io/commands/set) #### 语法 `SET key value [EX seconds|PX milliseconds] [NX|XX]` 将键`key`设定为指定的“字符串”值。如果 `key` 已经保存了一个值,那么这个操作会直接覆盖原来的值,并且忽略原始类型。当`set`命令执行成功之后,之前设置的过期时间都将失效。 从2.6.12版本开始,redis为`SET`命令增加了一系列选项: - `EX` *seconds* – Set the specified expire time, in seconds. - `PX` *milliseconds* – Set the specified expire time, in milliseconds. - `NX` – Only set the key if it does not already exist. - `XX` – Only set the key if it already exist. - `EX` *seconds* – 设置键key的过期时间,单位时秒 - `PX` *milliseconds* – 设置键key的过期时间,单位是毫秒 - `NX` – 只有键key不存在的时候才会设置key的值 - `XX` – 只有键key存在的时候才会设置key的值 版本\>= 6.0 - `KEEPTTL` -- 保持 key 之前的有效时间TTL #### 加锁步骤 一条命令即可加锁: `SET resource_name my_random_value NX PX 30000` The command will set the key only if it does not already exist (NX option), with an expire of 30000 milliseconds (PX option). The key is set to a value “my*random*value”. This value must be unique across all clients and all lock requests. 这个命令只有当`key` 对应的键不存在resource_name时(NX选项的作用)才生效,同时设置30000毫秒的超时,成功设置其值为my_random_value,这是个在所有redis客户端加锁请求中全局唯一的随机值。 #### 解锁步骤 解锁时需要确保my_random_value和加锁的时候一致。下面的Lua脚本可以完成 ```lau if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end ``` 这段Lua脚本在执行的时候要把前面的`my_random_value`作为`ARGV[1]`的值传进去,把`resource_name`作为`KEYS[1]`的值传进去。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。 ## Redis集群分布式锁 ### 实现三:Redlock 前面两种分布式锁的实现都是针对单redis master实例,既不是有互为备份的slave节点也不是多master集群,如果是redis集群,每个redis master节点都是独立存储,这种场景用前面两种加锁策略有锁的安全性问题。 比如下面这种场景: > 1. 客户端1从Master获取了锁。 > 2. Master宕机了,存储锁的key还没有来得及同步到Slave上。 > 3. Slave升级为Master。 > 4. 客户端2从新的Master获取到了对应同一个资源的锁。 > > 于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。 针对这种多redis服务实例的场景,redis作者antirez设计了**Redlock** (Distributed locks with Redis)算法,就是我们接下来介绍的。 ### 加锁步骤 **集群加锁的总体思想是尝试锁住所有节点,当有一半以上节点被锁住就代表加锁成功。集群部署你的数据可能保存在任何一个redis服务节点上,一旦加锁必须确保集群内任意节点被锁住,否则也就失去了加锁的意义。** 具体的: 1. 获取当前时间(毫秒数)。 2. 按顺序依次向N个Redis节点执行**获取锁**的操作。这个获取操作跟前面基于单Redis节点的**获取锁**的过程相同,包含随机字符串`my_random_value`,也包含过期时间(比如`PX 30000`,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个**获取锁**的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。 3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。 4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。 5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起**释放锁**的操作(即前面介绍的Redis Lua脚本)。 ### 解锁步骤 客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。 ### 算法实现 上面描述的算法已经有现成的实现,各种语言版本。 - [Redlock-rb](https://github.com/antirez/redlock-rb) (Ruby implementation). There is also a [fork of Redlock-rb](https://github.com/leandromoreira/redlock-rb) that adds a gem for easy distribution and perhaps more. - [Redlock-py](https://github.com/SPSCommerce/redlock-py) (Python implementation). - [Aioredlock](https://github.com/joanvila/aioredlock) (Asyncio Python implementation). - [Redlock-php](https://github.com/ronnylt/redlock-php) (PHP implementation). - [PHPRedisMutex](https://github.com/malkusch/lock#phpredismutex) (further PHP implementation) - [cheprasov/php-redis-lock](https://github.com/cheprasov/php-redis-lock) (PHP library for locks) - [Redsync](https://github.com/go-redsync/redsync) (Go implementation). - [Redisson](https://github.com/mrniko/redisson) (Java implementation). - [Redis::DistLock](https://github.com/sbertrang/redis-distlock) (Perl implementation). - [Redlock-cpp](https://github.com/jacket-code/redlock-cpp) (C++ implementation). - [Redlock-cs](https://github.com/kidfashion/redlock-cs) (C#/.NET implementation). - [RedLock.net](https://github.com/samcook/RedLock.net) (C#/.NET implementation). Includes async and lock extension support. - [ScarletLock](https://github.com/psibernetic/scarletlock) (C# .NET implementation with configurable datastore) - [Redlock4Net](https://github.com/LiZhenNet/Redlock4Net) (C# .NET implementation) - [node-redlock](https://github.com/mike-marcacci/node-redlock) (NodeJS implementation). Includes support for lock extension. ### 比如我用的C++实现 [源码在这](https://github.com/jacket-code/redlock-cpp) #### 创建分布式锁管理类CRedLock ```c++ CRedLock * dlm = new CRedLock(); dlm->AddServerUrl("127.0.0.1", 5005); dlm->AddServerUrl("127.0.0.1", 5006); dlm->AddServerUrl("127.0.0.1", 5007); ``` #### 加锁并设置超时时间 ```c++ CLock my_lock; bool flag = dlm->Lock("my_resource_name", 1000, my_lock); ``` #### 加锁并保持直到释放 ```c++ CLock my_lock; bool flag = dlm->ContinueLock("my_resource_name", 1000, my_lock); ``` `my_resource_name`是加锁标识;`1000`是锁的有效期,单位毫秒。 #### 加锁失败返回false, 加锁成功返回`Lock`结构如下 ```c++ class CLock { public: int m_validityTime; => 9897.3020019531 // 当前锁可以存活的时间, 毫秒 sds m_resource; => my_resource_name // 要锁住的资源名称 sds m_val; => 53771bfa1e775 // 锁住资源的进程随机名字 }; ``` #### 解锁 ```c++ dlm->Unlock(my_lock); ``` ## 总结 综上所述,三种实现方式。 - 单redis实例场景,分布式锁实现一和实现二都可以,实现二更简洁推荐用实现二,用实现三也可以,但是实现三有点复杂略显笨重。 - 多redis实例场景推荐用实现三最安全,不过实现三也不是完美无瑕,也有针对这种算法缺陷的讨论(节点宕机同步时延、时间同步假设),大家还需要根据自身业务场景灵活选择或定制自己的分布式锁。 ## 参考 [Distributed locks with Redis](https://redis.io/topics/distlock) [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) [基于Redis的分布式锁到底安全吗](http://zhangtielei.com/posts/blog-redlock-reasoning.html)