Skip to content

第十六章:分布式锁与并发控制 ​

Q50:为什么单机锁不够用?分布式锁解决的是什么问题? ​

答:

在单机部署下,用 JVM 的 synchronized、ReentrantLock,或者 Go 的 sync.Mutex、Python 的多线程锁,就能保证同一时刻只有一个线程执行某段代码。但一旦服务做了集群部署,多个实例跑在不同进程、甚至不同机器上,这些「进程内锁」就失效了。

text
单机:                   部署多个实例后:
进程 A                    实例 A           实例 B           实例 C
  └─ lock ──► 有效          └─ 自己的锁 ──► 各自为政,互不相干
                                     ▲
                          这里的锁只在「各自进程内」生效
                          不同实例之间没有共享这把锁

分布式锁要解决的问题,就是在多个独立进程/实例之间,对某个共享资源(或某段逻辑)实现互斥,保证同一时刻只有一个实例能拿到锁并执行。

典型场景:

场景为什么需要分布式锁
库存扣减 / 秒杀多个实例同时扣库存,不加锁会导致超卖
定时任务防重多个实例同时跑同一个定时任务,会造成重复执行
幂等操作同一个请求被多个实例重复处理,需要锁保证只处理一次
避免重复下单 / 抢占资源例如多个 worker 争抢同一个任务,谁抢到谁处理

锁要满足几个基本要求:

  1. 互斥:同一时刻只有一个客户端能持锁;
  2. 不死锁:持锁者崩溃后,锁能自动释放(靠过期时间);
  3. 可释放:只有持锁者自己能释放锁(不能被别人误删);
  4. 容错:锁服务本身高可用,不能因单点故障导致锁全部失效。

其中「互斥」和「死锁」这两点,单机锁天然具备但跨实例后就丢失了,这正是需要 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 None

SET 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 要覆盖业务最长耗时,或引入「锁续期」
锁过期时间太长持锁者崩溃后,锁长时间占着,影响其他实例合理评估业务耗时,别设过长

五、锁续期与可重入(进阶) ​

上面的是「一次性」简单锁。生产环境中还有两个进阶需求:

  1. 锁续期(看门狗 watchdog):业务执行时间可能超过锁 TTL,需要后台线程定期给锁续期,避免锁提前释放。Redisson 默认提供了这个能力。

  2. 可重入:同一个持锁者再次调用需要同一把锁的方法时,不应该被自己挡住。需要记录「持锁者 + 重入次数」,用 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 再回补、或 List LPOP 名额预分配。