redis + redission

一、为什么需要分布式锁

第一步:没有分布式锁时,本地锁有什么问题?

假设你有一个扣减库存的方法,需要防止并发超卖。在单台服务器上,你会这样写:

java 复制代码
@Service
public class StockService {
    
    // Java 提供的本地可重入锁
    private final ReentrantLock localLock = new ReentrantLock();
    
    public void deductStock(Long productId, int quantity) {
        localLock.lock();  // 加锁
        try {
            // 查库存
            int stock = stockMapper.getStock(productId);
            if (stock >= quantity) {
                // 扣减
                stockMapper.deduct(productId, quantity);
            }
        } finally {
            localLock.unlock();  // 释放锁
        }
    }
}

这种写法在单台服务器上没问题,但在企业级项目中有什么弊端?

企业级项目通常采用微服务架构,同一个服务会部署多个实例(比如 3 台服务器)。每个实例都是一个独立的 JVM,拥有自己独立的 localLock 对象。

当两个用户同时下单时:

  • 用户 A 的请求打到实例 1 ,实例 1 的 localLock 加锁成功,开始扣库存

  • 用户 B 的请求打到实例 2 ,实例 2 的 localLock 也加锁成功,也开始扣库存

两个实例的锁互不相干,它们同时进入了临界区,同时查到了相同的库存数量,同时做了扣减。

这就是本地锁在分布式环境下的失效synchronizedReentrantLockConcurrentHashMap 这些 Java 并发工具,锁的范围仅限于当前 JVM 进程,只能保证:同一个 JVM 里面的多个线程互斥。无法跨越网络影响到其他服务器上的进程。

因为本地锁在分布式场景下失效,所以我们需要一个"所有实例都能看到的锁",这就是分布式锁。

**所以,分布式锁的目的就是:**分布式锁,就是让多个不同机器、不同 JVM 的服务实例,也能够竞争同一把锁。


第二步:分布式锁的本质是什么?

分布式锁的核心思路是:把"锁的状态"存储在一个独立于所有应用实例之外的第三方存储中,所有实例都通过访问这个第三方存储来判断"锁是否被占用"。

复制代码
实例 1 ──┐
实例 2 ──┼──→  Redis(存储锁的状态)  ←── 所有实例共享同一把锁
实例 3 ──┘

一个合格的分布式锁必须满足三个条件:

条件 为什么必须满足
互斥性 同一时刻只能有一个客户端持有锁
防死锁 客户端崩溃后,锁能自动释放,否则其他客户端永远拿不到锁
可重入性 同一个客户端可以多次获取同一把锁(避免自己把自己锁死)

第三步:最原始的 Redis 分布式锁实现及其弊端

既然 Redis 是所有实例都能访问的,最直观的思路就是:用 Redis 的一个 Key 来表示锁。


3.1 第一版:SETNX + EXPIRE

java 复制代码
@Service
public class StockService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    public void deductStock(Long productId, int quantity) {
        String lockKey = "lock:stock:" + productId;
        String clientId = UUID.randomUUID().toString();
        
        // 1. 尝试加锁:SETNX = SET if Not eXists
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, clientId);
        
        // 2. 设置过期时间,防止死锁
        redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);
        
        if (!Boolean.TRUE.equals(locked)) {
            throw new RuntimeException("获取锁失败,请重试");
        }
        
        try {
            // 3. 执行业务:查库存、扣减
            int stock = stockMapper.getStock(productId);
            if (stock >= quantity) {
                stockMapper.deduct(productId, quantity);
            }
        } finally {
            // 4. 释放锁
            redisTemplate.delete(lockKey);
        }
    }
}

这个代码本质上是以下命令:

java 复制代码
SET lock:order:123 uuid NX EX 30

这里几个参数非常重要。

NX

表示:

只有这个 Key 不存在的时候才能设置成功。

所以:

java 复制代码
线程A:
SET lock:order:123 uuidA NX EX 30
        ↓
      成功

线程B:

