2022 年我做一个秒杀活动的库存扣减接口。逻辑很简单:用户下单,先查库存够不够,够就扣减一个。第一版在单机上跑,为了防止两个请求同时扣减同一个库存导致超卖,我加了个进程内的锁——threading.Lock。本地压测、单机上线,都没问题。后来活动流量涨了,运维把服务部署成了三个实例,挂在负载均衡后面。某次活动结束我对账,发现一件吓人的事:库存明明只有 100 件,却卖出去了 130 单——超卖了 30 件。我第一反应是"锁是不是没生效",去翻代码,threading.Lock 明明好端端地加着。我盯着那段代码看了很久,才终于想明白:我加的那把锁,是进程内的锁。三个实例,是三个独立的进程,每个进程里都有一把自己的 threading.Lock,它们互相之间根本不知道对方的存在。实例 A 的请求,拿到的是 A 进程里那把锁;实例 B 的请求,拿到的是 B 进程里那把锁——它俩各拿各的锁,谁也拦不住谁,同时进了扣减逻辑,同时读到库存是 1,同时把它扣成 0、又扣成 -1。锁是加了,可它只在一个进程内有效,对跨进程的并发毫无办法。我后来才彻底想明白,第一版错在一个根本的认知上:我以为"加了锁,就能防并发"。可"进程内的锁"和"跨进程的锁",是两回事。threading.Lock 这种锁,它的作用范围,就是它所在的那一个进程;一旦你的服务变成多实例、多进程,要在它们之间互斥,你需要的是一把所有进程都看得见、都来抢的、进程之外的锁——这就是分布式锁。我以为它不过是"Redis 里 SETNX 一下",结果真做下来,坑一个接一个。这篇文章就把它梳理一遍:为什么单机锁在多实例下会失效、分布式锁的本质是什么、锁为什么必须能自动释放、误删别人的锁是怎么发生的、校验和删除为什么必须原子,以及看门狗续期、获取锁超时、可重入这些把分布式锁真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个库存扣减接口,单机部署时用进程内的 threading.Lock 防并发,一切正常。服务扩成三个实例、挂上负载均衡后,出现超卖——100 件库存卖出了 130 单。代码里的锁好端端加着,却没拦住跨实例的并发。
我当时的错误认知:"只要加了锁,同一时刻就只有一个请求能进扣减逻辑,不会超卖。"
真相:threading.Lock 这类锁是进程内的,它的作用范围只有当前这一个进程。多实例部署下,每个进程都有自己独立的一把锁,互相看不见。要在多个进程、多台机器之间互斥,必须用分布式锁——一把存在于所有进程之外、所有进程都能看见并争抢的锁,通常用 Redis 实现。但分布式锁远不止"SETNX 一下"那么简单:锁会因为进程崩溃而永远不释放,会被别的进程误删,校验和删除不原子会出竞态,业务执行超时锁会提前过期——每一个都是坑。
要把分布式锁做对,需要几块认知:
- 为什么进程内的锁,在多实例部署下会彻底失效;
- 分布式锁的本质——一个所有进程都看得见的"标记";
- 锁为什么必须带过期时间,否则持锁进程一崩就死锁;
- 为什么锁要写唯一标识,以及释放为什么必须用 Lua 保证原子;
- 看门狗续期、获取锁超时、可重入这些工程坑怎么处理。
一、为什么单机锁在多实例下会失效
先把这件最根本的事钉死:threading.Lock 这类锁,锁住的是"这一个进程内部"的并发;它对"进程之间"的并发,一无所知、也无能为力。
设想一下我那个场景:服务部署成三个实例,就是三个独立的操作系统进程,可能还在三台不同的机器上。每个进程启动时,都会各自创建一个 threading.Lock 对象。这三把锁,是三个毫不相干的对象,它们之间没有任何通道能互相通气。下面这段代码,就是我那个会超卖的第一版:
import threading
stock = 100 # 库存,这里用变量模拟
_lock = threading.Lock() # 进程内的锁
def deduct_naive() -> bool:
# 反面教材:threading.Lock 只在【当前这一个进程】内有效。
with _lock:
global stock
if stock > 0:
stock -= 1
return True
return False
# 问题:服务部署成 3 个实例 = 3 个独立进程,
# 每个进程都有自己的一把 _lock,互相看不见。
# 实例 A 和实例 B 各拿各的锁,同时进到这里,
# 同时把 stock 从 1 扣成 0、再扣成 -1 —— 超卖。
这段代码,在单机单进程下是完全正确的——同一个进程里,不管来多少个线程,都得排队抢那唯一的一把 _lock,临界区里永远只有一个线程。它的错,只在一个前提变了之后才暴露:当服务变成多实例。这时,实例 A 进程里的线程抢的是 A 的 _lock,实例 B 进程里的线程抢的是 B 的 _lock——它们抢的压根不是同一把锁,自然谁也排不住谁的队。
所以问题的根子很清楚:互斥要生效,前提是所有竞争者抢的是同一把锁。单机锁做不到这一点,因为它天生只属于一个进程。要让分散在多个进程、多台机器上的请求抢同一把锁,这把锁就不能待在任何一个进程里面,它必须待在所有进程外面的某个公共的地方。
二、分布式锁的本质:一个所有进程都看得见的"标记"
上一节的死结是:锁待在进程里面,就只有这个进程能用它。分布式锁的破局点就一句话:把锁挪到所有进程都能访问的、进程之外的公共存储里去。
这个"公共存储",最常用的就是 Redis——所有实例都连着同一个 Redis。于是"锁"这个概念,就被翻译成了一件极朴素的事:在 Redis 里,约定一个 key 代表这把锁。谁能成功地把这个 key 创建出来,谁就算"拿到了锁";用完了,把这个 key 删掉,锁就"释放"了。因为 Redis 是所有进程共享的,这个 key 要么存在、要么不存在,全世界看到的是同一个事实——这就保证了所有进程抢的是同一把锁。
关键在于"创建 key"这个动作必须是抢占式的:已经存在了就不能再创建成功。Redis 的 SETNX(SET if Not eXists)正好就是干这个的:
import redis
r = redis.Redis(host="localhost", port=6379)
def acquire_naive(key: str) -> bool:
# SETNX:key 不存在才设置成功,返回 1;已存在则失败,返回 0。
# 谁设置成功,谁就算"抢到了这把锁"。
return r.setnx(key, "locked") == 1
def release_naive(key: str):
r.delete(key) # 用完把 key 删掉,锁就释放了
# 问题:如果抢到锁的那个进程,在 release 之前【崩溃了】,
# 这个 key 就【永远】不会被删 —— 锁被永久占住,
# 之后所有进程再也抢不到锁,整个功能彻底死锁。
这个版本,已经能跨进程互斥了:三个实例同时调 acquire_naive,Redis 保证只有一个能 SETNX 成功。但它有一个致命缺陷,我在注释里写了:如果持锁的进程在 release 之前崩溃了——进程被 kill、机器断电、程序抛异常没走到 delete——那个 key 就再也没人删了。锁被一具"尸体"永久占着,后面所有进程都卡死在抢锁上。要补这个洞,锁必须能自动释放。
三、锁必须能自动释放:给它一个过期时间
上一节的洞是:持锁进程一崩,锁就永久泄漏。修补的思路很自然:给锁一个"保质期"——就算没人主动释放它,到了时间它也自己消失。
Redis 的 key 本来就支持过期时间(TTL):给一个 key 设上 10 秒过期,10 秒后 Redis 会自动把它删掉。把这个能力用到锁上,死锁问题就解了:就算持锁进程崩了、永远不来 release,这个 key 也会在 TTL 到期后被 Redis 自动清掉,锁自动释放,别的进程就能继续抢。
但这里有个容易踩的坑:"设置 key"和"设置过期时间",必须是同一条命令、一次完成。如果你先 SETNX 成功、再用另一条命令去设过期时间,那么在这两条命令之间,进程一旦崩溃,这个 key 就成了一个没有过期时间的永久 key——死锁问题原样复活。Redis 的 SET 命令带上 NX 和 EX 参数,能把这两件事一次性原子地做掉:
def acquire_with_ttl(key: str, ttl: int = 10) -> bool:
# SET key value NX EX ttl:一条命令,同时做两件事——
# NX:key 不存在才设置(保证互斥)
# EX ttl:给 key 设过期时间(单位秒),到点 Redis 自动删
# 这两件事【原子】完成,中间不可能被进程崩溃劈开。
return r.set(key, "locked", nx=True, ex=ttl) is not None
# 这样,即使持锁进程崩溃、没来得及 release,
# 最多过 ttl 秒,Redis 也会自动把这个 key 删掉,
# 锁自动释放 —— 死锁问题被堵死。
到这里,锁已经能跨进程互斥,也能在持锁者崩溃后自动释放了。看起来很完整。但"加过期时间"这个补丁,在堵上一个洞的同时,又悄悄开了一个新洞:既然锁会"自己到期消失",那就有可能在持锁者还在干活的时候就消失了——这会引出一个比死锁更隐蔽的问题。
四、误删别人的锁:给每把锁写上唯一标识
上一节末尾那个新洞,具体是这样的。设想进程 A 抢到锁,TTL 设的是 10 秒。可 A 的业务执行得比较慢,跑了 12 秒。那么在第 10 秒,锁就自动过期了;进程 B 一直在抢,这一刻立即抢到了锁,开始干自己的活。到第 12 秒,进程 A 终于执行完了,它不知道自己的锁早过期了,照常调 release 去删 key——可它删掉的,是 B 刚刚抢到的那把锁。于是 B 还在临界区里干活,锁却被 A 删了,进程 C 又能抢到锁……互斥彻底崩坏。
问题的根子在于:release 时无脑 delete 这个 key,而它根本没法确认"这个 key 此刻还是不是自己当初设的那一个"。要修它,就得让每个进程的锁带上一个只有自己知道的、独一无二的标识:加锁时把这个标识写进 key 的 value,释放时先比对 value——只有 value 还是自己那个标识,才说明"这把锁确实还是我的",才能删。
import uuid
def acquire(key: str, ttl: int = 10) -> str | None:
# 锁的 value 不再是固定的 "locked",而是一个全局唯一的 token。
token = uuid.uuid4().hex
ok = r.set(key, token, nx=True, ex=ttl)
# 抢到锁,就把这个【只有自己知道】的 token 返回出去;
# 没抢到,返回 None。
return token if ok else None
# 这个 token,就是"这把锁是我加的"的唯一凭证。
# 释放锁时,必须凭它来证明身份,不能无脑删。
现在每把锁都有了"身份证"。acquire 成功后返回的那个 token,进程必须自己拿好,release 时凭它说话。逻辑上,释放就该是:"先 get 出 key 当前的 value,如果它等于我手里的 token,再 delete。"听上去严丝合缝——但如果你真的把"get 校验"和"delete"写成两步,你会发现自己又掉进了一个一模一样的竞态陷阱。
五、原子地释放锁:用 Lua 脚本
上一节结尾说的那个陷阱,是这样的。先看一个看起来对、其实有 bug 的释放写法:
def release_unsafe(key: str, token: str):
# 反面教材:先 get 校验,再 delete —— 这两步【不是原子的】。
if r.get(key) == token.encode():
# 致命窗口:就在【校验通过】和【执行下面这行 delete】之间,
# 锁可能恰好 TTL 到期、被 Redis 自动删,
# 然后别的进程立刻抢到了一把【新的】锁。
# 此刻这行 delete 删掉的,就是【别人刚抢到的锁】。
r.delete(key)
看出来了吗?它和第四节那个"误删"的本质完全一样:第四节是锁过期导致误删,这里是校验和删除之间的时间缝隙导致误删。get 返回的结果只代表"那一瞬间"的事实,等你的代码走到下一行 delete 时,这个事实可能早就变了。只要"判断"和"动作"是分开的两步,它们之间就有缝,有缝就有竞态。
要根治,只有一个办法:让"校验 token"和"删除 key"这两件事,合并成一个不可分割的原子操作。Redis 提供的手段是 Lua 脚本——Redis 保证一整段 Lua 脚本在执行期间,绝不会被其他任何命令打断。把"校验 + 删除"写进一段 Lua,它就要么整体成功、要么整体不做,中间那条缝被彻底焊死:
# Lua 脚本:在 Redis 内部【一次性、原子地】完成"校验 + 删除"。
_RELEASE_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
def release(key: str, token: str) -> bool:
# 整段脚本执行期间,Redis 不会插进任何别的命令。
# "key 的 value 还等于我的 token 吗"和"删掉 key",
# 要么一起发生,要么都不发生 —— 没有中间缝隙。
result = r.eval(_RELEASE_LUA, 1, key, token)
return result == 1 # 返回 1 才是真的把【自己的】锁删了
有了原子的 acquire 和 release,就能把它们封装成一个好用、不易出错的锁对象。用 Python 的上下文管理器(with 语法),能保证无论业务正常结束还是抛异常,锁都一定会被释放:
class DistributedLock:
"""一个可以用 with 语句使用的 Redis 分布式锁。"""
def __init__(self, key: str, ttl: int = 10):
self.key = key
self.ttl = ttl
self.token = None
def __enter__(self):
self.token = acquire(self.key, self.ttl)
if self.token is None:
raise RuntimeError(f"获取锁失败: {self.key}")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# with 块无论是正常结束、还是中途抛异常,
# __exit__ 都一定会被调用 —— 锁一定会被原子释放。
if self.token is not None:
release(self.key, self.token)
到这里,一把"能跨进程互斥、能崩溃自动释放、不会误删别人"的分布式锁,主干就算搭好了。但要把它真正用在生产上,还有几个绕不开的工程坑。
六、工程坑:看门狗续期、获取锁超时与可重入
分布式锁的主干通了,但有几个工程坑,不处理就会在生产上出事。
坑 1:业务还没干完,锁先过期了——要用"看门狗"续期。这是第四节那个问题的正面解法。TTL 设短了,业务没跑完锁就过期;设长了,持锁进程一旦真崩了,别人要白等很久。两难。正解是:TTL 还是设一个不太长的值(比如 10 秒),同时另起一个后台线程当"看门狗"——只要业务还在跑,它就每隔一段时间(比如 TTL 的三分之一)给锁续一次命,把过期时间重新拉回 10 秒。业务一结束,就停掉看门狗。这样,锁不会在业务进行中过期;而一旦进程真崩了,看门狗也跟着没了,锁会在最后一次续期后的 TTL 内正常过期释放。续期同样要校验 token、同样要原子:
import threading
# 续期脚本:仍然是自己的锁,才把过期时间重新设回 ttl 秒。
_RENEW_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
"""
def start_watchdog(key: str, token: str, ttl: int) -> threading.Event:
"""看门狗:后台线程,持锁期间每隔 ttl/3 秒给锁续一次命。"""
stop = threading.Event()
def loop():
# stop.wait 返回 True 说明收到停止信号,退出;
# 返回 False 说明是等够了 ttl/3 秒,该续期了。
while not stop.wait(ttl / 3):
r.eval(_RENEW_LUA, 1, key, token, ttl)
threading.Thread(target=loop, daemon=True).start()
return stop # 业务做完,调 stop.set() 让看门狗退出
坑 2:抢不到锁怎么办——不能死等,也不能立刻就放弃。前面的 acquire 是"抢一次,抢不到立刻返回 None"。但很多业务场景里,抢不到锁不该马上失败——也许只是另一个请求刚好在用,稍等一下(几十毫秒)它就放了。所以实际要的是一个带超时的获取:在一个有限的时间窗口内不断重试,真到了超时还抢不到,才放弃。有限很关键——绝不能写成 while True 的无限死等,那会把请求永久挂住。
import time
def acquire_blocking(key: str, ttl: int = 10,
wait: float = 3.0) -> str | None:
"""带超时的获取锁:在 wait 秒内不断重试,超时就放弃。"""
deadline = time.time() + wait
while time.time() < deadline:
token = acquire(key, ttl)
if token is not None:
return token # 抢到了,直接返回 token
time.sleep(0.05) # 没抢到,歇 50ms 再抢,别空转打爆 CPU
return None # 等够 wait 秒还没抢到,放弃
坑 3:同一个请求里重复加同一把锁——可重入问题。如果你的方法 A 加了锁,方法 A 又调用了方法 B,而 B 也去加同一把锁,就会自己把自己锁死:B 永远抢不到那把已经被 A(也就是它自己)占着的锁。解决要靠可重入设计——锁的 value 里除了 token,再记一个重入计数,同一个持有者再次加锁时计数 +1,释放时计数 -1,减到 0 才真正删 key。如果用不到可重入,那就守住一条纪律:一次业务流程里,同一把锁只在最外层加一次,别在嵌套调用里重复加。
把上面这些拼起来,就是一个生产可用的库存扣减。对比第一节那个会超卖的 deduct_naive,它的临界区,现在真正做到了"全局只有一个进程能进":
def deduct_stock(product_id: int) -> bool:
"""改造后的库存扣减:用分布式锁把跨进程的并发挡在外面。"""
key = f"lock:stock:{product_id}"
token = acquire_blocking(key, ttl=10, wait=3.0)
if token is None:
raise RuntimeError("系统繁忙,请稍后重试") # 超时没抢到
stop = start_watchdog(key, token, ttl=10) # 启动看门狗续期
try:
# 这一段临界区,全公司所有实例加起来,同一时刻只有一个进程能进
stock = get_stock(product_id)
if stock > 0:
set_stock(product_id, stock - 1)
return True
return False
finally:
stop.set() # 先停掉看门狗,不再续期
release(key, token) # 再用 Lua 原子地释放锁
下面这张图,把一次带分布式锁的操作,从抢锁到释放的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 单机锁的局限 | threading.Lock 只在一个进程内有效,多实例下每个进程一把,互不可见 |
| 分布式锁的本质 | 把锁挪到所有进程共享的外部存储,约定一个 key 代表锁 |
| SETNX 抢锁 | key 不存在才设置成功,谁设成功谁拿到锁,保证跨进程互斥 |
| 崩溃不释放 | 持锁进程崩在 release 之前,key 永不被删,锁永久泄漏成死锁 |
| 过期时间 TTL | SET NX EX 一条命令原子地加锁并设过期,进程崩了锁也自动释放 |
| 误删别人的锁 | 锁过期后被别人抢走,原持有者无脑 delete 会删掉别人的锁 |
| 唯一 token | 锁的 value 写一个唯一标识,释放前先比对,证明锁还是自己的 |
| Lua 原子释放 | 校验 token 和删除 key 必须在一段 Lua 里原子完成,否则仍有竞态 |
| 看门狗续期 | 后台线程持锁期间定时续期,业务不会因锁过期而失去互斥 |
| 获取锁超时 | 抢不到锁要在有限时间内重试,绝不能无限死等挂住请求 |
避坑清单
- threading.Lock 这类单机锁只在一个进程内有效,多实例部署下每个进程各有一把,拦不住跨进程并发。
- 分布式锁的本质是把锁挪到所有进程共享的外部存储,让所有竞争者抢的是同一把锁。
- 用 Redis 的 SETNX 抢锁,谁能创建出代表锁的 key 谁就拿到锁,Redis 保证只有一个能成功。
- 持锁进程在 release 之前崩溃会导致 key 永不被删,锁永久泄漏,必须给锁加过期时间。
- 加锁和设过期时间必须用 SET NX EX 一条命令原子完成,分两步会在缝隙处崩溃留下永久 key。
- 锁会因 TTL 到期被别人抢走,原持有者无脑 delete 会误删别人的锁,要给每把锁写唯一 token。
- 释放锁时先 get 校验再 delete 不是原子的,缝隙里锁可能过期,必须用 Lua 脚本原子完成。
- 业务执行可能超过 TTL,要用看门狗后台线程定时续期,业务结束再停掉看门狗。
- 获取锁要带超时重试,在有限时间窗口内重试,绝不能写成无限死等把请求永久挂住。
- 同一请求嵌套加同一把锁会自己锁死自己,要么做可重入设计,要么纪律上只在最外层加一次。
总结
回头看那次"加了锁还超卖"的事故,以及我后来在分布式锁上接连踩的坑,最该记住的不是某一段 Lua 脚本,而是我动手前那个想当然的判断——"加了锁,就能防并发"。这句话错在它把"锁"当成了一个抽象的、放之四海皆准的概念,而忽略了每一把锁都有它实实在在的作用边界。threading.Lock 的边界,就是它所在的那一个进程;出了这个进程,它什么也锁不住。我在单机时代用熟了它,就下意识地以为它在多实例时代照样管用——可"多实例"恰恰意味着"多进程",意味着我的并发越过了那把锁的边界。分布式锁想清楚的,正是这件事:当你的竞争者散落在多个进程、多台机器上时,你需要的锁,也必须住在它们之外的、共同的地方。
所以做分布式锁,真正的工程量不在"SETNX 一下"那个一目了然的主干上。那个主干,三行代码就能写完。真正的工程量,在于你有没有把那一连串"万一"都想到、并堵上:万一持锁的进程崩了呢?——所以要有 TTL。万一业务跑得比 TTL 久呢?——所以要有看门狗。万一锁过期后被别人抢走、原主却来删呢?——所以要有唯一 token。万一校验和删除之间那一瞬锁恰好过期呢?——所以要有 Lua。万一抢不到呢?——所以要有带超时的重试。这篇文章的几节,其实就是顺着这一串"万一"展开的:每一节,都是在修补上一节留下的那个新洞。
你会发现,分布式锁的演进,和我们生活里"多人共用一样东西"的智慧惊人地相似。一间公司只有一个会议室,大家怎么不打架?靠的不是每个人心里默念"我要用",而是门口那块所有人都看得见的预约牌(这是把锁挪到进程之外)。预约牌上要写用到几点,免得有人占着不走、牌子永远翻不过来(这是 TTL)。会还没开完、眼看要到点了,得有人出来把时间往后顺一顺(这是看门狗)。你要摘牌走人,得先看一眼牌子上写的还是不是你的名字——别把下一拨人的预约给撕了(这是 token 校验)。分布式锁所有的复杂,归根到底,都是在一个没有中心、人人平等的环境里,把"排队"这件事老老实实地、不留缝隙地做对。
最后想说,分布式锁做没做扎实,差距永远不会在开发期暴露——开发时你单机跑一个实例,有锁没锁、锁对锁错,功能跑起来一模一样。它只在真实的、多实例的、被高并发流量冲刷的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你要么像我一样在对账时发现超卖了一批货、得自己去赔,要么某天一个进程崩在了持锁的瞬间,整个功能集体卡死,所有用户都在转圈,而你查了半天才发现是 Redis 里躺着一个永不过期的死锁 key。而做对了,它会安安静静地、不被任何人注意地,把成百上千个并发请求一个一个地排好队,让那段绝不容许两个人同时进的临界区,永远只有一个人在里面。所以别等超卖的账单和卡死的告警一起找上门,在你把服务从一个实例扩到第二个实例的那一刻就该想清楚:我的并发,现在越过进程边界了吗?我的锁,跟得上吗?它会因为进程崩溃泄漏吗?它会误删别人吗?这几个问题都有了答案,你的锁才不只是开发库里那个单实例下有它没它都一样的摆设,而是一个无论服务扩到多少个实例,都能稳稳地、不留缝隙地守住互斥的可靠系统。
—— 别看了 · 2026