分布式锁完全指南:从一次"多实例部署后库存被超卖"看懂为什么单机锁挡不住分布式并发

2023 年我做一个电商的库存扣减接口用户下单要先扣库存怎么保证不会超卖这件事我没多想就有了方案加锁第一版我做得很顺手在扣库存的那段代码外面用一把锁圈起来同一时刻只放一个请求进去查库存够不够够就扣不够就拒绝本地一压测真不错并发再高库存也扣得分毫不差从来不会扣成负数我心里很笃定我都加锁了临界区同一时刻只有一个请求并发安全这不就有了可等这个接口真正上线被部署成多个实例扛起生产流量一串问题冒了出来第一种最先把我打懵代码里明明锁着库存却还是被超卖了第二种最难缠有个进程在持有锁的时候崩了这把锁再也没人释放后面所有请求全卡死第三种最头疼一个请求释放锁的时候把别人正持有的锁给删掉了第四种最莫名其妙锁设了过期时间业务还没做完锁就自己过期了两个请求又同时进了临界区我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为加锁就是把临界区圈起来同一时刻只放一个进并发安全就有了可任何一把锁都有它的作用范围我用的那把锁是一个进程内的对象它的作用范围只有一个进程当我的服务被部署成多个实例每个实例都是一个独立的进程各自有各自的锁它们之间根本不知道对方的存在多个进程同时进临界区超卖就发生了要在多个进程之间互斥这把锁必须放在一个所有进程都能访问到的共享存储里这就是分布式锁而分布式锁真正的难点不在加锁本身而在那些异常路径持有锁的进程可能崩溃可能卡顿可能被垃圾回收或网络问题暂停真正把分布式锁做扎实核心不是加把锁就完事而是认清锁有作用范围单机锁锁不住多进程理解加锁必须带过期时间否则进程一崩就死锁理解释放锁必须校验持有者不能直接删理解锁可能在业务没做完时提前过期要用看门狗续期本文从头梳理为什么单机锁挡不住分布式并发加锁为什么必须带过期时间释放锁为什么不能直接删锁提前过期了怎么办Redis 锁的可用性边界在哪里以及一些把它做扎实要避开的工程坑

2023 年我做一个电商的库存扣减接口:用户下单,接口要先查库存够不够,够就扣一笔。怎么保证不会超卖?这件事我没多想:加把锁,让扣库存这段代码同一时刻只有一个人能进,不就行了。第一版我做得很顺手:我在扣库存的方法上,用语言自带的锁(threading.Lock)把"查库存、扣库存"这两步圈起来,同一时刻只放一个线程进。就完事了。本地起一个服务、开几百个线程一压——真不错:库存一笔一笔扣,数字分毫不差,死活压不出超卖。我心里很笃定:"加锁我懂啊,把临界区圈起来,同一时刻只有一个能进,并发安全不就有了?"可等这个接口真正上线——它不是一个服务,是部署在好几台机器上的好几个实例——一串问题冒了出来。第一种最先把我打懵:线上实实在在地超卖了,我那把 threading.Lock 明明在每个实例里都好好地锁着,可库存就是被扣成了负数。第二种最难缠:我换成了 RedisSETNX 来做锁,刚上线没几天,扣库存接口整个卡死——一查,某个实例进程崩了,它持着的那把锁永远没被释放,后面所有人都卡在抢锁上。第三种最头疼:我给锁加了过期时间,可又冒出诡异的超卖,排查半天发现——一个实例释放锁时,把别人的锁给删了。第四种最莫名其妙:有一次业务逻辑跑得慢了点,锁在业务没做完时就自己过期了,另一个实例堂而皇之地拿到锁进来,两个实例同时扣了同一笔库存。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"加锁就是把临界区圈起来、同一时刻只放一个进,并发安全就有了"。这句话默认了那把锁,对所有想进临界区的人都有效。可我用的 threading.Lock,它压根管不到别的机器我脑子里,锁就是锁,我加了锁,这段代码就被保护了。可我忽略了一个最根本的东西:任何一把锁,都有它的作用域——它只对"能看到这把锁、并且愿意遵守这把锁"的那些执行者有效。threading.Lock 这把锁,是一个进程内存里的对象,它的作用域,就死死地框在那一个进程里。同一个进程里的几百个线程,都能看到这同一个锁对象,所以它们之间能靠它互斥。可我的服务部署在三台机器上、是三个独立的进程,每个进程的内存里,都有一把自己的、和别人毫无关系的 threading.Lock。机器 A 的线程锁住了 A 的锁,机器 B 的线程对此一无所知,它锁的是 B 自己的那把锁,然后大摇大摆地进了临界区——两台机器上的代码,同时在扣同一行库存。我那把锁从来没有失效,它一直忠实地工作着,只是它的作用域,根本覆盖不了"多个进程"这个范围。要让多个进程之间也能互斥,我需要的是一把所有进程都能看到、都来抢的锁——它必须存在于一个所有进程之外的、共享的地方,比如 Redis、比如 ZooKeeper。这种锁,叫分布式锁。而分布式锁真正的难,根本不在"加锁"这个动作本身,而在于:持有锁的那个进程,随时可能崩溃、可能卡顿、可能被网络延迟拖住——锁必须能应对持有者的种种异常,否则它要么把整个系统锁死,要么在你看不见的地方悄悄失效。真正把分布式并发控制做扎实,核心不是"加把锁圈住临界区",而是认清进程内的锁管不了多个进程、要用存在共享存储里的分布式锁,认清锁必须带过期时间防止持有者崩溃造成死锁,认清释放锁必须校验持有者、不能误删别人的锁,认清锁可能在业务没做完时提前过期、要用看门狗续期缓解,认清单点 Redis 锁有可用性边界,并把抢锁耗时、锁过期这些信号接进监控。这篇文章就把分布式锁这个坑梳理一遍:为什么单机锁挡不住分布式并发、加锁为什么必须带过期、释放锁为什么不能直接删、锁提前过期怎么用看门狗续期、Redis 锁的可用性边界在哪,以及一些把分布式锁做扎实要避开的工程坑。

