我用 Redis 加了分布式锁防并发,结果两个进程还是同时执行了、还互相把对方的锁删了,我对着分布式锁的几个致命细节排查了大半天的复盘
那是我做的一个定时任务,多个实例部署,我用 Redis 加了个分布式锁,确保"同一时刻只有一个实例在执行"。我以为加了锁就万事大吉。结果生产上出现了诡异的现象:有时两个实例同时跑起了这个任务(锁没锁住!),导致数据被重复处理;还有时一个实例把另一个实例的锁给删了,锁的逻辑彻底乱套。我盯着加锁代码反复看:SETNX 抢锁、执行完 DEL 释放,逻辑明明很标准啊?排查了大半天,我才发现,这段"看起来很标准"的分布式锁代码里,藏着好几个能让锁形同虚设的致命细节。这篇就把这场"分布式锁没锁住"的事故,从头复盘一遍。
故障现场:加了锁,却没锁住
先看现场。问题就藏在我那段"看起来很标准"的加锁代码里:
# 我的分布式锁: 看起来很标准, 实则漏洞百出
import redis
r = redis.Redis()
def do_task():
# 1. 抢锁: SETNX(set if not exists), 抢到才执行
locked = r.setnx("task_lock", "1") # ✗ 问题1: 没设过期时间!
if not locked:
return # 没抢到锁, 退出
try:
# 2. 执行业务(假设耗时不定, 有时很久)
run_business()
finally:
# 3. 释放锁
r.delete("task_lock") # ✗ 问题2: 可能删了别人的锁!
# 这段代码的几个致命问题:
# 问题1: SETNX 没设过期时间 → 死锁风险
# - 如果持锁的实例在 run_business() 时【崩溃了】(没走到 finally 的 delete):
# → 锁永远不会被释放! 其他实例永远抢不到锁 → 整个任务再也跑不了(死锁)。
# 问题2(修了问题1后引入): 锁提前过期 → 两个实例同时执行
# - 假设给锁设了 30 秒过期(SET task_lock 1 EX 30 NX):
# - 实例A抢到锁, 但 run_business() 这次跑了 40 秒(超过了锁的30秒)。
# - 第30秒: 锁自动过期了! 但实例A还在执行(它不知道锁没了)。
# - 第31秒: 实例B来抢锁, 锁已过期, B 抢到了 → B 也开始执行!
# - → 现在 A 和 B 【同时】在执行 → 锁形同虚设, 并发问题重现!
# 问题3: 释放锁时, 删了别人的锁
# - 接上面: 第40秒, 实例A执行完, 走 finally 的 r.delete("task_lock")。
# - 但此时 task_lock 已经是【实例B抢到的锁】了!
# - A 的 delete 把 B 的锁删了! → B 的锁没了, 实例C又能抢到 → 雪上加霜。
# - ★ 根因: A 删锁时, 没有判断"这个锁是不是我自己的"。
# 现象拼图:
# - 没过期 → 持锁者崩溃就死锁。
# - 设了过期但业务超时 → 锁提前过期, 多个实例同时执行。
# - 删锁不验身 → 删了别人的锁, 逻辑彻底乱套。
# - ★ 根因: 分布式锁看起来简单(抢锁-执行-释放), 但要做对, 有一堆
# 必须处理的边界(过期、超时、误删、原子性), 我一个都没处理好。
看清这几个问题后,我才明白这把锁为什么形同虚设。我那段"看起来标准"的代码,藏着三个致命问题。问题一:SETNX 没设过期时间 → 死锁风险——如果持锁实例在执行时崩溃了(没走到 finally 的 delete),锁永远不会被释放,其他实例永远抢不到、整个任务再也跑不了。问题二(给锁设了过期后引入):锁提前过期 → 两个实例同时执行——实例 A 抢到锁但业务跑了 40 秒(超过锁的 30 秒过期),第 30 秒锁自动过期、A 还在执行,第 31 秒实例 B 抢到锁也开始执行,A 和 B 同时执行、锁形同虚设。问题三:释放锁时删了别人的锁——第 40 秒 A 执行完去 delete,但此时锁已经是 B 抢到的了,A 把 B 的锁删了、逻辑彻底乱套;根因是 A 删锁时没判断"这个锁是不是我自己的"。归根结底:分布式锁看起来简单(抢锁-执行-释放),但要做对,有一堆必须处理的边界(过期、超时、误删、原子性),我一个都没处理好。
第一件事:搞懂分布式锁的几个必备要素
要解决它,得先搞懂一个正确的分布式锁,必须满足哪些要素。
分布式锁的必备要素
# 一、互斥性: 同一时刻只有一个客户端能持有锁(锁的最基本职责)。
# 二、防死锁: 锁必须能"自动释放"(设过期时间)
# - 否则持锁者崩溃/卡住, 锁永不释放 → 所有人都抢不到 → 死锁。
# - 所以: 加锁必须带过期时间(TTL)。
# - ★ 且"加锁 + 设过期"必须是【原子】的!
# SETNX 然后再 EXPIRE 是两步, 中间崩溃就又死锁了。
# 要用 SET key value EX seconds NX(一条命令原子完成抢锁+设过期)。
# 三、锁的"身份": 每个客户端的锁要有唯一标识(防止误删别人的锁)
# - 加锁时, value 存一个【唯一值】(如 UUID / 客户端ID+随机数)。
# - 释放锁时, 先检查"锁的value是不是我的", 是我的才删。
# - ★ 且"检查 + 删除"也必须是【原子】的(用 Lua 脚本)!
# 否则"检查是我的"之后、"删除"之前, 锁过期了被别人拿走, 还是会误删。
# 四、过期时间怎么定? —— 锁提前过期是个难题
# - 设短了: 业务没执行完锁就过期 → 多个客户端同时执行(问题2)。
# - 设长了: 持锁者崩溃后, 要等很久锁才释放 → 阻塞其他人。
# - 理想方案: "看门狗(watchdog)"自动续期 —— 持锁期间, 后台线程定期
# 给锁"续命"(延长过期时间), 业务执行完才停止续期并释放。
# 这样锁既不会"业务没完就过期", 也不会"崩溃后永不释放"(续期会停)。
# 五、可重入(进阶): 同一个客户端能否重复获取同一把锁(像 ReentrantLock)。
# 核心: 分布式锁要 互斥 + 防死锁(原子地加锁+设过期SET NX EX)+ 锁带唯一身份(防误删,
# 原子地检查+删除用Lua)+ 处理锁提前过期(看门狗自动续期); 这些边界一个都不能少。
想透分布式锁的必备要素,才知道一个正确的锁有多少讲究。一、互斥性:同一时刻只有一个客户端能持有锁(最基本职责)。二、防死锁:锁必须能自动释放(设过期时间)——否则持锁者崩溃锁永不释放;且"加锁 + 设过期"必须是原子的(SETNX 再 EXPIRE 两步,中间崩溃又死锁,要用 SET key value EX seconds NX 一条命令原子完成)。三、锁的"身份":每个客户端的锁要有唯一标识(防误删)——加锁时 value 存唯一值(UUID),释放时先检查"是不是我的锁"才删;且"检查 + 删除"也必须原子(用 Lua 脚本),否则检查后删除前锁被别人拿走还是会误删。四、过期时间怎么定?(锁提前过期是难题)——设短了业务没完就过期、设长了崩溃后阻塞别人;理想方案是"看门狗(watchdog)"自动续期:持锁期间后台线程定期续命,业务完才停止续期并释放,既不会业务没完就过期、也不会崩溃后永不释放。五、可重入(进阶):同一客户端能否重复获取同一把锁。
第二件事:正解——原子加锁 + 唯一标识 + Lua 释放 + 自动续期
搞懂了原理,正解就清晰了:原子地加锁+设过期、锁带唯一标识、用 Lua 脚本原子地"验身+删除"、用看门狗自动续期,或直接用 Redisson。
import redis, uuid, threading, time
r = redis.Redis()
# ====== 正解一: 原子加锁(SET NX EX) + 唯一标识 ======
def acquire_lock(key, ttl=30):
token = str(uuid.uuid4()) # ★ 唯一标识: 这把锁是"我"的凭证
# SET key token EX ttl NX: 原子地完成"抢锁 + 设过期"(一条命令)
locked = r.set(key, token, ex=ttl, nx=True)
return token if locked else None
# → 一条命令搞定"抢锁+设过期", 不会出现"抢到锁但没来得及设过期就崩溃"。
# ====== 正解二: 用 Lua 脚本原子地"验身 + 删除"释放锁 ======
RELEASE_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1]) -- 是我的锁才删
else
return 0 -- 不是我的锁, 不删(防误删!)
end
"""
def release_lock(key, token):
# Lua 脚本在 Redis 里【原子】执行"检查是不是我的 + 删除"
r.eval(RELEASE_SCRIPT, 1, key, token)
# → "检查"和"删除"原子完成, 不会出现"检查是我的之后、删除之前锁被别人拿走"。
# token 对不上(锁已是别人的)就不删 → 杜绝"误删别人的锁"。
# ====== 正解三: 看门狗自动续期(解决"锁提前过期")======
def with_lock(key, ttl=30):
token = acquire_lock(key, ttl)
if not token:
return False
stop = threading.Event()
# 后台线程: 持锁期间, 每 ttl/3 给锁续期一次(延长过期时间)
def watchdog():
while not stop.wait(ttl / 3):
# 续期也要验身(用Lua): 是我的锁才续期
r.eval("if redis.call('get',KEYS[1])==ARGV[1] then "
"return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end",
1, key, token, ttl)
t = threading.Thread(target=watchdog, daemon=True); t.start()
try:
run_business() # 不管业务跑多久, watchdog 都会续期, 锁不会提前过期
finally:
stop.set() # 停止续期
release_lock(key, token) # 释放锁(验身删除)
return True
# → 业务执行期间锁一直被续期(不会提前过期); 业务完/崩溃则续期停止、锁自然释放。
# ====== 正解四(强烈推荐): 直接用 Redisson(Java) / 成熟的库 ======
# 自己实现分布式锁, 边界太多、很容易出错。生产强烈建议用成熟库:
# - Java: Redisson(内置看门狗自动续期、可重入、公平锁等, 开箱即用)。
# RLock lock = redisson.getLock("task_lock");
# lock.lock(); try { 业务 } finally { lock.unlock(); }
# - 别自己造轮子, 除非你非常清楚所有边界。
# ====== 正解五(更高可靠): 多Redis节点的 RedLock(看场景)======
# 单 Redis 主从切换时, 锁可能丢失(主挂了从还没同步锁)。
# 对一致性要求极高的场景, 可考虑 RedLock(多个独立Redis节点过半数才算加锁成功)。
# 但 RedLock 有争议、复杂, 多数场景单节点Redis锁+续期已够用。
# 核心: 原子加锁(SET NX EX)+ 唯一token标识 + Lua脚本原子验身删除(防误删)
# + 看门狗自动续期(防提前过期); 强烈建议直接用Redisson等成熟库, 别自己造轮子。
修复的核心,是"把分布式锁的每一个边界都处理到位,或者直接用经过验证的成熟库"。正解一:原子加锁(SET NX EX)+ 唯一标识——用 SET key token EX ttl NX 一条命令原子完成"抢锁+设过期",token 用 UUID 作为"这把锁是我的"的凭证。正解二:用 Lua 脚本原子地"验身 + 删除"释放锁——Lua 在 Redis 里原子执行"检查是不是我的 + 删除",token 对不上就不删,杜绝误删别人的锁。正解三:看门狗自动续期(解决锁提前过期)——后台线程每 ttl/3 给锁续期一次,业务执行期间锁一直被续期(不会提前过期)、业务完或崩溃则续期停止、锁自然释放。正解四(强烈推荐):直接用 Redisson 等成熟库——自己实现边界太多易错,Redisson 内置看门狗续期、可重入、公平锁,开箱即用,别造轮子。正解五(更高可靠):多 Redis 节点的 RedLock(单 Redis 主从切换时锁可能丢失,对一致性极高的场景可考虑,但有争议、复杂,多数场景单节点+续期已够用)。归根结底:原子加锁 + 唯一 token + Lua 原子验身删除(防误删)+ 看门狗续期(防提前过期);强烈建议直接用 Redisson,别自己造轮子。
第三件事:分布式锁的几个深层问题
排查后我意识到,分布式锁的"坑"比表面看到的还要深。我把几个深层问题梳理了一遍。
分布式锁的深层问题
# 一、锁提前过期的"根本困境"(即使有续期也要想清楚)
# - 续期(看门狗)能缓解, 但若"持锁进程卡死/网络分区(GC停顿、Full GC、
# 线程卡住)", 续期线程也可能没及时续上 → 锁还是可能过期、被别人拿到。
# - 极端情况下, "两个进程都以为自己持有锁"是分布式锁理论上难以100%杜绝的。
# - 应对: 关键业务"加锁 + 业务本身也要幂等"(双保险, 见幂等篇)!
# 即: 别把"安全"完全押在锁上, 业务逻辑本身也要能容忍"万一锁失效了"。
# 二、单点 Redis 的可靠性
# - 单 Redis 挂了 → 锁服务不可用。
# - Redis 主从: 主挂了切到从, 但锁可能还没同步到从 → 锁"丢失"。
# - 应对: Redis 高可用(哨兵/集群); 极致一致性用 RedLock 或 ZooKeeper/etcd
# (基于一致性协议的锁, 更可靠但更重)。
# 三、锁的粒度
# - 锁太粗(一把大锁锁住所有): 并发度低, 性能差。
# - 锁太细(每个小资源一把锁): 复杂、易死锁。
# - 按"实际需要互斥的最小资源"加锁(如按订单ID加锁, 而非全局一把锁)。
# 四、要不要用分布式锁? —— 先想有没有更简单的方案
# - 很多"需要分布式锁"的场景, 其实可以用其他方式避免:
# * 唯一约束/幂等: 用数据库唯一索引防重复(比锁简单可靠)。
# * 单点执行: 定时任务用分布式调度框架(如XXL-JOB)保证只一个实例跑。
# * 乐观锁: 用版本号 CAS, 避免悲观锁。
# - 分布式锁是"重武器", 能不用就不用(它有上述这么多坑)。
# 核心: 锁提前过期理论上难100%杜绝, 关键业务要"锁+幂等"双保险; 注意单点Redis可靠性
# (高可用/RedLock)、锁粒度; 且优先考虑唯一约束/幂等/调度框架等更简单的替代方案。
排查让我看到分布式锁更深的几个问题。一、锁提前过期的"根本困境"——即使有续期,若持锁进程卡死/网络分区(GC 停顿、线程卡住),续期线程也可能没及时续上、锁还是可能被别人拿到;"两个进程都以为持有锁"在理论上难 100% 杜绝;应对是关键业务"加锁 + 业务本身也幂等"双保险,别把安全完全押在锁上。二、单点 Redis 的可靠性——单 Redis 挂了锁服务不可用,主从切换时锁可能丢失;应对是 Redis 高可用,极致一致性用 RedLock 或 ZooKeeper/etcd。三、锁的粒度——太粗并发低、太细易死锁,按"实际需要互斥的最小资源"加锁(如按订单 ID 而非全局一把锁)。四、要不要用分布式锁?先想有没有更简单的方案——很多场景可用数据库唯一约束/幂等防重复、用分布式调度框架(XXL-JOB)保证单点执行、用乐观锁;分布式锁是重武器、有这么多坑,能不用就不用。下面这张图,是这次分布式锁没锁住的成因与解法:
第四件事:分布式锁错误写法 vs 正确写法速查
这次踩坑后,我把分布式锁的常见错误和对应的正确做法整理成一张表。
| 环节 | 错误写法 | 正确写法 |
|---|---|---|
| 加锁 | SETNX 不设过期 | SET key token EX ttl NX(原子) |
| 加锁+过期 | SETNX 再 EXPIRE(两步) | 一条 SET NX EX 命令 |
| 锁标识 | value 固定值"1" | value 用唯一 token(UUID) |
| 释放 | 直接 DEL | Lua 验身后再删 |
| 验身+删除 | get 判断后再 del(两步) | Lua 脚本原子完成 |
| 过期时间 | 固定值(易提前过期/太长) | 看门狗自动续期 |
| 整体 | 自己手写(易错) | 用 Redisson 等成熟库 |
这张表,把分布式锁的"错与对"逐环列了出来。它清楚地显示:分布式锁的每一个环节(加锁、设过期、标识、释放、验身),都有"看起来对、实则错"的写法和"真正正确"的写法,而错与对的差别,常常就在"是不是原子的""有没有唯一标识"这些细节上。它给我的最大启发是:分布式锁是一个"看起来简单、做对极难"的典型——它的"简单"(抢锁-执行-释放)只是表象,它的"难"藏在分布式环境带来的一堆边界里(崩溃、超时、并发、网络分区)。而这些边界,恰恰是"偶发的、难复现的、平时测不出来的"——你本地测、小流量测,锁都"工作得好好的",直到生产上某次"业务恰好超时"或"实例恰好崩溃",问题才暴露。这让我领悟到一个关于"分布式原语"的认知:分布式锁、分布式事务、分布式 ID 这类"分布式原语",看起来都是"很基础的小功能",但它们的正确实现,凝结了大量对分布式环境复杂性的考量;自己手搓一个"看起来能用"的版本很容易,但要"在各种边界下都正确"极难。所以,对这类"简单但极易错"的分布式原语,最明智的选择是站在巨人的肩膀上——用经过大规模生产验证的成熟库(Redisson),而不是自己造一个充满隐患的轮子。
第五件事:什么时候真的需要分布式锁
这次也让我重新思考"到底要不要用分布式锁"。我把它和替代方案整理了一下。
| 场景 | 能否用更简单的替代 | 说明 |
|---|---|---|
| 防止重复插入 | ✓ 数据库唯一索引 | 比锁简单可靠 |
| 防止重复处理(消息/请求) | ✓ 幂等设计(去重表) | 见幂等篇 |
| 定时任务只一个实例跑 | ✓ 分布式调度框架 | XXL-JOB等天然支持 |
| 更新冲突 | ✓ 乐观锁(版本号CAS) | 无需悲观锁 |
| 限流/计数 | ✓ Redis 原子操作(INCR) | 原子命令即可 |
| 复杂的临界区互斥 | △ 可能确实需要分布式锁 | 且要用成熟库 |
这张表,让我看到"需要分布式锁"的场景其实比想象的少。很多看似"非锁不可"的需求,都有更简单可靠的替代:防重复插入用数据库唯一索引、防重复处理用幂等设计、定时任务单实例用调度框架、更新冲突用乐观锁、限流计数用 Redis 原子操作;真正需要分布式锁的,往往只剩"复杂的临界区互斥"这类场景(且要用成熟库)。它给我的最大启发是:面对一个问题,在选用一个"复杂、重、易错"的方案(分布式锁)之前,先问一句"有没有更简单的方案能解决它?"。我之前的思路是"要保证互斥 → 上分布式锁",这个条件反射让我直接跳到了一个最复杂的方案,而忽略了"这个具体场景,其实用数据库唯一约束/幂等就能解决",根本不需要扛出分布式锁这个重武器。这让我领悟到一个工程决策的智慧:"能用简单方案解决的,就别用复杂方案"——复杂的方案(分布式锁、分布式事务)不仅实现难、易错,还引入了新的故障点和维护成本;每多引入一份复杂度,就多一份出错的可能。所以,"克制地选择最简单够用的方案",本身就是一种重要的架构能力;追求"用对的、够用的、最简单的工具",而不是"用最高级、最复杂的工具"——这,是衡量一个工程师是否成熟的标志之一。
第六件事:要用分布式锁时,我现在的决策习惯
现在每当我想到"加个分布式锁",我都会先按这张图想清楚:
这张图的精髓,是"先找更简单的替代,确实需要才用锁,且用成熟库 + 幂等双保险"。第一问 "有没有更简单的替代":防重复插入用唯一索引、防重复处理用幂等、定时单实例用调度框架、更新冲突用乐观锁;只有"确实需要互斥临界区"才用分布式锁。用锁时:优先用 Redisson 等成熟库(别手写);非要手写则原子加锁 + 唯一 token + Lua 释放 + 看门狗续期。而最重要的兜底:关键业务"锁 + 业务幂等"双保险,别把安全全押在锁上(因为锁理论上难 100% 可靠)。这套决策,让我面对互斥需求时,从"条件反射上分布式锁"变成了"先找更简单方案、非用不可才慎重地用"——核心始终是:分布式锁是易错的重武器,优先用更简单的替代,非用不可则用成熟库并配业务幂等双保险。
我立下的几条规矩
这场"分布式锁没锁住"的事故,换来了我做分布式系统时,刻进骨子里的几条铁律:
- 加锁必须原子地设过期。用 SET key token EX ttl NX,别 SETNX 后再 EXPIRE,更别不设过期。
- 锁要带唯一标识。value 存 UUID,释放时验身,杜绝误删别人的锁。
- 释放锁用 Lua 原子"验身+删除"。别 get 判断后再 del(两步之间锁可能易主)。
- 处理锁提前过期。用看门狗自动续期,别让业务没执行完锁就过期。
- 别自己造轮子。用 Redisson 等成熟库,它处理好了所有边界。
- 关键业务锁+幂等双保险。锁理论上难 100% 可靠,业务本身也要能容忍锁失效。
- 优先找更简单的替代。唯一索引/幂等/调度框架/乐观锁能解决的,别上分布式锁。
附:一个相对完整的 Redis 分布式锁实现(含续期)
口说无凭。下面给一个把"原子加锁 + 唯一标识 + Lua 释放 + 看门狗续期"都实现了的分布式锁类:
import redis, uuid, threading, contextlib
class RedisLock:
# Lua: 验身后才删除(原子)
_RELEASE = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else return 0 end"""
# Lua: 验身后才续期(原子)
_RENEW = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else return 0 end"""
def __init__(self, client, key, ttl=30):
self.r = client
self.key = key
self.ttl = ttl
self.token = str(uuid.uuid4()) # 唯一标识(本次持锁的凭证)
self._stop = threading.Event()
self._wd = None
def acquire(self, blocking=True, retry_interval=0.1, timeout=10):
import time
deadline = time.monotonic() + timeout
while True:
# 原子: 抢锁 + 设过期
if self.r.set(self.key, self.token, ex=self.ttl, nx=True):
self._start_watchdog() # 抢到锁, 启动续期
return True
if not blocking or time.monotonic() > deadline:
return False
time.sleep(retry_interval) # 没抢到, 等一会儿重试
def _start_watchdog(self):
def renew():
# 每 ttl/3 续期一次, 直到 release 停止
while not self._stop.wait(self.ttl / 3):
self.r.eval(self._RENEW, 1, self.key, self.token, self.ttl)
self._wd = threading.Thread(target=renew, daemon=True)
self._wd.start()
def release(self):
self._stop.set() # 停止续期
# 原子: 验身后才删(防误删别人的锁)
self.r.eval(self._RELEASE, 1, self.key, self.token)
# 支持 with 语法, 自动 acquire/release
@contextlib.contextmanager
def __call__(self):
if not self.acquire():
raise TimeoutError(f"获取锁失败: {self.key}")
try:
yield
finally:
self.release()
# ====== 用法 ======
# r = redis.Redis()
# lock = RedisLock(r, "task_lock", ttl=30)
# with lock(): # 自动加锁, 块结束自动释放
# run_business() # 期间看门狗自动续期, 不怕业务超时
# # 出了 with, 锁自动释放(验身删除)
#
# ★ 但生产强烈建议直接用 Redisson(Java)等成熟库, 这个仅供理解原理!
# 核心: 这个类实现了 原子加锁(set nx ex)+唯一token+Lua验身释放+看门狗续期+with语法;
# 展示了一个"正确"的分布式锁要处理多少细节 —— 也正因如此, 生产更该用成熟库。
这个 RedisLock 类,把前面讲的所有要点,落成了一个相对完整的实现。它涵盖了:原子加锁(set nx ex)、唯一 token 标识、Lua 脚本原子"验身释放"和"验身续期"、看门狗自动续期、以及方便的 with 语法(自动加锁/释放)。但我想用它传递的,恰恰是一个略带反讽的结论:看看这个类有多少代码、处理了多少细节(原子性、唯一标识、Lua 脚本、后台续期线程、停止信号……),你就明白"正确地实现一个分布式锁"有多不容易——而这还只是单节点 Redis 锁,没考虑主从切换、RedLock 这些更复杂的情况。所以这个实现最大的价值,不是"拿去用",而是"帮你理解原理、并由此领悟到:你真的不应该自己手写它"。这,正是我想用这段代码,留给每个做分布式系统的人的最后一课:当你"亲手实现一遍"一个东西、真切地体会到它有多少隐藏的复杂细节时,你才会从心底里认同"用成熟的轮子"的价值。"不要重复造轮子"这句话,很多人都听过,但只有当你真正尝试去造一遍、被它的复杂性折磨过之后,你才会真正理解这句话的分量、才会对那些已有的、经过千锤百炼的轮子(Redisson、各种成熟库)心怀感激。所以,我建议每个工程师都可以"为了理解而亲手实现一遍"这类经典原语(分布式锁、连接池、限流器),但实现完之后,请把你的实现锁进抽屉、在生产里老老实实用成熟库——前者让你"懂原理",后者让你"不踩坑";懂原理是为了用好轮子、并在轮子出问题时能排查,而不是为了自己造一个充满隐患的轮子上生产。这,是我从这场"分布式锁"事故里,带走的、关于"理解与复用"的、最实在的领悟。
写在最后
回头看,这场由"分布式锁没锁住"引发的、并发防不住还互删锁的事故,真正教给我的,远不止"分布式锁要怎么正确实现"这一套技巧。它让我对"简单"和"复杂"的关系,有了更深刻的体会。分布式锁,是一个绝佳的"看起来简单、实则复杂"的例子:它的接口简单得不能再简单(加锁、解锁),简单到让我掉以轻心、随手就写了一个"看起来对"的版本;可它的正确实现,却暗藏着崩溃、超时、并发、网络分区等一系列分布式环境特有的、极其棘手的边界。我栽就栽在,被它"简单的表象"骗了,用"写单机锁的随意",去对待一个"本质上极其复杂的分布式问题"。这让我领悟到一个深刻的工程道理:一个东西"用起来简单"(接口简单),和它"实现起来简单 / 正确地用很简单",完全是两码事;恰恰是那些"接口简单、人人都觉得自己会"的东西(分布式锁、缓存、时间日期、字符编码……),最容易让人因为"轻视"而栽进它底层的复杂性里。这也呼应了那句名言:"简单是表象,复杂是内核"。所以,这件事给我的最大警示是:对那些"接口看起来简单"的东西,不要因为它的简单外表就轻视它;尤其当它身处一个复杂的环境(分布式、并发)时,要对它表象之下可能隐藏的复杂性保持敬畏;要么花时间真正理解并正确处理它的所有边界,要么(更明智地)站在巨人的肩膀上,用经过千锤百炼的成熟方案,而不是凭着"这不挺简单"的错觉自己硬来。对简单表象之下的复杂保持敬畏——这,是我用一次"分布式锁没锁住"的事故,换来的、关于架构、也关于"简单与复杂"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想加分布式锁时,先想想"有没有更简单的替代"、并优先选用 Redisson,那我对着那把形同虚设的锁熬的这大半天,就值了。
—— 别看了 · 2026