082-缓存模块-基于 Redis Lua 脚本实现分布式锁与原子操作实战
本文章代码: gitee , gitcode , github
一、引言
在分布式系统中,锁(Lock)是实现资源互斥访问、防止并发冲突的核心手段。Redis 凭借其高性能和丰富的数据结构,成为实现轻量级分布式锁的常用中间件。然而,分布式锁的实现必须满足原子性 ------加锁、解锁、续期等操作如果分成多条命令执行,会因网络延迟或客户端崩溃导致状态不一致。Redis 提供的 Lua 脚本功能,允许我们将多个操作封装为一个原子单元,从而完美解决这一问题。
本文将从一套实际项目中的代码出发,深入剖析如何通过 Lua 脚本封装 Redis 操作,实现一套健壮的分布式锁工具,并提供原子性的条件更新与过期续期能力。
二、分布式锁的核心需求与实现挑战
一个可靠的分布式锁通常需要满足以下条件:
- 互斥性:同一时刻只有一个客户端能持有锁。
- 防死锁:锁必须设置过期时间,避免因客户端异常而永久占用。
- 持有者识别:锁的值应为唯一标识(如 UUID),确保只有持有者才能释放或续期。
- 原子性:加锁、解锁等组合操作必须整体执行,不可拆分为多条命令。
这些需求单独使用 Redis 命令(如 SETNX、GET、DEL)很难同时满足原子性。例如,解锁时需要先 GET 比较值,再 DEL,如果这两步之间发生竞态,可能导致误删其他客户端的锁。Lua 脚本正是解决这类问题的标准方案。
三、Redis Lua 脚本与原子性保障
Redis 在执行 Lua 脚本时,会将整个脚本作为一个原子事务处理,期间不会执行其他客户端命令。这使得我们可以在脚本中组合多个 Redis 命令,并基于当前状态做出决策,而无需担心并发干扰。
本文涉及的脚本均返回 1 表示操作成功,0 表示失败(delete_if_same 对键不存在返回 1 是特殊处理)。Java 端通过 DefaultRedisScript<Long> 接收返回值,调用方根据结果判断执行状态。
四、代码全景解读
项目代码分为三个部分:
- ScriptType 枚举 :定义了四个脚本类型,每个枚举值关联一个 classpath 下的
.lua文件,并提供getScript()方法加载为RedisScript<Long>实例。 - RedisScriptExecutor :核心执行器,注入
RedisTemplateDecorator(实际为StringRedisTemplate的装饰器),内部缓存脚本实例,提供四个原子操作方法:setIfAbsent→ 加锁deleteIfSame→ 解锁checkAndSet→ 原子性 CAS 更新checkAndResetExpire→ 锁续期
- Lua 脚本文件:每个文件实现一种原子操作逻辑。
这种设计将脚本加载、缓存和执行分离,提高了复用性和可维护性。
五、四大核心 Lua 脚本详解
5.1 set_if_absent.lua ------ 加锁(SET IF ABSENT)
lua
if redis.call('exists', KEYS[1]) == 0 then
redis.call('psetex', KEYS[1], tonumber(ARGV[2]), ARGV[1])
return 1
else
return 0
end
- 功能:原子性地检查键是否存在,若不存在则设置值并指定毫秒级过期时间。
- 参数 :
KEYS[1]为锁键名,ARGV[1]为持有者标识(如 UUID),ARGV[2]为过期毫秒数。 - 返回值 :
1表示加锁成功,0表示键已存在(锁已被占用)。 - 特点 :使用
psetex一步完成赋值和过期时间设置,避免了SETNX+EXPIRE的非原子组合。
5.2 delete_if_same.lua ------ 安全解锁
lua
local value = redis.call('GET', KEYS[1])
if not value then
return 1
end
if value == ARGV[1] then
local val = redis.call('DEL', KEYS[1])
if val == 1 then
return 1
end
end
return 0
- 功能:仅当键存在且当前值等于传入标识时,删除该键。
- 特殊处理 :若键不存在,返回
1(视为成功)。这种设计使得解锁操作幂等------即使锁已因过期而消失,调用方仍认为解锁成功,避免重复释放报错。 - 原子性 :
GET比较和DEL在脚本内完成,防止误删其他客户端的锁。
5.3 check_and_set.lua ------ 原子性条件更新(CAS)
lua
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
- 功能:实现 Compare-And-Set 语义,仅当键的当前值等于期望旧值时,将其设置为新值。
- 用途:除了分布式锁,还可用于乐观锁控制(如版本号更新)。在锁场景中,可用于安全地修改锁持有的业务数据。
- 返回值 :
1表示更新成功,0表示值不匹配或键不存在。
5.4 check_and_reset_expire.lua ------ 锁续期(续命)
lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
- 功能:仅当键存在且值匹配时,重置该键的过期时间(单位毫秒)。
- 用途:当业务逻辑执行时间可能超过锁的初始过期时间时,持有者可以主动续期,防止锁提前释放。
- 返回值 :
pexpire返回1表示设置成功,脚本直接将其作为返回值;若值不匹配,返回0。
六、Java 执行器设计与使用示例
RedisScriptExecutor 使用 ConcurrentHashMap 缓存脚本实例,避免重复加载。每个方法均先校验参数非空,然后获取脚本,通过 StringRedisTemplate.execute 执行,最后根据返回值判断布尔结果。
典型使用流程:
java
@Autowired
private RedisScriptExecutor executor;
// 1. 加锁
String uuid = UUID.randomUUID().toString();
boolean locked = executor.setIfAbsent("lock:order:123", uuid, 30000); // 30秒
if (locked) {
try {
// 执行业务逻辑...
// 2. 续期(每10秒检查一次)
while (stillProcessing) {
executor.checkAndResetExpire("lock:order:123", uuid, 30000);
Thread.sleep(10000);
}
} finally {
// 3. 解锁
executor.deleteIfSame("lock:order:123", uuid);
}
}
注意事项:
- 锁的值必须全局唯一,建议使用 UUID 或雪花算法 ID。
- 过期时间应根据业务平均耗时合理设置,续期间隔应小于过期时间的一半,避免续期失败导致锁提前释放。
checkAndSet可用于锁持有期间更新锁关联的业务状态,但需谨慎使用。
七、潜在问题与改进建议
7.1 时钟漂移与过期时间
Redis 的过期时间依赖于服务器时钟,若主从切换或时钟跳跃可能导致过期时间不准确。建议监控 Redis 时钟同步,并适当增加过期时间容错。
7.2 脚本返回值类型统一
所有脚本均返回 Long(0/1),但 delete_if_same 在键不存在时返回 1 会导致调用方认为"删除成功",这可能掩盖某些错误。如果业务上需要明确区分"值不匹配"和"键不存在",可以修改脚本返回值(如返回 -1 表示键不存在)。
7.3 Redis 集群环境
上述脚本均操作单个键,在 Redis Cluster 中可正常工作(只要所有键在同一个 slot)。若后续扩展为多键操作,需确保使用 Hash Tag 使键分布在相同 slot。
7.4 锁的可重入性
当前实现不支持可重入,即同一线程多次加锁会因键已存在而失败。若需要可重入,可在脚本中添加计数逻辑(如使用 Hash 存储持有者 ID 和重入次数)。
八、总结
通过 Lua 脚本将多条 Redis 命令捆绑为原子操作,是实现分布式锁和复杂条件更新的最佳实践。本文分析的代码提供了一套完整的、可直接嵌入项目的锁工具,涵盖了加锁、解锁、CAS 更新和续期四个核心场景。其设计清晰,扩展性强,为开发者提供了可靠的原子操作模板。
在实际应用中,请根据业务场景调整过期时间、续期策略,并充分考虑异常处理(如网络超时、脚本加载失败等)。Redis Lua 脚本不仅适用于锁,还广泛应用于限流、计数、消息队列等场景,掌握它等于拥有了构建高性能分布式系统的利器。