在redis中使用lua脚本全指南

本来想继续“ChatGPT系列”,关于在本地知识库项目中使用ChatGLM替换GPT-3.5,但由于环境还没有搭好,先作罢了。

今天水一篇redis中使用lua脚本。

为什么在redis中使用lua脚本

​我们可以将lua脚本上传到redis服务器,让它在redis服务器上执行。因为脚本读写数据都是在服务器的本地内存,所以执行很高效。

使用lua脚本来执行redis命令,有两个好处,也可以说是使用它的出发点:

  1. 高效。由于lua在服务器本地执行,省去了数据在网络上传输的开销。例如,将两个set求交集后的结果保存到新的set。

  2. 原子性。整个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
}

整块代码比较简单:

  1. 加锁时使用setnx,保证key不存在时才能加锁成功,并且生成一个随机值作为key的value。

  2. 解锁脚本可以保证原子性,对比当前key中的value与自己持有的value是否一致,一致则进行解锁。

  3. 使用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脚本时,数据复制存在两种方式:

  1. 脚本复制(官方称为逐字复制 Verbatim replication)

  2. 效果复制(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代表开启。

 
posted @ 2023-05-18 23:05  刘玮  阅读(866)  评论(0)    收藏  举报