跳到正文

Redis 分布式锁 ​

一句话:Redis 分布式锁用 SET NX 做互斥、用唯一 value + Lua 防误删、用续期避免业务没跑完锁先过期;它能覆盖大多数互斥场景,但不是强一致保证。

适用版本:Redis 7.x / 8.x。SET ... NX EX/PX 自 2.6.12 起可用。

为什么需要 ​

多实例部署后,同一份资源可能被两个进程同时改:重复扣款、重复下单、定时任务重复执行。单机锁只在进程内有效,需要一把跨进程的锁。Redis 的吸引力在于:一条 SET NX 就能原子抢锁,性能高、接入简单;代价是它基于异步复制,主从切换等极端情况下可能丢锁。

核心机制 ​

四层演进 ​

阶段做法解决的问题留下的问题
1SET lock 1 NX互斥进程崩溃后锁永不释放
2SET lock <uuid> NX EX 30死锁业务超时后可能误删别人的锁
3解锁用 Lua 比对 value误删业务执行时间不确定,TTL 不好设
4WatchDog 自动续期TTL 不好设复杂度上升,续期本身可能出错

加锁 ​

bash
SET lock:order:12345 6f1c9a2e NX PX 30000
  • NX:key 不存在才设置,保证互斥。
  • PX 30000:30 秒自动过期,防止持有者崩溃后死锁;用毫秒单位便于精细控制。
  • value 必须是每次请求唯一的 token(UUID/雪花 ID),用于解锁时确认「这把锁是不是我的」。

解锁:必须用 Lua ​

lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

GET 和 DEL 是两条命令,中间可能插入其他请求:你查到锁还是自己的,但刚好过期被他人抢走,随后你的 DEL 删了别人的锁。Lua 保证比较和删除原子执行。

续期:只在仍持有时续 ​

WatchDog 的典型实现(Redisson):加锁成功后在后台起定时任务,每隔 leaseTime / 3 检查并续期;默认 lockWatchdogTimeout=30000 ms,即每 10 秒把 TTL 重置为 30 秒。业务完成或客户端崩溃后停止续期,锁最终过期。

自己实现续期时,必须满足三个条件:

  1. 续期也要比对 token:GET 等于自己的 token 才 PEXPIRE,否则说明锁已经丢了,立即停止续期并放弃业务。
  2. 续期间隔小于 TTL:留出网络和调度抖动余量,常见是 TTL 的 1/3。
  3. 释放后不再续期:解锁和停止续期要原子协调,避免「已经删了锁,后台又把别人的锁续上」。
bash
# 续期:仍是自己的锁才重置 TTL
EVAL "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('PEXPIRE',KEYS[1],ARGV[2]) else return 0 end" \
  1 lock:order:12345 6f1c9a2e 30000

Redisson 只在没有指定 leaseTime 时启用 WatchDog;调用 lock.lock(10, SECONDS) 表示你自己管理超时,它不会续期。

RedLock 与它的争议 ​

RedLock 的思路是:在 N 个相互独立的 Redis Master 上依次加锁,只有在多数派(N/2+1)加锁成功,且总耗时小于锁有效期,才算成功;失败则向所有节点解锁。

Martin Kleppmann 的批评是:如果持有者发生长时间 GC 停顿或时钟跳跃,锁可能在它不知情的情况下过期,另一个客户端拿到锁,两个客户端同时写共享资源。RedLock 没有 fencing 机制,无法阻止这种「过期后仍写入」。所以:

  • 一般互斥场景,单节点/主从 + WatchDog 足够。
  • 要求强一致,换 ZooKeeper/etcd,或者在资源侧做 fencing。

Fencing token:把正确性放到资源侧 ​

Fencing token 是一个单调递增的令牌,由独立的令牌服务(或数据库序列)在每次加锁时发放。客户端拿着 token 去写下游资源,下游记录见过的最大 token,拒绝更小的 token。这样即使旧持有者在锁过期后恢复并写入,它的 token 已经落后,会被资源拒绝。

关键点:fencing 需要下游配合,Redis 的 SET NX 本身不提供;RedLock 也不提供。没有资源侧的 token 校验,锁过期后的并发写就无法从根上避免。

实战场景 ​

一、leaseTime 写死后,锁在业务执行中途消失 ​

现象:账务任务用 tryLock(3, 30, SECONDS)(等待 3 秒、持锁 30 秒后自动释放)。某天上游变慢,任务平均耗时从 12 秒涨到 45 秒,锁在 30 秒时过期被下一个调度实例抢到,两个实例同时处理同一账期,产生 4 笔重复入账(经验值,需校准)。日志里没有任何“失锁”记录。

排查:显式传 leaseTime 会关闭 WatchDog 续期,这是最常见的用法陷阱。

定位层取证据判定
是否显式设 leaseTime调用处是否传了第三个参数传了就没有自动续期(Redisson 默认 lockWatchdogTimeout=30000,每 1/3 续一次)
业务耗时分布任务 P99 与 TTL 的比值P99 接近或超过 TTL 即注定失锁
失锁是否被感知业务结束时是否还校验持有者不校验就会“以为自己还有锁”
解锁结果Lua 解锁返回值是 0 还是 1返回 0 说明锁已不是自己的,是失锁的直接证据
并发执行证据同一 lock key 的两个实例日志时间区间重叠重叠即双跑

