Redis 分布式锁完全指南:从一次"同一笔订单被扣三次款"看懂 SET NX EX 为什么不是分布式锁

2023 年我接手一个老项目里面有个用 Redis 做的分布式锁场景是订单去重防止用户连续点击下单按钮造成重复扣款这套锁跑了两年没出过事我以为它就是稳的某天大促当晚一个用户疯狂连点付款页面客服那边几分钟后就传来同一笔订单被扣了三次的截图我打开日志一看三次请求确实都拿到了锁三次都执行了下单我去翻代码这把锁的实现是经典的 SET key value EX 30 NX 加锁成功就执行业务执行完 DEL key 释放看起来跟教科书一模一样可问题就出在这种看起来一模一样的实现上一串麻烦冒了出来第一种最先把我打懵那一晚我看到了一段 GC 日志业务执行中间有过一次 1.2 秒的 STW 锁的 TTL 是 30 秒怎么会过期我检查代码业务里调了一个第三方支付接口超时设了 60 秒锁的 TTL 居然比业务超时还短第二种最难缠 DEL 释放锁的时候只是 DEL key 没判断 value 是不是自己加的锁结果服务 A 加锁服务 A 卡住锁过期服务 B 也加锁服务 A 缓过来一句 DEL 把 B 的锁也给释放了第三种最离谱锁过期续约这事我没做以为业务都很短一个用户操作能多久结果有一类对账操作能跑 5 分钟 TTL 设 30 秒根本不够续不上锁就丢了第四种最莫名其妙我们 Redis 是主从架构主写从读某次主挂了从切上来那段时间锁数据还没复制过去同一个 key 不同的客户端都加锁成功了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 SET NX EX 三个参数加一行 DEL 就是一把可靠的分布式锁可这个认知是错的分布式锁的难点从来不是加锁那一行 API 而是什么时候它会失效那一整套场景本文从头梳理 SET NX EX 写法藏了哪些坑 TTL 怎么选续约怎么做 DEL 必须配合 value 校验 Redlock 算法解决的真正问题是什么以及一些把分布式锁做扎实要避开的工程坑

2023 年我接手一个老项目里面有个用 Redis 做的分布式锁场景是订单去重防止用户连续点击下单按钮造成重复扣款这套锁跑了两年没出过事我以为它就是稳的某天大促当晚一个用户疯狂连点付款页面客服那边几分钟后就传来同一笔订单被扣了三次的截图我打开日志一看三次请求确实都拿到了锁三次都执行了下单。我去翻代码这把锁的实现是经典的 SET key value EX 30 NX加锁成功就执行业务执行完 DEL key 释放看起来跟教科书一模一样可问题就出在这种"看起来一模一样"的实现上一串麻烦冒了出来第一种最先把我打懵那一晚我看到了一段 GC 日志业务执行中间有过一次 1.2 秒的 STW 锁的 TTL 是 30 秒怎么会过期我检查代码业务里调了一个第三方支付接口超时设了 60 秒锁的 TTL 居然比业务超时还短第二种最难缠 DEL 释放锁的时候只是 DEL key 没判断 value 是不是自己加的锁结果服务 A 加锁服务 A 卡住锁过期服务 B 也加锁服务 A 缓过来一句 DEL 把 B 的锁也给释放了第三种最离谱锁过期续约这事我没做以为业务都很短一个用户操作能多久结果有一类对账操作能跑 5 分钟 TTL 设 30 秒根本不够续不上锁就丢了第四种最莫名其妙我们 Redis 是主从架构主写从读某次主挂了从切上来那段时间锁数据还没复制过去同一个 key 不同的客户端都"加锁成功"了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 SET NX EX 三个参数加一行 DEL 就是一把可靠的分布式锁可这个认知是错的分布式锁的难点从来不是"加锁"那一行 API而是"什么时候它会失效"那一整套场景本文从头梳理 SET NX EX 写法藏了哪些坑 TTL 怎么选续约怎么做 DEL 必须配合 value 校验 Redlock 算法解决的真正问题是什么以及一些把分布式锁做扎实要避开的工程坑

问题背景

分布式锁是后端面试题里出现频率极高的一个话题,但真正在生产里把它做对的项目并不多。原因是这把锁背后有一整组隐藏假设——锁的持有方"一定会"在 TTL 内执行完、Redis "一定不会"主从切换丢数据、客户端和服务端时钟"一定保持"同步、业务"一定不会"被 STW 暂停。每个假设单看都成立,合在一起就一个接一个被打破。常见的几种坑:

  • TTL 短于业务执行时间:业务还没完锁就过期,别人能加上锁,同一段临界区被多人执行。
  • 释放别人的锁:DEL 不带 value 校验,任何客户端都能误删其他人的锁。
  • 没续约机制:长任务永远撑不过 TTL,要么 TTL 设极大丢失死锁保护,要么 TTL 短任务跑不完。
  • 主从切换丢锁:主写从读架构下,异步复制让"加锁成功"在 failover 后变成"两个客户端都成功"。

一、为什么"SET NX EX + DEL"不够

很多教程把分布式锁讲成一行代码:SET key value EX 30 NX,加上成功就持有锁,完事 DEL key。这个写法没错,但它只是"加锁的语法"对了,不代表"锁的语义"对了。一把可用的分布式锁,起码要保证三件事:互斥(同一时刻只有一个客户端持有)、防死锁(持有方崩溃后锁能被回收)、防误释放(只有持有方能释放自己的锁)。SET NX EX 解决了前两件,DEL 这一步如果不加判断,第三件就破了。

第三件事被破的典型场景是这样:客户端 A 加锁,TTL 30 秒,A 因为 GC 或网络抖动卡了 35 秒,锁过期被 Redis 自动回收;客户端 B 趁机加锁;A 缓过来不知道自己锁已过期,执行业务尾部那行 DEL key,把 B 的锁给删了;客户端 C 趁机加锁;此时 B 还在执行临界区,B 和 C 同时在临界区里跑——互斥被破坏。

下面这段是有问题的典型写法,它在生产里跑了两年都没事不代表它对,只代表"还没到极端场景":

import redis, time

r = redis.Redis()

def buggy_lock(key: str, ttl: int = 30):
    ok = r.set(key, "1", ex=ttl, nx=True)
    return bool(ok)

def buggy_unlock(key: str):
    r.delete(key)   # 大坑:没有校验是不是自己加的锁

# 业务代码
if buggy_lock("order:lock:8821"):
    try:
        do_business()   # 业务里有可能卡住超过 TTL
    finally:
        buggy_unlock("order:lock:8821")  # 可能误删别人的锁

认知翻转:分布式锁的"加锁"和"解锁"必须是配对的、带身份的。加锁时存一个只有自己知道的 token(UUID 或一次性随机数),解锁时先比对 token 再删除。这个动作必须是原子的——客户端层面"读出来再判断再删除"不是原子,Redis 主从切换或网络重连那一瞬间足以让你比对完发出 DEL 命令时锁已经换主人了。原子比对+删除只能用 Lua 脚本(EVAL)实现,这是绕不开的写法。

二、正确的加锁与解锁:UUID + Lua 原子释放

正确的最小可用实现需要三处改造:加锁时存一个 UUID;解锁时用 Lua 校验 UUID 后再删;TTL 必须显式设置,绝不依赖默认值。下面是基本骨架:

import redis, uuid, time

r = redis.Redis()

# 解锁脚本:KEYS[1] = lock key, ARGV[1] = token
UNLOCK_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
"""
_unlock = r.register_script(UNLOCK_LUA)

def acquire(key: str, ttl_ms: int = 30000) -> str | None:
    token = uuid.uuid4().hex
    # px 用毫秒精度,比秒精度更适合分布式锁
    ok = r.set(key, token, px=ttl_ms, nx=True)
    return token if ok else None

def release(key: str, token: str) -> bool:
    # Lua 保证 "比对 + 删除" 在 Redis 单线程里原子执行
    return bool(_unlock(keys=[key], args=[token]))

# 使用
token = acquire("order:lock:8821", ttl_ms=30000)
if token:
    try:
        do_business()
    finally:
        release("order:lock:8821", token)
else:
    raise BusyError("order in processing")

这套实现解决了三件事里的"防误释放":每个客户端只能释放自己的锁,别人无论 DEL 多少次都跟自己的锁无关。但它还没解决"业务执行时间超过 TTL"的问题——锁还是会过期,过期后被别人抢走,你以为自己还持有,实际上已经不持有了。这就引出下一节的续约机制。

认知翻转:分布式锁的 token 不是"加密考虑",而是"身份考虑"。它告诉 Redis "这把锁是谁的",让释放操作能识别"是不是我自己的锁"。一些团队为了图省事用线程 ID、机器 IP、进程号当 token,在单机进程内可能没问题,跨机器、跨容器、跨进程重启就会冲突。UUID 是最简单也最安全的选择,128 位空间足够保证两次冲突的概率小到可以忽略。

三、TTL 怎么选 + 续约(看门狗)机制

TTL 的选择是一个工程权衡。设短了业务跑不完就过期;设长了进程崩溃后锁久久不能回收,临界区被冻结。任何"统一一个值"的选法都是错的,正确做法是按业务划分锁的种类,每种用合适的 TTL。一个常见的分级如下:

分布式锁 TTL 分级(经验值,按业务调整)

短锁 (200ms - 2s):
  场景: 单条记录的读改写、扣库存、扣余额
  特点: 业务一定能在 1 秒内结束
  续约: 不需要,本来就短
  风险: 网络抖动一下就过期,业务要做幂等

中锁 (5s - 60s):
  场景: 提交订单、推送消息、生成报表
  特点: 业务大概率 30s 内结束,但偶尔会慢
  续约: 强烈建议加看门狗,每 1/3 TTL 续一次
  风险: GC/抖动导致锁过期,需要做幂等兜底

长锁 (1min - 10min):
  场景: 对账、批量处理、定时任务互斥
  特点: 业务执行时间可达分钟级
  续约: 必须加看门狗
  风险: 持有方崩溃后等到 TTL 才会被释放,期间没人能跑

不要用 (>10min):
  分布式锁不是用来保护长任务的
  长任务请用分布式调度框架(如 ElasticJob、XXL-JOB)
  或者拆分成可恢复的小步骤,每步加短锁

对于中长锁,续约机制几乎是必需的。续约的标准实现叫"看门狗"(Watchdog):后台开一个线程,定时检查锁是否还在自己手里,如果是就 PEXPIRE 把 TTL 续上。Java 里 Redisson 自带这个机制,Python 这边一般要自己写。下面是一个最小的 Python 看门狗实现:

import threading, time

RENEW_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("pexpire", KEYS[1], ARGV[2])
else
    return 0
end
"""
_renew = r.register_script(RENEW_LUA)

class Watchdog:
    def __init__(self, key: str, token: str, ttl_ms: int):
        self.key = key
        self.token = token
        self.ttl_ms = ttl_ms
        self.interval = ttl_ms / 3000.0   # 每 1/3 TTL 续一次,单位秒
        self._stop = threading.Event()
        self._thread = None

    def start(self):
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()

    def _loop(self):
        while not self._stop.wait(self.interval):
            ok = _renew(keys=[self.key], args=[self.token, self.ttl_ms])
            if not ok:
                # 锁已经不属于自己了,要么过期被抢,要么被误删
                # 这里必须打告警,业务可能正在执行不该执行的代码
                logger.error("watchdog lost lock: %s", self.key)
                break

    def stop(self):
        self._stop.set()
        if self._thread:
            self._thread.join(timeout=1)

看门狗的关键不在于"会续约",而在于"续约失败时能及时发现"。续约失败说明锁已经不在你手里了,这时你的业务还在跑,等于在没有锁保护的情况下操作临界区。正确的处理是立即抛异常中断业务,而不是闷头继续——闷头继续就是数据错乱的起点。

认知翻转:看门狗不是"延长锁的存活时间"的工具,而是"持续验证我还持有锁"的工具。它一旦验证失败就要立即叫停业务,而不是默默尝试重新加锁。重新加锁意味着你之前一段时间业务是在没锁保护下跑的,数据已经可能错了,再补一把锁也补不回来。设计看门狗时优先考虑的是"丢锁告警和中断",而不是"如何续得更久"。

四、主从架构与 Redlock:Redis 集群下的锁陷阱

单 Redis 实例下,上面这套实现已经基本够用。但生产环境一般用主从或集群,这时一个新的问题出现了:Redis 的主从复制是异步的。客户端 A 在主节点上 SET 成功,主节点还没把这个 SET 复制给从节点,主就挂了;从被提升为新主;客户端 B 来 SET 同一个 key,因为新主上根本没有这条记录,B 也加锁成功——两个客户端都"成功加锁"了。

这就是 antirez(Redis 作者)提出 Redlock 算法的原因。Redlock 的核心思路是:不依赖任何一个 Redis 节点,而是部署多个互相独立的 Redis 节点,客户端依次向所有节点请求加锁,只有当多数节点(N/2+1)都加锁成功且总耗时小于 TTL,才算真正持有锁。多数派思想保证了即使一个节点挂掉切换,锁的所有权也不会凭空冒出第二个。下面是 Redlock 加锁过程的流程图:

[mermaid]
flowchart TD
A[客户端要加锁] --> B[记录开始时间 t0]
B --> C[依次向 5 个独立 Redis 节点 SET NX PX]
C --> D{成功节点数 >= 3}
D -->|否| E[向所有节点 DEL 释放已加上的锁]
E --> F[加锁失败]
D -->|是| G[计算耗时 elapsed = now - t0]
G --> H{elapsed < TTL}
H -->|否| E
H -->|是| I[加锁成功 有效 TTL = TTL - elapsed]
I --> J[执行业务]
J --> K[释放: 向所有节点 DEL]

Redlock 的客户端骨架大致长这样,生产中不要手写,用成熟库的实现:

import time, uuid, redis

# 5 个完全独立的 Redis 节点,不是主从,不是 cluster
nodes = [redis.Redis(host=h) for h in
         ("r1", "r2", "r3", "r4", "r5")]

UNLOCK_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
"""

def redlock_acquire(key: str, ttl_ms: int = 10000):
    token = uuid.uuid4().hex
    t0 = time.time()
    success = 0
    for n in nodes:
        try:
            if n.set(key, token, px=ttl_ms, nx=True):
                success += 1
        except Exception:
            pass   # 单节点失败不阻塞,只统计成功数

    elapsed_ms = (time.time() - t0) * 1000
    quorum = len(nodes) // 2 + 1
    valid_ttl = ttl_ms - elapsed_ms

    if success >= quorum and valid_ttl > 0:
        return token, valid_ttl
    # 加锁失败:必须向所有节点(含失败的)发 DEL 清理
    for n in nodes:
        try:
            n.eval(UNLOCK_LUA, 1, key, token)
        except Exception:
            pass
    return None, 0

Redlock 有一段著名的争论,Martin Kleppmann 曾撰文质疑它在某些异常场景下并不安全,antirez 也写过回应。这场争论的结论可以简化为:Redlock 不是"绝对安全的分布式锁",任何基于异步复制或不强一致存储的分布式锁都不是绝对安全的;但 Redlock 在工程上已经把锁失效的概率压到了非常小,对于多数业务"已经够用"。如果业务对一致性要求极高(比如金融对账)应该用基于 ZooKeeper、etcd 这类强一致存储的锁服务,而不是 Redis。

实务上有几个比 Redlock 更现实的选择:第一,业务能容忍小概率失误的话,单实例 Redis + UUID + Lua 释放 + 看门狗就够了,Redlock 的复杂度不一定值;第二,业务对一致性敏感,直接用 Redisson 提供的 RedLock 实现,自己手写错误率很高;第三,业务真的要求强一致,改用 etcd 的 lease + compare-and-swap 机制,这是一个真正基于 Raft 的分布式锁,不依赖异步复制。选哪个不是技术决策,是业务决策——你愿意为一致性付多大代价。

认知翻转:很多人把"用了 Redis 锁"和"有了分布式互斥"画等号,这是错的。Redis 锁是个"概率性互斥"——绝大多数情况下互斥成立,极少数主从切换、网络分区、时钟漂移场景下互斥会被打破。能不能用它取决于你的业务能不能承受这种小概率失误。订单防重复点击可以接受,因为后面还有数据库唯一索引兜底;给用户结算佣金不可以接受,因为多算一次钱就是真金白银损失。判断"需不需要 Redlock"或者"需不需要 etcd"的标准,是看业务的兜底层在哪,而不是看技术潮流。

五、幂等是分布式锁的好搭档,也是它的最后兜底

分布式锁能做的事其实有限,它只能在"加锁的客户端没崩溃没卡死"的乐观假设下提供互斥。一旦碰上 GC 暂停、网络分区、Redis 切换、时钟漂移,锁就可能"看起来还在,其实已经丢了"。这时唯一能救你的就是业务层的幂等。

幂等的意思是:同一个业务操作执行 N 次,效果跟执行 1 次完全一样。最常见的实现方式是给操作分配一个唯一的 idempotency_key,业务层在执行前先查这个 key 是否已经处理过——处理过就直接返回上次的结果,没处理过就正常执行并落库。这一道防线和分布式锁是正交的,锁保护了"并发不冲突",幂等保护了"哪怕真的冲突了也不会重复执行"。

def place_order(user_id: int, order_payload: dict, idempotency_key: str):
    # 第一道:数据库层面用唯一索引兜住
    # 表设计: idempotency_key 字段加唯一索引
    try:
        with db.transaction():
            existing = db.fetchone(
                "SELECT id, result FROM orders WHERE idempotency_key = %s",
                (idempotency_key,),
            )
            if existing:
                return existing["result"]  # 已经处理过,直接返回

            # 第二道:Redis 分布式锁兜并发
            token = acquire(f"order:lock:{user_id}", ttl_ms=10000)
            if not token:
                raise BusyError("user has another order in flight")

            try:
                result = do_place_order(user_id, order_payload)
                db.execute(
                    "INSERT INTO orders (user_id, idempotency_key, result)"
                    " VALUES (%s, %s, %s)",
                    (user_id, idempotency_key, result),
                )
                return result
            finally:
                release(f"order:lock:{user_id}", token)

    except UniqueViolation:
        # 唯一索引冲突,说明有另一个请求恰好在我之前 INSERT 完
        existing = db.fetchone(
            "SELECT result FROM orders WHERE idempotency_key = %s",
            (idempotency_key,),
        )
        return existing["result"]

这套写法有两道防线:数据库唯一索引和 Redis 锁。锁只是用来减少冲突的乐观防线,真正的兜底是唯一索引——即使锁全挂、即使两个请求同时执行到 INSERT,数据库的唯一索引也只会让一个成功另一个失败,然后失败的那个去查已有的结果返回。这种"锁 + 唯一约束 + 幂等查询"三件套,才是支付/订单/扣款类场景的真正稳态架构。

认知翻转:不要把分布式锁当成系统正确性的最后防线,它只是一道乐观防线,作用是"减少冲突"。真正的最后防线必须放在数据持久层:唯一索引、CAS 版本号、状态机校验、对账修正。分布式锁挂掉,这些防线能让系统从"严重数据错乱"降级到"少量请求被拒",而不至于变成"两个客户都被扣了款"。设计一个高一致性场景时,先问"如果锁完全失效会怎样",如果答案是"数据错乱无法挽回",那这个设计就还不够,需要再往下加一道持久层的兜底。

六、工程坑:那些"理论上对、实际上踩"的细节

除了上面五节讲的核心问题,真正的生产环境还会冒出一堆"教程不教但你一定会碰上"的细节坑。下面挑几个最常见、最让人意外的列一下。

第一,锁的 key 一定要带业务前缀和清晰的命名,不要用 "lock1" 这种没意义的 key。生产里 Redis 是共享的,别人的服务可能不小心 SET 你的 key,或者你 DEL 别人的 key。一个好的命名是 "myservice:lock:resource:8821",前缀打死防混用。

第二,锁的 TTL 单位永远用毫秒(PX)不要用秒(EX),除非业务真的是分钟级。毫秒精度能让你在加锁失败重试时做更细的退避,也能更精确地判断锁是否过期。

第三,获取锁失败时不要立即重试,要带指数退避 + 抖动(jitter)。所有客户端在同一时刻重试会形成"惊群",把 Redis 打到 99% CPU。退避策略一般是 50ms、100ms、200ms、400ms,每个值再加一个 0-50ms 的随机抖动:

import random, time

def acquire_with_retry(key: str, ttl_ms: int,
                       max_attempts: int = 5):
    delay_ms = 50
    for attempt in range(max_attempts):
        token = acquire(key, ttl_ms=ttl_ms)
        if token:
            return token
        # 指数退避 + jitter,防惊群
        sleep_ms = delay_ms + random.randint(0, 50)
        time.sleep(sleep_ms / 1000.0)
        delay_ms = min(delay_ms * 2, 800)
    return None

第四,业务里不要嵌套加锁。A 锁里再去加 B 锁,B 锁里再去加 A 锁,跨进程死锁分分钟出现。如果业务真的需要锁多个资源,要按全局固定顺序(比如按 resource_id 数字大小排序)依次加锁:

def lock_multi(resource_ids: list[int], ttl_ms: int = 5000):
    # 关键:按数字大小排序,保证所有进程加锁顺序一致
    ordered = sorted(set(resource_ids))
    tokens = {}
    try:
        for rid in ordered:
            token = acquire_with_retry(
                f"res:lock:{rid}", ttl_ms=ttl_ms
            )
            if not token:
                raise BusyError(f"failed to lock {rid}")
            tokens[rid] = token
        return tokens
    except Exception:
        # 任何一步失败,按反序释放已拿到的锁
        for rid in reversed(list(tokens.keys())):
            release(f"res:lock:{rid}", tokens[rid])
        raise

第五,Redis 客户端连接池要充足。锁操作很快但很频繁,连接池打满会让加锁请求排队等待,加上 TTL 设得短,可能你还没等到加锁机会锁就被别人加完释放完了。

第六,日志一定要打。每次加锁要记 key、token、ttl、加锁结果;每次释放也要记。出问题时这是唯一能复盘"到底是谁拿了锁、谁释放了锁、谁的锁是不是被人误删了"的证据。

第七,Watchdog 要在业务结束时显式 stop,否则会一直续约下去,后台多了好多僵尸线程。常见做法是用 try/finally 或者上下文管理器(context manager)封装锁,保证 release 和 stop 一定执行。

第八,Redis 内存压力大的时候,设置了 TTL 的 key 可能因为 maxmemory-policy 被驱逐(尤其是 allkeys-lru 策略),你的锁会"凭空消失"。生产配置应该用 volatile-lru 或 noeviction,锁这种数据不能被随便驱逐。

认知翻转:分布式锁的工程难度被严重低估。从面试题的角度它就一行 SET NX EX,从生产的角度它是一整套"加锁语义 + 续约机制 + 失败处理 + 退避策略 + 命名规范 + 监控告警 + 业务幂等 + 数据兜底"的系统。新手做的锁能挡住单元测试,挡不住大促流量;熟手做的锁能挡住大促流量,挡不住主从切换;专家做的锁会同时配上持久层兜底,因为他知道任何分布式锁都有失效的可能,不依赖单一组件才是真正的工程心态。本地用 Redis 跑跑示例永远暴露不了生产中那些"概率极低但会发生"的失效场景,真正的检验只在生产上,在一次 GC 卡顿、一次 failover、一次网络分区的现场。

关键概念速查

概念 含义 常见误区 正确做法
SET NX EX 原子加锁 + 过期 用 SETNX + EXPIRE 两步 必须用一条 SET key value EX ttl NX
Lock Token 标识锁的持有者 用线程 ID / IP 用 UUID,128 位空间无冲突
Lua 解锁 原子比对 + 删除 客户端先 GET 再 DEL EVAL 一个 if get==token then del end
TTL 锁的存活时间 统一设 30s 或不设 按业务分级:短/中/长锁不同 TTL
Watchdog 后台续约 + 丢锁告警 不做或只做续约不告警 每 TTL/3 续一次,丢锁立即中断业务
Redlock 多节点多数派加锁 当成绝对安全 概率性互斥,关键场景仍需持久层兜底
主从异步复制 主写从读,异步同步 认为锁数据强一致 意识到 failover 时可能两端都加锁成功
幂等 同操作执行 N 次效果同 1 次 只靠锁保证不重复 idempotency_key + 唯一索引兜底
退避抖动 失败重试的间隔策略 立即重试 指数退避 + 随机抖动避免惊群
maxmemory-policy Redis 内存驱逐策略 用 allkeys-lru 放锁的实例用 volatile-lru / noeviction

避坑清单

  1. 不要用 SETNX + EXPIRE 两步,必须用 SET key value NX EX/PX,否则在 SETNX 和 EXPIRE 之间崩溃会留下永久锁。
  2. 不要直接 DEL key 释放锁,必须用 Lua 脚本"比对 token 再删除",否则会误删别人的锁。
  3. 不要忽视 TTL 选择,统一一个值要么短了业务超不过去,要么长了进程崩溃后锁久久回收不了,按业务分级。
  4. 不要不做续约,中长锁必须配看门狗,且续约失败时要立即中断业务而不是默默重试。
  5. 不要把单实例 Redis 锁当强一致互斥,生产用主从架构时要意识到 failover 期间可能两端都加锁成功。
  6. 不要只靠锁保证业务正确性,数据库唯一索引、CAS 版本号、状态机校验才是真正的最后兜底。
  7. 不要嵌套加锁导致跨进程死锁,如果业务必须多锁,按全局固定顺序(比如 resource_id 大小)加锁。
  8. 不要在加锁失败时立即重试,要带指数退避 + 抖动,否则惊群会把 Redis 直接打爆。
  9. 不要用模糊不带前缀的 key 名,锁 key 一定要带服务前缀和资源标识,避免共享 Redis 时跟其他服务串台。
  10. 不要把锁实例的 maxmemory-policy 配成 allkeys-lru,锁数据被驱逐就是"锁凭空消失"的典型事故。

总结

分布式锁是个被反复讲、反复教,但每个团队都还是会以自己独特方式踩坑的话题。原因不是这东西多复杂,而是这东西的"教科书写法"和"生产可用写法"之间隔了好几层细节,而这些细节恰恰不会出现在大多数教程里。教程告诉你 SET NX EX,告诉你 Lua 解锁,可能还提一句 Redlock,但很少告诉你 TTL 怎么按业务分级、续约失败时该怎么处理、主从切换时该如何兜底、连接池配置怎么影响锁性能、内存驱逐策略怎么让锁凭空消失——这些才是真正决定你的锁能不能扛过一次大促的关键。

另一层被低估的是工程量。从"写出能加锁的代码"到"建一套生产可用的分布式锁体系"中间至少要做这些事:封装统一的锁客户端、设计命名规范、加 token + Lua 释放、加 Watchdog + 失败告警、配监控指标(加锁成功率、锁等待时间、续约失败次数、丢锁次数)、做压测验证、配合业务幂等改造、写故障演练手册(主从切换、Redis 短暂不可用)。这些事每一项都不难,合起来才是真正的"做扎实"。很多团队跳过了 90% 的步骤,跑两年没事就以为没问题,直到大促那一晚被多扣三次款的截图打醒。

打个不太严谨的比方,分布式锁有点像出门时把家门钥匙交给保姆。钥匙(锁)本身是可靠的,可你给的是"钥匙串里第 3 把"这种描述,保姆开错锁开了邻居家(没有 token),你信了保姆说"她肯定能 30 分钟内回来"(TTL 设短),你没考虑保姆要是中途接到电话出去办事呢(GC 暂停),你也没考虑钥匙串本身有可能因为换锁工程师在隔壁施工被换掉(主从切换)。一个真正稳的方案是:钥匙本身可靠 + 保姆要凭身份证(token)开门 + 保姆要定时报平安(续约)+ 家里贵重物品另外锁柜子(幂等兜底)+ 物业有监控录像(日志)。每一道都不指望另一道,合起来才能让你睡得着觉。

所以做分布式锁,本地一台 Redis 跑跑测试用例永远暴露不了真正的问题。它暴露不了 GC 暂停下的锁过期,暴露不了主从切换下的双重加锁,暴露不了客户端连接池被打满后的排队失败,暴露不了 Redis 维护时段的失锁告警风暴,更暴露不了业务里两个看起来无关的接口因为嵌套加锁而周期性出现的跨进程死锁。真正的检验在生产环境,在一次内存抖动的凌晨两点,在一次主从切换的瞬间,在一次大促流量打到三倍的中午。把上面六节里的功夫提前做扎实,等那些时刻到来时,你会感谢自己当初没图省事。如果你正在用 Redis 锁,不妨找一段空闲时间把它按这套标准盘一遍,你大概率会找到至少三处可以变得更扎实的地方——这是一项收益极高的投资,因为分布式锁出事的代价通常不是慢,而是错。

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

LLM 应用工程化完全指南:从一次"内部工具被运营用一周烧掉几千块"看懂为什么不是调 API 就完了

2026-5-24 13:39:32

技术教程

LLM 多轮对话上下文管理完全指南:从一次"AI 客服第 20 轮就报 context 超限"看懂为什么不是塞历史就完了

2026-5-24 13:51:45

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