Skip to content

分布式锁扩展点

学完普通 Redis 分布式锁和红锁以后,不能只停留在“会 SET NX PX”。商业项目里真正容易出问题的地方,往往是锁的扩展能力和边界:锁要不要可重入、要不要公平、业务执行超时怎么办、多个资源怎么一起锁、读多写少怎么优化、核心写入怎么防旧请求覆盖新结果。

本页把分布式锁相关扩展点集中补齐。读完后你应该能做到:

  1. 知道 Redisson 常见锁和同步器分别解决什么问题。
  2. 知道锁续期、等待唤醒、可重入、公平锁、读写锁、联锁、信号量的原理。
  3. 知道 Fencing Token、状态机、幂等、唯一约束为什么必须和锁配合。
  4. 知道 Redis 锁、Redlock、ZooKeeper、etcd、数据库锁、MQ 串行消费怎么选。
  5. 能写出常见商业场景下的最小 demo。

扩展点总览

mermaid
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 里 synchronizedReentrantLock 都是可重入的。Redisson 的 RLock 也支持可重入,它通常用 Redis Hash 保存当前客户端线程和重入次数。

mermaid
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:

java
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()

  1. 当前线程可能没有拿到锁。
  2. 锁可能因为超时已经释放。
  3. 直接 unlock() 可能抛异常,或者误以为自己还持有锁。

适合场景:

场景说明
防重复提交同一订单、同一批次、同一资源只允许一个实例处理
缓存重建热点 key 失效时只允许一个线程查库重建
定时任务抢占多实例部署时只让一个实例执行任务

不适合场景:

场景原因
高吞吐扣库存大量请求争抢一把锁会严重降低吞吐
资金余额强一致不能只靠 Redis 锁,要靠数据库事务和流水
长时间批处理锁持有太久,失败和续期风险变大

tryLock 参数怎么理解

Redisson 常见写法:

java
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);

两个时间的含义完全不同:

参数含义配错后果
waitTime最多等待多久拿锁太大导致请求堆积,太小导致频繁失败
leaseTime拿到锁后多久自动释放太小导致锁提前过期,太大导致故障恢复慢

流程:

mermaid
flowchart TD
    A["请求 tryLock"] --> B{"锁是否空闲"}
    B -- "是" --> C["立即获得锁"]
    B -- "否" --> D["等待释放通知"]
    D --> E{"是否超过 waitTime"}
    E -- "是" --> F["返回 false"]
    E -- "否" --> B
    C --> G["到 leaseTime 自动过期"]

设置建议:

业务类型waitTimeleaseTime
用户接口短,例如 0 到 3 秒覆盖 P99 业务耗时
定时任务可稍长覆盖任务单次处理时间
缓存重建很短覆盖查库和写缓存时间
批处理谨慎使用更建议任务分片和状态机

看门狗续期

看门狗解决的是“业务还没执行完,但锁 TTL 快过期”的问题。Redisson 在没有指定固定 leaseTime 的情况下,会自动续期。

mermaid
flowchart TD
    A["线程获得锁"] --> B["设置默认过期时间"]
    B --> C["业务执行中"]
    C --> D["看门狗定期续期"]
    D --> E{"业务是否结束"}
    E -- "否" --> D
    E -- "是" --> F["unlock 释放锁"]

看门狗能缓解锁提前过期,但不能滥用:

风险为什么
业务线程卡死锁可能被持续续期,后续请求长期拿不到
JVM 长 GC续期线程可能暂停,锁仍可能过期
网络抖动续期命令可能失败
锁内远程调用太多执行时间不可控

正确姿势:

  1. 锁内只放必须串行的短逻辑。
  2. 核心状态写数据库,不能只靠锁。
  3. 监控锁等待时间、持有时间和失败次数。
  4. 不要把“看门狗会续期”当成可以无限执行的理由。

公平锁 FairLock

普通分布式锁更偏向吞吐,不保证严格先来先得。公平锁会尽量按等待顺序获取锁。

