分布式锁
分布式锁
本文将从最简单的 Redis 分布式锁讲起,一步步升级,最终达到几乎“完美”的工业级解决方案。附带完整 Java(RedisTemplate)与 Lua 脚本示例。
1. 分布式锁是什么?为什么需要?
分布式系统中,多节点同时执行同一段逻辑,可能导致:
- 超卖
- 重复扣费
- 任务重复执行
分布式锁的目标是 让多个服务节点能够互斥访问共享资源。
Redis 因其高性能与命令原子性,成为实现分布式锁的第一选择。
2. 最简单的 Redis 分布式锁:SETNX
基于redis指令: SETNX
实现的java代码
Boolean success = redisTemplate.opsForValue().setIfAbsent("lock:order:1", "1");
// 获得锁成功
if (Boolean.TRUE.equals(success)) {
try {
// 业务逻辑
} finally {
redisTemplate.delete("lock:order:1");
}
}
存在的问题:
- 没有过期时间,会出现死锁(例如业务代码执行一半直接宕机)
3.改进:设置过期时间
上锁时设置过期时间
redisTemplate.opsForValue().setIfAbsent(key, value, 10, TimeUnit.SECONDS);
这样就不会死锁了。但仍然有问题:
-
锁被别人误删, 将导致锁彻底失效(比如线程 A 的锁过期了,线程 B 设置成功,而 A 还执行 finally 误删了 B 的锁)
-
业务执行时间超过过期时间(锁自动释放,导致并发执行)
4.改进:UUID + 判断是否是自己的锁
为防止误删,要在 value 中存唯一标识(UUID):
String key = "lock:order:1";
String value = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, 30, TimeUnit.SECONDS);
try {
if (Boolean.TRUE.equals(success)) {
// do business
}
} finally {
// 删除前校验 value
String curr = redisTemplate.opsForValue().get(key);
if (value.equals(curr)) {
redisTemplate.delete(key);
}
}
仍有问题
finally块中的, “获取 + 删除”不是原子操作,value.equals(curr)等于true后,锁超时过期,被其他线程获取, 所以仍然可能删除其他线程上的锁。
5. 使用 Lua 保证操作原子性
删除锁时用 Lua 脚本保证 判断 + 删除 原子执行。
Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
java调用:
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end");
script.setResultType(Long.class);
Object result = redisTemplate.execute(script, Collections.singletonList(key), value);
到这里,已经接近工业级方案,但仍然有一个重大问题:就是时间问题,仍然没有解决,也就是锁的过期时间和业务的执行时间,没有办法做到完美
6. 自动续期(看门狗机制)
如果业务执行时间超过锁的过期时间,锁会提前释放。
解决方案:锁看门狗(WatchDog):
- 获得锁后,每隔固定时间自动给锁续期(例如 1/3 的过期时间)
- 业务执行完后停止续期
这一机制 Redisson 已完整实现
Java 自己实现简单示例(Timer 续期):
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
AtomicBoolean running = new AtomicBoolean(true);
scheduler.scheduleAtFixedRate(() -> {
if (running.get()) {
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
}
}, 10, 10, TimeUnit.SECONDS);
try {
// business logic
} finally {
running.set(false);
// delete with Lua
}
下面看下Redisson 是怎么实现这个逻辑的
6.1 加锁逻辑
调用入口:
RLock lock = redissonClient.getLock("order:1");
lock.lock();
Redisson 内部会执行:
public void lock() {
lock(-1, null, false);
}
-1 表示 启用看门狗机制(还有其他重载方法)。
Redisson 的加锁本质是执行一个 Lua 脚本,使操作“原子化”,源码在RedissonLock#tryLockInnerAsync:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, command,
"if ((redis.call('exists', KEYS[1]) == 0) " +
"or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
参数说明:
KEYS[1]:锁的 key,例如lock:order:1ARGV[1]:锁过期时间(毫秒)ARGV[2]:唯一线程标识UUID + threadId
其中lua脚本表达的含义是, 判断当前key是否存在,如果不存在,或者占用这个key的是当前线程(代表可重入),则表示加锁成功, value自增1,并设置过期时间, 返回null,若没有加锁成功,则返回当前锁的剩余时间.
redis中锁的数据结构:
key = lock:order:1
value = {
"uuid:threadId": 1
}
-
field:标识哪个 JVM 哪个线程持锁
-
value:重入次数
6.2 启动看门狗 WatchDog
如果 lock() 使用的是默认参数(没指定超时时间),Redisson 会启动 自动续期机制:
续期逻辑入口RedissonLock#tryAcquireAsync:
private RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
// 尝试加锁,返回null表示加锁成功, 返回时间则加锁失败
if (leaseTime > 0) {
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
CompletionStage<Long> s = handleNoSync(threadId, ttlRemainingFuture);
ttlRemainingFuture = new CompletableFutureWrapper<>(s);
CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> {
// 判断为null,表示加锁成功
if (ttlRemaining == null) {
if (leaseTime > 0) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 启动看门狗
scheduleExpirationRenewal(threadId);
}
}
return ttlRemaining;
});
return new CompletableFutureWrapper<>(f);
}
看门狗逻辑方法RedissonBaseLock#renewExpiration:
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 设置定时任务,时间为锁时间的三分之一
Timeout task = getServiceManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 执行锁延长时间的逻辑lua脚本
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
// 开启下一个看门狗定时任务
future.whenComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock {} expiration", getRawName(), e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
// 如果当前任务执行延长时间成功,代表当前业务没有执行完成
if (res) {
// 递归调用
renewExpiration();
} else {
// 取消
cancelExpirationRenewal(null);
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
再看下renewExpirationAsync 方法执行的lua脚本
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
lua脚本判断当前锁是否已经过期, 如果没有则延长过期时间, 并返回true, 如果过期,则返回false.
6.3 lock上锁失败自旋等待逻辑
上面两节描述了lock上锁成功的代码路径, 若其他线程上锁失败时,会自旋等待, lock方法如下:
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// 上锁成功
if (ttl == null) {
return;
}
// 上锁失败, 将会在此锁为key的通道上订阅消息
CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
pubSub.timeout(future);
RedissonLockEntry entry;
if (interruptibly) {
entry = commandExecutor.getInterrupted(future);
} else {
entry = commandExecutor.get(future);
}
try {
// while 自旋
while (true) {
// 每次循环都会尝试获取锁
ttl = tryAcquire(-1, leaseTime, unit, threadId);
// 获取锁成功,跳出循环
if (ttl == null) {
break;
}
if (ttl >= 0) {
try {
// 等待指定时间, 或者等待占用线程方释放锁唤醒
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
entry.getLatch().acquire();
} else {
entry.getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(entry, threadId);
}
}
可以看到,每次循环中,都要等待指定时间, 若再刚刚设定好时间后, 占用锁的业务逻辑已经执行完毕,若没有通知, 则此处将会白白浪费掉一段时间,所以再获取锁失败后, 会订阅该锁的一个redis通道, 监听到消息后,将会唤醒等待, 代码在LockPubSub#onMessage 处:
protected void onMessage(RedissonLockEntry value, Long message) {
// 若是解锁消息
if (message.equals(UNLOCK_MESSAGE)) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute != null) {
runnableToExecute.run();
}
// 手动唤醒
value.getLatch().release();
} else if (message.equals(READ_UNLOCK_MESSAGE)) {
while (true) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute == null) {
break;
}
runnableToExecute.run();
}
value.getLatch().release(value.getLatch().getQueueLength());
}
}
unlock后, 将会执行如下lua代码RedissonLock#unlockInnerAsync:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call(ARGV[4], KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()),
LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId), getSubscribeService().getPublishCommand());
}
lua代码中,判断锁的值是否为0, 若是,代表所有重入的锁皆已完成, 删除key, 并且发布解锁消息.
6.4 redisson 上锁示意图

