分布式锁的五个版本:从 SETNX 到业务幂等
每一版都修掉上一版的漏洞,但最后一版才触及根本:锁只是优化,幂等才是正确性。附 Redlock 争议与 ZooKeeper 的适用边界。
分布式锁看似简单:加锁、执行、解锁。但真正在线上跑起来,会遇到一连串问题——锁过期了业务还没跑完、解锁时删了别人的锁、Redis 主从切换导致两个客户端同时持锁。
这篇文章从最朴素的 SETNX 讲起,逐步叠加「过期时间 → 唯一标识 → 续期 → 自动续命」,最后讨论「Redlock 到底靠不靠谱」和「什么时候该换 ZooKeeper」。
一、第一版:SETNX
SETNX lock:order:100 1
SETNX = SET if Not eXists。返回 1 表示加锁成功,0 表示已被占用。
问题 1:锁没有过期时间。 业务代码抛异常没走到 DEL,这把锁就永久存在了。上线后第一天就锁死了。
# 修复:加过期时间
SETNX lock:order:100 1
EXPIRE lock:order:100 10
问题 2(更隐蔽):SETNX 和 EXPIRE 不是原子的。 两条命令之间如果客户端崩溃,锁没有过期时间——又锁死了。
二、第二版:SET NX EX
Redis 2.6.12 起支持:
SET lock:order:100 <token> NX EX 10
一条命令完成「不存在则设置 + 设置过期时间」,原子执行。
public boolean tryLock(String key, String token, int ttlSeconds) {
return Boolean.TRUE.equals(
redis.opsForValue().setIfAbsent(key, token, Duration.ofSeconds(ttlSeconds))
);
}
token 是调用方生成的唯一标识(UUID)。这是下一步的关键。
三、第三版:安全释放(Lua)
用 token 判断「这把锁是不是我加的」:
// 错误写法:判断和删除是两条命令,中间可能被插队
if (token.equals(redis.get(key))) {
redis.del(key); // ← 此时锁可能已过期并被别人拿走,删了别人的锁
}
正确做法是用 Lua 保证原子性:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
private static final RedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end", Long.class);
public boolean unlock(String key, String token) {
Long r = redis.execute(UNLOCK_SCRIPT, Collections.singletonList(key), token);
return r != null && r > 0;
}
为什么必须用 Lua? Redis 执行 Lua 脚本期间不会插入其他命令,get + del 成为一个原子操作。这是防「删别人锁」的唯一正确方式。
四、锁过期了业务还没跑完
这是分布式锁最本质的难题。假设 TTL 10 秒,业务跑了 12 秒:
t=0 A 加锁,TTL=10
t=10 锁自动过期
t=11 B 加锁成功,开始执行
t=12 A 执行完,调用 unlock → 删掉了 B 的锁(如果没校验 token 就会删)
→ C 也能加锁 → B 和 C 并发执行
只要锁有 TTL,就一定有「锁失效但业务未完成」的窗口。
三种应对:
方案 A:把 TTL 设得足够长
// 按 P99 耗时的 3 倍设 TTL
int ttl = estimatedP99Seconds * 3;
简单但不可靠——GC 停顿、网络抖动、下游变慢都会突破估算。
更好的做法是「拿不到锁就快速失败」,而不是等很久。 TTL 设长一点(比如 30 秒),但业务超时后主动释放。
方案 B:看门狗自动续期(Redisson 的做法)
Redisson 的 RLock 在加锁后会启动一个后台任务,每 TTL/3 续期一次:
RLock lock = redisson.getLock("lock:order:100");
lock.lock(); // 不传 leaseTime 就启用看门狗
try {
doBusiness(); // 跑多久都行,看门狗一直续
} finally {
lock.unlock();
}
注意:只有不指定 leaseTime 时看门狗才生效。
lock.lock(10, TimeUnit.SECONDS); // 指定了 leaseTime → 看门狗不启动 → 10 秒后必过期
这是 Redisson 使用中最容易踩的坑。很多人为了「防止死锁」显式传了 leaseTime,结果失去了续期能力。
看门狗的致命缺陷:如果 JVM 长时间 Full GC 或线程被挂起,看门狗也停了,锁照样会过期。所以 Redisson 的方案能覆盖「业务慢」但覆盖不了「进程假死」。
方案 C:业务幂等 + 版本号(根本解法)
不管锁怎么做,业务本身必须是幂等的。锁只是减少并发冲突,不是正确性的唯一保证。
// 用版本号做乐观锁,即使锁失效也不会写脏
int updated = jdbc.update(
"UPDATE stock SET count = count - 1, version = version + 1 " +
"WHERE sku_id = ? AND version = ? AND count > 0",
skuId, expectedVersion);
if (updated == 0) {
throw new OptimisticLockException("并发冲突,重试");
}
核心认知:分布式锁不能替代数据库的并发控制。 它是性能优化手段(减少无效竞争),不是正确性保障。
五、Redlock:争议与真相
Redis 作者 antirez 提出的 Redlock 算法,目的是解决「单点 Redis 主从切换丢锁」的问题。
主从切换丢锁的场景
t=0 A 在 master 加锁成功
t=1 master 宕机,锁数据还没同步到 slave
t=2 slave 提升为新 master(没有这把锁)
t=3 B 在新 master 加锁成功
→ A 和 B 同时持锁
这是真实存在的风险,尤其在异步复制的 Redis 上。
Redlock 的做法
部署 5 个独立的 Redis 主节点(不是主从,不是 Cluster),客户端:
- 记录开始时间
T1 - 依次向 5 个节点发送
SET NX EX - 如果在多数派(≥3) 上成功,且总耗时 < TTL,视为加锁成功
- 如果失败,向所有节点发送解锁
Martin Kleppmann 的质疑
他在《How to do distributed locking》里指出 Redlock 有三个问题:
1. 依赖时钟。 Redlock 的正确性假设「各节点的时钟漂移有限」。如果某个节点的时钟跳变(NTP 校时、虚拟机迁移),TTL 判断就错了,锁可能提前失效。
2. GC 停顿问题无法解决。 客户端 A 拿到锁后发生 10 秒 Full GC,锁过期,B 拿到锁开始执行,A 恢复后继续执行——两个客户端并发。Redlock 对此无能为力,任何基于超时的锁都有这个问题。
3. 它不能提供「fencing token」。 正确做法是每次加锁返回一个单调递增的序号,下游服务拒绝序号更小的请求:
A 拿到 token=33,GC 停顿
锁过期,B 拿到 token=34,写入成功
A 恢复,带着 token=33 写入 → 存储层发现 33 < 34,拒绝
这才是真正解决问题的方案,而 Redlock 不提供。
结论:什么时候用 Redlock
我的看法和业界主流一致:
- 不要用 Redlock 做「强一致」的保证。 它不解决问题,只是把单点风险分散了。
- 如果只是「减少重复执行」(比如定时任务防重、缓存重建互斥),单机 Redis 锁完全够用。
- 如果业务不能容忍并发执行(比如扣款),用 ZooKeeper/etcd 的临时顺序节点,或者数据库唯一约束。
六、ZooKeeper 方案
ZooKeeper 的锁基于临时顺序节点:
/locks/order-100/
├── lock-0000000001 ← 客户端 A
├── lock-0000000002 ← 客户端 B
└── lock-0000000003 ← 客户端 C
客户端创建临时顺序节点,如果自己是最小的那个就拿到锁;否则监听前一个节点的删除事件。
与 Redis 的对比
| Redis | ZooKeeper | |
|---|---|---|
| 一致性 | AP(异步复制,可能丢锁) | CP(ZAB 协议,强一致) |
| 锁释放 | 依赖 TTL,可能提前/延后 | 会话断开即释放,无需 TTL |
| 性能 | 高(10 万 QPS 级) | 中(1 万 QPS 级) |
| 惊群 | 需自己处理 | 只监听前一个节点,无惊群 |
| 阻塞等待 | 需轮询或 Pub/Sub | 原生支持 Watch 通知 |
| 运维 | 简单 | 需 3 节点集群 |
ZooKeeper 天然不会有「锁失效但业务还在跑」的问题,因为锁的生命周期绑定在会话上——进程死了会话就断,锁立即释放。而 Redis 必须靠 TTL,就有了那个窗口。
代价是性能低一个数量级,且运维复杂。
用 Curator 实现
CuratorFramework client = CuratorFrameworkFactory.newClient(
"zk1:2181,zk2:2181,zk3:2181", new ExponentialBackoffRetry(1000, 3));
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order-100");
try {
if (lock.acquire(5, TimeUnit.SECONDS)) { // 最多等 5 秒
doBusiness();
} else {
throw new BizException("获取锁超时");
}
} finally {
lock.release();
}
七、选型决策树
需要跨进程互斥?
├─ 否 → 用 JVM 内的锁(synchronized / ReentrantLock)
└─ 是
├─ 只是"减少重复执行",能容忍极小概率的并发?
│ └─ Redis 单机锁(SET NX EX + Lua 释放)✅ 最简单
├─ 需要阻塞等待 + 公平 + 强一致?
│ └─ ZooKeeper / etcd
├─ 数据在数据库里,冲突概率不高?
│ └─ 数据库唯一约束 / 乐观锁(最可靠,无需额外组件)
└─ 高并发 + 可续期 + 需要重入?
└─ Redisson(但注意 leaseTime 的坑)
优先顺序建议:数据库唯一约束 > Redis 锁 > ZooKeeper。
数据库方案最可靠(事务保证),只是性能受限于 DB。Redis 方案性能好但有理论缺陷。ZooKeeper 最严谨但成本高。
八、一份生产可用的 Redis 锁实现
如果不想引入 Redisson,这是一个精简但正确的实现:
@Component
public class RedisLock {
private final StringRedisTemplate redis;
private static final RedisScript<Long> UNLOCK = new DefaultRedisScript<>(
"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
Long.class);
/** 尝试加锁,成功返回 token,失败返回 null */
public String tryLock(String key, long ttlSeconds) {
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue()
.setIfAbsent(key, token, Duration.ofSeconds(ttlSeconds));
return Boolean.TRUE.equals(ok) ? token : null;
}
/** 带超时的阻塞式加锁 */
public String lock(String key, long ttlSeconds, long waitMillis) {
long deadline = System.currentTimeMillis() + waitMillis;
while (System.currentTimeMillis() < deadline) {
String token = tryLock(key, ttlSeconds);
if (token != null) return token;
try {
Thread.sleep(50 + ThreadLocalRandom.current().nextInt(50));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return null;
}
public boolean unlock(String key, String token) {
Long r = redis.execute(UNLOCK, Collections.singletonList(key), token);
return r != null && r > 0;
}
}
使用:
String token = redisLock.lock("lock:order:" + orderId, 30, 3000);
if (token == null) {
throw new BizException("操作太频繁,请稍后重试");
}
try {
doBusiness(orderId);
} finally {
redisLock.unlock("lock:order:" + orderId, token);
}
Thread.sleep 里加随机抖动是为了避免多个等待者同时醒来形成惊群。
九、几个必须注意的点
1. key 的粒度
"lock:order:100" ✓ 按订单粒度,并发度高
"lock:order" ✗ 全局一把锁,完全串行
2. TTL 要大于业务 P99 耗时
监控加锁成功率和锁等待时间。如果经常出现「拿到锁但业务超时」,说明 TTL 太短或者业务有问题。
3. 不要在锁内做远程调用
lock(...)
try {
httpClient.call(); // 下游超时 5 秒,锁被占用 5 秒
db.update();
} finally { unlock(); }
远程调用放在锁外,先拿到结果再进锁。
4. 加锁失败的降级策略
不要简单抛异常。要考虑:等待重试、返回友好提示、或者直接放行(如果业务能容忍)。
5. 一定要监控
metrics.counter("lock.acquire", "result", token != null ? "success" : "fail").increment();
加锁失败率突增,说明有长事务或者死锁。
小结
分布式锁的演进路径:
| 版本 | 命令 | 解决了什么 | 还差什么 |
|---|---|---|---|
| v1 | SETNX |
互斥 | 死锁(无 TTL) |
| v2 | SET NX EX |
死锁 | 删了别人的锁 |
| v3 | + Lua 校验 token | 误删 | 锁提前过期 |
| v4 | + 看门狗续期 | 业务慢 | JVM 假死 |
| v5 | + 业务幂等 | 全部 | — |
最后一步才是根本:不管锁怎么做,业务本身必须幂等。锁是优化,幂等是正确性。
Redlock 不要用于强一致场景。真要强一致,ZooKeeper 或者数据库唯一约束,别在 Redis 上硬撑。
正在加载评论…