|

Redis 分布式锁:从原理到生产实践

本文系统梳理 Redis 分布式锁的完整知识体系,从基础概念到手写实现,再到 Redisson 生产级方案,涵盖锁误删、原子性保障、Lua 脚本、可重入锁、WatchDog 看门狗、MultiLock 联锁等核心知识点。


分布式锁概述

为什么需要分布式锁

Java 中的 Synchronized 关键字只能保证单个 JVM 内部的线程互斥。当应用部署为集群模式时,多个 JVM 进程各自拥有独立的锁监视器,Synchronized 就失效了。

分布式锁的核心思路:使用 JVM 外部的锁监视器,让所有 JVM 进程共享同一把锁,从而实现多进程间的互斥。

核心特性

层级 特性 说明
核心 多进程可见 多个 JVM 进程都能访问同一个锁
核心 互斥 同一时刻只有一个线程能获取锁
重要 高可用 大多数情况下获取锁应成功
重要 高并发 获取锁的动作不能影响业务性能
重要 安全性 异常情况下锁能及时释放
功能 可重入性 / 阻塞与非阻塞 / 公平与非公平 根据业务场景选择

三种实现方案对比

对比维度 MySQL Redis Zookeeper
互斥原理 数据库事务 + 互斥锁 SET NX(key 不存在才成功) 有序临时节点,最小 ID 获取锁
高可用 主从模式,可用性尚可 主从/集群模式,可用性好 集群模式,可用性好
高性能 性能一般 性能最优 强一致性同步开销大,弱于 Redis
安全性 连接断开自动释放 需设置过期时间防死锁 临时节点,连接断开自动释放

综合性能与易用性,Redis 是目前最主流的分布式锁实现方案


手写 Redis 分布式锁

基本原理

获取锁:

SET key value NX EX seconds
  • NX:只有 key 不存在时才设置,保证互斥
  • EX seconds:设置过期时间,防止服务宕机导致死锁
  • 两者合并为一条原子命令,避免 SETNX 成功后尚未 EXPIRE 就宕机的问题

释放锁:

DEL key

获取锁的两种模式:

  • 阻塞式:获取失败后循环等待,直到锁释放(CPU 浪费大,实现复杂)
  • 非阻塞式:只尝试一次,成功返回 true,失败立即返回 false

Java 实现

锁接口定义:

public interface ILock {
    boolean tryLock(long timeoutSec);  // 非阻塞式尝试获取锁
    void unlock();                      // 释放锁
}

SimpleRedisLock 实现:

public class SimpleRedisLock implements ILock {
    private StringRedisTemplate stringRedisTemplate;
    private String name;
    private static final String KEY_PREFIX = "lock:";

    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

业务使用示例(一人一单场景):

// 锁范围 = 用户ID,粒度越细并发效率越高
SimpleRedisLock lock = new SimpleRedisLock(redisTemplate, "order:" + userId);

if (!lock.tryLock(1200)) {
    return Result.fail("不允许重复下单");
}
try {
    // 执行业务逻辑(查询订单、扣减库存、创建订单)
} finally {
    lock.unlock();
}

注意: 锁的 key 应与业务标识相关,锁范围尽量小(如按用户 ID 区分),以提高并发效率。


锁安全性问题与解决方案

问题一:锁误删

场景复现:

  1. 线程1 获取锁成功,开始执行业务
  2. 线程1 业务阻塞 → 锁超时自动释放(EXPIRE 到期)
  3. 线程2 获取锁成功,开始执行业务
  4. 线程1 恢复执行 DEL误删了线程2 的锁!
  5. 线程3 趁虚而入 → 并发安全被破坏

根因: 释放锁时没有验证”这把锁是不是自己的”。

解决方案 — 加入线程标识:

在 value 中存入全局唯一的线程标识(UUID + 线程ID),释放前先比对:

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    Boolean success = stringRedisTemplate.opsForValue()
        .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

@Override
public void unlock() {
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    if (threadId.equals(lockId)) {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

使用 UUID + 线程ID 而非单纯线程ID,是因为不同 JVM 中的线程ID 可能重复。

问题二:判断与释放的原子性

即使加了标识判断,仍有极端情况:

  1. 线程1 判断锁标识 → 确认是自己的 ✓
  2. 此时 JVM 发生 GC 停顿
  3. 停顿期间锁超时释放 → 线程2 获取锁成功
  4. GC 结束 → 线程1 继续执行 DEL又误删了线程2 的锁!

根因: “判断标识”和”删除锁”是两步操作,不具备原子性。

为什么 Redis 事务不能解决? Redis 事务无法在执行过程中获取查询结果来做条件判断,无法实现”先查询再决策”的逻辑。


Lua 脚本:实现原子性释放

Lua 语言基础

Lua 是一种轻量级脚本语言,Redis 内置了 Lua 解释器。核心语法要点:

  • 变量声明用 local,弱类型
  • 流程控制:if ... then ... end
  • 数组下标从 1 开始

在 Redis 中调用 Lua

EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 name rose
  • KEYS[]:Key 类型参数
  • ARGV[]:Value 等其他参数
  • 中间的数字 1 表示 KEYS 的个数

原子性释放锁的 Lua 脚本

-- unlock.lua
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
    return redis.call('DEL', KEYS[1])
end
return 0

判断和删除在同一个 Lua 脚本中执行,Redis 保证脚本执行的原子性。

Java 集成

public class SimpleRedisLock implements ILock {
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),  // KEYS
            ID_PREFIX + Thread.currentThread().getId()      // ARGV
        );
    }
}

RedisTemplate.execute() 参数:脚本对象、KEYS 列表、ARGV 可变参数。

至此,手写版分布式锁已具备:互斥 + 超时释放 + 标识校验 + 原子性释放,可以覆盖大多数简单场景。


手写锁的遗留问题

问题 说明
不可重入 同一线程无法多次获取同一把锁(方法 A 调用方法 B,两者都需要锁 → 死锁)
不可重试 获取失败立即返回 false,没有等待重试机制
超时释放 过期时间设太短业务没做完,设太长影响并发
主从一致性 主节点获取锁后未同步到从节点就宕机 → 锁丢失

这些问题需要更成熟的框架来解决 → Redisson


Redisson:生产级分布式锁框架

简介

Redisson 是基于 Redis 的 Java 驻内存数据网格,提供丰富的分布式工具集,包括:可重入锁、公平锁、联锁、红锁、读写锁、信号量等。

官方地址:https://github.com/redisson/redisson

快速入门

引入依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

配置客户端:

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://192.168.150.101:6379")
            .setPassword("123321");
        return Redisson.create(config);
    }
}

使用分布式锁:

@Resource
private RedissonClient redissonClient;

RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();

if (!isLock) {
    return Result.fail("不允许重复下单");
}
try {
    // 执行业务
} finally {
    lock.unlock();
}

tryLock() 参数:

参数 说明 默认值
waitTime 最大等待时间 -1(不等待)
leaseTime 锁自动释放时间 -1(启用看门狗,默认 30 秒)
unit 时间单位

Redisson 核心特性详解

可重入锁

核心思路: 使用 Hash 结构替代 String 结构,记录重入次数。

数据结构 Key Field Value
Hash 锁名称 线程标识 重入次数

原理与 JDK 的 ReentrantLock 一致:每次获取 count++,每次释放 count–,减到 0 时才真正删除锁。

获取锁流程(Lua 脚本):

判断锁是否存在 (EXISTS key)
├─ 不存在 → 首次获取
│   HSET key threadId 1        # 重入次数 = 1
│   EXPIRE key releaseTime     # 设置过期时间
│   return nil(成功)
│
└─ 存在 → 判断线程标识 (HEXISTS key threadId)
    ├─ 匹配 → 重入!
    │   HINCRBY key threadId 1   # 重入次数 +1
    │   EXPIRE key releaseTime   # 重置过期时间
    │   return nil(成功)
    │
    └─ 不匹配 → 别人的锁
        return PTTL key(返回剩余有效期,获取失败)

释放锁流程(Lua 脚本):

