一.分布式锁的介绍

定义:

分布式锁是满足在分布式系统或集群模式下,实现多进程可见并且互斥的锁机制。

核心特性(基于图示):

  1. 多进程可见:不同服务器节点上的进程都能感知到锁的状态。
  2. 互斥:同一时刻只允许一个进程持有锁,防止并发冲突。
  3. 高可用:锁服务本身需稳定可靠,避免单点故障。
  4. 高性能:加锁与解锁操作需高效,低延迟。
  5. 安全性:保障锁机制不被恶意篡改或误用。

二.基于Redis实现分布式锁

<1>原理分析

在分布式系统中,为了保证多个节点对共享资源的互斥访问,常使用 Redis 实现分布式锁。其核心是实现两个基本方法:获取锁 和 释放锁

1、获取锁

  • 互斥性:确保同一时刻只有一个线程能成功获取锁。
  • 非阻塞尝试:尝试一次,成功返回 true,失败返回 false,不等待。

命令

SET lock thread1 NX EX 10
  • NX:仅当 key 不存在时才设置 → 保证互斥
  • EX 10:设置过期时间为 10 秒 → 防止死锁

2、释放锁

  • 手动释放:业务执行完成后主动删除锁。
  • 超时自动释放:通过设置过期时间,即使服务宕机或业务超时,锁也会自动失效,避免死锁。

命令

DEL key

<2>流程图

<3>代码实现Redis锁---版本1

获取锁与删除锁代码

public class SimpleRedisLock implements ILock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    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) {
        String threadId = String.valueOf(Thread.currentThread().getId());
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(success);
    }

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

将锁添加进业务中

//5.1获取用户id
        Long UserId = UserHolder.getUser().getId();
        //创建锁对象
        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + UserId, stringRedisTemplate);
        //获取锁
        boolean isLock = simpleRedisLock.tryLock(1200);
        //判断获取锁成功
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        //获取代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId, voucher);

<3>解决Redis分布锁中的误删问题

1.产生原因以及解决办法

误删问题的核心原因是:锁的持有者(线程)与释放者不一致,且缺乏身份校验机制。

具体产生场景如下:

  1. 业务执行超时:线程 A 获取了锁,但业务逻辑执行时间过长,超过了锁设定的过期时间(TTL)。
  2. 锁自动失效:Redis 因超时自动删除了该锁,此时锁处于“空闲”状态。
  3. 新线程介入:线程 B 趁虚而入,成功获取了新的锁,并开始执行业务。
  4. 旧线程“诈尸”:线程 A 终于执行完了(虽然已超时),它 unaware 锁已过期或被别人拿走,直接执行“释放锁”操作。
  5. 误删发生:线程 A 把线程 B 正在持有的锁给删掉了。

后果:线程 B 失去保护,此时若有线程 C 进来,就能和线程 B 同时访问临界资源,导致数据竞争和不一致。

根本症结:释放锁时,没有判断“当前要删的锁是不是我自己加的那把”。

解决办法

线程1先成功获取锁并执行业务,但在完成前锁因超时被自动释放;此时它尝试释放锁时发现锁已不属于自己(返回NIL),于是不操作。随后线程2趁机获取到新锁,正常执行业务后校验归属一致,安全释放锁。接着线程3也顺利拿到锁开始执行。

2.代码实现

改进Redis的分布式锁

需求:修改之前的分布式锁实现,满足:

  1. 在获取锁时存入线程标示(可以用UUID表示)
  2. 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
    • 如果一致则释放锁
    • 如果不一致则不释放锁
//获取锁
    @Override
    public boolean tryLock(long timeoutSec) {
        String threadId = ID_PREFIX + String.valueOf(Thread.currentThread().getId());
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(success);
    }
    //删除锁
    @Override
    public void unlock() {
        //获取线程标识
        String threadId = ID_PREFIX + String.valueOf(Thread.currentThread().getId());
        //获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //判断标识是一致
        if (threadId.equals(id)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

<4>分布式锁的原子性问题

1.产生原因

产生原因

  1. 线程A拿到锁,执行业务。
  2. 业务执行时间 > 锁超时时间 → Redis自动释放锁。
  3. 线程B趁机获取锁,开始执行。
  4. 线程A业务结束,尝试删锁 → 此时锁已是线程B的!但A仍会误删!

→ 结果:线程B的锁被A提前删掉,后续线程C可立即抢锁,导致并发失控!

2.解决办法----Redis的Lua脚本

Lua脚本简介

Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。
Lua 是一种编程语言,基本语法可参考:https://www.runoob.com/lua/lua-tutorial.html

这里重点介绍调用函数语法

redis.call('命令名称', 'key', '其它参数', ...)

示例一:执行 set name jack

redis.call('set', 'name', 'jack')

示例 2:先 set name Rose,再 get name

redis.call('set', 'name', 'Rose')
local name = redis.call('get', 'name')
return name

写好脚本以后,需要用 Redis 命令来调用脚本,调用脚本的常见命令如下:

127.0.0.1:6379> help @scripting

EVAL script numkeys key [key ...] arg [arg ...]
summary: Execute a Lua script server side
since: 2.6.0

例如,我们要执行 redis.call('set', 'name', 'jack') 这个脚本,语法如下:

# 调用脚本
EVAL "return redis.call('set', 'name', 'jack')" 0
  • 脚本内容"return redis.call('set', 'name', 'jack')"
  • Key 参数个数0(表示脚本中没有使用 KEYS 数组类型的参数)

如果脚本中的 key、value 不想写死,可以作为参数传递。

  • key 类型参数会放入 KEYS 数组。
  • 其它参数会放入 ARGV 数组。
  • 在脚本中可以从 KEYS 和 ARGV 数组获取这些参数。

示例代码

# 调用脚本
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose

3.代码实现

释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指定的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做

用 Lua 脚本表示如下:

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

我们要在idea中集成一个EmmyLua插件

然后创建这个文件

将lua脚本写入文件中

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

完整代码

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;
    }
@Override
    public void unlock() {
        stringRedisTemplate.execute(UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + String.valueOf(Thread.currentThread().getId()));
    }

更多推荐