分布式锁:Redis 与 Redisson 的工程实践与避坑指南

在单机架构中,我们通常使用 synchronized 或 ReentrantLock 来控制多线程对共享资源的并发访问。然而,随着业务的演进,系统往往会不可避免地走向分布式架构。当多个服务实例同时尝试修改同一个共享资源(例如扣减库存、处理账户余额)时,单机的本地锁便失效了。此时,我们需要引入分布式锁来保证跨进程的数据一致性。

在诸多分布式锁的方案中,基于 Redis 的实现因其高性能和易用性,成为了许多开发者的首选。本文将从 Redis 分布式锁的基础实现原理出发,深入探讨在实际工程中可能遇到的陷阱,并介绍 Redisson 是如何优雅地解决这些问题的。


1 Redis 分布式锁的基础实现

使用 Redis 实现分布式锁,其核心思想是利用 Redis 的单线程处理机制,通过特定的命令来尝试获取锁。如果获取成功,则执行业务逻辑;执行完毕后,释放锁。

1.1 SETNX 命令的引入

最直观的方法是使用 SETNX (Set if Not eXists)命令。该命令的作用是:如果键不存在,则设置键值并返回 1;如果键已存在,则不做任何操作并返回 0。

我们可以将锁的标识作为键名,例如 lock_key。多个进程并发调用 SETNX lock_key some_value 时,只有一个进程能返回 1,即成功获取锁。

java 复制代码
// 伪代码示例
public void doBusiness() {
    // 尝试获取锁
    Long result = redisTemplate.execute((RedisCallback<Long>) connection -> 
        connection.setNX("lock_key".getBytes(), "value".getBytes())
    );

    if (result != null && result == 1L) {
        try {
            // 获取锁成功,执行业务逻辑
            System.out.println("成功获取分布式锁,开始处理业务...");
        } finally {
            // 业务执行完毕,释放锁
            redisTemplate.delete("lock_key");
        }
    } else {
        // 获取锁失败,可以选择重试或直接返回
        System.out.println("获取分布式锁失败");
    }
}

上面的代码展示了最基本的锁获取与释放流程。但是,这只是一个粗糙的雏形,在复杂的分布式环境中,它充满了隐患。


2 基础方案的致命陷阱

看似简单的 SETNX 和 delete 组合,实际上隐藏着几个严重的问题,稍有不慎就会导致系统雪崩或数据错乱。

2.1 死锁危机:进程崩溃导致锁无法释放

如果一个进程通过 SETNX 成功获取了锁,但在执行业务逻辑的过程中突然崩溃(例如 OOM 导致进程被 kill,或是宿主机宕机),此时 finally 块中的删除命令将永远无法执行。这就意味着 lock_key 会一直存在于 Redis 中,其他所有尝试获取该锁的进程都将被永久阻塞,形成了典型的死锁。

为了解决这个问题,我们必须为锁设置一个过期时间(TTL)。即使进程崩溃,锁也会在过期后自动释放。

2.2 原子性破坏:设置锁与设置过期时间的割裂

在 Redis 早期版本中,我们通常会使用 SETNX 和 EXPIRE 两个命令组合来完成上锁并设置过期时间:

java 复制代码
// 错误示例:非原子操作
Long result = redisTemplate.execute((RedisCallback<Long>) connection -> 
    connection.setNX("lock_key".getBytes(), "value".getBytes())
);

if (result != null && result == 1L) {
    // 如果在这里发生异常或进程崩溃,EXPIRE 将无法执行
    redisTemplate.expire("lock_key", 10, TimeUnit.SECONDS);
    // ...
}

由于 SETNX 和 EXPIRE 是两条独立的命令,它们之间不具备原子性。如果 SETNX 刚执行成功,客户端就崩溃了,那么 EXPIRE 命令就无法发送给 Redis 服务器,死锁问题依然存在。

为了保证操作的原子性,我们需要使用 Redis 2.6.12 之后提供的强大的 SET 命令扩展。该命令允许我们将 NX(不存在则设置)和 EX(设置过期时间)组合在一起执行。

2.3 误删他人锁:锁被提前释放引发的连锁反应

假设我们解决了原子性问题,使用 SET key value EX 10 NX 命令成功获取了锁,并设置了 10 秒的过期时间。但新的问题又来了:

  1. 线程 A 获取锁成功(过期时间 10 秒)。
  2. 线程 A 的业务逻辑非常复杂,执行了 15 秒。
  3. 在第 10 秒时,线程 A 的锁因为超时被 Redis 自动删除了。
  4. 此时,线程 B 尝试获取锁,因为锁已过期,所以线程 B 获取成功。
  5. 到了第 15 秒,线程 A 执行完业务逻辑,执行了 finally 块中的 delete 操作。
  6. 糟糕的事情发生了:线程 A 把线程 B 刚刚获取的锁给删除了!

如果此时线程 C 进来,它也能成功获取锁。这就导致多线程并发访问共享资源,分布式锁完全失效。

解决误删问题的关键在于:在释放锁的时候,必须判断这把锁是不是自己加的。我们可以在加锁时,将 value 设置为一个唯一的标识(例如 UUID 加上当前线程 ID),在删除时先校验 value,一致才删除。

