用redis来实现锁的方式

基于 Redis 实现分布式锁,在 Java 面试中通常被归纳为三种方式:手写基础版、Lua 脚本增强版,以及使用 Redisson 框架。

这几种方式本质上是递进的,核心都是在解决手写实现中存在的原子性、锁续期和可重入等痛点。

🔧 方式一:基于 SETNX + EXPIRE(基础版)

这是最原始的写法,利用 Redis 的 SETNX(不存在才设置)命令实现互斥。

  • 核心思路:用 SETNX 抢占锁,成功后用 EXPIRE 设置超时,防止宕机死锁。
  • 关键问题:SETNX 和 EXPIRE 是两条命令,不是原子操作。如果设置锁成功后,设置过期时间前服务宕机,锁将永远无法释放,导致死锁。

⚙️ 方式二:基于 SET NX EX + Lua 脚本(原子增强版)

为了解决方式一的原子性问题,Redis 2.6.12 之后支持了带 NX 和 EX 参数的 SET 命令,并配合 Lua 脚本解决误删问题。

  • 加锁改进:使用 SET lockKey uniqueValue EX 30 NX,一条命令完成“不存在则设置”和“设置过期时间”,保证原子性。
  • 解锁改进:释放锁时不能直接 DEL。因为可能存在线程 A 业务超时导致锁过期,线程 B 拿到锁后,线程 A 业务执行完误删了 B 的锁。解决方法是把 uniqueValue(如 UUID+线程ID)作为锁的值,释放前通过 Lua 脚本判断值是否匹配,匹配才删除,保证“判断+删除”的原子性。

🚀 方式三:基于 Redisson 框架(企业级工业版)

手写方案仍然难以解决业务超时导致锁提前释放以及可重入等问题。在实际生产环境或面试中,Redisson 是更高级的答案。

  • 看门狗(Watch Dog)机制:这是 Redisson 解决锁续期的核心。当获取锁且未指定超时时间时,Redisson 会启动一个后台线程,默认每隔 10秒(约锁超时时间的 1/3)检查一次业务是否执行完毕。若未完毕,则自动延长锁的过期时间,有效防止业务未完成时锁自动失效。
  • 可重入性:利用 Redis 的 Hash 结构存储线程 ID 和重入次数。同一线程再次获取锁时,重入次数加 1,而不是被阻塞。
  • 重试机制:底层利用信号量(Semaphore)和发布订阅(PubSub)功能,在获取锁失败时等待其他线程释放锁并唤醒自己,实现等待重试。

📊 三种方式对比总结

特性 基础版 (SETNX + EXPIRE) 原子增强版 (SET NX EX + Lua) Redisson 框架
加锁原子性 ❌ 非原子 ✅ 原子(单命令) ✅ 原子(Lua 封装)
防误删 ❌ 无法防止 ✅ 通过 Lua 校验 uniqueValue ✅ 内部封装
锁续期(看门狗) ❌ 无 ❌ 无 ✅ 自动续期
可重入 ❌ 不支持 ❌ 不支持 ✅ 支持
获取锁失败重试 ❌ 直接失败 ❌ 直接失败 ✅ 支持(带等待)
适用场景 仅学习原理 简单临时场景 生产环境首选

在面试时,建议这样回答:“主要分三种,最基础的是用 SETNX 和 EXPIRE 组合,但有原子性问题。改进版是用 SET NX EX 加 Lua 脚本保证原子释放,但依然无法解决业务超时和可重入。所以生产上通常直接用 Redisson,它的看门狗机制能自动续期,还支持可重入和重试,是目前的工业标准。”

Redis 分布式锁"误删锁"问题 Demo

我用生活化的比喻 + 完整可运行代码,帮你彻底搞懂这个问题。


一、先讲个故事(理解问题本质)

假设你去公共厕所,门上有一把锁:

  • 锁 = Redis 里的 lockKey
  • 钥匙 = uniqueValue(唯一标识)
  • 30秒自动开锁 = 锁的过期时间

事故现场:

1. 张三(A) 进厕所,上锁,钥匙上写"张三的钥匙" —— 但张三拉肚子,30秒没出来
2. 30秒到,锁自动开了(张三还在里面!)
3. 李四(B) 进来,上锁,钥匙写"李四的钥匙"
4. 张三终于出来了,看都没看,直接把门上的锁拆了 —— 拆的是李四的锁!
5. 王五(C) 推门进来 —— 厕所里李四还在,乱套了

