当前位置:   article > 正文

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

redis lua脚本实现原子性

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

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

一个简单的lua脚本

原生命令

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

  1. [db1] > eval "return redis.call('incr',KEYS[1])" 1 mykey
  2. (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()函数时遇到的错误将返回到脚本的执行上下文,而不是进行可能的处理。

下面是一个示例

  1. [db1] > sadd book mathBook
  2. (integer) 1
  3. [db1] > eval "redis.call('get',KEYS[1])" 1 book
  4. "ERR Error running script (call to f_06d4a1ccca3a25f32b1ffed60c2ca95db2b3d95b): @user_script:1: WRONGTYPE Operation against a key holding the wrong kind of value"
  5. [db1] > eval "redis.pcall('get',KEYS[1])" 1 book
  6. (nil)

用java执行Lua脚本

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

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

因为这段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]

  1. -- 先判断该用户是否领过礼品
  2. local isMember = redis.call('SISMEMBER',KEYS[1],ARGV[1])
  3. if (isMember == 1)
  4. then
  5. return 1
  6. end
  7. -- 由于存储礼品数量的值为string,所以取到后要用tonumber转为数字类型后再进行比较
  8. local giftNum = tonumber(redis.call('get',KEYS[2]))
  9. if( giftNum <= 0 )
  10. then
  11. return 0
  12. else
  13. redis.call('DECR',KEYS[2])
  14. redis.call('SADD',KEYS[1],ARGV[1])
  15. return 1
  16. 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函数

代码实现

  1. @Test
  2. public void luaGiftTest() throws InterruptedException {
  3. String luaScript = "local isMember = redis.call('SISMEMBER',KEYS[1],ARGV[1]) \n" +
  4. "if (isMember == 1)\n" +
  5. "then \n" +
  6. "\treturn 1\n" +
  7. "end\n" +
  8. "\n" +
  9. "local giftNum = tonumber(redis.call('get',KEYS[2]))\n" +
  10. "if( giftNum <= 0 )\n" +
  11. "then\n" +
  12. "\treturn 0\n" +
  13. "else\n" +
  14. "\tredis.call('DECR',KEYS[2])\n" +
  15. "\tredis.call('SADD',KEYS[1],ARGV[1])\n" +
  16. "\treturn 1\n" +
  17. "end";
  18. DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
  19. redisScript.setScriptText(luaScript);
  20. redisScript.setResultType(Long.class);
  21. String userPrefix = "user_";
  22. String recordKey = "record";
  23. String giftNumKey = "giftNum";
  24. // 模拟10个人同时领取礼品
  25. CountDownLatch countDownLatch = new CountDownLatch(10);
  26. for (int i = 1; i <= 10; i++) {
  27. String userId = userPrefix + i;
  28. CompletableFuture.runAsync(() -> {
  29. Long result = redisTemplate.execute(redisScript, Arrays.asList(recordKey, giftNumKey), userId);
  30. log.info("用户:{},领取结果:{}", userId, result);
  31. countDownLatch.countDown();
  32. });
  33. }
  34. countDownLatch.await();
  35. log.info("领取结束");
  36. }

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

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

java代码实现

  1. @Test
  2. public void luaGiftFileTest() throws InterruptedException {
  3. DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
  4. redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/getGift.lua")));
  5. redisScript.setResultType(Long.class);
  6. String userPrefix = "user_";
  7. String recordKey = "record";
  8. String giftNumKey = "giftNum";
  9. // 模拟10个人同时领取礼品
  10. CountDownLatch countDownLatch = new CountDownLatch(10);
  11. for (int i = 1; i <= 10; i++) {
  12. String userId = userPrefix + i;
  13. CompletableFuture.runAsync(() -> {
  14. Long result = redisTemplate.execute(redisScript, Arrays.asList(recordKey, giftNumKey), userId);
  15. log.info("用户:{},领取结果:{}", userId, result);
  16. countDownLatch.countDown();
  17. });
  18. }
  19. countDownLatch.await();
  20. log.info("领取结束");
  21. }

代码执行结果

  1. 用户:user_8,领取结果:0
  2. 用户:user_2,领取结果:0
  3. 用户:user_7,领取结果:1
  4. 用户:user_1,领取结果:1
  5. 用户:user_10,领取结果:0
  6. 用户:user_6,领取结果:1
  7. 用户:user_5,领取结果:1
  8. 用户:user_4,领取结果:1
  9. 用户:user_3,领取结果:0
  10. 用户:user_9,领取结果:0
  11. 领取结束

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并没有提供相关方法 本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】

推荐阅读
相关标签