四层演进的每一层都留了问题:NX 解决互斥但崩溃永不释放;加 EX 解决死锁但可能误删别人的锁;Lua 比对 value 解决误删,但业务时长不定让 TTL 难设;WatchDog 解决 TTL 难设,但复杂度上升、续期本身可能出错。

对症做法:

java
// 1. 交给 WatchDog:不传 leaseTime,让它在持锁期间续期
RLock lock = redisson.getLock("lock:settle:" + period);
boolean got = lock.tryLock(3, TimeUnit.SECONDS);          // 只给等待超时,不给持锁时长
if (!got) return Result.busy();
try {
    doSettle(period);
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();       // 续期失败/已失锁时不要乱解
}
text
2. 自己实现续期时必须满足三个条件:
   - 续期也要比对 token:GET 等于自己的 token 才 PEXPIRE,
     否则说明锁已经丢了,立即停止续期并放弃业务;
   - 续期间隔小于 TTL(常见 TTL 的 1/3),留出网络与调度抖动余量;
   - 释放后不再续期:解锁与停止续期要原子协调,
     避免“已经删了锁,后台又把别人的锁续上”。
3. 关键结论:WatchDog 只能保证“进程活着就能持锁”,不能保证“失锁后业务停下”。
   所以业务侧还要有第三道:见下一个场景的 fencing。
4. 任务型并发优先用调度中心的分片/串行保证(XXL-JOB 的 SERIAL_EXECUTION、
   ElasticJob 的分片),不要把所有互斥都压在 Redis 锁上。

复盘要点:判据是“解锁 Lua 返回 0 的次数应为 0”——非 0 就说明发生过失锁;把它做成指标并告警。TTL 不是靠猜的:显式设 leaseTime 的场景,TTL > 业务 P99 × 3(经验值,需校准),且要有“业务超过 TTL”的告警,让 TTL 与耗时一起演进。

二、主从切换后,两把“同一个锁”同时在跑 ​

现象:库存扣减用 Redis 锁串行化。某次主节点宕机、哨兵把副本提为主,之后的 10 分钟里同一 SKU 出现并发扣减,库存被扣成负数 7 次(经验值,需校准)。复盘发现:锁是在旧主上写的,还没同步到副本就故障切换,新主上没有这个 key。

排查:异步复制意味着锁的持久性不保证。

定位层取证据判定
锁的写入是否已同步旧主 master_repl_offset 与新主 slave_repl_offset 在故障时刻的差锁 key 落在差集内就是丢了
复制模式Redis 主从是异步复制主写完内存即返回,不等副本
切换时间哨兵 +switch-master 事件时间与双跑时间段对齐
客户端行为切换后是否重连新主并“成功加锁”新主没 key,NX 一定成功
业务是否有第二道防线扣减是否带库存下限条件无约束则负库存直接产生

这不是配置错误,是这套机制的固有边界:Redis 锁在“分区 + 故障切换”面前不提供严格的互斥保证。Redlock 用多数派独立实例降低这个概率,但代价是部署复杂、且仍受时钟漂移与进程暂停影响,不能当强一致互斥使用。

对症做法:

text
1. 互斥的正确落点是数据层约束,Redis 锁只做“减少无效竞争”的优化:
   UPDATE stock SET qty = qty - ? WHERE sku = ? AND qty >= ?;   -- 影响行数 0 即库存不足
   或给“同一账期只能有一份结果”加唯一索引 / 乐观版本判定。
2. 需要真正严格的互斥时,用有共识保证的组件:
   ZooKeeper/etcd 的顺序节点与会话机制(CP),代价是写延迟更高,
   见 [分布式锁与 Curator](../../S04_消息与中间件/16_ZooKeeper/03-分布式锁与Curator.md)。
3. Redlock 若要用,必须先接受它的两个局限:
   需要 N 个独立实例(多数派)、对时钟漂移敏感,
   并且仍然要求业务侧幂等——它把丢锁概率降低,不是降到 0。
4. 业务侧必须假设“锁可能被两个人同时持有”,
   即所有加锁代码路径都要能安全处理双跑(幂等 + 数据约束)。
java
// 扣减的正确写法:锁是优化,条件更新才是保证
// (即便 Redis 锁失效,数据库层也不会把库存扣成负数)
int n = stockMapper.deductIfEnough(sku, qty);
if (n == 0) throw new SoldOutException(sku);

复盘要点:判据是“关键资源写路径存在数据库层约束”,用一次“人为清空锁 key 制造双跑”的演练验证:库存不会变负、账期不会出两套结果。锁的作用要写进设计文档:它降低竞争,不替代正确性。

三、GC 停顿 35 秒,醒来继续写:缺 fencing token ​

