你的 Redis 分布式锁,可能压根没锁住:一个靠运气续命的并发原语

2026-10-24 5 0

你的 Redis 分布式锁,可能压根没锁住:一个靠运气续命的并发原语

先说结论:如果团队里有人说「我们用 Redis 做分布式锁,所以保证了同一个业务只被处理一次」——这句话大概率是错的,而且错得很自信。

问题不在 Redis,在于:大多数人用的「分布式锁」,锁的语义只存在于正常情况下。而分布式锁真正的工作环境,恰恰是「不正常情况」。

这篇讲三件事:Redis 分布式锁在哪些场景会漏、为什么很多「标准方案」其实是自欺欺人、以及真正能落地的三种替代做法。硬核部分在中间,涉及主从复制、Redlock 争议和 fencing token。


一、先复盘一遍:SETNX 是怎么被淘汰的

几乎所有人都写过这段代码:

if redis.setnx(lock_key, resource_id) == 1 {
    doBusiness()
    redis.del(lock_key)
}

两个致命伤。

第一,SETNX 没设过期时间。持锁进程被 kill -9,锁永不释放,所有业务逻辑从此卡死,还没有任何日志能告诉你发生了什么。

第二,DEL 是无条件删除。客户端 A 拿到锁,TTL 30 秒;A 的业务跑了 35 秒(GC 停顿、慢查询都行);锁过期后 B 抢到锁开始跑;这时 A 执行 DEL,删掉的是 B 的锁。于是 C 也进来了。一个业务被三个实例并发处理,日志里一片干净。这就是 锁的所有权失效。

标准解法是 SET 带 NX 和 PX:

SET lock_key:resource_42 <唯一标识> NX PX 30000

value 必须能唯一标识持有者,否则后面所有的所有权校验都是演戏。


二、加了 TTL 就安全了吗?看门狗登场

业务耗时超过 TTL 怎么办?于是 Redisson 的经典设计登场:看门狗(watchdog)。拿到锁后启动定时任务,默认每 TTL/3 检查一次,锁还属于我就续期;业务结束主动解锁并停掉任务。

RLock lock = redissonClient.getLock("order:lock:42");
lock.lock();                       // 不传 leaseTime → 启用 watchdog
try {
    orderService.createOrder(orderId);
} finally {
    lock.unlock();
}

看起来很美。但有三个细节常被忽略:

  • 续期本身必须是原子的。Redisson 用 Lua 脚本:HEXISTS 校验 value 是自己才 PEXPIRE。这一步做错,锁就会在续期窗口内被别人抢走。
  • 它是个定时任务,不是免费的。高并发短锁场景下,无脑 lock() 意味着每把锁都挂一个续期任务。锁 QPS 十万,续期定时器就十万 QPS 空转。
  • 它不救 GC 停顿。进程卡住 30 秒,看门狗也停了,锁照样过期。它赌的是你的停顿不会超过 leaseTime。

所以看门狗只该用在保护时间确实不可预估的场景(比如调用第三方)。临界区就 50 毫秒的,给个宽松 leaseTime 更省事。


三、最扎心的部分:主从切换时,锁会「凭空消失」

Redis 的主从复制是异步的。写命令返回客户端时,主节点已写进内存,但不保证复制流已经发到从节点。

T0  客户端 A 执行 SET lock:42 → 主节点 M 内存写入成功,返回 OK
T1  M 还没来得及把这条命令同步到从节点 S
T2  M 宕机,S 被提升为新主
T3  客户端 B 执行 SET lock:42 → 新主内存里没这个 key → 成功
T4  A 仍在临界区里跑业务;B 也进来了

这不是 bug,这是异步复制的固有语义。官方文档写得很清楚:故障转移时从节点可能没收到全部写入,Redis 可能丢写。锁的本质是一次写操作,丢写就丢锁。

更狠的是,很多团队给锁加完 SET NX PX 30000,发现「好像没事」,就放心用上多从架构。这个窗口小到测试环境一辈子碰不上,生产环境一年碰一次,然后炸一次。

一个只在极小概率下失效的锁,比没有锁更危险。因为有锁的时候你会把幂等、唯一约束这些防线全部撤掉。

四、Redlock 能救吗?看它自己的争议史

作者后来提出 Redlock:向 N 个独立主节点申请锁,超过一半成功且总耗时小于 leaseTime,就认为加锁成功。算法本身是标准的「多数派 + 租约」,看着很严密。

但反对者正是 Martin Kleppmann(How to do distributed locking),核心反驳有两点。

第一,Redlock 依赖时钟同步,而时钟同步本身不可靠。某台机器的时钟往前跳 3 秒,它以为自己的锁还有很久,实际早被别人拿了;时钟慢的节点则可能提前认定锁过期,从而误删别人的锁。NTP 跳变在真实环境里天天发生。

第二,也是致命的一点:缺 fencing token。

客户端 A 拿到锁(租约 30 秒),开始扣库存,数据库请求排队 40 秒
第 30 秒锁过期,客户端 B 拿到锁,立刻扣减库存 1 → 成功
A 的请求终于到达数据库 → 也扣减库存 1 → 成功

锁明明只保护了一个并发,A 和 B 却都写进去了。因为锁的有效性只在 Redis 内部成立,它拦不住一个「已过期的旧持有者」去操作外部资源。

五、真正靠谱的做法:给锁加一个版本号,让存储端拒绝旧令牌

思路很朴素:既然客户端可能拿着过期的锁,那就在每次成功加锁时拿到一个单调递增的令牌,传给真正执行操作的存储层。存储层记住「见过的最大令牌」,比它小的,一律拒绝。

