详细介绍:架构思维:用 Redis 实现一个生产级的 Java 分布式锁_从入门到 RedLock 高可用方案
文章目录
概述
在分布式系统开发中,"分布式锁"几乎是每个 工程师都会遇到的高频需求。今天,我将带你从零开始,用 Java 实现一个真正可用于生产环境的 Redis 分布式锁,不仅解决基础互斥问题,更涵盖超时、重入、集群高可用等核心痛点。
一、为什么需要分布式锁?它必须满足哪些特性?
在单体应用时代,我们用 synchronized
或 ReentrantLock
就能轻松解决并发问题。但在微服务架构下,多个 JVM 进程可能同时操作同一份资源(如库存扣减、订单创建),这时就必须引入分布式锁。
一个生产级的分布式锁,必须满足以下五个核心特性:
- 互斥性:同一时刻,只能有一个客户端持有锁。
- 超时释放:防止客户端崩溃导致死锁,必须有自动过期机制。
- 可重入性:同一个线程可以多次获取同一把锁(类似
ReentrantLock
)。 - 高性能 & 高可用:加解锁操作要快,且系统要能容忍部分节点故障。
- 安全性:只能释放自己持有的锁,避免误删他人锁。
下面,我们就用 Java + Redis,一步步实现一个满足以上所有特性的分布式锁。
二、基础实现:从 SETNX
到原子化 SET
命令
阶段 1:最简陋的 SETNX
实现(有严重缺陷)
import redis.clients.jedis.Jedis;
public class SimpleRedisLock
{
private Jedis jedis;
private String lockKey;
public SimpleRedisLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = lockKey;
}
// 尝试获取锁
public boolean tryLock(String requestId, long expireTime) {
// SETNX: Key不存在时才设置成功
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 设置过期时间 - 但这里和SETNX不是原子操作!
jedis.expire(lockKey, expireTime);
return true;
}
return false;
}
// 释放锁
public void unlock(String requestId) {
// 简单粗暴删除 - 会误删别人的锁!
jedis.del(lockKey);
}
}
致命缺陷:
setnx
和expire
不是原子操作。如果在setnx
成功后、expire
执行前进程崩溃,锁将永不过期。unlock
方法直接del
,如果锁已过期被别人获取,你会把别人的锁删掉!
阶段 2:原子化加锁 - 使用 SET
命令的扩展参数
Redis 2.8+ 提供了原子化的 SET
命令,完美解决阶段 1 的第一个问题。
public class AtomicRedisLock
{
private Jedis jedis;
private String lockKey;
public AtomicRedisLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = lockKey;
}
// 尝试获取锁 - 原子化操作
public boolean tryLock(String requestId, long expireTime) {
// NX: 仅当key不存在时设置
// PX: 设置过期时间,单位毫秒
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
return "OK".equals(result);
}
// 释放锁 - 依然有缺陷
public void unlock(String requestId) {
jedis.del(lockKey);
// 问题依旧:可能误删别人的锁
}
}
改进点:SET key value NX PX milliseconds
命令将设置值和设置过期时间合并为一个原子操作,彻底解决了“锁永不过期”的问题。
遗留问题:释放锁时的“误删”问题仍未解决。
三、终极安全方案:Lua 脚本确保“谁加锁,谁解锁”
为了解决“误删”问题,我们必须在删除锁之前,先检查这个锁是不是自己加的。这需要一个“读取-判断-删除”的原子操作,而 Redis 的 Lua 脚本 正是为此而生。
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.UUID;
public class SafeRedisLock
{
private Jedis jedis;
private String lockKey;
// Lua脚本:先判断value是否匹配,再删除
private static final String UNLOCK_LUA_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public SafeRedisLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = lockKey;
}
// 获取锁
public boolean tryLock(String requestId, long expireTime) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
return "OK".equals(result);
}
// 安全释放锁
public boolean unlock(String requestId) {
// 使用Lua脚本执行原子操作
Object result = jedis.eval(UNLOCK_LUA_SCRIPT,
Collections.singletonList(lockKey),
Collections.singletonList(requestId));
return (Long) result == 1L;
}
// 示例:如何使用
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
SafeRedisLock lock = new SafeRedisLock(jedis, "order:123:lock");
// 生成唯一请求ID,用于标识“锁的主人”
String requestId = UUID.randomUUID().toString();
try {
if (lock.tryLock(requestId, 30000)) {
// 尝试获取锁,30秒过期
// 模拟业务处理
System.out.println("获取锁成功,执行业务逻辑...");
Thread.sleep(5000);
} else {
System.out.println("获取锁失败,业务逻辑无法执行");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 无论成功失败,都尝试释放锁
if (lock.unlock(requestId)) {
System.out.println("锁释放成功");
} else {
System.out.println("锁释放失败,可能已被自动过期或非本人持有");
}
jedis.close();
}
}
}
核心突破:
requestId
:每个线程在加锁时生成一个全局唯一的 ID(如 UUID),并将其作为 Value 存入 Redis。- Lua 脚本:在释放锁时,脚本会先
GET
锁的值,与传入的requestId
对比。只有完全相等时,才执行DEL
。这确保了绝对的安全性。
四、进阶挑战:Redis 集群下的高可用方案 — RedLock
上面的方案在单机 Redis 下是完美的。但在生产环境,我们通常使用 Redis 集群 或 主从架构 来保证高可用。这时,一个新的问题出现了:异步复制导致的锁失效。
问题场景模拟
- 客户端 A 从 Master 节点成功获取锁。
- Master 节点在将“加锁”操作同步给 Slave 前突然宕机。
- 一个 Slave 节点被提升为新的 Master。
- 客户端 B 向新的 Master 请求同一把锁,竟然也成功了!
- 结果:两个客户端同时持有同一把锁,互斥性被彻底破坏。
解决方案:RedLock 算法
Redis 作者 Antirez 提出了 RedLock 算法,其核心思想是:不依赖单个 Redis 实例,而是与多个独立的 Redis 节点交互,只有获得“大多数”节点的锁才算成功。
RedLock 算法步骤(N 个 Redis 节点,通常 N=5)
- 记录开始时间。
- 依次向 N 个 Redis 节点请求加锁(使用上面的原子化
SET
命令)。 - 计算总耗时 = 当前时间 - 开始时间。
- 判断是否成功:必须满足两个条件
- 从 超过半数 (N/2 + 1) 的节点获取到了锁。
- 总耗时 小于 锁的预设过期时间。
- 计算实际有效时间 = 预设过期时间 - 总耗时。
- 如果失败,则向所有节点发起解锁请求。
Java 实现建议:使用 Redisson
手动实现 RedLock 非常复杂。幸运的是,Java 社区有成熟的解决方案 —— Redisson。
<!-- 在 pom.xml 中添加依赖 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.23.0</version> <!-- 请使用最新稳定版 -->
</dependency>
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonRedLockExample
{
public static void main(String[] args) {
// 配置 Redisson 客户端(连接到 Redis 集群)
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 如果是集群,使用 config.useClusterServers()...
RedissonClient redisson = Redisson.create(config);
// 获取一个可重入锁
RLock lock = redisson.getLock("myOrderLock");
try {
// 尝试加锁,等待时间10秒,锁自动释放时间30秒
boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (isLocked) {
try {
// 执行你的业务逻辑
System.out.println("Redisson锁获取成功,执行业务...");
Thread.sleep(5000);
} finally {
lock.unlock();
// 释放锁
System.out.println("Redisson锁已释放");
}
} else {
System.out.println("Redisson锁获取失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redisson.shutdown();
}
}
}
Redisson 的优势:
- 开箱即用:内置了基于单实例和 RedLock 的分布式锁实现。
- 功能完备:支持可重入、自动续期(看门狗机制)、公平锁、读写锁等。
- 生产验证:经过大量生产环境检验,稳定可靠。
五、总结与最佳实践
通过本文,我们从最基础的 SETNX
一路优化到生产级的 Lua
脚本方案,最后探讨了集群环境下的 RedLock
。总结几个关键的最佳实践:
- 永远不要用
SETNX
+EXPIRE
:务必使用原子化的SET key value NX PX
命令。 - 加锁必带唯一标识:使用
UUID
或线程ID作为Value
,这是安全释放锁的前提。 - 释放锁必须用 Lua:这是保证“判断-删除”原子性的唯一可靠方法。
- 评估是否需要 RedLock:对于绝大多数业务,单机 Redis + 完善的锁实现已足够。RedLock 会带来性能损耗和复杂度,仅在对一致性要求极高的场景下使用。
- 优先考虑 Redisson:除非有特殊定制需求,否则直接使用 Redisson 是最高效、最安全的选择。
分布式锁是一个看似简单,实则暗藏玄机的技术点。