问题背景

这个坑普遍,是因为"加锁"这个概念太深入人心、又太容易想当然了——几乎每个程序员都先学会了单机的锁(synchronizedLock、互斥量),它们用起来太顺手,以至于第一次面对分布式场景时,人会下意识地以为"锁就是锁",直接把单机锁搬过来。它错得隐蔽,是因为单机锁在单实例下、在本地测试时,工作得完美无缺:你本地只起一个进程,几百个线程压上去,单机锁稳稳地挡住所有并发,你由此确信"锁加对了"。它只在服务被部署成多实例的那一刻才暴露——而多实例几乎是任何线上服务的标配,于是上线即超卖。换成 Redis 锁之后,那些"加锁/解锁"看似简单的操作里,藏着的过期、误删、续期问题,又会在进程崩溃、业务卡顿这些异常时刻一个个引爆。

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:单机锁锁着却线上超卖;进程崩了锁永不释放、接口卡死;释放锁时删掉了别人的锁;锁在业务没做完时提前过期。
  • 错误认知一:以为锁对所有执行者都有效。真相是锁有作用域,进程内的锁只对同进程的线程有效。
  • 错误认知二:以为加锁解锁就是 SETNX 一下、DEL 一下。真相是分布式锁的难全在异常路径——持有者会崩溃、会卡顿。
  • 错误认知三:以为锁设了过期就安全了。真相是过期又引出"业务没做完锁就没了"和"误删别人的锁"两个新问题。
  • 真相:正确的分布式锁要同时满足互斥、带过期防死锁、释放时校验持有者、用看门狗应对提前过期,并清楚它的可用性边界。

一、为什么单机锁挡不住分布式并发

先把第一版那个单机锁的写法摆出来。它就是字面意思——用一个进程内的锁对象,把临界区圈起来:

# 第一版:用进程内的 threading.Lock 锁住扣库存(反面教材)
import threading

stock_lock = threading.Lock()   # 一个进程内存里的锁对象

def deduct_stock(product_id: int, qty: int) -> bool:
    with stock_lock:             # 圈住临界区:查库存 + 扣库存
        stock = db.query_stock(product_id)
        if stock < qty:
            return False
        db.update_stock(product_id, stock - qty)
        return True

# 单实例 + 多线程:完美。几百个线程抢这同一个 stock_lock,
# 同一时刻只有一个能进临界区,库存一笔一笔扣,绝不超卖。
# 但服务一旦部署成 3 个实例 —— 是 3 个独立进程,
# 每个进程里都有一个自己的 stock_lock,3 把锁互不相识 ——
# 3 个进程会同时进临界区,同时扣同一行库存,超卖

这段代码没有任何语法错误,单实例下它工作得完美。它唯一的问题是 stock_lock 是一个进程内存里的对象,它的作用域被框死在一个进程里。服务部署成 3 个实例,就是 3 个进程,内存彼此隔离,每个进程里那个 stock_lock,对另外两个进程完全不可见。要让 3 个进程互斥,锁必须挪到一个所有进程都够得着的共享地方去。把单机锁失效、分布式锁生效的对比画出来:

[mermaid]
flowchart TD
A[实例1 的线程] --> B[实例1 进程内的 stock_lock]
C[实例2 的线程] --> D[实例2 进程内的 stock_lock]
B -->|两把锁互不相识| E[两个实例同时进临界区 超卖]
D -->|两把锁互不相识| E
F[实例1 的线程] --> G[共享存储里的同一把分布式锁]
H[实例2 的线程] --> G
G -->|抢同一把锁 只有一个赢| I[同一时刻只有一个实例进临界区]

看懂这张图,"单机锁锁着却超卖"这个怪现象就有了答案:不是 threading.Lock 坏了,它在每个进程里都尽职尽责地工作。问题是它的作用域不够大——它能让一个进程内的线程互斥,却管不了进程与进程之间。分布式锁的核心改动,就是把"锁"这个东西,从各进程私有的内存,挪到一个公共的、所有进程都来访问的地方。

