脚本复制

在了解了 Redis 服务器传播普通 Redis 命令的方法之后,我们接下来要了解的将是 Redis 传播 Lua 脚本的具体方法。

Redis 服务器拥有两种不同的脚本复制模式,第一种是从 Redis 2.6 版本开始支持的脚本传播模式(whole script replication),而另一种则是从 Redis 3.2 版本开始支持的命令传播模式(script effect replication),本节接下来将分别介绍这两种模式。

脚本传播模式

处于脚本传播模式的主服务器会将被执行的脚本及其参数(也即是 EVAL 命令本身)复制到 AOF 文件以及从服务器里面。因为带有副作用的函数在不同服务器上运行时可能会产生不同的结果,从而导致主从服务器不一致,所以在这一模式下执行的脚本必须是纯函数:换句话说,对于相同的数据集,相同的脚本以及参数必须产生相同的效果。

为了保证脚本的纯函数性质,Redis 对处于脚本传播模式的 Lua 脚本设置了以下限制:

  • 脚本不能访问 Lua 的时间模块、内部状态又或者除给定参数之外的其他外部信息。

  • 在 Redis 的命令当中,存在着一部分带有随机性质的命令,这些命令对于相同的数据集以及相同的参数可能会返回不同的结果。如果脚本在执行这类带有随机性质的命令之后,尝试继续执行写命令,那么 Redis 将拒接执行该命令并返回一个错误。带有随机性质的 Redis 命令分别为:SPOPSRANDMEMBERSCANSSCANZSCANHSCANRANDOMKEYLASTSAVEPUBSUBTIME

  • 当用户在脚本中调用 SINTERSUNIONSDIFFSMEMBERSHKEYSHVALSKEYS 这七个会以随机顺序返回结果元素的命令时,为了消除其随机性质,Lua 环境在返回这些命令的结果之前会先对结果中包含的元素进行排序,以此来确保命令返回的元素总是有序的。

  • Redis 会确保每个被执行的脚本都拥有相同的随机数生成器种子,这意味着如果用户不主动修改这一种子,那么所有脚本在默认情况下产生的伪随机数列都将是相同的。

脚本传播模式是 Redis 复制脚本时默认使用的模式。如果用户在执行脚本之前没有修改过相关的配置选项,那么 Redis 将使用脚本传播模式来复制脚本。

作为例子,如果我们在启用了脚本传播模式的主服务器执行以下命令:

  1. EVAL "redis.call('SET', KEYS[1], 'hello world');redis.call('SET', KEYS[2], 10086);redis.call('SADD', KEYS[3], 'apple', 'banana', 'cherry')" 3 'msg' 'number' 'fruits'

那么主服务器将向从服务器发送完全相同的 EVAL 命令:

  1. EVAL "redis.call('SET', KEYS[1], 'hello world');redis.call('SET', KEYS[2], 10086);redis.call('SADD', KEYS[3], 'apple', 'banana', 'cherry')" 3 'msg' 'number' 'fruits'

命令传播模式

处于命令传播模式的主服务器会将执行脚本产生的所有写命令用事务包裹起来,然后将事务复制到 AOF 文件以及从服务器里面。因为命令传播模式复制的是写命令而不是脚本本身,所以即使脚本本身包含副作用,主服务器给所有从服务器复制的写命令仍然是相同的,因此处于命令传播模式的主服务器能够执行带有副作用的非纯函数脚本。

除了脚本可以不是纯函数之外,与脚本传播模式相比,命令传播模式对 Lua 环境还有以下放松:

  • 用户可以在执行 RANDOMKEYSRANDMEMBER 等带有随机性质的命令之后继续执行写命令。

  • 脚本的伪随机数生成器在每次调用之前,都会随机地设置种子。换句话说,被执行的每个脚本在默认情况下产生的伪随机数列都是不一样的。

除了以上两点之外,命令传播模式跟脚本传播模式的 Lua 环境限制是一样的,比如说,即使在命令传播模式下,脚本还是无法访问 Lua 的时间模块以及内部状态。

为了开启命令传播模式,用户在使用脚本执行任何写操作之前,需要先在脚本里面调用以下函数:

  1. redis.replicate_commands()

redis.replicate_commands() 只对调用该函数的脚本有效:在使用命令传播模式执行完当前脚本之后,服务器将自动切换回默认的脚本传播模式。

作为例子,如果我们在主服务器执行以下命令:

  1. EVAL "redis.replicate_commands();redis.call('SET', KEYS[1], 'hello world');redis.call('SET', KEYS[2], 10086);redis.call('SADD', KEYS[3], 'apple', 'banana', 'cherry')" 3 'msg' 'number' 'fruits'

那么主服务器将向从服务器复制以下命令:

  1. MULTI
  2. SET "msg" "hello world"
  3. SET "number" "10086"
  4. SADD "fruits" "apple" "banana" "cherry"
  5. EXEC

选择性命令传播

为了进一步提升命令传播模式的威力,Redis 允许用户在脚本里面选择性地打开或者关闭命令传播功能,这一点可以通过在脚本里面调用 redis.set_repl() 函数并向它传入以下四个值来完成:

  • redis.REPL_ALL —— 默认值,将写命令传播至 AOF 文件以及所有从服务器。

  • redis.REPL_AOF —— 只将写命令传播至 AOF 文件。

  • redis.REPL_SLAVE —— 只将写命令传播至所有从服务器。

  • redis.REPL_NONE —— 不传播写命令。

redis.replicate_commands() 函数一样,redis.set_repl() 函数也只对执行该函数的脚本有效。用户可以通过这一功能来定制被传播的命令序列,以此来确保只有真正需要的命令会被传播至 AOF 文件以及从服务器。