java 复制代码
SET lock:order:123 uuidB NX EX 30
        ↓
      失败

EX:

假设:

java 复制代码
线程A
 ↓
获取 Redis 锁
 ↓
开始处理业务
 ↓
突然服务器宕机

如果没有过期时间:lock:order:123,会永远存在。

那么以后:线程B → 永远获取不到锁。

所以必须设置:EX 30,也就是:

锁最多存在 30 秒。

即使持有锁的服务挂掉,30 秒之后锁也会自动释放。


这版代码还有什么问题?

问题一:SETNX 和 EXPIRE 不是原子操作

如果 setIfAbsent 执行成功后,应用突然挂了(还没执行到 expire),这个 Key 就永远存在于 Redis 中,其他实例永远拿不到锁,系统死锁


问题二:业务执行时间超过锁的过期时间

你设置了 30 秒过期,但数据库查询很慢,业务执行了 35 秒。第 30 秒时 Redis 自动删除了 Key,锁提前释放了 。此时另一个实例拿到了锁,也开始执行业务。等你的业务执行完去 delete 时,你把别人持有的锁给删掉了


问题三:释放锁时没有判断是不是自己加的锁

finally 里直接 delete,如果锁已经被 Redis 自动过期删除了,另一个实例又创建了新锁,你的 delete 会误删别人的锁。


3.2 第二版:原子加锁 + Lua 释放

针对问题一,Redis 提供了原子命令:

bash 复制代码
SET lock:stock:1001 uuid NX PX 30000
参数 含义 示例
EX 秒(seconds) EX 30 = 30秒
PX 毫秒(milliseconds) PX 30000 = 30000毫秒 = 30秒
  • NX:只有 Key 不存在时才设置(相当于 SETNX)

  • PX 30000:同时设置 30 秒过期时间

针对问题三,释放锁时用 Lua 脚本保证"判断+删除"的原子性:

java 复制代码
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

java

java 复制代码
@Service
public class StockService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String RELEASE_SCRIPT = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) else return 0 end";
    
    public void deductStock(Long productId, int quantity) {
        String lockKey = "lock:stock:" + productId;
        String clientId = UUID.randomUUID().toString();
        
        // 原子加锁:SET key value NX PX 30000
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
        
        if (!Boolean.TRUE.equals(locked)) {
            throw new RuntimeException("获取锁失败");
        }
        
        try {
            int stock = stockMapper.getStock(productId);
            if (stock >= quantity) {
                stockMapper.deduct(productId, quantity);
            }
        } finally {
            // 原子释放:先判断 value 是否匹配,匹配才删除
            redisTemplate.execute(
                new DefaultRedisScript<>(RELEASE_SCRIPT, Long.class),
                Collections.singletonList(lockKey),
                clientId
            );
        }
    }
}
这版还有什么问题?

问题二仍然没有解决:业务执行时间超过过期时间

如果业务执行了 35 秒,第 30 秒锁自动过期,另一个实例拿到锁,两个实例同时执行业务。分布式锁失效了

你可能会想:"那把过期时间设长一点,比如 5 分钟?"但如果应用真的挂了,锁要 5 分钟后才释放,系统在这 5 分钟内完全不可用

因为手动处理"锁续期"极其复杂且容易出错,所以诞生了 Redisson。


第四步:Redisson 的设计------它到底解决了什么?

Redisson 是一个基于 Redis 的 Java 驻内存数据网格,它把分布式锁的复杂性全部封装了起来。

Redisson 解决了三个核心问题:

问题 Redisson 的解决方案
锁续期(看门狗) 自动启动后台线程,每隔 10 秒检查一次,如果业务还没执行完,自动给锁续期
可重入性 同一个线程可以多次获取同一把锁,内部用计数器记录重入次数
锁释放的原子性 内部已经用 Lua 脚本封装好了,不需要你手写

第五步:Spring Boot 集成 Redisson 的完整流程

5.1 引入依赖

