分布式锁完全指南:从数据库到 Redisson 的演进
单体应用时代,一个 synchronized 就能解决并发互斥。但服务一旦部署多个实例,JVM 锁就只能锁住自己进程内的线程——三个实例三把锁,互斥名存实亡。这时候就需要分布式锁:让所有实例去竞争同一把"外部锁"。这篇文章按照工程演进的顺序,把数据库锁 → Redis SETNX → Lua 原子释放 → Redisson 看门狗 → Redlock → ZooKeeper/etcd一步步讲透:每一代方案解决了什么、又暴露了什么新问题。
文章配了 10 张原创 SVG 图解、6 段核心代码,读完你不仅能选对分布式锁方案,还能在面试里把"误删他人锁、看门狗续期、主从切换锁失效"这些经典追问全部接住。
1. 为什么单机锁在分布式环境下失效?
先看一个最典型的场景:秒杀扣库存。业务逻辑是"读库存 → 判断够不够 → 扣减 → 写回",这几步必须是原子的,否则并发下会超卖。单机时代我们用 JVM 锁保护这段临界区,一切正常;但部署了三个实例之后,每个实例的 synchronized 只对自己进程内的线程生效。

解决思路很直接:找一个所有实例共享的外部系统来保存"这把锁现在归谁",各实例加锁/释放锁都请求这个外部系统。数据库、Redis、ZooKeeper 都能扮演这个角色——它们的差异在于性能、可靠性和实现复杂度。
2. 一个合格的分布式锁要满足什么?
动手实现之前,先明确分布式锁的三条硬性要求,后面每一代方案的缺陷,都可以对照这三条来检查:

另外还有两个加分项:可重入(同一线程可以重复获取同一把锁而不死锁)和高性能(加锁/释放的开销要小)。带着这把"尺子",我们开始逐代演进。
3. 方案一:用数据库实现分布式锁
最朴素的做法:建一张锁表,给 lock_key 加唯一索引。加锁就是 INSERT 一条记录——成功即拿到锁,冲突(唯一索引报错)说明别人持有;释放锁就是 DELETE。
-- 锁表:lock_key 唯一索引保证互斥
CREATE TABLE distributed_lock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
lock_key VARCHAR(64) NOT NULL,
owner VARCHAR(128) NOT NULL, -- 持有者标识(IP+线程)
expire_at DATETIME NOT NULL, -- 兜底过期时间
UNIQUE KEY uk_lock_key (lock_key)
) ENGINE=InnoDB;
-- 加锁:插入成功 = 拿到锁
INSERT INTO distributed_lock (lock_key, owner, expire_at)
VALUES ('lock:order:1001', '10.0.0.5:thread-1', NOW() + INTERVAL 30 SECOND);
-- 唯一索引冲突 = 锁被占用
-- 释放:删除自己的锁(owner 条件防止误删)
DELETE FROM distributed_lock
WHERE lock_key = 'lock:order:1001' AND owner = '10.0.0.5:thread-1';

数据库方案的适用面很窄:并发量低、又不想引入新组件的场景可以凑合用。并发一高,锁表就成了热点行,数据库 QPS 直接被打满。另外它也没有"锁自动过期"——持有者宕机后记录残留,需要额外的定时清理任务兜底,三要素里的"防死锁"要靠人肉补齐。
顺带一提乐观锁:很多"防超卖"场景其实根本不需要显式加锁,用版本号一条 UPDATE 搞定:UPDATE stock SET num = num - 1, version = version + 1 WHERE id = 1 AND version = ?。影响行数为 0 就说明被人抢先改了,重试即可。能用乐观锁解决的,别上分布式锁。
4. 方案二:Redis 分布式锁的正确姿势
Redis 性能远超数据库,是分布式锁的主流选择。但"用 Redis 写一把正确的锁"远没有看起来那么简单——网上大量教程的第一版就是错的。我们按演进顺序来。
4.1 第一步的坑:SETNX 和 EXPIRE 不是原子的
很多老教程分两步加锁:SETNX key value 拿锁,再 EXPIRE key 30 设过期。问题是这是两条命令——如果客户端在两条命令之间宕机,锁被设置了但永远不会过期,其他客户端全部卡死。

