兄弟们,搞分布式的,哪个没踩过Redis分布式锁的坑?这玩意儿用起来方便,但真要落地到生产环境,各种细节问题能让你头疼好几天。今天咱们就来盘点盘点,实际项目中用Redis锁,最容易遇到的几个大坑,以及怎么爬出来。
坑一:锁的超时时间(TTL)- 定多少才叫“刚刚好”?
这绝对是第一个拦路虎。用Redis实现分布式锁,最常见的姿势就是 SET key value NX PX milliseconds
。NX
保证只有键不存在时才设置成功(原子性加锁),PX
用来设置过期时间(防止死锁)。问题来了,这个 milliseconds
到底设多少?
- 设太短了? 比如你估摸着一个业务操作最多3秒完成,于是自信地设置TTL为3000ms。结果呢?某个请求因为网络波动、DB慢查询或者其他幺蛾子,执行了3.5秒。好家伙,业务逻辑还没跑完,锁自己到期释放了!这时候另一个请求冲进来,拿到锁,也开始执行... 数据一致性问题、并发问题统统找上门。这就等于锁了个寂寞。
- 设太长了? “那我干脆设个1分钟,总够了吧?” 看起来安全了,但新的问题又来了。万一拿到锁的那个线程/进程,代码写得有Bug直接挂了,或者服务器宕机了,它又没机会去
DEL
掉那个锁。那这个锁就会一直占用到1分钟过期为止。在这期间,其他所有需要这个锁的请求都得干等着,系统吞吐量直线下降。要是锁的是个核心资源,那整个业务线可能都卡住了。
怎么办?
合理评估 + 冗余: 这是最基础的。你需要根据业务操作的正常执行时间,再加上一个合理的冗余时间。比如,一个操作平均耗时500ms,峰值可能达到1.5s,那你可以考虑设置TTL为3s或5s。这个“合理”很关键,需要结合压测数据和业务监控来调整。但纯靠猜,风险很大。
锁续期(Watchdog): 这才是更稳妥的方案。既然怕业务没执行完锁就过期,那就让持有锁的线程在锁快过期的时候,自动给它续命!这就是所谓的“看门狗”机制。
- 基本思路: 启动一个后台线程(或者利用现有框架如 Redisson),当你加锁成功后,这个后台线程就负责“盯”着这个锁。比如锁TTL是30秒,看门狗可以每隔10秒检查一下,持有锁的业务线程是不是还在运行。如果在,就重新设置一遍TTL,把它续到30秒。如果业务线程执行完了,或者挂了,看门狗就停止续期。
- 优点: 大大降低了业务没执行完锁就过期的风险。
- 缺点: 实现复杂度高!你需要考虑后台线程的管理、续期失败的处理、业务线程异常退出时如何通知看门狗停止续期等等。自己撸一个健壮的看门狗不容易,所以很多人会选择成熟的库。
小结: TTL设置是个技术活,没有绝对的银弹。简单场景靠“评估+冗余”,复杂或耗时长的场景,“看门狗”机制几乎是必须的,但要警惕其复杂性。
坑二:锁的粒度 - 锁住整个世界还是锁住一个按钮?
确定了TTL,下一个问题就是:这个锁,到底要锁什么?锁的范围(粒度)直接影响系统的并发能力和实现的复杂度。
假设你在做一个电商系统,用户下单时要去扣减库存。
方案一:锁用户?
lock:user:{userId}
- 逻辑: 用户一开始下单流程,就先获取该用户的锁。整个下单过程中,其他任何涉及这个用户的并发操作(比如同时修改地址、领优惠券)都得等着。
- 优点: 实现简单粗暴,能有效防止单个用户层面的并发冲突。
- 缺点: 并发度极低!用户下单可能要好几秒,这期间该用户啥也干不了。如果锁的范围更大,比如锁整个商品
lock:product:{productId}
,那所有用户购买这个商品都得排队,简直是灾难。
方案二:锁操作?
lock:user:{userId}:place_order
或者lock:product:{productId}:decrease_stock
- 逻辑: 只在执行“下单”这个具体动作,或者更细粒度的“扣减库存”这个环节加锁。
- 优点: 并发度大大提高。用户下单的同时,可以修改地址;不同用户购买同一个商品,只要库存充足,基本可以并行进行(如果锁的是扣库存操作)。
- 缺点: 实现复杂度上升。你需要精确识别哪些是需要保护的临界区,并设计合适的锁Key。Key的设计也可能更复杂,比如需要组合多个ID。
怎么选?
核心原则是:按需保护,最小范围。
- 分析临界资源: 明确你要保护的到底是什么?是用户账户的整体状态?还是某个商品的库存数量?或者是防止同一个订单被重复提交?
- 评估并发冲突的可能性和影响: 如果某个操作并发冲突的概率很低,或者冲突了也没啥大影响(比如读操作),那就没必要加锁。如果冲突影响很大(比如资金、库存),那就必须加锁。
- 选择最小化锁范围: 在能保证数据一致性的前提下,锁的范围越小越好,锁持有时间越短越好。比如扣库存,锁具体SKU的库存就比锁整个商品SPU要好。
举个例子:防止表单重复提交
很多时候我们加锁是为了防止用户手抖快速点击提交按钮,导致同一个请求发了两次。
- 粗粒度:
lock:user:{userId}:submit_form
。用户提交任何表单都用这个锁。并发差。 - 细粒度:
lock:form:{formId}:{uniqueRequestId}
或者lock:user:{userId}:submit_form:{formType}
。只锁 конкретный 表单实例的某次提交,或者某类表单。更好。
思考: 粒度设计没有标准答案,需要你深入理解业务场景,平衡并发性能和实现成本。
坑三:可重入性 - 自己把自己锁门外了?
想象一个场景:方法A获取了一个锁 lock:resource:123
,然后它调用了方法B,方法B也需要访问同一个资源,于是它也去尝试获取 lock:resource:123
。如果你的锁不支持可重入,那方法B就会失败(或者阻塞),因为它发现锁已经被“别人”(实际上是同一个线程/流程中的方法A)持有了。这就叫“自己把自己锁门外了”。
为什么需要可重入?
在稍微复杂点的业务逻辑中,方法调用链比较深,同一个锁可能在调用栈的不同层级被需要。如果不支持可重入,代码写起来会非常别扭,甚至无法实现某些逻辑。
Redis怎么实现可重入锁?
基本思路是:锁不仅仅是占个位,还要记录是谁占的,以及占了几次。
使用Hash结构: 不再用简单的String
SET key value NX PX ttl
。而是用 Redis Hash。- 加锁时:
- 用
HSETNX lock_key thread_id 1
尝试加锁。如果成功,说明之前没人持有锁,设置过期时间EXPIRE lock_key ttl
。 - 如果
HSETNX
失败,说明锁已存在。检查HGET lock_key thread_id
是否等于当前线程ID。 - 如果是当前线程持有,说明是重入。执行
HINCRBY lock_key thread_id 1
,增加重入计数,并刷新过期时间EXPIRE lock_key ttl
。 - 如果不是当前线程持有,说明锁被别人占着,加锁失败。
- 用
- 解锁时:
- 检查
HGET lock_key thread_id
是否是当前线程ID。如果不是,或者Key不存在,说明锁已经不是你的了(或者已经释放),可能出错了,告警或抛异常。 - 如果是当前线程持有,执行
HINCRBY lock_key thread_id -1
,减少重入计数。 - 检查计数是否变为0。如果是,说明这是最外层的锁释放,执行
DEL lock_key
,彻底删除锁。 - 如果计数还大于0,说明只是内层调用返回,刷新一下过期时间
EXPIRE lock_key ttl
(可选,看业务是否需要)。
- 检查
- 加锁时:
使用String + Lua脚本: 为了保证“检查持有者”和“增加计数/删除锁”这些操作的原子性,通常需要把上述逻辑封装在Lua脚本里,然后通过
EVAL
或EVALSHA
来执行。
伪代码(Lua脚本思路):
-- 加锁 (lockKey, holderId, ttlSeconds)
local key = KEYS[1]
local holder = ARGV[1]
local ttl = tonumber(ARGV[2])
if redis.call('exists', key) == 0 then
-- 锁不存在,尝试获取
redis.call('hset', key, holder, 1)
redis.call('expire', key, ttl)
return 1 -- 成功
else
-- 锁存在,检查是否是自己持有
if redis.call('hexists', key, holder) == 1 then
-- 是自己持有,重入
redis.call('hincrby', key, holder, 1)
redis.call('expire', key, ttl) -- 刷新TTL
return 1 -- 成功
else
-- 被别人持有
return 0 -- 失败
end
end
-- 解锁 (lockKey, holderId)
local key = KEYS[1]
local holder = ARGV[1]
if redis.call('hexists', key, holder) == 0 then
-- 锁不存在或不是自己持有,不能解别人的锁
return 0 -- 失败或忽略
end
local count = redis.call('hincrby', key, holder, -1)
if count > 0 then
-- 计数减1后仍大于0,说明是内层解锁,刷新TTL(可选)
-- redis.call('expire', key, new_ttl) -- 如果需要解锁也刷新TTL
return 1 -- 成功,但锁未完全释放
else
-- 计数变为0,删除锁
redis.call('del', key)
return 2 -- 成功,锁已完全释放
end
注意: 上述Lua脚本只是示意,实际使用需要更严谨的错误处理和逻辑。使用 thread_id
作为 holder
是一个常见的做法,但在分布式环境和异步编程中,需要一个更可靠的、能在请求或任务范围内保持唯一的ID。
复杂性: 可重入锁比简单的 SETNX
锁复杂得多。你需要仔细处理计数、持有者判断、原子性(靠Lua)和过期逻辑。
坑四:自动续期(Watchdog)- 救星还是负担?
前面提到了解决TTL过短问题的“看门狗”机制。很多成熟的Redis客户端库,比如Java的Redisson,都内置了这个功能。
Redisson的Watchdog原理(简化版):
- 当你尝试加锁时,比如
RLock lock = redisson.getLock("myLock"); lock.lock(30, TimeUnit.SECONDS);
,如果你没有指定leaseTime
(租约时间,即TTL),Redisson会默认启用看门狗,默认锁时间是30秒。 - 加锁成功后,Redisson会启动一个后台线程(这个线程池是共享的)。
- 这个后台线程会定期(默认是
lockWatchdogTimeout / 3
,也就是10秒)检查持有锁的那个业务线程是否还在活动。 - 如果业务线程还在,就发送一个命令给Redis,延长锁的过期时间(比如重新设置为30秒)。
- 这个续期动作会一直持续,直到业务线程调用
unlock()
方法。 unlock()
时,Redisson会取消这个续期任务,并释放锁。- 如果业务线程挂了,或者持有锁的节点宕机了,续期任务自然就停了,锁会在最后一次续期后的30秒(默认)自动过期释放。
优点:
- 极大简化开发: 你几乎不用关心TTL设多少合适,只要业务逻辑没执行完,锁就大概率不会丢(网络分区等极端情况除外)。
- 提高健壮性: 能应对业务执行时间不确定的情况。
缺点和风险:
- 实现复杂: 如果你自己实现,会非常复杂,涉及线程管理、定时任务、与Redis的交互、异常处理等。强烈建议使用成熟的库。
- 资源消耗: 后台线程池、定时任务会消耗一定的CPU和内存资源。在高并发场景下,大量的锁和续期任务可能会成为性能瓶颈。
- 依赖活性检测: Watchdog依赖于能检测到业务线程(或持有锁的客户端实例)是否“活着”。如果检测机制本身出问题(比如业务线程假死但Watchdog认为它活着),可能会导致锁无法正常释放。
- 对Redis的压力: 定期的续期操作会增加Redis的命令负载。
- “脑裂”问题下的潜在风险: 在网络分区等极端情况下,Watchdog可能会导致一些难以预料的行为,尽管成熟库会尽量处理这些情况。
要不要用Watchdog?
- 对于执行时间远小于TTL的短平快操作: 比如前面说的防止表单重复提交,一次提交撑死几百毫秒,你设个5秒TTL足够了,完全没必要上Watchdog,简单
SETNX
+ 合理TTL 就行。 - 对于执行时间不确定,可能较长的任务: 比如处理一个复杂订单、调用外部API、执行数据迁移脚本等,Watchdog几乎是刚需。否则你很难设定一个既安全又不会导致死锁的TTL。
- 对系统资源和复杂度敏感的场景: 如果你的系统资源非常紧张,或者团队对复杂机制的维护能力有限,可以考虑其他替代方案,比如:
- 手动续期: 在业务代码的关键检查点,手动调用续期方法。这比Watchdog简单,但需要业务代码侵入。
- 分段锁 + 状态机: 将长任务拆分成多个阶段,每个阶段使用不同的锁或检查状态,避免长时间持有单一锁。
小结: Watchdog是个好东西,能解决大问题,但它不是免费的午餐。理解它的原理、开销和风险,根据你的业务场景和团队能力来决定是否使用以及如何使用(自研 vs. 成熟库)。
总结
Redis分布式锁,看似简单一个 SETNX
,实则背后坑不少。总结一下关键点:
- TTL设置 是个平衡艺术,需要评估+冗余,或者上Watchdog。
- 锁粒度 决定并发度,遵循“按需保护,最小范围”原则。
- 可重入性 在复杂调用链中很需要,实现依赖原子操作(Hash+计数 或 Lua)。
- Watchdog 能自动续期,解决长任务锁超时问题,但有复杂度和资源开销。
没有哪个方案是万能的。最好的策略是深入理解你的业务需求、并发场景、性能要求,然后结合Redis的特性(以及你选择的客户端库的能力),做出最合适的权衡和设计。希望这次的避坑指南能帮你在实战中少走弯路!