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 原子
业务慢就用看门狗续期,别赌一个超长过期时间
避坑清单
- synchronized 和 ReentrantLock 只锁一个 JVM 进程内的线程,多机部署就是多把互不可见的锁,必然失效
- 分布式锁的本质是一个所有进程都能访问的公共标记,谁先把那个 key 创建出来谁就抢到锁
- 加锁必须同时设过期时间,持锁进程崩了解锁代码没机会跑,靠过期自动释放才不会死锁
- 加锁的 NX 和 EX 必须在同一条 SET 命令里原子完成,拆成 SETNX 加 EXPIRE 两步会留下永不释放的死锁
- 锁的 value 要存一个本次加锁独有的 UUID,解锁时靠它确认这把锁到底是不是自己加的
- 解锁前必须先检查 value 是不是自己的 token,否则会误删掉已经过期又被别人抢走的锁
- 解锁的检查 value 加删除两步也必须原子,要用 Lua 脚本做,否则检查通过到删除之间锁仍可能易主
- 业务没跑完锁就过期会让临界区裸奔,别赌一个超长过期时间,正解是用看门狗在业务执行期间自动续期
- 生产环境别自己从零拼 Redis 锁,用 Redisson 的 RLock,原子加锁看门狗安全解锁可重入全都内置
- 能用数据库原子操作或乐观锁解决的就别上分布式锁,扣库存一条带 num 大于 0 条件的 UPDATE 就够了
总结
这一趟把分布式锁彻底理清的过程,纠正了我一个关于"锁"的、藏得极深的错觉。在我撞见那个"超卖 3 件"之前,我心里对"锁"这个东西,有一种近乎本能的信任:锁,就是锁。我用了那么多年 synchronized,它从来没让我失望过——我把临界区一包,互斥就成立了,这件事在我脑子里,是一条不需要再想的公理。所以当超卖发生时,我的全部精力,都耗在"锁是不是写错了"上,我把那段代码看了十几遍,因为我潜意识里坚信:锁是对的,那超卖就一定是别的地方错了。我从没想过,会是"锁"本身——更准确地说,是"锁的作用范围"——出了问题。直到我看见那张"3 台机器"的部署图,我才明白:我那把 synchronized 锁,它一直都在【正确地】工作,它兢兢业业地锁住了它能锁的一切——它进程内的那些线程。它没有错,错的是我:我把一把"只管得了一个房间"的锁,用在了一栋"有三个房间"的楼里,然后责怪它没锁住整栋楼。它从设计上,就只被赋予了"一个房间"的权限,我却一直以为它的权限是"整个世界"。复盘到最深,我意识到我犯的错,根子是把一个工具的"能力",误当成了它的"承诺"。synchronized 给我的承诺,从来都只是"同一个进程内的线程互斥"——这行字,清清楚楚写在它的定义里。是我自己,在日复一日的"它没出过问题"里,悄悄地、不知不觉地,把这个承诺【脑补放大】成了"任何情况下的互斥"。它没骗我,是我没有认真读过它的"说明书",就擅自扩大了对它的指望。而分布式锁这件事真正教给我的,不是 Redis 的几个命令、不是看门狗的几行代码,而是一种朴素的警觉:我用的每一个工具——一把锁、一个缓存、一个框架、一个中间件——它都有一条【清清楚楚的能力边界线】。这条线以内,是它向我郑重承诺、并且会忠实兑现的;这条线以外,是它【从未承诺过】、我却常常一厢情愿地以为它会管的。事故,几乎从不发生在"线以内"——工具在它的承诺范围内,通常稳如磐石;事故,绝大多数都发生在那条"我以为在线内、其实早已在线外"的灰色地带。我以为 synchronized 管多机,其实没有;很多人以为 Redis 缓存"必然和数据库一致",其实没承诺过;以为某个 HTTP 客户端"默认就有超时",其实没有。这些坑,长得一模一样:不是工具坏了,是我们对工具的【想象】,越过了它真实的边界,而我们【浑然不觉】。这次最大的收获,是我给自己养成了一个新习惯:当我要依赖一个工具去保证某件【重要的事】时,我不再凭"它一直都好好的"这种感觉去信任它,我会逼自己回到它的定义,冷静地、一字一句地确认一遍——"它【明确承诺】的,到底是什么?我现在指望它做的这件事,【确定】在它承诺的范围之内吗?"那个超卖的 3 件商品教给我的,从来不是一个并发漏洞的补法,而是一句让我受用很久的话:你信任一个工具,信任的不该是它"看起来很可靠",而该是它"明确承诺过、且你确认了你的用法没有越过那条线"。真正的可靠,不来自盲目的信任,来自看清边界之后的、有依据的托付。
—— 别看了 · 2026