Skip to content

Redis 分布式锁

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

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

为什么本地锁不够

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

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 能简化实现,但不能替代业务幂等、数据库唯一约束和状态机。越核心的业务,越要有多层兜底。