XML 复制代码
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Redis 依赖(Spring Boot 自带) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- Redisson Spring Boot Starter -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.27.0</version>
    </dependency>
</dependencies>

为什么用 redisson-spring-boot-starter 而不是普通 redisson

因为 starter 会自动读取 application.yml 中的 Redis 配置,自动创建 RedissonClient Bean 并注入 Spring 容器。没有它,你需要手动写配置类创建 RedissonClient

5.2 配置文件

bash 复制代码
spring:
  redis:
    host: localhost
    port: 6379
    # 如果有密码
    # password: yourpassword
    
    # Redisson 额外配置
    redisson:
      config: |
        singleServerConfig:
          # 连接超时时间
          connectTimeout: 10000
          # 看门狗超时时间(默认 30 秒)
          lockWatchdogTimeout: 30000

5.3 业务代码中使用分布式锁

java 复制代码
@Service
public class StockService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StockMapper stockMapper;
    
    public void deductStock(Long productId, int quantity) {
        // 1. 获取锁对象:每个商品一把独立的锁(细粒度)
        String lockKey = "lock:stock:" + productId;
        RLock lock = redissonClient.getLock(lockKey);
        
        // 2. 尝试加锁
        boolean isLocked = false;
        try {
            // tryLock(等待时间, 锁自动释放时间, 时间单位)
            // 如果 leaseTime 设为 -1,启用看门狗自动续期
            isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
            
            if (!isLocked) {
                throw new RuntimeException("系统繁忙,请稍后重试");
            }
            
            // 3. 执行业务逻辑(临界区)
            int stock = stockMapper.getStock(productId);
            if (stock < quantity) {
                throw new RuntimeException("库存不足");
            }
            stockMapper.deduct(productId, quantity);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取锁被中断");
        } finally {
            // 4. 释放锁:只有当前线程持有锁时才释放
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

代码逐行解释

代码 为什么这样写 没有它会怎样
redissonClient.getLock(lockKey) 通过锁名获取锁对象,相同名称的锁全局唯一 无法定位到具体的锁资源
tryLock(5, -1, TimeUnit.SECONDS) 最多等待 5 秒;-1 表示不设置固定过期时间,启用看门狗自动续期 如果设了固定时间,业务执行超时会提前释放;如果不等待,获取不到锁立即失败
lock.isHeldByCurrentThread() 判断当前线程是否持有锁,防止误删 如果直接 unlock,可能释放的是其他线程的锁
finally { unlock() } 保证无论业务是否异常,锁最终都会被释放 业务抛异常后锁不释放,导致死锁

看门狗(Watch Dog)的工作机制

当你使用 tryLock(waitTime, -1, unit) 时( leaseTime 为 -1 ):

复制代码
线程获取锁成功
    ↓
Redisson 启动一个后台线程(看门狗)
    ↓
每隔 10 秒检查:业务线程还活着吗?
    ↓ 是
自动把锁的过期时间重置为 30 秒
    ↓
业务执行完,调用 unlock()
    ↓
看门狗线程停止,锁被删除

如果没有看门狗 ,你必须预估业务执行时间设置 leaseTime。预估长了,应用崩溃后死锁时间长;预估短了,业务还没执行完锁就过期了。


第六步:Spring Cloud 场景下的考虑

在 Spring Cloud 微服务中,通常有多个服务实例。分布式锁的命名需要特别注意:

6.1 锁的粒度

java 复制代码
// ❌ 错误:所有商品共用一把锁,并发度极低
RLock lock = redissonClient.getLock("lock:stock");

// ✅ 正确:每个商品一把锁,互不干扰(细粒度)
RLock lock = redissonClient.getLock("lock:stock:" + productId);

为什么必须细粒度? 如果所有商品共用一把锁,用户 A 买商品 1 时,用户 B 买商品 2 也会被阻塞,系统吞吐量急剧下降。

6.2 锁前缀规范

建议统一命名格式,避免不同业务模块的锁冲突:

java 复制代码
lock:{业务模块}:{资源类型}:{资源ID}

例如:
lock:stock:product:1001      -- 商品库存锁
lock:order:deduct:20240820   -- 订单日结锁
lock:user:coupon:5678        -- 用户领券锁

6.3 配合本地缓存的双层锁(高并发优化)

在超高并发场景下,每个请求都访问 Redis 加锁会有网络开销。可以结合本地锁做"双层锁":

java 复制代码
@Service
public class StockService {
    
    // 本地锁:减少 Redis 访问次数
    private final ConcurrentHashMap<Long, ReentrantLock> localLocks = new ConcurrentHashMap<>();
    
    @Autowired
    private RedissonClient redissonClient;
    
    public void deductStock(Long productId, int quantity) {
        // 第一层:本地锁(JVM 级别,减少 Redis 竞争)
        ReentrantLock localLock = localLocks.computeIfAbsent(productId, k -> new ReentrantLock());
        localLock.lock();
        
        try {
            // 第二层:分布式锁(跨 JVM 级别)
            RLock redisLock = redissonClient.getLock("lock:stock:" + productId);
            boolean locked = redisLock.tryLock(3, -1, TimeUnit.SECONDS);
            
            if (!locked) {
                throw new RuntimeException("系统繁忙");
            }
            
            try {
                // 执行业务
                stockMapper.deduct(productId, quantity);
            } finally {
                redisLock.unlock();
            }
        } finally {
            localLock.unlock();
        }
    }
}

这个 ConcurrentHashMap 存的是:商品 ID → 这个商品对应的本地锁

例如现在有三个商品:

productId = 1001

productId = 1002

productId = 1003

那么这个 Map 里面可能就是:

java 复制代码
localLocks

┌───────────┬─────────────────┐
│ 商品ID     │ 本地锁           │
├───────────┼─────────────────┤
│ 1001      │ ReentrantLock A │
│ 1002      │ ReentrantLock B │
│ 1003      │ ReentrantLock C │
└───────────┴─────────────────┘

意思就是:

如果这个商品还没有对应的锁,就创建一个;如果已经有了,就直接拿之前创建好的锁。


第七步:Redisson 的其他锁类型

Redisson 不仅提供了普通的可重入锁,还提供了更复杂的锁:

锁类型 适用场景 代码
公平锁 防止线程饥饿,按请求顺序获取锁 redissonClient.getFairLock("lock:order")
读写锁 读多写少场景,读锁共享、写锁互斥 redissonClient.getReadWriteLock("lock:config")
红锁(RedLock) 对可靠性要求极高,防止单点 Redis 故障 new RedissonRedLock(lock1, lock2, lock3)

红锁是什么? 当单台 Redis 挂了,锁信息丢失怎么办?

红锁要求在多个独立的 Redis 实例上同时加锁,超过半数成功才算加锁成功。这样即使一台 Redis 挂了,其他实例上的锁仍然有效。


完整逻辑回顾

步骤 解决了什么问题 如果没有它,会怎样
本地锁(synchronized 单 JVM 内线程互斥 多实例部署时完全失效
Redis SET NX PX 跨 JVM 的互斥 非原子操作导致死锁或误删
Lua 脚本释放锁 保证"判断+删除"原子性 误删其他实例持有的锁
Redisson 看门狗 自动续期,解决业务超时 锁提前释放,并发安全问题
Redisson 封装 隐藏 Redis 命令细节 每个业务都要手写几十行锁逻辑
细粒度锁命名 减少锁竞争,提升并发 全局一把锁,系统吞吐量极低

二、企业级场景中redis的部署情况

用于分布式锁的 Redis,实际部署时通常是一个独立的 Redis 服务/集群,不是跟某一个微服务部署在一起。

比如一个真实的企业项目,可能是这样的:

复制代码
                    用户
                     ↓
                  Nginx
                     ↓
          ┌──────────┼──────────┐
          ↓          ↓          ↓
       微服务A     微服务A     微服务A
       服务器1     服务器2     服务器3
          │          │          │
          └──────────┼──────────┘
                     ↓
                Redis集群
             ┌───────┼───────┐
             ↓       ↓       ↓
          Redis1   Redis2   Redis3

这里最关键的是:

Redis 和微服务是两个独立的基础设施。


1、为什么所有微服务都能访问 Redis?

其实和你访问 MySQL 是一样的。

比如你的 Spring Boot 项目配置:

复制代码
spring:
  datasource:
    url: jdbc:mysql://192.168.1.100:3306/order

你的微服务服务器可能是:

复制代码
192.168.1.101

MySQL 是:

复制代码
192.168.1.100

虽然 MySQL 和 Spring Boot 根本不是一台机器,但是 Spring Boot 可以通过:

复制代码
192.168.1.100:3306

访问 MySQL。


Redis 完全一样。

例如:

复制代码
spring:
  data:
    redis:
      host: 192.168.1.200
      port: 6379

那么:

复制代码
微服务1 ──────┐
              │
微服务2 ──────┼──→ 192.168.1.200:6379
              │
微服务3 ──────┘
                    Redis

只要网络是通的,并且 Redis 允许这些服务器访问,那么它们都可以访问这个 Redis。


2、那实际生产环境 Redis 会和服务器放在一起吗?

通常不会简单地和某个微服务绑在一起。

更典型的是:

复制代码
                    生产环境
┌──────────────────────────────────────┐
│                                      │
│   微服务服务器                       │
│   ┌──────┐ ┌──────┐ ┌──────┐        │
│   │服务A │ │服务B │ │服务C │        │
│   └──┬───┘ └──┬───┘ └──┬───┘        │
│      │         │         │            │
│      └─────────┼─────────┘            │
│                ↓                      │
│          ┌──────────────┐             │
│          │ Redis 集群   │             │
│          │              │             │
│          │ Master       │             │
│          │ Slave        │             │
│          │ Slave        │             │
│          └──────────────┘             │
│                                      │
└──────────────────────────────────────┘

甚至 Redis 根本不一定是公司自己部署的。

比如使用云厂商提供的 Redis:

复制代码
Spring Boot
    ↓
云 Redis

你的程序只需要知道:

复制代码
Redis地址
Redis端口
密码

就可以连接。


3、这里还有一个非常重要的点

你可能会产生一个疑问:

"既然 Redis 是一个独立服务器,那 Redis 挂了怎么办?"

因为:

复制代码
所有微服务
      ↓
    Redis
      ↓
分布式锁

Redis 如果只有一台:

复制代码
Redis挂了
   ↓
所有服务都无法正常获取分布式锁

所以,生产环境一般不会简单搞一个单机 Redis,而会根据可靠性要求使用:

复制代码
Redis主从
Redis Sentinel
Redis Cluster
云Redis高可用

也就是说,分布式锁本身也是依赖 Redis 集群的高可用能力的。

相关推荐
李可以量化2 小时前
Redis 从了解到精通(三)下:性能基准测试与量化场景性能避坑指南
redis·git·python·量化交易·qmt·ptrade
蜀道山老天师2 小时前
Zabbix监控MySQL与Redis应用实践完整指南
linux·运维·redis·mysql·zabbix
陈皮波比茶12 小时前
Redis学习
数据库·redis·学习
wear工程师16 小时前
Redis 和 MySQL 缓存一致性,延迟双删为什么不是强一致
redis·mysql
李可以量化17 小时前
Redis 从了解到精通(二)下:发布订阅进阶命令、量化场景落地与避坑指南
数据库·redis·python·qmt·ptrade
添砖java‘’17 小时前
Redis中常用数据结构
数据库·redis·缓存
ruleslol19 小时前
redis的持久化
redis
玛丽莲茼蒿1 天前
Redis(六)—— Redis高级
数据库·redis·缓存
渣渣盟1 天前
Flink 写入 Redis 实战:数据模型选型、连接池调优与常见坑
大数据·redis·flink