在分布式系统与高并发架构的战场中,开发者们始终在与两个永恒的命题博弈:数据一致性与系统性能。当我们试图用Redis构建高速缓存、实现分布式锁或设计秒杀系统时,往往会陷入这样的困境——如何在保证原子性的同时,避免网络往返带来的性能损耗?如何让复杂的多命令操作像单一指令般高效执行?
这正是Redis Lua脚本闪耀的舞台。作为Redis的"核武器级"特性,Lua脚本不仅实现了原子性、隔离性的操作保障,更能将复杂的业务逻辑压缩成服务端的高性能执行单元。
为什么要选择Lua脚本?
在Redis中使用Lua脚本能带来以下核心优势:
原子性保证:Lua脚本在Redis中以单线程方式原子执行,避免多命令操作时的竞态条件。例如库存扣减、分布式锁等场景必须依赖这种特性。
减少网络开销:将多个Redis命令合并为一个脚本执行,减少客户端与服务端之间的网络往返次数(RTT)。对于高频操作性能提升显著。
逻辑复用与版本控制:脚本上传后可通过SHA摘要重复调用,结合SCRIPT LOAD
/EVALSHA
实现服务端逻辑复用,避免重复传输代码。
服务端计算能力:利用Redis服务端的计算资源处理数据,减少客户端计算压力。例如实现复杂统计、数据过滤等操作。
事务增强版:相比MULTI
事务,Lua脚本提供更灵活的逻辑控制(支持if/else、循环等),且执行期间不会被其他命令打断。
Lua脚本命令的使用
有关事务的命令可以通过help @scripting
命令来查看。有关命令的使用可以通过help 命令
来查看,例如help eval
。
EVAL
eval:执行脚本。
语法:
EVAL script numkeys key [key ...] arg [arg ...]
参数说明:
- script:Lua脚本代码
- numkeys:后面key数组的数量
- key:脚本代码中所需要操作的redis中的key数组
- arg:脚本代码中所需要用到的变量参数数组
使用:
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 k1 k2 v1 v2
1) "k1"
2) "k2"
3) "v1"
4) "v2"
SCRIPT系列命令
语法:
# 设置执行脚本为调试模式
SCRIPT DEBUG YES|SYNC|NO# 验证脚本是否存在
SCRIPT EXISTS sha1 [sha1 ...]# 清空脚本缓存
SCRIPT FLUSH [ASYNC|SYNC]# 终止运行中的脚本
SCRIPT KILL -# 缓存脚本,并返回SHA摘要
SCRIPT LOAD script
使用:
127.0.0.1:6379> script load "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"
"a42059b356c875f0717db19a51f6aaca9ae659ea"127.0.0.1:6379> script exists "a42059b356c875f0717db19a51f6aaca9ae659ea"
1) (integer) 1127.0.0.1:6379> script flush
OK127.0.0.1:6379> script exists "a42059b356c875f0717db19a51f6aaca9ae659ea"
1) (integer) 0
EVALSHA
evalsha:通过脚本的SHA1摘要执行已缓存的脚本。
语法:
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
使用:
127.0.0.1:6379> evalsha a42059b356c875f0717db19a51f6aaca9ae659ea 2 k1 k2 v1 v2
(error) NOSCRIPT No matching script. Please use EVAL.127.0.0.1:6379> script load "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"
"a42059b356c875f0717db19a51f6aaca9ae659ea"127.0.0.1:6379> evalsha a42059b356c875f0717db19a51f6aaca9ae659ea 2 k1 k2 v1 v2
1) "k1"
2) "k2"
3) "v1"
4) "v2"
EVAL命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。Redis有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。
为了减少带宽的消耗,Redis实现了EVALSHA命令,它的作用和EVAL一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的SHA1校验和(sum)。
如果服务器还记得给定的SHA1校验和所指定的脚本,那么执行这个脚本,如果服务器不记得给定的SHA1校验和所指定的脚本,那么它返回一个特殊的错误,提醒用户使用EVAL代替EVALSHA。
Lua中执行redis命令
在Lua中,可以通过内置的函数redis.call()和redis.pcall()来执行redis命令。
redis.call()和redis.pcall()两个函数的参数可以是任意的Redis命令:
127.0.0.1:6379> eval "return redis.call('set','foo','bar')" 0
OK
需要注意的是,上面这段脚本的确实现了将键foo的值设为bar的目的,但是,它违反了EVAL命令的语义,因为脚本里使用的所有键都应该由KEYS数组来传递,就像这样:
127.0.0.1:6379> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK
要求使用正确的形式来传递键(key)是有原因的,因为不仅仅是EVAL这个命令,所有的Redis命令,在执行之前都会被分析,借此来确定命令会对哪些键进行操作。
因此,对于EVAL命令来说,必须使用正确的形式来传递键,才能确保分析工作正确地执行。除此之外,使用正确的形式来传递键还有很多其他好处,它的一个特别重要的用途就是确保Redis集群可以将你的请求发送到正确的集群节点。
redis.call()与redis.pcall()很类似,他们唯一的区别是当redis命令执行结果返回错误时,redis.call()将返回给调用者一个错误,而redis.pcall()会将捕获的错误以Lua表的形式返回。
下面的例子演示了redis.call()与redis.pcall()的区别:
127.0.0.1:6379> eval "return redis.call('set1',KEYS[1],'bar')" 1 foo
(error) ERR Error running script (call to f_d968406ee98123006fa91fd2ee764d4f7f859dd7): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script127.0.0.1:6379> eval "return redis.pcall('set1',KEYS[1],'bar')" 1 foo
(error) @user_script: 1: Unknown Redis command called from Lua script127.0.0.1:6379> eval "return type(redis.call('set1',KEYS[1],'bar'))" 1 foo
(error) ERR Error running script (call to f_c62b83c8313fd8f2557865e37d2bb5133f1789af): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script127.0.0.1:6379> eval "return type(redis.pcall('set1',KEYS[1],'bar'))" 1 foo
"table"
Lua数据类型和Redis数据类型之间转换
当Lua通过call()或pcall()函数执行Redis命令的时候,命令的返回值会被转换成Lua数据结构。
同样地,当Lua脚本在Redis内置的解释器里运行时,Lua脚本的返回值也会被转换成Redis协议(protocol),然后由EVAL将值返回给客户端。
数据类型之间的转换遵循这样一个设计原则:如果将一个Redis值转换成Lua值,之后再将转换所得的Lua值转换回Redis值,那么这个转换所得的Redis 值应该和最初时的Redis值一样。
换句话说,Lua类型和Redis类型之间存在着一一对应的转换关系。
Lua | Redis | 示例 |
---|---|---|
number(整型) | integer | 3 → 3 |
number(浮点型) | bulk string | 3.3 -> “3.3” |
string | bulk string | “value” → “value” |
table (数组形式) | multi-bulk | {1,2,3} → [“1”,“2”,“3”] |
table(键值对形式) | multi-bulk | {name=“Bob”} → (empty array),无法转换需使用json库序列化为字符串 |
table(只带一个ok的键值对) | status | {ok=‘success’} -> success |
table(只带一个err的键值对) | error | {err=‘My Error’} -> (error) My Error |
boolean | integer | true → 1, false → (nil) |
nil | nil | nil -> 无返回 |
Lua中整数和浮点数之间没有什么区别。因此,我们始终将Lua的数字转换成整数的回复,这样将舍去小数部分。如果你想从Lua返回一个浮点数,你应该将它作为一个字符串,比如ZSCORE命令。
以下是几个类型转换的例子:
127.0.0.1:6379> eval "return 10" 0
(integer) 10127.0.0.1:6379> eval "return {1,2,{3,'Hello World!'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 32) "Hello World!"127.0.0.1:6379> eval "return redis.call('get','foo')" 0
"bar"
最后一个例子展示如果是Lua直接命令调用它是如何可以从redis.call()或redis.pcall()接收到准确的返回值。
下面的例子我们可以看到浮点数和nil将怎么样处理:
127.0.0.1:6379> eval "return {1,2,3.3333,'foo',nil,'bar'}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "foo"
正如你看到的3.333被转换成了3,并且nil后面的字符串bar没有被返回回来。
可以使用tostring()函数将数字转字符串:
127.0.0.1:6379> eval "return tostring(3.3333)" 0
"3.3333"
有两个辅助函数从Lua返回Redis的类型:
- redis.error_reply(error_string):returns an error reply. This function simply returns the single field table with the err field set to the specified string for you.
- redis.status_reply(status_string):returns a status reply. This function simply returns the single field table with the ok field set to the specified string for you.
使用redis.error_reply()函数与直接返回一个table效果一样:
127.0.0.1:6379> eval "return {err='My Error'}" 0
(error) My Error127.0.0.1:6379> eval "return redis.error_reply('My Error')" 0
(error) My Error
可用库
Redis Lua解释器可用加载以下Lua库:
- base lib.
- table lib.
- string lib.
- math lib.
- debug lib.
- struct lib.
- cjson lib.
- cmsgpack lib.
- bitop lib.
- redis.sha1hex function.
每一个Redis实例都拥有以上的所有类库,以确保您使用脚本的环境都是一样的。
struct,CJSON和cmsgpack都是外部库,所有其他库都是标准Lua库。
CJSON库为Lua提供极快的JSON处理:
127.0.0.1:6379> eval 'return cjson.encode({["foo"]= "bar"})' 0
"{\"foo\":\"bar\"}"127.0.0.1:6379> eval 'return cjson.decode(ARGV[1])["foo"]' 0 "{\"foo\":\"bar\"}"
"bar"127.0.0.1:6379> eval "local table = {} table['foo']='bar' table['hello']='world' return cjson.encode(table)" 0
"{\"hello\":\"world\",\"foo\":\"bar\"}"
在java中的使用
lua脚本:
-- 库存扣减脚本
local key = KEYS[1]
local quantity = tonumber(ARGV[1])local stock = tonumber(redis.call('GET', key))
if not stock thenreturn -1
endif stock >= quantity thenreturn redis.call('DECRBY', key, quantity)
elsereturn 0
end
在java中使用lua脚本:
package com.morris.redis.demo.lua;import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;import javax.annotation.Resource;
import java.util.Collections;/*** RedisTemplate中lua脚本的使用*/
@Service
public class RedisTemplateLuaDemo {@Resourceprivate RedisTemplate redisTemplate;public void testLua() {DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/deduct_stock.lua")));redisScript.setResultType(Long.class);Object object = redisTemplate.execute(redisScript,Collections.singletonList("stock:10086"),Integer.toString(1));System.out.println(object);}}
RedisTemplate底层源码已经实现了evalsha+eval,无需手动加载脚本,源码如下:
org.springframework.data.redis.core.script.DefaultScriptExecutor#eval
protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {Object result;try {result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);} catch (Exception e) {if (!ScriptUtils.exceptionContainsNoScriptError(e)) {throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);}result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);}if (script.getResultType() == null) {return null;}return deserializeResult(resultSerializer, result);}