先说结论
Redis 分布式锁至少要使用
SET key uniqueValue NX PX ttl原子加锁,锁值标识持有者,释放时通过 Lua 比较锁值后删除;还要处理业务超时、续期、重试、主从切换和进程暂停。对于不能容忍旧持有者继续写入的关键资源,还需要 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 锁可以代替数据库唯一约束。
- 主从切换对锁没有影响。
- 获取失败无限重试。
常见问题
追问 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 请求串行化。不要让大量线程无界自旋。