主题
第十六章:分布式锁与并发控制
Q50:为什么单机锁不够用?分布式锁解决的是什么问题?
答:
在单机部署下,用 JVM 的 synchronized、ReentrantLock,或者 Go 的 sync.Mutex、Python 的多线程锁,就能保证同一时刻只有一个线程执行某段代码。但一旦服务做了集群部署,多个实例跑在不同进程、甚至不同机器上,这些「进程内锁」就失效了。
text
单机: 部署多个实例后:
进程 A 实例 A 实例 B 实例 C
└─ lock ──► 有效 └─ 自己的锁 ──► 各自为政,互不相干
▲
这里的锁只在「各自进程内」生效
不同实例之间没有共享这把锁分布式锁要解决的问题,就是在多个独立进程/实例之间,对某个共享资源(或某段逻辑)实现互斥,保证同一时刻只有一个实例能拿到锁并执行。
典型场景:
| 场景 | 为什么需要分布式锁 |
|---|---|
| 库存扣减 / 秒杀 | 多个实例同时扣库存,不加锁会导致超卖 |
| 定时任务防重 | 多个实例同时跑同一个定时任务,会造成重复执行 |
| 幂等操作 | 同一个请求被多个实例重复处理,需要锁保证只处理一次 |
| 避免重复下单 / 抢占资源 | 例如多个 worker 争抢同一个任务,谁抢到谁处理 |
锁要满足几个基本要求:
- 互斥:同一时刻只有一个客户端能持锁;
- 不死锁:持锁者崩溃后,锁能自动释放(靠过期时间);
- 可释放:只有持锁者自己能释放锁(不能被别人误删);
- 容错:锁服务本身高可用,不能因单点故障导致锁全部失效。
其中「互斥」和「死锁」这两点,单机锁天然具备但跨实例后就丢失了,这正是需要 Redis 这类共享存储介入的原因——用 Redis 作为所有实例共同可见的「锁的仲裁者」。
Q51:用 Redis 实现分布式锁,正确姿势是什么?有哪些坑?
答:
Redis 分布式锁的正确实现,核心只有一句话:抢锁用 SET NX EX,释放锁用 Lua 脚本「判断 token 再删除」。
一、正确的抢锁
python
import uuid
def acquire_lock(lock_key: str, timeout_seconds: int = 30) -> str | None:
lock_token = str(uuid.uuid4())
ok = redis_client.set(lock_key, lock_token, nx=True, ex=timeout_seconds)
return lock_token if ok else NoneSET key value NX EX seconds 一条命令同时做了三件事,且是原子的:
NX:只有 key 不存在时才写入(实现互斥);EX:设置过期时间(防止持锁者崩溃导致死锁);value(token):写入唯一随机值,用于释放锁时校验「是不是自己的锁」。
这里不能用 SETNX 再单独 EXPIRE 这种两条命令的方式,因为两步之间如果进程崩溃,锁就没有过期时间,会永久锁死。
二、正确的释放锁(必须 Lua)
python
UNLOCK_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
def release_lock(lock_key: str, lock_token: str):
redis_client.eval(UNLOCK_SCRIPT, 1, lock_key, lock_token)为什么必须用 Lua:因为「判断 token 是否是自己的」和「删除锁」两步必须原子。否则:
text
客户端 A:GET lock_key → "A-token"(确认是自己的)
(此时锁刚好过期,客户端 B 抢到,写入 "B-token")
客户端 A:DEL lock_key → 误删了 B 的锁Lua 脚本把「GET 判断 + DEL」合并成一次原子执行,杜绝误删。
三、使用方式(业务侧模板)
python
def do_with_lock(lock_key: str):
token = acquire_lock(lock_key)
if token is None:
return False # 没抢到锁,放弃或重试
try:
# 临界区:执行需要互斥的业务逻辑
do_critical_work()
finally:
release_lock(lock_key, token)
return True四、常见坑与规避
| 坑点 | 后果 | 规避方式 |
|---|---|---|
抢锁用 SETNX 不设过期 | 持锁者崩溃 → 永久死锁 | 用 SET NX EX 一条命令 |
释放时直接 DEL | 业务超时锁易主后,误删别人的锁 | 用 Lua 判断 token 再删 |
| 释放时先 GET 再 DEL(两步) | 两步非原子,仍可能误删 | 合并进一条 Lua |
| token 用固定字符串 | 无法区分锁归属,任何客户端比对都相等 | 用 UUID 随机唯一 token |
| 锁过期时间太短 | 业务还没执行完,锁就自动释放,别的实例又进来了 | TTL 要覆盖业务最长耗时,或引入「锁续期」 |
| 锁过期时间太长 | 持锁者崩溃后,锁长时间占着,影响其他实例 | 合理评估业务耗时,别设过长 |
五、锁续期与可重入(进阶)
上面的是「一次性」简单锁。生产环境中还有两个进阶需求:
锁续期(看门狗 watchdog):业务执行时间可能超过锁 TTL,需要后台线程定期给锁续期,避免锁提前释放。Redisson 默认提供了这个能力。
可重入:同一个持锁者再次调用需要同一把锁的方法时,不应该被自己挡住。需要记录「持锁者 + 重入次数」,用 Hash 存储持锁线程标识和计数。
这两点用原生 Redis 手写比较繁琐,生产环境通常直接用成熟的库(如 Java 的 Redisson、Go 的 redsync、Python 的 redis-py 配合封装),而不是自己从零造轮子。
六、单点 Redis 锁的可靠性问题
上面基于单实例 Redis 的锁,在「Redis 主从切换」场景下存在一个经典缺陷:客户端 A 在主节点拿到锁,主节点还没来得及同步到从节点就宕机了,从节点被提升为新主,此时新主上没有这把锁,客户端 B 又能拿到锁,出现「两把锁同时存在」。
对于强一致性要求极高的场景,可以考虑:
- Redlock 算法:向多个独立的 Redis 主节点同时加锁,多数节点加锁成功才算成功(有争议,需谨慎使用,且要求节点间无主从关系);
- ZooKeeper / etcd:用强一致的分布式协调系统做锁(ZooKeeper 的临时顺序节点天然适合做锁),代价是性能和复杂度。
选型建议:大多数业务对「极致一致性」的要求没那么高,单实例 Redis + SET NX EX + Lua 释放已经足够;只有当锁的正确性直接关系到资金、不可重复的关键资产时,才考虑引入 ZooKeeper/etcd 这类强一致方案。
Q52:秒杀系统里,如何用 Redis 防止超卖?
答:
秒杀是「高并发 + 有限库存」的典型场景,超卖是因为多个请求同时读到「还有库存」,都通过了校验并同时扣减。Redis 防止超卖的关键,是把「检查库存 + 扣减库存」做成一个原子操作。
一、为什么普通写法会超卖
python
# 错误写法:先读再写,非原子
def buy_wrong(goods_id: str):
stock = redis_client.get(f"stock:{goods_id}") # 读库存
if int(stock or 0) <= 0:
return "已售罄"
redis_client.decr(f"stock:{goods_id}") # 扣库存(两步之间被并发插入)
return "购买成功"两个并发请求可能同时读到 stock=1,都通过 <= 0 判断,都执行 DECR,最终库存变成 -1,超卖 1 件。
二、方案一:Lua 脚本原子扣减
把「判断 + 扣减」放进一条 Lua,让 Redis 单线程原子执行:
lua
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1 -- 已售罄
end
redis.call('DECR', KEYS[1])
return stock - 1 -- 返回扣减后的剩余库存python
BUY_SCRIPT = """
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
return stock - 1
"""
def buy(goods_id: str):
left = redis_client.eval(BUY_SCRIPT, 1, f"stock:{goods_id}")
if left < 0:
return "已售罄"
return f"购买成功,剩余 {left}"因为脚本整体原子执行,多个请求的「读 + 判断 + 减」会串行化,不会同时读到相同的旧库存,从而杜绝超卖。
三、方案二:DECR + 阈值判断(前提是先预扣)
也可以用 DECR 的返回值来判断,但前提是「先无脑减、再根据结果回补」,而不是「先读再决定减不减」:
lua
-- 先原子递减,再根据结果判断是否超卖
local left = redis.call('DECR', KEYS[1])
if left < 0 then
redis.call('INCR', KEYS[1]) -- 超卖了,回补
return -1
end
return left核心思想:DECR 本身是原子的,先让库存原子地减 1,再根据减完后的值判断。减成负数说明超卖,再 INCR 回补。
四、方案三:List / 队列预分配(商品秒杀)
如果库存是「有限的实体商品」,也可以提前把 N 件商品的「名额」放进一个 List,用户抢购就是 LPOP 弹出名额,弹到就成功,弹空就售罄:
python
# 初始化:把 100 个名额塞进 List
redis_client.lpush("seckill:goods:1001", *range(100))
def buy_by_queue(user_id: str):
# 原子弹出:谁弹出名额谁抢到
got = redis_client.lpop("seckill:goods:1001")
if got is None:
return "已售罄"
return f"抢购成功,名额编号 {got}"LPOP 是原子的,天然不会超卖。但要注意两点:用这种方案时,库存的「真实扣减」和「名额预分配」要对应好;还要防止同一个用户重复抢购(用 Set 记录已抢用户,或加 SISMEMBER 判断)。
五、秒杀系统的完整减负思路
| 手段 | 作用 |
|---|---|
| Lua / 原子命令扣库存 | 保证库存不超卖 |
| 前置限流 | 把超出库存容量的大量请求直接挡掉 |
| 库存预扣 + 异步落库 | Redis 里先扣,异步同步到数据库,减少 DB 压力 |
| 用户去重(Set) | 防止同一用户重复抢购 |
| 消息队列削峰 | 把突发流量先入队,再慢慢消费,保护后端 |
| 本地缓存 + 预热 | 热点商品数据提前缓存,减少 Redis 压力 |
六、小结
- 防超卖的本质是把「读库存 + 判断 + 扣库存」原子化;
- 手段上优先用 Lua 脚本(判断 + 扣减原子)或先
DECR再判断回补; - 也可以用 List
LPOP名额预分配,天然不超卖; - 生产环境还要配合限流、去重、削峰、异步落库,形成完整方案。
小结
- 分布式锁解决「多实例之间互斥」问题,用 Redis 作为共享仲裁者。
- 正确姿势:
SET NX EX抢锁(原子 + 设过期)、Lua「判断 token 再 DEL」释放(原子防误删)、token 用 UUID。 - 进阶:锁续期(看门狗)、可重入锁;对极致一致性要求高的场景再考虑 Redlock 或 ZooKeeper/etcd。
- 防超卖核心是「检查 + 扣减」原子化:Lua 脚本、先
DECR再回补、或 ListLPOP名额预分配。