并且,这个校验和删除的过程也必须是原子的,这通常需要借助 Lua 脚本来实现。

2.4 锁提前释放:业务未完结而锁已过期的窘境

尽管我们通过唯一标识防止了误删他人的锁,但并没有解决上个问题中的根本矛盾:线程 A 的业务还没执行完,锁就已经过期了。

这就意味着,在第 10 秒到第 15 秒这 5 秒的时间窗口内,线程 A 和线程 B 是在并行执行的。这不仅违反了分布式锁的初衷,还可能导致严重的数据不一致。

要解决这个问题,我们需要一种机制:如果业务执行时间超过了锁的过期时间,必须自动延长锁的生命周期。这种机制通常被称为 "看门狗"(Watchdog)。


3 Redisson:企业级分布式锁的优雅解法

自己手写分布式锁不仅容易出错,而且维护成本高。在 Java 领域,Redisson 是一个强大的企业级 Redis 客户端,它为我们封装了众多高级特性,其中就包括极其健壮的分布式锁实现。

Redisson 完美地解决了前文提到的所有痛点。

3.1 Redisson 的核心机制

当我们使用 Redisson 获取锁时,它底层执行了一段复杂的 Lua 脚本,保证了加锁操作的原子性。更重要的是,它内置了强大的看门狗机制。

java 复制代码
// Redisson 使用示例
@Autowired
private RedissonClient redissonClient;

public void doBusinessSafe() {
    // 获取一把名为 "my_resource_lock" 的锁
    RLock lock = redissonClient.getLock("my_resource_lock");

    try {
        // 尝试获取锁,最多等待 10 秒,上锁以后 30 秒自动解锁
        // 这里如果不指定自动解锁时间(即不传 leaseTime),Redisson 才会启用看门狗机制
        boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
        
        if (isLocked) {
            System.out.println("线程 " + Thread.currentThread().getId() + " 获取锁成功");
            // 模拟耗时的业务逻辑
            Thread.sleep(40000); 
        } else {
            System.out.println("线程 " + Thread.currentThread().getId() + " 获取锁失败");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        System.err.println("获取锁被中断");
    } finally {
        // 安全地释放锁
        // isHeldByCurrentThread 判断锁是否由当前线程持有,防止误删
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
            System.out.println("线程 " + Thread.currentThread().getId() + " 释放了锁");
        }
    }
}

在这段代码中,最核心的一点是:如果你在使用 tryLock 或 lock 方法时,没有显式指定 leaseTime(锁的持有时间),Redisson 就会默认赋予该锁一个 30 秒的过期时间,并启动看门狗。

3.2 深入理解看门狗

看门狗实际上是一个后台定时任务。当一个线程成功获取锁并启动了看门狗后,看门狗会定期(默认是锁过期时间的 1/3,即 10 秒)去检查该锁的状态。

如果发现该锁仍然被当前线程持有,看门狗就会向 Redis 发送一条指令,将这把锁的过期时间重新重置为 30 秒。这个过程被称为 "锁续期"。

通过这种机制,只要持有锁的线程还在运行,锁就不会因为超时而自动释放。这完美地解决了业务执行时间不可预估导致的锁提前释放问题。

同时,如果持有锁的进程崩溃了,看门狗线程也会随之消亡,续期操作停止。那么在经过 30 秒的默认过期时间后,Redis 仍然会自动释放这把锁,避免了死锁的发生。


4 总结

分布式锁是微服务架构中保障数据一致性的重要防线。基于 Redis 的实现虽然简单高效,但若不深入理解其背后的机制,很容易写出充满漏洞的代码。

从最初的 SETNX 到原子性组合命令,再到 Lua 脚本防止误删,最后到看门狗机制解决超时释放,这是一个不断填坑的过程。在实际的工程实践中,强烈建议直接使用 Redisson 这样成熟的框架,而不是试图从零开始造轮子。这不仅能大大降低开发风险,还能让我们将更多的精力集中在核心业务逻辑的实现上。

相关推荐
掉鱼的猫1 小时前
agentscope-harness vs solon-ai-harness:Java 智能体「马具引擎」的双雄对决
java·openai
RainCity1 小时前
Java Swing 自定义组件库分享(四)
java·笔记·后端
Kiyra1 小时前
Query Rewrite 不是越智能越好:RAG 检索的精确词保护与动态召回
redis·websocket·junit·单元测试·json
带刺的坐椅1 小时前
agentscope-harness vs solon-ai-harness:Java 智能体「马具引擎」的双雄对决
java·ai·llm·solon·agentscope·harness
Seven971 小时前
Paxos算法:如何解决分布式系统中的共识问题?
java
铁皮哥2 小时前
【力扣题解】LeetCode 25. K 个一组翻转链表
java·数据结构·windows·python·算法·leetcode·链表
小新同学^O^2 小时前
简单学习 --> 单例模式
java·学习·多线程
Henray20242 小时前
LRU缓存设计与实现
java·面试
无盐海2 小时前
Foundatio,内存,Redis 缓存
数据库·redis·缓存