Redis远不止缓存
在深入分布式锁之前,让我们先快速浏览一下 Redis 在现代应用架构中扮演的多样化角色:
- 数据共享与会话管理 (Data Sharing & Session Management) :在分布式 Web 服务中,用户的 Session 信息需要被多个服务实例共享。Redis 作为内存数据库,提供了高速的读写能力,是实现分布式 Session 共享的理想选择。
- 计数器与排行榜 (Counters & Leaderboards) :利用 Redis 的原子性操作
INCR
、DECR
,可以轻松实现高并发场景下的计数功能,如文章阅读量、点赞数等。而其ZSET
(有序集合) 数据结构,则能完美地应用于构建实时排行榜,如热搜榜、积分榜等。 - 消息队列 (Message Queue) :通过
LPUSH
/RPOP
(List) 或Streams
数据类型,可以实现一个轻量级的消息队列,用于服务间的异步通信和任务解耦。 - 社交与推荐 (Social & Recommendations) :
SET
(集合) 数据结构可以方便地处理用户关注、共同好友等关系。利用其交集、并集、差集运算,可以快速构建简单的推荐模型,如“可能认识的人”。 - 地理空间服务 (Geospatial) :Redis 的
GEO
数据类型支持存储地理位置信息,并能进行距离计算、范围查找等操作,可用于实现“附近的人”或“附近的餐厅”等功能。 - 位统计 (Bitmaps) :通过位操作,可以高效地记录海量用户的状态信息,如用户签到、打卡等。这种方式极大地节省了内存空间。
- 全局ID生成 (Global ID Generation) :利用
INCR
命令的原子性,可以生成全局唯一的序列号,作为分布式系统中的唯一标识符。
而今天要深入探讨的,是 Redis 在分布式协调方面的核心应用——分布式锁。
核心挑战:构建一个可靠的分布式锁
为什么需要分布式锁?
在单体应用中,我们通常使用 synchronized
关键字或 java.util.concurrent.locks.Lock
接口来处理并发问题。这些锁机制依赖于 JVM 内存,确保了在同一个虚拟机内,多线程对共享资源的互斥访问。
然而,在微服务架构下,系统被拆分成多个独立的服务,部署在不同的机器上。这时,JVM 层面的锁就失去了作用,因为多个服务实例(即多个 JVM)之间无法感知彼此的锁状态。为了保证在不同节点上的多个线程能够互斥地访问共享资源,我们需要一个所有服务都能访问到的“全局锁”,这就是分布式锁。
一个“合格”的分布式锁应具备哪些条件?
在着手实现之前,我们必须明确一个优秀的分布式锁应该满足以下核心条件:
- 互斥性 (Exclusivity) :在任何时刻,只有一个客户端(线程)能够持有锁。
- 高可用 (High Availability) :锁服务应该是高可用的。即使部分节点宕机,加锁和解锁操作也应能正常进行。
- 防死锁 (Deadlock Prevention) :即使持有锁的客户端崩溃或发生网络分区,未能主动释放锁,也必须有机制保证锁最终能够被释放,避免其他客户端永久无法获取锁。
- 安全性 (Safety) :锁不能被误释放。一个客户端不能释放掉其他客户端持有的锁。
- 可重入性 (Reentrancy) :同一个客户端(在同一个线程中)在持有锁的情况下,可以再次请求并获取到该锁,而不会被自己阻塞。
Redis 分布式锁的“进化之路”
了解了基本原则后,让我们来看一下如何基于 Redis 一步步构建出一个可靠的分布式锁。
版本一:天真的 SETNX
SETNX key value
(SET if Not eXists) 是 Redis 中一个基础的原子命令。如果 key
不存在,则设置 key
为 value
并返回 1;如果 key
已存在,则不做任何操作并返回 0。利用这个特性,我们可以实现一个最基础的锁:
# 尝试获取锁
SETNX lock_key "any_value"
问题所在:这版实现非常脆弱。如果一个客户端获取锁后,在执行业务逻辑时崩溃了,它就永远无法执行 DEL lock_key
来释放锁。这将导致其他所有客户端都无法再获取该锁,造成“死锁”。
版本二:SETNX
+ EXPIRE
的“死亡组合”
为了解决死锁问题,我们自然会想到给锁加上一个过期时间。
# 尝试获取锁
SETNX lock_key "any_value"
# 如果获取成功,设置一个30秒的过期时间
EXPIRE lock_key 30
问题所在:SETNX
和 EXPIRE
是两个独立的命令,它们并非原子操作。如果在执行完 SETNX
后,客户端还没来得及执行 EXPIRE
就崩溃了,死锁问题依然会发生。这是一个非常经典的错误用法。
版本三:原子操作的 SET ... NX ... EX
Redis 后续版本为 SET
命令提供了扩展参数,使得“加锁”和“设置过期时间”这两个操作可以合并为一条原子命令,彻底解决了版本二的问题。
# 一条命令完成加锁和设置过期时间,保证原子性
SET lock_key "unique_value" EX 30 NX
-
EX 30
:设置过期时间为 30 秒。 -
NX
:等同于SETNX
,确保只有在key
不存在时才设置成功。
这看起来已经很不错了,但它仍然存在一个严重的缺陷。
版本四:锁的“所有权”问题
考虑以下场景:
- 客户端 A 获取了锁,过期时间为 30 秒。
- 客户端 A 的业务逻辑执行时间超过了 30 秒(例如,发生了长时间的 GC 或网络延迟)。
- 在 A 执行期间,锁因超时而自动释放。
- 客户端 B 此时发现锁已释放,于是成功获取了该锁。
- 客户端 A 的业务逻辑终于执行完毕,它执行
DEL lock_key
命令来释放锁。 - 问题出现:客户端 A 释放了本应由客户端 B 持有的锁!
解决方案:为了保证锁不被误删,我们必须在 value
中存入一个当前客户端的唯一标识(如 UUID 或线程 ID)。在释放锁时,先验证该锁的 value
是否与自己的唯一标识相符,如果相符才能删除。
-- 使用 Lua 脚本保证“获取、比较、删除”的原子性
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
通过执行上述 Lua 脚本来释放锁,可以确保每个客户端都只能释放自己持有的锁。
版本五:锁的“自动续期”与“看门狗”
虽然我们解决了锁的误释放问题,但新的问题又来了:如果业务逻辑的执行时间真的超过了锁的过期时间,锁被自动释放,其他客户端就能获取到锁,这同样会破坏互斥性。
我们不能简单地把过期时间设置得非常长,因为这会增加在客户端崩溃时,锁长时间不被释放的风险。一个更优雅的方案是自动续期。
解决方案:当一个客户端成功获取锁后,启动一个后台线程(通常称为“看门狗”,Watchdog),在锁的过期时间到达之前,定期检查客户端是否还持有锁。如果持有,就自动延长锁的过期时间。当业务逻辑执行完毕,客户端主动释放锁时,这个“看门狗”线程也随之停止。
这样就保证了只要客户端没有崩溃,它的锁就不会因为执行时间过长而意外过期。