Redis 分布式锁的实现原理与常见问题

举报
yd_232225224 发表于 2026/08/29 19:07:31 2026/08/29
【摘要】 在单体应用中,如果多个线程同时操作一份数据,我们通常可以使用 synchronized、ReentrantLock 之类的方式进行加锁。但当一个服务部署了多个实例之后,情况就不一样了。JVM 中的锁只能限制当前进程里的线程,不同服务器之间并不知道对方有没有加锁。例如一个订单服务部署了三个实例,同时收到针对同一个订单的处理请求。三个实例都有可能读取数据库并开始执行后续操作,这时单纯使用进程内的...

在单体应用中,如果多个线程同时操作一份数据,我们通常可以使用 synchronizedReentrantLock 之类的方式进行加锁。但当一个服务部署了多个实例之后,情况就不一样了。JVM 中的锁只能限制当前进程里的线程,不同服务器之间并不知道对方有没有加锁。

例如一个订单服务部署了三个实例,同时收到针对同一个订单的处理请求。三个实例都有可能读取数据库并开始执行后续操作,这时单纯使用进程内的锁已经无法解决问题。分布式锁就是为了解决这类场景产生的,而 Redis 因为性能高、支持原子操作,本身又有 Key 过期机制,所以经常被用来实现分布式锁。

最简单的 Redis 锁

Redis 中有一个 SETNX 命令,含义是 SET if Not Exists,也就是只有 Key 不存在时才进行设置。

例如:

SETNX lock:order:10001 1

第一次执行时,由于 lock:order:10001 不存在,因此设置成功。其他服务器再次执行同样的命令时,因为这个 Key 已经存在,所以设置失败。

业务处理完成之后再删除这个 Key:

DEL lock:order:10001

这样看起来,一个最简单的 Redis 分布式锁就实现了。谁先创建成功这个 Key,谁就获得锁,其他服务器只能等待或者直接放弃本次操作。

不过这种写法实际上存在一个很明显的问题。

假设服务器成功执行了 SETNX,但是在业务处理过程中突然宕机,那么后面的 DEL 就永远不会执行。Redis 中的锁一直存在,其他服务器也就永远拿不到这把锁。

因此,实际使用时必须给锁设置一个过期时间。

为什么不能 SETNX 之后再设置过期时间

最容易想到的改进方式是先执行:

SETNX lock:order:10001 1

成功之后再执行:

EXPIRE lock:order:10001 30

这样锁在 30 秒以后就会自动删除,即使服务器发生异常,也不会永久占用。

问题在于,这实际上是两条独立的 Redis 命令。

如果服务器刚执行完 SETNX 就宕机了,还没有来得及执行 EXPIRE,这个 Key 仍然会变成一个没有过期时间的永久锁。

所以加锁和设置过期时间必须作为一个原子操作完成。现在通常直接使用 SET 命令:

SET lock:order:10001 8f4c2a NX PX 30000

这里的 NX 表示只有 Key 不存在时才进行设置,PX 30000 表示 Key 的有效时间为 30000 毫秒,也就是 30 秒。

如果命令返回 OK,说明成功获得了锁;如果返回空值,则说明当前已经有其他客户端持有这把锁。

这样即使持有锁的服务器突然宕机,30 秒之后 Redis 也会自动删除这个 Key,不会造成永久死锁。

锁的 Value 为什么不能随便写

上面的例子中,Value 写的是:

8f4c2a

实际项目中这个值一般应该是一个能够唯一标识当前锁持有者的随机值,例如 UUID,而不是简单地写成 1 或者 locked

这主要是为了防止错误释放其他客户端的锁。

假设服务器 A 获得了一把有效期为 10 秒的锁,但是因为数据库查询或者网络问题,业务执行了 15 秒。执行到第 10 秒的时候,Redis 中的锁已经自动过期。

此时服务器 B 又成功获得了这把锁。

又过了 5 秒,服务器 A 终于执行完成。如果 A 此时直接执行:

DEL lock:order:10001

它删除的其实已经不是自己的锁,而是服务器 B 刚刚获得的锁。

这样服务器 C 又可以获得锁,最终 B 和 C 就可能同时执行原本应该互斥的业务。

因此 Redis 锁的 Value 需要保存一个唯一值,例如:

SET lock:order:10001 550e8400-e29b-41d4-a716-446655440000 NX PX 30000

释放锁的时候只有发现 Value 仍然是自己当初写进去的 UUID,才能删除这个 Key。

解锁也需要保证原子性

知道了这一点以后,很容易写出这样的代码逻辑:

GET lock:order:10001

判断是不是自己的 UUID

DEL lock:order:10001

但这里又出现了和前面类似的问题:GETDEL 是两个操作。

假设客户端通过 GET 确认锁属于自己,但就在执行 DEL 之前,这个锁刚好过期。另一个客户端随后获得了新的锁,此时原来的客户端再执行 DEL,依然会把别人的锁删除。

因此,“判断锁是不是自己的”和“删除锁”同样需要保证原子性。

Redis 中通常使用 Lua 脚本完成:

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

脚本首先读取锁对应的 Value,如果和当前客户端保存的 UUID 一致,就删除这个 Key,否则什么都不做。

Redis 执行 Lua 脚本时能够保证这段操作不会被其他命令插入,因此不会出现刚检查完 Value,锁就被别人重新获取的问题。