代码清单 18-1 储存并集计算结果的脚本

  1. -- 打开目录传播模式
  2. -- 以便在执行 SRANDMEMBER 之后继续执行 DEL
  3. redis.replicate_commands()
  4.  
  5. -- 集合键
  6. local set_a = KEYS[1]
  7. local set_b = KEYS[2]
  8. local result_key = KEYS[3]
  9.  
  10. -- 随机元素的数量
  11. local count = tonumber(ARGV[1])
  12.  
  13. -- 计算并集,随机选出指定数量的并集元素,然后删除并集
  14. redis.call('SUNIONSTORE', result_key, set_a, set_b)
  15. local elements = redis.call('SRANDMEMBER', result_key, count)
  16. redis.call('DEL', result_key)
  17.  
  18. -- 返回随机选出的并集元素
  19. return elements

举个例子,代码清单 18-1 所示的脚本会将给定的两个集合的并集计算结果储存到一个集合里面,接着使用 SRANDMEMBER 命令从结果集合里面随机选出指定数量的元素,然后删除结果集合并向调用者返回被选中的随机元素。

如果我们使用以下方式执行这个脚本:

  1. redis-cli --eval union_random.lua set_a set_b union_random , 3

那么主服务器将向从服务器复制以下写命令:

  1. MULTI
  2. SUNIONSTORE "union_random" "set_a" "set_b"
  3. DEL "union_random"
  4. EXEC

但仔细地思考一下就会发现,SUNIONSTORE 命令创建的 union_random 实际上只是一个临时集合,脚本在取出并集元素之后就会使用 DEL 命令将其删除,因此主服务器即使不将 SUNIONSTORE 命令和 DEL 命令复制给从服务器,主从服务器包含的数据也是相同的。

代码清单 18-2 展示了根据上述想法对脚本进行修改之后得出的新脚本,新旧两个脚本在执行时将得到相同的结果,但主服务器在执行新脚本时将不会向从服务器复制任何命令。


代码清单 18-2 带有选择性命令传播特性的脚本

  1. -- 打开目录传播模式
  2. -- 以便在执行 SRANDMEMBER 之后继续执行 DEL
  3. redis.replicate_commands()
  4.  
  5. -- 因为这个脚本即使不向从服务器传播 SUNIONSTORE 命令和 DEL 命令
  6. -- 也不会导致主从服务器数据不一致,所以我们可以把命令传播功能关掉
  7. redis.set_repl(redis.REPL_NONE)
  8.  
  9. -- 集合键
  10. local set_a = KEYS[1]
  11. local set_b = KEYS[2]
  12. local result_key = KEYS[3]
  13.  
  14. -- 随机元素的数量
  15. local count = tonumber(ARGV[1])
  16.  
  17. -- 计算并集,随机选出指定数量的并集元素,然后删除并集
  18. redis.call('SUNIONSTORE', result_key, set_a, set_b)
  19. local elements = redis.call('SRANDMEMBER', result_key, count)
  20. redis.call('DEL', result_key)
  21.  
  22. -- 返回随机选出的并集元素
  23. return elements

需要注意的是,虽然选择性复制功能非常强大,但用户如果没有正确地使用这个功能的话,那么就可能会导致主从服务器的数据出现不一致,因此用户在使用这个功能的时候必须慎之又慎。

模式的选择

既然存在着两种不同的脚本复制模式,那么如何选择正确的模式来复制脚本就显得至关重要了。一般来说,用户可以根据以下情况来判断应该使用哪种复制模式:

  • 如果脚本的体积不大,执行的计算也不多,但是却会产生大量命令调用,那么使用脚本传播模式可以有效地节约网络资源。

  • 相反地,如果一个脚本的体积非常大,执行的计算非常多,但是只会产生少量命令调用,那么使用命令传播模式可以通过重用已有的计算结果来节约计算资源以及网络资源。

举个例子,假设我们正在开发一个游戏系统,该系统的其中一项功能就是在节日给符合条件的一批用户增加指定数量的金币。为此,我们可能会写出包含以下代码的脚本,并在执行这个脚本的时候,通过 KEYS 变量将数量庞大的用户余额键名传递给脚本:

  1. local user_balance_keys = KEYS
  2. local increment = ARGV[1]
  3.  
  4. -- 遍历所有给定的用户余额键,对它们执行 INCRBY 操作
  5. for i = 1, #user_balance_keys do
  6. redis.call('INCRBY', user_balance_keys[i], increment)
  7. end

很明显,这个脚本将产生相当于传入键数量的 INCRBY 命令:在用户数量极其庞大的情况下,使用命令传播模式对这个脚本进行复制将耗费大量网络资源,但使用脚本传播模式来复制这个脚本则会是一件非常容易的事。

现在,考虑另一种情况,假设我们正在开发一个数据聚合脚本,它包含了一个需要进行大量聚合计算以及大量数据库读写操作的 aggregate_work() 函数:

  1. local result_key = KEYS[1]
  2.  
  3. local aggregate_work = function()
  4. -- ... 省略大量代码
  5. end
  6.  
  7. redis.call('SET', result_key, aggregate_work())

因为执行 aggregate_work() 函数需要耗费大量计算资源,所以如果我们直接复制整个脚本的话,那么相同的操作就要在每个从服务器上面都执行一遍,这对于宝贵的计算资源来说无疑是一种巨大的浪费;相反地,如果我们使用命令传播模式来复制这个脚本,那么主服务器在执行完这个脚本之后,就可以通过 SET 命令直接将函数的计算结果复制给各个从服务器。