两个实例同时处理同一笔退款,库存扣了两次,两条日志的时间戳只差 3 毫秒。单机时代一个 synchronized 就结束的事,到了多副本部署就必须把互斥搬出 JVM。
Redis 锁的问题在于"三行代码看着能跑",真正决定成败的是三件事:加解锁是否原子、持有者怎么校验、锁过期与业务耗时怎么对齐。下面按"要求 → 错法 → 正法 → 边界"拆。
1. 四条要求,逐条对应实现
| 要求 | 含义 | 实现上的硬约束 |
|---|---|---|
| 互斥 | 同一时刻只有一个持有者 | 加锁必须是单条原子命令,不能先查后写 |
| 不死锁 | 持有者崩溃后锁最终能释放 | 锁必须带 TTL,且 TTL 由服务端时钟裁决 |
| 谁加谁解 | 只能删自己加的锁 | value 存唯一标识,解锁前比对 |
| 可重入 | 同一线程再次加锁不阻塞自己 | value 里存"持有者 + 重入计数",用 Hash |
第五条"高可用"常被混进这四条:它是部署问题,不是锁语义问题,主从异步复制下锁可能丢,见第 8 节。
2. SETNX 版本的三处漏洞
// 错误示范:三行代码,三个洞
Boolean ok = redis.opsForValue().setIfAbsent("lock:order:8848", "1");
redis.expire("lock:order:8848", 30, TimeUnit.SECONDS);
// ... 业务逻辑
redis.delete("lock:order:8848");
| 漏洞 | 触发条件 | 后果 |
|---|---|---|
SETNX + EXPIRE 非原子 |
两条命令之间进程被杀、连接断 | key 永久存在,锁永远不释放 |
| 解锁不校验持有者 | A 超时后锁被 B 拿到,A 恢复执行完删 key | 删掉 B 的锁,互斥失效 |
| 超时后误删别人的锁 | 业务耗时 > TTL,锁已易主 | 同上,且表现为"偶发"并发冲突 |
第二、三条的根因是同一个:删除动作没有身份校验。而且它表现为"偶发",测试环境很难复现。
3. 正确的加锁与解锁
加锁用一条命令把"不存在才写"和"设过期"绑在一起,这是唯一正确的加锁形态:
# 抢锁成功返回 OK,失败返回 nil
SET lock:order:8848 6f1c9a3e-2b71-4d0a-9c55-8e1f7 NX PX 30000
NX 不存在才写,PX 30000 即 30 秒过期,由服务端计时——客户端本地时间不可信。
解锁必须"比对后删除",两步要原子,只能走 Lua:
-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
String UNLOCK = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK, Long.class);
redis.execute(script, List.of("lock:order:8848"), "6f1c9a3e-2b71-4d0a-9c55-8e1f7");
GET 与 DEL 之间只要有别的命令插入,校验就形同虚设。Lua 在 Redis 单线程里整段执行、不可打断,这才是"原子"的含义。
4. 锁超时定多久
这是整个设计里最没有标准答案的一格,但有一条底线:TTL 必须大于业务耗时的上界,否则互斥本身就破了。
| 业务耗时特征 | 建议 TTL | 说明 |
|---|---|---|
| 稳定在 50–200ms 的写操作 | 3–5 秒 | 留 10 倍以上余量,容忍一次 GC 停顿 |
| 波动大、有外部调用(HTTP/MQ) | 不设固定值,走看门狗 | 外部依赖的 P99 不可预测 |
| 批处理、跑几分钟 | 看门狗 + 分段提交 | 单段太长就该拆任务 |
把 TTL 调到 10 分钟来"保证不超时",等于把故障恢复时间也一起调到 10 分钟。TTL 是故障恢复参数,不是业务耗时参数,冲突时用续期解决。
5. 看门狗:把 TTL 变成动态的
Redisson 的做法是:加锁成功后起一个后台任务,在锁到期前定期把它续回原值,业务跑多久锁就活多久;业务结束主动解锁时停掉续期。
| 参数 | 默认值 | 说明 |
|---|---|---|
lockWatchdogTimeout |
30000 ms | 锁默认租期,也是看门狗的基准 |
| 续期间隔 | 租期 / 3 = 10000 ms | 每次把过期时间重置为 30 秒 |
| 续期停止条件 | 主动 unlock / 客户端 shutdown / 续期连续失败 | 三种情况都会让锁自然过期 |
| 进程被 kill -9 | 续期任务随之消失 | 最长 30 秒后锁自动释放,这是设计意图 |
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.setLockWatchdogTimeout(30_000L); // 单位毫秒
RedissonClient redisson = Redisson.create(config);
看门狗不是"防一切"。它防的是"业务没跑完锁先过期",防不了进程假死却仍在续期(网络分区时客户端以为自己续上了),也防不了业务里的死等。前提是续期任务与业务线程同 JVM,容器被驱逐时两者一起消失。
6. 可重入:Hash + 计数
同一个线程再次加锁,如果 key 已存在就判定失败,会把自己锁死。可重入的实现是把 value 从字符串换成 Hash:
-- 加锁:field = 客户端唯一标识(UUID:threadId)
if redis.call('EXISTS', KEYS[1]) == 0 then
redis.call('HSET', KEYS[1], ARGV[1], 1)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
end
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then
return redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
end
return 0
解锁时 HINCRBY -1,归零才 DEL 并广播唤醒等待者。细节:每次重入都要重置过期时间,否则外层 TTL 会被内层节奏带偏。
7. tryLock 的参数决定行为
RLock lock = redisson.getLock("lock:order:8848");
// 最多等 3 秒,租期交给看门狗(leaseTime 缺省 = -1)
boolean ok = lock.tryLock(3, TimeUnit.SECONDS);
if (ok) {
try { /* 业务 */ } finally { lock.unlock(); }
}
| 调用形式 | 等待行为 | 租期行为 |
|---|---|---|
tryLock() |
只试一次,立即返回 | 看门狗续期 |
tryLock(waitTime, unit) |
在 waitTime 内重试 | 看门狗续期(关键) |
tryLock(waitTime, leaseTime, unit) |
同上 | 固定租期,看门狗关闭 |
lock() |
无限等待 | 看门狗续期 |
最常见的误用是传了 leaseTime 却以为看门狗还在——传了就停止续期,业务超时锁照样丢。
等待机制本身也有两代做法:
| 方式 | 原理 | 代价 |
|---|---|---|
| 轮询(早期) | 循环 SET NX + sleep |
空转消耗连接与 CPU,唤醒延迟取决于 sleep 间隔 |
| 订阅发布(Redisson) | SUBSCRIBE redisson_lock__channel:{lock},解锁时 PUBLISH |
唤醒及时,但多一条连接与一个订阅关系 |
8. 主从切换下的锁失效,与 RedLock 的边界
Redis 主从是异步复制。加锁成功的 SET NX 写在主库,还没同步到从库时主库宕机,哨兵提拔从库——新主库里没有这把锁,另一个客户端立刻能加锁成功——这是异步复制的固有窗口,不是配置问题。
RedLock 的思路是向 5 个独立主节点依次加锁,拿到 ≥3 个才算成功。它的争议集中在两点:客户端 GC 停顿会让"我以为还在锁内"的假设失效;多个节点的时钟漂移会侵蚀有效租期。业界的结论是务实的:
| 场景 | 建议 |
|---|---|
| 锁只为"减少重复计算" | 单实例锁足够,丢了最多多做一次 |
| 保护金额、库存等强一致写 | 不能只靠 Redis 锁,用 DB 唯一约束 / 条件更新兜底 |
| 需要严格互斥(如选主、配置变更) | 用 ZooKeeper / etcd 这类有共识的协调服务 |
| 已上 RedLock 且能接受其前提 | 可用,但必须配"锁内操作幂等"作为第二道防线 |
锁的正确用法是"降低并发冲突的概率",不是"保证互斥的数学必然"。凡是把正确性完全押在锁上的设计,都会在故障转移那天暴露。
9. 与其他互斥方案的对照
| 方案 | 释放机制 | 一致性 | 主要代价 |
|---|---|---|---|
| Redis 单实例锁 | TTL 到期 | 异步复制可能丢锁 | 简单、快;需要幂等兜底 |
| Redisson 看门狗 | 续期 + TTL | 同左 | 续期流量;依赖客户端存活 |
| 数据库乐观锁 | UPDATE ... WHERE version = ? |
强(单库) | 冲突时重试;热点行锁竞争 |
| 数据库悲观锁 | SELECT ... FOR UPDATE |
强 | 连接被占住,长事务拖垮连接池 |
| ZooKeeper 临时顺序节点 | 会话断开自动删 | 强(ZAB) | 运维成本、写性能低于 Redis |
乐观锁和 Redis 锁不是替代关系:前者管"更新是否基于最新版本",后者管"这段时间内别让两个人同时进来"。前者能兜住后者的失效,写金额的代码里两样都该有。
10. 排错与常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 偶发并发重复写 | TTL 小于业务耗时,锁已易主 | 上 tryLock(wait, unit) 让看门狗接管 |
解锁报 IllegalMonitorStateException |
当前线程不是持有者,或锁已过期 | 检查是否跨线程 unlock;确认 TTL |
| 大量请求卡在同一把锁上 | 锁粒度过粗(如 lock:order 锁全站订单) |
收细到 lock:order:{id} |
| 加锁成功但业务串行严重 | 把锁套在长事务 / 外部 HTTP 调用外层 | 锁只包住临界区那几行 |
| 重启后立刻并发冲突 | kill -9 后旧锁还在 TTL 内 |
新实例走 tryLock 等待,别假设锁不存在 |
| 解锁脚本偶发返回 0 | value 不匹配,说明锁已过期被别人拿走 | 属于正常路径,需记录并告警业务超时 |
三条值得单独说:锁粒度过粗把并发热点变成排队;长事务里持锁让 TTL 与事务时长互相拉扯;解锁没走 Lua在低并发下永远测不出来,压测才现形。
11. 边界:这些情况别硬上 Redis 锁
| 需求 | 更合适的做法 |
|---|---|
| 只需要"同一秒内不重复提交" | 幂等 token + 唯一索引,不需要锁 |
| 跨行、跨表的复杂一致性 | 数据库事务 + 隔离级别 |
| 任务调度只跑一份 | 调度框架的选主(XXL-Job / Quartz 集群) |
| 秒杀级热点扣减 | Lua 原子扣减 + 异步落库,锁即瓶颈 |
| 严格 FIFO 排队 | 队列 / 信号量,不是互斥锁 |
Redis 锁的甜区是临界区毫秒级、冲突不密集、失败可容忍重试。离开这个区间,要么换协调服务,要么改成"根本不需要锁"的设计。
12. 自测
SETNX之后紧跟EXPIRE,在哪一个时刻崩溃会造成死锁?- 为什么解锁必须用 Lua,而不是先
GET比对再DEL? tryLock(3, 10, TimeUnit.SECONDS)这个调用里,看门狗还生效吗?- 看门狗在哪些条件下会停止续期?停止之后锁靠什么释放?
- 主从切换导致锁失效的根因是什么?RedLock 解决了它,又引入了什么假设?
- 一笔扣减库存的写操作,为什么"Redis 锁 + DB 乐观锁"要同时存在?