基于Redisson实现分布式锁

基于Redisson实现分布式锁

一、 为什么需要分布式锁?

在单体应用时代,我们处理并发问题通常会使用 Java 提供的 synchronized 关键字或者 java.util.concurrent.locks 包下的 ReentrantLock 等。这些锁机制在单个 JVM 进程内是可靠且高效的。

然而,随着微服务架构和分布式系统的普及,我们的应用会被部署到多个独立的服务器实例上。这时,传统的 JVM 内部锁就失去了作用,因为它只能保证单个进程内的线程安全,无法协调不同服务器实例上的进程。

想象一个场景:电商系统中的扣减库存操作。在高并发下,多个服务实例可能同时收到扣减同一商品库存的请求。如果没有一个跨实例的锁机制,很可能导致库存被超卖(数据不一致)。

这时,分布式锁就应运而生了。它是一种跨 JVM、跨机器的锁机制,用于控制分布式系统中多个进程对共享资源的访问,确保在任意时刻,只有一个客户端(服务实例)能够持有锁并访问共享资源。

二、 什么是分布式锁?

简单来说,分布式锁需要满足以下几个核心特性:

  1. 互斥性 (Mutual Exclusion): 在任何时刻,只有一个客户端能持有锁。这是最基本的要求。
  2. 不会死锁 (Deadlock Free): 即使持有锁的客户端崩溃或发生网络分区,锁最终也能被释放,其他客户端可以继续获取锁。通常通过超时机制实现。
  3. 容错性 (Fault Tolerance): 只要大部分锁服务节点(如 Redis 集群、ZooKeeper 集群)可用,客户端就能正常获取和释放锁。
  4. (可选) 可重入性 (Reentrancy): 同一个线程(或客户端标识)可以多次获取同一个锁而不会被自己阻塞。

三、 分布式锁的使用场景

分布式锁主要用于解决分布式环境下的并发控制问题,常见场景包括:

  1. 防止数据不一致: 如前面提到的库存扣减账户余额修改等,确保关键操作的原子性。
  2. 避免重复操作:防止用户重复提交订单防止消息重复消费(虽然消息队列通常有幂等性保证,但锁可以作为补充)。
  3. 任务调度协调: 在分布式定时任务场景下,确保同一个任务在同一时间只被一个服务实例执行。例如,使用 ShedLock 等库。
  4. 资源同步: 多个服务实例需要顺序访问某个共享资源。

四、 常见的分布式锁实现方式

实现分布式锁有多种方案,各有优劣:

  1. 基于数据库:
    • 原理: 利用数据库的唯一约束(如主键、唯一索引)或行级锁(如 SELECT ... FOR UPDATE)。
    • 优点: 实现简单,无需引入额外组件。
    • 缺点: 性能开销大,依赖数据库稳定性,锁粒度控制不灵活,容易产生死锁(需要处理)。
  2. 基于 ZooKeeper:
    • 原理: 利用 ZooKeeper 的临时有序节点特性。客户端尝试创建节点,创建成功的持有锁;节点会话断开或客户端主动删除节点即释放锁。
    • 优点: 天然支持容错、顺序性、Watch 机制(可实现锁等待通知),不易死锁。
    • 缺点: 性能相较于 Redis 较低,需要维护 ZooKeeper 集群,实现相对复杂。
  3. 基于 Redis:
    • 原理: 利用 Redis 的 SETNX (SET if Not eXists) 指令或更原子化的 Lua 脚本。通过设置一个带超时时间的 key 来表示锁。
    • 优点: 性能高,实现相对灵活,社区活跃,有成熟的客户端库(如 Redisson)。
    • 缺点: 需要考虑 Redis 的主从同步延迟、哨兵/集群模式下的锁失效问题(Redlock 算法尝试解决,但也有争议),锁超时设置需要谨慎。

选择考量: 在 Spring Cloud 生态中,由于 Redis 的广泛应用和高性能特性,基于 Redis 的分布式锁方案(特别是使用 Redisson 客户端) 是非常流行且推荐的选择。

五、Redisson 实现分布式锁

Spring Cloud 本身并没有提供一个内建的分布式锁抽象,但它与 Spring Boot 一脉相承,可以方便地集成各种第三方实现。这里我们重点学习使用 Redisson

Redisson 是一个功能强大且易于使用的 Redis Java 客户端,它不仅提供了标准的 Redis 命令操作,还封装了许多分布式的 Java 对象和服务,其中就包括非常完善的分布式锁实现 (RLock)。

5.1 学习资料

  • Redisson 官方文档: https://github.com/redisson/redisson/wiki (尤其是 "8. Distributed locks and synchronizers" 章节)
  • Spring Boot Data Redis: 虽然不直接用它实现锁,但理解 Spring Boot 如何集成 Redis 很重要。

5.2 代码示例

假设我们有一个简单的商品服务,需要实现一个线程安全的库存扣减接口。

Step 1: 添加依赖 (Maven)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.17.7</version> 
</dependency>

Step 2: 配置 Redisson (application.yml)

