Redis中使用Lua脚本实现原子操作

Redis允许用户在服务器上上传并执行 Lua 脚本,也就是说Redis支持我们使用Lua编写一些简单的逻辑,当做一个自定义的命令,在单次操作中来执行,这在很多场景中都很有用,比如redisson分布式锁,滑动窗口限流等。

现在就以简单例子上手来看看java编程怎么使用lua脚本实现redis原子操作。

一个简单的lua脚本

原生命令

先来看一个使用redis-cli命令行执行的命令,这个lua脚本仅仅是执行了一个incr命令

[db1] > eval "return redis.call('incr',KEYS[1])" 1 mykey
(integer) 1

运行结果

EVAL命令是从 Redis 2.6.0 版本开始的,使用内置的 Lua 解释器,可以对 Lua 脚本进行求值。具体用法如下

EVAL script numkeys key [key ...] arg [arg ...] 

参数说明:

  • script: 参数是一段 Lua 5.1 脚本程序。该脚本不需要包含任何 Lua 函数的定义。
  • numkeys: 用于指定键名参数的个数。
  • key [key ...]: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为起始序号的形式访问( KEYS[1] , KEYS[2] ,以此类推)。
  • arg [arg ...]: 附加参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。

我们上面执行的简单脚本中没有用到ARGV,但是其他参数都用到了,可以对照着看下。

redis.call() redis.pcall() 可以从 Lua 脚本调用 Redis 命令。两者几乎相同。 两者都执行 Redis 命令及其提供的参数。 然而,这两个函数之间的区别在于处理运行时错误(例如语法错误)的方式。 调用redis.call()函数引发的错误将直接返回到执行该函数的客户端。 相反,调用redis.pcall()函数时遇到的错误将返回到脚本的执行上下文,而不是进行可能的处理。

下面是一个示例

[db1] > sadd book mathBook
(integer) 1

[db1] > eval "redis.call('get',KEYS[1])" 1 book
"ERR Error running script (call to f_06d4a1ccca3a25f32b1ffed60c2ca95db2b3d95b): @user_script:1: WRONGTYPE Operation against a key holding the wrong kind of value"

[db1] > eval "redis.pcall('get',KEYS[1])" 1 book
(nil)

用java执行Lua脚本

上面的命令用java代码实现的话,是这样写的

@Test
public void luaIncrTest() {
    String luaScript = "return redis.call('incr',KEYS[1])";
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptText(luaScript);
    redisScript.setResultType(Long.class);
    Long ddd = redisTemplate.execute(redisScript, Collections.singletonList("mykey"));
    System.out.println("result is : "+ddd);
}

因为这段lua脚本执行后return的值是个Long类型的,需要在DefaultRedisScript声明要接收的返回值类型为Long,否则类型对不上的话就会报错。假如我们用DefaultRedisScript<String>来接的话,就会报这个错

org.springframework.data.redis.RedisSystemException: Unknown redis exception; nested exception is java.lang.UnsupportedOperationException: io.lettuce.core.output.ValueOutput does not support set(long)

复杂一点的lua脚本

自己动手写lua脚本的好处就是可以将多个redis命令放在一次请求中执行,也可以添加自己的一点处理逻辑,比如if else判断等。下面拟定一个场景,使用lua脚本来实现。

场景:有一个领取礼品的活动,谁都可以领,但是礼品数量有限,先到先得,且不可重复领取,使用Lua脚本将操作限制在一条redis命令中完成。

设计:设置两个key

  • giftNum:当前礼品剩余数量,初始值5, key value形式
  • record:领取记录,不设初始值,set形式

每个用户过来后先判断是否领取过,领取过直接返回成功,未领取过的话判断礼品是否有剩余,有剩余的话将礼品数量减一后记录领取记录,否则返回失败。

lua脚本实现

  KEYS[领取记录,礼品数量] , ARGV[用户id]

-- 先判断该用户是否领过礼品
local isMember = redis.call('SISMEMBER',KEYS[1],ARGV[1])
if (isMember == 1)
then
    return 1
end
-- 由于存储礼品数量的值为string,所以取到后要用tonumber转为数字类型后再进行比较
local giftNum = tonumber(redis.call('get',KEYS[2]))
if( giftNum <= 0 )
then
    return 0
else
    redis.call('DECR',KEYS[2])
    redis.call('SADD',KEYS[1],ARGV[1])
    return 1
end

编写lua脚本需要注意几点

1.脚本中不能有全局变量,只能有局部变量,即所有变量必须用local修饰,否则会报错(假如把isMember前的local修饰符去掉)

org.springframework.data.redis.RedisSystemException: Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR Error running script (call to f_732bb1e3b0b078463f5aec5128960b2bee72fb17): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'isMember'

2.脚本中不能编写function函数,否则也会报错(假如我写了个名为myFunc的函数)

org.springframework.data.redis.RedisSystemException: Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR Error running script (call to f_bfa61041dfb81419bd85b055a7ff2f3d44251881): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'myFunc'

