实用指南:Spring Boot 实战 Redis 分布式锁:从原理到高并发落地

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

在单体应用中,我们用 synchronizedReentrantLock 就能解决并发问题(比如库存扣减、订单幂等创建)。但当应用部署在多台服务器(分布式架构)时,本地锁只能控制单台机器的线程,无法跨服务同步,会导致超卖、重复操作等严重问题。

比如库存扣减场景,3台服务器同时处理请求,本地锁失效,最终库存变成负数

Redis 分布式锁凭借高性能、高可用、易实现的特点,成为分布式场景下的主流选择,核心优势:

  • 基于内存操作,响应速度快(毫秒级)
  • 支持分布式部署,可横向扩展
  • 自带过期机制,避免死锁

二、Redis 分布式锁核心原理

2.1 核心命令:SET NX EX

Redis 分布式锁的核心是通过 原子命令 保证“加锁”操作的唯一性,推荐使用 SET 命令的扩展参数:

SET lock_key unique_value NX EX 30

各参数含义:

  • lock_key:锁的唯一标识(如 stock:lock:1001,1001 为商品ID)
  • unique_value:锁的唯一值(必须全局唯一,用于释放锁时判断“是否是自己的锁”,避免误删)
  • NX(Not Exist):仅当 lock_key 不存在时才创建,保证同一时间只有一个线程加锁
  • EX(Expire):设置锁的过期时间(如 30 秒),避免业务异常导致锁无法释放(死锁)

2.2 关键注意点

  1. 原子性:加锁必须是“判断不存在 + 设置值 + 设过期”三步合一的原子操作,否则会出现并发安全问题(比如先判断再设置,中间被其他线程插队)。
  2. 唯一标识:释放锁时必须先判断“当前锁的 value 是否是自己加锁时的 value”,再删除,这两步也需要原子性(用 Lua 脚本实现)。
  3. 过期时间:过期时间要大于业务执行时间,避免业务没跑完锁就过期,导致并发问题;若业务时间不确定,需引入“看门狗”机制自动续期。

三、Spring Boot 实战 Redis 分布式锁

3.1 环境准备

1. 引入依赖

创建 Spring Boot 项目(推荐 2.7.x 版本),在 pom.xml 中添加 Redis 依赖:

<!-- Spring Data Redis -->
  <dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <!-- Commons Pool2(Lettuce 连接池) -->
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    </dependency>
    <!-- Lombok(简化代码) -->
      <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
      </dependency>
2. 配置 Redis

application.yml 中配置 Redis 连接信息(Lettuce 是 Spring Boot 默认客户端,性能优于 Jedis):

spring:
redis:
# Redis 地址
host: localhost
# 端口
port: 6379
# 密码(若未设置则省略)
password: 123456
# 数据库索引(默认 0)
database: 0
# 连接超时时间
timeout: 3000ms
# Lettuce 连接池配置
lettuce:
pool:
# 最大活跃连接数
max-active: 8
# 最大空闲连接数
max-idle: 8
# 最小空闲连接数
min-idle: 2
# 连接池最大阻塞等待时间
max-wait: -1ms
3. 配置 RedisTemplate

Spring Boot 自动配置的 RedisTemplate 默认用 JDK 序列化,可读性差,我们自定义配置,使用 JSON 序列化:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
  RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    // 字符串序列化器(key 用 String 序列化)
    StringRedisSerializer stringSerializer = new StringRedisSerializer();
    template.setKeySerializer(stringSerializer);
    template.setHashKeySerializer(stringSerializer);
    // JSON 序列化器(value 用 JSON 序列化)
    GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
    template.setValueSerializer(jsonSerializer);
    template.setHashValueSerializer(jsonSerializer);
    template.afterPropertiesSet();
    return template;
    }
    }

3.2 手写 Redis 分布式锁工具类