配置连接到你的 Redis 服务器(单机、哨兵或集群)。


spring:
  redis:
    # 如果使用 Spring Boot 的 RedisTemplate,可以在这里配置
    # host: localhost
    # port: 6379
    # database: 0
    # password: your_password
    # 注意:以下 redisson 配置会覆盖 spring.redis 的部分配置用于 RedissonClient
redisson:
  # 可以是 "singleServerConfig", "sentinelServersConfig", "clusterServersConfig", "replicatedServersConfig"
  config: |
    # 示例:单机模式
    singleServerConfig:
      address: "redis://127.0.0.1:6379"
      database: 0
      # password: your_password
      # 其他配置项,如连接池大小等
      # connectionPoolSize: 64
      # connectionMinimumIdleSize: 10
    # 示例:哨兵模式
    # sentinelServersConfig:
    #   masterName: "mymaster"
    #   sentinelAddresses:
    #     - "redis://127.0.0.1:26379"
    #     - "redis://127.0.0.1:26380"
    #   database: 0
    #   password: your_password
    # 示例:集群模式
    # clusterServersConfig:
    #   nodeAddresses:
    #     - "redis://127.0.0.1:7000"
    #     - "redis://127.0.0.1:7001"
    #     - "redis://127.0.0.1:7002"
    #   password: your_password
  # Redisson 的配置文件路径,也可以用外部文件
  # file: classpath:redisson.yaml
  

Step 3: 创建服务类并使用 Redisson 锁

package com.example.distributedlockdemo.service;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Service
public class InventoryService {

  private static final Logger log = LoggerFactory.getLogger(InventoryService.class);

  // 模拟数据库中的库存
  private static final Map<String, Integer> inventory = new HashMap<>();
  static {
      inventory.put("product_sku_123", 10); // 初始库存 10 个
  }

  @Autowired
  private RedissonClient redissonClient; // Redisson 客户端自动注入

  /**
   * 扣减库存方法
   * @param sku 商品 SKU
   * @param quantity 扣减数量
   * @return 是否扣减成功
   */
  public boolean deductStock(String sku, int quantity) {
      // 锁的 key,通常用业务相关的唯一标识符
      String lockKey = "lock:inventory:" + sku;
      RLock lock = redissonClient.getLock(lockKey); // 获取 RLock 对象

      boolean acquiredLock = false;
      try {
          // 尝试获取锁
          // tryLock(long waitTime, long leaseTime, TimeUnit unit)
          // waitTime: 最多等待获取锁的时间
          // leaseTime: 锁的持有时间(超时释放时间)。到期后锁会自动释放。
          //            Redisson 默认有 Watchdog 机制,如果 leaseTime=-1 或不设置,
          //            会在持有锁期间自动续期,防止业务没执行完锁就过期。
          //            但如果设置了具体的 leaseTime,则 Watchdog 不会生效。
          acquiredLock = lock.tryLock(10, 30, TimeUnit.SECONDS); // 等待 10 秒,持有 30 秒

          if (acquiredLock) {
              log.info("线程 {} 获取锁成功: {}", Thread.currentThread().getName(), lockKey);

              // --- 临界区:执行业务逻辑 ---
              Integer currentStock = inventory.get(sku);
              if (currentStock != null && currentStock >= quantity) {
                  // 模拟数据库操作耗时
                  try {
                      Thread.sleep(50); // 模拟耗时
                  } catch (InterruptedException e) {
                      Thread.currentThread().interrupt();
                      log.error("线程被中断", e);
                      return false;
                  }

                  inventory.put(sku, currentStock - quantity);
                  log.info("线程 {} 扣减库存成功,商品: {}, 数量: {}, 剩余库存: {}",
                          Thread.currentThread().getName(), sku, quantity, inventory.get(sku));
                  return true; // 扣减成功
              } else {
                  log.warn("线程 {} 库存不足或商品不存在,商品: {}, 请求数量: {}, 当前库存: {}",
                          Thread.currentThread().getName(), sku, quantity, currentStock);
                  return false; // 库存不足
              }
              // --- 临界区结束 ---

          } else {
              log.warn("线程 {} 获取锁失败: {}", Thread.currentThread().getName(), lockKey);
              return false; // 未获取到锁,直接返回失败或进行其他处理(如重试)
          }
      } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
          log.error("线程 {} 获取锁时被中断: {}", Thread.currentThread().getName(), lockKey, e);
          return false;
      } finally {
          // **极其重要**: 必须在 finally 块中释放锁,且只有持有锁的线程才能释放
          if (acquiredLock && lock.isHeldByCurrentThread()) {
              lock.unlock();
              log.info("线程 {} 释放锁: {}", Thread.currentThread().getName(), lockKey);
          }
      }
  }

  // 获取当前库存(用于测试查看)
  public Integer getStock(String sku) {
      return inventory.getOrDefault(sku, 0);
  }
}



Step 4: 创建 Controller 调用 Service

package com.example.distributedlockdemo.controller;