现象:一次 JVM Full GC 停顿 35 秒(锁 TTL 30 秒)。停顿期间锁过期、另一个实例拿到锁并写了一遍状态;停顿结束的实例醒来继续按“我还持有锁”执行写入,覆盖了对方刚写的结果。整个过程没有异常,也没有日志说明发生过失锁。

排查:进程暂停让“持锁”这个事实变得不可信。

定位层取证据判定
停顿时长 vs TTLGC 日志的 pause 时间超过 TTL 即确定失锁
是否有失锁检查关键写之前是否校验 token/版本只检查一次则必然出现 stale write
下游是否拒绝旧令牌存储是否记录“见过的最大 token”无记录就无法拒绝
写入顺序证据两个实例的写日志时间戳与内容后者覆盖前者即为 lost update
Redis 与 ZK 的共性Redis 靠 TTL、ZK 靠会话两者在进程暂停面前都需要 fencing

“Redis 锁没有原子性检查归属再释放”已经过时——Lua 脚本能原子地做“值等于我的标识才 DEL”。真正的差异是租约模型与 fencing 能力。

对症做法:

java
// 1. 加锁时取一个单调递增令牌(自增计数或 INCR lock:seq)
long token = redis.incr("lock:settle:" + period + ":seq");   // 令牌随每次成功加锁递增
if (!redis.set("lock:settle:" + period, token + ":" + uuid, SetArgs.Builder.nx().px(30000))) {
    return Result.busy();
}

// 2. 下游存储拒绝比已见过令牌更小的写
boolean ok = dao.writeIfNewer(period, result, token);
// UPDATE settle_result SET data=?, token=? WHERE period=? AND token < ?
if (!ok) {
    log.warn("stale lock holder discarded, token={}", token);
    metrics.staleWriteRejected("settle");
    return Result.aborted();
}
text
3. 令牌空间必须单调且持久:Redis 侧用 INCR 要注意它本身也要高可用;
   用数据库序列或 ZK 的顺序节点更稳。
4. 关键写之前复核一次持有状态(token 比对),
   但这只能缩小窗口,不能替代下游的版本判定——复核与写之间仍有间隙。
5. GC 停顿要单独治理:TTL 必须显著大于 P99.9 停顿(3 倍以上,经验值需校准),
   做不到就换更短的临界区或换存储层约束。

复盘要点:判据是“stale_write_rejected 指标有计数”——它不是错误,是防线生效的证据;出现计数说明失锁发生过但数据没被污染。把“所有 Redis 锁保护的写都必须带版本或令牌判定”写进规范,配合一次“人为制造停顿/失锁”的演练验收。续期、解锁与令牌的完整关系见 缓存穿透击穿雪崩 里的并发重建写法。

最小上手 ​

bash
redis-cli

# 加锁(value 用唯一 token)
SET lock:order:12345 6f1c9a2e NX PX 30000

# 解锁(比对 token 后删除)
EVAL "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end" \
  1 lock:order:12345 6f1c9a2e

# 观察剩余 TTL
PTTL lock:order:12345

生产动作 ​

  • TTL 要大于业务 P99:按压测的 P99 留 2–3 倍余量,再配合续期;不要用「拍脑袋 10 秒」。
  • 解锁必须 Lua 比对 token,不要先 GET 再 DEL。
  • 续期失败要停业务:续期返回 0 说明锁已不属于自己,继续执行会产生并发写。
  • 业务要幂等:锁只是降低并发概率,网络重试、超时补偿仍可能重复执行,关键写操作要有幂等键。
  • 长任务不要用锁硬扛:几十分钟的批处理更适合状态机、任务队列或租约 + fencing,而不是不断续期。
  • 监控:锁等待时间、续期失败次数、解锁失败次数;这些比「加锁成功率」更能反映问题。

边界 ​

  • Redis 锁是 AP:主从异步复制,主节点挂掉时锁可能还没同步到副本,切换后两个客户端可能同时持锁。
  • RedLock 不解决 GC/时钟问题:它降低单节点故障的影响,但没有 fencing,无法阻止过期持有者写入。
  • 可重入要额外设计:同一个线程重复加锁会死锁;Redisson 用 Hash 计数实现,手写要自己维护。
  • 锁不保证公平:谁先抢到不确定,不能当队列用。
  • 不要用锁保护长事务:锁 TTL 与业务时长赛跑,续期逻辑越复杂,出错面越大。
  • 强一致场景换 CP:ZooKeeper 临时顺序节点、etcd 租约 + revision 更合适。

讲清楚 ​

Redis 分布式锁的起点是 SET key token NX PX ttl:NX 保证互斥,token 标记持有者,TTL 防止崩溃后死锁。解锁要用 Lua 比较 token 再删,否则可能删掉别人的锁。业务时间不确定时加 WatchDog 续期,但续期必须确认锁还是自己的,丢了就停。

它的上限是 AP:主从切换、GC 停顿、时钟跳跃都可能让锁提前失效。RedLock 用多个独立 Master 降低概率,但没有 fencing,仍不能从根上防止旧持有者写入。真要强一致,要么换 ZooKeeper/etcd,要么在存储侧校验单调递增的 fencing token。

把一门技术讲透到既懂也会用