I'm Aron

Redis分布式锁

1022 字
5 分钟
Redis分布式锁

分布式锁#

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

基本要求:

  • 互斥性
  • 多进程可见
  • 安全性
  • 高可用
  • 高性能
  • 防止死锁

常见实现:

实现方式核心机制特点
MySQL唯一索引、行锁或排他锁实现方便,性能通常低于Redis
RedisSET NX EX性能高,需要处理过期和误删问题
ZooKeeper临时节点、顺序节点一致性较强,连接断开后临时节点自动删除

基于Redis实现分布式锁#

ILock.lua#

public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功,false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}

SimpleRedisLock.java#

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) {
// 获取线程标识
long threadId = Thread.currentThread().getId();
// 获取锁
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);
}
}

setIfAbsent同时传入过期时间时,底层相当于一个原子的Redis命令:

SET lock thread1 NX EX 10
  • NX保证只有Key不存在时才能获取锁
  • EX设置过期时间,避免服务宕机后产生死锁

VoucherOrderServiceImpl.java#

Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order" + userId, stringRedisTemplate);
// 获取锁
boolean isLock = lock.tryLock(1200);
// 判断是否获取锁成功
if(!isLock){
// 获取锁失败,返回错误或重试
return Result.fail("不允许重复下单");
}
try{
// 获取代理对象(事物)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}

Redis分布式锁误删问题#

线程1获取锁成功
线程1执行时间过长,锁自动过期
线程2获取到同一把锁
线程1执行完成,直接执行DEL
线程1错误地删除了线程2的锁
线程3也可以获取锁并进入业务

锁中需要保存锁持有者的唯一标识:

UUID + 线程ID

UUID用于区分不同JVM,线程ID用于区分同一个JVM中的不同线程。

改进Redis的分布锁#

private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
// 获取线程标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().
setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);

UUID.randomUUID().toString(true)是Hutool UUID的写法。如果使用JDK的java.util.UUID,应写为:

UUID.randomUUID().toString()

SimpleRedisLock.java#

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

Redis分布式锁原子性问题#

上面的GETDEL是两个独立操作,仍然可能出现:

线程1执行GET,确认锁属于自己
线程1发生阻塞
锁到期自动删除
线程2获取锁
线程1恢复执行DEL
线程1删除了线程2的锁

因此“判断锁标识”和“删除锁”必须作为一个原子操作执行。

Redis的Lua脚本#

Redis可以在一个Lua脚本中编写多条Redis命令。Redis会将整个脚本作为一个整体执行,脚本执行期间不会插入其他命令。

基本语法:

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

示例:

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

执行多条命令:

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

EVAL命令#

EVAL script numkeys key [key ...] arg [arg ...]

固定参数:

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

动态参数:

EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose

参数对应关系:

1 脚本需要一个Key
name 放入KEYS[1]
Rose 放入ARGV[1]

Lua数组从1开始,因此使用KEYS[1]ARGV[1]

使用Lua脚本释放锁#

unLock.lua#

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

脚本将读取标识、比较标识和删除锁放在同一次执行中,保证操作的原子性。

Spring调用Lua脚本#

SimpleRedisLock.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()
);
}

评论区

文章目录