mermaid
flowchart TD
    A["请求 A 先到"] --> B["进入等待队列"]
    C["请求 B 后到"] --> B
    D["请求 C 后到"] --> B
    B --> E["锁释放"]
    E --> F["按顺序唤醒 A"]
    F --> G["再轮到 B 和 C"]

Demo:

java
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

读写锁适合读多写少场景。多个读请求可以同时执行,写请求必须独占。

mermaid
flowchart TD
    A["读请求 1"] --> B["读锁"]
    C["读请求 2"] --> B
    D["读请求 3"] --> B
    B --> E["可以并发读取"]
    F["写请求"] --> G["写锁"]
    G --> H["等待读锁释放后独占写入"]

Demo:配置缓存读多写少。

java
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

联锁是把多把锁组合成一把锁。只有所有锁都拿到,整体才算成功。

例如一次操作必须同时锁订单和库存:

mermaid
flowchart TD
    A["获取订单锁"] --> B{"成功"}
    B -- "否" --> C["失败返回"]
    B -- "是" --> D["获取库存锁"]
    D --> E{"成功"}
    E -- "否" --> F["释放订单锁"]
    E -- "是" --> G["执行业务"]
    G --> H["释放全部锁"]

Demo:

java
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 节点故障带来的锁丢失风险,但不要把它当成强一致锁。

mermaid
flowchart TD
    A["多个独立 Redis 节点"] --> B["多数派加锁成功"]
    B --> C["校验总耗时小于 TTL"]
    C --> D["获得红锁"]
    D --> E["业务仍需状态机和幂等兜底"]

信号量 Semaphore

分布式锁控制的是“同一时刻只能一个执行”。信号量控制的是“同一时刻最多 N 个执行”。

这很适合限制并发量,例如最多允许 10 个实例同时执行文件转换,防止 CPU 或第三方接口被打爆。

mermaid
flowchart TD
    A["总许可 10 个"] --> B["请求获取许可"]
    B --> C{"还有许可"}
    C -- "有" --> D["执行业务"]
    C -- "无" --> E["等待或失败"]
    D --> F["释放许可"]

Demo:

java
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:

java
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

分布式闭锁适合多个服务实例等待某些前置任务完成,再一起继续执行。

mermaid
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:

java
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 是很多人学分布式锁会漏掉的关键点。它解决的是:旧锁持有者在锁过期后恢复执行,继续写下游资源。

流程:

mermaid
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:

java
Long token = redisTemplate.opsForValue().increment("lock:token:collect:" + batchNo);

数据库条件写入:

sql
update t_collect_batch
set status = 'RUNNING',
    lock_token = :newToken
where batch_no = :batchNo
  and lock_token < :newToken
  and status = 'WAITING';

Java 使用方式:

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 要由外部单调递增组件生成:

  1. UUID 只能证明“是不是自己”,不能比较新旧。
  2. 递增 token 能让下游资源拒绝旧持有者。
  3. 核心资源最终要在数据库侧判断版本,而不是只相信应用内判断。

状态机和幂等

锁失效后,真正兜底的是状态机和幂等。

以订单支付回调为例:

mermaid
flowchart TD
    A["收到支付回调"] --> B["根据支付流水查订单"]
    B --> C{"订单是否待支付"}
    C -- "否" --> D["幂等返回成功"]
    C -- "是" --> E["条件更新为已支付"]
    E --> F{"更新行数是否为 1"}
    F -- "否" --> D
    F -- "是" --> G["发送后续消息"]

SQL:

sql
update t_order
set status = 'PAID',
    paid_time = now()
where order_no = ?
  and status = 'WAIT_PAY';

这条 SQL 本身就是强约束。即使两个实例同时处理回调,也只有一个能更新成功。

分布式锁在这里的价值是降低并发冲突和重复处理概率,但最终正确性靠状态条件。

数据库锁和唯一约束

不是所有并发问题都要上 Redis 锁。数据库本身也能处理很多一致性问题。

唯一约束防重复

sql
create unique index uk_payment_trade_no
on t_payment(trade_no);

