【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性

简介: 本文介绍了如何通过Lua脚本在Redis中实现分布式锁的原子性操作,避免并发问题。首先讲解了Lua脚本的基本概念及其在Redis中的使用方法,包括通过`eval`指令执行Lua脚本和通过`script load`指令缓存脚本。接着详细展示了如何用Lua脚本实现加锁、解锁及可重入锁的功能,确保同一线程可以多次获取锁而不发生死锁。最后,通过代码示例演示了如何在实际业务中调用这些Lua脚本,确保锁操作的原子性和安全性。

引言

在02篇的小结中,为大家指出了我们处理锁误删的代码中存在的问题,但其实只要使用redis来做分布式锁,如果你不能把操作一步完成,不管什么场景可能或多或少都会出问题。所以引出了本篇的内容。在03篇中,我会为大家讲解如何通过Lua脚本来保持redis指令的原子性,从而避免并发问题。友情提示:本篇其实算番外篇,如果你对Lua脚本不感兴趣可以直接跳过,毕竟在实际业务里应该没多少人会自己去手撸,后续篇章中会讲解到其他中间件更为成熟可靠的方案。

什么是Lua脚本

官方介绍它是一种轻量小巧的脚本语言,设计的目的是为了嵌入应用程序,从而为应用程序提供灵活的扩展性和定制功能。redis支持嵌入Lua脚本,因此可以很方便地使用。安装过程很简单,本文不做赘述,直接移步官网下载即可,如遇到问题,善用一下你的搜索引擎即可。

1.png

Lua脚本在redis中的使用

我们并不讲解Lua的基础语法,能看这篇文章的肯定都是起码掌握一门甚至多门编程语言的人,所有我们直接上手实战,看看它是如何在redis中使用的。

通过eval指令执行Lua脚本

redis里提供了eval指令,让用户可以输入Lua脚本并执行,接下来,让我们以此来实现一个简单的set指令,如下:

2.png

大致说明下,redis.call就是执行redis指令,里面的参数分别是具体的指令和key、value的占位符,括号外的1表示1个键,name和cc则分别是key和value的实际值。通过get命令查看结果,如下:

3.png

通过script load指令缓存lua脚本

上面通过eval指令可以实现输入并执行lua脚本,那么如果相同的脚本每次都要重新输入就很麻烦,因此,我们可以通过script load指令来将脚本缓存下来,如下:

4.png

执行后它会返回一个校验和,我们就把这个校验和当做脚本的id吧,然后我们就可以通过evalsha命令通过这个校验和来执行对应的脚本命令,如下:

5.png

利用Lua脚本实现建议加锁和解锁

既然我们已经学会了Lua脚本的基础用法,现在让我们用Lua脚本来分别实现加锁和解锁的逻辑,这里可以创建一个lock.lua文件来进行编写。

简易锁加锁

if redis.call('EXISTS', KEYS[1]) == 0 then
    redis.call('SET', KEYS[1], ARGV[1])
    redis.call('EXPIRE', KEYS[1], ARGV[2])
    return 1
else
    return 0
end

逻辑看起来非常的简单,就是判断一下当前是否存在这把锁,如果存在,则加锁失败;如果不存在,就set一个锁,并且给一个过期时间。脚本编写好后,就可以用eval来执行了,如下:

redis-cli eval "if redis.call('EXISTS', KEYS[1]) == 0 then redis.call('SET', KEYS[1], ARGV[1]); redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1; else return 0; end" 1 lockName uuid 3000

6.png

简易锁解锁

if (redis.call('EXISTS', KEYS[1]) == 0) then
    return 0;
end
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
    redis.call('DEL', KEYS[1])
    return 1;
else
    return 0;
end

逻辑也是相当的简单,就是先判断锁是否存在,如果存在,再比较的value的uuid是否一致,如果一致,则删除锁。执行如下:

7.png

在代码中使用lua脚本

脚本编写完成后,我们就需要在代码中进行调用,这里我们对原来的加锁和解锁方法进行改造,代码如下:

    @Override
    public void lock(TimeUnit timeUnit, Long expireTime) {
   

        while (true) {
   
            // 使用Lua脚本进行加锁
            String luaScript = "if(redis.call('exists', KEYS[1]) == 0) then redis.call('set', KEYS[1], ARGV[1]) redis.call('expire', KEYS[1], ARGV[2]) return 1; else return 0; end";
            Long result = stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
                    Collections.singletonList(this.lockName), uuid, expireTime.toString());
            if (result != null && result.equals(1L)) {
   
                break;
            }

            try {
   
                Thread.sleep(100);
            } catch (InterruptedException e) {
   
                throw new RuntimeException(e);
            }
        }
    }

    @Override
    public void unlock() {
   
        // 使用Lua脚本进行解锁
        String luaScript = "if (redis.call('EXISTS', KEYS[1]) == 0) then return 0; end if (redis.call('GET', KEYS[1]) == ARGV[1]) then redis.call('DEL', KEYS[1]) return 1; else return 0; end";
        stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
                Collections.singletonList(this.lockName), uuid);
    }

