分布式锁扩展点
学完普通 Redis 分布式锁和红锁以后,不能只停留在“会 SET NX PX”。商业项目里真正容易出问题的地方,往往是锁的扩展能力和边界:锁要不要可重入、要不要公平、业务执行超时怎么办、多个资源怎么一起锁、读多写少怎么优化、核心写入怎么防旧请求覆盖新结果。
本页把分布式锁相关扩展点集中补齐。读完后你应该能做到:
- 知道 Redisson 常见锁和同步器分别解决什么问题。
- 知道锁续期、等待唤醒、可重入、公平锁、读写锁、联锁、信号量的原理。
- 知道 Fencing Token、状态机、幂等、唯一约束为什么必须和锁配合。
- 知道 Redis 锁、Redlock、ZooKeeper、etcd、数据库锁、MQ 串行消费怎么选。
- 能写出常见商业场景下的最小 demo。
扩展点总览
flowchart TD
A["分布式锁扩展点"] --> B["锁能力"]
A --> C["可靠性增强"]
A --> D["协调组件"]
A --> E["业务兜底"]
B --> B1["可重入 / 公平 / 读写"]
B --> B2["联锁 / 红锁 / 信号量"]
C --> C1["续期 / 等待唤醒 / 超时"]
C --> C2["监控 / 降级 / 补偿"]
D --> D1["Redis / ZK / etcd / DB / MQ"]
E --> E1["幂等 / 状态机 / Fencing Token"]先记住一个原则:
锁只能减少并发进入临界区,不能单独证明业务结果一定正确。越核心的业务,越要把锁放在“第一道防线”,把数据库状态、幂等和补偿放在“最终防线”。
可重入锁 RLock
可重入锁表示同一个线程已经拿到锁后,可以再次拿同一把锁,不会被自己堵死。
本地 Java 里 synchronized 和 ReentrantLock 都是可重入的。Redisson 的 RLock 也支持可重入,它通常用 Redis Hash 保存当前客户端线程和重入次数。
flowchart TD
A["第一次 lock"] --> B["Hash 写入线程标识=1"]
B --> C["同线程再次 lock"]
C --> D["重入次数 + 1"]
D --> E["unlock 一次"]
E --> F["重入次数 - 1"]
F --> G{"次数是否为 0"}
G -- "否" --> H["继续持有锁"]
G -- "是" --> I["删除锁并通知等待者"]Demo:
RLock lock = redissonClient.getLock("lock:order:" + orderNo);
boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS);
if (!locked) {
throw new IllegalStateException("订单处理中,请稍后再试");
}
try {
orderService.check(orderNo);
orderService.pay(orderNo);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}为什么要判断 isHeldByCurrentThread():
- 当前线程可能没有拿到锁。
- 锁可能因为超时已经释放。
- 直接
unlock()可能抛异常,或者误以为自己还持有锁。
适合场景:
| 场景 | 说明 |
|---|---|
| 防重复提交 | 同一订单、同一批次、同一资源只允许一个实例处理 |
| 缓存重建 | 热点 key 失效时只允许一个线程查库重建 |
| 定时任务抢占 | 多实例部署时只让一个实例执行任务 |
不适合场景:
| 场景 | 原因 |
|---|---|
| 高吞吐扣库存 | 大量请求争抢一把锁会严重降低吞吐 |
| 资金余额强一致 | 不能只靠 Redis 锁,要靠数据库事务和流水 |
| 长时间批处理 | 锁持有太久,失败和续期风险变大 |
tryLock 参数怎么理解
Redisson 常见写法:
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);两个时间的含义完全不同:
| 参数 | 含义 | 配错后果 |
|---|---|---|
waitTime | 最多等待多久拿锁 | 太大导致请求堆积,太小导致频繁失败 |
leaseTime | 拿到锁后多久自动释放 | 太小导致锁提前过期,太大导致故障恢复慢 |
流程:
flowchart TD
A["请求 tryLock"] --> B{"锁是否空闲"}
B -- "是" --> C["立即获得锁"]
B -- "否" --> D["等待释放通知"]
D --> E{"是否超过 waitTime"}
E -- "是" --> F["返回 false"]
E -- "否" --> B
C --> G["到 leaseTime 自动过期"]设置建议:
| 业务类型 | waitTime | leaseTime |
|---|---|---|
| 用户接口 | 短,例如 0 到 3 秒 | 覆盖 P99 业务耗时 |
| 定时任务 | 可稍长 | 覆盖任务单次处理时间 |
| 缓存重建 | 很短 | 覆盖查库和写缓存时间 |
| 批处理 | 谨慎使用 | 更建议任务分片和状态机 |
看门狗续期
看门狗解决的是“业务还没执行完,但锁 TTL 快过期”的问题。Redisson 在没有指定固定 leaseTime 的情况下,会自动续期。
flowchart TD
A["线程获得锁"] --> B["设置默认过期时间"]
B --> C["业务执行中"]
C --> D["看门狗定期续期"]
D --> E{"业务是否结束"}
E -- "否" --> D
E -- "是" --> F["unlock 释放锁"]看门狗能缓解锁提前过期,但不能滥用:
| 风险 | 为什么 |
|---|---|
| 业务线程卡死 | 锁可能被持续续期,后续请求长期拿不到 |
| JVM 长 GC | 续期线程可能暂停,锁仍可能过期 |
| 网络抖动 | 续期命令可能失败 |
| 锁内远程调用太多 | 执行时间不可控 |
正确姿势:
- 锁内只放必须串行的短逻辑。
- 核心状态写数据库,不能只靠锁。
- 监控锁等待时间、持有时间和失败次数。
- 不要把“看门狗会续期”当成可以无限执行的理由。
公平锁 FairLock
普通分布式锁更偏向吞吐,不保证严格先来先得。公平锁会尽量按等待顺序获取锁。
flowchart TD
A["请求 A 先到"] --> B["进入等待队列"]
C["请求 B 后到"] --> B
D["请求 C 后到"] --> B
B --> E["锁释放"]
E --> F["按顺序唤醒 A"]
F --> G["再轮到 B 和 C"]Demo:
RLock fairLock = redissonClient.getFairLock("lock:fair:report");
boolean locked = fairLock.tryLock(5, 20, TimeUnit.SECONDS);
if (!locked) {
return;
}
try {
generateReport();
} finally {
if (fairLock.isHeldByCurrentThread()) {
fairLock.unlock();
}
}公平锁的代价是吞吐下降,因为系统要维护等待队列和顺序唤醒。商业项目中只有在“顺序本身很重要”时才考虑公平锁,例如排队发号、审批顺序处理。大多数高并发接口不应该为了“看起来公平”牺牲吞吐。
读写锁 RReadWriteLock
读写锁适合读多写少场景。多个读请求可以同时执行,写请求必须独占。
flowchart TD
A["读请求 1"] --> B["读锁"]
C["读请求 2"] --> B
D["读请求 3"] --> B
B --> E["可以并发读取"]
F["写请求"] --> G["写锁"]
G --> H["等待读锁释放后独占写入"]Demo:配置缓存读多写少。
RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:config:global");
public Config readConfig() {
RLock readLock = rwLock.readLock();
readLock.lock();
try {
return configCache.get();
} finally {
readLock.unlock();
}
}
public void refreshConfig(Config newConfig) {
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
configCache.set(newConfig);
configRepository.save(newConfig);
} finally {
writeLock.unlock();
}
}适合:
| 场景 | 原因 |
|---|---|
| 字典配置缓存 | 读很多,写很少 |
| 黑白名单读取 | 查询频繁,更新偶尔发生 |
| 规则引擎配置 | 大量请求读规则,后台少量更新 |
不适合:
| 场景 | 原因 |
|---|---|
| 写很多 | 读写频繁互斥,收益下降 |
| 单机内存缓存 | 本地读写锁可能更简单 |
| 强一致数据库写 | 仍要依赖数据库事务 |
联锁 MultiLock
联锁是把多把锁组合成一把锁。只有所有锁都拿到,整体才算成功。
例如一次操作必须同时锁订单和库存:
flowchart TD
A["获取订单锁"] --> B{"成功"}
B -- "否" --> C["失败返回"]
B -- "是" --> D["获取库存锁"]
D --> E{"成功"}
E -- "否" --> F["释放订单锁"]
E -- "是" --> G["执行业务"]
G --> H["释放全部锁"]Demo:
RLock orderLock = redissonClient.getLock("lock:order:" + orderNo);
RLock stockLock = redissonClient.getLock("lock:stock:" + skuId);
RLock multiLock = redissonClient.getMultiLock(orderLock, stockLock);
boolean locked = multiLock.tryLock(2, 20, TimeUnit.SECONDS);
if (!locked) {
throw new IllegalStateException("资源繁忙");
}
try {
createOrderAndReserveStock(orderNo, skuId);
} finally {
multiLock.unlock();
}联锁的坑:
| 坑 | 后果 | 处理方式 |
|---|---|---|
| 锁资源太多 | 成功率下降,等待时间变长 | 减少锁数量,重新设计资源粒度 |
| 顺序不固定 | 容易形成等待和死锁风险 | 统一锁 key 排序 |
| 业务事务太大 | 锁持有时间不可控 | 拆流程,使用状态机 |
更常见的做法是避免同时锁很多资源,改为数据库条件更新、库存预占、消息最终一致。
红锁 Redlock
红锁是多独立 Redis 节点多数派加锁方案,详细原理看 Redis 红锁 Redlock。
红锁适合降低单 Redis 节点故障带来的锁丢失风险,但不要把它当成强一致锁。
flowchart TD
A["多个独立 Redis 节点"] --> B["多数派加锁成功"]
B --> C["校验总耗时小于 TTL"]
C --> D["获得红锁"]
D --> E["业务仍需状态机和幂等兜底"]信号量 Semaphore
分布式锁控制的是“同一时刻只能一个执行”。信号量控制的是“同一时刻最多 N 个执行”。
这很适合限制并发量,例如最多允许 10 个实例同时执行文件转换,防止 CPU 或第三方接口被打爆。
flowchart TD
A["总许可 10 个"] --> B["请求获取许可"]
B --> C{"还有许可"}
C -- "有" --> D["执行业务"]
C -- "无" --> E["等待或失败"]
D --> F["释放许可"]Demo:
RSemaphore semaphore = redissonClient.getSemaphore("sem:export");
semaphore.trySetPermits(10);
boolean acquired = semaphore.tryAcquire(1, 3, TimeUnit.SECONDS);
if (!acquired) {
throw new IllegalStateException("导出任务过多,请稍后再试");
}
try {
exportFile();
} finally {
semaphore.release();
}使用场景:
| 场景 | 为什么用信号量 |
|---|---|
| 限制批量导出并发 | 防止 CPU 和内存被打满 |
| 限制第三方接口调用 | 避免超过供应商 QPS 或并发限制 |
| 限制大文件上传处理 | 防止磁盘 IO 被打爆 |
信号量不是幂等方案,它只是容量保护。请求失败、重复提交、状态乱跳仍然要靠业务设计。
可过期信号量 PermitExpirableSemaphore
普通信号量如果获取许可后服务崩溃,可能忘记释放。可过期信号量给每个许可一个过期时间,适合“任务可能异常退出”的场景。
Demo:
RPermitExpirableSemaphore semaphore =
redissonClient.getPermitExpirableSemaphore("sem:third-api");
semaphore.trySetPermits(20);
String permitId = semaphore.tryAcquire(5, 30, TimeUnit.SECONDS);
if (permitId == null) {
throw new IllegalStateException("第三方接口繁忙");
}
try {
callThirdApi();
} finally {
semaphore.release(permitId);
}这里的 30 秒表示许可过期时间。即使应用异常退出,许可也会最终回收。
闭锁 CountDownLatch
分布式闭锁适合多个服务实例等待某些前置任务完成,再一起继续执行。
flowchart TD
A["主任务创建 latch=3"] --> B["子任务 1 完成 countDown"]
A --> C["子任务 2 完成 countDown"]
A --> D["子任务 3 完成 countDown"]
B --> E["计数归零"]
C --> E
D --> E
E --> F["主任务继续"]Demo:
RCountDownLatch latch = redissonClient.getCountDownLatch("latch:collect:batch:1001");
latch.trySetCount(3);
// 子任务完成时
latch.countDown();
// 主任务等待
boolean completed = latch.await(60, TimeUnit.SECONDS);
if (!completed) {
throw new IllegalStateException("采集子任务超时");
}注意:闭锁更适合流程协调,不适合替代 MQ 或工作流引擎。复杂审批、补偿、重试还是应该使用工作流、任务表或消息队列。
Fencing Token
Fencing Token 是很多人学分布式锁会漏掉的关键点。它解决的是:旧锁持有者在锁过期后恢复执行,继续写下游资源。
流程:
flowchart TD
A["A 获得锁 token=10"] --> B["A 长时间暂停"]
B --> C["锁过期"]
C --> D["B 获得锁 token=11"]
D --> E["B 写入资源"]
B --> F["A 恢复后写入"]
F --> G["资源比较 token"]
G --> H["拒绝旧 token"]Redis 生成递增 token:
Long token = redisTemplate.opsForValue().increment("lock:token:collect:" + batchNo);数据库条件写入:
update t_collect_batch
set status = 'RUNNING',
lock_token = :newToken
where batch_no = :batchNo
and lock_token < :newToken
and status = 'WAITING';Java 使用方式:
Long token = redisTemplate.opsForValue().increment("lock:token:collect:" + batchNo);
RLock lock = redissonClient.getLock("lock:collect:" + batchNo);
boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS);
if (!locked) {
return;
}
try {
int updated = collectBatchMapper.markRunning(batchNo, token);
if (updated == 0) {
return;
}
doCollect(batchNo);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}为什么 token 要由外部单调递增组件生成:
- UUID 只能证明“是不是自己”,不能比较新旧。
- 递增 token 能让下游资源拒绝旧持有者。
- 核心资源最终要在数据库侧判断版本,而不是只相信应用内判断。
状态机和幂等
锁失效后,真正兜底的是状态机和幂等。
以订单支付回调为例:
flowchart TD
A["收到支付回调"] --> B["根据支付流水查订单"]
B --> C{"订单是否待支付"}
C -- "否" --> D["幂等返回成功"]
C -- "是" --> E["条件更新为已支付"]
E --> F{"更新行数是否为 1"}
F -- "否" --> D
F -- "是" --> G["发送后续消息"]SQL:
update t_order
set status = 'PAID',
paid_time = now()
where order_no = ?
and status = 'WAIT_PAY';这条 SQL 本身就是强约束。即使两个实例同时处理回调,也只有一个能更新成功。
分布式锁在这里的价值是降低并发冲突和重复处理概率,但最终正确性靠状态条件。
数据库锁和唯一约束
不是所有并发问题都要上 Redis 锁。数据库本身也能处理很多一致性问题。
唯一约束防重复
create unique index uk_payment_trade_no
on t_payment(trade_no);插入支付流水:
try {
paymentMapper.insert(payment);
} catch (DuplicateKeyException ex) {
return paymentMapper.findByTradeNo(payment.getTradeNo());
}适合防止重复创建数据,例如支付流水、幂等请求号、业务单号。
乐观锁防覆盖
update t_product
set stock = stock - 1,
version = version + 1
where id = ?
and stock > 0
and version = ?;适合库存、配置版本、状态流转。它不阻塞别人,只在提交时判断版本是否仍然匹配。
悲观锁
select *
from t_order
where order_no = ?
for update;适合一个数据库事务内强制串行修改同一行,但不适合跨服务、长流程、远程调用,因为数据库连接和锁会被长时间占用。
ZooKeeper 分布式锁
ZooKeeper 常用临时顺序节点实现分布式锁。
流程:
flowchart TD
A["客户端创建临时顺序节点"] --> B["读取同目录所有节点"]
B --> C{"自己是否最小节点"}
C -- "是" --> D["获得锁"]
C -- "否" --> E["监听前一个节点"]
E --> F["前一个节点删除"]
F --> B
D --> G["业务完成删除节点"]为什么监听前一个节点,而不是所有客户端都监听最小节点:
- 避免羊群效应。
- 当前一个节点释放时,只唤醒下一个等待者。
- 更接近排队获取锁。
Curator Demo:
InterProcessMutex lock = new InterProcessMutex(curatorClient, "/locks/order-1001");
if (!lock.acquire(3, TimeUnit.SECONDS)) {
throw new IllegalStateException("订单处理中");
}
try {
handleOrder("1001");
} finally {
lock.release();
}ZooKeeper 锁的特点:
| 特点 | 说明 |
|---|---|
| 一致性较强 | 基于 ZooKeeper 的顺序一致性和会话机制 |
| 自动释放 | 客户端会话失效后临时节点删除 |
| 适合协调 | 配置、选主、任务调度、低频关键协调 |
| 不适合热点高频锁 | 吞吐和延迟通常不如 Redis |
etcd 分布式锁
etcd 常用 lease 和 revision 实现锁。客户端创建带租约的 key,租约过期后 key 自动删除。revision 可以天然作为 Fencing Token。
flowchart TD
A["申请 lease"] --> B["创建锁 key"]
B --> C["获得 revision"]
C --> D["执行业务"]
D --> E["续租或释放 lease"]
C --> F["revision 可作 Fencing Token"]Go 伪代码:
session, err := concurrency.NewSession(client, concurrency.WithTTL(30))
if err != nil {
return err
}
defer session.Close()
mutex := concurrency.NewMutex(session, "/locks/order-1001")
if err := mutex.Lock(context.Background()); err != nil {
return err
}
defer mutex.Unlock(context.Background())
// mutex.Header().Revision 可作为 fencing token 思路参考
handleOrder("1001")etcd 更适合云原生控制面、服务协调、选主、配置一致性,不适合用来承载业务热点高频锁。
MQ 串行消费替代锁
如果一个资源的大量操作都要串行处理,锁不一定是最优解。可以把同一业务 key 的消息路由到同一个队列或同一个分区,让消费者顺序处理。
flowchart TD
A["订单操作消息"] --> B["按 orderNo 分区"]
B --> C["同一订单进入同一分区"]
C --> D["单消费者顺序处理"]
D --> E["避免大量线程抢锁"]例如 Kafka 按订单号作为 key:
ProducerRecord<String, String> record =
new ProducerRecord<String, String>(
"order-event",
orderNo,
jsonBody
);
kafkaProducer.send(record);这种方式适合:
| 场景 | 原因 |
|---|---|
| 同一订单事件顺序处理 | 按订单号分区天然串行 |
| 异步状态推进 | 消费端控制顺序和重试 |
| 削峰填谷 | 消息队列能缓冲流量 |
但 MQ 不能替代数据库状态机。消息也可能重复投递,消费者仍要幂等。
锁粒度设计
锁粒度决定吞吐和正确性。
| 锁 key | 粒度 | 问题 |
|---|---|---|
lock:order | 全局订单锁 | 所有订单串行,吞吐极差 |
lock:order:1001 | 单订单锁 | 同一订单串行,不同订单并行 |
lock:user:88 | 用户锁 | 同一用户串行,可能过大 |
lock:sku:9 | 商品锁 | 热点商品可能竞争严重 |
设计原则:
- 锁住真正共享的业务资源。
- 不要为了省事使用全局锁。
- 热点资源要考虑队列化、分片、限流,而不是无限加锁。
- key 必须包含租户、业务类型、资源 ID,避免误锁。
推荐命名:
lock:{业务}:{资源类型}:{资源ID}
lock:order:pay:202607020001
lock:collect:batch:BATCH20260702001
lock:cache:product:1001监控和排查
分布式锁上线后必须能观测,否则出了问题只能猜。
核心指标:
| 指标 | 说明 | 异常含义 |
|---|---|---|
| 加锁成功率 | 成功次数 / 尝试次数 | 竞争激烈或 Redis 异常 |
| 等待时间 | tryLock 等待多久 | 锁粒度过大或业务太慢 |
| 持有时间 | 拿到锁后执行多久 | 锁内逻辑过重 |
| 续期次数 | 看门狗续期频率 | 业务耗时长或线程卡住 |
| 失败原因 | 超时、异常、未持有锁 | 定位连接或业务问题 |
日志建议:
long start = System.currentTimeMillis();
boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS);
long waitMs = System.currentTimeMillis() - start;
log.info("try lock result, key={}, locked={}, waitMs={}", lockKey, locked, waitMs);排查流程:
flowchart TD
A["接口变慢或重复执行"] --> B["查锁等待时间"]
B --> C{"等待是否很长"}
C -- "是" --> D["查锁粒度和持有时间"]
C -- "否" --> E["查业务状态机"]
D --> F["查慢 SQL 和远程调用"]
E --> G["查幂等和唯一约束"]
F --> H["优化锁内逻辑"]
G --> I["补业务兜底"]选型表
| 需求 | 推荐方案 | 不推荐 |
|---|---|---|
| 热点缓存重建 | Redis RLock | 数据库悲观锁 |
| 多实例定时任务抢占 | Redis RLock、XXL-JOB 分片 | 本地锁 |
| 同一订单防重复处理 | Redis 锁 + 状态机 | 只靠 Redis 锁 |
| 支付回调幂等 | 唯一约束 + 状态机 | 只靠 Redlock |
| 读多写少配置同步 | RReadWriteLock 或配置中心 | 全局互斥锁 |
| 限制最多 N 个任务并发 | RSemaphore | N 把独立锁 |
| 控制面选主 | ZooKeeper 或 etcd | 业务 Redis 热点锁 |
| 高并发同 key 串行处理 | MQ 分区顺序消费 | 大量线程抢同一把锁 |
刷题复习入口
本页负责讲清楚锁扩展能力、适用边界、Demo 和选型。需要快速复习答题结构时,跳到 Redis 面试知识点 的 Redisson 和锁扩展点部分。
关联知识点
- Redis 分布式锁:掌握普通 Redis 锁、Lua 释放、Redisson 看门狗。
- Redis 红锁 Redlock:掌握多 Redis 节点多数派加锁。
- Redisson 分布式锁:掌握 Redisson 锁的常用 API。
- 幂等设计:理解为什么锁不能替代幂等。
- 数据一致性:理解一致性模型和业务兜底。
- 分布式事务:理解跨服务一致性方案。
- 消息队列:理解串行消费、削峰和最终一致。
本章小结
分布式锁扩展点的核心不是“组件 API 越多越好”,而是根据业务风险选择合适的并发控制方式。普通 Redis 锁适合高性能互斥,Redlock 降低单点风险,Redisson 提供可重入、续期、读写锁、信号量等工程能力,ZooKeeper 和 etcd 更适合一致性协调,数据库约束和状态机负责最终正确性。真正成熟的方案,一定会把锁、幂等、状态机、唯一约束、监控和补偿组合起来。
