分布式锁 Redis 实现源码分析:SETNX、过期时间与可重入设计

复制代码
如果这篇文章对你有帮助,欢迎关注我的CSDN账号「来福猿」,
有问题可以在评论区留言,我会一一回复。

**摘要:**分布式锁是解决跨进程并发访问共享资源的关键机制。本文从源码层面剖析 Redis 分布式锁的三大要点------SETNX 加锁、过期时间设置与可重入设计,并适用于库存扣减、订单更新、定时任务等并发场景。

1. 引言

想象一下:多个服务实例同时扣减同一件商品的库存,稍有不慎就会出现超卖。要保证数据一致,就必须让这些跨进程的访问互斥起来,这正是分布式锁的用武之地。Redis 凭借高性能和丰富的数据结构,成为实现分布式锁最常用的中间件。本文将从源码层面剖析其实现,重点拆解 SETNX 加锁、过期时间设置与可重入设计这三个核心问题。

2. 为什么需要分布式锁

在单机环境下,我们可以借助语言层面的锁(如 Java 的 synchronized、ReentrantLock)来保证线程安全。但在分布式架构中,多个应用实例运行在不同的 JVM 或进程中,本地锁无法跨进程生效。此时就需要一个所有实例都能访问的第三方协调者来统一管理锁的获取与释放,Redis 正是这样一个轻量级且高效的协调者。

一个合格的分布式锁需要满足以下基本要求:

  • 互斥性:在任意时刻,只有一个客户端能持有锁。
  • 安全性:锁的持有者只能释放自己持有的锁,不能误删他人的锁。
  • 死锁避免:即使持有锁的客户端崩溃,锁也能在超时后自动释放。
  • 可重入性:同一客户端在持有锁的情况下可以再次获取同一把锁。

3. SETNX 命令与加锁实现

SETNX 是 Redis 中 "SET if Not eXists" 的缩写,即只有当键不存在时才设置成功。这是实现分布式锁最基础的原子操作。在 Redis 2.6.12 版本之前,开发者通常组合使用 SETNX 和 EXPIRE 两条命令来加锁和设置过期时间,但这种方式存在原子性问题:如果 SETNX 成功之后、EXPIRE 执行之前客户端崩溃,锁将永远不会过期,从而造成死锁。

为了解决这个问题,Redis 官方在 2.6.12 版本之后推荐使用带扩展参数的 SET 命令,将加锁和设置过期时间合并为一次原子操作:

bash 复制代码
SET lock_key unique_value NX PX 30000

这条命令的含义是:仅当 lock_key 不存在时设置成功,同时设置 30000 毫秒的过期时间。其中 NX 参数等价于 SETNX 的语义,PX 指定过期时间。下面我们通过一段 Java 代码演示基于 Spring Data Redis 的加锁实现:

java 复制代码
public boolean tryLock(String lockKey, String requestId, long expireMillis) {
    Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireMillis, TimeUnit.MILLISECONDS);
    return Boolean.TRUE.equals(result);
}

这里将 requestId 作为锁的 value,是为了在释放锁时校验当前客户端是否仍然持有锁,避免误删其他客户端设置的锁。

4. 过期时间与死锁防护

过期时间是分布式锁设计中至关重要的一环。它的核心作用是防止持有锁的客户端在运行过程中发生崩溃、网络分区或长时间 GC 停顿,导致锁无法被正常释放,进而阻塞其他客户端。通过设置合理的过期时间,Redis 会在键到期后自动删除,从而保证锁最终能被其他客户端获取。

过期时间的选择需要权衡两个因素:

  • 业务执行时间:过期时间必须大于业务逻辑的最长执行时间,否则锁会在业务尚未完成时提前释放,造成并发冲突。
  • 故障恢复时间:过期时间越短,锁被提前释放的风险越高;过期时间越长,客户端崩溃后其他客户端等待的时间越长。

在实际工程中,一种常见的优化手段是引入"看门狗"机制:客户端在获取锁之后,启动一个后台定时任务,在锁即将过期时自动续期,直到业务执行完毕主动释放锁。Redisson 的 watchdog 就是这一思路的典型实现,其默认续期时间为锁过期时间的三分之一。

5. 释放锁的原子性校验

5.1 实战:Spring Boot 封装 Redis 分布式锁工具类