这里要建立的第一个、也是最重要的认知是:你使用任何一个机制——一把锁、一个变量、一个缓存、一份配置——之前,都必须先问清楚它的"作用域"到底有多大,也就是,它在什么范围内是有效的、被共享的,出了这个范围它就一文不值。我第一版栽的跟头,根子就是从没问过这个问题:我用了 threading.Lock,却从没想过"这把锁的有效范围是什么"。它的作用域是"一个进程",而我的问题域是"多个进程",作用域比问题域小,锁就形同虚设。这个"作用域"的概念,是理解一切并发与分布式问题的一把总钥匙,因为程序里几乎每一个东西,都有它的、常常不那么显眼的作用域:一个普通变量,作用域是一个线程的栈;一个进程的全局变量,作用域是这一个进程,多进程下各有一份;一个进程内的锁、连接池、内存缓存,作用域都是这一个进程;而一个数据库、一个 Redis、一个 ZooKeeper,它们的作用域,才跨越了进程、跨越了机器,是真正"全局共享"的。判断一个并发控制手段够不够用,方法极其简单,却极其有效:把"参与竞争的执行者的范围"和"这个手段的作用域"摆在一起比——前者必须被后者完全覆盖,手段才有效。单机多线程竞争,用进程内的锁就够,因为线程都在一个进程里;多进程、多机器竞争,就必须用作用域覆盖了所有这些进程的手段,也就是把仲裁点放到一个外部共享存储里。这个思维方式能让你避开大量似是而非的坑:你用进程内存做缓存,多实例下每个实例的缓存是不一致的;你用单机的限流计数器,多实例下实际限流阈值是你设定值的 N 倍;你用本地文件做幂等标记,换台机器标记就丢了。它们和我的单机锁,错得一模一样——都是拿一个"作用域偏小"的手段,去解一个"范围更大"的问题。所以,动手用任何共享性的机制之前,先在脑子里给它画一个圈,问自己:这个圈,圈得住所有会来竞争的人吗?圈不住,就换一个圈得住的。

二、SETNX 加锁:必须带过期时间

把锁挪到共享存储,最常见的选择是 Redis。Redis 提供了一个天然适合做锁的命令:SETNX(SET if Not eXists)——只有当 key 不存在时,这次 set 才成功。谁 set 成功,谁就算抢到了锁。但这里有个第一版踩过的、致命的坑——只 SETNX 不设过期:

# 用 SETNX 做分布式锁,但不设过期时间(反面教材)
def acquire_lock_bad(key: str) -> bool:
    # setnx: key 不存在才设成功,返回 True;已存在返回 False
    return redis.setnx(key, "locked")

def release_lock_bad(key: str):
    redis.delete(key)

# 看似没问题。但只要持有锁的进程在 acquire 和 release 之间
# 崩溃了 —— 进程没了,release 永远不会被调用 ——
# 这个 key 就永远留在 Redis 里。之后所有进程 setnx 都失败,
# 谁也拿不到锁,整个临界区被永久锁死:死锁

问题出在:持有锁的进程,随时可能在释放锁之前就崩溃——进程被 kill、机器断电、OOM。它一崩,release 那行代码就永远不会执行,锁这个 key 就永远赖在 Redis 里,后面所有人都抢不到。解法是给锁加一个过期时间:就算持有者崩了没来得及释放,锁也会到点自动消失。Redis 的 SET 命令支持把"设值"和"设过期"原子地一起做:

# 加锁必须带过期时间:set 的 nx + ex 一步原子完成
import uuid

def acquire_lock(key: str, ttl: int = 10) -> str | None:
    # nx=True: key 不存在才设;ex=ttl: 同时设 ttl 秒过期。
    # 这两件事在一条 SET 命令里原子完成 —— 绝不会出现
    # "设了值还没来得及设过期,进程就崩了"的窗口
    token = uuid.uuid4().hex          # 锁的唯一标识,第三节要用
    ok = redis.set(key, token, nx=True, ex=ttl)
    return token if ok else None

# 加了 ex 过期,死锁的问题就堵住了:持有锁的进程哪怕崩了,
# 这把锁最多 ttl 秒后也会自动释放,后面的进程就能抢到。
# 注意:绝不能"先 set 再单独 expire"分两步 —— 两步之间
# 进程崩了,锁就又变回了永不过期,死锁重现

这里有个细节绝不能错:设值和设过期,必须在一条命令里原子完成(setnx + ex 参数),不能"先 setnx、再 expire"分两步——两步之间进程一旦崩溃,锁就成了一把永不过期的锁,死锁又回来了。

这里要建立的认知是:给锁加过期时间这个动作,背后是一个做分布式系统、乃至做一切健壮系统时必须刻进骨子里的思维方式——你必须默认地、悲观地假设:任何一个持有着资源的参与者,都随时可能在没有任何征兆、没有任何"善后"的情况下,直接消失。我第一版不设过期,是因为我心里有一个温情脉脉的假设:"拿了锁的进程,会好好地、负责任地把锁还回来"。可在分布式世界里,这个假设是天真的。进程会被 OOM killer 毫无预兆地杀掉,机器会断电,网络会分区,容器会被调度器驱逐——持有锁的那个进程,完全可能在执行到一半时,就那样凭空消失了,它根本没有机会执行任何"释放锁"的收尾代码。一个健壮的系统设计,绝不能把正确性,寄托在"每个参与者都能优雅退场"这个假设上。它必须反过来:假设参与者会"猝死",然后设计出一种机制,让这种猝死不会留下永久的烂摊子。给锁加过期时间,正是这种机制——它的专业名字叫"租约":我不把锁"无限期"地交给你,我只"租"给你一段时间(ttl),时间一到,无论你是用完了、还是早就猝死了,这把锁都自动收回。租约的精髓,是把"释放资源"这件事的责任,从"持有者主动归还"(不可靠,持有者会猝死),转移到了"到期自动回收"(可靠,由那个共享存储来保证)。这个"租约 / 自动过期"的思想,在分布式系统里无处不在:服务注册中心里,每个服务实例的注册信息都带 TTL,要靠它不断发心跳来续约,实例一挂、心跳停了,注册信息自动过期摘除;DHCP 分配的 IP 地址是租约;分布式系统里大量的"健康状态",都是靠这种"需要持续证明自己还活着、否则就被判定死亡"的机制来维持的。所以,当你设计任何一个"某个参与者占用某种共享资源"的场景时,一定要养成习惯,问一句:如果这个占用者此刻猝死,这个资源会怎样?如果答案是"它会被永久占用、再没人能用",那你的设计就有一个致命的死锁隐患。你必须给这个占用,加上一个"到期自动失效"的租约——让系统具备一种能力:不依赖任何参与者的善意配合,就能自动地从猝死中恢复过来。

