一、为什么需要分布式锁
第一步:没有分布式锁时,本地锁有什么问题?
假设你有一个扣减库存的方法,需要防止并发超卖。在单台服务器上,你会这样写:
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也加锁成功,也开始扣库存
两个实例的锁互不相干,它们同时进入了临界区,同时查到了相同的库存数量,同时做了扣减。
这就是本地锁在分布式环境下的失效。synchronized、ReentrantLock、ConcurrentHashMap 这些 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 配置,自动创建RedissonClientBean 并注入 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 集群的高可用能力的。