Redis - Lua 脚本

5,848 阅读3分钟

Redis 从 2.6 开始内嵌了 Lua 环境来支持用户扩展功能. 通过 Lua 脚本, 我们可以原子化地执行多条 Redis 命令.

Redis 中的 Lua 脚本


在 Redis 中执行 Lua 脚本需要用到 EVALEVALSHASCRIPT *** 这几个命令, 下面分别来介绍一下:

  1. EVAL: 执行 Lua 脚本

    EVAL script numkeys key[key ...] arg [arg ...]
    
    127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
    1) "key1"
    2) "key2"
    3) "first"
    4) "second"
    
    • script 就是 Lua 脚本本身
    • numkeys 表示脚本中涉及到的 Redis Key 的数量
    • key[key ...] 表示脚本中涉及到的所有 Redis Key
    • arg [arg ...] 表示脚本中涉及到的所有参数(变量), 不限制个数

    在 Lua 脚本中可以通过 KEYS[] 数组加上脚标访问具体的 Redis Key, 通过 ARGV[] 数据加脚标访问传入的参数(变量). 注意, 脚标都是从 1 开始的.

  2. EVALSHA: 从缓存中执行 Lua 脚本

    EVAL sha1 numkeys key[key ...] arg [arg ...]
    
    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 key1 key2 first second
    1) "key1"
    2) "key2"
    3) "first"
    4) "second"
    

    EVALSHAEVAL 的参数差不多. 只是把脚本改成了缓存中脚本的 sha1 值, 其余没有区别.

  3. SCRIPT LOAD: 将脚本缓存到服务器中.

  4. SCRIPT FLUSH: 清空服务器中的所有脚本

  5. SCRIPT EXISTS: 判断脚本是否存在于服务器中

  6. SCRIPT KILL: 停止当前正在执行的脚本

在 Redis 中执行的 Lua 脚本必须是纯函数形式. 也就是说, 给定一段脚本并且传入相同的参数, 写入 Redis 中的数据也必须是一致的. Redis 会拒绝随机性的写入, 因为这会造成数据的不一致性.

Lua 脚本的持久化和主从复制(Redis 5.0 以下)


Redis 允许在 Lua 脚本中通过 redis.call()redis.pcall() 来执行 Redis 命令. 如果 Lua 脚本对 Redis 中的数据进行了更改, 那么除了更新数据库中的数据之外, 还会执行另外两个操作:

  • 把这段 Lua 脚本写入到 AOF 文件中, 保证 Redis 在重启时候可以执行该脚本
  • 把这段 Lua 脚本复制给从库执行, 保证主从数据一致性
127.0.0.1:6379> eval "redis.call('set', KEYS[1], ARGV[1]); return redis.call('get', KEYS[1])" 1 mykey myvalue
"myvalue"

# 查看 AOF 文件
➜ cat appendonly.aof
*5
$4
eval
$70
redis.call('set', KEYS[1], ARGV[1]); return redis.call('get', KEYS[1])
$1
1
$5
mykey
$7
myvalue

所以, 如果 Redis 接受随机性写入的话, Redis 在重启前后或者在主从库之间就会存在数据不一致的现象, 当然, 这是不被允许的.

Redis 防止随机写入(Redis 5.0 以下)


比如, 我在 Lua 脚本中获取当前时间并将当前时间 SET 到一个 KEY 中, Redis 就会拒绝操作并抛出一个异常; 也就是说, Redis 会拒绝存在随机写入的 Lua 脚本执行.

异常

127.0.0.1:6379> eval "local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
Write commands not allowed after non deterministic commands. Call redis.replicate_commands() at the start of your script in order to switch to single commands replication mode. 

Redis 中一共有 10 个随机类命令: SPOP, SRANDMEMBER, SSCAN, ZSCAN, HSCAN, SCAN, RANDOMKEY, LASTSAVE, PUBSUB, TIME.