三、释放锁:不能直接 DEL,要校验持有者

加了过期时间,死锁堵住了,可它又顺手挖出一个新坑——第一版第三种诡异的超卖,就出在这里。问题在释放锁:第一版释放锁,是直接 DEL 那个 key。可如果一个进程持锁后业务执行得比 ttl 还慢,锁会先自动过期,这时别的进程就能拿到这把锁;等慢进程终于做完、回头执行 DEL,它删掉的,已经是别人的锁了。把这个误删的时序画出来:

[mermaid]
flowchart TD
A[进程A 拿到锁 ttl 设为 10 秒] --> B[进程A 业务卡顿 实际执行了 12 秒]
B --> C[第 10 秒 锁因 ttl 自动过期消失]
C --> D[进程B 趁机用 SETNX 拿到同一个 key 的锁]
D --> E[第 12 秒 进程A 业务做完 执行 DEL key]
E --> F[进程A 删掉的其实是进程B 刚拿到的锁]
F --> G[进程B 的锁凭空消失 进程C 又能进来 互斥被打破]

看懂这张图就明白:直接 DEL 的错,在于它没有校验"这把锁现在到底是不是我的"就动手删了。解法分两步:第一,加锁时给锁存一个唯一的标识(就是上一节 acquire_lock 里那个 token),每个进程的 token 都不同;第二,释放锁时,先校验 key 里存的 token 是不是自己的,是自己的才删。但这个"校验 + 删除",又必须是原子的——校验完到删除之间若有缝隙,锁仍可能在缝隙里过期、被别人抢走。Redis 里保证这个原子性的办法,是用 Lua 脚本:

-- 释放锁的 Lua 脚本:校验持有者 + 删除,原子完成
-- KEYS[1] = 锁的 key   ARGV[1] = 我加锁时存进去的那个 token
if redis.call("get", KEYS[1]) == ARGV[1] then
    -- key 里的 token 确实是我的 —— 这把锁是我的,删它
    return redis.call("del", KEYS[1])
else
    -- token 对不上 —— 锁已经过期、且被别人拿走了,
    -- 这不是我的锁,绝不能删,直接返回 0
    return 0
end
# Python 端:用 Lua 脚本原子地释放锁
RELEASE_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
"""

def release_lock(key: str, token: str) -> bool:
    # Redis 执行 Lua 脚本时是原子的:get 校验和 del 删除
    # 之间,绝不会被别的命令插进来
    result = redis.eval(RELEASE_LUA, 1, key, token)
    return result == 1

# 要害:绝不能"先 get 出来用 Python 比一下、再 del"。
# get 和 del 是两次独立的网络往返,中间那道缝隙里,
# 锁完全可能过期 + 被别人抢走,你照样删了别人的锁。
# 把"校验 + 删除"塞进一个 Lua 脚本,这道缝隙才消失

这里要建立的认知是:释放锁的这个坑,要把两个在并发编程里反复出现、又反复被踩中的教训,一起钉进你的脑子里。第一个教训:任何一个有"归属"的资源,你在操作它之前,必须先确认它此刻确实属于你。我第一版直接 DEL,本质是默认了"我加的锁,到我释放时,一定还是我的"——可分布式环境里,因为有过期机制,这个默认完全不成立,锁可能早就易主了。所以锁必须带一个唯一的持有者标识(token),释放前先认一认"这是不是我的",这其实是一种"凭证校验":你不能凭"我曾经拥有过"就去操作,你得凭"我现在出示的凭证和它记录的持有者对得上"才去操作。第二个教训,更深、也更普遍:check-then-act——先检查、再行动——这个模式,只要检查和行动不是一个原子操作,中间那道缝隙在并发下就一定会出事。我如果"先 get 出 token 比对、再 del",这就是一个活生生的 check-then-act:get 是 check,del 是 act,它俩是两次独立的网络往返,中间隔着一道缝。在这道缝里,锁可以过期、可以被进程B抢走,于是我"check 的时候它还是我的、act 的时候它已经是别人的",我照样删错。这个 check-then-act 的陷阱,我在别处见过无数次:先判断文件不存在再创建、先查余额够不够再扣款、先看锁空不空再去抢——所有这种"基于一个先前观察到的状态、再去做一个动作"的代码,只要状态在观察之后、动作之前可能被别人改变,就埋着一颗雷。而它的解法,永远是同一个:想办法把"检查"和"行动"捆成一个不可分割的原子操作,让那道致命的缝隙根本不存在。Redis 给的原子武器是 Lua 脚本(脚本执行期间不会被别的命令插入),数据库给的是事务和行锁,CPU 给的是 CAS 指令。所以,当你写下任何一段"先看一眼状态、再根据状态做事"的并发代码时,立刻警觉地问自己:这两步之间,状态会被别人改吗?会,就必须找一个原子机制把它们焊成一步。这两个教训——操作前校验归属、把 check-then-act 焊成原子——是你在并发世界里安身立命的基本功。

四、锁提前过期:用看门狗自动续期

到这里,死锁和误删都堵住了,可还剩第一版最后一个、也是最棘手的问题:锁在业务没做完时,就提前过期了。第二节为了防死锁,我们给锁设了 ttl;可 ttl 设多长,是个两难——设短了,业务稍微慢一点(一次慢查询、一次 GC 停顿)就超过 ttl,锁提前失效,别人进来,互斥被打破;设长了,持有者一旦真的崩溃,这把锁要白白占用很久才过期。这个矛盾,没法靠"把 ttl 调到一个完美值"来解决。业界的缓解办法,是看门狗(watchdog):加锁后,起一个后台线程,定期检查"业务是否还在进行",还在,就把锁的过期时间往后续一截:

# 第四道防线:看门狗自动续期 —— 业务没做完就一直把锁往后续
import threading, time, uuid

# 续期的 Lua:只有锁还是我的,才把它的 ttl 重置
RENEW_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("expire", KEYS[1], ARGV[2])
else
    return 0
end
"""

