杰瑞科技汇

Java Redis分布式锁如何实现与优化?

目录

  1. 什么是分布式锁?
  2. 为什么需要分布式锁?
  3. Redis 实现分布式锁的基本原理
  4. 简单实现(不推荐)
    • 代码示例
    • 严重缺陷: 不具备锁的可靠性
  5. Redis 官方推荐的 Redlock 算法
    • 算法流程
    • 严重缺陷: 实现复杂,性能开销大,争议多
  6. Redisson(业界最佳实践)
    • 什么是 Redisson?
    • Redisson 如何实现分布式锁?
      • 可重入性
      • 锁续期(Watchdog 机制)
      • 锁的释放
    • 代码示例
  7. 总结与对比
  8. 最佳实践建议

什么是分布式锁?

在单机应用中,我们可以使用 synchronizedReentrantLock 等锁机制来保证共享资源在同一时间只被一个线程访问,但在分布式系统中,多个服务实例部署在不同的机器上,它们拥有各自的 JVM,这些 JVM 内置的锁机制无法跨进程、跨机器生效。

Java Redis分布式锁如何实现与优化?-图1
(图片来源网络,侵删)

分布式锁就是一种用来控制分布式系统之间访问共享资源的机制,它能够保证在分布式场景下的多个节点,对于同一个共享资源,在同一时间只有一个节点能访问它。

为什么需要分布式锁?

当多个服务实例需要同时修改一个共享资源时(库存扣减、订单创建、秒杀活动),如果不加锁,就会发生并发问题,导致数据不一致。

场景示例: 一个秒杀活动,库存只剩 1 件,两个用户几乎同时下单。

  • 服务 A 查询库存,发现为 1。
  • 服务 B 也查询库存,发现为 1。
  • 服务 A 扣减库存,库存变为 0。
  • 服务 B 也执行扣减库存,库存变为 -1。 结果就是库存出现负数,超卖了。

使用分布式锁,可以确保在服务 A 完成扣减库存并释放锁之前,服务 B 无法获取到锁,从而避免了超卖。

Java Redis分布式锁如何实现与优化?-图2
(图片来源网络,侵删)

Redis 实现分布式锁的基本原理

利用 Redis 的 SET 命令,并结合一些其他命令和选项,可以实现一个基础的分布式锁。

核心思想是:

  1. 获取锁: 尝试使用 SET 命令向一个 key(lock:product:123)设置一个唯一值(UUID),如果设置成功,则表示获取锁成功。
  2. 释放锁: 执行 DEL 命令删除这个 key,表示释放锁。

但仅仅这样做是远远不够的,必须考虑各种异常情况。

方案一:简单实现(不推荐)

这是最 naive 的实现方式,能理解基本逻辑,但绝对不能用于生产环境

Java Redis分布式锁如何实现与优化?-图3
(图片来源网络,侵删)

代码示例

import redis.clients.jedis.Jedis;
public class SimpleRedisLock {
    private Jedis jedis;
    private String lockKey;
    private String requestId; // 请求的唯一标识
    public SimpleRedisLock(Jedis jedis, String lockKey, String requestId) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.requestId = requestId;
    }
    // 获取锁
    public boolean tryLock() {
        // SET key value NX PX 30000
        // NX: Not eXist, 只有当 key 不存在时才设置
        // PX: 毫秒级过期时间
        String result = jedis.set(lockKey, requestId, "NX", "PX", 30000);
        return "OK".equals(result);
    }
    // 释放锁
    public void unlock() {
        // 使用 Lua 脚本来保证原子性
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(luaScript, 1, lockKey, requestId);
    }
}

严重缺陷:不具备锁的可靠性

  1. 锁的误释放:

    • 问题: 如果客户端 A 获取了锁,但在执行业务逻辑时,因为某些原因(如 GC 停顿、网络延迟)导致锁超时自动释放了,客户端 B 获取到了锁,随后,客户端 A 的业务逻辑执行完毕,调用 unlock() 方法,由于 unlock() 只判断了 key 的 value 是否和自己当初设置的一样,而客户端 A 的 value 仍然存在,所以它会误删除客户端 B 的锁。
    • 解决方案: 释放锁时,必须验证 value 是否为自己设置的值,这需要原子操作,上面的 unlock() 方法使用了 Lua 脚本来保证 getdel 的原子性。
  2. 锁续期问题:

    • 问题: 一个业务逻辑的执行时间可能超过了锁的默认过期时间(30 秒),如果业务还没执行完,锁就自动过期了,其他客户端就可以获取到锁,导致并发问题。
    • 解决方案: 需要一个机制在业务执行期间,自动续期锁的过期时间,启动一个后台线程,每隔 10 秒就去检查一下,如果当前线程还持有锁,就重置过期时间为 30 秒,这就是所谓的 Watchdog 机制
  3. 单点故障问题:

    • 问题: Redis 实例发生故障(主从切换、宕机),可能会导致锁服务不可用,或者锁在切换过程中丢失。
    • 解决方案: 使用 Redis 集群来提高可用性,但这也引出了更复杂的 Redlock 算法

方案二:Redis 官方推荐的 Redlock 算法

为了解决单点故障问题,Redis 官方提出了 Redlock 算法,它不再依赖单个 Redis 节点,而是使用多个独立的 Redis 节点(5 个)。

算法流程

  1. 获取当前时间戳(start_time)。
  2. 使用相同的 key 和 value,依次向这 5 个 Redis 节点尝试获取锁,获取锁的方式和之前一样(SET key value NX PX 30000)。
  3. 客户端计算获取锁所花费的时间(time_elapsed = current_time - start_time)。
  4. 只有当满足以下两个条件时,才认为锁获取成功:
    • 客户端在大多数(N/2 + 1,这里是 3 个)节点上成功获取了锁。
    • 获取锁的总耗时 time_elapsed 小于锁的过期时间。
  5. 如果锁获取成功,锁的有效时间是原始过期时间减去 time_elapsed
  6. 如果获取锁失败,客户端会向所有节点发送释放锁的命令。

