===
本文从一个生动的"抢购演唱会门票"场景将起,花5分钟带你搞懂分布式锁。
一、引言
1.1 库存超卖
你最爱的歌手突然宣布加场演唱会,门票在晚上8点准时开抢。
作为铁杆粉丝,你早已摩拳擦掌,准备在开售瞬间拿下门票。
8点整,你和成千上万的用户一起疯狂点击"购买"按钮。系统后台,请求如洪水般涌来。
如果,此时数据库里只剩最后一张票。多个处理请求的服务器,同时执行了以下操作:
-
「读取库存」:从数据库读取到,库存还剩 1 张。
-
「判断库存」:程序判断,1 > 0,库存充足,可以下单。
-
「扣减库存」:程序执行库存减一操作,并将结果写回数据库。
如果这三步操作不是原子性的, 可能发生的情况是:
-
「服务器A」 读取库存为1,判断库存充足,可以购买。
-
在A准备扣减库存的之前,「服务器B」 也读取了库存,此时库存仍然是1,B也认为库存充足,也可以购买。
-
于是,A和B都认为自己抢到了票,都执行了扣减库存的操作,最终数据库的库存可能变成了-1。
服务器代码如下:
csharp
public void deductStock() {
// 1. 从数据库查询库存
int stock = database.getStock("concert_ticket");
// 2. 判断库存是否充足
if (stock > 0) {
// 处理业务逻辑
process_business() // 请求A执行到这里,尚未扣减库存
// 3. 扣减库存
database.setStock("concert_ticket", stock - 1);
System.out.println("恭喜,抢票成功!");
} else {
System.out.println("抱歉,票已售罄。");
}
}
这就是典型的 「库存超卖」 问题,是分布式系统中最经典的并发场景之一。
1.2 问题根源:并发下的资源竞争
这个问题的根源在于 「共享资源」 (这里的库存)在 「并发环境」 下被多个参与者 「竞争访问」。
在单体应用中,我们可以用编程语言提供的锁(如Java的 synchronized
或 ReentrantLock
)来轻松解决。
当一个线程访问库存时,它会先"锁"住这个资源,其他线程只能等待,直到锁被释放。

但在分布式架构下,系统被部署在多台独立的服务器上,每个服务器都有自己的JVM。
Java的内置锁只能保证在**「同一个JVM进程内」**的线程互斥,无法跨越服务器的边界。
这时,我们就需要一个更强大的"锁"来维持秩序------「分布式锁」。
1.3 什么是分布式锁?
假设大楼里有多个团队(服务器),他们都需要使用同一个会议室(共享资源)。

为了避免冲突,大楼物业(锁服务)只提供一把会议室的钥匙。
-
「获取锁」:任何团队想用会议室,必须先到前台去拿这把唯一的钥匙。
-
「持有锁」:拿到钥匙的团队可以使用会议室,其他团队只能在前台排队等待。
-
「释放锁」:使用完毕后,团队必须立即归还钥匙,以便下一个等待的团队使用。
这个"会议室钥匙"就是分布式锁。
它在整个分布式系统中,提供一个**「全局唯一的信物」**,确保在任意时刻,只有一个客户端能够持有它,从而获得对共享资源的 「独占访问权」。
❝
「思考题」:除了库存超卖,你还能想到哪些业务场景也需要用到分布式锁来保证数据一致性?
❞
二、如何实现一个分布式锁?
目前最主流、最高效的分布式锁方案,无疑是基于 「Redis」 的方案。
Redis 实现分布式锁的优势在于:
-
「高性能」:基于内存操作,读写速度极快,不会成为系统的性能瓶颈。
-
「支持原子操作」 :Redis提供了多种原子命令(如
SETNX
),这是实现分布式锁的关键。 -
「成熟的生态」:有丰富的客户端库支持,在各种语言中都能轻松集成。
2.1 最简单的实现:SETNX + EXPIRE
实现分布式锁,最核心的就是要找到一种方法,来表达"我占用了这个位置"这个状态,并且这个表达方式必须是原子性的。

我们一般使用 Redis的 SETNX
命令(SET if Not eXists)来实现:
SETNX lock_key 1
:尝试设置一个键 lock_key
,值为 1
。
-
如果这个键不存在,则设置成功,命令返回1。
-
如果这个键已存在,则设置失败,命令返回0。
这样,一个最简单的加锁逻辑诞生了。
但是,这里有一个致命问题:如果一个客户端加锁成功后,业务代码还没执行完就意外崩溃了,它就永远没有机会释放锁了。这个锁将一直存在于Redis中,导致其他所有客户端都无法获取锁,造成 「"死锁"」。

