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()); } 经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~
其他问题
除了误删之外,现在的分布式锁实现还存在以下几个问题:
-
不可重入:同一个线程无法获取同一把锁(递归调用或调用的子函数抢同一把锁时就会出现死锁)
-
不可重试:没抢到锁就失败了
-
超时释放:业务未执行完,锁就超时释放了
-
主从一致性:主节点设置锁成功,还未及时同步到从节点,这时主节点宕机,从节点被选为主节点。但此时从节点还没有锁,仍可以抢锁成功。
本文作者:万事胜意k
本文链接:https://www.cnblogs.com/ysk0904/p/17753152.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步