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限流的文章,敬请期待。