在redis中使用lua脚本全指南
本来想继续“ChatGPT系列”,关于在本地知识库项目中使用ChatGLM替换GPT-3.5,但由于环境还没有搭好,先作罢了。
今天水一篇redis中使用lua脚本。
为什么在redis中使用lua脚本
我们可以将lua脚本上传到redis服务器,让它在redis服务器上执行。因为脚本读写数据都是在服务器的本地内存,所以执行很高效。
使用lua脚本来执行redis命令,有两个好处,也可以说是使用它的出发点:
-
高效。由于lua在服务器本地执行,省去了数据在网络上传输的开销。例如,将两个set求交集后的结果保存到新的set。
-
原子性。整个lua脚本和普通的单个redis命令一样,具有原子性。如果你的逻辑比较复杂,需要使用多条redis命令完成,而且又需要保证整个逻辑的原子性。lua脚本是不二选择。(与数据库事务的原子性不同,lua脚本不会回滚出错之前的写入)
我经常使用的一个库 redsync( github.com/go-redsync/redsync ),它基于redis来实现分布式锁。以下是截取自 redsync 的部分使用lua脚本的代码:
func (m *Mutex) acquire(ctx context.Context, pool redis.Pool, value string) (bool, error) {
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
// 加锁时使用setnx,保证key不存在时才能加锁成功
reply, err := conn.SetNX(m.name, value, m.expiry)
if err != nil {
return false, err
}
return reply, nil
}
// 解锁脚本要保证value确实是自己当时设置的value,才能安全解锁
var deleteScript = redis.NewScript(1, `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
func (m *Mutex) release(ctx context.Context, pool redis.Pool, value string) (bool, error) {
conn, err := pool.Get(ctx)
if err != nil {
return false, err
}
defer conn.Close()
// 执行解锁脚本
status, err := conn.Eval(deleteScript, m.name, value)
if err != nil {
return false, err
}
return status != int64(0), nil
}
整块代码比较简单:
-
加锁时使用setnx,保证key不存在时才能加锁成功,并且生成一个随机值作为key的value。
-
解锁脚本可以保证原子性,对比当前key中的value与自己持有的value是否一致,一致则进行解锁。
-
使用Eval执行脚本。
为什么要对比当前key中的value和自己持有的value呢?如果程序A获得锁后没有在key过期时间之内正常释放锁,key过期后,程序B获得了锁,程序A恢复运行并进行解锁,如果不对比value,程序A会误将程序B的锁释放。
在redis中使用lua也有它的缺点。即使你是一名编程老手,精通多门编程语言,你也不想频繁在多门语言之间切换,更别说还要考虑语言之间的数据类型对应关系。
再者脚本在服务端运行,它的可调试性更差。当脚本中读写大量数据时,会阻塞其他命令的运行。另外工程里出现太多lua脚本,可读性也差。
想要用好这把双刃剑,就需要自己掌握好脚本的复杂程度和读写数据的量级。
程序中如何执行lua脚本
这里以 go 的 redis client:go-redis ( https://github.com/redis/go-redis ) 为例。
go-redis 提供了两个接口:
Eval(ctx context.Context, script string, keys []string, args ...interface{}) *Cmd
EvalSha(ctx context.Context, sha1 string, keys []string, args ...interface{}) *Cmd
Eval 允许我们上传脚本字符串,需要操作的key和其他参数。当脚本被上传过一次后,redis 服务器会把它缓存下来。所以就有了第二个接口 EvalSha,它允许我们上传脚本的sha1值,redis 服务器使用sha1值从缓存中找到脚本并执行。
需要注意,如果因为某些原因,例如执行了 SCRIPT FLUSH,导致脚本缓存失效。EvalSha 会执行失败。所以推荐另一个更保险的方式:
script := redis.NewScript("xxx")
script.Run(ctx, yourRedisClient, keys, args)
Run 方法体:
func (s *Script) Run(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd {
r := s.EvalSha(ctx, c, keys, args...)
if err := r.Err(); err != nil && strings.HasPrefix(err.Error(), "NOSCRIPT ") {
return s.Eval(ctx, c, keys, args...)
}
return r
}
Run 方法首先尝试执行 EvalSha,如果失败并且是找不到脚本的错误,再执行 Eval。
执行redis命令的lua脚本怎么写
这里主要介绍怎么在脚本中获取入参、怎么设置返回值,怎么调用redis命令,不对lua语法进行探讨(因为不会)。
以下是一个脚本示例,脚本的意图为:向set中添加若干元素
-
当set为空时,不添加
-
当set不为空,且存在“空标志”元素时,删除set
-
其他情况,进行元素的添加
local setKey = KEYS[1]
local emptyMember = ARGV[1]
local currentValue = redis.call('scard', setKey)
if currentValue == 0 then
return nil
else
local emptyIn = redis.call('sismember', setKey, ARGV[1])
if emptyIn == 1 then
redis.call('del', setKey)
return 0
end
return redis.call('sadd', setKey, unpack(ARGV, 2))
end
示例中主要关注 KEYS、ARGV 和 redis.call
KEYS 和 ARGV 即上文中的 EVAL 或者 Run 方法中的 keys 和 args 参数。KEYS 和 ARGV 是lua中的 table 类型,在这里可以理解为数组,需要注意lua中的数组下标是从1开始的。
redis.call 用来执行redis命令,命令名称和参数格式与正常的redis命令一致。
返回值可以直接使用redis.call的返回值,也可以自定义。返回nil对应redis.Nil。
执行脚本时的数据复制
高可用的redis服务,一定会为每个redis实例配置备用实例,主备之间通过数据复制达成一致。
当遇到执行的命令是lua脚本时,数据复制存在两种方式:
-
脚本复制(官方称为逐字复制 Verbatim replication)
-
效果复制(Effects replication)
脚本复制:将整个脚本和执行参数复制到其他实例,这种方式可以减少网络消耗,但是其他实例会重复执行主实例的计算。如果脚本中有不确定性的指令(如随机指令:srandmember),导致同样的输入会产生不同的输出,这样的脚本在其他实例执行时将产生不一致的数据。
效果复制:将脚本执行产生的写命令打包进一个事务发送给其他实例。这种方式不用考虑脚本的不确定性。但在脚本写入的数据较多时,会有更多命令在数据复制时进行传输。
这两种复制方式是不是非常类似mysql通过binlog进行数据复制时的两种方式,Row 和 Statement:
binlog记录了所有对数据库更新的操作,记录方式有三种:
Row:每一行记录被更新前后的值
Statement:记录执行的SQL语句
Mix:当SQL语句无法回放,例如SQL语句中有UUID()这样的依赖当时执行环境的方法,使用Row方式记录。其他使用Statement。
让我想起《现代操作系统》这本书中的一段话:
个体重复系统发育:一些技术暂时消失的结果会造成我们有时需要反复考察一些“过时”概念,即那些在当代技术中并不理想的思想。而技术的变化会把一些“过时”概念带回来。正由于此,更重要的是要理解为什么一个概念会过时,而什么样的环境的变化又会启用“过时”概念。
redis从3.2版本开始支持了效果复制,从5.0版本开始默认设置为效果复制。从7.0版本开始脚本复制被废弃。废弃的主要原因是不确定性的脚本会产生数据不一致。3.2至7.0之间可以在脚本中执行redis.replicate_commands()来开启效果复制,这个命令必须在所有的写操作之前执行,返回True代表开启。
浙公网安备 33010602011771号