3.字符串类型与数字类型比较时需要先将字符串转为数字,用tonumber函数

代码实现

@Test
public void luaGiftTest() throws InterruptedException {
    String luaScript = "local isMember = redis.call('SISMEMBER',KEYS[1],ARGV[1]) \n" +
            "if (isMember == 1)\n" +
            "then \n" +
            "\treturn 1\n" +
            "end\n" +
            "\n" +
            "local giftNum = tonumber(redis.call('get',KEYS[2]))\n" +
            "if( giftNum <= 0 )\n" +
            "then\n" +
            "\treturn 0\n" +
            "else\n" +
            "\tredis.call('DECR',KEYS[2])\n" +
            "\tredis.call('SADD',KEYS[1],ARGV[1])\n" +
            "\treturn 1\n" +
            "end";
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptText(luaScript);
    redisScript.setResultType(Long.class);

    String userPrefix = "user_";
    String recordKey = "record";
    String giftNumKey = "giftNum";
    // 模拟10个人同时领取礼品
    CountDownLatch countDownLatch = new CountDownLatch(10);
    for (int i = 1; i <= 10; i++) {
        String userId = userPrefix + i;
        CompletableFuture.runAsync(() -> {
            Long result = redisTemplate.execute(redisScript, Arrays.asList(recordKey, giftNumKey), userId);
            log.info("用户:{},领取结果:{}", userId, result);
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    log.info("领取结束");
}

上面的代码实现,将lua脚本直接写在了代码里,在脚本比较长的时候,显得有点杂乱,介于此,我们可以将lua脚本单独放到一个文件中并使用redisScript.setScriptSource来加载脚本。

将lua脚本放到resources目录下(文件名后缀不是必须为.lua,我试了.txt也可以)

java代码实现

@Test
public void luaGiftFileTest() throws InterruptedException {
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/getGift.lua")));
    redisScript.setResultType(Long.class);

    String userPrefix = "user_";
    String recordKey = "record";
    String giftNumKey = "giftNum";
    // 模拟10个人同时领取礼品
    CountDownLatch countDownLatch = new CountDownLatch(10);
    for (int i = 1; i <= 10; i++) {
        String userId = userPrefix + i;
        CompletableFuture.runAsync(() -> {
            Long result = redisTemplate.execute(redisScript, Arrays.asList(recordKey, giftNumKey), userId);
            log.info("用户:{},领取结果:{}", userId, result);
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    log.info("领取结束");
}

代码执行结果

用户:user_8,领取结果:0
用户:user_2,领取结果:0
用户:user_7,领取结果:1
用户:user_1,领取结果:1
用户:user_10,领取结果:0
用户:user_6,领取结果:1
用户:user_5,领取结果:1
用户:user_4,领取结果:1
用户:user_3,领取结果:0
用户:user_9,领取结果:0
领取结束

redis客户端查看键值情况

可以看到,这套代码完美的实现了我们的需求。之所以能使用lua脚本实现对redis的原子操作,还是得益于redis的执行主线程是单线程,命令是单条依次执行,不存在并行执行的特性。

也因为这个单线程的特性,我们写的lua脚本一定要保证它耗时少,执行快,否则慢吞吞执行的lua脚本会将redis服务阻塞住,导致不能执行后面的所有命令。

EVALSHA

Redis 实现了EVALSHA命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)。

脚本缓存

Redis 保证所有被eval运行过的脚本都存储在服务器保留的专用缓存中。这意味着,当 eval 命令在一个 Redis 实例上成功执行某个脚本之后,随后针对这个脚本的所有 evalsha 命令都会成功执行(亲测可行)。

但是Redis 脚本缓存始终是不稳定的。 它不被视为数据库的一部分,并且不会持久化。 缓存可能会在服务器重新启动时、在副本承担主角色时的故障转移期间或由SCRIPT FLUSH显式清除。 这意味着缓存的脚本是短暂的,并且缓存的内容随时可能丢失。

SCRIPT 命令

Redis 提供了以下几个 SCRIPT 命令,用于对脚本子系统(scripting subsystem)进行控制:

  • SCRIPT FLUSH :清除所有脚本缓存 
  • SCRIPT EXISTS :根据给定的脚本校验和,检查指定的脚本是否存在于脚本缓存 
  • SCRIPT LOAD :将一个脚本装入脚本缓存,但并不立即运行它 
  • SCRIPT KILL :杀死当前正在运行的脚本

script load命令可以将lua脚本放入redis服务器中,并返回一个脚本的sha1值。

单看evalsha命令可以明白,比较大的lua脚本使用evalsha命令可以减少客户端与redis服务器的网络开销,但是,RedisTemplate并没有提供相关方法😂,可能是目前大家用到的lua脚本都不是很大,这点网络开销可以忽略不计? Jedis倒是有相关方法,感兴趣的同学可以试试。

Jedis中的方法

好了,到这里redis中lua脚本的基本用法就介绍完了,有时间了再出一篇用lua脚本实现qps限流的文章,敬请期待。

  • 23
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值