7. RedLock
7.1. Redisson 锁仍然存在的问题
Redisson 的普通锁已经做了很多增强:
-
使用 Lua 脚本保证加锁和解锁的原子性
-
WatchDog 确保锁不会因业务执行过长提前过期
-
CommandExecutor 处理主从切换
-
在 Redis Cluster 模式下按 Slot 选择正确节点
-
脚本缓存到所有节点避免 failover 时报错
这些都解决了大部分情况下 “主宕机导致锁丢失”、 “加锁原子性”、 “续期问题” 等主从架构的缺陷。
但核心问题仍然无法根治:
主从复制仍然可能延迟(异步复制)
Redis 主从复制是:异步复制(async replication)
意味着:
- 客户端写入 A 主节点成功
- A 主节点宕机
- 数据很可能还没同步到从节点
- 从节点提升为主
- 刚刚写入的锁“丢失”
Redisson 的处理能降低概率,但 不可能从架构上根治异步复制的缺陷。
7.2. RedLock 的解决办法
RedLock 是 Redis 官方提出的一种高可用分布式锁方案。
核心思路:不是依赖一个 Redis,而是依赖多个独立的,不是主从,不同步,不复制的单机(推荐) Redis 实例。只要超过半数节点成功加锁,就认为加锁成功。
RedLock 要求的节点是 完全独立的 Redis 实例:
- 没有主从架构
- 没有复制延迟
- 没有 failover
所以:
- 即便某个 Redis A 挂了
- 只要还有“大多数(> N/2)”实例保持锁信息
- 锁就不会丢
- 也不会因为主从切换导致不一致
这就是 RedLock 比普通锁强的地方。