插入支付流水:

java
try {
    paymentMapper.insert(payment);
} catch (DuplicateKeyException ex) {
    return paymentMapper.findByTradeNo(payment.getTradeNo());
}

适合防止重复创建数据,例如支付流水、幂等请求号、业务单号。

乐观锁防覆盖

sql
update t_product
set stock = stock - 1,
    version = version + 1
where id = ?
  and stock > 0
  and version = ?;

适合库存、配置版本、状态流转。它不阻塞别人,只在提交时判断版本是否仍然匹配。

悲观锁

sql
select *
from t_order
where order_no = ?
for update;

适合一个数据库事务内强制串行修改同一行,但不适合跨服务、长流程、远程调用,因为数据库连接和锁会被长时间占用。

ZooKeeper 分布式锁

ZooKeeper 常用临时顺序节点实现分布式锁。

流程:

mermaid
flowchart TD
    A["客户端创建临时顺序节点"] --> B["读取同目录所有节点"]
    B --> C{"自己是否最小节点"}
    C -- "是" --> D["获得锁"]
    C -- "否" --> E["监听前一个节点"]
    E --> F["前一个节点删除"]
    F --> B
    D --> G["业务完成删除节点"]

为什么监听前一个节点,而不是所有客户端都监听最小节点:

  1. 避免羊群效应。
  2. 当前一个节点释放时,只唤醒下一个等待者。
  3. 更接近排队获取锁。

Curator Demo:

java
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。

mermaid
flowchart TD
    A["申请 lease"] --> B["创建锁 key"]
    B --> C["获得 revision"]
    C --> D["执行业务"]
    D --> E["续租或释放 lease"]
    C --> F["revision 可作 Fencing Token"]

Go 伪代码:

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 的消息路由到同一个队列或同一个分区,让消费者顺序处理。

mermaid
flowchart TD
    A["订单操作消息"] --> B["按 orderNo 分区"]
    B --> C["同一订单进入同一分区"]
    C --> D["单消费者顺序处理"]
    D --> E["避免大量线程抢锁"]

例如 Kafka 按订单号作为 key:

java
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商品锁热点商品可能竞争严重

设计原则:

  1. 锁住真正共享的业务资源。
  2. 不要为了省事使用全局锁。
  3. 热点资源要考虑队列化、分片、限流,而不是无限加锁。
  4. key 必须包含租户、业务类型、资源 ID,避免误锁。

推荐命名:

text
lock:{业务}:{资源类型}:{资源ID}
lock:order:pay:202607020001
lock:collect:batch:BATCH20260702001
lock:cache:product:1001

监控和排查

分布式锁上线后必须能观测,否则出了问题只能猜。

核心指标:

指标说明异常含义
加锁成功率成功次数 / 尝试次数竞争激烈或 Redis 异常
等待时间tryLock 等待多久锁粒度过大或业务太慢
持有时间拿到锁后执行多久锁内逻辑过重
续期次数看门狗续期频率业务耗时长或线程卡住
失败原因超时、异常、未持有锁定位连接或业务问题

日志建议:

java
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);

排查流程:

mermaid
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 个任务并发RSemaphoreN 把独立锁
控制面选主ZooKeeper 或 etcd业务 Redis 热点锁
高并发同 key 串行处理MQ 分区顺序消费大量线程抢同一把锁

刷题复习入口

本页负责讲清楚锁扩展能力、适用边界、Demo 和选型。需要快速复习答题结构时,跳到 Redis 面试知识点 的 Redisson 和锁扩展点部分。

关联知识点

本章小结

分布式锁扩展点的核心不是“组件 API 越多越好”,而是根据业务风险选择合适的并发控制方式。普通 Redis 锁适合高性能互斥,Redlock 降低单点风险,Redisson 提供可重入、续期、读写锁、信号量等工程能力,ZooKeeper 和 etcd 更适合一致性协调,数据库约束和状态机负责最终正确性。真正成熟的方案,一定会把锁、幂等、状态机、唯一约束、监控和补偿组合起来。