目录
[一、 为什么需要分布式锁?(它解决了什么问题?)](#一、 为什么需要分布式锁?(它解决了什么问题?))
[方案一:基于 Redis(最常用,性能极高)](#方案一:基于 Redis(最常用,性能极高))
[方案二:基于 Zookeeper(可靠性极高)](#方案二:基于 Zookeeper(可靠性极高))
[方案三:基于 数据库(例如 MySQL)](#方案三:基于 数据库(例如 MySQL))
[3.1 最基础的加锁](#3.1 最基础的加锁)
[3.2 引入过期时间](#3.2 引入过期时间)
[3.3 锁的验证](#3.3 锁的验证)
[3.4 判断和删除原子化](#3.4 判断和删除原子化)
一、 为什么需要分布式锁?(它解决了什么问题?)
在传统的单机应用中,如果多个线程要同时修改同一个数据(比如扣减库存),为了防止数据错乱,我们会在代码里加一个本地锁 (例如 Java 中的 synchronized 或 ReentrantLock)。
随着业务发展,你的应用变成了分布式系统 。为了抗住高并发,你的应用被部署在了多台服务器上。单机锁只能锁住自己所在的那个 JVM(进程),它管不到其他服务器上的进程。
这时候就需要一个公共的锁也就是分布式锁来控制部署在不同服务器上的进程。
二、分布式锁的实现方案
方案一:基于 Redis(最常用,性能极高)
利用 Redis 的单线程处理机制和 SETNX(Set if Not eXists)命令。
-
原理: 尝试在 Redis 里写入一个 Key,如果 Key 不存在,就写入成功(代表加锁成功);如果 Key 已经存在,就写入失败(代表别人已经加锁了)。
-
优点: 性能极高,支持高并发,实现相对简单。
-
缺点: 极端情况下(如 Redis 主从切换时),可能会出现锁丢失的问题。通常会使用
Redisson这样的成熟框架,它内置了"看门狗(Watchdog)"机制来自动为还在执行的业务续期锁的时间,非常省心。
方案二:基于 Zookeeper(可靠性极高)
利用 Zookeeper 的"临时顺序节点"特性。
-
原理: 每个请求都在 Zookeeper 下创建一个临时节点,序号最小的那个节点获得锁。操作完后删除节点。如果客户端宕机,Zookeeper 会自动删除它的临时节点(释放锁)。
-
优点: 非常可靠,不存在 Redis 的锁过期时间难以把控的问题,天生具备强一致性。
-
缺点: 性能比 Redis 差一些,因为频繁创建和删除节点开销较大。
方案三:基于 数据库(例如 MySQL)
-
原理: 利用数据库的唯一索引(Unique Key)或者行锁(
FOR UPDATE)。试图插入一条记录,插入成功的获得锁。 -
优点: 不需要引入额外的中间件,如果系统本身就有数据库就能做。
-
缺点: 性能最差,数据库的连接资源非常宝贵,抗不住高并发。极少在大型互联网项目的高并发场景中作为首选。
三、基于Redis实现分布式锁
接下来,将以版本迭代的方式来介绍Redis实现分布式锁的完整过程。
3.1 最基础的加锁
Redis 提供了一个原生命令:SETNX(Set if Not eXists,如果不存在则设置)。
-
加锁:
SETNX lock_key 1。如果返回 1,说明 key 之前不存在,加锁成功;如果返回 0,说明 key 已存在,别人正在用,加锁失败。 -
解锁:
DEL lock_key。业务执行完,删掉这个 key。
缺陷:死锁问题,如果服务器 A 抢到了锁(执行了
SETNX),但在执行业务逻辑时突然断电宕机了,没来得及执行DEL。这个锁就会永远留在 Redis 里,其他所有服务器无限期等待,系统死锁。
3.2 引入过期时间
为了解决上面的死锁问题,我们可以给锁提供一个过期时间,来给锁释放做一个兜底的操作。
SET 命令提供了扩展参数。我们可以把加锁和设置过期时间合并成一条原子命令:
SET lock_key 1 NX PX 30000
缺陷:锁被误删问题,
假设设置了 30 秒过期:
线程 A 拿到锁,但业务执行极其缓慢,花了 40 秒。
第 30 秒时,Redis 自动删除了锁。
此时线程 B 顺利拿到了锁,开始执行业务。
第 40 秒时,线程 A 执行完了,执行了一句
DEL lock_key。线程 A 把线程 B 刚加的锁给删了,接着线程C又进来了。
3.3 锁的验证
为了解决上面锁被误删问题,我们的解决方案是给锁加上一个身份ID,每当一个线程加上锁之后会在Redis中记录一个锁的ID,为了后面验证这是自己加上的锁,如果不是自己加上的锁,那么就不会删除。
上锁:
SET lock_key "UUID-ThreadA" NX PX 30000
释放锁:加一个判断
java
String threadId = ID_PREFIX + Thread.currentThread().getId();
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if(threadId.equals(id)){
stringRedisTemplate.delete(KEY_PREFIX + name);
缺陷:判断和删除不是原子操作,在线程A判断是自己上的锁之后,接下来本来应该要执行释放锁的操作,但是在释放之前阻塞了,在阻塞期间锁刚好到期自动释放,并且被别的线程抢走了,当线程A恢复之后还是要执行释放操作,依然会发生误删。
3.4 判断和删除原子化
为了保证"判断并删除"这两步操作的绝对原子性,我们需要向 Redis 发送一段 Lua 脚本。Redis 会把整个 Lua 脚本作为一个整体执行,执行期间绝对不会被打断。
解锁的 Lua 脚本如下:
Lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end