它的使用也是非常简单,把脚本声明之后,直接通过execute方法进行传参调用即可。通过Lua脚本的加解锁主要是将原本使用redis的多步操作合并成了一步来保证了操作的原子性。

利用Lua脚本实现锁的可重入

在前文中,我们讲到分布式锁具备的几个特性中有提到可重入性,这个特性对于分布式锁的实现至关重要。首先,我们明确下它的定义-在同一个线程中,同一个锁可以被多次获取而不会发生死锁。假设方法A调用了需要相同锁的方法B或者本身就是递归的,当不可重入时,那么第二次获取锁的时候就会被阻塞,从而发生死锁。而观察下我们前面写的Lua脚本显然不足以支持可重入,因此我们就需要改造。

8.png

那么实现可重入的关键就是:获取了多少把锁就得解锁的时候解多少把,这里需要保持最终一致性。所以我们这里的实现思路就需要用到redis的一种数据结构-hash,代码如下:

if (redis.call('EXISTS', KEYS[1]) == 0) then
    redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
    redis.call('PEXPIRE', KEYS[1], ARGV[2])
    return 1;
end
if (redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1) then
    redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
    redis.call('PEXPIRE', KEYS[1], ARGV[2])
    return 1;
else
    return 0;
end

这里的实现逻辑也是很简单:先判断锁是否存在,如果不存在,直接加锁,重入次数设置为1以及加过期时间;如果存在,则比较uuid是否是本线程,如果是,那么可重入次数+1,并且给锁加一个过期时间,如果不是,那么就加锁失败。那么我们的解锁逻辑也就很清晰了,如下:

if (redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0) then
    return 0;
end
local lockCount = redis.call('HINCRBY', KEYS[1], ARGV[1], -1)
if (lockCount > 0) then
    redis.call('PEXPIRE', KEYS[1], ARGV[2])
    return 1;
else
    redis.call('DEL', KEYS[1])
end

这里的实现逻辑是:首先判断当前持有锁的线程是不是本线程,不是的话,就不需要释放了。如果是,就对重入次数减1,减1之后判断值是否大于0,如果大于还持有锁,就设置一个新的过期时间,如果不大于0,就可以删除锁了。这里我们执行一下脚本看看效果,如下:

9.png

这里我们测试先加了三次锁,然后执行一次解锁,再次查看,可以看到锁只剩两次了,至此,可重入锁成功实现。

小结

本期带领大家简单学习了如何通过Lua脚本来保证锁的原子性,进而保证了我们锁的安全性。在下期的04篇中将会给大家继续完善我们的redis分布式锁,因为目前看似很完美,但其实还存在细节问题,那么我们下一期见。

目录
相关文章
|
11月前
|
NoSQL Redis
Lua脚本协助Redis分布式锁实现命令的原子性
利用Lua脚本确保Redis操作的原子性是分布式锁安全性的关键所在,可以大幅减少由于网络分区、客户端故障等导致的锁无法正确释放的情况,从而在分布式系统中保证数据操作的安全性和一致性。在将这些概念应用于生产环境前,建议深入理解Redis事务与Lua脚本的工作原理以及分布式锁的可能问题和解决方案。
374 8
|
12月前
|
缓存 NoSQL 算法
高并发秒杀系统实战(Redis+Lua分布式锁防超卖与库存扣减优化)
秒杀系统面临瞬时高并发、资源竞争和数据一致性挑战。传统方案如数据库锁或应用层锁存在性能瓶颈或分布式问题,而基于Redis的分布式锁与Lua脚本原子操作成为高效解决方案。通过Redis的`SETNX`实现分布式锁,结合Lua脚本完成库存扣减,确保操作原子性并大幅提升性能(QPS从120提升至8,200)。此外,分段库存策略、多级限流及服务降级机制进一步优化系统稳定性。最佳实践包括分层防控、黄金扣减法则与容灾设计,强调根据业务特性灵活组合技术手段以应对高并发场景。
3350 7
|
安全
【📕分布式锁通关指南 07】源码剖析redisson利用看门狗机制异步维持客户端锁
Redisson 的看门狗机制是解决分布式锁续期问题的核心功能。当通过 `lock()` 方法加锁且未指定租约时间时,默认启用 30 秒的看门狗超时时间。其原理是在获取锁后创建一个定时任务,每隔 1/3 超时时间(默认 10 秒)通过 Lua 脚本检查锁状态并延长过期时间。续期操作异步执行,确保业务线程不被阻塞,同时仅当前持有锁的线程可成功续期。锁释放时自动清理看门狗任务,避免资源浪费。学习源码后需注意:避免使用带超时参数的加锁方法、控制业务执行时间、及时释放锁以优化性能。相比手动循环续期,Redisson 的定时任务方式更高效且安全。
1132 24
【📕分布式锁通关指南 07】源码剖析redisson利用看门狗机制异步维持客户端锁
|
NoSQL 安全 调度
【📕分布式锁通关指南 10】源码剖析redisson之MultiLock的实现
Redisson 的 MultiLock 是一种分布式锁实现,支持对多个独立的 RLock 同时加锁或解锁。它通过“整锁整放”机制确保所有锁要么全部加锁成功,要么完全回滚,避免状态不一致。适用于跨多个 Redis 实例或节点的场景,如分布式任务调度。其核心逻辑基于遍历加锁列表,失败时自动释放已获取的锁,保证原子性。解锁时亦逐一操作,降低死锁风险。MultiLock 不依赖 Lua 脚本,而是封装多锁协调,满足高一致性需求的业务场景。
503 0
【📕分布式锁通关指南 10】源码剖析redisson之MultiLock的实现
【📕分布式锁通关指南 08】源码剖析redisson可重入锁之释放及阻塞与非阻塞获取
本文深入剖析了Redisson中可重入锁的释放锁Lua脚本实现及其获取锁的两种方式(阻塞与非阻塞)。释放锁流程包括前置检查、重入计数处理、锁删除及消息发布等步骤。非阻塞获取锁(tryLock)通过有限时间等待返回布尔值,适合需快速反馈的场景;阻塞获取锁(lock)则无限等待直至成功,适用于必须获取锁的场景。两者在等待策略、返回值和中断处理上存在显著差异。本文为理解分布式锁实现提供了详实参考。
591 11
【📕分布式锁通关指南 08】源码剖析redisson可重入锁之释放及阻塞与非阻塞获取
|
存储 安全 NoSQL
【📕分布式锁通关指南 09】源码剖析redisson之公平锁的实现
本文深入解析了 Redisson 中公平锁的实现原理。公平锁通过确保线程按请求顺序获取锁,避免“插队”现象。在 Redisson 中,`RedissonFairLock` 类的核心逻辑包含加锁与解锁两部分:加锁时,线程先尝试直接获取锁,失败则将自身信息加入 ZSet 等待队列,只有队首线程才能获取锁;解锁时,验证持有者身份并减少重入计数,最终删除锁或通知等待线程。其“公平性”源于 Lua 脚本的原子性操作:线程按时间戳排队、仅队首可尝试加锁、实时发布锁释放通知。这些设计确保了分布式环境下的线程安全与有序执行。
516 0
【📕分布式锁通关指南 09】源码剖析redisson之公平锁的实现
|
NoSQL Java Redis
【📕分布式锁通关指南 06】源码剖析redisson可重入锁之加锁
本文详细解析了Redisson可重入锁的加锁流程。首先从`RLock.lock()`方法入手,通过获取当前线程ID并调用`tryAcquire`尝试加锁。若加锁失败,则订阅锁释放通知并循环重试。核心逻辑由Lua脚本实现:检查锁是否存在,若不存在则创建并设置重入次数为1;若存在且为当前线程持有,则重入次数+1。否则返回锁的剩余过期时间。此过程展示了Redisson高效、可靠的分布式锁机制。
495 0
【📕分布式锁通关指南 06】源码剖析redisson可重入锁之加锁
|
NoSQL Java 测试技术
【📕分布式锁通关指南 05】通过redisson实现分布式锁
本文介绍了如何使用Redisson框架在SpringBoot中实现分布式锁,简化了之前通过Redis手动实现分布式锁的复杂性和不完美之处。Redisson作为Redis的高性能客户端,封装了多种锁的实现,使得开发者只需关注业务逻辑。文中详细展示了引入依赖、配置Redisson客户端、实现扣减库存功能的代码示例,并通过JMeter压测验证了其正确性。后续篇章将深入解析Redisson锁实现的源码。
605 0
【📕分布式锁通关指南 05】通过redisson实现分布式锁
|
运维 NoSQL 算法
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
本文深入探讨了基于Redis实现分布式锁时遇到的细节问题及解决方案。首先,针对锁续期问题,提出了通过独立服务、获取锁进程自己续期和异步线程三种方式,并详细介绍了如何利用Lua脚本和守护线程实现自动续期。接着,解决了锁阻塞问题,引入了带超时时间的`tryLock`机制,确保在高并发场景下不会无限等待锁。最后,作为知识扩展,讲解了RedLock算法原理及其在实际业务中的局限性。文章强调,在并发量不高的场景中手写分布式锁可行,但推荐使用更成熟的Redisson框架来实现分布式锁,以保证系统的稳定性和可靠性。
1097 0
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
|
10月前
|
存储 负载均衡 NoSQL
【赵渝强老师】Redis Cluster分布式集群
Redis Cluster是Redis的分布式存储解决方案,通过哈希槽(slot)实现数据分片,支持水平扩展,具备高可用性和负载均衡能力,适用于大规模数据场景。
702 2