当一些返回数据是无序的命令, 比如 SMEMBERS 在 Lua 中被调用时, 返回的数据都是进行过排序后返回的, 所以得到的数据顺序都是一致的.

并且 Redis 修改了 Lua 脚本中的随机数生成函数(math.random, math.randomseed)使得新脚本执行的时候, 返回的种子都是一样的. 所以在 Lua 脚本中, 如果未使用 math.randomseed ,仅仅使用 math.random, 生成的随机数序列都是一样的.

Redis 允许随机写入的情况


等下, 不是说为了保证 Redis 重启前后和主从之间的数据一致性, Redis 会拒绝执行执行存在随机写入的 Lua 脚本执行吗? 怎么又可以了呢?

从 Redis 3.2 开始(5.0 以后是默认开启), 提供了另外一种持久化和主从复制的方案可以允许随机写入. 相较于前一种直接复制 Lua 脚本并重新执行脚本这一方案, 第二种方案不复制 Lua 脚本, 并且脚本只会运行一次, 运行完后对数据库产生的数据变化会生成 Redis 命令用于持久化和主从同步. 由于 Lua 脚本只会执行一次, 所以就不存在之前执行多次造成的随机性不一致现象, 自然允许随机行操作了.

  1. 5.0 版本之前 Redis 3.2 提供了 redis.replicate_commands(), 但是需要在执行 Lua 脚本的时候的手动开启.

    127.0.0.1:6379> eval "redis.replicate_commands(); local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
    "1552060128"
    

    在 Lua 脚本中, 从调用 redis.replicate_commands() 开始到脚本结束, 这一部分脚本所产生的 Redis 命令会被包在一个 MULTI / EXEC 事务中, 并发给 AOF 或者从库. 当然, 只有对数据库中的数据产生变化的 Redis 命令才会被生成并包装进 MULTI / EXEC 事务.

    # AOF 文件
    ➜ cat appendonly.aof
    *1
    $5
    MULTI
    *3
    $3
    set
    $3
    now
    $10
    1552114016
    *1
    $4
    EXEC
    

    注意: Redis 只是会将调用 redis.replicate_commands() 后面的部分放进事务中. 在其前面的部分如果调用了写操作是会破坏数据的一致性的, 此时, redis.replicate_commands() 并不会生效. 见🌰:

    127.0.0.1:6379> eval "redis.call('set', 'key', 'value') redis.replicate_commands(); local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
    (error) ERR Error running script (call to f_a8c3ce5ccbfc3074b49ea277b7370ded0c2d354b): @user_script:1: @user_script: 1: Write commands not allowed after non deterministic commands. Call redis.replicate_commands() at the start of your script in order to switch to single commands replication mode.
    
    127.0.0.1:6379> keys *
      1) "now"
      2) "key"
    
    # AOF 文件
    ➜ cat appendonly.aof
    *3
    $4
    eval
    $156
    redis.call('set', 'key', 'value') redis.replicate_commands(); local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')
    $1
    0
    

    所以, 如果在 Lua 脚本中需要进行随机写入的话, 建议在脚本的开头就调用 redis.replicate_commands()

  2. 5.0 版本及以后版本默认开启

Redis 对于随机写入的持久化和主从复制的控制


Redis 3.2 还引入了另一个机制: 可以自行决定是否持久化或者进行主从复制, 可以通过 redis.set_repl(***) 设置, 参数可以为:

  • redis.REPL_ALL: 开启 AOF 持久化和主从复制(默认)
  • redis.REPL_AOF: 仅开启 AOF 持久化
  • redis.REPL_REPLICA: 仅开启主从复制
  • redis.REPL_SLAVE: 同 redis.REPL_REPLICA
  • redis.REPL_NONE: 都不开启 一般 redis.set_repl(***) 很少用到, 因为这会造成重启前后和主从库之间数据不一致. 保留默认的 redis.REPL_ALL 就可以了.

参考