核心实现“加锁”“释放锁”“续期”三个核心方法,重点保证原子性:

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* Redis 分布式锁工具类(实现 Lock 接口,符合 Java 锁规范)
*/
@Component
@RequiredArgsConstructor
public class RedisDistributedLock implements Lock {
private final RedisTemplate<String, Object> redisTemplate;
  // 锁的默认过期时间(30秒)
  private static final long DEFAULT_EXPIRE = 30;
  // 锁的唯一标识前缀(UUID 保证全局唯一)
  private final ThreadLocal<String> lockValue = new ThreadLocal<>();
    /**
    * 加锁(阻塞式,直到获取锁成功)
    */
    @Override
    public void lock() {
    // 循环加锁,直到成功
    while (!tryLock()) {
    // 避免频繁自旋,让出 CPU
    try {
    TimeUnit.MILLISECONDS.sleep(50);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    }
    }
    /**
    * 尝试加锁(非阻塞式,获取成功返回 true,失败返回 false)
    * @return 是否加锁成功
    */
    @Override
    public boolean tryLock() {
    return tryLock(DEFAULT_EXPIRE, TimeUnit.SECONDS);
    }
    /**
    * 尝试加锁(带过期时间)
    * @param time 过期时间
    * @param unit 时间单位
    * @return 是否加锁成功
    */
    @Override
    public boolean tryLock(long time, TimeUnit unit) {
    // 1. 生成全局唯一的锁值(UUID + 线程ID,避免同一JVM内线程冲突)
    String value = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
    // 2. 转换过期时间为秒(Redis EX 命令单位是秒)
    long expireSeconds = unit.toSeconds(time);
    // 3. 执行 Redis SET NX EX 命令(原子操作)
    Boolean success = redisTemplate.opsForValue().setIfAbsent(
    getLockKey(),  // 锁的 key
    value,         // 锁的 value
    expireSeconds, // 过期时间
    TimeUnit.SECONDS
    );
    // 4. 加锁成功,保存锁值到 ThreadLocal
    if (Boolean.TRUE.equals(success)) {
    lockValue.set(value);
    return true;
    }
    return false;
    }
    /**
    * 释放锁(必须保证原子性:判断 value + 删除 key)
    */
    @Override
    public void unlock() {
    // 1. Lua 脚本:判断锁的 value 是否是当前线程的,若是则删除(原子操作)
    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "return redis.call('del', KEYS[1]) " +
    "else " +
    "return 0 " +
    "end";
    // 2. 执行 Lua 脚本
    DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
      Long result = redisTemplate.execute(
      script,
      Collections.singletonList(getLockKey()), // KEYS[1]:锁的 key
      lockValue.get()                          // ARGV[1]:当前线程的锁值
      );
      // 3. 释放 ThreadLocal 资源
      lockValue.remove();
      // 4. 若 result == 0,说明锁已过期或被其他线程占用,可打印警告
      if (result == 0) {
      System.err.println("释放锁失败:锁已过期或被其他线程占用");
      }
      }
      /**
      * 续期锁(看门狗机制核心方法,业务执行超时前调用)
      * @param expireSeconds 续期时间(秒)
      * @return 是否续期成功
      */
      public boolean renewLock(long expireSeconds) {
      // Lua 脚本:判断锁的 value 是当前线程的,才续期(原子操作)
      String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
      "return redis.call('expire', KEYS[1], ARGV[2]) " +
      "else " +
      "return 0 " +
      "end";
      DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
        Long result = redisTemplate.execute(
        script,
        Collections.singletonList(getLockKey()),
        lockValue.get(),
        String.valueOf(expireSeconds)
        );
        return result == 1;
        }
        // ------------------------------ 以下方法暂不实现(按需扩展) ------------------------------
        @Override
        public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("暂不支持可中断锁");
        }
        @Override
        public Condition newCondition() {
        throw new UnsupportedOperationException("暂不支持 Condition");
        }
        /**
        * 生成锁的 key(可根据业务自定义,比如加商品ID、用户ID)
        * 示例:stock:lock:1001(1001 为商品ID)
        */
        private String getLockKey() {
        // 实际项目中可通过参数传入,这里简化为固定值,需根据业务调整
        return "stock:lock:1001";
        }
        }

3.3 实战场景:库存扣减(高并发测试)

我们用“商品库存扣减”这个经典场景,测试分布式锁的有效性。