前面我们从原理层面理解了加锁、释放锁的原子性校验以及过期时间防护。下面我们把这些知识落地,在 Spring Boot 项目中封装一个基于 Redis 的分布式锁工具类,包含 tryLock、unlock 方法,并处理锁超时、重试和异常情况。

首先引入依赖(以 Maven 为例):

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后编写分布式锁工具类,核心思路如下:

  • tryLock :使用 SET key value NX PX 原子命令加锁,支持设置过期时间和重试次数。
  • unlock:通过 Lua 脚本先校验持有者身份再删除,保证释放操作的原子性。
  • 异常处理:加锁失败时按重试策略等待后重试,释放锁时捕获异常避免影响业务主流程。
java 复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
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;
@Component
public class RedisDistributedLock {
private final StringRedisTemplate redisTemplate;
// 释放锁的 Lua 脚本:先校验 value 再删除,保证原子性
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
public RedisDistributedLock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
尝试获取锁,支持重试
@param lockKey      锁的 key
@param expireMillis 锁的过期时间(毫秒)
@param retryTimes   重试次数(0 表示不重试)
@param retryWaitMs  每次重试的等待时间(毫秒)
@return 成功返回唯一标识(用于释放锁),失败返回 null
*/
public String tryLock(String lockKey, long expireMillis, int retryTimes, long retryWaitMs) {
String requestId = UUID.randomUUID().toString();
int attempt = 0;
while (attempt &lt;= retryTimes) {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, expireMillis, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(success)) {
return requestId;
}
attempt++;
if (attempt &lt;= retryTimes) {
try {
Thread.sleep(retryWaitMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
}
return null;
}
/**
释放锁(原子性校验)
@param lockKey   锁的 key
@param requestId 加锁时返回的唯一标识
@return 是否释放成功
*/
public boolean unlock(String lockKey, String requestId) {
try {
DefaultRedisScript&lt;Long&gt; script = new DefaultRedisScript&lt;&gt;(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(lockKey),
requestId
);
return Long.valueOf(1L).equals(result);
} catch (Exception e) {
// 释放锁失败不应影响业务主流程,记录日志后返回 false
return false;
}
}
}

使用示例:在业务方法中先加锁,执行完业务逻辑后在 finally 块中释放锁,确保锁一定能被释放:

java 复制代码
@Service
public class OrderService {
private final RedisDistributedLock lock;
public OrderService(RedisDistributedLock lock) {
this.lock = lock;
}
public void deductStock(String productId) {
String lockKey = "lock:product:" + productId;
// 尝试加锁,最多重试 3 次,每次等待 100ms
String requestId = lock.tryLock(lockKey, 30000, 3, 100);
if (requestId == null) {
throw new RuntimeException("获取分布式锁失败,请稍后重试");
}
try {
// 模拟业务逻辑:扣减库存
System.out.println("扣减库存:" + productId);
} finally {
lock.unlock(lockKey, requestId);
}
}
}

上述实现完整覆盖了加锁、释放锁、锁超时、重试和异常处理等关键场景,可直接在 Spring Boot 项目中落地使用。

释放锁时,我们必须确保只有锁的持有者才能删除锁。如果直接调用 DEL 命令删除锁,可能会发生以下问题:客户端 A 持有锁后因业务执行时间过长导致锁过期,客户端 B 获取到同一把锁,此时 A 业务执行完毕执行 DEL,误删了 B 的锁。为了避免这种情况,释放锁时必须先校验 value 是否等于自己写入的 requestId,再执行删除。

校验和删除必须是原子操作,否则在"校验通过"和"执行删除"之间仍可能被其他客户端抢占。因此,我们通常借助 Lua 脚本来保证原子性:

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

对应的 Java 调用代码如下:

java 复制代码
public boolean unlock(String lockKey, String requestId) {
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(script, Long.class),
        Collections.singletonList(lockKey),
        requestId
    );
    return Long.valueOf(1L).equals(result);
}

通过 Lua 脚本,Redis 保证了"取值比对"和"删除"两个操作在同一原子执行单元内完成,从根本上杜绝了误删他人锁的问题。

6. 可重入设计

可重入锁允许同一个客户端在已经持有锁的情况下再次获取同一把锁。这在业务中存在嵌套调用时非常有用,例如一个方法内部调用了另一个同样需要加锁的方法。如果锁不可重入,就会发生死锁。

基于 Redis 实现可重入锁的常见方案是使用 Hash 数据结构,以锁名为 key,以客户端标识为 field,以重入次数为 value。每次加锁时,如果 field 已存在,则对 value 执行自增;每次释放锁时,对 value 执行自减,当 value 减到 0 时才真正删除整个 Hash 键。下面给出加锁的 Lua 脚本:

lua 复制代码
if redis.call("exists", KEYS[1]) == 0 then
    redis.call("hset", KEYS[1], ARGV[1], 1)
    redis.call("pexpire", KEYS[1], ARGV[2])
    return 1
end
if redis.call("hexists", KEYS[1], ARGV[1]) == 1 then
    redis.call("hincrby", KEYS[1], ARGV[1], 1)
    redis.call("pexpire", KEYS[1], ARGV[2])
    return 1
end
return 0

释放锁的 Lua 脚本如下:

lua 复制代码
if redis.call("hexists", KEYS[1], ARGV[1]) == 0 then
    return nil
end
local count = redis.call("hincrby", KEYS[1], ARGV[1], -1)
if count > 0 then
    redis.call("pexpire", KEYS[1], ARGV[2])
    return 0
else
    redis.call("del", KEYS[1])
    return 1
end

这种基于 Hash 的实现既支持同一客户端的重入计数,又能在锁被完全释放后自动清理键,是 Redisson 等成熟框架采用的经典方案。

7. 主从复制与 RedLock 算法

上述实现基于单机 Redis,在 Redis 主从架构下存在一个隐患:客户端 A 在主节点上成功加锁,但主节点在将数据同步到从节点之前发生故障,此时从节点被提升为新的主节点,但锁数据已经丢失,客户端 B 可以再次加锁成功,从而破坏互斥性。

为了解决这个问题,Redis 作者提出了 RedLock 算法。其核心思想是:在多个相互独立的 Redis 节点上依次尝试加锁,只有当超过半数节点加锁成功,并且总耗时小于锁的过期时间时,才认为加锁成功。RedLock 通过引入多节点冗余来降低单点故障带来的锁丢失风险,但它在极端场景下仍存在争议,例如发生时钟跳跃或 GC 停顿导致锁提前过期时,仍可能出现多个客户端同时持有锁的情况。

因此,在实际业务中需要根据一致性要求权衡:对于允许少量并发冲突的场景,单机 Redis 加锁配合合理的过期时间通常已经足够;对于强一致要求极高的场景,则需要引入 RedLock 或改用 ZooKeeper、etcd 等具备强一致性保证的协调服务。

8. 总结

本文从源码层面剖析了基于 Redis 的分布式锁实现,核心要点可以归纳为以下三点:

  • 加锁:使用 SET key value NX PX 原子命令,将加锁与设置过期时间合并,避免死锁。
  • 释放:通过 Lua 脚本先校验持有者身份再删除,保证释放操作的原子性,防止误删他人锁。
  • 可重入:基于 Hash 数据结构记录重入次数,支持同一客户端的嵌套加锁。

此外,过期时间的合理设置、看门狗续期机制以及主从架构下的 RedLock 方案,都是生产环境中需要重点考虑的设计细节。希望本文的分析能帮助你更深入地理解 Redis 分布式锁的原理,并在实际项目中做出更合理的技术选型。

相关推荐
xiangyun6144 分钟前
C++ list 模拟实现(一):手撕双向链表,从节点到迭代器
数据库·c++·算法·链表·stl·list
wespaul1 小时前
一条自然语言命令的 49 个节点:可视化编排与每段强校验
前端·数据库
KIDULT°9 小时前
MySQL系列 (1):数据库原理
运维·数据库·mysql·云计算
我不是程序员三三10 小时前
透明加密软件偶发解密异常排错实录:文件时能打开时乱码
数据库·域智盾
snhna10 小时前
向量数据库深度实战:从 HNSW 到 pgvector、Milvus 选型与 RAG 落地
数据库·人工智能·python·postgresql·milvus
天行健,君子而铎10 小时前
数据分类分级的范式转换:从规则匹配到场景化高准确率一键部署
网络·数据库·安全
牛战士coding11 小时前
MySQL 读写分离完整链路:主从复制、多数据源与 @DS 路由
数据库·mysql
goehou11 小时前
MCP 查数实战:让 LLM 安全地查数据库(只读账号、SQL 校验、参数漂移)
数据库·ai·llm·教程·mcp·function calling
我不会插花弄玉12 小时前
7.函数【由浅入深-MySQL】
android·数据库·mysql