核心问题:张三拆锁时,没确认这把锁是不是自己的。

解决方案:拆锁前先看钥匙上写的是不是自己的名字,确认是自己的才拆。而且"看+拆"必须一次做完(不能看的时候是张三的,手伸过去的瞬间变成李四的)。

这就是 uniqueValue + Lua 脚本 的作用。


二、错误示范(会误删别人的锁)

public class WrongLock {

    // ❌ 错误:直接删除,不管锁是不是自己的
    public static void unlock(StringRedisTemplate redis, String lockKey) {
        redis.delete(lockKey);  // 危险!!!
    }
}

触发场景:

时间 线程 A 线程 B Redis 里的锁
T0 加锁成功(超时5秒) 锁=A
T1 业务卡住(超过5秒) 锁=A
T6 还在执行... 锁过期,自动删除
T6 加锁成功 锁=B
T7 执行完毕,调用 delete 锁被A删了!(B的锁)
T8 还在执行 锁=空
T8 线程C可以加锁成功,B和C同时执行 💥

三、正确实现(uniqueValue + Lua)

1. 加锁:SET NX EX + 唯一值

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.UUID;

@Component
public class RedisLock {

    private final StringRedisTemplate redis;

    public RedisLock(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /**
     * 尝试加锁
     * @param lockKey   锁的key,如 "lock:order:123"
     * @param expireSec 过期时间(秒)
     * @return 成功返回 uniqueValue,失败返回 null
     */
    public String tryLock(String lockKey, long expireSec) {
        // 生成唯一标识:UUID + 线程ID,保证每个线程的值不同
        String uniqueValue = UUID.randomUUID() + ":" + Thread.currentThread().getId();

        // 一条命令完成"不存在才设置"+"设置过期",原子操作
        Boolean success = redis.opsForValue()
                .setIfAbsent(lockKey, uniqueValue, expireSec, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(success) ? uniqueValue : null;
    }

    /**
     * 解锁:先判断是不是自己的锁,是才删除
     * 用 Lua 保证"判断+删除"的原子性
     */
    public boolean unlock(String lockKey, String uniqueValue) {
        // Lua 脚本:值匹配才删除,返回1;不匹配返回0
        String luaScript =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "    return redis.call('del', KEYS[1]) " +
                "else " +
                "    return 0 " +
                "end";

        Long result = redis.execute(
                new DefaultRedisScript<>(luaScript, Long.class),
                Collections.singletonList(lockKey),
                uniqueValue  // 传入自己加锁时的唯一值
        );

        return result != null && result == 1L;
    }
}

⚠️ 注意:需要 import java.util.concurrent.TimeUnit;


2. 为什么必须用 Lua?

因为 "GET 判断" 和 "DEL 删除" 是两步操作,中间可能被打断:

线程A: GET lockKey → 值是我的 (判断通过) ✅
          ↓ 【此刻锁刚好过期,B加锁成功,锁变成B的】
线程A: DEL lockKey → 删掉的是B的锁!❌

Lua 脚本在 Redis 中是单线程原子执行的,"判断"和"删除"之间不会被其他命令插入,从根本上杜绝了这个问题。


3. 业务代码使用示例

@Service
public class OrderService {

    @Autowired
    private RedisLock redisLock;

    public void createOrder(Long orderId) {
        String lockKey = "lock:order:" + orderId;
        String uniqueValue = redisLock.tryLock(lockKey, 30);

        if (uniqueValue == null) {
            throw new RuntimeException("操作太频繁,请稍后再试");
        }

        try {
            // ===== 真正的业务逻辑 =====
            doBusiness(orderId);
        } finally {
            // 无论如何都要尝试释放锁,但只会释放自己的
            redisLock.unlock(lockKey, uniqueValue);
        }
    }

    private void doBusiness(Long orderId) {
        System.out.println("处理订单:" + orderId);
    }
}

四、完整可运行的测试 Demo

用 main 方法模拟"业务超时导致锁过期"的场景:

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class LockDemo {

    static StringRedisTemplate redis;

    public static void main(String[] args) throws Exception {
        // 1. 初始化 Redis 连接(本地 Redis 默认端口 6379)
        LettuceConnectionFactory factory = new LettuceConnectionFactory("localhost", 6379);
        factory.afterPropertiesSet();
        redis = new StringRedisTemplate(factory);
        redis.afterPropertiesSet();

        String lockKey = "lock:order:1001";

        // ================= 场景1:正常释放 =================
        System.out.println("===== 场景1:线程A正常执行并释放 =====");
        String valueA = tryLock(lockKey, 30);
        System.out.println("A 加锁: " + valueA);
        boolean r1 = unlock(lockKey, valueA);
        System.out.println("A 解锁结果(应为true): " + r1 + "\n");

        // ================= 场景2:误删演示 =================
        System.out.println("===== 场景2:模拟业务超时,锁自动过期后被B拿到 =====");
        String valueA2 = tryLock(lockKey, 2);  // A 只锁2秒
        System.out.println("A 加锁(2秒过期): " + valueA2);

        System.out.println("A 业务执行中,休眠3秒导致锁过期...");
        Thread.sleep(3000);  // 模拟A业务超时,锁自动过期

        String valueB = tryLock(lockKey, 30);  // B 此时能拿到锁
        System.out.println("B 加锁成功: " + valueB);

        // A 执行完,尝试解锁 —— 关键点来了
        boolean r2 = unlock(lockKey, valueA2);
        System.out.println("A 解锁结果(应为false,因为锁是B的): " + r2);

        // 验证 B 的锁还在
        String currentValue = redis.opsForValue().get(lockKey);
        System.out.println("当前锁的值: " + currentValue);
        System.out.println("B的锁是否安然无恙: " + valueB.equals(currentValue) + "\n");

        // 清理
        unlock(lockKey, valueB);
        factory.destroy();
    }

    /** 加锁 */
    static String tryLock(String key, long expireSec) {
        String value = UUID.randomUUID() + ":" + Thread.currentThread().getId();
        Boolean ok = redis.opsForValue().setIfAbsent(key, value, expireSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(ok) ? value : null;
    }

    /** 解锁:Lua 保证原子性 */
    static boolean unlock(String key, String value) {
        String lua =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "  return redis.call('del', KEYS[1]) " +
                "else return 0 end";
        Long result = redis.execute(
                new DefaultRedisScript<>(lua, Long.class),
                Collections.singletonList(key), value);
        return result != null && result == 1L;
    }
}

运行结果:

===== 场景1:线程A正常执行并释放 =====
A 加锁: 8a3f...:1
A 解锁结果(应为true): true

===== 场景2:模拟业务超时,锁自动过期后被B拿到 =====
A 加锁(2秒过期): 5b2c...:1
A 业务执行中,休眠3秒导致锁过期...
B 加锁成功: 9d1e...:1
A 解锁结果(应为false,因为锁是B的): false    ← 关键!A没删掉B的锁
当前锁的值: 9d1e...:1
B的锁是否安然无恙: true

看结果就懂了:A 虽然执行完了想去删锁,但因为锁已经是 B 的了,Lua 判断值不匹配,拒绝删除,B 的锁安全了。✅


五、Maven 依赖(Spring Boot 项目)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.yml:

spring:
  redis:
    host: localhost
    port: 6379

六、一图总结

加锁:SET lockKey uniqueValue NX EX 30
      └─ uniqueValue = UUID + 线程ID,标识"这是谁的锁"

解锁:Lua 脚本
      ├─ 判断:GET lockKey == 我的 uniqueValue ?
      │         ├─ 是 → DEL lockKey(只删自己的)✅
      │         └─ 否 → 什么都不做(别人的锁不能碰)✅
      └─ 整个过程原子执行,中间不会被插入

七、温馨提示(面试加分点)

这套手写方案虽然解决了误删,但仍有 2 个没解决的问题,正是 Redisson 存在的意义:

  1. 业务超时问题:A 业务执行超过 30 秒,锁还是会过期 → 需要看门狗自动续期
  2. 可重入问题:同一线程再次加锁会失败 → 需要 Hash 结构记录重入次数

所以面试时可以这样收尾:

"手写版用 uniqueValue + Lua 解决了误删,但业务超时和可重入还得自己处理,生产上直接上 Redisson 更省心。"

这样回答,既有原理深度,又有工程视野。

posted @ 2026-10-10 08:21  ~码铃薯~  阅读(15)  评论(0)    收藏  举报