082-缓存模块-基于 Redis Lua 脚本实现分布式锁与原子操作实战

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> 接收返回值,调用方根据结果判断执行状态。


四、代码全景解读

项目代码分为三个部分:

  1. ScriptType 枚举 :定义了四个脚本类型,每个枚举值关联一个 classpath 下的 .lua 文件,并提供 getScript() 方法加载为 RedisScript<Long> 实例。
  2. RedisScriptExecutor :核心执行器,注入 RedisTemplateDecorator(实际为 StringRedisTemplate 的装饰器),内部缓存脚本实例,提供四个原子操作方法:
    • setIfAbsent → 加锁
    • deleteIfSame → 解锁
    • checkAndSet → 原子性 CAS 更新
    • checkAndResetExpire → 锁续期
  3. 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 脚本不仅适用于锁,还广泛应用于限流、计数、消息队列等场景,掌握它等于拥有了构建高性能分布式系统的利器。

相关推荐
aibo8603082 小时前
基于 Spring Boot + Vue3 的博物馆藏品管理系统设计与实现
java·spring boot·后端
zzu123zsw2 小时前
把条件校验收敛到枚举接口:Spring Boot 统一断言模式与零污染异常切面工程化
java·spring boot·后端·架构设计
code_slave(码畜)2 小时前
微服务架构落地:分库分表实战(下) —— 分布式主键、跨分片查询与分页聚合防坑
数据库·spring boot·分布式·spring cloud·微服务·架构·分库分表
zzu123zsw3 小时前
面对多 RPC 扇入超时崩溃:深入 asyncTool 核心 DAG 编排与线程池隔离防护
java·网络·spring boot·网络协议·rpc·并发编程
aibo8603084 小时前
基于 Spring Boot + Vue3 的爱心众筹管理系统设计与实现
java·spring boot·后端
aibo8603085 小时前
基于 Spring Boot + Vue3 的餐饮食材采购与成本分析系统设计与实现
java·spring boot·后端
aibo8603085 小时前
基于 Spring Boot + Vue3 的菜谱分享网站设计与实现
java·spring boot·后端
凤山老林5 小时前
Spring Boot 集成 TOTP 双因素认证与恢复码机制
java·spring boot·后端
vipxieliang5 小时前
Spring Boot 测试策略:单元测试、切片测试与集成测试
java·spring boot·后端