RedLock方案并不是很复杂, 但是自己实现一个生产级别的代码,还有有很多坑点的, 不过,Redisson 对该方案有一个落地实现:
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://127.0.0.1:6379")
.setPassword("abc123").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://127.0.0.1:6379")
.setPassword("abc123").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://127.0.0.1:6379")
.setPassword("abc123").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
String resourceName = "LOCK_KEY";
RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 向3个redis实例尝试加锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
// 业务代码
}
} catch (Exception e) {
} finally {
// 解锁
redLock.unlock();
}
7.3 RedLock为什么不推荐使用
RedLock(多节点加锁算法)本来是 Redis 作者 antirez 提出的分布式锁高可用方案,但后来被 大量分布式系统专家证明不安全,甚至 Redis 作者本人也开始弱化宣传。
RedLock 的设计假设过于理想化(核心问题)
RedLock 要求满足以下假设:
| RedLock 假设 | 实际情况 |
|---|---|
| 多个 Redis 节点之间时间非常接近 | 分布式系统中几乎不可能 |
| 网络延迟可以忽略 | 真实网络存在抖动、延迟 |
| 各节点之间的失败是独立事件 | 主从在同机房,经常同时故障 |
| 节点时钟不会漂移 | 实际上 NTP 时间漂移很正常 |
一旦这些假设不成立,RedLock 的“多数派锁成功就安全”的推论就崩了。
RedLock 在网络抖动情况下可能产生“双锁”.
这是最致命的问题:在网络分区(网络抖动、延迟、局部断网)情况下,RedLock 可能让两个客户端同时认为自己加锁成功。
举例:
- 有 5 个 Redis 节点, 分别是 R1,R2,R3,R4,R5
- 客户端 A 访问其中 3 个成功 → 认为加锁成功,但是在加锁时,由于网络抖动,GC等等原因, A 在T0时刻加锁了R3,过期时间10s, 在T1时刻加锁了 R1,R2,而此时R3的过期时间只剩下9.9秒, 比R1,R2少0.1秒, 另外 R4,R5加锁失败(宕机等原因).
- 客户端 B 访问时, R4,R5加锁成功, 而加锁R3时,碰巧R3此时锁已经过期,这正是客户端A最后的时间窗口期, R1,R2 加锁失败, 那么此时将会发生, 客户端A和B,同时持有了该把锁
这已经被分布式算法专家(Martin Kleppmann)严格证明。
RedLock 对时钟依赖太强(严重缺陷)
RedLock 用时间戳判断“是否超时释放锁”。
但现实是:
- 分布式节点的时钟漂移随时可能发生(NTP 不可靠)
- 不同 Redis 节点时间偏差会导致锁提前或延迟过期
- 客户端本地时间也不是统一时间
任何时间相关的算法在分布式系统中都非常脆弱
尤其用于安全性要求极高的锁系统:不可靠
RedLock 的性能比单节点 + 主从低太多
你的系统想要:
- 更安全?
- 更高可用?
- 更单点避免?
RedLock 却要:
- 访问 多个 Redis 节点
- 多次加锁、计算时间
- 成本高,延迟大
- TPS 下降
却 没有真正提升安全性(提高加锁的复杂度反而更危险)
Redis 官方也淡化 RedLock 的使用
Redis 作者 antirez 最初提出 RedLock
但随着争议越来越大,他后续公开表示:
大多数情况下使用 Redis 单实例(带持久化 + 主从)就足够安全。
他不再大力推荐 RedLock。
Redisson 文档里明确写:
Redlock 只适用于极端分布式环境,不推荐在普通系统使用。
他们内部默认使用的是:
- 单节点锁 + 自动续期 (看门狗)
- 主从复制 + failover
- Lua 脚本保证原子性
而不是 RedLock。
8. 其他强一致性的分布式锁
为什么 Redis(含 Redisson/Redlock)不足以作为金融“最终”方案
-
Redis 本质是内存型 KV,依赖复制延迟(主从同步/异步)——遇到主宕机 + 未同步就可能丢失键,从而导致两个客户端同时认为“拿到锁”。
-
Redlock 在多数情况下很强,但学术上与实践中都存在争议(时钟漂移、网络分区、不同客户端同时在不同节点拿到多数节点锁的极端情况)。金融级需要严格线性化语义(linearizability),而 Redlock 在极端故障模型下难以给出同样强的数学证明。
-
即便用 Redisson,它做了大量工程处理(看门狗、脚本、故障处理),但它仍然运行在 Redis 的一致性边界之内
因此金融级系统更倾向于:使用 Raft/Paxos 一致性存储(etcd/zk)或把锁逻辑放在核心交易数据库的事务体内。
不过基于redis做分布式锁,仍然是性价比最高,兼顾性能和一致性的方案.非极端情况,仍然为首选
8.1 etcd(或 Consul) + Lease + Compare-And-Swap + Fencing token(强烈推荐)
- 优点:基于 Raft,写入在多数节点持久化,时序严格(单调修改索引),支持 TTL lease 和原子操作。
- 如何实现:
- 客户端申请 lease(带 TTL),使用 etcd 的
putwithlease绑定键(或使用txn做 compare-and-swap)。 - etcd 返回一个
lease ID,并可读取当前revision(或单独维护递增 token)。 - 把返回的 fencing token(如当前 revision 或自增 counter) 附到业务后端调用中。
- 后端在执行关键操作前,检查 token 是否为最新(或把 token 写入数据库作为操作的一部分)。
- 客户端申请 lease(带 TTL),使用 etcd 的
- 故障场景:即使 leader 宕机,只要写入被 majority 提交,该状态不会丢失。
- 缺点: - 性能开销:Raft协议的日志同步机制降低了吞吐量,增加了延迟 。 部署复杂度:需要部署多个etcd节点,增加了系统复杂度 。 学习成本:需要理解Raft协议和Fencing token机制,增加了学习成本 。
8.2. ZooKeeper ephemeral sequential nodes + Fencing(可选)
- 优点:强一致性:基于ZAB协议,提供严格的顺序一致性保证 。 - 自动释放:临时节点(ephemeral nodes)在客户端会话结束时自动删除,避免了死锁问题 。 - 天然支持排队:顺序节点(sequential nodes)支持公平锁,确保客户端按顺序获取锁 。 - 线性一致性:提供严格的线性一致性保证,适合金融级场景 。
- 注意: 性能瓶颈:ZooKeeper的性能较低,不适合高并发场景 。 部署复杂度:需要部署多个ZooKeeper节点,增加了系统复杂度 。 实现复杂度:需要处理节点创建、监听、删除等复杂逻辑,增加了实现难度 。
8.3 数据库主库行级锁(SELECT ... FOR UPDATE)+ 事务日志
- 优点:利用现有单一强主库(例如金融核心库)的事务保证一致性;操作和应用数据在同一事务域内,天然满足原子性。
- 适合:资源有限、对延迟容忍、业务可以把锁逻辑放进数据库的场景。
- 缺点:负载高时会成为瓶颈;跨库/跨服务锁实现复杂。
9.总结与建议
Redis分布式锁从最简单的SETNX命令到接近工业级的Redisson看门狗机制,再到RedLock算法,体现了分布式锁实现的不断演进和优化。在大多数实际业务场景中,Redisson的看门狗机制已经足够满足需求,提供了较高的互斥性、安全性和可靠性,同时保持了良好的性能
对于高并发场景,应优先考虑Redis基础锁或带过期时间的锁,结合看门狗机制实现自动续期 。对于强一致性场景,应考虑etcd或ZooKeeper,利用其线性一致性和Fencing token机制。对于数据库操作场景,应利用数据库的行级锁和事务机制,确保锁与数据操作的原子性。
不推荐在普通系统中使用RedLock,除非系统对锁的可靠性要求极高,且可以接受较低的性能。如果必须使用RedLock,应确保部署至少5个独立的Redis节点,且网络延迟极低。
最后,分布式锁的实现应根据具体业务场景进行选择和优化,没有一种锁方案可以满足所有场景的需求。在实现分布式锁时,应充分考虑互斥性、安全性、可靠性和性能的平衡,确保系统在各种场景下都能稳定运行。

浙公网安备 33010602011771号