Redis 分布式锁:一行命令抢锁,但你真的会释放吗?(附可运行源码)

0 阅读8分钟

Redis 分布式锁:一行命令抢锁,但你真的会释放吗?(附可运行源码)

作者:鱼宵 | 实战驱动系列 · 第 1 篇

完整可运行工程(pom.xml + 源码 + 运行说明)已开源在 Gitee:gitee.com/j67mk2/redi…


一、一个真实场景:3 台机器抢同一个库存

你的订单服务上了 3 台机器。用户下单,扣库存:

// 这段代码在 3 台机器上同时跑
if (stock > 0) {
    stock = stock - 1;
    // 写回数据库……
}

单机时代 synchronized 就够了,但它是 JVM 级的锁——3 台 Tomcat 就是 3 个 JVM,互相管不着。库存剩 1 件,可能卖出 3 单。

类比:多人共用一个公共卫生间,门后挂"有人/没人"的牌子。Redis 就是那块公共牌子。

锁要放到所有机器都能看到、都认账的地方 → Redis 分布式锁。


二、加锁:为什么必须是 SET key value NX EX 一条命令?

先看错误写法:

SETNX lockKey 1      # 第一步:抢占
EXPIRE lockKey 10    # 第二步:设过期

问题:两步之间进程一旦崩了(刚抢到锁、还没设过期就被 kill),这把锁永远没有过期时间 = 死锁。

正确写法——"不存在才设置"和"设过期"合成一条原子命令:

SET lock:product:1001  唯一标识  NX  EX 10
参数作用大白话
NXNot eXists,key 不存在才设置成功牌子是"有人"就抢不到
EX 1010 秒自动过期就算持锁者崩了,锁 10 秒后自动释放,不死锁
合一条抢占+过期无中间态翻牌子 + 装定时器是同一个动作

Java 里核心就三行:

SetParams params = new SetParams().nx().ex(expireSeconds);
String result = jedis.set(lockKey, requestId, params);
return "OK".equals(result) ? requestId : null;   // 抢到返回唯一标识,没抢到返回 null

三、value 为什么要存"唯一标识"?

防误删。场景:线程 A 拿锁,业务跑得太慢,10 秒到了锁自动过期;线程 B 抢到锁;A 终于跑完去解锁——如果它无脑 DEL,删掉的是 B 的锁!

所以解锁前必须确认:锁的 value 还是不是我当初拿到的 UUID?是我的我才删。

String requestId = UUID.randomUUID().toString();  // 每个持锁者一个专属编号
// 解锁时:get 出来的 value 等于我的 requestId 才允许 del

四、解锁:为什么必须用 Lua 脚本?

错误写法(先判断、再删除,两条命令):

if get(lockKey) == 我的UUID:   # 第一步:判断
    del(lockKey)              # 第二步:删除

窗口期:判断完"是我的",正要 del 的瞬间,锁刚好到期自动释放、B 抢到了锁 → 你又把 B 的锁删了。

Lua 把"判断 + 删除"焊死成一个原子操作,Redis 执行整段脚本期间不插队任何命令:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])   -- 是我的才删
else
    return 0                             -- 不是我的,绝不动手
end

至此,三板斧齐了,完整工具类:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;

/**
 * Redis 分布式锁(完整版,可直接运行)
 * 三板斧:SET NX EX 原子加锁 + value 存唯一标识 + Lua 原子解锁
 */
public class DistributedLock {

    private final Jedis jedis;

    public DistributedLock(Jedis jedis) {
        this.jedis = jedis;
    }

    /**
     * 加锁。成功返回唯一标识 requestId(解锁时必须带上);失败返回 null
     */
    public String lock(String lockKey, int expireSeconds) {
        String requestId = UUID.randomUUID().toString();
        // nx() = 不存在才设置;ex(秒) = 过期时间。合成一条原子命令
        SetParams params = new SetParams().nx().ex(expireSeconds);
        String result = jedis.set(lockKey, requestId, params);
        return "OK".equals(result) ? requestId : null;
    }

