【📕分布式锁通关指南 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
AI 代码解读

逻辑看起来非常的简单,就是判断一下当前是否存在这把锁,如果存在,则加锁失败;如果不存在,就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
AI 代码解读

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
AI 代码解读

逻辑也是相当的简单,就是先判断锁是否存在,如果存在,再比较的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);
    }
AI 代码解读

它的使用也是非常简单,把脚本声明之后,直接通过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
AI 代码解读

这里的实现逻辑也是很简单:先判断锁是否存在,如果不存在,直接加锁,重入次数设置为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
AI 代码解读

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

9.png

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

小结

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

相关文章
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
本文介绍了从单机锁到分布式锁的演变,重点探讨了使用Redis实现分布式锁的方法。分布式锁用于控制分布式系统中多个实例对共享资源的同步访问,需满足互斥性、可重入性、锁超时防死锁和锁释放正确防误删等特性。文章通过具体示例展示了如何利用Redis的`setnx`命令实现加锁,并分析了简化版分布式锁存在的问题,如锁超时和误删。为了解决这些问题,文中提出了设置锁过期时间和在解锁前验证持有锁的线程身份的优化方案。最后指出,尽管当前设计已解决部分问题,但仍存在进一步优化的空间,将在后续章节继续探讨。
486 131
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
|
12天前
|
【📕分布式锁通关指南 07】源码剖析redisson利用看门狗机制异步维持客户端锁
Redisson 的看门狗机制是解决分布式锁续期问题的核心功能。当通过 `lock()` 方法加锁且未指定租约时间时,默认启用 30 秒的看门狗超时时间。其原理是在获取锁后创建一个定时任务,每隔 1/3 超时时间(默认 10 秒)通过 Lua 脚本检查锁状态并延长过期时间。续期操作异步执行,确保业务线程不被阻塞,同时仅当前持有锁的线程可成功续期。锁释放时自动清理看门狗任务,避免资源浪费。学习源码后需注意:避免使用带超时参数的加锁方法、控制业务执行时间、及时释放锁以优化性能。相比手动循环续期,Redisson 的定时任务方式更高效且安全。
62 24
【📕分布式锁通关指南 07】源码剖析redisson利用看门狗机制异步维持客户端锁
|
8天前
【📕分布式锁通关指南 08】源码剖析redisson可重入锁之释放及阻塞与非阻塞获取
本文深入剖析了Redisson中可重入锁的释放锁Lua脚本实现及其获取锁的两种方式(阻塞与非阻塞)。释放锁流程包括前置检查、重入计数处理、锁删除及消息发布等步骤。非阻塞获取锁(tryLock)通过有限时间等待返回布尔值,适合需快速反馈的场景;阻塞获取锁(lock)则无限等待直至成功,适用于必须获取锁的场景。两者在等待策略、返回值和中断处理上存在显著差异。本文为理解分布式锁实现提供了详实参考。
41 11
【📕分布式锁通关指南 08】源码剖析redisson可重入锁之释放及阻塞与非阻塞获取
|
21天前
|
【📕分布式锁通关指南 06】源码剖析redisson可重入锁之加锁
本文详细解析了Redisson可重入锁的加锁流程。首先从`RLock.lock()`方法入手,通过获取当前线程ID并调用`tryAcquire`尝试加锁。若加锁失败,则订阅锁释放通知并循环重试。核心逻辑由Lua脚本实现:检查锁是否存在,若不存在则创建并设置重入次数为1;若存在且为当前线程持有,则重入次数+1。否则返回锁的剩余过期时间。此过程展示了Redisson高效、可靠的分布式锁机制。
43 0
【📕分布式锁通关指南 06】源码剖析redisson可重入锁之加锁
【📕分布式锁通关指南 05】通过redisson实现分布式锁
本文介绍了如何使用Redisson框架在SpringBoot中实现分布式锁,简化了之前通过Redis手动实现分布式锁的复杂性和不完美之处。Redisson作为Redis的高性能客户端,封装了多种锁的实现,使得开发者只需关注业务逻辑。文中详细展示了引入依赖、配置Redisson客户端、实现扣减库存功能的代码示例,并通过JMeter压测验证了其正确性。后续篇章将深入解析Redisson锁实现的源码。
34 0
【📕分布式锁通关指南 05】通过redisson实现分布式锁
|
26天前
|
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
本文深入探讨了基于Redis实现分布式锁时遇到的细节问题及解决方案。首先,针对锁续期问题,提出了通过独立服务、获取锁进程自己续期和异步线程三种方式,并详细介绍了如何利用Lua脚本和守护线程实现自动续期。接着,解决了锁阻塞问题,引入了带超时时间的`tryLock`机制,确保在高并发场景下不会无限等待锁。最后,作为知识扩展,讲解了RedLock算法原理及其在实际业务中的局限性。文章强调,在并发量不高的场景中手写分布式锁可行,但推荐使用更成熟的Redisson框架来实现分布式锁,以保证系统的稳定性和可靠性。
48 0
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
【📕分布式锁通关指南 01】从解决库存超卖开始加锁的初体验
本文通过电商场景中的库存超卖问题,深入探讨了JVM锁、MySQL悲观锁和乐观锁的实现及其局限性。首先介绍了单次访问下库存扣减逻辑的正常运行,但在高并发场景下出现了超卖问题。接着分析了JVM锁在多例模式、事务模式和集群模式下的失效情况,并提出了使用数据库锁机制(如悲观锁和乐观锁)来解决并发问题。 悲观锁通过`update`语句或`select for update`实现,能有效防止超卖,但存在锁范围过大、性能差等问题。乐观锁则通过版本号或时间戳实现,适合读多写少的场景,但也面临高并发写操作性能低和ABA问题。 最终,文章强调没有完美的方案,只有根据具体业务场景选择合适的锁机制。
78 12
【📕分布式锁通关指南 01】从解决库存超卖开始加锁的初体验
Redis 功能扩展 Lua 脚本 对Redis扩展 eval redis.call redis.pcall
通过本文的介绍,我们详细讲解了 Lua 脚本在 Redis 中的作用、`eval` 命令的使用方法以及 `redis.call` 和 `redis.pcall` 的区别和用法。通过合理使用 Lua 脚本,可以实现复杂的业务逻辑,确保操作的原子性,并减少网络开销,从而提高系统的性能和可靠性。
85 13
|
1月前
|
Springboot使用Redis实现分布式锁
通过这些步骤和示例,您可以系统地了解如何在Spring Boot中使用Redis实现分布式锁,并在实际项目中应用。希望这些内容对您的学习和工作有所帮助。
181 83
分布式爬虫框架Scrapy-Redis实战指南
本文介绍如何使用Scrapy-Redis构建分布式爬虫系统,采集携程平台上热门城市的酒店价格与评价信息。通过代理IP、Cookie和User-Agent设置规避反爬策略,实现高效数据抓取。结合价格动态趋势分析,助力酒店业优化市场策略、提升服务质量。技术架构涵盖Scrapy-Redis核心调度、代理中间件及数据解析存储,提供完整的技术路线图与代码示例。
分布式爬虫框架Scrapy-Redis实战指南

热门文章

最新文章

AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等