# 正确姿势:一条命令同时完成"互斥 + 过期"
SET lock:order:1001 8a3f9c2e-1111:thread-1 NX PX 30000
# 返回 OK → 加锁成功
# 返回 nil → 锁被占用
4.2 第二个坑:释放锁时删掉了别人的锁
锁有过期时间就有新问题:业务没执行完,锁先过期了。看下面这条事故时间线:

修复后的释放逻辑用 Lua 脚本实现——比对 value 和删除必须在同一个原子操作里完成:
-- release_lock.lua:校验 value 一致才删除,否则返回 0(不是自己的锁,不动)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
4.3 第三个坑:业务没跑完,锁先过期了怎么办?
Lua 只解决了"别删错锁",但"锁过期、业务还在跑"本身仍然会导致互斥被破坏。两种应对:
- 把过期时间设得足够长:治标不治本——设太长,持有者宕机后其他客户端要白等很久;设太短,照样过期。
- 锁自动续期(看门狗):业务执行期间,定时把锁的有效期往后延,直到业务执行完再释放。这是正确答案,也是 Redisson 的核心卖点。
在进入 Redisson 之前,先自己手写一个"能用的" Redis 锁客户端,体会一下要补齐多少细节:
public class SimpleRedisLock {
private final StringRedisTemplate redis;
private final String lockKey;
private final String value; // 唯一标识:UUID:threadId
public boolean tryLock(long waitMs, long leaseMs) {
long deadline = System.currentTimeMillis() + waitMs;
while (System.currentTimeMillis() < deadline) {
Boolean ok = redis.opsForValue()
.setIfAbsent(lockKey, value, Duration.ofMillis(leaseMs)); // 原子加锁
if (Boolean.TRUE.equals(ok)) return true;
sleep(50); // 自旋重试,生产建议指数退避
}
return false;
}
public void unlock() {
// 必须用 Lua 保证"比对 + 删除"原子执行
DefaultRedisScript<Long> script = new DefaultRedisScript<>(RELEASE_LUA, Long.class);
redis.execute(script, List.of(lockKey), value);
}
}
手写版仍然缺两块:① 没有自动续期——业务超过 leaseMs 依然会互斥失效;② 不可重入——同一线程二次加锁会把自己锁死。这两个坑,正是 Redisson 存在的理由。
5. 方案三:Redisson——生产级的 Redis 分布式锁
Redisson 是 Redis 官方推荐的 Java 客户端之一,它的 RLock 把上面所有坑都填平了:原子加锁、唯一 value、Lua 校验释放、可重入、看门狗自动续期,开箱即用。
5.1 看门狗(Watchdog):自动续期机制

5.2 可重入原理:Hash 结构记录重入次数
Redisson 的锁不是简单 String,而是Hash 结构:field 是"客户端 ID : 线程 ID",value 是重入次数。同一线程第二次加锁,次数 +1;每次 unlock 减 1;减到 0 才真正删除锁。

