分布式锁

分布式锁

本文将从最简单的 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:1
  • ARGV[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 上锁示意图

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 比普通锁强的地方。

image-20251127104943681

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 可能让两个客户端同时认为自己加锁成功。

举例:

  1. 有 5 个 Redis 节点, 分别是 R1,R2,R3,R4,R5
  2. 客户端 A 访问其中 3 个成功 → 认为加锁成功,但是在加锁时,由于网络抖动,GC等等原因, A 在T0时刻加锁了R3,过期时间10s, 在T1时刻加锁了 R1,R2,而此时R3的过期时间只剩下9.9秒, 比R1,R2少0.1秒, 另外 R4,R5加锁失败(宕机等原因).
  3. 客户端 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 的 put with lease 绑定键(或使用 txn 做 compare-and-swap)。
    • etcd 返回一个 lease ID,并可读取当前 revision(或单独维护递增 token)。
    • 把返回的 fencing token(如当前 revision 或自增 counter) 附到业务后端调用中。
    • 后端在执行关键操作前,检查 token 是否为最新(或把 token 写入数据库作为操作的一部分)。
  • 故障场景:即使 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节点,且网络延迟极低。

最后,分布式锁的实现应根据具体业务场景进行选择和优化,没有一种锁方案可以满足所有场景的需求。在实现分布式锁时,应充分考虑互斥性、安全性、可靠性和性能的平衡,确保系统在各种场景下都能稳定运行。

posted @ 2025-11-30 22:23  哈哈丶丶  阅读(9)  评论(0)    收藏  举报