严重缺陷:实现复杂,争议多

Redlock 算法虽然解决了单点问题,但带来了新的复杂性:

  • 性能开销大: 需要和多个节点通信,网络延迟较高。
  • 实现复杂: 代码逻辑繁琐,容易出错。
  • 理论争议: 很多分布式系统专家(如 Martin Kleppmann)对 Redlock 的必要性提出了质疑,认为在大多数场景下,一个高可用的 Redis 集群(使用主从复制)已经足够,Redlock 带来的额外复杂性可能不值得。

除非你的业务场景对锁的极端容错性有非常高的要求,否则一般不推荐自己实现 Redlock。

方案三:Redisson(业界最佳实践)

Redisson 是一个在 Java 生态中非常流行的 Redis 客户端,它不仅提供了丰富的 Redis 数据结构的操作,更重要的是,它封装好了分布式锁的实现,解决了我们前面提到的所有问题,是生产环境中的首选。

什么是 Redisson?

Redisson 是一个基于 Redis 的 Java 驻内存数据网格(In-Memory Data Grid),它极大地简化了 Redis 在 Java 项目中的使用,特别是分布式对象和服务(如分布式锁、分布式集合、分布式服务)。

Redisson 如何实现分布式锁?

Redisson 的分布式锁 RLock 是基于 Redis 的 hash 数据结构实现的,并且完美地解决了我们之前讨论的缺陷。

  1. 可重入性

    • 问题: 同一个线程可以多次获取同一个锁,如果不可重入,会导致自己死锁。
    • 解决方案: Redisson 在 key 对应的 hash 中,存储了 key -> (线程ID, 重入计数) 的映射,当同一个线程再次获取锁时,它会检查 hash 中是否已有该线程的记录,如果有,则增加计数,而不是重新设置 key。
  2. 锁续期(Watchdog 机制)

    • 问题: 业务执行时间超过锁的默认过期时间。
    • 解决方案: 当客户端通过 lock() 方法成功获取锁后,Redisson 会启动一个后台线程(Watchdog),这个线程会定期(例如每 10 秒)检查当前线程是否还持有锁,如果持有,就为这个 key 续期(重置过期时间为 30 秒),这个后台线程会一直运行,直到锁被手动释放或者客户端关闭。
  3. 锁的释放

    • 问题: 释放锁的原子性,以及只释放自己持有的锁。
    • 解决方案: Redisson 的 unlock() 方法同样通过执行 Lua 脚本来实现,确保只有锁的持有者才能释放锁,并且释放过程是原子的。

代码示例

pom.xml 中添加 Redisson 依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.4</version> <!-- 请使用最新版本 -->
</dependency>
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class ProductService {
    @Autowired
    private RedissonClient redissonClient;
    public void deductStock(String productId) {
        // 1. 定义锁的名称,通常与业务资源相关
        String lockKey = "lock:product:" + productId;
        // 2. 获取锁对象
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 3. 尝试获取锁
            // - 第一个参数:等待时间,最多等待 10 秒
            // - 第二个参数:锁的自动释放时间,30 秒后自动释放(Watchdog 会续期)
            // - 第三个参数:时间单位
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (locked) {
                System.out.println(Thread.currentThread().getName() + " 成功获取到锁,开始处理业务...");
                // --- 模拟业务逻辑 ---
                Thread.sleep(15000); // 假设业务逻辑需要 15 秒
                // --- 业务逻辑结束 ---
                System.out.println(Thread.currentThread().getName() + " 业务处理完成,准备释放锁。");
            } else {
                System.out.println(Thread.currentThread().getName() + " 获取锁失败,可能已被其他线程占用。");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            // 如果线程在等待锁时被中断,需要中断状态
            Thread.currentThread().interrupt();
        } finally {
            // 4. 确保锁一定会被释放
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + " 锁已释放。");
            }
        }
    }
}

总结与对比

特性 简单实现 Redlock 算法 Redisson
实现复杂度 极高
可靠性
可重入性
锁续期 有 (Watchdog)
原子性释放 需手动实现 (Lua) 需手动实现 (Lua) 内置
容错性
适用场景 学习、演示 极端苛刻的业务场景 生产环境首选
推荐度 ⭐⭐ ⭐⭐⭐⭐⭐

最佳实践建议

  1. 首选 Redisson:在绝大多数 Java 项目中,直接使用 Redisson 是最简单、最可靠、最高效的选择,它为你处理了所有复杂的细节。
  2. 锁的粒度要小:锁的范围应该尽可能小,只锁定必要的代码段(通常是修改共享资源的那部分),不要将整个方法都用 synchronizedlock 包裹起来,以减少锁的持有时间。
  3. 设置合理的超时时间tryLock 的等待时间和锁的过期时间需要根据业务逻辑的执行时间来设定,太短可能导致业务未完成就释放,太长可能导致资源被长时间占用。
  4. finally 块中释放锁:这是必须遵守的原则,确保无论业务逻辑是否抛出异常,锁都能被正确释放,避免死锁。
  5. 避免长时间持有锁:在持有锁期间,不要执行耗时操作(如网络请求、复杂计算),这会严重影响系统性能和并发能力,可以将耗时操作放到锁外执行。
  6. 注意锁的 Key 设计:Key 应该具有唯一性,通常与业务资源 ID 相关,lock:order:1001,避免使用全局锁(如 lock:global)。
分享:
扫描分享到社交APP
上一篇
下一篇