Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)

Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)

在单体架构中,我们习惯使用 synchronizedLock 来解决并发安全问题。但在分布式集群架构下,不同的服务运行在不同的 JVM 中,本地锁也就失效了。

本文将复现如何基于 Redis 实现一个分布式锁,并一步步解决死锁误删原子性等经典问题。

一、 初级版本:利用 SETNX 实现互斥

Redis 的 SETNX (Set if Not Exists) 命令天生具备互斥性:只有 Key 不存在时才能设置成功。

为了防止获取锁的服务器宕机导致锁永远无法释放(死锁),我们需要在使用 SETNX 的同时设置过期时间(TTL)。

核心命令:

vbnet 复制代码
SET lock:key threadId NX EX 10

注意:必须保证 SETNX 和 EXPIRE 是原子操作,不能分成两条命令执行。

Java 代码实现

定义一个 SimpleRedisLock 类,实现基础的加锁和解锁逻辑。

java 复制代码
public class SimpleRedisLock implements ILock {

    private String name; // 锁的业务名称
    private StringRedisTemplate stringRedisTemplate; // Redis操作工具

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

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程ID作为标识
        long threadId = Thread.currentThread().getId();
        // 执行 SET lock:name threadId NX EX timeout
        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);
    }
}

二、 进阶版本:解决"误删"问题

初级版本存在一个严重的隐患:如果业务执行时间超过了锁的过期时间,会发生什么?

1. 事故场景还原

假设锁的有效期是 10s,但业务执行了 15s:

  1. 线程 A 获取锁,开始执行业务。
  2. 10s 后,Redis 锁自动过期释放。
  3. 线程 B 尝试获取锁,成功拿到(因为 A 的锁没了)。
  4. 15s 后 ,线程 A 业务执行完毕,执行 unlock(),直接删除了 Key。
  5. 问题出现 :线程 A 删掉的其实是 线程 B 正在持有的锁!
  6. 此时 线程 C 进来,发现没锁,直接加锁。导致 B 和 C 并发执行,互斥失效。

2. 解决方案:给锁加上"身份证"

为了遵循"解铃还须系铃人"的原则,我们需要在解锁时判断:这把锁是不是我的?

  • 改进 Value:单用线程 ID 在集群下可能重复,我们需要拼接一个 JVM 的唯一标识(UUID)。
  • 改进 unlock:删除前先查询 Value,判断是否与自己一致。

3. 代码升级

java 复制代码
import cn.hutool.core.lang.UUID;

public class SimpleRedisLock implements ILock {
    // ... 构造方法同上 ...

    // 生成 JVM 唯一的 UUID 前缀
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 拼接 UUID + 线程 ID
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 存入 Redis
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 1. 获取当前线程的标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 2. 获取 Redis 中锁的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 3. 判断是否一致
        if (threadId.equals(id)) {
            // 4. 一致才删除
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

三、 终极版本:Lua 脚本保证原子性

上面的 Java 代码解决了"误删"的大部分场景,但在极端并发下依然有漏洞。

1. 原子性漏洞

在 unlock 方法中,"判断锁标识" 和 "删除锁" 是两个动作。

如果线程 A 判断成功(是自己的锁),正准备删除时,系统发生了 GC 停顿(Stop The World)或者网络阻塞。

恰好在这段时间内,锁过期了,线程 B 抢到了锁。

等线程 A 恢复运行,它不会再次判断,而是直接执行 delete,结果还是把 B 的锁给删了。

2. 解决方案:Lua 脚本

Redis 提供了 Lua 脚本功能,可以将多条命令作为一个整体执行,中间不会被其他命令插入,从而保证了原子性。

编写 Lua 脚本 (unlock.lua):

lua 复制代码
-- KEYS[1] 是锁的 key
-- ARGV[1] 是当前线程的标识
if redis.call('get', KEYS[1]) == ARGV[1] then
    -- 标识一致,执行删除
    return redis.call('del', KEYS[1])
else
    -- 不一致,返回 0
    return 0
end

3. 代码最终形态

我们需要预加载 Lua 脚本,并使用 execute 方法调用。

java 复制代码
public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;
    // 静态代码块预加载 Lua 脚本
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 脚本文件位置
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

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

    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() {
        // 调用 Lua 脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name), // KEYS[1]
                ID_PREFIX + Thread.currentThread().getId()    // ARGV[1]
        );
    }
}

四、 总结

手写 Redis 分布式锁是一个非常好的学习过程,经历了三个阶段:

  1. 基础版 :利用 SETNX 实现互斥,EX 防止死锁。
  2. 改进版 :利用 UUID + ThreadID 防止锁超时后误删他人锁。
  3. 终极版 :利用 Lua 脚本 解决"查询"与"删除"非原子性的问题。

注意 :这只是一个入门级的分布式锁实现。在生产环境中,还要考虑锁续期 (看门狗机制)、可重入性主从一致性 (Redlock)等问题。建议生产环境直接使用成熟的框架 Redisson

相关推荐
我命由我123452 小时前
Python Flask 开发问题:ImportError: cannot import name ‘Markup‘ from ‘flask‘
开发语言·后端·python·学习·flask·学习方法·python3.11
無量2 小时前
Java并发编程基础:从线程到锁
后端
小信啊啊2 小时前
Go语言数组与切片的区别
开发语言·后端·golang
计算机学姐2 小时前
基于php的摄影网站系统
开发语言·vue.js·后端·mysql·php·phpstorm
Java水解2 小时前
【SpringBoot3】Spring Boot 3.0 集成 Mybatis Plus
spring boot·后端
whoops本尊2 小时前
Golang-Data race【AI总结版】
后端
墨守城规2 小时前
线程池用法及原理
后端
用户2190326527352 小时前
Spring Boot + Redis 注解极简教程:5分钟搞定CRUD操作
java·后端
计算机学姐2 小时前
基于php的旅游景点预约门票管理系统
开发语言·后端·mysql·php·phpstorm