class DistributedLock:
    def __init__(self, key: str, ttl: int = 30):
        self.key, self.ttl = key, ttl
        self.token = uuid.uuid4().hex
        self._stop = threading.Event()
        self._dog = None

    def acquire(self, timeout: int = 10) -> bool:
        deadline = time.time() + timeout
        while time.time() < deadline:
            if redis.set(self.key, self.token, nx=True, ex=self.ttl):
                self._start_watchdog()    # 抢到锁,立刻放出看门狗
                return True
            time.sleep(0.1)
        return False

    def _start_watchdog(self):
        def run():
            # 每过 1/3 个 ttl,就把锁的过期时间重置回 ttl。
            # 只要业务还在跑(没 release),锁就不会过期
            while not self._stop.wait(self.ttl / 3):
                redis.eval(RENEW_LUA, 1, self.key, self.token, self.ttl)
        self._dog = threading.Thread(target=run, daemon=True)
        self._dog.start()

    def release(self):
        self._stop.set()                  # 先停掉看门狗,不再续期
        if self._dog:
            self._dog.join(timeout=1)
        redis.eval(RELEASE_LUA, 1, self.key, self.token)  # 再原子释放

# 看门狗让"业务正常但偏慢"和"持有者已崩溃"两种情况被区分开:
# 业务还活着,看门狗就持续续期,锁不会被冤枉地提前收走;
# 持有者一旦崩溃,看门狗线程也跟着进程一起没了,
# 续期停止,锁就会在一个 ttl 之后正常过期 —— 死锁依然防住

看门狗的妙处,是它让"业务慢"和"进程崩"这两种本来分不清的情况,有了区别:业务只是慢、进程还活着,看门狗就一直续,锁不会被冤枉收走;进程真崩了,看门狗线程也随进程一起没了,续期自动停止,锁照样在一个 ttl 后过期。但要清醒:看门狗是缓解,不是根治——如果进程不是崩溃,而是被长时间的 GC、或网络分区"冻"住了(没死,但也没法续期),锁还是会过期。

这里要建立的认知是:看门狗这个方案,要教给你一个走向工程成熟的关键标志——学会识别并坦然接受"这个问题没有完美解,只有权衡"。我一开始面对"ttl 设多长"时,本能地想找一个"正确答案",一个设了就一劳永逸的完美数值。可这个完美值根本不存在:它要同时短到"持有者崩溃后锁能尽快释放",又长到"覆盖业务最慢的那次执行",而这两个要求在方向上是相互拉扯的。这就是一个典型的"权衡型问题"——你不可能让所有指标同时最优,你只能在它们之间做取舍。一个不成熟的工程师,会在这种问题上反复纠结、试图找到那个不存在的完美参数;一个成熟的工程师,会先一眼认出"这是个权衡问题",然后做两件事:第一,接受没有完美解,转而寻找一个"足够好"的折中;第二,更高明的——想办法引入一个新的机制,去缓解这个权衡本身的尖锐程度。看门狗就是第二种:它没有去解决"ttl 该设多长"这个无解的问题,而是改变了游戏规则——它让 ttl 不必再去硬扛"业务最长能跑多久"这个不确定的量,ttl 只需要设一个不长的值用来防崩溃,而"业务到底跑多久"由看门狗动态地续期来兜。它把一个"必须一次设对的静态参数",变成了一个"能动态适应的过程"。但即便如此,你看,这一节结尾我依然要诚实地说:看门狗也只是缓解,它扛不住进程被 GC 或网络分区冻住的情况——因为分布式锁这个问题域,本身就和"两个节点无法在不可靠网络上达成绝对共识"这个根本困难纠缠在一起,它就是没有完美解。这种"诚实地承认局限"的态度,本身就是工程成熟度的体现。所以,当你下次遇到一个怎么都想不出完美方案的问题时,先别焦虑,先判断一下:它是不是一个本质上就只有权衡、没有完美解的问题?如果是,你的任务就从"寻找完美解"切换成"清晰地列出每个选项的代价、选一个最适合当前场景的折中、并想办法用新机制去缓解权衡的尖锐度"。能坦然地与"没有完美解"共处、并在约束下做出明智的取舍,是区分工程师成熟与否的一道分水岭。

