Redis 分布式锁
Redis 分布式锁用于解决多个进程、多个服务实例同时操作同一份资源的问题。例如防止多个实例同时执行定时任务、防止缓存击穿时多个请求同时查数据库、防止重复提交。
分布式锁的目标是互斥,但它不能替代数据库唯一约束、幂等和业务状态机。
为什么本地锁不够
在单个 JVM 中,synchronized 或 ReentrantLock 可以保护共享资源。但服务部署多个实例后,每个实例都有自己的内存锁,彼此不知道对方已经加锁。
flowchart TD
A["实例 A 本地锁"] --> C["共享资源"]
B["实例 B 本地锁"] --> C
C --> D["两个实例可能同时执行"]所以需要一个所有实例都能访问的外部协调点,Redis 就是常见选择。
正确加锁流程
flowchart TD
A["生成唯一 value"] --> B["SET lockKey value NX PX expire"]
B --> C{"是否返回 OK"}
C -- "否" --> D["加锁失败,重试或快速失败"]
C -- "是" --> E["执行业务逻辑"]
E --> F["执行 Lua 脚本"]
F --> G{"value 是否匹配"}
G -- "匹配" --> H["删除锁"]
G -- "不匹配" --> I["不删除,避免释放别人的锁"]Redis 命令:
SET lock:product:1001 request-uuid NX PX 30000含义:
| 部分 | 说明 |
|---|---|
lock:product:1001 | 锁 key |
request-uuid | 当前请求唯一标识 |
NX | key 不存在时才写入 |
PX 30000 | 30 秒后自动过期 |
为什么释放锁要用 Lua
错误做法:
DEL lock:product:1001如果当前线程的锁已经过期,另一个线程刚好加锁成功,此时直接 DEL 可能删掉别人的锁。
正确做法是先判断 value 是否属于自己,再删除。判断和删除必须原子执行:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endJava 伪代码
String lockKey = "lock:product:" + productId;
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(locked)) {
throw new RuntimeException("系统繁忙,请稍后再试");
}
try {
// 执行业务逻辑
rebuildHotCache(productId);
} finally {
// 用 Lua 校验 value 后释放
releaseLockByLua(lockKey, requestId);
}生产项目通常推荐使用 Redisson,因为它封装了锁重入、自动续期和安全释放。
Redisson 示例
RLock lock = redissonClient.getLock("lock:product:" + productId);
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("系统繁忙,请稍后再试");
}
try {
rebuildHotCache(productId);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}参数含义:
| 参数 | 说明 |
|---|---|
waitTime | 最多等待多久获取锁 |
leaseTime | 锁自动释放时间 |
isHeldByCurrentThread | 防止当前线程释放不属于自己的锁 |
自动续期
如果业务执行时间可能超过锁过期时间,就会出现锁提前释放的问题。Redisson 的看门狗机制可以在没有指定固定 leaseTime 时自动续期。
flowchart TD
A["线程获取锁"] --> B["业务仍在执行"]
B --> C["看门狗定期续期"]
C --> D{"业务是否结束"}
D -- "否" --> C
D -- "是" --> E["释放锁"]注意:如果指定了明确的 leaseTime,通常不会启用默认看门狗续期,要根据业务耗时谨慎设置。
Redisson 锁的底层结构
Redisson 的可重入锁不是简单地 SET key value。它通常会用 Redis Hash 记录“哪个客户端线程持有锁”和“重入次数”。
可以简化理解为:
key = lock:product:1001
field = clientId:threadId
value = 重入次数第一次加锁:
HSET lock:product:1001 clientA:thread-1 1
PEXPIRE lock:product:1001 30000同一个线程再次加锁:
HINCRBY lock:product:1001 clientA:thread-1 1
PEXPIRE lock:product:1001 30000释放一次锁:
HINCRBY lock:product:1001 clientA:thread-1 -1只有重入次数归零,才真正删除锁 key 并发布解锁消息。
flowchart TD
A["线程第一次 lock"] --> B["Hash field 不存在"]
B --> C["写入 field=clientId:threadId value=1"]
C --> D["设置过期时间"]
E["同线程再次 lock"] --> F["field 匹配"]
F --> G["重入次数 + 1"]
H["unlock"] --> I["重入次数 - 1"]
I --> J{"次数是否为 0"}
J -- "否" --> K["继续持有锁"]
J -- "是" --> L["删除锁并发布解锁通知"]这也是为什么 Redisson 可以支持可重入,而手写 SET NX PX 版本默认不支持可重入。
等锁为什么用发布订阅
如果一个线程加锁失败,它不应该一直疯狂循环打 Redis。Redisson 会结合等待时间、订阅解锁消息等机制,减少无意义自旋。
简化流程:
flowchart TD
A["线程 B 尝试加锁"] --> B{"锁是否空闲"}
B -- "空闲" --> C["加锁成功"]
B -- "被线程 A 持有" --> D["订阅锁释放频道"]
D --> E["等待通知或超时"]
F["线程 A unlock"] --> G["发布解锁消息"]
G --> E
E --> H["线程 B 再次尝试加锁"]这样能减少 Redis 压力,也能让等待线程更快感知锁释放。
看门狗续期的细节
Redisson 默认看门狗超时时间通常是 30 秒。加锁成功后,如果没有指定固定 leaseTime,Redisson 会定期续期,常见节奏可以理解为每隔锁超时时间的三分之一续一次。
flowchart TD
A["加锁成功,过期 30s"] --> B["业务执行中"]
B --> C["约 10s 后续期到 30s"]
C --> D["再过约 10s 继续续期"]
D --> E{"业务是否结束"}
E -- "否" --> C
E -- "是" --> F["unlock 删除锁"]看门狗解决的是“业务执行时间比预估长,锁提前过期”的问题。但它不是万能的:
| 场景 | 风险 |
|---|---|
| JVM 长时间 STW | 续期线程可能来不及执行 |
| Redis 网络抖动 | 续期命令可能失败 |
| 业务线程卡死 | 锁可能被持续续期,影响后续任务 |
指定固定 leaseTime | 默认看门狗通常不会生效 |
所以核心业务不能只靠锁,还要靠业务状态、唯一约束、幂等和补偿。
Redis 主从切换为什么会影响锁可靠性
Redis 主从复制默认是异步的。极端情况下可能发生:
- 客户端 A 在 Master 加锁成功。
- 锁还没复制到 Slave。
- Master 宕机。
- Slave 被提升为新 Master。
- 客户端 B 在新 Master 上也加锁成功。
flowchart TD
A["客户端 A 在旧 Master 加锁成功"] --> B["锁尚未复制到 Slave"]
B --> C["旧 Master 宕机"]
C --> D["Slave 晋升为新 Master"]
D --> E["客户端 B 再次加锁成功"]
E --> F["同一资源可能被两个客户端同时处理"]这就是为什么 Redis 锁适合做“工程互斥”,但不适合作为金融级唯一正确性保障。关键资源最终要有数据库唯一约束、状态机或 fencing token 兜底。
红锁 Redlock
普通 Redis 锁主要依赖一个 Redis Master 做加锁判断。主从异步复制和故障切换时,可能出现“客户端 A 在旧 Master 加锁成功,但锁还没复制到 Slave,Slave 就被提升为新 Master,客户端 B 又加锁成功”的极端情况。
Redlock 的思路是准备多个相互独立的 Redis Master,客户端向多个节点尝试写入同一把锁,只要成功节点数达到多数派,并且总耗时小于锁 TTL,才认为加锁成功。
flowchart TD
A["客户端生成唯一 token"] --> B["请求多个独立 Redis 节点"]
B --> C["统计成功节点数量"]
C --> D{"是否达到多数派"}
D -- "否" --> E["释放部分锁并失败"]
D -- "是" --> F{"加锁耗时是否小于 TTL"}
F -- "否" --> E
F -- "是" --> G["获得红锁"]但红锁不是强一致事务,也不是 Paxos/Raft 这类共识算法。它不能替代业务幂等、状态机、数据库唯一约束和补偿任务。详细原理、Java Demo、商业选型和面试回答看 Redis 红锁 Redlock。
还要掌握哪些扩展点
分布式锁不是只有 SET NX PX 和 Redlock。商业项目还会遇到可重入、公平锁、读写锁、联锁、信号量、Fencing Token、ZooKeeper/etcd 锁、MQ 串行消费替代锁等问题。
flowchart TD
A["Redis 分布式锁"] --> B["Redisson 工程能力"]
B --> C["可重入 / 看门狗 / 公平锁"]
B --> D["读写锁 / 联锁 / 信号量"]
A --> E["业务正确性兜底"]
E --> F["幂等 / 状态机 / Fencing Token"]
A --> G["其他协调方案"]
G --> H["ZooKeeper / etcd / DB / MQ"]这些内容已经整理到 分布式锁扩展点。学习顺序建议是:先掌握本页普通 Redis 锁,再看 红锁 Redlock,最后看扩展点做项目选型。
Fencing Token:防止过期锁继续写
锁过期后,旧持有者可能还在执行。如果它后续继续写数据库,就会覆盖新持有者的结果。
一种增强方式是 fencing token:每次获取锁时拿到一个单调递增的令牌,下游资源只接受更大的 token。
flowchart TD
A["客户端 A 获得锁 token=10"] --> B["A 执行业务卡住"]
B --> C["锁过期"]
C --> D["客户端 B 获得锁 token=11"]
D --> E["B 写数据库,记录 last_token=11"]
B --> F["A 恢复后尝试写 token=10"]
F --> G["数据库发现 10 < 11,拒绝旧写入"]SQL 思路:
update t_collect_task
set status = 'RUNNING',
lock_token = :newToken
where task_id = :taskId
and lock_token < :newToken;这样即使旧锁持有者“复活”,也不能覆盖新持有者的状态。
锁、幂等、状态机要一起用
分布式锁只能降低并发进入临界区的概率,不能替代业务正确性。
以采集批次处理为例:
public void handleBatch(String batchNo) {
RLock lock = redissonClient.getLock("lock:collect:batch:" + batchNo);
boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS);
if (!locked) {
return;
}
try {
int updated = collectBatchRepository.markRunning(batchNo);
if (updated == 0) {
return; // 已处理或状态不允许,幂等返回
}
doCollect(batchNo);
collectBatchRepository.markSuccess(batchNo);
} catch (Exception ex) {
collectBatchRepository.markFailed(batchNo, ex.getMessage());
throw ex;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}其中 markRunning 应该带状态条件:
update t_collect_batch
set status = 'RUNNING'
where batch_no = ?
and status = 'WAITING';只有更新成功的线程才真正处理。这样即使锁异常失效,数据库状态机仍然能兜底。
常见使用场景
| 场景 | 是否适合 |
|---|---|
| 防止缓存击穿时重复重建热点缓存 | 适合 |
| 多实例定时任务抢占执行权 | 适合 |
| 防止同一订单重复处理 | 可用,但要配合幂等和状态机 |
| 银行扣款最终一致保障 | 不应只依赖 Redis 锁 |
| 替代数据库唯一索引 | 不适合 |
常见问题
| 问题 | 后果 | 解决方式 |
|---|---|---|
| 没有过期时间 | 服务宕机后死锁 | SET NX PX |
| 直接删除锁 | 可能删掉别人的锁 | Lua 校验 value |
| 业务执行超过过期时间 | 多实例并发执行 | 设置合理过期或自动续期 |
| 锁粒度过大 | 吞吐下降 | 按业务资源设计 key |
| Redis 主从切换 | 极端情况下锁可靠性下降 | 核心交易用数据库约束兜底 |
练习
- 为“重建商品缓存”设计一个锁 key。
- 解释为什么
DEL key释放锁不安全。 - 写出
SET key value NX PX中每个参数的作用。 - 思考业务执行 60 秒但锁 30 秒过期会发生什么。
- 判断“防重复下单”是否只靠 Redis 锁就足够,并说明原因。
小结
Redis 分布式锁必须具备四点:互斥、过期时间、唯一 value、Lua 安全释放。Redisson 能简化实现,但不能替代业务幂等、数据库唯一约束和状态机。越核心的业务,越要有多层兜底。
