什么是分布式锁
分布式锁是一种用于在分布式系统中实现同步和互斥访问的机制。在分布式系统中,多个节点同时访问共享资源可能会导致数据不一致或竞争条件的发生。分布式锁提供了一种保护共享资源的方式,以确保在任意时刻只有一个节点可以访问该资源,如:同一时刻每个订单只能有一个线程操作取消订单功能。
分布式锁的特点
| 特点 | 描述 |
|---|---|
| 独占性 | 任何时刻有且只有一个线程持有使用该锁 |
| 高可用 & 高性能 | 程序不易崩溃,时刻都保证较高的可用性,在redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况;在高并发请求下,分布式锁依旧具有良好的性能; |
| 防死锁 | 不能出现死锁问题,必须有超时重试机制或者撤销操作,有个终止跳出的途径; |
| 不乱抢 | 多线程下,防止张冠李戴,只能解锁自己的锁,不能把别人的锁给释放了; |
| 重入性 | 同一节点的同一线程如果获得锁之后,该线程可以再次获取使用这个锁 |
为什么要使用分布式锁
使用一个例子进行引入:公司的会议室的使用
假设你们公司只有一个会议室(共享资源),有很多团队(不同服务器上的程序)都想用它开会。
- 抢钥匙:谁先拿到会议室的钥匙,谁就能使用会议室(获得锁)。
- 开门进去:进去开会(执行业务逻辑)。
- 归还钥匙:开完会,把钥匙放回原处(释放锁),让其他人使用。
这就是分布式锁的核心思想:在分布式系统中,确保同一时刻只有一个节点(或线程)能访问某个共享资源。
Redis如何实现这个会议室的钥匙呢?
Redis实现分布式锁的核心是原子操作。原子操作是指一系列操作要么全部成功,要么全部失败,中间不会被打断。
简单来说,就是Redis提供了一台只能进一个人、出来后下一个人才能进的"旋转门"。
如何实现分布式锁
基础实现:SETNX+EXPIRE
-
加锁(抢钥匙)
用
SETNX命令(SET if Not EXists)设置一个key。如果key不存在,设置成功(抢到锁);如果已存在,设置失败(锁被别人拿着)。redisSETNX lock_key "unique_value" -
为防止程序崩溃导致锁永远不释放(死锁),还需设置过期时间:
EXPIRE lock_key 30 -
上面的方式存在问题:这两步不是原子操作。。后面会给出解决方案
-
解锁(换钥匙):
直接删除key:DEL lock_key
-
上面的解锁也存在着问题:可能会误删别人的锁。假设线程A的锁过期了,线程B拿到锁,这时A执行
DEL就会把B的锁删掉。
分布式锁常见的问题以及解决方案
1.分布式锁死锁的问题
问题:
在业务异常没有处理好或者应用服务宕机没有解锁就会出现死锁问题,锁key一直存储 在Redis中不会被释放,后续业务恢复去获取锁时因为锁已经存在一直都无法获取到锁,这就是死锁问题
解决方案:
对于该问题的解决方案,相信你能轻易的想到,就是给锁加上一个过期时间就行了,在该进程死锁后,不会主动释放锁,此时过期时间到了,就会自动释放该锁
2.分布式锁原子性问题
问题:
上面基本实现中提到的原子性问题,加锁和设置过期时间是两步操作,不具备原子性,这就在并发实现的时候会出现问题。如果在SETNX成功、EXPIRE之前程序崩溃,这个锁就永远卡在那了(死锁)。
解决方案:
在Redis2.6.12之后,SET命令支持NX和EX/PX选项一次行执行:
SET lock_key unique_value NX PX 30000
lock_key:锁的名字(比如"meeting_room")unique_value:随机字符串(如UUID),用来标识是谁的锁NX:只在key不存在时设置PX 30000:过期时间30秒
3.分布式锁可重入问题
问题:
如果你在同一个进程里,已经持有锁 lock_key,再次执行SET NX,因为key已经存在,会直接失败(返回nil)。这样你的进程就会被自己锁死------比如你的业务方法A调用了方法B,它们都需要同一把锁,没有可重入性就会死锁。
想要解决上述问题,首先先了解一下什么是可重入锁
可重入锁(Reentrant Lock)指的是:同一个线程(或同一个进程/节点)已经持有了某个锁,再次尝试获取同一把锁时,不会被阻塞,而是直接成功,并且内部会记录重入次数。
通俗一点就是:还是公司那个会议室。
假设你作为团队负责人,拿着钥匙进了会议室。中途你需要出去接个电话,再回来继续开会------你不可能把钥匙还给别人再自己重新抢一次。可重入锁就像允许你"多次进入同一个会议室",只要持有钥匙的人是你自己,你就能反复开门进去,每次进入记录一下"层数",最后当你完全离开时才真正归还钥匙。
解决方案:
让每个锁的"value"不再只是一个随机字符串,而是一个结构化数据(比如用Redis的Hash),记录:
- 持有锁的唯一标识(比如进程ID+线程ID,或UUID)
- 重入次数(计数器)
加锁逻辑:
- 检查Hash中是否存在该锁。
- 如果不存在,设置
field=owner_id, value=1,并设置过期时间。 - 如果存在且
owner_id等于当前请求的ID,则value+1(重入次数+1),并刷新过期时间。 - 如果存在但
owner_id不等于当前ID,则加锁失败。
解锁逻辑:
- 检查Hash中owner_id是否匹配。
- 匹配则
value-1。 - 如果减到0,则删除整个Hash;否则只更新计数。
- 必须用Lua脚本保证原子性。
定义一个锁的例子:
go
type ReentrantLock struct {
client *redis.Client
key string // Redis key
owner string // 唯一标识(比如进程内唯一ID)
ttl time.Duration // 锁过期时间
}
4. 分布式如何锁防止误删
问题:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删
典型场景:
- 客户端A获取锁成功,锁的过期时间是10秒。
- 客户端A执行业务,由于某种原因(GC停顿、网络延迟、代码bug),业务耗时超过了10秒。
- 锁自动过期,Redis删除了这个锁。
- 客户端B此时获取锁成功。
- 客户端A业务终于执行完,执行
DEL lock_key------ 结果把客户端B的锁给删了! - 客户端B还在执行业务,但锁已经没了,其他客户端(C、D)可能同时获取锁,造成数据错乱。
解决方案:
核心思想:解锁时必须验证当前锁的持有者是不是自己。
1. 加锁时设置唯一标识
加锁时不再用固定字符串,而是生成一个全局唯一的value(比如UUID或"机器ID+进程ID+goroutineID")。
SET lock_key unique_value NX PX 30000
2. 解锁时先GET`再DEL,但这两步必须原子执行
错误做法(两步非原子,会产生竞态条件):
go
// 先GET
val := client.Get(key).Val()
if val == myValue {
// 在GET和DEL之间,锁可能被其他客户端抢走或过期
client.Del(key)
}
**正确做法:**使用Lua脚本,让Redis一次性完成"检查+删除"。
lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
5. 分布式锁自动续期问题
问题:
给某个业务分布式锁设置了30s的过期时间,但是这个业务要执行40s,在30s后锁过期其它线程也可以获取到这把锁,但是前一个线程还没有执行完毕
例子:
你拿着会议室钥匙,进去开会。会议原本预计30分钟(TTL=30分钟),但实际开了1小时。如果没有自动续期,30分钟一到,钥匙就自动失效(锁过期),别人就可以拿钥匙开门进来,造成两个会同时开(数据错乱)。
自动续期就像有个助理每隔10分钟(TTL/3)跑来问你:"会开完了吗?没开完我就帮你把钥匙的有效期再延长30分钟。"
- 客户端A加锁成功,TTL=10秒。
- 客户端A业务执行耗时12秒(GC、慢查询、网络延迟等)。
- 第10秒时,锁自动过期,Redis删除了这把锁。
- 客户端B加锁成功,开始执行自己的业务。
- 第12秒时,客户端A执行完,执行
DEL删锁 ------ 把客户端B的锁删掉了(误删)。 - 客户端B的业务还在跑,但锁已经没了,危险!
解决方案:
使用一种自动续期的机制(看门狗,Watchdog):在锁的持有者还在执行业务时,自动延长锁的过期时间,防止锁因为业务耗时过长而提前释放。
- 加锁成功后,启动一个后台goroutine(看门狗)。
- 每隔锁TTL的1/3时间(例如TTL=30s,则每10s续期一次)执行一次续期操作。
- 续期时用Lua脚本检查:当前锁的owner是否还是自己,如果是,则重置过期时间为TTL。
- 当业务主动解锁或锁被主动释放时,停止看门狗。
lua
-- KEYS[1] = 锁的key
-- ARGV[1] = owner唯一标识
-- ARGV[2] = 要延长的过期时间(毫秒)
-- 返回值:1表示续期成功,0表示续期失败(锁已被他人持有或已不存在)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
如果是可重入锁(Hash结构),续期脚本要相应修改为对owner字段判断,然后pexpire整个Hash。