1. 库存服务实现
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
@RequiredArgsConstructor
public class StockService {
private final RedisTemplate<String, Object> redisTemplate;
  private final RedisDistributedLock redisLock;
  // 库存 key(商品ID:1001)
  private static final String STOCK_KEY = "stock:1001";
  // 初始库存(100件)
  private static final int INIT_STOCK = 100;
  /**
  * 初始化库存(项目启动时执行)
  */
  public void initStock() {
  redisTemplate.opsForValue().set(STOCK_KEY, INIT_STOCK);
  log.info("初始化库存成功,初始库存:{}", INIT_STOCK);
  }
  /**
  * 扣减库存(无锁版本,高并发下会超卖)
  */
  public boolean deductStockWithoutLock() {
  // 1. 获取当前库存
  Integer stock = (Integer) redisTemplate.opsForValue().get(STOCK_KEY);
  if (stock == null || stock <= 0) {
  log.error("库存不足,当前库存:{}", stock);
  return false;
  }
  // 2. 扣减库存(非原子操作,高并发下会超卖)
  int newStock = stock - 1;
  redisTemplate.opsForValue().set(STOCK_KEY, newStock);
  log.info("扣减库存成功,当前库存:{}", newStock);
  return true;
  }
  /**
  * 扣减库存(有锁版本,避免超卖)
  */
  public boolean deductStockWithLock() {
  try {
  // 1. 加锁(阻塞式,直到获取锁)
  redisLock.lock();
  // 2. 业务逻辑:扣减库存
  Integer stock = (Integer) redisTemplate.opsForValue().get(STOCK_KEY);
  if (stock == null || stock <= 0) {
  log.error("库存不足,当前库存:{}", stock);
  return false;
  }
  // 3. 扣减库存(此时只有一个线程执行,安全)
  int newStock = stock - 1;
  redisTemplate.opsForValue().set(STOCK_KEY, newStock);
  log.info("扣减库存成功,当前库存:{}", newStock);
  return true;
  } finally {
  // 4. 释放锁(必须在 finally 中,保证业务异常时也能释放)
  redisLock.unlock();
  }
  }
  }
2. 接口暴露(用于测试)
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/stock")
@RequiredArgsConstructor
public class StockController {
private final StockService stockService;
/**
* 初始化库存
*/
@GetMapping("/init")
public String initStock() {
stockService.initStock();
return "库存初始化成功";
}
/**
* 无锁扣减库存
*/
@GetMapping("/deduct/without-lock")
public String deductWithoutLock() {
boolean success = stockService.deductStockWithoutLock();
return success ? "库存扣减成功" : "库存不足";
}
/**
* 有锁扣减库存
*/
@GetMapping("/deduct/with-lock")
public String deductWithLock() {
boolean success = stockService.deductStockWithLock();
return success ? "库存扣减成功" : "库存不足";
}
}
3. 高并发测试(JMeter)
  1. 测试准备

    • 调用 http://localhost:8080/stock/init 初始化库存(100件)。
    • 用 JMeter 创建线程组:1000 个线程,循环 1 次(模拟 1000 个并发请求)。
  2. 测试无锁版本

    • 请求地址:http://localhost:8080/stock/deduct/without-lock
    • 结果:库存会变成负数(比如 -23),出现超卖,因为 1000 个请求同时扣减 100 个库存。
  3. 测试有锁版本

    • 请求地址:http://localhost:8080/stock/deduct/with-lock
    • 结果:库存最终为 0,无超卖,所有请求有序执行,分布式锁生效。

四、进阶优化:解决锁的“过期”与“集群”问题

4.1 问题1:锁过期导致业务未完成

若业务执行时间超过锁的过期时间(比如锁设 30 秒,但业务需要 60 秒),锁会自动释放,其他线程会加锁,导致并发问题。

解决方案:看门狗机制
在加锁成功后,启动一个后台线程,每隔 expire/3 秒(比如 10 秒)调用 renewLock() 续期锁,直到业务执行完成。

优化后的 lock() 方法:

@Override
public void lock() {
// 1. 加锁
while (!tryLock()) {
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 2. 启动看门狗线程(续期)
startWatchDog();
}
/**
* 看门狗线程:每隔 expire/3 秒续期一次
*/
private void startWatchDog() {
// 续期间隔(锁过期时间的 1/3)
long renewInterval = DEFAULT_EXPIRE / 3;
// 后台线程续期
Thread watchDog = new Thread(() -> {
while (lockValue.get() != null) { // 锁未释放时续期
try {
TimeUnit.SECONDS.sleep(renewInterval);
// 续期锁
boolean success = renewLock(DEFAULT_EXPIRE);
if (!success) {
log.warn("看门狗续期失败,锁可能已过期");
break;
}
log.debug("看门狗续期成功,锁过期时间延长 {} 秒", DEFAULT_EXPIRE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
// 设为守护线程(主线程结束后自动退出)
watchDog.setDaemon(true);
watchDog.start();
}

4.2 问题2:Redis 集群下的“脑裂”问题

若 Redis 是主从集群,主节点加锁成功后,未同步到从节点就宕机,从节点升级为主节点,其他线程会重新加锁,导致“双锁”问题。

解决方案:使用 Redisson
Redisson 是 Redis 官方推荐的分布式锁实现,封装了“红锁”(RedLock)机制,通过多个 Redis 节点加锁,保证集群下的一致性,同时支持:

  • 可重入锁(Reentrant Lock)
  • 公平锁(Fair Lock)
  • 自动看门狗续期
  • 读写锁(ReadWrite Lock)
Redisson 集成步骤
  1. 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.3</version> <!-- 与 Redis 版本兼容,参考官方文档 -->
</dependency>
  1. 配置 Redisson
spring:
redis:
host: localhost
port: 6379
password: 123456
redisson:
# 单节点配置(集群/哨兵配置参考官方文档)
singleServerConfig:
address: redis://localhost:6379
password: 123456
# 连接池大小
connectionPoolSize: 10
# 连接超时时间
connectTimeout: 3000
  1. 使用 Redisson 锁
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RedissonStockService {
private final RedissonClient redissonClient;
private static final String STOCK_KEY = "stock:1001";
private static final String LOCK_KEY = "stock:lock:1001";
// 构造函数注入 RedissonClient
public RedissonStockService(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/**
* Redisson 锁扣减库存(自动续期,支持可重入)
*/
public boolean deductStock() {
// 1. 获取锁
RLock lock = redissonClient.getLock(LOCK_KEY);
try {
// 2. 加锁(30秒过期,Redisson 自动续期)
lock.lock(30, TimeUnit.SECONDS);
// 3. 扣减库存(业务逻辑同上)
Integer stock = (Integer) redisTemplate.opsForValue().get(STOCK_KEY);
if (stock == null || stock <= 0) {
return false;
}
redisTemplate.opsForValue().set(STOCK_KEY, stock - 1);
return true;
} finally {
// 4. 释放锁(仅当前线程加的锁能释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

五、常见问题与解决方案

问题场景原因解决方案
锁无法释放(死锁)业务异常导致 unlock() 未执行1. 锁必须设过期时间;2. unlock() 放在 finally
释放别人的锁未判断锁的唯一标识,直接删除释放锁用 Lua 脚本,先判断 value 再删除
锁过期导致并发业务执行时间超过锁过期时间1. 预估合理过期时间;2. 引入看门狗自动续期
集群下双锁问题Redis 主从同步延迟,主节点宕机使用 Redisson 红锁机制,多节点加锁
高并发下性能差锁竞争激烈,线程频繁自旋1. 减小锁粒度(如按商品ID加锁);2. 用公平锁避免饥饿;3. 引入队列削峰

六、总结与最佳实践

  1. 原理优先:先理解 Redis 分布式锁的核心(SET NX EX + Lua 脚本),再使用工具类或 Redisson。
  2. 生产环境推荐 Redisson:原生实现适合学习,生产环境用 Redisson,避免重复造轮子,且支持更多高级特性。
  3. 锁粒度要小:避免用全局锁(如 stock:lock),改用细粒度锁(如 stock:lock:1001),提高并发效率。
  4. 过期时间要合理:既不能太短(导致续期频繁),也不能太长(死锁时影响范围大),建议 10-60 秒。
  5. 监控不可少:在生产环境中,监控锁的加锁成功率、续期次数、释放失败次数,及时发现问题。

通过本文的实战,你已经掌握了 Spring Boot 集成 Redis 分布式锁的核心流程,从原理到落地,再到高并发优化,可直接应用到实际项目中(如库存、订单、支付等场景)。

如果觉得有帮助,欢迎点赞、收藏,如有疑问,欢迎在评论区留言讨论!

posted on 2025-10-15 16:53  slgkaifa  阅读(100)  评论(0)    收藏  举报

导航