【黑马点评 | 第七篇】Redis 分布式锁的两种实现

前言

优惠券"一人一单"要求同一个用户的下单操作不能并发执行。如果服务只运行在一个 JVM 中,可以用 Java 锁控制线程;服务部署到多台机器后,各 JVM 的锁互不可见,就需要一把所有实例都能访问的锁。Redis 可以提供非阻塞的加锁与释放,Lua 脚本则可以解决释放锁时的竞态问题。

一、什么是分布式锁,以及它有哪些特性

1. 为什么需要分布式锁

分布式锁是运行在分布式系统中的互斥锁。它把锁状态放在多个进程都能访问的公共存储中,让不同 JVM、不同服务器上的线程按照同一套规则竞争资源。

本地锁只在当前 JVM 内有效。例如,两个请求分别进入服务实例 A 和 B,即使两个实例的方法都加了 synchronized,它们仍然可以同时执行,因为 A 和 B 并不共享这把 Java 锁。分布式锁把锁状态放到 Redis、数据库或 ZooKeeper 等公共组件中,实例 A 和 B 看到的是同一把锁。

在优惠券"一人一单"场景中,同一用户的两个请求可能进入不同实例。两个实例都会先查询订单,如果没有统一的互斥控制,就可能同时判断为"尚未下单",随后各自创建订单。分布式锁要做的,就是让同一用户的这段关键业务在同一时刻只能由一个线程执行。

线程 1 和线程 3 获取同一把锁成功后执行业务,线程 2 和线程 4 获取失败,需要等待锁释放或直接返回失败。对于"一人一单",锁名可以按用户 ID 区分,例如 lock:order:7;不同用户不必争夺同一把锁。

2. 分布式锁的核心特性

分布式锁需要同时关注以下特性:

  • 多进程可见:不同 JVM、不同服务器访问同一个锁 key 时,必须看到一致的持有状态。
  • 互斥:同一把锁在同一时刻只能由一个线程持有,其他线程获取失败或等待。
  • 高可用:锁服务需要可靠的部署与故障恢复能力。采用 Redis 作为锁服务时,如果 Redis 不可用,获取锁就会失败,不能跳过加锁直接执行受保护的业务。
  • 高性能:加锁和释放锁都会出现在业务请求链路中,锁操作的延迟应尽量低,避免把所有请求长时间阻塞在锁上。
  • 安全性:锁必须能正确释放,持有者不能误删其他线程的锁;进程异常退出后也不能留下永久锁。

这些特性之间需要结合实现方式权衡。例如,Redis 的 NX 可以保证同一把锁的互斥,过期时间可以避免进程异常后长期占锁,唯一 value 和 Lua 脚本可以避免旧持有者误删新持有者的锁。

二、Redis 锁的获取原理

获取锁需要同时完成两件事:仅在锁不存在时写入,并给锁设置过期时间。对应的 Redis 命令是:

redis 复制代码
SET lock:order:7 owner-token NX EX 10

NX 表示 key 不存在时才写入;EX 10 表示十秒后过期。它们属于同一条 SET 命令,避免先加锁、再设置过期时间时进程突然退出,留下永不过期的锁。获取失败时直接返回失败,因此锁的获取过程不会阻塞当前线程。

value 不能随手写成固定的 1。锁到期后可能被另一个请求重新获取,释放时必须靠 value 判断当前请求是否仍拥有这把锁。SimpleRedisLock 使用一个 JVM 级 UUID 拼接线程 ID,区分不同实例和线程。

三、实现一:使用 Redis 命令加锁和释放

锁的接口包含尝试获取和释放两个动作:

java 复制代码
public interface ILock {
    /**
     * 尝试获取锁
     * @Param timeoutSec 锁的持有时间
     * @return true:获取成功 false:获取失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

使用 StringRedisTemplate 操作普通 Redis 命令时,获取锁和释放锁分别经过以下步骤。获取锁使用带过期时间的 setIfAbsent,释放锁先读取锁值、判断归属,再删除锁:

java 复制代码
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString();

    private final String name;
    private final StringRedisTemplate stringRedisTemplate;

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

    @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 id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //判断标示是否一致
        if (threadId.equals(id)) {
            //释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS) 对应带 NX 和过期时间的 SET;返回 true 表示成功取得锁。Redis 操作的返回值可能为 null,因此用 Boolean.TRUE.equals(success) 判断。锁值由 UUID 和当前线程 ID 组成,避免不同 JVM 中相同线程 ID 被误认为同一个持有者。

释放时先比较锁值,可以避免无条件 DEL 直接误删其他线程的锁。如果自己的锁已经过期,并被另一个线程重新获得,两个锁值不同,当前线程就不应删除它。不过,读取和删除之间仍然存在时间窗口。

四、先判断再删除,为什么仍可能误删

线程 A 读取锁值,确认它与自己的标识相同。此时 A 暂停,锁刚好到期;线程 B 获取相同的锁 key。随后 A 继续执行 DEL,删除的已经是 B 的锁。线程 C 因而也可能获取成功,与 B 并发处理同一业务。

问题出在 GET 和 DEL 是两条独立命令。判断时属于自己,不代表删除时仍属于自己。 要消除这个窗口,就必须让"比较锁值"和"删除锁"在 Redis 中连续执行,中间不允许其他命令插入。

五、实现二:Lua 脚本原子释放锁

unlock.lua 将比较和删除写在同一个脚本中:

lua 复制代码
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

KEYS1 是锁的 key,ARGV1 是当前线程写入的标识。标识相同才执行 DEL,成功删除返回 1;锁已过期或已属于别人时返回 0。Redis 执行脚本期间不会插入其他客户端命令,因此 GET 与 DEL 之间不会出现被其他线程抢锁的间隙。

Java 端通过 DefaultRedisScript 加载脚本,释放锁时调用脚本;获取锁仍使用带过期时间的 setIfAbsent。相关 import 为:

java 复制代码
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Collections;

脚本字段、静态初始化块和 unlock() 方法可以这样定义:

java 复制代码
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() {
    //调用lua脚本-判断和删除在一行,保证了原子性,线程安全
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}

ClassPathResource 从 resources 目录加载脚本;Long.class 对应脚本返回的整数;execute 的第二个参数传入锁 key,后续参数传入持有者标识。当锁属于 B 时,使用标识 A 执行脚本会返回 0,锁值保持不变;使用标识 B 执行脚本会返回 1,锁被删除。

原子执行的是脚本内部的 GET 与 DEL。获取锁的 SET 是另一条独立命令。Lua 脚本也不提供数据库事务式的出错回滚;它解决的是释放阶段两条命令之间的竞态。

六、两种实现的区别与边界

实现 获取锁 释放锁 结果
Redis 命令版 一条 SET 命令完成 NX 与 EX Java 中分别执行 GET、DEL 能检查归属,但检查与删除之间可能发生锁过期和重新获取
Lua 脚本版 沿用同一条 SET 命令 在一个脚本中比较标识并删除 避免旧持有者误删新持有者的锁

Lua 版解决了误删,但不会延长锁的有效期。如果业务执行时间超过过期时间,其他请求仍可能拿到锁并并发执行业务。因此,它保证释放锁时不误删,不代表锁能覆盖任意长的业务执行时间。

总结

Redis 分布式锁的关键是三步:用 SET NX EX 原子获取一把有期限的锁,用唯一标识记录持有者,再用 Lua 原子地核对并释放。普通命令完成基本的加锁和释放流程,Lua 脚本进一步消除了释放阶段最容易遗漏的竞态窗口。

相关推荐
这个DBA有点耶1 小时前
连接池与MySQL交互实战:连接风暴、连接泄漏与连接状态异常排查
数据库·mysql·架构
TDengine (老段)1 小时前
TDengine TSDB 实战排障三(集群高可用)
数据库·物联网·时序数据库·tdengine·涛思数据·问题排查
SL_staff1 小时前
JVS私有化交付为何敢承诺100%源码开放与无兜底风险?
java·低代码·全栈
需要8261 小时前
MySQL MVCC 与事务隔离级别:从一条 update 看版本链
java·数据库·spring boot·mysql·spring cloud
Memory_荒年1 小时前
订单超时未支付?从“定时扫库”一步步到最终形态
java·后端
夜雪一千1 小时前
MySQL 默认值使用方法
数据库·mysql
SelectDB2 小时前
1TB/天 × 30 天日志成本怎么估:核对命令、降冷配置与踩坑记录
大数据·数据库·数据分析
SelectDB2 小时前
日志选型别只比 Loki 和 ELK:三条路径、一份可复制的落地清单
大数据·数据库·数据分析
bksczm2 小时前
MySQL进阶篇之范式及E-R图
数据库·sql·mysql