import com.example.distributedlockdemo.service.InventoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class InventoryController {

    @Autowired
    private InventoryService inventoryService;

    @PostMapping("/inventory/deduct/{sku}")
    public String deductStock(@PathVariable String sku, @RequestParam(defaultValue = "1") int quantity) {
        boolean success = inventoryService.deductStock(sku, quantity);
        if (success) {
            return "库存扣减成功! SKU: " + sku + ", 数量: " + quantity + ", 剩余: " + inventoryService.getStock(sku);
        } else {
            return "库存扣减失败(可能库存不足或未能获取锁)。 SKU: " + sku + ", 剩余: " + inventoryService.getStock(sku);
        }
    }

    @GetMapping("/inventory/{sku}")
    public String getStock(@PathVariable String sku) {
        return "当前库存,SKU: " + sku + ", 剩余: " + inventoryService.getStock(sku);
    }
}


Step 5: 测试

启动应用,然后可以使用 curl 或 Postman 等工具并发调用 /inventory/deduct/product_sku_123?quantity=1 接口。观察日志输出,你会看到只有一个线程能够成功获取锁并扣减库存,其他线程会等待或获取锁失败,最终库存数量会是正确的,不会出现超卖。

5.3 Redisson 分布式锁的关键点

  • RLock 接口: Redisson 提供的锁接口,功能丰富。
  • getLock(String name): 获取一个 RLock 对象,name 是全局唯一的锁标识。
  • lock(): 阻塞式获取锁,直到成功。如果遇到死锁,会抛出异常。具备 Watchdog 机制。
  • tryLock(): 非阻塞式尝试获取锁,立即返回 true (成功) 或 false (失败)。
  • tryLock(long waitTime, TimeUnit unit): 在指定时间内尝试获取锁,具备 Watchdog 机制。
  • tryLock(long waitTime, long leaseTime, TimeUnit unit): 在指定时间内尝试获取锁,并指定锁的持有时间。注意:指定 leaseTime 后,Watchdog 机制默认会失效。 如果业务执行时间可能超过 leaseTime,需要谨慎处理,或者不设置 leaseTime 让 Watchdog 工作。
  • unlock(): 释放锁。必须由持有锁的线程调用,并且务必放在 finally 块中执行。
  • 可重入性: Redisson 的 RLock 默认是可重入的。
  • 公平锁/非公平锁: getFairLock(String name) 可以获取公平锁,先到先得;getLock(String name) 默认获取非公平锁,性能更高,但可能导致饥饿。
  • Watchdog (看门狗机制): 这是 Redisson 的一个亮点。当使用 lock() 或未指定 leaseTime 的 tryLock() 获取锁成功后,Redisson 会启动一个后台线程(Watchdog),默认每隔 lockWatchdogTimeout / 3 (默认 lockWatchdogTimeout 是 30 秒) 检查持有锁的客户端是否还存活,如果存活,则自动延长锁的过期时间。这极大地避免了因客户端宕机导致锁无法释放的问题。如果业务执行时间很长,Watchdog 会持续续期,直到业务完成并手动 unlock()。

深入思考与注意事项

  1. 锁的粒度: 锁的 key 设计很关键。粒度太粗(如锁整个表)会严重影响并发性能;粒度太细(如锁每一行数据)可能会增加锁管理的复杂度。lock:inventory:product_sku_123 是一个比较合适的粒度。
  2. 锁超时时间 (leaseTime): 如果不使用 Watchdog(即指定了 leaseTime),需要合理评估业务执行时间,设置一个安全的超时时间,既要避免业务没执行完锁就过期,也要防止程序异常导致锁长时间不释放。
  3. 获取锁失败的处理: tryLock 返回 false 时,是直接返回错误、等待后重试,还是将请求放入队列?需要根据业务场景决定。
  4. Redis 集群/哨兵模式下的可靠性: 单个 Redis 实例存在单点故障风险。在哨兵或集群模式下,如果 Master 节点宕机且锁信息未同步到新的 Master,可能导致锁丢失。Redisson 提供了 RedLock 算法的实现 (RedissonRedLock),尝试通过在多个独立的 Redis 实例上获取锁来提高容错性,但 RedLock 本身也有一定争议和复杂性。对于大多数场景,依赖 Redis Sentinel 或 Cluster 自身的高可用机制配合 Redisson 的标准 RLock 通常足够。
  5. 性能: 频繁地获取和释放分布式锁是有网络开销和 Redis 处理开销的,应仅在必要的地方使用

七、 总结

分布式锁是构建可靠分布式系统不可或缺的一部分。通过本次学习:

  1. 我们理解了分布式锁的必要性和核心概念。
  2. 了解了常见的实现方式(数据库、ZooKeeper、Redis)及其优缺点。
  3. 重点实践了 Spring Cloud (Spring Boot) 集成 Redisson 实现基于 Redis 的分布式锁,包括依赖引入、配置、代码编写(RLock, tryLock, unlock, finally)。
  4. 认识到 Redisson 的Watchdog 机制对于提高锁的健壮性的重要作用。
  5. 思考了使用分布式锁时需要注意的关键点(锁粒度、超时、失败处理、可靠性)。
Comment