Redlock:多个Redis节点上的分布式锁及其争议

1. 概述

当一个服务的多个实例同时处理一个资源时,例如减少Order的库存,你需要一个分布式锁。在单个Redis节点上加锁很简单,但如果那个节点宕机,锁就消失了。Redlock是Redis的作者(antirez)提出的算法,用于在多个独立的Redis节点上获取锁。

本文介绍Redlock如何工作,如何用Jedis自己实现它,如何用Testcontainers编写测试。最后分析Martin Kleppmann和antirez之间的争议。读完后,你将了解何时Redlock足够用,何时应该选择其他工具。

2. 为什么单个Redis节点还不够

在单个节点上的基础锁只需要一条命令:

SET lock:order:42 "c3a1-uuid" NX PX 30000

NX意味着只有当key不存在时才set。PX 30000设置30秒的TTL,使得锁在客户端崩溃时自动过期。值是一个随机token,只有持有锁的客户端才能删除它。

问题在于可用性。如果你使用主从复制(master-replica),可能会出现以下情景:

  1. 客户端A在主节点上获取锁。
  2. 主节点在将key复制到从节点之前宕机(Redis复制是异步的)。
  3. 从节点被提升为新的主节点,上面没有锁key。
  4. 客户端B获得了相同的锁。

此时A和B同时持有锁。Redlock正是为了处理这种情景而诞生的。

3. Redlock算法

Redlock使用N个独立的Redis节点,这些节点之间没有复制关系和集群关系。通常N = 5。客户端按以下步骤获取锁:

  1. 记录开始时间。
  2. 依次向N个节点发送SET key token NX PX ttl。每个请求都有很短的超时时间(几十ms),以防止宕机节点导致客户端挂起。
  3. 计算已经过去的时间。当满足以下两个条件时,锁被认为获取成功:
    • 在至少N/2 + 1个节点上成功(法定人数),
    • 已经过去的时间小于TTL。
  4. 锁的实际有效时间等于ttl - elapsed - drift,其中drift补偿节点之间的时钟偏差。
  5. 如果失败,客户端向所有节点发送解锁命令,包括它认为没有成功获取的节点。

核心思想是两个客户端不能在同一时间同时控制多数节点。

4. 用Jedis实现Redlock

4.1. 依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>

