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

发布于:2025-02-24 ⋅ 阅读:(13) ⋅ 点赞:(0)

引言

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

什么是Lua脚本

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

在这里插入图片描述

Lua脚本在redis中的使用

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

通过eval指令执行Lua脚本

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

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

在这里插入图片描述

通过script load指令缓存lua脚本

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

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

在这里插入图片描述

利用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

在这里插入图片描述

简易锁解锁

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是否一致,如果一致,则删除锁。执行如下:

在这里插入图片描述

在代码中使用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脚本显然不足以支持可重入,因此我们就需要改造。

在这里插入图片描述

那么实现可重入的关键就是:获取了多少把锁就得解锁的时候解多少把,这里需要保持最终一致性。所以我们这里的实现思路就需要用到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,就可以删除锁了。这里我们执行一下脚本看看效果,如下:

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

小结

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