跳转至

分布式锁

定义

满足分布式系统或集群模式下多进程可见并且互斥的锁。

主要目的是在分布式系统中确保多个进程或服务对共享资源的互斥访问,防止并发操作导致的数据不一致或冲突。

实现对比

image-20250114164648004

Redis 实现

基本命令

  • 获取:

    需要保证 set 和 expire 一起是原子性的

    setnx lock t1 ex 10 nx
    
  • 释放:

    del lock
    

流程

image-20250114173729939

初步实现

接口:

public interface ILock {
    boolean tryLock(long timeoutSec);

    void unlock();
}

加锁类:

public class SimpleRedisLock implements ILock {
    private StringRedisTemplate stringRedisTemplate;

    private String name;
    private static final String KEY_PREFIX = "lock:";

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

    @Override
    public boolean tryLock(long timeoutSec) {
        long threadId = Thread.currentThread().getId();

        Boolean ret = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(ret);
    }

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

调用:

SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean gotLock = lock.tryLock(1200);
if(!gotLock){
    return Result.fail("不允许重复下单");
}

/* 正常逻辑 */
try {

} finally {
    lock.unlock();
}

误删问题

误删问题本质上是放锁时可能会释放不属于当前线程的锁,解决方式是使用 UUID 作为 value,在放锁之前检查 redis 中的 value

问题

image-20250114182850892

解决

释放锁之前判断这个锁是否是本线程加的

具体来说使用全局唯一的 UUID 和 threadId 拼接成一个整体作为 value,在放锁的时候需要使用 lua 脚本判断当前线程(放锁线程)从 redis 中取出的 value 是否和预期相同。

image-20250114183049503

实现

在获取锁时存入 UUID

这里的 UUID 作为分布式系统中的唯一标识,可以通过算法本地生成而无需依赖全局状态

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
    String threadId = ID_PREFIX + Thread.currentThread().getId();

    /* 存入 redis */
    // key:    KEY_PREFIX + name
    // value:  threadId
}

@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);
    }
}

原子性问题

在 unlock 判断成功后,线程可会阻塞,导致锁超时自动删除,但是由于已经判断成功,当线程被唤醒时会继续执行解锁操作,导致其释放了不属于自己的锁

这里阻塞的原因一般是来源于垃圾回收的 STW 停顿阻塞

解决思路:

让判断是否释放锁和释放锁为一个整体的原子性操作

Lua 脚本

redis 的事务是批处理(类似于 verilog 中的 => 运算,无法先判断再执行),这里不适应 Redis 的事务功能保证原子性,而是使用 Lua 脚本。

Lua 功能

使用 Lua 编程语言,其数组的下标是从 1 开始的

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

Redis 可以通过 EVAL 命令来调用脚本

EVAL "return redis.call('set', 'name', 'kz')" 0

脚本的内容使用 " " 来包裹

这里 0 表示脚本需要的 key 类型的参数个数

  • 如果脚本中的 key, value 不想写死,可以作为参数传递。
    • key 类型的参数会放入 keys 数组
    • 其他参数会放入 ARGV 数组

image-20250115150223885

Lua 编写

-- 比较加锁和放锁是否为同一个线程
if(redis.call('get', KEYS[1]) == ARGV[1]) then
    return redis.call('del', KEYS[1]) -- 成功则返回 1
end
return 0;

实现

可以调用 RedisTemplate 工具类中的 execute 方法,此处不需要指定 KEYS 的个数

image-20250115152820508

基于脚本的放锁逻辑

这里将 Lua 脚本的代码写到 unlock.lua 文件中,然后放到 /resource 文件夹下

/* 类变量 script,初始化为 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);   
}

@Override
public void unlock() {
    stringRedisTemplate.execute(
        UNLOCK_SCRIPT,
        Collections.singletonList(KEY_PREFIX + name),
        ID_PREFIX + Thread.currentThread().getId();
    );
}