4.2. Redlock类

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.params.SetParams;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class Redlock {

    private static final String UNLOCK_SCRIPT = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """;
    private static final double CLOCK_DRIFT_FACTOR = 0.01;

    private final List<JedisPool> nodes;
    private final int quorum;

    public Redlock(List<JedisPool> nodes) {
        this.nodes = nodes;
        this.quorum = nodes.size() / 2 + 1;
    }

    public Optional<LockHandle> tryLock(String resource, long ttlMillis) {
        String token = UUID.randomUUID().toString();
        long start = System.nanoTime();

        int acquired = 0;
        for (JedisPool pool : nodes) {
            if (lockInstance(pool, resource, token, ttlMillis)) {
                acquired++;
            }
        }

        long elapsed = (System.nanoTime() - start) / 1_000_000;
        long drift = (long) (ttlMillis * CLOCK_DRIFT_FACTOR) + 2;
        long validity = ttlMillis - elapsed - drift;

        if (acquired >= quorum && validity > 0) {
            return Optional.of(new LockHandle(resource, token, validity));
        }
        unlockAll(resource, token);
        return Optional.empty();
    }

    public void unlock(LockHandle handle) {
        unlockAll(handle.resource(), handle.token());
    }

    private boolean lockInstance(JedisPool pool, String resource, String token, long ttl) {
        try (Jedis jedis = pool.getResource()) {
            return "OK".equals(jedis.set(resource, token, SetParams.setParams().nx().px(ttl)));
        } catch (JedisException e) {
            return false; // 节点宕机或超时:视为未获取
        }
    }

    private void unlockAll(String resource, String token) {
        for (JedisPool pool : nodes) {
            try (Jedis jedis = pool.getResource()) {
                jedis.eval(UNLOCK_SCRIPT, List.of(resource), List.of(token));
            } catch (JedisException ignored) {
                // 节点未响应,key将按TTL自动过期
            }
        }
    }

    public record LockHandle(String resource, String token, long validityMillis) {}
}

关键要点:

  • quorum = N/2 + 1。对于3个节点,quorum是2。对于5个节点,quorum是3。
  • lockInstance捕获异常并返回false。一个节点宕机只会减少获票数,不会破坏整个操作。
  • validity是你被允许认为自己持有锁的时间。这个时间总是小于TTL。
  • 解锁使用Lua脚本使“比较token然后删除”操作以原子方式执行。如果你分别用GET和DEL两条命令,可能会误删其他客户端刚刚获取的锁。
  • 失败时,unlockAll向所有节点发送请求。原因是SET命令可能已在服务器端成功,但响应在客户端超时。

4.3. 用短超时初始化节点

import redis.clients.jedis.JedisPoolConfig;

List<JedisPool> pools = List.of(
        new JedisPool(new JedisPoolConfig(), "redis-1", 6379, 50),
        new JedisPool(new JedisPoolConfig(), "redis-2", 6379, 50),
        new JedisPool(new JedisPoolConfig(), "redis-3", 6379, 50));
Redlock redlock = new Redlock(pools);

最后一个参数是50ms的超时。如果你保留默认超时2秒,一个挂起的节点可能会“消耗”锁的大部分TTL。

4.4. 在InventoryService中使用

public class InventoryService {

    private final Redlock redlock;
    private final InventoryRepository repository;

    public InventoryService(Redlock redlock, InventoryRepository repository) {
        this.redlock = redlock;
        this.repository = repository;
    }

    public boolean reserve(String sku, int quantity) {
        var handle = redlock.tryLock("lock:sku:" + sku, 10_000);
        if (handle.isEmpty()) {
            return false; // 供调用者重试并带回退
        }
        try {
            int stock = repository.findStock(sku);
            if (stock < quantity) return false;
            repository.updateStock(sku, stock - quantity);
            return true;
        } finally {
            redlock.unlock(handle.get());
        }
    }
}

解锁始终在finally中。锁定区域内的代码必须在validityMillis内完成。如果操作可能耗时长,你需要锁续期机制(watchdog)或分解任务。

5. 用Testcontainers进行测试

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
@Testcontainers
class RedlockTest {

    @Container static GenericContainer<?> r1 = redis();
    @Container static GenericContainer<?> r2 = redis();
    @Container static GenericContainer<?> r3 = redis();

    static GenericContainer<?> redis() {
        return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    }

    static Redlock newClient() {
        return new Redlock(Stream.of(r1, r2, r3)
                .map(c -> new JedisPool(new JedisPoolConfig(),
                        c.getHost(), c.getMappedPort(6379), 200))
                .toList());
    }

    @Test
    void secondClientCannotAcquireWhileFirstHoldsLock() {
        Redlock clientA = newClient();
        Redlock clientB = newClient();

        var lockA = clientA.tryLock("lock:order:42", 5_000);
        var lockB = clientB.tryLock("lock:order:42", 5_000);

        assertThat(lockA).isPresent();
        assertThat(lockA.get().validityMillis()).isLessThan(5_000);
        assertThat(lockB).isEmpty();

        clientA.unlock(lockA.get());
        assertThat(clientB.tryLock("lock:order:42", 5_000)).isPresent();
    }

    @Test
    void stillAcquiresWhenOneNodeIsDown() {
        Redlock client = newClient();
        r3.stop();

        assertThat(client.tryLock("lock:order:99", 5_000)).isPresent();
    }
}

第一个测试检查互斥性:B在A持有锁时失败,在A解锁后立即成功。第二个测试显示Redlock在失去3个节点中的1个时仍然工作,因为剩下的2个节点足以形成法定人数。此测试停止了容器,所以你应该将其放在最后,或者分离到一个单独的类中。

6. 那Redisson呢?

实际上,很少有人自己编写Redlock。Redisson曾提供RedissonRedLock,可以接收来自独立客户端的多个RLock。

注意: 在最新的Redisson版本中,RedissonRedLock和RedissonMultiLock已被标记为deprecated。Redisson建议使用普通的RLock,它有自动续期的watchdog且可以复制到从节点,或者如果你需要fencing token,使用RFencedLock。在选择API之前,请检查你使用的版本。

连最流行的库都放弃了Redlock,这引领我们进入争议部分。

7. 争议:Kleppmann vs antirez

7.1. Martin Kleppmann的论证

《设计数据密集型应用》的作者Kleppmann指出,Redlock对于需要正确性的问题并不安全。主要有两个原因。

进程暂停。 看看以下情景:

t=0     客户端A获取锁(TTL 10秒)
t=1     A被GC停止世界(stop-the-world)15秒
t=10    锁在所有节点上过期
t=11    客户端B获取锁,写入数据到DB
t=16    A苏醒,仍然认为自己持有锁,覆盖B的数据

没有基于TTL的锁算法能阻止这种情景。在写入前检查validity也无济于事,因为pause可能发生在检查之后。导致暂停的原因不仅是GC。页面错误、虚拟机迁移或网络数据包延迟也会造成类似的效果。

时钟依赖。 Redlock假设各节点的时钟以大致相同的速度运行。如果某个节点的时钟发生跳跃,例如由于NTP激进调整或管理员手动修改时间,该节点上的key可能会提前过期。以5个节点为例的具体情景:

A在节点1、2、3上获取锁
节点3的时钟向前跳跃,节点3上的key过期
B在节点3、4、5上获取锁
=> A和B都控制了法定人数

Kleppmann的结论:要保证安全,你需要fencing token,即一个随锁一起递增的数字。Redlock不提供这个,因为它的token是随机字符串。

7.2. Fencing token长什么样

思想是存储本身拒绝带有过期token的写入:

public class FencedInventoryRepository {

    private final JdbcTemplate jdbc;

    public FencedInventoryRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean updateStock(String sku, int newStock, long fencingToken) {
        int rows = jdbc.update("""
                UPDATE inventory SET stock = ?, last_token = ?
                WHERE sku = ? AND last_token < ?
                """, newStock, fencingToken, sku, fencingToken);
        return rows == 1;
    }
}

应用到上面的GC情景:B用token 34写入,A苏醒后用token 33写入。A的UPDATE影响了0行,所以B的数据保持不变。关键是存储而不是锁才能保证正确性。Token必须来自有共识的系统,如ZooKeeper(zxid)或etcd(revision)。

7.3. antirez的回应

antirez用以下要点进行了回应:

  • 如果存储已经支持比较并设置(compare-and-set)的token检查,你可以使用随机token本身进行检查,此时锁几乎不再必要。
  • Redlock在获取锁之后有一个时间检查步骤,所以获取过程中的网络延迟会被检测到。在那之后发生的暂停确实是所有基于TTL的锁都会遭遇的问题。
  • 如果你操作得当,对时钟的假设是合理的:不让NTP突然跳跃(使用slew模式)且不手动调整时间。

迄今为止,还没有一方“绝对获胜”。不过,大多数社区都同意Kleppmann的分类方法,这将在下一部分介绍。

8. 何时使用,何时不使用

问问自己:如果两个客户端同时持有锁会怎样?

  • 效率锁(避免重复工作):重复发送报告邮件、重复运行cron任务、重复计算缓存。偶尔的重复无关紧要。这种情况下,一个带有SET NX PX的Redis节点就够了。Redlock消耗3-5倍的基础设施,而改进微乎其微。
  • 正确性锁(错误会破坏数据):转账、扣库存、发号发票。Redlock不够安全。你应该使用etcd或ZooKeeper配合fencing token,或者更好的做法是把约束推到数据库,使用乐观锁定(JPA中的@Version)、SELECT ... FOR UPDATE或唯一约束。

注意: 使用Redlock时常见的错误:

  • 用Redis Cluster或主从复制作为Redlock的“节点”。这些节点必须完全独立。
  • 保留过长的默认连接超时。
  • 用DEL而不是检查token的Lua脚本来解锁。
  • 启用持久化但不配置fsync=always,或者不延迟节点重启。一个节点重启并丢失key可能导致两个客户端都达到法定人数。antirez建议重启的节点等待至少最大TTL的时间后才重新参与。

9. 结论

Redlock在N个独立的Redis节点的多数上获取锁,并计算有效时间来补偿网络延迟和时钟偏差。你已经看到了如何用Jedis实现它,如何用Lua脚本安全地解锁,以及如何用Testcontainers进行测试。

Redlock仍然依赖TTL和时钟,所以无法防止进程暂停或时钟跳跃。因此,对于仅防止重复工作的锁,一个Redis节点就足够了。对于保护数据正确性的锁,你需要来自共识系统的fencing token,或者让数据库自己维持约束。

redisredlockdistributed-lockjavaconcurrency