Skip to content

Redis 分布式锁 ​

Redis 分布式锁用于解决多个进程、多个服务实例同时操作同一份资源的问题。例如防止多个实例同时执行定时任务、防止缓存击穿时多个请求同时查数据库、防止重复提交。

分布式锁的目标是互斥,但它不能替代数据库唯一约束、幂等和业务状态机。

为什么本地锁不够 ​

在单个 JVM 中,synchronized 或 ReentrantLock 可以保护共享资源。但服务部署多个实例后,每个实例都有自己的内存锁,彼此不知道对方已经加锁。

mermaid
flowchart TD
    A["实例 A 本地锁"] --> C["共享资源"]
    B["实例 B 本地锁"] --> C
    C --> D["两个实例可能同时执行"]

所以需要一个所有实例都能访问的外部协调点,Redis 就是常见选择。

正确加锁流程 ​

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

bash
SET lock:product:1001 request-uuid NX PX 30000

含义:

部分说明
lock:product:1001锁 key
request-uuid当前请求唯一标识
NXkey 不存在时才写入
PX 3000030 秒后自动过期

为什么释放锁要用 Lua ​

错误做法:

bash
DEL lock:product:1001

如果当前线程的锁已经过期,另一个线程刚好加锁成功,此时直接 DEL 可能删掉别人的锁。

正确做法是先判断 value 是否属于自己,再删除。判断和删除必须原子执行:

lua
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

Java 伪代码 ​

java
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 示例 ​

java
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 时自动续期。

mermaid
flowchart TD
    A["线程获取锁"] --> B["业务仍在执行"]
    B --> C["看门狗定期续期"]
    C --> D{"业务是否结束"}
    D -- "否" --> C
    D -- "是" --> E["释放锁"]

注意:如果指定了明确的 leaseTime,通常不会启用默认看门狗续期,要根据业务耗时谨慎设置。

Redisson 锁的底层结构 ​

Redisson 的可重入锁不是简单地 SET key value。它通常会用 Redis Hash 记录“哪个客户端线程持有锁”和“重入次数”。

可以简化理解为:

text
key = lock:product:1001
field = clientId:threadId
value = 重入次数

第一次加锁:

text
HSET lock:product:1001 clientA:thread-1 1
PEXPIRE lock:product:1001 30000

同一个线程再次加锁:

text
HINCRBY lock:product:1001 clientA:thread-1 1
PEXPIRE lock:product:1001 30000

释放一次锁:

text
HINCRBY lock:product:1001 clientA:thread-1 -1

只有重入次数归零,才真正删除锁 key 并发布解锁消息。

mermaid
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 会结合等待时间、订阅解锁消息等机制,减少无意义自旋。

简化流程:

mermaid
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 会定期续期,常见节奏可以理解为每隔锁超时时间的三分之一续一次。

mermaid
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 主从复制默认是异步的。极端情况下可能发生:

  1. 客户端 A 在 Master 加锁成功。
  2. 锁还没复制到 Slave。
  3. Master 宕机。
  4. Slave 被提升为新 Master。
  5. 客户端 B 在新 Master 上也加锁成功。
mermaid
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,才认为加锁成功。

mermaid
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 串行消费替代锁等问题。

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

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

sql
update t_collect_task
set status = 'RUNNING',
    lock_token = :newToken
where task_id = :taskId
  and lock_token < :newToken;

这样即使旧锁持有者“复活”,也不能覆盖新持有者的状态。

锁、幂等、状态机要一起用 ​

分布式锁只能降低并发进入临界区的概率,不能替代业务正确性。

以采集批次处理为例:

java
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 应该带状态条件:

sql
update t_collect_batch
set status = 'RUNNING'
where batch_no = ?
  and status = 'WAITING';

只有更新成功的线程才真正处理。这样即使锁异常失效,数据库状态机仍然能兜底。

常见使用场景 ​

场景是否适合
防止缓存击穿时重复重建热点缓存适合
多实例定时任务抢占执行权适合
防止同一订单重复处理可用,但要配合幂等和状态机
银行扣款最终一致保障不应只依赖 Redis 锁
替代数据库唯一索引不适合

常见问题 ​

问题后果解决方式
没有过期时间服务宕机后死锁SET NX PX
直接删除锁可能删掉别人的锁Lua 校验 value
业务执行超过过期时间多实例并发执行设置合理过期或自动续期
锁粒度过大吞吐下降按业务资源设计 key
Redis 主从切换极端情况下锁可靠性下降核心交易用数据库约束兜底

练习 ​

  1. 为“重建商品缓存”设计一个锁 key。
  2. 解释为什么 DEL key 释放锁不安全。
  3. 写出 SET key value NX PX 中每个参数的作用。
  4. 思考业务执行 60 秒但锁 30 秒过期会发生什么。
  5. 判断“防重复下单”是否只靠 Redis 锁就足够,并说明原因。

小结 ​

Redis 分布式锁必须具备四点:互斥、过期时间、唯一 value、Lua 安全释放。Redisson 能简化实现,但不能替代业务幂等、数据库唯一约束和状态机。越核心的业务,越要有多层兜底。