1
0
0

Redis 分布式锁:手动实现原理与存在的问题

2022-01-26
2026-10-26
文章摘要
|

一、Redis 分布式锁的实现原理

Redis 分布式锁主要利用 SET NX EX 实现,核心是保证多个线程或进程之间的互斥访问。

手动实现时,可以定义一个 ILock 接口,封装获取锁和释放锁的方法。

1. 获取锁

使用 Redis 的 SET 命令:

SET lock:order:1001 uuid:threadId NX EX 30

参数说明:

参数

作用

Key

业务锁标识

Value

UUID + 线程 ID,标识锁的持有者

NX

Key 不存在时才能创建,保证互斥

EX 30

设置 30 秒过期时间,避免锁永久不释放

Spring Boot 中可以使用:

Boolean success = stringRedisTemplate.opsForValue()
    .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);

其中:

  • lockKey:业务锁 Key,例如 lock:order:1001

  • lockValue:唯一标识,例如 UUID + 线程ID

注意:不能只使用线程 ID,因为不同 JVM 中可能存在相同的线程 ID。推荐使用全局唯一标识。

2. 释放锁

释放锁需要考虑两个问题:

问题一:不能误删其他线程的锁。

例如:

  1. 线程 A 获取锁,TTL 为 30 秒。

  2. 线程 A 业务执行时间过长,锁自动过期。

  3. 线程 B 获取了同一把锁。

  4. 线程 A 执行完成,如果直接执行 DEL,就会误删线程 B 的锁。

解决方案:释放锁时,必须比较 Redis 中的锁标识与当前线程持有的锁标识是否一致,只有一致才能删除。

问题二:判断锁归属和删除锁必须具有原子性。

如果分开执行 GET 和 DEL,两个命令之间可能发生锁过期和其他线程获取锁的情况。

因此,需要使用 Lua 脚本:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

Redis 执行该 Lua 脚本时,可以保证判断和删除操作的原子性。

二、手动实现分布式锁的主要问题

1. 不可重入

使用简单的 SET NX 实现的锁默认不支持重入。

例如:

  • 线程 A 已经获取锁。

  • 线程 A 再次申请同一把锁。

  • 由于 Key 已经存在,第二次获取失败。

解决方案:记录锁的持有者和重入次数。Redisson 使用 Redis Hash 结构实现可重入锁。

2. 不支持自动重试

简单实现中,获取锁失败后通常直接返回失败。

解决方案:增加重试机制,例如循环尝试、等待间隔、最大等待时间等。

注意:SET NX 本身并非不能重试,而是简单封装通常没有实现重试机制。

3. 锁超时释放问题

当业务执行时间超过锁的 TTL 时:

  1. 线程 A 获取锁。

  2. 线程 A 因业务阻塞或 Full GC 暂停执行。

  3. 锁 TTL 到期,Redis 自动释放锁。

  4. 线程 B 获取锁,开始执行业务。

  5. 线程 A 恢复执行,导致两个线程可能同时操作相同数据。

问题本质:锁提前过期,破坏业务互斥性。

解决方案:

  • 使用 Redisson 看门狗机制自动续期。

  • 对强一致性要求较高的业务,结合 Fencing Token、数据库乐观锁或条件更新等机制。

注意:看门狗不能完全避免超长时间 Full GC 导致的锁过期。

4. Redis 主从一致性问题

Redis 主从复制通常是异步的,可能出现锁丢失问题。

例如:

  1. 线程 A 在 Redis 主节点成功获取锁。

  2. 主节点还没将锁同步给从节点,就发生宕机。

  3. 从节点升级为新的主节点。

  4. 新主节点不存在之前的锁。

  5. 线程 B 成功获取锁,造成两个线程都认为自己拥有锁。

问题本质:Redis 异步复制可能造成锁状态丢失。

Redisson 的可重入、重试和看门狗机制,都不能从根本上消除普通 Redis 主从故障切换带来的锁丢失风险。

对于强一致性要求较高的业务,需要采用额外的数据一致性保护机制。

三、释放锁失败能否通过事务回滚解决?

不能完全解决。

假设线程 A 发生 Full GC,恢复执行时锁已经被线程 B 占用。

即使线程 A 执行业务完成后,在释放锁时发现锁标识不一致,并抛出异常,也不能保证业务一定回滚。

原因包括:

  • 数据库事务可能已经提交。

  • MQ 消息可能已经发送。

  • 第三方接口可能已经调用。

  • 即使数据库事务成功回滚,也不能保证其他外部操作同步回滚。

因此,Lua 脚本只能保证安全释放锁,不能保证锁过期后的业务互斥性。

对于需要严格防止过期锁持有者继续写入的场景,可以使用 Fencing Token,并要求数据库或下游系统原子地校验令牌、拒绝旧令牌。令牌生成机制本身也必须可靠。

四、手动实现与 Redisson 对比

功能

简单 SET NX 实现

Redisson RLock

互斥性

正常持锁期间支持

正常持锁期间支持

可重入

默认不支持

支持

等待重试

需要手动实现

支持

自动续期

不支持

看门狗机制

防止误删锁

需要 Lua 脚本

内置

主从切换锁丢失

有风险

普通异步复制部署仍有风险

实现复杂度

简单

封装完善

五、总结

手动实现 Redis 分布式锁,需要记住以下几个核心知识点:

  1. 获取锁: 使用 SET NX EX,保证加锁与设置过期时间的原子性。

  2. 唯一标识: 使用 UUID 等唯一标识区分锁的持有者,不能只依赖线程 ID。

  3. 安全释放: 使用 Lua 脚本原子地判断锁归属并删除,避免误删其他线程的锁。

  4. 不可重入: 简单 SET NX 锁不支持重入,需要维护锁的持有者和重入次数。

  5. 不可自动重试: 简单实现通常没有等待重试机制,需要额外开发。

  6. 超时释放: TTL 过期可能破坏业务互斥性,看门狗可以缓解,但不是绝对保证。

  7. 主从一致性: Redis 异步复制可能导致锁丢失,需要根据业务一致性要求采取额外措施。

实际开发建议: Java 8 + Spring Boot 项目中,优先使用 Redisson,而不是自己实现完整的分布式锁。对于资金扣减、库存扣减等重要业务,不能只依赖分布式锁,还需要结合数据库约束、条件更新或其他一致性保护手段。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论