一句话回答

Redis 分布式锁至少要使用 SET key uniqueValue NX PX ttl 原子加锁,锁值标识持有者,释放时通过 Lua 比较锁值后删除;还要处理业务超时、续期、重试、主从切换和进程暂停。对于不能容忍旧持有者继续写入的关键资源,还需要 Fencing Token 或选择具备一致性保证的协调系统。

面试考察点

  • 是否知道 SETNX 后再设置过期时间不是原子操作。
  • 能否解释为什么解锁前必须比较唯一锁值。
  • 是否理解过期时间太短和太长各有什么风险。
  • 能否处理 GC Stop-The-World、网络分区和锁续期失败。
  • 是否知道 Redis 主从故障切换可能让锁丢失。
  • 能否说明锁、幂等和 Fencing Token 的职责差异。

为什么需要分布式锁

单 JVM 的 synchronizedReentrantLock 只能协调同一进程线程。服务部署多个实例后,需要跨进程协调:

  • 防止同一任务被多个实例同时执行。
  • 控制热点缓存只有一个实例重建。
  • 防止同一订单并发执行某个非幂等操作。
  • 选出短期 Leader 执行调度。

使用锁前先判断能否通过数据库唯一约束、条件更新、消息分区或幂等设计解决。分布式锁会增加故障状态,不应成为默认方案。

错误实现一:SETNX 后再 EXPIRE

SETNX lock:order:1001 instance-a
EXPIRE lock:order:1001 30

如果进程在两条命令之间崩溃,Key 没有过期时间,锁会永久存在。必须使用单条原子命令:

SET lock:order:1001 request-uuid NX PX 30000

返回成功才表示获取锁。

为什么锁值必须唯一

锁值可以包含请求 UUID、实例 ID 和线程标识:

instance-a:thread-18:4f92...

不能只保存固定值 1。以下时序会误删他人的锁:

A 获取锁,TTL 30 秒
A 业务卡顿超过 30 秒,锁过期
B 获取同一个锁
A 恢复,直接 DEL
结果:A 删除了 B 的锁

因此只有锁值仍等于自己的唯一值时才能删除。

Lua 原子解锁

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

不能在客户端先 GET 判断,再单独 DEL。两个操作之间锁可能过期并被其他客户端获得,仍会误删。

Java 伪代码

String lockKey = "lock:order:" + orderId;
String lockValue = instanceId + ":" + UUID.randomUUID();

boolean acquired = redis.set(lockKey, lockValue, NX, PX, 30_000);
if (!acquired) {
    throw new BusyException();
}

try {
    processOrder(orderId);
} finally {
    redis.eval(UNLOCK_SCRIPT, List.of(lockKey), List.of(lockValue));
}

真实项目优先使用经过验证的 Redis 锁客户端,例如 Redisson,并理解其看门狗和故障语义,不建议每个团队重复手写完整协议。

TTL 应该设置多长

TTL 必须大于业务处理的高分位耗时,并留出网络和 GC 抖动余量:

TTL > 业务 P99 + 最大可接受暂停 + 网络余量

太短:业务尚未完成锁已过期,多个实例并发执行。

太长:持有者崩溃后资源长时间无法继续处理。

如果业务执行时间不可预测,需要续期机制或重新设计为可分段、幂等任务。

看门狗续期

客户端获取没有显式固定租期的锁后,后台定期检查持有线程是否仍存活,并延长 TTL。业务完成后停止续期并释放。

看门狗需要处理:

  • 客户端进程崩溃后停止续期,锁最终过期。
  • 网络分区时续期失败。
  • JVM 长时间 STW 导致错过续期。
  • 执行线程转移后持有关系变化。

续期减少锁意外过期,但不能消除暂停恢复后的旧持有者问题。

GC 暂停带来的安全问题

A 获取锁
A 发生 60 秒 STW
锁在 30 秒时过期
B 获取锁并开始操作
A 恢复,继续操作

即使 A 释放锁时不会删除 B 的锁,A 仍可能继续写下游资源。仅比较锁值解锁不能阻止已经过期的持有者执行业务。

Fencing Token

每次成功获得锁时,同时获取单调递增 Token:

A 获取 Token 100
B 后来获取 Token 101

下游资源记录已接受的最大 Token,拒绝所有更小 Token 的写入。A 从暂停中恢复后携带 100,数据库或存储发现已经接受 101,拒绝旧持有者操作。

Fencing 的关键是下游必须参与校验。只有 Redis 返回 Token、下游不检查,没有任何保护作用。