5.3 标准用法
RLock lock = redissonClient.getLock("lock:order:1001");
try {
// 推荐:等待 5s;不传 leaseTime → 启用看门狗自动续期
boolean acquired = lock.tryLock(5, TimeUnit.SECONDS);
if (!acquired) {
throw new BusyException("获取锁失败,请稍后重试");
}
// ... 临界区业务 ...
} finally {
// 只释放自己持有的锁,避免 IllegalMonitorStateException
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
# Redisson 接入 Spring Boot
spring:
redis:
redisson:
config: |
singleServerConfig:
address: "redis://127.0.0.1:6379"
connectionMinimumIdleSize: 8
# watchdogTimeoutMs 不配则默认 30s,续期间隔 = 30s / 3 = 10s
6. 更深一层:主从切换时锁会丢——Redlock 出场
到这里 Redisson 已经很完善了,但还有一个架构层面的漏洞:Redis 主从复制是异步的。

**Redlock(红锁)**的解法是:部署 N 个(通常 5 个)完全独立、互不复制的 Redis 主节点。加锁时客户端依次向所有节点发送加锁请求,满足以下条件才算成功:

7. 另一条路线:ZooKeeper 与 etcd
Redis 系方案的本质是"允许极小概率出错,换取极致性能"。如果业务对正确性要求苛刻(如资金、调度选主),可以换用基于共识协议的组件:
- ZooKeeper:客户端在锁节点下创建临时顺序节点,序号最小的获得锁;其余节点 watch 自己前一个节点,前者释放后依次被唤醒(避免惊群)。会话断开时临时节点自动删除,天然防死锁。互斥性由 ZAB 共识协议保证,比 Redis 主从切换可靠得多。
- etcd:用
lease(租约)+Revision全局有序版本号实现同类语义,Raft 协议保证一致性,Kubernetes 选主用的就是这套。
代价是吞吐量比 Redis 低一到两个数量级,且要多运维一套组件。它们与 Redis 方案不是替代关系,而是正确性与性能之间的取舍。
8. 横评与选型:一张表 + 一棵决策树
| 方案 | 互斥强度 | 性能 | 防死锁 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 数据库唯一索引 / 行锁 | 强 | 差(DB 瓶颈) | 需自建清理任务 | 极低 | 低并发、不想引组件 |
| Redis SET NX PX + Lua | 中(主从可丢锁) | 极高 | 过期时间兜底 | 中(细节多易踩坑) | 高并发、容忍极小概率失效 |
| Redisson | 中(同上,但细节完备) | 极高 | 看门狗 + 过期 | 低(开箱即用) | 绝大多数 Java 业务的首选 |
| Redlock | 较强(多数派仲裁) | 高 | 各节点过期 | 高(5 独立节点) | 对主从丢锁敏感的核心链路 |
| ZooKeeper / etcd | 强(共识协议) | 中 | 会话结束自动释放 | 中 | 强一致要求:选主、资金调度 |

比选型更重要的兜底思维:分布式锁从来不是 100% 可靠的,成熟系统的做法是"锁 + 幂等"双保险——锁用来减少冲突、提升效率;就算锁失效,业务层的幂等校验(版本号、状态机、唯一约束)也能拦住重复写入。
9. 面试速查与总结
高频追问速答:
- 为什么 SETNX 和 EXPIRE 要合成一条命令?——两条命令之间宕机会产生永不过期的死锁;
SET key value NX PX 30000原子完成。 - 为什么释放锁要用 Lua?——"GET 比对 value"和"DEL"必须原子,否则比对后锁过期被他人拿走,仍会误删。
- 看门狗什么时候生效?——
lock()不传 leaseTime 时,默认 30s 有效期、每 10s 续期;手动传了 leaseTime 就不续期。 - Redisson 可重入怎么实现?——Hash 结构,field 为客户端 ID:线程 ID,value 计数加减,减到 0 删除。
- 主从切换为什么会丢锁?——复制异步,锁 key 还没同步到从库主库就宕机,从库晋升后锁不存在。
- Redlock 成功条件?——多数派(N/2+1)加锁成功,且总耗时小于锁有效期;失败则向全部节点释放。
一句话总结:分布式锁的演进史,就是不断修补互斥漏洞的历史——数据库方案把压力给了 DB,Redis 方案用性能换取主从复制的一致性缺口,Redisson 用看门狗和 Lua 堵上了使用层面的坑,Redlock 和 ZooKeeper 则试图在架构层面根治。对大多数业务,Redisson + 业务幂等兜底就是那个投入产出比最高的答案;对零容忍场景,请让共识协议上场。