Redis学习之分布式锁
分布式锁的两个基本特征:
-
多线程(进程)可见(读写)
-
互斥
还应具备的特征:
-
高可用:不能挂机
-
高性能:读写要快
-
安全性:不能出现死锁
实现方式
主要有以下三种:
其中,MySQL 的实现成本相对最低、Redis 性能最高、Zookeeper 可以实现但不推荐使用(Zk 重点在于保证强一致性而不是性能和高可用性,CP 模型)
实现
获取锁:
-
使用 setnx 命令设置 lock(本质是创建一个 redis 键值对),保证只有一个线程获取锁成功(满足互斥)并执行业务逻辑,其他线程可以重试或返回失败。
-
必须 setex 指定 lock 的过期时间(满足安全性)
注意事项:
-
为了防止 setnx 后就宕机了导致 lock 永久存在,必须使用 set [key] ex nx 的原子命令,保证每个 lock 都有过期时间。
-
锁的 key 值建议设计为包含 userId 的,保证多个用户可以并发执行操作,而不是多个用户抢同一把锁。
释放锁:
-
主动释放:业务执行完成删掉 key,注意需要把释放锁的逻辑放到 finally 里保证一定执行
-
超时自动释放(key 过期)
流程图:
代码
接口
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功;false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
SimpleRedisLock
加锁
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
@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);
}
释放锁
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
完整代码
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
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 success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
修改业务代码
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
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();
}
}
误删问题
情况1
如果线程 A 执行业务时间过长,锁提前过期了,另一个线程 B 会拿到锁并执行业务流程,这时 A 又突然执行完了,结果误删了线程 B 加的锁,会导致新的线程 C 又能拿到锁,从而出现线程安全问题。
如图:
情况1 - 解决方案
获取锁的时候在 value 中存入【本机标识 + 当前线程 id】,释放锁时检测 value 必须等于该值,是自己的锁才能释放。 注意,不能只在 value 中存入线程 id,因为多个机器的线程 id 可能是一样的,仍然可能会出现误删。因此可以给每个机器生成一个唯一标识(比如 UUID),再拼接 id。
流程图:
具体代码
加锁
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);
}
释放锁
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);
}
情况2
假设线程 A 释放锁时已经判断了是自己加的锁,但就在这时,JVM 触发了 Stop The World,线程 A 卡住了,然后锁超时释放了,线程 B 就拿到了锁并执行业务。这时,线程 A 又 “醒了”,删除了锁 key,线程 C 又可以拿到锁并执行了,又出现了线程安全问题。
如下图:
情况2 - 解决方案
问题的本质是判断锁value和删除锁是两个动作,不具备原子性!
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
可以使用Redis lua脚本,将多个Redis命令放到一个脚本中,整个脚本执行具备原子性。
Lua 语言是轻量脚本语言,可以很方便地嵌入到各种应用程序中。 不用刻意去学习,随用随查:https://www.runoob.com/lua/lua-tutorial.html
Lua脚本可以使用redis.call调用redis命令,并支持传递动态参数:
语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
例如,我们要执行 redis.call('set', 'name', 'jack') 这个脚本,语法如下:
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
Lua脚本代码:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
利用Java代码调用Lua脚本改造分布式锁
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);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~
其他问题
除了误删之外,现在的分布式锁实现还存在以下几个问题:
-
不可重入:同一个线程无法获取同一把锁(递归调用或调用的子函数抢同一把锁时就会出现死锁)
-
不可重试:没抢到锁就失败了
-
超时释放:业务未执行完,锁就超时释放了
-
主从一致性:主节点设置锁成功,还未及时同步到从节点,这时主节点宕机,从节点被选为主节点。但此时从节点还没有锁,仍可以抢锁成功。