分布式锁
基本原理
- 说分布式锁之前我们先来说一下
synchronized
,synchronized 利用 JVM 内部的锁监视器来控制线程,由此可以在 JVM 内部可以实现线程间的互斥 - 但是,当有多个 JVM 的时候,就会有多个锁监视器,就会有多个线程获取到锁,这样就无法实现多 JVM 进程之前的互斥
- 要解决这个问题,就要让多个 JVM 使用同一个锁监视器,这个锁监视器一定是在 JVM 内部,多 JVM 进程都可以看到的这么一个锁监视器。因此,这时无论是 JVM 内部的,还是多 JVM 的线程都应该来这个锁监视器来获取锁,这样就会只有一个线程获取锁,就能够实现多进程之间的互斥
- 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
分布式锁的特性
- 多进程可见: 多个 JVM 进程看到同一个锁监视器
- 互斥:只有一个进程能拿到线程锁
- 高可用
- 高性能(高并发)
- 安全性
- ...
分布式锁的实现
- 常见的有三种
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用MySQL本身的互斥锁机制 | 利用 setnx 这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
基于 Redis 的分布式锁
获取锁
- 互斥:确保只能有一个线程获取锁(setnx 命令)
- 添加超时时间(expire)
- 原子性:获取锁和添加超时时间同时进行(
set key value ex 10 nx
) - 非阻塞:尝试一次,成功返回 true,失败返回 false
释放锁
- 手动释放(del 命令)
- 超时释放:获取锁时添加一个超时时间
简单实现 Redis 分布式锁
ILook.java
package com.hmdp.utils;
/**
* Redis 分布式锁接口
*/
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,获取后自动释放
* @return true代表获取锁成功;false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
SimpleRedisLock.java
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
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);
}
}
分布式锁误删问题
- 我们来看下面一种极端情况
- 线程1获取到锁后去执行自己的业务,但由于某些原因业务被阻塞, 在线程1业务阻塞期间锁被超时释放,这时线程2来获取到锁去执行自己的业务,在线程2执行业务期间,线程1被唤醒也继续执行业务,线程1执行完业务后会去释放锁(相当于把线程2得到的锁给释放掉了),这时线程3来获取锁,由于线程1将锁释放掉了,线程3可以得到锁,得到锁后也去执行自己的业务,此时,线程2和线程3的业务就在并发执行,这就可能会引发线程安全
- 发生这种情况归根结底是线程1把别人的锁(线程2)给释放掉了,如果线程1在释放锁之前能够判断一下是否是自己的锁,那么问题就能够得到解决
- 因此,我们的业务流程也应该发生变化
改进 Redis 分布式锁
- 在获取锁时存入线程标识(可以用UUID表示)
在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
- 如果一致则释放锁
- 如果不一致则不释放锁
为什么不用线程id?
- 线程id就是一串递增的数字,在 JVM 内部,每创建一个线程数字就会递增
- 如果是在集群的模式下,每个 JVM 内部都会维护这样一个递增的数字,这样就很有可能出现线程 id 冲突的情况
- 因此我们可以使用 UUID+线程id 确保不同以及相同线程标识一定不同
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
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:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@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);
}
}
}
分布式锁的原子性问题
- 我们再来看下面一种情况
- 线程1获取到锁后去执行自己的业务,执行完成后判断锁标识一致通过,当要释放锁的时候被阻塞(eg:JVM垃圾回收);这时线程2获取到锁去执行业务,在这个期间,线程1被唤醒,线程1业务执行完直接去释放锁,因为前面已经判断过标识,线程1这里直接将线程2的锁给释放掉了;线程2执行业务期间线程3又来获取锁,线程3得到锁后去执行业务,此时线程2和线程3的业务就并发执行了,这就可能会引发线程安全
- 出现这个问题的主要原因就是判断锁标识和释放锁是两个动作,这两个动作之间出了问题,要想避免这个问题的发生,我们必须确保判断锁标识的动作和释放锁的动作组成一个
原子性
的操作
Lua 脚本解决多条命令原子性问题
- Lua 脚本:在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性
- Redis 的调用函数
# 执行redis 命令
redis.call('命令名称', 'key', '其他参数', ...)
# eg: 执行 set name ruochen
redis.call('set', 'name', 'ruochen')
# eg: 先执行 set name ruochen, 再执行 get name
redis.call('set', 'name', 'ruochen')
local name = redis.call('get', 'name')
return name
- Redis 调用脚本命令
# 调用脚本 (0:key类型参数数量)
EVAL "return redis.call('set', 'name', 'ruochen')" 0
# key 类型参数会放到KEYS数组,其他参数会放到ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数(数组角标从1开始)
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name ruochen
- Lua 脚本编写释放锁流程(
unlock.lua
)
-- 获取锁中线程标识,比较线程标识与锁中的标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
- Java 调用 Lua 脚本
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
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:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
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 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() {
// 调用Lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
}
总结
基于Redis的分布式锁实现思路:
- 利用
set nx ex
获取锁,并设置过期时间,保存线程标识 - 释放锁时先判断线程标识是否与自己一致,一致则删除锁,且使用 Lua 脚本保证原子性
- 利用
特性
- 利用
set ng
满足互斥性 - 利用
set ex
保证故障时锁依然能释放,避免死锁,提高安全性 - 利用 Redis 集群保证高可用和高并发特性
- 利用
目前为止已经是一个相对完善的分布式锁了,但是它仍然有进步的空间