五、Redis 锁的可用性边界:单点、主从与 RedLock

前四节把基于单个 Redis 的分布式锁做得相当扎实了,但还得认清它最后一道边界:这个 Redis 自己,可靠吗?如果锁就存在一个单点 Redis 上,这个 Redis 一宕机,所有人都没法加锁,整个加锁机制瘫痪。给 Redis 配主从,又会引入另一个问题:你在主节点上加锁成功了,可这条加锁数据还没来得及同步到从节点,主节点就挂了、从节点被提升为主——新主上根本没有这把锁,于是另一个进程又能在新主上加锁成功,互斥被打破。把几种方案的边界摆清楚:

分布式锁的几种底座,可靠性与代价的权衡:

  单点 Redis
     优点:简单、快
     致命伤:Redis 一挂,加锁机制整体瘫痪(可用性差)

  Redis 主从 / 哨兵
     优点:主挂了能自动切换,可用性变好
     致命伤:主从同步有延迟。主挂之前刚加的锁可能没同步到从,
             切换后锁丢失 —— 两个进程同时持锁

  RedLock(向多个独立 Redis 实例同时加锁)
     思路:向 N 个独立 Redis 加锁,多数成功才算拿到锁
     代价:实现复杂、有争议,对时钟和网络假设较强

  ZooKeeper / etcd
     优点:为一致性而生(CP),用临时节点做锁,
           持有者会话一断,锁节点自动消失,天然防死锁
     代价:写入比 Redis 慢,运维更重

  选型原则:
     偶尔的"锁丢失 / 双持有"业务能扛(配幂等兜底)-> Redis 够用
     绝不允许双持有、一致性要求极高             -> 上 ZooKeeper / etcd

所以,Redis 分布式锁,本质是一个"性能好、实现简单,但在极端故障下不能 100% 保证互斥"的方案。绝大多数业务,用它 + 一层业务幂等兜底,完全够用;只有那些绝对不容许任何一次双持有的场景,才值得付出代价上 ZooKeeper、etcd 这类为一致性而生的系统。