HEXISTS key threadId
├─ 不存在 → 锁已释放/超时,return nil
│
└─ 存在
    HINCRBY key threadId -1       # 重入次数 -1
    判断重入次数 > 0?
    ├─ 是 → 还有外层调用持有锁
    │   EXPIRE key releaseTime    # 续期
    │   return 0
    └─ 否 → 最外层调用释放
        DEL key
        PUBLISH channel msg       # 通知等待者
        return 1

锁重试机制(Pub/Sub)

Redisson 利用 Redis 的发布/订阅机制实现高效的锁等待,避免 while(true) 盲轮询:

首次尝试获取锁
├─ 成功 (TTL=nil) → 返回 true
└─ 失败 (TTL=剩余时间)
    检查等待时间是否耗尽
    ├─ 耗尽 → 返回 false
    └─ 还有时间
        SUBSCRIBE 订阅锁释放信号(节省 CPU)
        等待信号(设超时上限)
        ├─ 收到信号 → 再次尝试获取(循环)
        └─ 超时 → 返回 false

巧妙之处: 释放锁时 PUBLISH,等待者 SUBSCRIBE,收到信号才重试,对 CPU 非常友好。

WatchDog 看门狗机制

解决”锁超时释放但业务还没做完”的难题。

触发条件: leaseTime == -1(未手动指定锁释放时间)。

工作原理:

  • 默认锁超时时间:30 秒lockWatchdogTimeout
  • 获取锁成功后,启动定时任务,每 10 秒(30 ÷ 3)续约一次:
if (redis.call('HEXISTS', key, threadId) == 1) then
    redis.call('EXPIRE', key, 30)  -- 重置有效期
end
  • 递归调用自身,循环续约 → 只要业务在执行,锁就不会过期
  • 释放锁时,取消定时任务(从 ConcurrentHashMap 中移除 entry,cancel timeout)

如果手动指定了 leaseTime,看门狗不会启动,锁到期后自动释放。

MultiLock 联锁

解决 Redis 主从模式下的一致性问题。

问题场景: 主节点获取锁成功 → 尚未同步给从节点 → 主节点宕机 → 新主节点上没有锁信息 → 锁失效。

MultiLock 方案:

  • 不使用主从架构,所有节点都是独立的 Redis 实例
  • 获取锁时:必须在所有节点上都获取成功才算成功
  • 释放锁时:释放所有节点上的锁

配置多个独立 Redis 节点:

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient1() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClient2() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6380");
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClient3() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6381");
        return Redisson.create(config);
    }
}

使用联锁:

RLock lock1 = redissonClient1.getLock("lock:order:" + userId);
RLock lock2 = redissonClient2.getLock("lock:order:" + userId);
RLock lock3 = redissonClient3.getLock("lock:order:" + userId);

RLock multiLock = redissonClient1.getMultiLock(lock1, lock2, lock3);

multiLock.tryLock();
try {
    // 执行业务
} finally {
    multiLock.unlock();
}

方案对比与实践建议

三种分布式锁方案总览

特性 手写 Redis 锁 Redisson 可重入锁 Redisson MultiLock
数据结构 String(SET NX EX) Hash 多个独立 Hash
可重入 ✓(重入计数器)
重试机制 ✓(Pub/Sub + 信号量)
超时处理 EXPIRE 兜底 WatchDog 自动续约 WatchDog 自动续约
误删防护 Lua 脚本判断标识 Lua 脚本判断标识 Lua 脚本判断标识
主从一致性 ✓(独立节点无主从)
适用场景 学习理解原理 生产环境首选 高安全性要求场景

Redisson 解决的核心问题

问题 解决方案
不可重入 Hash 结构 + 重入计数器
不可重试 Pub/Sub 信号量,收到释放信号后才重试
超时释放 WatchDog 每 10 秒续约一次(默认 30 秒有效期)
主从一致性 MultiLock 联锁,所有独立节点都获取成功才算成功

实践建议

  • 大多数生产场景:直接使用 Redisson 可重入锁
  • 高安全性要求:使用 Redisson MultiLock(建议至少 3 个独立节点)
  • 面试准备重点:理解手写锁的演进过程(基础实现 → 标识校验 → Lua 原子性 → Redisson 高级特性)
评论交流

文章目录