为了解决这个问题,我们自然会想到给锁加上一个 「过期时间」。
scss
// 1. 尝试加锁
if (SETNX lock_key "any_value" == 1) {
// 2. 设置过期时间,比如30秒
EXPIRE lock_key 30;
// 3. 执行业务逻辑
process_business();
}
然而,SETNX
和 EXPIRE
是两条独立的命令,它们并非原子操作。
如果在执行完 SETNX
后,客户端在执行 EXPIRE
前崩溃了,死锁问题依然会发生。
2.2 原子性的保证
幸运的是,从 Redis 2.6.12 版本开始,SET
命令得到了极大的增强,它允许我们在一条命令里同时完成 SETNX
和 EXPIRE
的功能,从而保证了原子性。
SET key value [EX seconds] [PX milliseconds] [NX|XX]
-
EX seconds
:设置过期时间,单位为秒。 -
PX milliseconds
:设置过期时间,单位为毫秒。 -
NX
:只在键不存在时,才对键进行设置操作(等效于SETNX
)。 -
XX
:只在键已经存在时,才对键进行设置操作。
于是,我们的加锁操作就变成了下面这样:
scss
// 使用 SET 命令原子性地加锁并设置过期时间
// "OK" 表示加锁成功
if (SET lock_key "any_value" EX 30 NX == "OK") {
// 加锁成功,执行业务逻辑
process_business();
}
2.3 安全的释放:谁加的锁,谁来解
现在我们有了可靠的加锁方式,那么释放锁可以直接 DEL lock_key
?
答案是:「不行!」
设想一个场景:
-
「客户端A」 加锁成功,设置了30秒过期。
-
「客户端A」 的业务逻辑执行时间过长,超过了30秒,导致锁被Redis自动释放。
-
「客户端B」 在此时趁虚而入,成功获取了这把锁。
-
「客户端A」 的业务逻辑终于执行完毕,它执行
DEL lock_key
命令,结果把 **「客户端B」**刚刚加上的锁给释放了!
为了解决这个问题,我们需要确保 「"谁加的锁,就必须由谁来解"」。
实现方法是,在加锁时,将 value
设置成一个客户端的唯一标识(比如UUID或者一个随机字符串)。
在释放锁时,先获取锁的 value
,判断是否与自己的唯一标识相等,如果相等,才执行 DEL
操作。
但 GET
和 DEL
依然是两个独立的操作,不是原子性的。要保证原子性,我们需要借助 「Lua脚本」。
vbnet
-- 安全释放锁的Lua脚本
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
通过执行这个脚本,Redis会确保"判断"和"删除"两个动作在一个原子操作中完成。
❝
「思考题」:如果业务执行时间真的超过了锁的过期时间,除了让锁自动释放,还有没有更优雅的处理方式来防止业务中断?(提示:锁续期)
❞
三、面试常考题
掌握了上述实现,你已经能应对80%的场景了。
但在面试或设计复杂系统时,你可能还会遇到更深入的拷问。
3.1 锁的续期(Lease)机制
设想一个视频转码的场景。
用户上传了一个巨大的4K视频文件,转码任务获取了一个锁,防止其他任务重复处理。
我们预估转码需要10分钟,于是将锁的超时时间设为10分钟。
但如果遇到一个异常复杂的视频,实际转码耗时30分钟,会发生什么?
在第10分钟时,锁会自动释放,另一个空闲的转码服务器可能会"错误地"认为这个任务无人处理,也开始转码。
这不仅浪费了宝贵的计算资源,还可能导致最终文件写入冲突。
一个简单的办法是把过期时间设置得足够长。但这会导致因服务崩溃引发的超时重试,恢复时间变得更长。
这个问题的一般做法是:实现 「"锁续期"」 ,也叫 「看门狗(Watchdog)」 机制。
-
客户端在获取锁之后,会启动一个后台线程。
-
这个后台线程会定期(比如每隔10秒)检查锁是否存在,如果存在且即将过期,就为其延长有效期。
-
当业务执行完毕,客户端会主动释放锁,并停止续期线程。
❝
「思考题:」 想一下为什么看门狗机制可以解决这个问题?(提示: 这种情况下超时意味着什么?)
❞
很多成熟的Redis客户端(如Java的 「Redisson」)已经内置了这种机制,开箱即用。
3.2 主从切换导致的问题
我们之前的讨论都基于单个Redis实例。
但在生产环境中,为了保证高可用,Redis通常会以哨兵(Sentinel)或集群(Cluster)模式部署。这会引入一个新的问题:「主从切换时的数据一致性」。
-
客户端A从Master节点获取了锁。
-
在Master将锁信息同步到Slave节点之前,Master宕机了。
-
哨兵机制将一个Slave节点提升为新的Master。
-
客户端B从新的Master节点请求锁,由于数据没来得及同步,新Master上没有这个锁,于是客户端B也成功获取了锁。
这样一来,系统中就同时存在两个客户端持有同一把锁,分布式锁的互斥性被打破了。
3.3 Redlock:高可用分布式锁算法
如何构建一个高容错的锁机制呢?
Redis的作者提出了一种名为 「Redlock」(红锁)的算法。
Redlock的思想是,不再依赖单个Redis实例,而是向**「多个独立的Redis实例」**(建议是5个)申请锁。
「加锁过程」:
-
客户端记录当前时间戳。
-
依次向N个Redis实例发起加锁请求,并设置较短的超时时间。
-
客户端需要从**超过半数(N/2 + 1)**的实例上成功获取锁。
-
客户端计算获取锁的总耗时。如果总耗时小于锁的过期时间,并且成功获取了超过半数的锁,那么就认为加锁成功。
-
如果加锁失败,客户端需要向**「所有」**Redis实例发起锁的释放请求。
「释放锁」:客户端向所有Redis实例发起释放锁的操作。
Redlock通过"少数服从多数"的原则,极大地提高了分布式锁在极端情况下的可用性和可靠性。
但它也带来了更高的复杂度、网络开销和运维成本。
3.4 其他常考题
「1. 为什么不直接用数据库的行锁或表锁来实现分布式锁?」
「答:」 可以,但这通常不是最佳实践。原因有几点:
-
「性能开销大:」 数据库锁是基于磁盘操作,相比Redis的内存操作,性能有数量级的差距。在高并发场景下,数据库可能成为瓶颈。
-
「实现复杂:」 需要处理锁的超时、防止死锁、以及处理连接释放等问题,自己实现一个健壮的数据库锁比较复杂。
-
「不具备可重入性:」 数据库的排它锁通常不具备可重入性,一个线程在持有锁的情况下再次请求,会被自己阻塞。
-
「悲观锁与乐观锁:」 数据库锁多为悲观锁,在高并发下性能较差。而Redis实现可以更灵活地结合业务场景。
「2. Redisson是如何实现可重入锁的?」
「答:」 Redisson利用Redis的 「Hash」 数据结构来实现可重入锁。
当一个线程第一次获取锁时,它会在Hash中记录下自己的线程ID和一个计数器(初始为1)。
lock_key -> { "thread_id_1": 1 }
如果该线程再次尝试获取同一个锁,Redisson会检查Hash中是否存在该线程ID,如果存在,则直接将计数器加1。
释放锁时,则将计数器减1。只有当计数器减到0时,才会真正删除这个Hash键,释放锁。
四、总结
今天,我们从一个简单的秒杀场景出发,深入浅出介绍了分布式锁:
-
「我们理解了"为什么"」:为了解决分布式系统下,并发访问共享资源导致的数据不一致问题。
-
「我们掌握了"是什么"」:它就像一个全局唯一的信物,保证了操作的互斥性。
-
「我们学会了"怎么做"」:我们重点学习了基于Redis的实现,从最基础的 SETNX,到保证原子性的 SET ... NX EX ...,再到保证安全释放的Lua脚本。
-
「我们探讨了"面试常考题"」:我们了解了锁续期、主从切换带来的问题,并认识了为高可用而生的Redlock算法。
最后推荐大家一些 「扩展阅读」:
-
Redis官方关于分布式锁的介绍[1]: 最权威的参考资料,详细解释了基于Redis实现分布式锁的模式和Redlock算法的由来。
-
How to do distributed locking[2]: 一篇非常深刻的博文,详细论证了Redlock在现实世界中可能遇到的问题,有助于你深入理解分布式系统的复杂性。
-
Redisson的官方文档[3]: 学习一个成熟的工业级分布式锁实现,了解其API设计和高级功能(如可重入锁、公平锁、读写锁等)
「最后,留一个开放性问题给大家讨论:」 在你看来,Redlock算法是保证分布式锁高可用的"终极答案",还是一个"过度设计"?欢迎在评论区留下你的想法。
如果觉得本文对你有帮助,别忘了点个 「"在看"」 和 「"分享"」,让更多朋友看到。你的认可对我非常重要。
「推荐阅读我的其他文章」
Reference
1
Redis官方关于分布式锁的介绍:
2
How to do distributed locking:
martin.kleppmann.com/2016/02/08/...
3
Redisson的官方文档: