分布式锁完全指南:Redis 分布式锁的正确打开方式

一场秒杀活动让我栽了个跟头:商品库存 100 件,扣库存那段我特意加了锁 —— synchronized 把"查库存、判断够不够、扣减"三步整个包住,自觉绝不可能超卖。活动结束运营找来:卖出去 103 件。代码翻了十几遍 synchronized 明明白白包在那里逻辑没错,直到看了一眼部署架构图才一身冷汗:服务部署了 3 台机器 = 3 个独立 JVM 进程,synchronized 锁的是一个 JVM 进程内部的线程,3 个进程各有各的锁彼此完全看不见,机器 A 的线程拿 A 的锁、机器 B 的线程拿 B 的锁各自以为独占,同时去扣同一件库存。梳理:单机锁 synchronized/ReentrantLock 实现互斥靠 JVM 进程内部一块状态,作用域从物理上焊死在一个进程里,多进程部署就是多把各管各的锁,只要服务超过一个实例所有进程内的锁全失效,这不是 bug 是设计边界。真正需要的是把锁放到所有进程之外一个公共的、所有进程都能访问的地方,所有进程到同一个地方抢同一把锁,这就是分布式锁。分布式锁本质是在公共存储里约定用某个 key 当锁,加锁=谁第一个创建出这个 key 谁抢到、解锁=删掉 key,必须满足四条件:互斥、不会死锁(持锁进程崩了锁也最终能释放)、谁加谁解、加解锁高效可用。用 Redis 实现:加锁靠 SET 的 NX(不存在才设=抢锁)+EX/PX(设过期=防死锁),NX 和 EX 必须在同一条命令里原子完成,千万别拆成 SETNX 加 EXPIRE 两步否则中间崩了会留下永不释放的死锁;锁的 value 要存本次加锁独有的 UUID。三个致命坑:解锁会误删别人的锁(A 锁 30 秒业务跑 35 秒,锁过期后 B 抢到同一 key,A 跑完一把删掉的是 B 的锁)—— 解锁前要检查 value 是不是自己的 token;检查加删除两步也必须原子,用 Lua 脚本做;业务没跑完锁就过期会让临界区裸奔。解决锁提前过期靠看门狗自动续期:加锁过期设 30 秒,后台线程每 10 秒检查业务还在跑就把过期续到 30 秒,业务在跑锁永不过期、进程一崩看门狗也没了锁终会过期,两头都顾住。生产别自己从零拼,用 Redisson 的 RLock,原子加锁、自带看门狗、Lua 安全解锁、可重入全内置。选型先问这场景真需要分布式锁吗 —— 能用数据库原子操作如 UPDATE stock SET num=num-1 WHERE id=? AND num>0 解决的就别上,分布式锁是最后手段;Redis+Redisson 绝大多数场景够用,ZooKeeper/etcd 适合强一致零容忍场景。正确做法是认清每个工具都有清清楚楚的能力边界线,事故几乎都发生在"我以为在线内、其实早已在线外"的灰色地带,真正的可靠不来自盲目信任,来自看清边界之后有依据的托付。

2023 年,一场秒杀活动,让我栽了一个至今记得很清楚的跟头。规则很简单:某款商品,库存 100 件,先到先得,抢完为止。代码我写得很"小心":扣库存那段,我特意加了锁——synchronized 把"查库存、判断够不够、扣减"这三步整个包起来。我当时心里很踏实,觉得"加了锁,绝不可能超卖"。活动开始,流量涌进来,一切看着都正常。直到活动结束,运营找过来,脸色不太好看:卖出去了 103 件。我盯着那个数字,血压上来了——多卖的 3 件,意味着 3 个用户付了钱,我们却没有货。我第一反应是"锁是不是没生效",把代码翻来覆去看了十几遍,那个 synchronized 明明白白包在那里,逻辑没有任何错。我又怀疑是不是并发太高锁扛不住,可 synchronized 不存在"扛不住"这一说。我卡在这里很久,百思不得其解。直到我看了一眼部署架构图,一下子明白了——也一身冷汗:我们这个服务,部署了【3 台】机器。synchronized 锁,锁的是【一个 JVM 进程内部】的线程。3 台机器,就是 3 个独立的 JVM 进程,它们各自有各自的锁,彼此【完全看不见】。机器 A 上的线程拿到了"A 的锁",机器 B 上的线程拿到了"B 的锁"——它俩在各自看来都"独占"了,然后【同时】去扣了同一件库存。我那个 synchronized,从一开始就锁了个寂寞。这件事逼着我把单机锁为什么在分布式下失效、分布式锁到底是什么、怎么用 Redis 把它实现对、那几个能让你再次超卖的致命坑、以及真实项目里的工程选型,彻底理清了一遍。本文是这份梳理的完整复盘。

问题背景:一个"加了锁还是超卖"的秒杀

需求:秒杀,商品库存 100 件,绝不能超卖
我的代码:扣库存的"查-判断-扣"三步,用 synchronized 整个包住
事故现象:
- ★ 活动结束,卖出 103 件 —— 超卖了 3 件
- ★ 代码逻辑没错,synchronized 确确实实包在那里
- ★★ 真相:服务部署了 3 台机器 = 3 个独立 JVM 进程

★★ 想明白的根:synchronized(以及 ReentrantLock)锁的是
   【一个 JVM 进程内部】的线程。它的作用范围,被死死地
   圈在【一个进程】里。
   - 机器 A 的线程,拿到的是 "A 进程里的那把锁";
   - 机器 B 的线程,拿到的是 "B 进程里的那把锁";
   - 这是【两把不同的锁】,A、B 互相看不见。
   于是 A、B 各自以为"我独占了",同时去扣了同一件库存。

★ 单机锁(synchronized / ReentrantLock / 进程内的任何锁)
  在【多进程 / 多机器】下,天然失效。它从设计上,就只
  管得了"一个进程内,多个线程"的互斥。

★ 多个进程之间要互斥,需要的是一把【所有进程都能看见、
  都来这里抢】的锁 —— 它必须存在于所有进程【之外】的
  某个公共地方。这,就是【分布式锁】。

★ 本文要做的:把分布式锁是什么、用 Redis 怎么实现对、
  有哪些会让你再次翻车的坑,彻底讲透。

为什么单机锁在分布式下必然失效

# === ★ 先彻底想清楚:synchronized 那把锁,到底锁了什么 ===

# === ★ 单机锁的作用域:被焊死在"一个进程"里 ===
# ★ ★ synchronized、ReentrantLock,它们实现互斥,靠的是
#   【JVM 进程内部】的一块状态(对象头里的标记 / AQS 的
#   一个 volatile 变量)。多个线程抢锁,抢的就是这块
#   【进程内的内存】。
# ★ ★ 所以它的作用域,从物理上就被限制死了:它只能让
#   【同一个进程里的多个线程】互斥。出了这个进程,它
#   【什么都管不了】。

# === ★★ 多进程部署,等于有了多把"各管各的"锁 ===
# ★ 你的服务部署 3 台机器,就是起了 3 个独立的 JVM 进程。
#   每个进程,都有自己那块"锁内存"。
# ★ ★ 进程 A 里的 synchronized,和进程 B 里的 synchronized,
#   是【两把毫不相干的锁】。A 的线程拿到 A 的锁、B 的
#   线程拿到 B 的锁 —— 在各自进程看来,都是"成功上锁、
#   独占执行"。但它俩【同时】在跑同一段临界区代码。
# ★ 互斥,在"进程内"成立,在"进程间"完全不成立。

# === ★ 为什么"加机器""上集群"必然撞上这个问题 ===
# ★ 现代服务,几乎没有单机部署的:要高可用,得多机;
#   要扛流量,得水平扩展。★★ 只要你的服务【超过一个
#   进程实例】,所有"进程内的锁",就全部失效。
# ★ 这不是 bug,是它们的设计边界。指望 synchronized
#   去管多机互斥,是用错了工具。

# === ★★ 真正需要的:一把"进程外的、公共的"锁 ===
# ★ 既然每个进程都只信"自己进程内的东西",那互斥就不能
#   再依赖任何"进程内的东西"。
# ★ ★ 解法只有一个方向:把锁,放到【所有进程之外】的
#   一个【公共的、所有进程都能访问的】地方去。所有进程,
#   都到这【同一个地方】来抢同一把锁 —— 谁抢到,谁执行。
# ★ 这个"公共的地方",可以是 Redis、ZooKeeper、etcd、
#   甚至一行数据库记录。这就是【分布式锁】的全部出发点。

# === 小结 ===
# ★ synchronized / ReentrantLock 实现互斥靠的是 JVM
#   进程内部的一块状态,多线程抢的就是这块进程内内存,
#   ★★ 作用域从物理上被焊死在"一个进程"里,出了进程
#   什么都管不了。多进程部署 = 多把各管各的锁:进程 A
#   的 synchronized 和进程 B 的是两把毫不相干的锁,A、B
#   线程各自拿到自己进程的锁都以为独占,却同时跑同一段
#   临界区 —— 互斥进程内成立进程间完全不成立。★ 现代
#   服务几乎都多机部署,只要超过一个进程实例所有进程内
#   的锁全失效,这不是 bug 是设计边界。★★ 真正需要的
#   是把锁放到所有进程之外一个公共的、所有进程都能访问
#   的地方,所有进程到同一个地方抢同一把锁 —— 这就是
#   分布式锁的出发点。

分布式锁的本质:一个所有进程都抢的"公共标记"

# === ★ 把分布式锁这件事的本质,一次说清 ===

# === ★ 它的本质:一个"谁先占上谁就拥有"的公共标记 ===
# ★ 剥到最里面,分布式锁就是:在一个【所有进程都能访问
#   的公共存储】里,大家约定用某个【特定的 key】当锁。
# ★ ★ "加锁" = 谁能【第一个】把这个 key 创建出来,谁就
#   算抢到了锁。"解锁" = 把这个 key 删掉,让别人能再抢。
# ★ 它不是什么神秘的东西 —— 它就是一个"抢着去插旗子"的
#   游戏,旗子(key)只有一面,谁先插上谁就拥有。

# === ★★ 一个能用的分布式锁,必须满足四个条件 ===
# ★ 条件 1 —— ★★ 互斥:任何时刻,最多只有一个进程能持有
#   这把锁。这是锁的【根本】,不满足就不叫锁。
# ★ 条件 2 —— ★★ 不会死锁:持锁的进程,万一【崩溃了】
#   (没来得及解锁),这把锁也必须【最终能被释放】 ——
#   否则它永远占着,别人永远抢不到,整个系统卡死。
# ★ 条件 3 —— ★ 谁加的锁谁解:进程 A 加的锁,只能由
#   A 自己来解,B【不能】把 A 的锁解掉。
# ★ 条件 4 —— ★ 加锁/解锁本身要高效、可用:抢锁这个
#   操作不能太慢,存锁的那个公共组件不能动不动就挂。

# === ★ 条件 2 是新手最容易漏的:必须能"自动过期" ===
# ★ ★ 你不能假设"持锁的进程一定会乖乖解锁"。它可能
#   【进程崩了、断网了、卡死了】 —— 解锁代码根本没机会
#   执行。
# ★ ★ 所以加锁时,必须【同时给这把锁设一个过期时间】。
#   就算持锁者死了,时间一到,锁【自动消失】,别人就能
#   重新抢。这是分布式锁【防死锁】的命根子。

# === ★ 用什么来当那个"公共存储" ===
# ★ ★ Redis:最常用。它够快(抢锁要快),有现成的
#   "只在 key 不存在时才设置"的原子操作,有原生的过期
#   机制 —— 几乎是为分布式锁量身定做的。本文主讲它。
# ★ 其他选择:ZooKeeper、etcd(一致性更强),甚至一行
#   带唯一约束的数据库记录(简单场景够用)。最后一节
#   讲选型。

# === 小结 ===
# ★ 分布式锁的本质:在一个所有进程都能访问的公共存储里
#   约定用某个特定 key 当锁,★★ 加锁=谁第一个把这个 key
#   创建出来谁就抢到、解锁=把 key 删掉让别人再抢,就是
#   一个抢着插旗子的游戏旗子只有一面。★★ 一个能用的
#   分布式锁必须满足四条件:① 互斥(任何时刻最多一个
#   进程持有,根本);② 不会死锁(持锁进程崩了锁也必须
#   最终能释放);③ 谁加的锁谁解(A 的锁 B 不能解);
#   ④ 加解锁高效可用。★ 条件 2 新手最易漏 —— 不能假设
#   持锁进程一定乖乖解锁,它可能崩了断网了卡死了解锁
#   代码根本没机会跑,所以加锁时必须同时设过期时间,
#   持锁者死了时间一到锁自动消失,这是防死锁的命根子。
# ★ 公共存储用 Redis 最常用(快、有原子的不存在才设置、
#   有原生过期),也可用 ZooKeeper/etcd/数据库记录。

用 Redis 实现分布式锁:SET NX EX 一步到位

# === ★ 怎么用 Redis,把那把锁实现出来 ===

# === ★ 加锁:靠 SET 的 NX + EX 两个选项 ===
# ★ Redis 的 SET 命令,带两个关键选项:
#  - ★★ NX:"只在这个 key 【不存在】时,才设置成功"。
#    —— 这正好就是"抢锁":谁设置成功,谁就是第一个,
#    谁就抢到了锁。已经存在了,SET 返回失败,就是没抢到。
#  - ★★ EX(或 PX):给这个 key 设一个过期时间。
#    —— 这正好就是"防死锁":持锁者崩了,时间一到锁自动没。
# ★ ★★ 关键:NX 和 EX 必须在【同一条 SET 命令】里完成。
#   它俩是一个【原子操作】 —— 要么"占上锁且带着过期时间"
#   一起成功,要么都不发生。

# === ★★ 千万别把"加锁"和"设过期"拆成两步 ===
# ★ 老代码常见的致命写法:先 SETNX 占锁,成功后,再单独
#   发一条 EXPIRE 命令去设过期时间。
# ★ ★ 坑在哪:SETNX 成功了,但就在你要发 EXPIRE 的【那
#   一瞬间】,进程崩了 —— 这把锁,就【占上了、却永远
#   没有过期时间】。它变成一把【永不释放的死锁】,所有
#   人永远抢不到。
# ★ ★ 所以,必须用"SET key value NX EX 30"这【一条】
#   命令,把占锁和过期【一次性原子地】做完。

# === ★ 锁的 value:必须放一个"只有自己知道的唯一标识" ===
# ★ 加锁时,这个 key 的 value,不要随便填。要填一个
#   【当前这次加锁独有的、别人猜不到的】值(比如一个
#   UUID),并且自己记住它。
# ★ ★ 为什么:解锁时要靠它来确认"这把锁【是不是我加
#   的】"。如果 value 是固定值,你就没法区分锁是不是
#   自己的 —— 下一节的"误删别人的锁",根子就在这。

# === ★ 过期时间设多久:要 ≥ 业务最长耗时 ===
# ★ ★ 过期时间,要设得比"临界区业务正常执行所需的最长
#   时间"还要长一些。设短了,业务还没跑完锁就过期了 ——
#   这是下一节要重点拆的另一个大坑。
# ★ 但也别设得离谱地长(比如 1 小时)—— 万一真死锁了,
#   要等 1 小时才解,期间系统是瘫的。要估一个合理值。

# === 小结 ===
# ★ 加锁靠 SET 的两个选项:★★ NX("只在 key 不存在时
#   才设置成功",正好是抢锁 —— 谁设置成功谁第一个谁
#   抢到)+ EX/PX(给 key 设过期时间,正好是防死锁 ——
#   持锁者崩了时间到锁自动没)。★★ NX 和 EX 必须在
#   同一条 SET 命令里完成,是一个原子操作。千万别把
#   加锁和设过期拆两步:先 SETNX 占锁再单独 EXPIRE ——
#   SETNX 成功后发 EXPIRE 前那一瞬进程崩了,锁就占上了
#   却永远没过期时间,变成永不释放的死锁。必须用"SET
#   key value NX EX 30"一条命令原子做完。★ 锁的 value
#   要填一个当前加锁独有、别人猜不到的值(UUID)并自己
#   记住,解锁时靠它确认锁是不是自己加的。★ 过期时间
#   要 ≥ 业务最长耗时,设短了业务没跑完锁就过期,但也
#   别离谱地长否则真死锁要等很久。
// ★ Redis 分布式锁:加锁 —— 用 SET 的 NX + PX,一条命令原子完成
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;

public class RedisLock {
    private final Jedis jedis;

    public RedisLock(Jedis jedis) { this.jedis = jedis; }

    // ★ 加锁:成功返回那个唯一标识(解锁时要用),失败返回 null
    public String tryLock(String lockKey, int expireSeconds) {
        // ★★ value 是本次加锁独有的 UUID —— 解锁时靠它认"锁是不是我的"
        String token = UUID.randomUUID().toString();
        // ★★ NX:只在 key 不存在时才设置(抢锁);PX:同时设过期(防死锁)
        //    NX 和 PX 在同一条命令里,原子完成 —— 绝不能拆成两步
        SetParams params = SetParams.setParams()
                .nx()                                  // ★ 不存在才设
                .px(expireSeconds * 1000L);            // ★ 过期时间(毫秒)
        String ret = jedis.set(lockKey, token, params);

        // ★ 返回 "OK" 表示设置成功 = 抢到了锁
        return "OK".equals(ret) ? token : null;
    }
}

// ★ 调用方:抢到锁才执行业务,无论如何 finally 里要解锁
public boolean deductStock(String productId) {
    String lockKey = "lock:stock:" + productId;
    String token = redisLock.tryLock(lockKey, 30);   // ★ 过期 30 秒
    if (token == null) {
        return false;                                // ★ 没抢到锁,直接退
    }
    try {
        return doDeductStock(productId);             // ★ 临界区:查-判断-扣
    } finally {
        redisLock.unlock(lockKey, token);            // ★★ 解锁(见下一节)
    }
}

三个能让你再次超卖的致命坑

# === ★ 上面那段代码,能跑,但还不安全 —— 三个坑 ===

# === ★★ 坑 1:解锁,误删了【别人的】锁 ===
# ★ 设想这个时序,极其常见:
#  - ① 进程 A 抢到锁,锁过期时间 30 秒;
#  - ② A 的业务,因为某次 GC、慢查询,跑了 35 秒;
#  - ③ 第 30 秒,A 的锁【自动过期】了 —— 锁没了;
#  - ④ 第 31 秒,进程 B 来抢,抢到了【同一个 key】的锁;
#  - ⑤ 第 35 秒,A 的业务跑完,执行 unlock —— ★★ 它一把
#    删掉了那个 key。可那个 key 现在的锁,【是 B 的】!
# ★ ★ 结果:A 把 B 的锁删了。B 还在临界区里跑着,锁却
#   没了,第三个进程 C 又能进来 —— 互斥彻底崩溃。
# ★ ★ 解法:解锁前,先【检查那个 key 的 value,是不是
#   自己当初存的那个 token】。是自己的,才删;不是,
#   说明锁早不是自己的了,什么都别做。

# === ★★ 坑 2:解锁的"检查 + 删除",也必须原子 ===
# ★ 你说"那我就先 GET 出来比对,一样再 DEL"。但"GET 比对"
#   和"DEL"是【两步】 —— 又出问题了:
#  - 你 GET 出来,确认 value 是自己的 token,✔;
#  - 就在你要 DEL 的【这一瞬】,锁过期了、又被 B 抢走了;
#  - 你那条 DEL 还是执行了 —— ★★ 又把 B 的锁删了。
# ★ ★ 解法:把"检查 value + 删除"这两步,用一段 Lua
#   脚本写,丢给 Redis【原子执行】。Redis 执行 Lua
#   脚本期间不会插入别的命令 —— 检查和删除连成一个
#   不可分割的整体。

# === ★★ 坑 3:业务没跑完,锁就先过期了 ===
# ★ 这就是坑 1 时序里的 ②③ —— 它本身就是个独立的大坑:
#   你设的过期时间(30 秒),没扛住业务的实际耗时(35
#   秒)。锁提前没了,临界区就【裸奔】了,别人能进来。
# ★ ★ 你可能想"那我把过期时间设长点,设 5 分钟"。但这
#   是赌博:你永远估不准业务最坏要多久;而且设太长,
#   一旦真死锁,系统要瘫很久。
# ★ ★ 正解不是"把过期时间设长",而是【自动续期】 ——
#   只要业务还在跑,就有个后台机制,定期把锁的过期
#   时间往后延。这就是下一节的"看门狗"。

# === 小结 ===
# ★ 三个致命坑。★★ 坑 1 误删别人的锁:A 锁 30 秒过期、
#   业务跑了 35 秒,第 30 秒锁自动过期、第 31 秒 B 抢到
#   同一 key 的锁,第 35 秒 A 业务跑完 unlock 一把删掉
#   那个 key —— 删的是 B 的锁,互斥崩溃;解法是解锁前
#   先检查 key 的 value 是不是自己存的 token,是才删。
# ★★ 坑 2 解锁的"检查+删除"也必须原子:GET 比对和 DEL
#   是两步,GET 确认是自己的后、DEL 前那一瞬锁过期又被
#   B 抢走,DEL 还是把 B 的锁删了;解法是把"检查 value+
#   删除"用一段 Lua 脚本丢给 Redis 原子执行。★★ 坑 3
#   业务没跑完锁就先过期:设的过期时间没扛住实际耗时,
#   临界区裸奔;把过期时间设长是赌博估不准还会让真死锁
#   瘫很久,正解是自动续期(看门狗,下一节)。
// ★ 安全解锁:用 Lua 脚本,把"检查 token + 删除"原子地做完
public class RedisLock {
    // ... tryLock 见上一节 ...

    // ★★ Lua 脚本:KEYS[1]=锁的 key,ARGV[1]=自己的 token
    //    先比对 value,只有"确实是自己的锁"才删 —— 检查和删除一气呵成
    private static final String UNLOCK_LUA =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('del', KEYS[1]) " +
        "else " +
        "  return 0 " +
        "end";

    // ★ 解锁:把 Lua 脚本交给 Redis 原子执行
    public boolean unlock(String lockKey, String token) {
        Object ret = jedis.eval(
                UNLOCK_LUA,
                java.util.Collections.singletonList(lockKey),   // KEYS
                java.util.Collections.singletonList(token));     // ARGV
        // ★ 返回 1 = 确实是自己的锁、已删除;0 = 锁早已不是自己的
        return Long.valueOf(1).equals(ret);
    }
}

// ★★ 反例:绝不要这样解锁 —— "GET 比对" 和 "DEL" 是两步,非原子
public void unlockWRONG(String lockKey, String token) {
    if (token.equals(jedis.get(lockKey))) {   // ← 比对通过的这一瞬...
        // ★★ ...锁可能已过期、已被别人抢走 —— 下面这行就误删了别人的锁
        jedis.del(lockKey);
    }
}

看门狗:让锁在业务没跑完前不会过期

# === ★ 解决"业务没跑完锁就过期",靠自动续期(看门狗)===

# === ★ 看门狗(Watchdog)是什么 ===
# ★ ★ 思路很朴素:加锁时,过期时间设一个【不长的值】
#   (比如 30 秒)。同时,启动一个【后台线程】,这个
#   线程的工作就一件事:每隔一小段时间(比如 10 秒),
#   就检查"业务还在跑吗?在的话,把锁的过期时间【重新
#   续到 30 秒】"。
# ★ ★ 效果:只要你的业务【还在执行】,看门狗就一直在
#   "续命",锁就【永远不会过期】。一旦业务跑完(正常
#   解锁),或者进程崩了(看门狗线程也跟着没了,不再
#   续期)—— 锁就会在最多 30 秒后,自然过期释放。
# ★ ★★ 它精妙在:既解决了"业务慢、锁提前过期"(在跑
#   就续),又没破坏"防死锁"(进程一死就停止续期、锁
#   终会过期)。两头都顾住了。

# === ★ 自己实现看门狗,有不少细节要抠 ===
# ★ - 续期,也得用 Lua 脚本【确认是自己的锁】才续 ——
#   不然续期续到别人的锁上,又是一种"误操作"。
# ★ - 业务结束(无论成功失败)、解锁后,必须【及时停掉】
#   看门狗线程,否则线程泄漏。
# ★ - 续期的间隔,要明显小于过期时间(如过期 30 秒,
#   每 10 秒续一次),留足容错余量。

# === ★★ 强烈建议:别自己造,用 Redisson ===
# ★ 上面这些(原子加锁、Lua 安全解锁、看门狗自动续期、
#   可重入、线程泄漏处理),自己全做对,坑非常多。
# ★ ★ Redisson 是 Redis 官方推荐的 Java 客户端,它把
#   分布式锁封装成了一个【就像 JUC 的 Lock 一样好用】
#   的 RLock。你只管 lock() / unlock(),它内部:
#  - ★ 加锁就是原子的(NX + 过期);
#  - ★ ★★ 自带看门狗:你 lock() 不传过期时间时,它默认
#    锁 30 秒,并自动每 10 秒续期一次,直到你 unlock();
#  - ★ 解锁自带"判断是不是自己的锁"(Lua 实现);
#  - ★ 支持可重入(同一线程能重复 lock 同一把锁)。
# ★ ★ 生产环境用 Redis 做分布式锁,首选 Redisson,
#   别自己从零拼 —— 把精力留给业务。

# === 小结 ===
# ★ 解决"业务没跑完锁就过期"靠自动续期(看门狗)。思路:
#   加锁时过期时间设一个不长的值(30 秒),同时启动一个
#   后台线程,每隔一小段(10 秒)检查"业务还在跑吗,在
#   就把锁的过期时间重新续到 30 秒"。★★ 效果:业务还在
#   执行看门狗就一直续命锁永不过期,业务跑完正常解锁、
#   或进程崩了看门狗线程也没了不再续期 —— 锁最多 30 秒
#   后自然释放;精妙在既解决业务慢锁提前过期(在跑就续)
#   又没破坏防死锁(进程一死就停续期锁终会过期)。★ 自己
#   实现细节多:续期也要 Lua 确认是自己的锁、业务结束后
#   必须及时停掉看门狗线程防泄漏、续期间隔要明显小于过期
#   时间。★★ 强烈建议别自己造,用 Redisson —— Redis 官方
#   推荐的 Java 客户端,RLock 像 JUC 的 Lock 一样好用,
#   原子加锁、自带看门狗(默认锁 30 秒每 10 秒续)、Lua
#   安全解锁、可重入,生产首选。
// ★ 生产推荐:用 Redisson 的 RLock —— 看门狗、安全解锁全内置
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;

public class StockService {
    private final RedissonClient redisson;

    public StockService() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        this.redisson = Redisson.create(config);
    }

    public boolean deductStock(String productId) {
        RLock lock = redisson.getLock("lock:stock:" + productId);
        boolean locked = false;
        try {
            // ★ 最多等 5 秒去抢锁;★★ 第二个参数不传过期时间(用 -1),
            //    Redisson 就启用【看门狗】:默认锁 30 秒、每 10 秒自动续期
            locked = lock.tryLock(5, TimeUnit.SECONDS);
            if (!locked) {
                return false;                       // ★ 5 秒没抢到,放弃
            }
            // ★★ 临界区:业务跑多久,看门狗就续多久,锁不会提前过期
            return doDeductStock(productId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        } finally {
            // ★★ 必须判断"锁是当前线程持有的"再解 —— 防止解了别人的
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();                      // ★ 解锁,同时停掉看门狗
            }
        }
    }
}

工程选型:你的系统到底需不需要分布式锁

# === ★ 选型之前,先问一个最该问的问题 ===

# === ★★ 问题 0:这个场景,真的需要分布式锁吗 ===
# ★ 分布式锁,是把"并发"强行变成"串行",它【牺牲性能】
#   换"绝对互斥"。上之前,先看看有没有更轻的办法:
# ★ ★ 比如"扣库存",很多时候根本不需要分布式锁 ——
#   一条 SQL 就够:UPDATE stock SET num=num-1 WHERE
#   id=? AND num>0。数据库行锁保证了它的原子性,
#   num>0 保证了不超卖,影响行数为 0 就是没抢到。
#   ★ 这比"加分布式锁再查再改"又快又稳。
# ★ ★ 经验:能用【数据库的原子操作 / 唯一约束 / 乐观锁
#   (版本号)】解决的,就别上分布式锁。分布式锁是"最后
#   手段",不是"默认选项"。

# === ★ 什么时候,才真的该上分布式锁 ===
# ★ ★ 当临界区【不是一条数据库语句能搞定的】 —— 它
#   横跨多个步骤、多个数据源、还夹着外部调用(查库存 +
#   调第三方 + 写多张表),你需要把这一整段,在多个进程
#   间互斥 —— 这时,分布式锁才是对的工具。
# ★ 典型场景:防止定时任务在多实例上重复执行、防止同一
#   笔订单被重复处理、一段复杂的"读-改-写"事务编排。

# === ★ 选型 1:Redis 锁 —— 快,但一致性是"尽力而为" ===
# ★ ★ 优点:快、简单、生态成熟(Redisson)。绝大多数
#   业务场景,Redis 分布式锁【完全够用】。
# ★ ★ 软肋:Redis 主从架构下,有个理论上的漏洞 —— 你在
#   主节点加锁成功,但主节点【还没把这个锁同步给从节点】
#   就挂了;从节点被提为新主,新主上【没有这把锁】 ——
#   于是别人又能加锁,互斥被破坏。

# === ★ 选型 2:Redlock —— 有争议,多数业务不必上 ===
# ★ Redlock 是 Redis 作者提的方案:向【多个独立的 Redis
#   节点】同时加锁,多数成功才算加锁成功,以此对抗单点
#   故障。★★ 但它实现复杂、有知名的学术争议、性能也打
#   折。结论:除非你的业务对"锁绝对不能失效"有极端要求,
#   否则【单 Redis + Redisson】就够,不必上 Redlock。

# === ★ 选型 3:ZooKeeper / etcd —— 要强一致,选它 ===
# ★ ★ 如果你的场景,对"锁绝对不能同时被两人持有"是
#   【零容忍】的(金融、强一致的协调),用 ZooKeeper
#   或 etcd。它们基于一致性协议,锁的可靠性比 Redis 强;
#   代价是比 Redis 慢、运维更重。
# ★ ★ 一句话权衡:Redis 锁是"高性能、一致性尽力而为";
#   ZK/etcd 锁是"一致性强、性能让一让"。按你对这两者的
#   需求轻重来选。

# === ★ 几条不分方案的铁律 ===
# ★ ① 抢锁要设【超时/重试上限】,抢不到就快速失败或
#   降级,别让线程死等。
# ★ ② 业务逻辑要尽量【幂等】 —— 万一锁真失效了重复执行,
#   幂等能兜底,是最后一道防线。
# ★ ③ 锁的粒度要【尽量小】(锁到"某商品"而非"整个库存
#   表"),粒度越小,并发度越高。

# === 认知 ===
# ★ 选型前先问问题 0:这场景真需要分布式锁吗 —— 它把
#   并发强行变串行、牺牲性能换绝对互斥。★★ 能用数据库
#   原子操作/唯一约束/乐观锁版本号解决的就别上:扣库存
#   一条 UPDATE stock SET num=num-1 WHERE id=? AND
#   num>0 就够,行锁保证原子、num>0 保证不超卖、影响
#   行数 0 就是没抢到,比加分布式锁又快又稳;分布式锁是
#   最后手段不是默认选项。★ 真该上锁是临界区不是一条
#   SQL 能搞定 —— 横跨多步骤多数据源夹着外部调用,要把
#   一整段在多进程间互斥(防定时任务多实例重复执行、防
#   订单重复处理)。★ 三种选型:Redis 锁快简单生态成熟
#   绝大多数场景够用,软肋是主从下主节点加锁成功未同步
#   就挂、从节点提主后没这把锁;Redlock 向多个独立 Redis
#   加锁但实现复杂有争议,多数业务不必上;ZK/etcd 基于
#   一致性协议可靠性强适合零容忍场景,代价是慢、运维重。
# ★ 铁律:抢锁设超时/重试上限抢不到快速失败别死等、业务
#   尽量幂等做最后一道防线、锁粒度尽量小并发度才高。

命令速查

Redis 分布式锁核心命令
=============================================================
加锁    SET lock:key <token> NX PX 30000
        ★ NX=不存在才设(抢锁) PX=过期毫秒(防死锁) 一条原子
解锁    用 Lua: get 比对 token 一致才 del —— 检查+删除原子
续期    用 Lua: get 比对 token 一致才 pexpire —— 看门狗调用
查看    TTL lock:key   看锁还剩多久过期

四个必备条件
-------------------------------------------------------------
互斥        任何时刻最多一个进程持有
不会死锁    持锁进程崩了,锁靠过期时间最终能释放
谁加谁解    解锁前用 token 确认锁是自己的,Lua 原子检查+删
高效可用    抢锁要快,存锁的组件不能动不动挂

选型速查
-------------------------------------------------------------
能用 DB 原子操作/乐观锁解决   就别上分布式锁(扣库存常如此)
Redis + Redisson             绝大多数业务,快、生态成熟,首选
Redlock                      实现复杂有争议,多数业务不必上
ZooKeeper / etcd             强一致零容忍场景,可靠强但慢

口诀:加锁 NX+EX 必须一条命令原子完成
      解锁先认 token 再删,检查+删除必须 Lua 原子
      业务慢就用看门狗续期,别赌一个超长过期时间

避坑清单

  1. synchronized 和 ReentrantLock 只锁一个 JVM 进程内的线程,多机部署就是多把互不可见的锁,必然失效
  2. 分布式锁的本质是一个所有进程都能访问的公共标记,谁先把那个 key 创建出来谁就抢到锁
  3. 加锁必须同时设过期时间,持锁进程崩了解锁代码没机会跑,靠过期自动释放才不会死锁
  4. 加锁的 NX 和 EX 必须在同一条 SET 命令里原子完成,拆成 SETNX 加 EXPIRE 两步会留下永不释放的死锁
  5. 锁的 value 要存一个本次加锁独有的 UUID,解锁时靠它确认这把锁到底是不是自己加的
  6. 解锁前必须先检查 value 是不是自己的 token,否则会误删掉已经过期又被别人抢走的锁
  7. 解锁的检查 value 加删除两步也必须原子,要用 Lua 脚本做,否则检查通过到删除之间锁仍可能易主
  8. 业务没跑完锁就过期会让临界区裸奔,别赌一个超长过期时间,正解是用看门狗在业务执行期间自动续期
  9. 生产环境别自己从零拼 Redis 锁,用 Redisson 的 RLock,原子加锁看门狗安全解锁可重入全都内置
  10. 能用数据库原子操作或乐观锁解决的就别上分布式锁,扣库存一条带 num 大于 0 条件的 UPDATE 就够了

总结

这一趟把分布式锁彻底理清的过程,纠正了我一个关于"锁"的、藏得极深的错觉。在我撞见那个"超卖 3 件"之前,我心里对"锁"这个东西,有一种近乎本能的信任:锁,就是锁。我用了那么多年 synchronized,它从来没让我失望过——我把临界区一包,互斥就成立了,这件事在我脑子里,是一条不需要再想的公理。所以当超卖发生时,我的全部精力,都耗在"锁是不是写错了"上,我把那段代码看了十几遍,因为我潜意识里坚信:锁是对的,那超卖就一定是别的地方错了。我从没想过,会是"锁"本身——更准确地说,是"锁的作用范围"——出了问题。直到我看见那张"3 台机器"的部署图,我才明白:我那把 synchronized 锁,它一直都在【正确地】工作,它兢兢业业地锁住了它能锁的一切——它进程内的那些线程。它没有错,错的是我:我把一把"只管得了一个房间"的锁,用在了一栋"有三个房间"的楼里,然后责怪它没锁住整栋楼。它从设计上,就只被赋予了"一个房间"的权限,我却一直以为它的权限是"整个世界"。复盘到最深,我意识到我犯的错,根子是把一个工具的"能力",误当成了它的"承诺"。synchronized 给我的承诺,从来都只是"同一个进程内的线程互斥"——这行字,清清楚楚写在它的定义里。是我自己,在日复一日的"它没出过问题"里,悄悄地、不知不觉地,把这个承诺【脑补放大】成了"任何情况下的互斥"。它没骗我,是我没有认真读过它的"说明书",就擅自扩大了对它的指望。而分布式锁这件事真正教给我的,不是 Redis 的几个命令、不是看门狗的几行代码,而是一种朴素的警觉:我用的每一个工具——一把锁、一个缓存、一个框架、一个中间件——它都有一条【清清楚楚的能力边界线】。这条线以内,是它向我郑重承诺、并且会忠实兑现的;这条线以外,是它【从未承诺过】、我却常常一厢情愿地以为它会管的。事故,几乎从不发生在"线以内"——工具在它的承诺范围内,通常稳如磐石;事故,绝大多数都发生在那条"我以为在线内、其实早已在线外"的灰色地带。我以为 synchronized 管多机,其实没有;很多人以为 Redis 缓存"必然和数据库一致",其实没承诺过;以为某个 HTTP 客户端"默认就有超时",其实没有。这些坑,长得一模一样:不是工具坏了,是我们对工具的【想象】,越过了它真实的边界,而我们【浑然不觉】。这次最大的收获,是我给自己养成了一个新习惯:当我要依赖一个工具去保证某件【重要的事】时,我不再凭"它一直都好好的"这种感觉去信任它,我会逼自己回到它的定义,冷静地、一字一句地确认一遍——"它【明确承诺】的,到底是什么?我现在指望它做的这件事,【确定】在它承诺的范围之内吗?"那个超卖的 3 件商品教给我的,从来不是一个并发漏洞的补法,而是一句让我受用很久的话:你信任一个工具,信任的不该是它"看起来很可靠",而该是它"明确承诺过、且你确认了你的用法没有越过那条线"。真正的可靠,不来自盲目的信任,来自看清边界之后的、有依据的托付。

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

大模型 Function Calling 完全指南:让 AI 真正能干活

2026-5-21 12:21:20

技术教程

Prompt 注入完全指南:大模型应用的头号安全漏洞

2026-5-21 12:36:57

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