这里要建立的认知是:Redis 锁和 ZooKeeper 锁的这个分野,把分布式系统里那个最根本的权衡,清清楚楚地摆到了你面前——一致性(Consistency)和可用性(Availability),在网络可能出故障的前提下,你无法同时把两者都做到极致,你必须有所侧重。这就是著名的 CAP 定理在一个具体问题上的投影。Redis 锁,尤其是主从架构的,它的侧重点偏向可用性:它要快、要在节点故障时尽快恢复服务,代价是主从切换的瞬间,它可能丢掉一把锁、破坏一次互斥——它在"一致性"上做了让步。而 ZooKeeper、etcd,它们的侧重点偏向一致性:它们宁可在网络分区、多数节点失联时拒绝服务(牺牲可用性),也绝不返回一个可能错误的结果——它们用更慢的写入、更重的协议(Raft 是它的简化版。">Paxos、Raft),换来了"只要它说你拿到了锁,就一定没有别人同时拿着"这个强保证。这里的关键认知,不是"哪个更好"——它们没有绝对的好坏,只有适不适合;关键认知是:你必须想清楚,你的业务,究竟更怕哪一种失败。你更怕"加锁服务偶尔不可用,导致一部分请求暂时处理不了"吗?那你怕的是可用性受损,你该偏向 CP 的 ZooKeeper 会更难受,Redis 的高可用更适合你。你更怕"极小概率下两个进程同时拿到锁,导致一次数据错乱"吗?那你怕的是一致性被破坏,你就该选 CP 的 ZooKeeper,哪怕它慢一点、运维重一点。这个"想清楚自己更怕哪种失败、再据此选型"的思维方式,适用范围远不止分布式锁:选数据库,要在强一致和高可用、高吞吐之间权衡;做缓存,要在数据新鲜度和性能之间权衡;设计接口,要在功能丰富和稳定简单之间权衡。技术选型的成熟,从来不是去追逐某个"最强"的方案——没有最强,只有最合适;它是先把你的业务对各种失败的承受能力摸得一清二楚,知道自己的底线和软肋在哪,然后挑一个"它的弱点恰好你扛得住、它的强项恰好补上你的软肋"的方案。脱离了具体业务去谈一个技术方案的好坏,是没有意义的;一切选型,都是权衡,而权衡的依据,是你对自己业务的深刻理解。

六、工程里那些分布式锁的坑

分布式锁的主线理顺了,落地时还有几个工程坑反复咬人。第一个,能不用锁就别用锁。分布式锁是把"重武器",它把并行变回了串行,会成为性能瓶颈。很多场景用数据库的乐观锁(带版本号的 UPDATE)、用唯一约束、用业务幂等,就能避免对锁的依赖——优先考虑这些。第二个,锁的粒度要尽量细。别用一把大锁锁住"所有商品的库存",要按 product_id 锁,让不同商品的扣减能并行,锁只在同一商品的并发上生效。第三个,加锁要设获取超时。抢不到锁时不能无限等下去,要设一个等待上限,超时就快速失败返回,别让请求都堆在抢锁上。第四个,持锁时间要尽量短。临界区里只放真正需要互斥的代码,把能挪出去的(日志、通知、非关键计算)都挪到锁外,持锁越久,并发度越低、提前过期的风险越大。第五个,锁不是万能的,业务还要能兜底。既然 Redis 锁在极端情况下可能双持有,真正关键的操作(扣库存、扣款)在锁之外,还应该有数据库唯一约束、乐观锁这类最终防线。第六个,可重入要显式设计。同一个线程若可能在持锁期间再次请求同一把锁,要做可重入计数,否则会自己把自己锁死。把这些信号都接进监控,你才有数据判断锁健不健康:

分布式锁上线后必须盯死的几个指标:

  lock_wait_time        抢锁的等待耗时,p99 飙高说明锁竞争激烈
  lock_acquire_fail     抢锁超时失败的次数,持续非 0 要排查热点
  lock_hold_time        持锁时长分布,过长说明临界区太重该瘦身
  lock_timeout_expired  锁因 ttl 到期而非主动释放的次数,非 0 危险
  watchdog_renew_count  看门狗续期次数,某些锁频繁续期说明业务偏慢
  release_mismatch      释放时 token 对不上的次数,反映锁被提前易主
  redis_lock_unavail    因 Redis 故障导致加锁不可用的时长

这里要建立的认知是:把这一节的坑串起来看,尤其是头一条"能不用锁就别用锁",会浮现一个对待"分布式锁"乃至一切"重型解决方案"的总体态度——分布式锁是一个能解决问题、但代价高昂的方案,你引入它之前,要先尽一切努力问自己"这个问题能不能根本不靠它来解决"。我第一版从单机锁直接跳到分布式锁,跳得太顺、太想当然了,我从没停下来想过:我真的需要一把锁吗?库存扣减这个问题,其实有不止一条路:我可以用一条带条件的、原子的 SQL(UPDATE stock SET count = count - 1 WHERE id = ? AND count >= 1),靠数据库行锁的原子性,根本不需要应用层的分布式锁;我可以用乐观锁,加一个版本号,更新时带上版本条件,冲突了就重试;我可以把同一商品的扣减请求,都路由到同一个队列里串行处理。这些方案,各有各的适用场景,但它们都绕开了"显式地引入一个分布式锁组件"这件事。为什么要尽量绕开?因为分布式锁的代价是实实在在的:它把本可以并行的操作强行串行化,它是性能的瓶颈;它自己是一个需要被正确实现、正确运维的有状态组件,本文前五节讲的过期、误删、续期、可用性,每一个都是它带来的、需要你额外背负的复杂度;它还在你的系统里,引入了一个新的故障点和依赖。一个手段越强大、越通用,它的代价往往也越重。所以,一个有经验的工程师,看到"并发""互斥"这样的字眼,不会条件反射地就去加锁——他会先在脑子里过一遍那些"更轻"的选项:这个操作本身能不能做成原子的(用数据库的原子操作)?能不能用乐观并发控制代替悲观加锁?能不能通过把竞争的请求路由到一处来从源头避免竞争?能不能把操作设计成幂等的、从而对偶尔的重复执行免疫?只有当这些更轻的路都走不通,确实需要一个跨进程的、强制性的互斥时,他才会、并且是谨慎地,请出分布式锁这把重武器,同时清醒地准备好承接它带来的全部复杂度。这个认知可以推广到一切技术决策:面对一个问题,不要一上来就伸手去拿那个最强大、最通用的工具——最强大的工具,往往也是最重、代价最高的。永远先问"有没有更简单、更轻、代价更小的办法",把重武器,留到真正非它不可的时候。用最小的代价解决问题,而不是用最威猛的方案解决问题,这是工程审美里很重要的一条。

关键概念速查

概念 说明 关键点
锁的作用域 一把锁有效与被共享的范围 进程内锁只对同进程的线程有效
分布式锁 存在共享存储里跨进程互斥的锁 所有进程都能看到都来抢同一把
SETNX key 不存在才设成功 用来抢锁 必须同时设过期 用 set 的 nx 加 ex
锁过期 给锁设 ttl 防持有者崩溃造成死锁 租约思想 到期自动回收资源
误删别人的锁 锁已过期易主 却被原持有者删掉 锁要带唯一 token 释放前校验归属
原子释放 校验 token 和删除合成一步 用 Lua 脚本 避免 check-then-act 缝隙
看门狗续期 后台线程定期延长锁的过期时间 缓解锁提前过期 进程崩则续期自停
主从锁丢失 加锁数据没同步到从节点就切换 Redis 锁偏可用性 不保证强一致
CAP 权衡 一致性与可用性不可同时极致 Redis 偏 AP ZooKeeper 偏 CP
乐观锁 带版本号的 UPDATE 替代显式加锁 能不用分布式锁就别用

避坑清单

  1. 别用进程内锁解分布式并发,threading.Lock 的作用域只在一个进程内。
  2. 分布式锁要放共享存储,Redis、ZooKeeper 这类所有进程都够得着的地方。
  3. 加锁必须带过期时间,且设值与设过期要用一条命令原子完成,防持有者崩溃死锁。
  4. 锁要带唯一持有者标识,释放前先校验是不是自己的锁,绝不直接 DEL。
  5. 校验加删除要用 Lua 原子执行,别先 get 再 del,中间的缝隙会误删别人的锁。
  6. 用看门狗应对锁提前过期,业务没做完就续期,但要知道它只是缓解不是根治。
  7. 认清 Redis 锁的可用性边界,主从切换可能丢锁,极端一致性要上 ZooKeeper、etcd。
  8. 关键操作要有业务兜底,扣库存扣款在锁之外还应有唯一约束、乐观锁这层最终防线。
  9. 锁粒度要细、持锁时间要短,按业务主体加锁,临界区只放真正要互斥的代码。
  10. 能不用锁就别用锁,优先用原子 SQL、乐观锁、幂等,分布式锁是代价高的重武器。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为加锁就是把临界区圈起来、同一时刻只放一个进,并发安全就有了。可我用的 threading.Lock 是个进程内存里的对象,它的作用域死死框在一个进程里。服务部署成三个实例就是三个进程,每个进程里都有一把自己的、互不相识的锁,三个进程会同时进临界区、同时扣同一行库存。锁从没失效,只是它的作用域,根本覆盖不了"多个进程"这个范围——要跨进程互斥,锁必须挪到一个所有进程都够得着的共享存储里。

真正把分布式并发控制做扎实,工作量不在"加把锁圈住临界区",而在认清分布式锁的难全在异常路径上:持有者会崩溃,所以锁必须带过期防死锁;锁会因业务慢而提前过期、被别人抢走,所以释放前必须校验持有者、用 Lua 原子地 check-and-del;业务可能比 ttl 慢,所以要用看门狗续期来缓解;单点 Redis 有可用性边界,主从切换可能丢锁,极端场景要上 ZooKeeper。每一步都是在为"持有锁的进程随时可能出意外"这件事兜底。每一步都不复杂,难的是先承认:你手里的不是一把万能的、加上就安全的锁,而是一个需要你为种种异常路径层层设防的精巧机制。

我后来常拿"公司里那间唯一的会议室"来想这件事。一个团队要用会议室,在自己工位上喊一嗓子"我占了"——这就是单机锁,只有听得见这一嗓子的同组人会让着你,隔壁组的人根本不知道,照样推门进来,撞车。正确的做法,是在会议室门口挂一块共享的预约牌(分布式锁):谁用谁把名字写上去。可只写名字不够——你写完名字进去开会,中途被老板叫走再没回来,这间会议室就被你的名字永久占着,谁也不敢用(死锁);所以预约必须写一个结束时间,到点了名字自动作废(过期)。可如果你开会超时了,牌子上的时间一过,别人就把牌子擦了写上自己的名字进来了;你这时晃悠回来,顺手把牌子擦了——擦掉的其实是别人的预约(误删);所以擦牌子前你得先看一眼"上面写的还是不是我的名字"(校验持有者)。要是你这个会确实重要、就是会开很久,那得有个助理隔一会儿就来把你的结束时间往后涂一截(看门狗续期)。你看,"挂块牌子"这个动作一点都不难,难的是把"人会中途离开、会议会超时、牌子会被误擦"这些意外,一个一个都想到、都兜住。

这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你本地只起一个实例,单机锁稳稳地挡住几百个线程的并发,你压一万次都压不出超卖,那把锁看起来固若金汤。它只在服务被部署成多实例的那一刻才暴露——而多实例是几乎所有线上服务的标配。换成 Redis 锁之后,那些过期、误删、续期的坑,又只在进程崩溃、业务卡顿、主从切换这些"异常的瞬间"才引爆,而这些瞬间在功能测试里几乎不会出现。它们没有一个会在你跑测试时喊疼,它只是在生产环境里,在某次发版、某次 GC、某次网络抖动时,悄悄地多扣一笔库存、悄悄地让一个本该互斥的操作并行了。所以别等线上开始对账对不平、等用户投诉超卖,才想起去补这些防护:决定用一把锁的那一刻,就该把"我的服务是多实例的吗""持有这把锁的进程如果崩了会怎样""这把锁会不会在业务没做完时就过期"当成和写对业务逻辑同等重要的事来设计——这些异常路径的处理,不该是"出了事故再补"的补丁,而该是你设计加锁方案时,和加锁动作本身一起摆上桌的另一半。把"分布式锁的难全在异常路径上"这件事在一开始就认下来,你才算真正跳出了那个把锁当成万能护身符、出了超卖还在盯着临界区代码发愁的坑。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 结构化输出完全指南:从一次"json.loads 在生产环境随机崩溃"看懂大模型为什么给不了你稳定的 JSON

2026-5-22 19:46:31

技术教程

LLM 上下文窗口管理完全指南:从一次"对话变长后机器人开始胡说八道"看懂 token 预算与多轮记忆

2026-5-22 20:00:10

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索