如何生成 Fencing Token

可以使用数据库序列、一致性协调系统的修订版本,或在满足要求的单点 Redis 中原子递增。若锁本身在故障切换中可能回退,Token 也必须保证不会回退和重复。

对极高正确性要求的场景,应使用 etcd、ZooKeeper、Consul 等基于共识的协调系统,并仍考虑会话暂停和 Fencing。

获取锁失败怎么处理

  • 快速失败:适合接口请求和热点缓存。
  • 有限重试:使用随机退避,避免所有客户端同时重试。
  • 排队:用消息队列串行处理同一业务 Key。
  • 转移:返回当前任务正在处理,用户查询结果。

不能无上限自旋。锁竞争高时,大量重试会放大 Redis 和业务压力。

可重入锁

同一线程多次进入锁保护的方法时,可以维护持有者标识和重入次数。释放时计数减一,归零后删除 Key。

重入状态和 TTL 更新必须原子,通常使用 Hash 加 Lua。自行实现容易出错,建议使用成熟客户端。

公平锁

公平锁需要维护等待队列和通知机制,保证先等待的客户端优先。它会增加 Redis 操作和故障清理复杂度,吞吐通常低于非公平锁。

大多数业务不需要严格公平,只需防止长期饥饿和设置最大等待时间。

Redis 主从切换的问题

Redis 复制通常异步:

A 在 Master 获取锁
锁尚未同步到 Replica
Master 宕机
Replica 提升为新 Master
B 在新 Master 获取同一把锁

A、B 同时认为自己持有锁。Redis 单实例锁在主从切换场景无法提供严格互斥保证。

Redlock 是什么

Redlock 尝试在多个独立 Redis Master 上获取相同锁,规定时间内获得多数才成功,以减少单节点故障影响。

它的安全性依赖时钟、网络延迟、暂停和部署独立性,社区存在较多讨论。普通业务可以根据风险使用成熟实现;资金、主节点选举等严格一致场景更适合共识协调系统和 Fencing Token。

锁和数据库事务的边界

不要在持有 Redis 锁期间执行很长数据库事务或外部调用。锁无法与数据库提交形成一个原子事务:

  • 数据库成功但解锁失败,锁等 TTL。
  • 锁过期但数据库事务仍在执行。
  • Redis 成功而数据库回滚。

核心数据仍应使用数据库唯一约束、状态机和幂等作为最终防线。

防止缓存击穿的锁

缓存失效时只允许一个线程查询数据库并回填:

读缓存未命中
  ↓
尝试获取短锁
  ├── 成功:查库、回填、释放
  └── 失败:短暂等待、读旧值或降级

这种场景锁只用于减少重复回源,即使偶尔两个线程并发回填,通常不会破坏核心业务,因此可以选择更偏可用性的策略。

定时任务抢占

多个实例竞争定时任务时,Redis 锁可以避免大多数重复执行,但任务本身仍应幂等。更可靠的方式是使用带租约和任务状态的调度平台,记录开始、心跳、完成和重试。

锁粒度

  • 全局锁简单但并发差。
  • 按订单、用户或商品 ID 加锁可以提高并发。
  • 粒度过细会让跨多个资源的操作存在死锁和顺序问题。

多资源加锁时应固定排序顺序,并限制持锁数量和等待时间。更推荐重新设计为单资源状态机或消息串行化。

可观测性

监控:

  • 获取成功率和失败率。
  • 获取等待 P95/P99。
  • 持锁时间分布。
  • 续期失败和锁过期次数。
  • 解锁值不匹配次数。
  • 热点锁 Key。
  • Redis 故障切换和网络异常。

日志记录 Lock Key、Owner、请求 ID、Token 和耗时,但不要记录敏感业务数据。

常见误区

  • SETNX 成功后再设置 EXPIRE。
  • 所有客户端使用相同锁值。
  • 解锁直接执行 DEL。
  • TTL 随便设置 30 秒。
  • 有看门狗就绝对不会并发执行。
  • Redis 锁可以代替数据库唯一约束。
  • 主从切换对锁没有影响。
  • 获取失败无限重试。

核心考点清单

  • 加锁使用 SET key value NX PX ttl 保证原子性。
  • 锁值唯一标识持有者,解锁通过 Lua 比较后删除。
  • TTL 要覆盖业务高分位耗时,长任务需要续期或重新设计。
  • GC 暂停后旧持有者可能继续执行,关键资源使用 Fencing Token。
  • Redis 异步主从切换可能导致两个持有者同时获得锁。
  • 数据库唯一约束、状态机和幂等仍是最终正确性防线。
  • 严格一致场景应评估共识协调系统,而不是只依赖 Redis。

