分布式锁,就像是你在一个大办公室里,大家都要用同一个打印机。为了避免大家同时打印导致文件混乱,我们需要一个"排队管理员"。这个管理员就是分布式锁。当一个人要用打印机时,他先去管理员那里"拿号"(获取锁),用完后"还号"(释放锁),这样下一个人才能用。
Redis 实现这个"管理员"的原理,就是利用它的一些特性,比如:
- 原子性操作 :Redis 的某些命令(比如
SETNX
或SET
命令的扩展)能保证"拿号"和"设置过期时间"这两个动作要么都成功,要么都失败,不会出现只拿了号但没设置过期时间,导致号永远被占用的情况。 - 过期时间:即使拿号的人突然"失联"了(程序崩溃),这个号也会在一定时间后自动失效,避免死锁。
- 唯一标识:每个"号"都有一个独特的标记,确保只有"拿号"的人才能"还号",别人不能乱还。
宏观目标与微观步骤:从底层原理理解分布式锁
我们来想象一下,你是一个系统架构师,现在要设计一个分布式系统,其中有一个共享资源(比如库存数量),多个服务实例(比如多个订单处理服务)都可能同时去修改它。如果没有锁,就可能出现"超卖"或者数据不一致的问题。
宏观目标:
确保在分布式环境下,对共享资源的访问是互斥的,即同一时间只有一个客户端能操作该资源。
微观步骤(Redis 实现分布式锁的思考过程):
1. 最原始的想法:SETNX
(Set if Not Exists)
- 思考: 我怎么知道这个"打印机"现在有没有人在用?最简单的方法就是,如果没人用,我就在打印机旁边贴个"我正在用"的纸条。
- Redis 命令:
SETNX lock_key unique_value
lock_key
:就是"打印机"的名字,比如product:123:lock
。unique_value
:一个随机的、唯一的字符串,比如 UUID。这就像是你的"签名",证明是你贴的纸条。
- 工作原理:
- 如果
lock_key
不存在,SETNX
会设置它,并返回 1(成功)。表示你成功拿到了锁。 - 如果
lock_key
已经存在,SETNX
不会做任何操作,并返回 0(失败)。表示锁已经被别人拿走了。
- 如果
- 释放锁:
DEL lock_key
。用完后把纸条撕掉。
返回 1 (成功) 返回 0 (失败) 返回 0 (失败)
(锁已被A持有) 返回 1 (成功) 客户端A: 尝试获取锁 执行 SETNX lock_key unique_value_A 客户端A: 成功获取锁
执行业务逻辑 客户端A: 获取锁失败
等待或重试 客户端A: 业务逻辑完成 客户端A: 释放锁
执行 DEL lock_key 客户端B: 尝试获取锁 执行 SETNX lock_key unique_value_B 客户端B: 获取锁失败
等待或重试 客户端B: 再次尝试获取锁
(等待A释放后) 执行 SETNX lock_key unique_value_B 客户端B: 成功获取锁
执行业务逻辑 客户端B: 业务逻辑完成 客户端B: 释放锁
执行 DEL lock_key
- 问题: 如果客户端 A 拿到锁后,在执行业务逻辑时突然崩溃了,没有来得及
DEL
锁,怎么办?这个锁就永远不会被释放,导致死锁!
2. 引入过期时间:EXPIRE
- 思考: 纸条不能永远贴着,万一我"失联"了,纸条也得自动掉下来。
- Redis 命令:
SETNX lock_key unique_value
EXPIRE lock_key timeout_seconds
- 工作原理: 拿到锁后,立即给锁设置一个过期时间。
- 问题:
SETNX
和EXPIRE
是两个独立的操作。如果SETNX
成功了,但在执行EXPIRE
之前,客户端崩溃了,怎么办?还是死锁!
3. 原子性地设置锁和过期时间:SET
命令的扩展
- 思考: 我需要一个"原子操作",即"贴纸条"和"设置纸条自动掉落时间"这两个动作必须同时成功或同时失败。
- Redis 命令:
SET lock_key unique_value NX EX timeout_seconds
NX
:只在lock_key
不存在时才设置。EX timeout_seconds
:设置过期时间(秒)。PX timeout_milliseconds
:设置过期时间(毫秒)。
- 工作原理: Redis 2.6.12 版本引入了这个强大的
SET
命令扩展,完美解决了原子性问题。 - 释放锁:
DEL lock_key
。
返回 OK (成功) 返回 nil (失败) 返回 nil (失败)
(锁已被A持有) 返回 OK (成功) 客户端A: 尝试获取锁 执行 SET lock_key unique_value_A NX EX timeout 客户端A: 成功获取锁
执行业务逻辑 客户端A: 获取锁失败
等待或重试 客户端A: 业务逻辑完成 客户端A: 释放锁
执行 DEL lock_key 客户端B: 尝试获取锁 执行 SET lock_key unique_value_B NX EX timeout 客户端B: 获取锁失败
等待或重试 客户端B: 再次尝试获取锁
(等待A释放后) 执行 SET lock_key unique_value_B NX EX timeout 客户端B: 成功获取锁
执行业务逻辑 客户端B: 业务逻辑完成 客户端B: 释放锁
执行 DEL lock_key
- 问题: 客户端 A 拿到锁后,执行业务逻辑时间过长,导致锁自动过期了。此时客户端 B 拿到了锁。然后客户端 A 业务逻辑执行完毕,去
DEL
锁,结果把客户端 B 的锁给删掉了!这叫"误删"或"锁被提前释放"。
4. 解决误删问题:唯一标识 + Lua 脚本
- 思考: 纸条上不仅要有"自动掉落时间",还得有我的"签名"。只有我自己的签名,我才能撕掉。
- Redis 命令: 释放锁时,使用 Lua 脚本。
-
获取锁: 仍然是
SET lock_key unique_value NX EX timeout_seconds
。 -
释放锁(Lua 脚本):
luaif redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
KEYS[1]
:就是lock_key
。ARGV[1]
:就是unique_value
(你的签名)。
-
- 工作原理:
- 获取锁时,将一个随机的、唯一的
unique_value
作为锁的值。 - 释放锁时,先检查
lock_key
对应的值是否是自己设置的unique_value
。 - 如果是,才执行
DEL
操作。 - 这个"检查"和"删除"必须是原子性的,Redis 通过 Lua 脚本来保证。因为 Lua 脚本在 Redis 中是作为一个整体执行的,不会被其他命令打断。
- 获取锁时,将一个随机的、唯一的
返回 OK (成功) 返回 nil (失败) 是 否 返回 nil (失败)
(锁已被A持有) 返回 OK (成功) 是 否 客户端A: 尝试获取锁 执行 SET lock_key unique_value_A NX EX timeout 客户端A: 成功获取锁
执行业务逻辑 客户端A: 获取锁失败
等待或重试 客户端A: 业务逻辑完成 客户端A: 释放锁
执行 Lua 脚本 Lua 脚本: GET lock_key == unique_value_A ? Lua 脚本: DEL lock_key
(成功释放锁) Lua 脚本: 不删除
(避免误删) 客户端B: 尝试获取锁 执行 SET lock_key unique_value_B NX EX timeout 客户端B: 获取锁失败
等待或重试 客户端B: 再次尝试获取锁
(等待A释放后) 执行 SET lock_key unique_value_B NX EX timeout 客户端B: 成功获取锁
执行业务逻辑 客户端B: 业务逻辑完成 客户端B: 释放锁
执行 Lua 脚本 Lua 脚本: GET lock_key == unique_value_B ? Lua 脚本: DEL lock_key
(成功释放锁) Lua 脚本: 不删除
(避免误删)
- 思考: 这样就完美了吗?
- 问题: Redis 是单点部署的。如果 Redis 实例挂了,或者 Redis 发生了主从切换,怎么办?
- 单点故障: Redis 实例挂了,锁服务就不可用了。
- 主从切换: 如果客户端 A 在主节点获取了锁,但这个锁还没来得及同步到从节点,主节点就挂了。从节点升级为主节点后,这个锁就"丢失"了,客户端 B 可能会再次获取到同一个锁,导致两个客户端同时持有锁!
5. Redlock 算法:多实例投票机制
- 思考: 单个管理员不靠谱,那就找多个管理员,大家投票决定。
- 原理: Redlock 是 Redis 官方提出的一种分布式锁算法,它要求客户端尝试在多个独立的 Redis 实例 (通常是 5 个)上获取锁。
- 客户端获取当前时间戳。
- 客户端尝试在所有 Redis 实例上以相同的
lock_key
和unique_value
获取锁,并设置一个较短的过期时间(比如 50ms)。 - 客户端计算获取锁所花费的时间。
- 如果客户端在大多数(N/2 + 1,比如 5 个实例中至少 3 个)实例上成功获取了锁,并且获取锁的总时间小于锁的有效时间,那么客户端才认为成功获取了锁。
- 如果获取锁失败(没有在大多数实例上获取,或者时间超时),客户端会尝试释放所有实例上的锁(即使是未成功获取的)。
- 优点: 提高了分布式锁的可用性和可靠性,降低了单点故障和主从切换带来的风险。
- 缺点: 复杂性高,性能开销大,实际应用中争议较多。对于大多数业务场景,基于单 Redis 实例的锁(带过期时间和唯一标识)已经足够。
基于上述思考,最常用且相对可靠的 Redis 分布式锁实现方案是:使用 SET key value NX EX seconds
获取锁,并使用 Lua 脚本原子性地释放锁。
核心思想:
- 互斥性:
NX
保证只有一个客户端能成功设置键。 - 防死锁:
EX
保证锁有过期时间,即使客户端崩溃也能自动释放。 - 防误删:
value
使用唯一标识,释放锁时校验,确保只有持有锁的客户端才能释放。 - 原子性:
SET
命令的NX EX
选项和 Lua 脚本保证操作的原子性。
Java 示例代码:
java
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.UUID;
public class RedisDistributedLock {
private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
/**
* 获取分布式锁
* @param jedis Jedis客户端实例
* @param lockKey 锁的键名
* @param requestId 请求ID,用于标识请求,防止误删
* @param expireTime 锁的过期时间(秒)
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
// SET lockKey requestId NX EX expireTime
// NX: 只在键不存在时设置
// EX: 设置过期时间(秒)
// PX: 设置过期时间(毫秒)
String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
return LOCK_SUCCESS.equals(result);
}
/**
* 释放分布式锁
* @param jedis Jedis客户端实例
* @param lockKey 锁的键名
* @param requestId 请求ID,用于标识请求,防止误删
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
// Lua 脚本:先判断值是否相等,再删除
// KEYS[1] -> lockKey
// ARGV[1] -> requestId
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
return RELEASE_SUCCESS.equals(result);
}
public static void main(String[] args) {
// 假设你有一个Jedis实例连接到Redis
Jedis jedis = new Jedis("localhost", 6379); // 连接到本地Redis
String lockKey = "my_resource_lock";
String requestId = UUID.randomUUID().toString(); // 生成一个唯一的请求ID
int expireTime = 10; // 锁的过期时间10秒
System.out.println("客户端A尝试获取锁...");
if (tryGetDistributedLock(jedis, lockKey, requestId, expireTime)) {
System.out.println("客户端A成功获取锁!requestId: " + requestId);
try {
// 模拟业务逻辑执行
System.out.println("客户端A正在执行业务逻辑...");
Thread.sleep(5000); // 模拟业务耗时5秒
System.out.println("客户端A业务逻辑执行完毕。");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("客户端A尝试释放锁...");
if (releaseDistributedLock(jedis, lockKey, requestId)) {
System.out.println("客户端A成功释放锁。");
} else {
System.out.println("客户端A释放锁失败(可能锁已过期或被其他客户端持有)。");
}
}
} else {
System.out.println("客户端A获取锁失败,锁已被其他客户端持有。");
}
// 模拟另一个客户端尝试获取锁
System.out.println("\n客户端B尝试获取锁...");
String requestIdB = UUID.randomUUID().toString();
if (tryGetDistributedLock(jedis, lockKey, requestIdB, expireTime)) {
System.out.println("客户端B成功获取锁!requestId: " + requestIdB);
// 模拟业务逻辑
try {
System.out.println("客户端B正在执行业务逻辑...");
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("客户端B尝试释放锁...");
if (releaseDistributedLock(jedis, lockKey, requestIdB)) {
System.out.println("客户端B成功释放锁。");
} else {
System.out.println("客户端B释放锁失败(可能锁已过期或被其他客户端持有)。");
}
}
} else {
System.out.println("客户端B获取锁失败,锁已被其他客户端持有。");
}
jedis.close(); // 关闭Jedis连接
}
}
运行上述代码,你会看到类似这样的输出:
客户端A尝试获取锁...
客户端A成功获取锁!requestId: 1a2b3c4d-e5f6-7890-1234-567890abcdef
客户端A正在执行业务逻辑...
客户端B尝试获取锁...
客户端B获取锁失败,锁已被其他客户端持有。
客户端A业务逻辑执行完毕。
客户端A尝试释放锁...
客户端A成功释放锁。
客户端B尝试获取锁...
客户端B成功获取锁!requestId: fedcba98-7654-3210-fedc-ba9876543210
客户端B正在执行业务逻辑...
客户端B尝试释放锁...
客户端B成功释放锁。
如果说我们前面讨论的 Redis 分布式锁的底层实现,就像是给你提供了"砖头、水泥、钢筋"这些基础材料,让你自己去盖房子。那么 Redisson 就像是一个专业的建筑公司,它不仅用这些材料帮你盖好了房子,还给你提供了:
- 更高级、更方便的"户型":不仅仅是简单的互斥锁,还有读写锁、公平锁、信号量、闭锁等等。
- 更智能的"施工方案":比如自动续期(看门狗)、锁重入、集群模式下的高可用等,这些都是你自己用基础材料很难搞定,或者搞定起来非常麻烦的。
- 更完善的"装修和配套设施":不仅仅是锁,还有分布式集合、队列、Map 等等,把 Redis 的各种数据结构都包装成了 Java 对象,让你用起来就像操作本地对象一样简单。
Redisson 的核心价值在于,它将 Redis 的能力抽象化、工程化、产品化,让开发者能够以更高级、更安全、更便捷的方式使用 Redis 来构建分布式应用,而无需关心底层复杂的细节和潜在的坑。
Redisson 实现了什么?------从"盖房子"的角度看
我们前面自己用 SET NX EX
和 Lua 脚本实现的锁,就像是自己用砖头搭了个简易的棚子。虽然能用,但离真正的"房子"还差得远。Redisson 做的,就是把这个"棚子"升级成了功能齐全、安全可靠的"别墅"。
它主要在以下几个方面进行了增强和扩展:
1. 自动续期(Watchdog / 看门狗机制)
- 思考: 我们自己实现的锁,过期时间是固定的。如果业务逻辑执行时间超过了锁的过期时间,锁就会被自动释放,导致"误删"问题(虽然我们用
requestId
避免了自己删别人的锁,但自己的锁被别人拿走,仍然是问题)。这就像你租了个房子,租期到了,你还没搬走,房东就把房子租给别人了。 - Redisson 怎么做: Redisson 引入了一个"看门狗"机制。当你成功获取锁后,Redisson 会启动一个后台线程(看门狗),它会定时检查 你的业务逻辑是否还在执行。如果还在执行,它会自动延长锁的过期时间(默认是 30 秒),直到你的业务逻辑执行完毕并主动释放锁。
- 底层原理: Redisson 会在获取锁时,给锁设置一个默认的过期时间(比如 30 秒)。同时,它会启动一个定时任务,每隔
expireTime / 3
(默认 10 秒)就去执行一个 Lua 脚本,这个脚本会检查锁是否存在且是当前客户端持有的,如果是,就将锁的过期时间重置为expireTime
。
成功 是 否 客户端A: 尝试获取锁 Redisson: SET lock_key unique_id EX 30 客户端A: 成功获取锁
启动看门狗线程 看门狗线程: 定时检查
(每10秒) 看门狗: 锁是否存在且是当前客户端持有? 看门狗: 延长锁过期时间
(EXPIRE lock_key 30) 看门狗: 停止续期
(锁已释放或过期) 客户端A: 执行业务逻辑 客户端A: 业务逻辑完成 客户端A: 释放锁
(DEL lock_key)
2. 可重入锁(Reentrant Lock)
- 思考: Java 中的
ReentrantLock
允许同一个线程多次获取同一个锁。我们自己实现的 Redis 锁,如果一个线程已经获取了锁,它再次尝试获取同一个锁时,会因为SET NX
失败而获取不到,导致死锁或逻辑错误。 - Redisson 怎么做: Redisson 的
RLock
实现了 Java 的Lock
接口,支持可重入。它在 Redis 中存储锁时,不仅仅是存储一个unique_id
,而是使用一个 Hash 结构 来存储:lock_key
(Hash)unique_id:thread_id
(Field):count
(Value)
unique_id:thread_id
是客户端 ID 和线程 ID 的组合,确保唯一性。count
记录了当前线程获取锁的次数。
- 底层原理:
- 获取锁: 第一次获取时,设置 Hash 字段
unique_id:thread_id
为 1,并设置过期时间。后续同一个线程再次获取时,会增加count
值。 - 释放锁: 每次释放锁时,
count
减 1。当count
变为 0 时,才真正删除整个lock_key
。 - 这些操作都是通过 Lua 脚本保证原子性。
- 获取锁: 第一次获取时,设置 Hash 字段
成功 (count=1) 成功 (count=2) 客户端A (线程T1): 尝试获取锁 Redisson: Lua脚本
HINCRBY lock_key unique_id:T1 1
EXPIRE lock_key 30 客户端A (T1): 成功获取锁
(看门狗启动) 客户端A (T1): 再次尝试获取锁 Redisson: Lua脚本
HINCRBY lock_key unique_id:T1 1
EXPIRE lock_key 30 客户端A (T1): 再次获取锁成功 客户端A (T1): 业务逻辑完成 客户端A (T1): 释放锁 (第一次) Redisson: Lua脚本
HINCRBY lock_key unique_id:T1 -1
(count=1) 客户端A (T1): 释放锁 (第二次) Redisson: Lua脚本
HINCRBY lock_key unique_id:T1 -1
(count=0) Redisson: Lua脚本
DEL lock_key
(锁完全释放)
3. 公平锁与非公平锁
- 思考: 如果多个客户端都在等待获取同一个锁,谁先获取到?我们自己实现的锁是"非公平"的,谁抢到了就是谁的。
- Redisson 怎么做: Redisson 提供了公平锁(
RLock.lock()
)和非公平锁(RLock.tryLock()
)。公平锁会维护一个等待队列,确保等待时间最长的客户端优先获取锁。 - 底层原理: 公平锁通常会结合 Redis 的有序集合(Sorted Set)来实现一个等待队列,将等待的客户端按照时间戳排序。
4. 读写锁(ReadWriteLock)
- 思考: 有些场景下,多个客户端可以同时读取资源,但写入时必须互斥。我们自己实现的锁是排他锁,读写都互斥。
- Redisson 怎么做: Redisson 提供了
RReadWriteLock
。它允许:- 多个读锁可以同时被持有。
- 写锁是排他的,当写锁被持有时,任何读锁或写锁都不能被获取。
- 当读锁被持有时,写锁不能被获取。
- 底层原理: 内部会维护两个锁:一个读锁,一个写锁。通常会利用 Redis 的 Hash 结构来记录读锁的数量和写锁的状态。
5. 信号量(Semaphore)
- 思考: 我想限制某个资源的并发访问数量,比如数据库连接池,最多只能有 10 个连接同时被使用。
- Redisson 怎么做: Redisson 提供了
RSemaphore
,类似于 Java 的Semaphore
。你可以指定一个最大许可数,客户端通过acquire()
获取许可,release()
释放许可。 - 底层原理: 通常会使用 Redis 的 List 或 Set 来存储可用的许可,或者直接使用一个计数器。
6. 闭锁(CountDownLatch)
- 思考: 我想让一个或多个线程等待,直到其他线程完成了一组操作。
- Redisson 怎么做: Redisson 提供了
RCountDownLatch
,类似于 Java 的CountDownLatch
。 - 底层原理: 使用 Redis 的计数器,当计数器减到 0 时,所有等待的客户端都会被通知。
7. 分布式集合、队列、Map 等
- 思考: 除了锁,我还需要在分布式环境下共享数据结构,比如一个所有服务都能访问的 Set、List 或 Map。
- Redisson 怎么做: Redisson 将 Redis 的各种数据结构(String, List, Set, ZSet, Hash, HyperLogLog, Stream)都封装成了对应的 Java 接口和类,比如
RList
,RSet
,RMap
等。你可以像操作本地 Java 集合一样操作它们,Redisson 会自动将操作转换为 Redis 命令。 - 底层原理: 内部通过 Redis 的各种数据结构命令(如
LPUSH
,SADD
,HSET
等)来实现。
8. 集群模式支持
- 思考: 如果我的 Redis 是集群模式(Master-Slave, Sentinel, Cluster),Redisson 能否正常工作?
- Redisson 怎么做: Redisson 对各种 Redis 部署模式都有很好的支持,它能自动发现节点、处理故障转移、进行请求路由等。
- 底层原理: Redisson 客户端内部实现了对 Redis 集群协议的解析和管理,能够智能地将命令发送到正确的节点。
9. 连接管理与线程池
- 思考: 频繁地创建和关闭 Redis 连接会很耗性能,如何高效管理连接?
- Redisson 怎么做: Redisson 内部实现了连接池,复用连接,提高性能。
- 底层原理: 维护一个连接池,客户端从池中获取连接,用完后归还。
Redisson 就像一个全能的 Redis 客户端框架,它不仅仅是实现了分布式锁,更是将 Redis 的强大功能以 Java 开发者最熟悉、最便捷的方式暴露出来。它解决了我们自己实现分布式锁时遇到的各种复杂问题(如死锁、误删、重入、高可用等),并提供了更丰富的分布式数据结构和同步器,极大地降低了分布式系统开发的门槛和复杂度。
你提到了一个非常关键且深刻的问题:"Redis 不保证事务失败回滚" 。这确实是 Redis 事务(MULTI
/EXEC
)的一个特性,它与传统关系型数据库的事务概念有所不同。那么,这个特性对我们前面讨论的 Redis 分布式锁会有什么影响呢?
Redis事务不回滚对分布式锁有影响吗?
Redis 的事务更像是一个"批处理"或者"命令队列",它保证的是原子性执行 (要么都执行,要么都不执行,中间不会被其他客户端的命令插队),但它不保证回滚。如果事务中的某个命令执行失败(比如语法错误,或者操作了错误的数据类型),Redis 不会撤销之前已经成功执行的命令。
对于我们实现的分布式锁,这个特性几乎没有负面影响,甚至可以说,我们是利用了 Redis 命令的原子性,而不是依赖其事务的回滚能力。
深入思考:Redis 事务与分布式锁的关系
我们来回顾一下分布式锁的几个核心操作:
- 获取锁:
SET lock_key unique_value NX EX timeout_seconds
- 释放锁: Lua 脚本
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
1. 获取锁:SET lock_key unique_value NX EX timeout_seconds
这个命令本身就是一个原子操作 。它不是一个 Redis 事务(MULTI
/EXEC
)中的多个命令。
- 原子性: Redis 保证
SET
命令的NX
和EX
选项是原子执行的。这意味着,要么lock_key
被成功设置了unique_value
并且设置了过期时间,要么就什么都没发生(因为lock_key
已经存在)。不会出现只设置了值但没设置过期时间的情况。 - 回滚问题: 在这里,根本不存在"回滚"的需求。因为这是一个单一的原子命令,它要么成功,要么失败,没有中间状态需要回滚。
2. 释放锁:Lua 脚本
我们使用 Lua 脚本来释放锁,其目的是为了保证**"检查锁的持有者"和"删除锁"这两个操作的原子性**。
- 原子性: Redis 保证 Lua 脚本作为一个整体是原子执行的。在脚本执行期间,不会有其他客户端的命令插入进来。
- 回滚问题:
- 如果
redis.call("get", KEYS[1]) == ARGV[1]
为true
,那么redis.call("del", KEYS[1])
会被执行。如果del
命令本身因为某种原因失败(比如 Redis 内存不足,这在实际中极少发生),那么脚本会返回 0,表示删除失败。但由于get
已经成功,且del
是最后一个操作,没有后续操作需要回滚。 - 如果
redis.call("get", KEYS[1]) == ARGV[1]
为false
,那么del
命令根本就不会被执行。所以也不存在回滚问题。
- 如果
核心点在于: 我们利用的是 Redis 命令(包括 Lua 脚本)的原子性 ,而不是 Redis 事务的回滚能力。Redis 的原子性保证了单个命令或单个 Lua 脚本的执行是不可分割的,要么全部完成,要么全部不完成,不会出现执行到一半中断的情况。
Redis 事务(MULTI/EXEC)的局限性与分布式锁的无关性
为了更清晰地理解,我们再来看看 Redis 事务的"不回滚"特性。
假设你执行一个 Redis 事务:
redis
MULTI
SET mykey "hello"
LPUSH mylist "world" # 假设 mylist 是一个字符串,这里会报错
EXEC
在传统关系型数据库中,LPUSH
报错会导致整个事务回滚,mykey
不会被设置。但在 Redis 中:
SET mykey "hello"
会被成功执行。LPUSH mylist "world"
会报错,但 Redis 不会回滚SET mykey "hello"
的操作。EXEC
会返回一个结果列表,其中包含SET
的成功结果和LPUSH
的错误信息。
为什么 Redis 这样设计?
- 性能考虑: Redis 追求极致的性能。如果每次命令失败都要回滚,会增加复杂性和开销。
- 简单性: Redis 的设计哲学是简单。它认为大多数命令的失败是由于编程错误(如操作了错误的数据类型),而不是业务逻辑错误。这种错误应该由客户端在收到错误信息后自行处理,而不是由 Redis 自动回滚。
- 单线程模型: Redis 的单线程模型保证了命令的顺序执行和原子性,这使得它不需要像多线程数据库那样复杂的锁和回滚机制来保证数据一致性。
对分布式锁的影响:
正如前面分析的,我们分布式锁的实现并没有依赖 MULTI
/EXEC
这种 Redis 事务模式。我们依赖的是:
- 单个 Redis 命令的原子性:
SET ... NX EX
。 - Lua 脚本的原子性: 脚本中的所有命令作为一个整体执行,不会被中断。
因此,Redis 事务"不回滚"的特性,对我们基于 SET NX EX
和 Lua 脚本实现的分布式锁,没有任何负面影响。 我们的锁机制是健壮的,因为它利用了 Redis 最核心的原子性保证。