这样,一个比较基本的 Redis 分布式锁才算完整。

锁的过期时间应该设置多久

引入过期时间虽然解决了服务器宕机导致锁无法释放的问题,但同时又带来了另一个麻烦:到底应该设置多长时间?

如果时间设置得很长,比如一个正常只需要 500ms 的任务设置了 5 分钟,那么服务器一旦发生异常,其他客户端可能需要等待很长时间才能重新处理这个任务。

但设置得太短同样有问题。

例如锁设置 5 秒,而某次业务因为数据库响应变慢实际执行了 8 秒,那么第 5 秒时锁就已经自动失效。其他服务器此时可以重新获得锁,于是两个服务器又会同时执行这段业务。

所以锁的过期时间并不是随便设置一个数字就可以。对于执行时间相对固定的任务,可以根据正常执行时间以及一定的冗余量设置 TTL。如果任务执行时间非常不确定,就需要考虑自动续期。

一些 Redis 分布式锁实现会使用类似“看门狗”的机制。客户端成功获得锁以后,后台定期检查业务是否仍然执行,如果锁仍然属于当前客户端,就延长它的过期时间。等业务真正执行完成,再停止续期并主动释放锁。

不过续期操作同样需要判断锁的所有权,不能看到 Key 存在就直接延长 TTL,否则可能给其他客户端后来获得的锁续期。

获取锁失败以后怎么办

另一个实际开发中经常遇到的问题是,如果没有获得锁应该怎么处理。

最简单的方式是直接返回失败,这种方式比较适合不要求一定执行成功的业务。但有些任务必须等待前一个任务完成,这时就需要进行重试。

一种比较差的写法是不断循环请求 Redis:

while not get_lock():
    pass

大量客户端同时这样做,会不断向 Redis 发送请求。锁竞争比较激烈时,不仅浪费 CPU,还可能给 Redis 带来没有必要的压力。

更合理的方式是每次失败以后等待一段时间再重试,例如等待几十毫秒或者几百毫秒。为了避免大量客户端在同一时间重新发起请求,还可以加入一个随机的等待时间,也就是经常提到的 jitter。

具体应该选择立即失败、固定时间重试还是退避重试,需要根据业务本身决定,并不存在一种适用于所有情况的方案。

Redis 主从切换带来的问题

前面的实现建立在一个重要前提上:Redis 本身不会丢失这把锁。

但如果使用 Redis 主从架构,这个前提并不一定成立。

假设客户端 A 在 Redis Master 上成功获得锁,但是这个锁的数据还没有同步到 Replica,Master 就发生故障。Redis 随后将 Replica 提升为新的 Master。

因为新的 Master 上没有刚才那个 Key,所以客户端 B 又能够成功获得同一把锁。

此时 A 和 B 都可能认为自己拥有锁,互斥关系就被破坏了。

这也是为什么不能简单认为“用了 Redis 分布式锁以后业务就绝对不会并发执行”。单 Redis 实例实现的分布式锁本身存在明确的可靠性边界,如果业务要求非常严格的强一致性,就需要进一步考虑 Redis 故障切换、网络分区等问题,或者选择基于 ZooKeeper、etcd 等一致性系统实现的协调机制。

Redis 官方还提出过 Redlock 算法,通过多个相互独立的 Redis 节点来提高分布式锁在节点故障情况下的安全性。不过 Redlock 本身涉及更多分布式系统中的时间、故障和一致性问题,如果只是普通业务系统,并不一定需要一开始就使用这么复杂的方案。

分布式锁也不是万能的

实际项目中还有一个很容易出现的误区,就是一遇到并发问题就加分布式锁。

分布式锁本身会增加额外的 Redis 请求和等待时间,在竞争严重的情况下还可能降低整个系统的吞吐量。有些问题其实可以通过数据库唯一索引、乐观锁、消息队列或者幂等设计解决,并不一定需要分布式锁。

例如防止同一个订单重复创建,如果能够通过数据库唯一约束从数据层保证订单号不会重复,那么即使上层出现并发请求,最终数据仍然不会产生重复。这种约束往往比单纯依赖 Redis 锁更加可靠。

所以 Redis 分布式锁更适合解决“多个服务实例需要协调同一份资源”的问题,而不应该成为所有并发问题的默认解决方案。

总结

Redis 实现分布式锁的原理并不复杂,本质上还是利用一个所有服务实例都能访问的 Key 来判断某个资源当前是否正在被使用。真正麻烦的是各种异常情况。

一个基本可用的 Redis 分布式锁,至少需要使用 SET key value NX PX timeout 原子地完成加锁和设置过期时间,Value 使用能够标识锁持有者的唯一值,并且在释放锁时通过 Lua 脚本判断锁的所有权后再删除。

在这个基础上,还要根据实际业务考虑锁提前过期、自动续期、获取失败后的重试以及 Redis 自身发生故障时的问题。业务对一致性的要求越高,需要考虑的情况也就越多。

所以 Redis 分布式锁真正值得理解的地方,并不是记住一条 SET NX PX 命令,而是理解为什么加锁、过期时间、唯一 Value 和原子解锁这些东西缺一不可。把这些问题弄明白以后,再去使用 Redisson 或其他封装好的分布式锁组件,也会更容易理解它们内部到底解决了什么问题。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。