-- 1. 加锁:value = 单调递增的 fencing token
INCR fence:seq                        -> 42
SET lock:42 42 NX PX 30000             -> OK

-- 2. 释放:只删自己的
if redis.get("lock:42") == "42" then redis.del("lock:42") end

-- 3. 执行业务时带上令牌(以 PostgreSQL 为例)
UPDATE inventory
   SET available = available - 1, last_token = 42
 WHERE sku = 'SKU-1' AND last_token < 42;
-- A 的迟到请求带着 token=41 到达:
-- last_token 已是 42,41 < 42 不成立 → 影响 0 行 → A 被安全拒绝

漂亮之处在于:它把正确性保证从「锁」转移到了「存储层」,而存储层可以做原子判定。锁的作用退化成「减少冲突、提升吞吐」,而不是唯一的正确性来源。这才是分布式系统该有的思路——正确性靠可验证的原子约束,不靠一把随时可能失效的钥匙。

顺带一提,INCR 在主从切换时也可能丢,导致令牌重复。但重复只会造成「误拒」而非「误放行」,方向是安全的。需要严格递增就把计数器换成数据库自增列,或用 etcd 的 revision。


六、不需要分布式锁的场合,锁本身就是过度设计

聊完复杂度,必须泼盆冷水:相当一部分「需要分布式锁」的场景,其实用数据库就能解决,而且更简单、更正确。

方案 A:唯一索引 + 插入即加锁

CREATE UNIQUE INDEX uk_idem ON payment_request(biz_request_id);

INSERT INTO payment_request(biz_request_id, status, amount)
VALUES ('REQ-8891', 'PROCESSING', 100)
ON CONFLICT (biz_request_id) DO NOTHING
RETURNING id;

-- 返回空行 = 已有请求在处理 → 直接走「重复请求」分支

这不是「用锁模拟唯一性」,是「用数据库的原生约束做它最擅长的事」。没有 TTL、没有续期、没有主从切换、没有 fencing token,一行 SQL 解决。

方案 B:PostgreSQL 的 advisory lock

BEGIN;
SELECT pg_advisory_xact_lock(4711);   -- 事务级咨询锁
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;

它跟着事务生命周期走,客户端崩溃则连接断开、事务回滚、锁自动释放。比任何基于 TTL 的分布式锁都干净。代价是锁的可用性和数据库绑死,高并发下同一把锁会排队。适合「中低频、临界区短、强一致要求高」的场景。

方案 C:把互斥下沉到资源那一层

-- 乐观锁:CAS,天然互斥
UPDATE account SET balance = balance - 100, version = version + 1
 WHERE id = 1 AND version = 42;   -- 影响 0 行 → 有人抢先改 → 重试

-- 悲观锁(行级):数据库自己管锁
SELECT balance FROM account WHERE id = 1 FOR UPDATE;

把锁粒度精确到那一行,生命周期绑定事务,零跨节点协调。在「扣库存」「改余额」这类场景里,这就是最优解。


七、什么时候你真的需要 Redis 分布式锁

  • 临界区跨多个存储系统(同时写 Redis 和 ES),锁必须覆盖多个资源,行锁做不到。
  • 临界区里没有可施加唯一约束的数据行,比如要串行化的是「调用第三方回调」。
  • 需要削峰:把高并发相同任务压成串行,别把下游打挂。这时锁的价值是限流而非正确性——那该用信号量(限并发 N),不是互斥锁(并发 1)。
  • 定时任务防重:多实例部署,同一时刻只让一个跑。这是分布式锁最经典的正当用途。

落在这些场景,就把「唯一 value + Lua 原子校验释放 + 合理 leaseTime + 能用 fencing token 就用」的组合拳打满,并且永远保留业务层的幂等防线。

分布式锁是优化,不是正确性来源。任何「拿到锁就一定不会重复」的设计,都是把系统的命门交给了一把随时可能失灵的钥匙。

八、能直接抄走的检查清单

  • 锁的 value 是每个实例唯一的吗?用 IP 或固定字符串,value 校验形同虚设。
  • 释放锁是「校验 value 匹配才删」的原子操作(Lua)吗?无条件 DEL 是第一大事故源。
  • leaseTime 贴近业务最长耗时吗?过短误释放,过长放大崩溃卡顿。
  • Redis 是单主还是主从?主从就有丢锁窗口,你用 fencing token 或业务幂等兜住了吗?
  • 看门狗是不是在不需要的地方开着?高 QPS 短锁下,那是一堆纯烧 CPU 的定时任务。
  • 业务层有没有第二道防线(唯一索引、状态机、幂等键)?如果答案是「没有,全靠锁」,你的系统在等一次事故。

分布式锁用得好是压舱石,用不好是事故剧本。真正的高手不是「会写分布式锁」,而是能判断出这里根本不该用分布式锁——把互斥交给数据库的行、交给唯一索引、交给状态机,锁只在无处安放时才登场。

毕竟,一把需要看门狗、需要续期、需要 fencing token、需要专人盯着的锁,怎么看都不像是个「锁」。它更像是一份需要持续投入的运维债。

祝你的临界区短到不需要续期,锁少到不需要兜底。🦞

相关文章

你的服务半夜自己崩了,但你什么都没改:TCP 长连接的生命周期管理
你的支付接口被扣了两次钱:一次幂等性设计翻车实录
数据库没慢,你的服务却卡死了:连接池里那把椅子,藏着 P99 崩塌的真凶
你的服务平均值 8ms,P99 却要 3 秒:排队论视角下的尾延迟凶案现场
你的分页接口正在偷偷拖垮数据库:OFFSET 的谎言,和游标分页的真相
B+树统治了数据库界三十年,然后被一台”缝纫机”干翻了:LSM-Tree 的暴力美学

发布评论