    /** 解锁脚本:判断 + 删除,一个原子操作 */
    private static final String UNLOCK_LUA =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else " +
            "  return 0 " +
            "end";

    /**
     * 释放锁。
     * @return true=成功删除了自己的锁;false=锁不是自己的(或已过期),没动它
     */
    public boolean unlock(String lockKey, String requestId) {
        Object result = jedis.eval(UNLOCK_LUA, 1, lockKey, requestId);
        return result != null && Long.parseLong(result.toString()) >= 1;
    }
}

五、完整演示:两个线程抢同一把锁

import redis.clients.jedis.Jedis;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class Main {

    private static final String HOST = "127.0.0.1";
    private static final int PORT = 6383;   // 本机 Docker 起的 redis:7 容器
    private static final String LOCK_KEY = "lock:product:1001";
    private static final int EXPIRE_SECONDS = 10;
    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");

    public static void main(String[] args) throws InterruptedException {
        try (Jedis check = new Jedis(HOST, PORT)) {
            System.out.println("[" + now() + "] 连接 Redis 成功,ping = " + check.ping());
            check.del(LOCK_KEY);   // 清残留锁,保证结果干净
        }

        // ===== 线程 A:抢到锁,做 3 秒业务,再释放 =====
        Thread threadA = new Thread(() -> {
            Jedis jedis = new Jedis(HOST, PORT);
            DistributedLock lock = new DistributedLock(jedis);

            String requestId = lock.lock(LOCK_KEY, EXPIRE_SECONDS);
            System.out.println("[" + now() + "] 线程A:抢到锁成功!requestId = " + requestId);

            try {
                System.out.println("[" + now() + "] 线程A:拿着锁做业务中(模拟 3 秒)...");
                Thread.sleep(3000);
                System.out.println("[" + now() + "] 线程A:业务做完了");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                boolean released = lock.unlock(LOCK_KEY, requestId);
                System.out.println("[" + now() + "] 线程A:释放锁,结果 = " + released);
                jedis.close();
            }
        }, "线程A");

        // ===== 线程 B:反复重试,直到 A 释放 =====
        Thread threadB = new Thread(() -> {
            sleepQuietly(200);  // 稍晚启动,保证 A 先拿到锁

            Jedis jedis = new Jedis(HOST, PORT);
            DistributedLock lock = new DistributedLock(jedis);

            String requestId = null;
            int tryCount = 0;
            while (tryCount < 20) {
                tryCount++;
                requestId = lock.lock(LOCK_KEY, EXPIRE_SECONDS);
                if (requestId != null) {
                    System.out.println("[" + now() + "] 线程B:第 " + tryCount + " 次尝试 → 抢到锁了!requestId = " + requestId);
                    break;
                } else {
                    System.out.println("[" + now() + "] 线程B:第 " + tryCount + " 次尝试 → 抢锁失败,0.5s 后重试");
                    sleepQuietly(500);
                }
            }
            if (requestId == null) {
                System.out.println("[" + now() + "] 线程B:试了 20 次都没抢到,放弃");
                return;
            }
            sleepQuietly(500);
            boolean released = lock.unlock(LOCK_KEY, requestId);
            System.out.println("[" + now() + "] 线程B:用完锁,释放锁,结果 = " + released);
            jedis.close();
        }, "线程B");

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
        System.out.println("[" + now() + "] 演示结束。");
    }

    private static String now() {
        return LocalTime.now().format(FMT);
    }

    private static void sleepQuietly(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

怎么跑

# 1. 启动 Redis(任选一种)
docker run -d --name redis-lock -p 6383:6379 redis:7

# 2. 运行 Main(IDEA 直接右键运行,或命令行)
mvn compile exec:java -Dexec.mainClass=Main

实测输出(跑出来应该和这份一致)

[15:51:04.861] 连接 Redis 成功,ping = PONG
[15:51:04.924] 线程A:抢到锁成功!requestId = eae4577b-b541-48f0-b446-80fb57d4db0a
[15:51:04.924] 线程A:拿着锁做业务中(模拟 3 秒)...
[15:51:05.092] 线程B:第 1 次尝试 → 抢锁失败,0.5s 后重试
[15:51:05.595] 线程B:第 2 次尝试 → 抢锁失败,0.5s 后重试
[15:51:06.098] 线程B:第 3 次尝试 → 抢锁失败,0.5s 后重试
[15:51:06.599] 线程B:第 4 次尝试 → 抢锁失败,0.5s 后重试
[15:51:07.112] 线程B:第 5 次尝试 → 抢锁失败,0.5s 后重试
[15:51:07.615] 线程B:第 6 次尝试 → 抢锁失败,0.5s 后重试
[15:51:07.930] 线程A:业务做完了
[15:51:07.931] 线程A:释放锁,结果 = true
[15:51:08.121] 线程B:第 7 次尝试 → 抢到锁了!
[15:51:08.637] 线程B:用完锁,释放锁,结果 = true
[15:51:08.637] 演示结束。

请盯着这份输出问自己 3 个问题:

  1. 为什么 B 前面 6 次都失败、偏偏 第 7 次成功?(答案藏在时间线里)
  2. 如果我把 Thread.sleep(3000) 改成 5000,B 会提前成功还是更晚?为什么?
  3. 如果我把 EX 10 改成 EX 0.5(过期 0.5 秒),会发生什么恐怖的事?(提示:误删!——亲手跑一次,你会永远记住 Lua 那 5 行代码为什么存在)

跑出来的输出和我这份不一样?把环境(JDK 版本 / Redis 版本 / Docker)发评论区,我们一起查。


六、跑完之后的加分题(生产环境三件事)

1. 锁的过期时间必须 > 业务最长耗时,否则业务没跑完锁先释放,等于没锁。

2. Redisson 看门狗(watchdog):生产环境别手写,直接用 Redisson。锁快到期自动帮你续租,直到你主动释放。一句话:防"业务还没跑完,锁先被别人抢走"。

3. 可重入:同一个线程已持锁,再进一个也要加锁的方法,value 里记计数器,进来 +1、出去 -1,减到 0 才真释放。


七、面试回答模板(背下来)

面试官:Redis 分布式锁怎么实现?

三板斧:① 加锁用 SET key 唯一标识 NX EX 秒数 一条原子命令,NX 保证不存在才设置、EX 保证自动过期,杜绝死锁;② value 存持锁者唯一标识,防止业务超时锁自动过期后误删新持有者的锁;③ 解锁用 Lua 脚本把"判断 value + 删除"合成原子操作,消除判断到删除之间的竞态窗口。生产环境直接用 Redisson,看门狗自动续租、支持可重入。


八、总结

三板斧解决什么问题不这么做的后果
SET key value NX EX抢占 + 过期原子化分两条命令 = 死锁
value 存唯一标识防误删别人的锁无脑 DEL = 误删
Lua 判断 + 删除消除竞态窗口get 再 del = 又误删

九、关于这个系列

本文是「Java 后端实战精通营」系列第 1 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都来自真实运行,完整可运行工程在 Gitee:

👉 Redis 实战精通营(10 课):gitee.com/j67mk2/redi…

本课源码位置:lesson-05/(分布式锁 + 缓存一致性,跑完本文演示后可继续做缓存一致性实验,同样可运行)

后续系列(陆续发布):Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch,每门课配完整可运行工程 + 高频面试 30 问 + 简历包装话术。

下一篇:《缓存一致性:先更新数据库再删缓存,为什么?延迟双删又是干嘛的?》——同样有可运行源码,提前 git clone 等着。