主题
Redis 分布式锁
一句话:Redis 分布式锁用
SET NX做互斥、用唯一 value + Lua 防误删、用续期避免业务没跑完锁先过期;它能覆盖大多数互斥场景,但不是强一致保证。
适用版本:Redis 7.x / 8.x。SET ... NX EX/PX 自 2.6.12 起可用。
为什么需要
多实例部署后,同一份资源可能被两个进程同时改:重复扣款、重复下单、定时任务重复执行。单机锁只在进程内有效,需要一把跨进程的锁。Redis 的吸引力在于:一条 SET NX 就能原子抢锁,性能高、接入简单;代价是它基于异步复制,主从切换等极端情况下可能丢锁。
核心机制
四层演进
| 阶段 | 做法 | 解决的问题 | 留下的问题 |
|---|---|---|---|
| 1 | SET lock 1 NX | 互斥 | 进程崩溃后锁永不释放 |
| 2 | SET lock <uuid> NX EX 30 | 死锁 | 业务超时后可能误删别人的锁 |
| 3 | 解锁用 Lua 比对 value | 误删 | 业务执行时间不确定,TTL 不好设 |
| 4 | WatchDog 自动续期 | TTL 不好设 | 复杂度上升,续期本身可能出错 |
加锁
bash
SET lock:order:12345 6f1c9a2e NX PX 30000NX: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
endGET 和 DEL 是两条命令,中间可能插入其他请求:你查到锁还是自己的,但刚好过期被他人抢走,随后你的 DEL 删了别人的锁。Lua 保证比较和删除原子执行。
续期:只在仍持有时续
WatchDog 的典型实现(Redisson):加锁成功后在后台起定时任务,每隔 leaseTime / 3 检查并续期;默认 lockWatchdogTimeout=30000 ms,即每 10 秒把 TTL 重置为 30 秒。业务完成或客户端崩溃后停止续期,锁最终过期。
自己实现续期时,必须满足三个条件:
- 续期也要比对 token:
GET等于自己的 token 才PEXPIRE,否则说明锁已经丢了,立即停止续期并放弃业务。 - 续期间隔小于 TTL:留出网络和调度抖动余量,常见是 TTL 的 1/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 30000Redisson 只在没有指定 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 TTL | GC 日志的 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。