一句话回答
Redis 分布式锁至少要使用
SET key uniqueValue NX PX ttl原子加锁,锁值标识持有者,释放时通过 Lua 比较锁值后删除;还要处理业务超时、续期、重试、主从切换和进程暂停。对于不能容忍旧持有者继续写入的关键资源,还需要 Fencing Token 或选择具备一致性保证的协调系统。
面试考察点
- 是否知道
SETNX后再设置过期时间不是原子操作。 - 能否解释为什么解锁前必须比较唯一锁值。
- 是否理解过期时间太短和太长各有什么风险。
- 能否处理 GC Stop-The-World、网络分区和锁续期失败。
- 是否知道 Redis 主从故障切换可能让锁丢失。
- 能否说明锁、幂等和 Fencing Token 的职责差异。
为什么需要分布式锁
单 JVM 的 synchronized 或 ReentrantLock 只能协调同一进程线程。服务部署多个实例后,需要跨进程协调:
- 防止同一任务被多个实例同时执行。
- 控制热点缓存只有一个实例重建。
- 防止同一订单并发执行某个非幂等操作。
- 选出短期 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 在最终写入点再次裁决。