Redis 分布式锁:从原理到生产实践
本文系统梳理 Redis 分布式锁的完整知识体系,从基础概念到手写实现,再到 Redisson 生产级方案,涵盖锁误删、原子性保障、Lua 脚本、可重入锁、WatchDog 看门狗、MultiLock 联锁等核心知识点。
分布式锁概述
为什么需要分布式锁
Java 中的 Synchronized 关键字只能保证单个 JVM 内部的线程互斥。当应用部署为集群模式时,多个 JVM 进程各自拥有独立的锁监视器,Synchronized 就失效了。
分布式锁的核心思路:使用 JVM 外部的锁监视器,让所有 JVM 进程共享同一把锁,从而实现多进程间的互斥。
核心特性
| 层级 | 特性 | 说明 |
|---|---|---|
| 核心 | 多进程可见 | 多个 JVM 进程都能访问同一个锁 |
| 核心 | 互斥 | 同一时刻只有一个线程能获取锁 |
| 重要 | 高可用 | 大多数情况下获取锁应成功 |
| 重要 | 高并发 | 获取锁的动作不能影响业务性能 |
| 重要 | 安全性 | 异常情况下锁能及时释放 |
| 功能 | 可重入性 / 阻塞与非阻塞 / 公平与非公平 | 根据业务场景选择 |
三种实现方案对比
| 对比维度 | MySQL | Redis | Zookeeper |
|---|---|---|---|
| 互斥原理 | 数据库事务 + 互斥锁 | SET NX(key 不存在才成功) |
有序临时节点,最小 ID 获取锁 |
| 高可用 | 主从模式,可用性尚可 | 主从/集群模式,可用性好 | 集群模式,可用性好 |
| 高性能 | 性能一般 | 性能最优 | 强一致性同步开销大,弱于 Redis |
| 安全性 | 连接断开自动释放 | 需设置过期时间防死锁 | 临时节点,连接断开自动释放 |
综合性能与易用性,Redis 是目前最主流的分布式锁实现方案。
手写 Redis 分布式锁
基本原理
获取锁:
SET key value NX EX secondsNX:只有 key 不存在时才设置,保证互斥EX seconds:设置过期时间,防止服务宕机导致死锁- 两者合并为一条原子命令,避免 SETNX 成功后尚未 EXPIRE 就宕机的问题
释放锁:
DEL key获取锁的两种模式:
- 阻塞式:获取失败后循环等待,直到锁释放(CPU 浪费大,实现复杂)
- 非阻塞式:只尝试一次,成功返回
true,失败立即返回false
Java 实现
锁接口定义:
public interface ILock {
boolean tryLock(long timeoutSec); // 非阻塞式尝试获取锁
void unlock(); // 释放锁
}SimpleRedisLock 实现:
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}业务使用示例(一人一单场景):
// 锁范围 = 用户ID,粒度越细并发效率越高
SimpleRedisLock lock = new SimpleRedisLock(redisTemplate, "order:" + userId);
if (!lock.tryLock(1200)) {
return Result.fail("不允许重复下单");
}
try {
// 执行业务逻辑(查询订单、扣减库存、创建订单)
} finally {
lock.unlock();
}注意: 锁的 key 应与业务标识相关,锁范围尽量小(如按用户 ID 区分),以提高并发效率。
锁安全性问题与解决方案
问题一:锁误删
场景复现:
- 线程1 获取锁成功,开始执行业务
- 线程1 业务阻塞 → 锁超时自动释放(EXPIRE 到期)
- 线程2 获取锁成功,开始执行业务
- 线程1 恢复执行
DEL→ 误删了线程2 的锁! - 线程3 趁虚而入 → 并发安全被破坏
根因: 释放锁时没有验证”这把锁是不是自己的”。
解决方案 — 加入线程标识:
在 value 中存入全局唯一的线程标识(UUID + 线程ID),释放前先比对:
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(lockId)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}使用
UUID + 线程ID而非单纯线程ID,是因为不同 JVM 中的线程ID 可能重复。
问题二:判断与释放的原子性
即使加了标识判断,仍有极端情况:
- 线程1 判断锁标识 → 确认是自己的 ✓
- 此时 JVM 发生 GC 停顿
- 停顿期间锁超时释放 → 线程2 获取锁成功
- GC 结束 → 线程1 继续执行
DEL→ 又误删了线程2 的锁!
根因: “判断标识”和”删除锁”是两步操作,不具备原子性。
为什么 Redis 事务不能解决? Redis 事务无法在执行过程中获取查询结果来做条件判断,无法实现”先查询再决策”的逻辑。
Lua 脚本:实现原子性释放
Lua 语言基础
Lua 是一种轻量级脚本语言,Redis 内置了 Lua 解释器。核心语法要点:
- 变量声明用
local,弱类型 - 流程控制:
if ... then ... end - 数组下标从 1 开始
在 Redis 中调用 Lua
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 name roseKEYS[]:Key 类型参数ARGV[]:Value 等其他参数- 中间的数字
1表示 KEYS 的个数
原子性释放锁的 Lua 脚本
-- unlock.lua
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
return redis.call('DEL', KEYS[1])
end
return 0判断和删除在同一个 Lua 脚本中执行,Redis 保证脚本执行的原子性。
Java 集成
public class SimpleRedisLock implements ILock {
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name), // KEYS
ID_PREFIX + Thread.currentThread().getId() // ARGV
);
}
}
RedisTemplate.execute()参数:脚本对象、KEYS 列表、ARGV 可变参数。
至此,手写版分布式锁已具备:互斥 + 超时释放 + 标识校验 + 原子性释放,可以覆盖大多数简单场景。
手写锁的遗留问题
| 问题 | 说明 |
|---|---|
| 不可重入 | 同一线程无法多次获取同一把锁(方法 A 调用方法 B,两者都需要锁 → 死锁) |
| 不可重试 | 获取失败立即返回 false,没有等待重试机制 |
| 超时释放 | 过期时间设太短业务没做完,设太长影响并发 |
| 主从一致性 | 主节点获取锁后未同步到从节点就宕机 → 锁丢失 |
这些问题需要更成熟的框架来解决 → Redisson。
Redisson:生产级分布式锁框架
简介
Redisson 是基于 Redis 的 Java 驻内存数据网格,提供丰富的分布式工具集,包括:可重入锁、公平锁、联锁、红锁、读写锁、信号量等。
官方地址:https://github.com/redisson/redisson
快速入门
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>配置客户端:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
return Redisson.create(config);
}
}使用分布式锁:
@Resource
private RedissonClient redissonClient;
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
// 执行业务
} finally {
lock.unlock();
}tryLock() 参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
waitTime |
最大等待时间 | -1(不等待) |
leaseTime |
锁自动释放时间 | -1(启用看门狗,默认 30 秒) |
unit |
时间单位 | — |
Redisson 核心特性详解
可重入锁
核心思路: 使用 Hash 结构替代 String 结构,记录重入次数。
| 数据结构 | Key | Field | Value |
|---|---|---|---|
| Hash | 锁名称 | 线程标识 | 重入次数 |
原理与 JDK 的 ReentrantLock 一致:每次获取 count++,每次释放 count–,减到 0 时才真正删除锁。
获取锁流程(Lua 脚本):
判断锁是否存在 (EXISTS key)
├─ 不存在 → 首次获取
│ HSET key threadId 1 # 重入次数 = 1
│ EXPIRE key releaseTime # 设置过期时间
│ return nil(成功)
│
└─ 存在 → 判断线程标识 (HEXISTS key threadId)
├─ 匹配 → 重入!
│ HINCRBY key threadId 1 # 重入次数 +1
│ EXPIRE key releaseTime # 重置过期时间
│ return nil(成功)
│
└─ 不匹配 → 别人的锁
return PTTL key(返回剩余有效期,获取失败)释放锁流程(Lua 脚本):
HEXISTS key threadId
├─ 不存在 → 锁已释放/超时,return nil
│
└─ 存在
HINCRBY key threadId -1 # 重入次数 -1
判断重入次数 > 0?
├─ 是 → 还有外层调用持有锁
│ EXPIRE key releaseTime # 续期
│ return 0
└─ 否 → 最外层调用释放
DEL key
PUBLISH channel msg # 通知等待者
return 1锁重试机制(Pub/Sub)
Redisson 利用 Redis 的发布/订阅机制实现高效的锁等待,避免 while(true) 盲轮询:
首次尝试获取锁
├─ 成功 (TTL=nil) → 返回 true
└─ 失败 (TTL=剩余时间)
检查等待时间是否耗尽
├─ 耗尽 → 返回 false
└─ 还有时间
SUBSCRIBE 订阅锁释放信号(节省 CPU)
等待信号(设超时上限)
├─ 收到信号 → 再次尝试获取(循环)
└─ 超时 → 返回 false巧妙之处: 释放锁时 PUBLISH,等待者 SUBSCRIBE,收到信号才重试,对 CPU 非常友好。
WatchDog 看门狗机制
解决”锁超时释放但业务还没做完”的难题。
触发条件: leaseTime == -1(未手动指定锁释放时间)。
工作原理:
- 默认锁超时时间:30 秒(
lockWatchdogTimeout) - 获取锁成功后,启动定时任务,每 10 秒(30 ÷ 3)续约一次:
if (redis.call('HEXISTS', key, threadId) == 1) then
redis.call('EXPIRE', key, 30) -- 重置有效期
end- 递归调用自身,循环续约 → 只要业务在执行,锁就不会过期
- 释放锁时,取消定时任务(从 ConcurrentHashMap 中移除 entry,cancel timeout)
如果手动指定了
leaseTime,看门狗不会启动,锁到期后自动释放。
MultiLock 联锁
解决 Redis 主从模式下的一致性问题。
问题场景: 主节点获取锁成功 → 尚未同步给从节点 → 主节点宕机 → 新主节点上没有锁信息 → 锁失效。
MultiLock 方案:
- 不使用主从架构,所有节点都是独立的 Redis 实例
- 获取锁时:必须在所有节点上都获取成功才算成功
- 释放锁时:释放所有节点上的锁
配置多个独立 Redis 节点:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient1() {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient2() {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6380");
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient3() {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6381");
return Redisson.create(config);
}
}使用联锁:
RLock lock1 = redissonClient1.getLock("lock:order:" + userId);
RLock lock2 = redissonClient2.getLock("lock:order:" + userId);
RLock lock3 = redissonClient3.getLock("lock:order:" + userId);
RLock multiLock = redissonClient1.getMultiLock(lock1, lock2, lock3);
multiLock.tryLock();
try {
// 执行业务
} finally {
multiLock.unlock();
}方案对比与实践建议
三种分布式锁方案总览
| 特性 | 手写 Redis 锁 | Redisson 可重入锁 | Redisson MultiLock |
|---|---|---|---|
| 数据结构 | String(SET NX EX) | Hash | 多个独立 Hash |
| 可重入 | ✗ | ✓(重入计数器) | ✓ |
| 重试机制 | ✗ | ✓(Pub/Sub + 信号量) | ✓ |
| 超时处理 | EXPIRE 兜底 | WatchDog 自动续约 | WatchDog 自动续约 |
| 误删防护 | Lua 脚本判断标识 | Lua 脚本判断标识 | Lua 脚本判断标识 |
| 主从一致性 | ✗ | ✗ | ✓(独立节点无主从) |
| 适用场景 | 学习理解原理 | 生产环境首选 | 高安全性要求场景 |
Redisson 解决的核心问题
| 问题 | 解决方案 |
|---|---|
| 不可重入 | Hash 结构 + 重入计数器 |
| 不可重试 | Pub/Sub 信号量,收到释放信号后才重试 |
| 超时释放 | WatchDog 每 10 秒续约一次(默认 30 秒有效期) |
| 主从一致性 | MultiLock 联锁,所有独立节点都获取成功才算成功 |
实践建议
- 大多数生产场景:直接使用 Redisson 可重入锁
- 高安全性要求:使用 Redisson MultiLock(建议至少 3 个独立节点)
- 面试准备重点:理解手写锁的演进过程(基础实现 → 标识校验 → Lua 原子性 → Redisson 高级特性)