高频追问与参考回答

追问 1:为什么不能先 SETNX 再 EXPIRE?

两条命令不是原子操作,进程可能在中间崩溃,留下永不过期的锁。单条 SET 命令同时指定 NX 和 PX。

追问 2:为什么解锁要用 Lua?

GET 判断和 DEL 必须原子,否则判断后锁可能过期并被别人获得,随后 DEL 会误删别人的锁。

追问 3:业务执行超过 TTL 怎么办?

使用有边界的看门狗续期,或把任务拆分为可恢复、幂等步骤。严格场景还要用 Fencing Token 防止旧持有者恢复后继续写。

追问 4:Redisson 看门狗能保证绝对安全吗?

不能。长时间进程暂停、网络分区和 Redis 主从切换仍可能让锁过期或丢失。看门狗改善租期管理,不提供跨故障的严格一致证明。

追问 5:Redis 锁和 ZooKeeper 锁怎么选?

缓存重建、普通任务去重等偏性能场景可使用 Redis;主节点选举和高正确性协调更适合基于共识与会话语义的 ZooKeeper、etcd,同时仍建议 Fencing。

追问 6:分布式锁能解决库存超卖吗?

可以降低并发,但数据库条件更新、唯一流水或状态机更适合作为最终防线。用全局锁扣库存吞吐低,锁过期仍可能并发执行。

追问 7:如何避免热点锁导致 Redis 压力?

减少锁范围内耗时、快速失败、随机退避、按业务 Key 拆锁,或用消息分区把同 Key 请求串行化。不要让大量线程无界自旋。

机制全景图

「Redis 分布式锁应该怎么正确实现?」的实现链路如下,节点可与后面的源码和运行证据逐一对应。

flowchart LR
    A["生成唯一持有者标识"]
    A --> B["SET NX PX 竞争"]
    B --> C["临界区执行业务"]
    C --> D["Lua 校验后释放"]
    D --> E["超时续期与故障恢复"]

源码与实现定位

入口 阅读重点
SET NX PX 原子获取与租期
EVAL compare-and-delete 只释放自己的锁

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

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

注入超过租期的 STW、网络分区和释放重试;资源端记录 fencing token,验证旧持有者被拒绝。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「持锁 P99」为主基线,记录值应满足「<租期 30%」;同时保存 获取等待与失败率、持锁时间分布,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「SET NX PX」确认请求确实进入「原子获取与租期」对应的实现,再沿「EVAL compare-and-delete」观察「只释放自己的锁」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「无唯一锁值误删他人锁」,并把单一变量逐级放大,直到「持锁 P99」越过「接近租期」。随后再分别验证「租期短于最坏执行时间」和「把锁成功当成外部副作用恰好一次」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「唯一 value+Lua 释放」,确认它能控制影响范围;第二轮应用「关键写携带 fencing token」,验证核心链路恢复;最后落实「临界区缩短且租期按 P99 设置」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「持锁 P99」回到「<租期 30%」、「续期失败」回到「目标 0」、「旧 token 拒绝」回到「演练可见」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
持锁 P99 <租期 30% 接近租期 双持有风险
续期失败 目标 0 出现 停止副作用
旧 token 拒绝 演练可见 资源端不校验 补 fencing

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:库存任务因 GC 停顿出现并发执行

线程获得 10 秒锁后发生 15 秒停顿,锁过期被另一实例取得;旧线程恢复后继续写库。仅有锁无法阻止过期持有者,数据库写入必须携带 fencing token 并拒绝较旧令牌。

失败模式 首要证据 第一处置动作
无唯一锁值误删他人锁 获取等待与失败率 唯一 value+Lua 释放
租期短于最坏执行时间 持锁时间分布 关键写携带 fencing token
把锁成功当成外部副作用恰好一次 续期失败 临界区缩短且租期按 P99 设置

发布与回滚检查点

  • 发布前:确认「SET NX PX」对应实现和上述配置在目标版本仍然有效,并保存「持锁 P99」基线。
  • 灰度中:同时观察 获取等待与失败率、持锁时间分布、续期失败;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「唯一 value+Lua 释放」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「无唯一锁值误删他人锁」没有再次出现,才关闭变更观察窗口。

设计边界与工程取舍

分布式锁提供互斥意图,不等于线性一致事务;关键资源应通过版本、唯一约束或 fencing 在最终写入点再次裁决。