Redis 分布式锁原理与正确性边界
→ 返回 Redis
Redis 锁本质上是一个带租约(TTL)的协作式互斥标记。只有所有参与者都遵守“先持锁再访问资源”,互斥才成立;锁本身不会阻止未接入锁的客户端直接修改数据库、文件或远程服务。
通用基线:
SET key token NX PX ttl原子加锁;释放和续期必须比较 token;业务必须设置超时,并以唯一约束、幂等或 fencing token 防御锁过期、进程暂停和故障切换。
分布式锁需要哪些性质
| 性质 | 含义 |
|---|---|
| 互斥 | 正常租约有效期内,最多一个客户端进入临界区 |
| 所有权 | 只有获得锁的客户端可以释放或续期 |
| 防死锁 | 客户端崩溃后,锁最终会因 TTL 释放 |
| 有界等待 | 加锁、业务执行、重试都必须有超时 |
| 容错 | Redis/网络故障时行为明确,不能无限阻塞 |
| 防陈旧写 | 旧持有者暂停后恢复时,不能破坏新持有者结果;需要 fencing token 或资源侧约束 |
Redis 单实例方案能较好满足前四项,但异步复制切换和客户端长暂停会削弱严格互斥。对正确性要求越高,越不能只依赖“我在 Redis 中还能看到锁”。
单 Redis 实例的正确加锁
SET lock:order:1001 550e8400-e29b-41d4-a716-446655440000 NX PX 30000NX:key 不存在才写入;PX 30000:30 秒后自动释放,避免持有者崩溃形成永久死锁;- value 是本次加锁请求的高熵唯一 token,不要只用固定服务名;
SET ... NX PX在一条命令中同时建立锁和 TTL。
不要使用:
SETNX lock token
EXPIRE lock 30客户端可能在两条命令之间崩溃,留下没有 TTL 的永久锁。也不要用时间戳作为 value 后让客户端自行判断并覆盖“过期锁”;Redis 已提供原子过期机制,并发覆盖会破坏所有权。
锁 key 与粒度
lock:<业务>:<资源类型>:<资源ID>
lock:order:create:<userId>
lock:inventory:<skuId>锁太粗会把无关请求串行化,太细则可能无法覆盖同一不变量。粒度应围绕需要保护的业务不变量设计,而不只是围绕某张表。
为什么不能直接 DEL
经典故障时间线:
Client A 获得 token=A,TTL=30s
Client A 暂停 35s,锁过期
Client B 获得同一 key,token=B
Client A 恢复,直接 DEL key
Client B 的锁被 A 错删释放必须是“token 相等才删除”的原子操作。Redis 8.4+ 可使用:
DELEX lock:order:1001 IFEQ 550e8400-e29b-41d4-a716-446655440000旧版本使用 Lua:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0不能在客户端先 GET 再 DEL,因为两条命令之间锁可能过期并被别人重新获得,这是典型 TOCTOU 竞态。
续期与 Watchdog
业务耗时无法准确预估时,需要在锁仍属于自己的前提下续期:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0Redisson 看门狗
Redisson 在未显式指定 leaseTime 时启用 watchdog,默认 lockWatchdogTimeout 为 30 秒,并在客户端存活且仍认为自己持锁时周期续期。显式传入 leaseTime 后通常按固定租约到期,不再由 watchdog 自动续期。
RLock lock = redisson.getLock("lock:order:" + orderId);
lock.lock(); // 未指定 leaseTime,启用 watchdog
try {
createOrder();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}看门狗不是无限安全保证:
- JVM Stop-The-World、容器冻结或机器挂起可能超过 TTL;
- Redis/网络不可达会导致续期失败;
- 客户端事件循环拥塞也可能错过续期;
- 锁已经过期后恢复的旧客户端,不能继续假设自己仍有操作权。
因此临界区仍应尽量短,外部 HTTP、人工审批、长时间批处理不适合一直持有普通 Redis 锁。
可重入锁底层语义
普通 String token 无法表达同一线程重入次数。Redisson RLock 通常用 Redis Hash 保存“客户端实例 ID + 线程 ID → 重入计数”,并用 Lua 原子完成判断、计数和 TTL 更新:
key: lock:order:1001
type: HASH
field: <clientId>:<threadId>
value: 2 # 已重入两次
TTL: 30000ms同一所有者再次加锁时计数加一;每次解锁减一,降到零才删除锁并通知等待者。RLock 所有权包含线程语义,所以不能在线程 A 加锁后让线程 B 解锁,否则会抛出所有权异常。
等待者通常通过 Redis Pub/Sub 接收解锁通知,再竞争加锁,避免持续高频轮询;Spin Lock 则使用退避轮询,适合不希望为大量锁建立订阅的特定场景。
Redis 主从切换为什么可能出现双持有者
Redis 复制默认异步:
A 在 Master 写入锁成功
│
├─ 尚未复制到 Replica
└─ Master 宕机
Replica 提升为新 Master,不存在该锁
B 加锁成功此时 A、B 都可能执行临界区。WAIT 或客户端的副本同步检查能让锁写入先得到副本确认,降低风险,但 Redis 官方明确说明 WAIT 不会把系统变成强一致存储,故障转移仍是 best effort。
Redisson 提供 checkLockSyncedSlaves、slavesSyncTimeout 等配置来检查锁是否同步到可用副本;这改善 failover 安全性,但仍不能替代资源侧 fencing 和业务幂等。
Redis Cluster 中一个锁 key 只属于一个 slot/一个主节点及其副本,不是自动在所有 Cluster 主节点各写一份锁。Cluster 解决分片与故障转移,不等于 Redlock。
Fencing Token:防止暂停客户端写坏数据
即使锁服务本身没有故障,客户端也可能暂停到租约过期:
A 获得锁与 token=41 ──暂停──────────────► 恢复并尝试写
B 获得新锁 token=42 → 已经写入资源只有判断“当前锁 key 属不属于 A”已经太晚:A 恢复时锁可能属于 B。fencing token 为每次成功加锁返回严格递增序号,受保护资源记录已接受的最大 token,并拒绝更小序号:
UPDATE inventory
SET stock = ?, last_fence = 42
WHERE sku_id = ?
AND last_fence < 42;若 A 用 41 再写,影响行数为 0,被数据库拒绝。真正的关键是资源端执行比较并拒绝旧 token;只生成 token、不校验没有作用。
RFencedLock lock = redisson.getFencedLock("lock:inventory:" + skuId);
Long token = lock.lockAndGetToken();
try {
inventoryRepository.updateWithFence(skuId, stock, token);
} finally {
lock.unlock();
}目标系统若无法保存并比较 token(例如某些第三方 HTTP API),就无法获得完整 fencing 保护,只能依靠幂等键、条件更新或业务补偿。
锁与数据库事务的顺序
推荐边界:
获得分布式锁
→ BEGIN DB transaction
→ 查询/校验/更新
→ COMMIT
释放分布式锁必须先提交事务再解锁,否则另一个客户端获得锁后可能仍读不到上一事务的新值。Spring 中 @Transactional 和锁 AOP 的切面顺序非常关键;最清晰的做法是锁内使用 TransactionTemplate:
lock.lock();
try {
transactionTemplate.executeWithoutResult(status -> updateDatabase());
} finally {
lock.unlock();
}也不要在数据库事务已经开启后长时间等待 Redis 锁,这会占用连接、延长事务和锁等待,并可能提前建立旧的一致性视图。
分布式锁不能替代数据库约束:下单去重仍应有 UNIQUE(request_id),库存仍应使用 UPDATE ... WHERE stock >= n,状态迁移仍应带当前状态条件。
多资源锁与死锁
同时锁定账户 A、B 时,两个请求若按相反顺序获取,就可能互相等待:
请求 1:持有 A,等待 B
请求 2:持有 B,等待 A处理原则:
- 对资源 ID 排序,所有调用者按同一顺序加锁;
- 每次加锁使用有限 waitTime;
- 任一失败立即释放已获得的锁;
- 重试使用随机退避并设置总截止时间;
- 能用数据库单事务/条件更新解决时,不引入多把分布式锁。
Redisson MultiLock 用于把多把锁作为一组协调,但不意味着任意业务组合都天然无死锁或具备跨 Redis 节点事务语义。
Redlock:算法与边界
Redlock 假设有 N 个相互独立且不依赖主从复制的 Redis 主节点,常见 N=5:
- 使用同一 key、token 和 TTL,并行/快速尝试 N 个节点;
- 超过半数(N/2+1)成功;
- 获取多数派总耗时必须小于 TTL;
- 有效期约为
TTL - 获取耗时 - 时钟漂移余量; - 失败时尽快释放所有节点上的部分成功锁;重试加入随机退避。
争议集中在网络异步、进程长暂停、时钟假设和资源端无法识别陈旧持有者。工程上不要把 Redlock 当成“用了五台 Redis 就自动严格安全”:
- 可容忍极低概率重复执行的任务,可使用单 Redis/Redisson + TTL + 幂等;
- 外部资源能校验单调 token 时,优先增加 fencing;
- 资金、核心库存等强一致不变量优先由数据库条件更新/唯一约束/事务保护;
- 需要一致协调服务时评估 ZooKeeper/etcd,但即便使用共识锁,暂停客户端仍最好有 fencing。
锁、幂等与防重的区别
| 机制 | 解决问题 | 不能解决 |
|---|---|---|
| 分布式锁 | 同一时刻减少并发进入 | 请求稍后重试、锁过期后的重复执行 |
| 幂等键/唯一索引 | 同一业务请求最多产生一个结果 | 不同请求对共享资源的并发竞争 |
| 乐观锁 version | 防止旧版本覆盖新版本 | 高冲突下频繁重试 |
| fencing token | 拒绝过期持有者的陈旧写 | 资源端不支持 token 校验 |
实际业务往往组合使用,而不是四选一。
Redisson 锁类型选择
| 类型 | 特点 | 适用 |
|---|---|---|
RLock | 可重入、Pub/Sub 唤醒、watchdog | 通用短临界区 |
| Fair Lock | FIFO 倾向,维护等待队列成本更高 | 确实要求先来先服务 |
RReadWriteLock | 多读单写 | 读多写少且参与者都遵守锁协议 |
MultiLock | 同时协调多把锁 | 少量固定资源组合,仍需排序和超时 |
RFencedLock | 获得单调 fencing token | 外部资源可校验 token 的关键写入 |
| Semaphore | 同时允许 N 个持有者 | 限并发,不是互斥 |
不要把公平锁用于所有场景:公平排队通常降低吞吐,死亡/断连等待者的处理也会增加延迟。
生产检查清单
- 锁 key 是否按业务不变量设计,是否会形成热点;
- token 是否每次请求唯一,释放/续期是否原子比较所有权;
- waitTime、leaseTime、业务超时和总请求 deadline 是否都有上限;
- 事务是否在锁内提交,解锁是否在
finally; - 锁过期或续期失败后是否停止副作用,是否有 fencing/条件更新;
- 是否有唯一索引、幂等记录和状态机作为最终防线;
- 监控获取成功率、等待时间、持锁时间、续期失败、非法解锁和 Redis failover;
- 高竞争是否出现惊群、重试风暴或单个热点 slot;
- 定时任务是否更适合 ShedLock/Quartz/XXL-Job 等具备任务状态的调度方案。
面试结论
Redis 锁用
SET key token NX PX ttl原子获取,token 标识所有者,释放和续期必须通过 DELEX/Lua 原子比较 token。TTL 防永久死锁但会产生租约过期窗口,watchdog 只能续期,不能消除 GC 暂停、网络故障和主从切换风险。关键资源应使用 fencing token、数据库条件更新、唯一约束与幂等;锁内事务必须先提交再释放。Redlock 是多个独立 Redis 主节点的多数派租约算法,不等同于 Redis Cluster,也不是无条件强一致。