vlambda博客
学习文章列表

Redis 管道、事务、Lua 脚本对比

概述

Redis 提供三种将客户端多条命令打包发送给服务端执行的方式: Pipelining(管道) 、 Transactions(事务) 和 Lua Scripts(Lua 脚本)。本文不会过细的讨论三种方式的基础知识,将从这三种方式的 优势 、 局限性 和 原子性 方面展开讨论

Pipelining(管道)

Redis 管道是三者之中最简单的,当客户端需要执行多条 redis 命令时,可以通过管道一次性将要执行的多条命令发送给服务端,其作用是为了降低 RTT(Round Trip Time) 对性能的影响,比如我们使用 nc 命令将两条指令发送给 redis 服务端

$ printf "INCR x\r\nINCR x\r\n" | nc localhost 6379
:1
:2

可以看到,管道只是简单的将多个命令拼接在一起,命令之间用换行符(/r/n)分割,并没有在第一条命令前或最后一条命令后面添加开始/结束标志位

redis 服务端接收到管道发送过来的多条命令后,会一直执命令,并将命令的执行结果进行缓存,直到最后一条命令执行完成,再所有命令的执行结果一次性返回给客户端

Pipelining 的优势

在性能方面, Pipelining 有下面两个优势:

  • 将多条命令打包一次性发送给服务端,减少了客户端与服务端之间的网络调用次数,节省了  RTT
  • 避免了上下文切换,当客户端/服务端需要从网络中读写数据时,都会产生一次系统调用,系统调用是非常耗时的操作,其中设计到程序由用户态切换到内核态,再从内核态切换回用户态的过程。当我们执行 10 条  redis 命令的时候,就会发生 10 次用户态到内核态的上下文切换,但如果我们使用  Pipeining 将多条命令打包成一条一次性发送给服务端,就只会产生一次上下文切换

Pipelining 原子性

我们都知道, redis 执行命令的时候是单线程执行的,所以 redis 中的所有命令都具备原子性,这意味着 redis 并不会在执行某条命令的中途停止去执行另一条命令

但是 Pipelining 并不具备原子性,想象一下有两个客户端 client1 和 client2 同时向 redis 服务端发送 Pipelining 命令,每条 Pipelining 包含 5 条 redis 命令。 redis 可以保证 client1 管道中的命令始终是顺序执行的, client2 管道中的命令也是一样,始终按照管道中传入的顺序执行命令

但是 redis 并不能保证等 client1 管道中的所有命令执行完成,再执行 client2 管道中的命令,因此,在服务端中的命令执行顺序有可能是下面这种情况这种行为显示 Pipelining 在执行的时候并不会阻塞服务端。即使 client1 向客户端发送了包含多条指令的 Pipelining ,其他客户端也不会被阻塞,因为他们发送的指令可以插入到 Pipelining 中间执行

Pipelining 局限性

只有在 Pipelining 内所有命令执行完后,服务端才会把执行结果通过数组的方式返回给客户端。在执行 Pipelining 内的命令的时候,如果某些指令执行失败, Pipelining 仍会继续执行

比如下面的例子

$ printf "SET name huangxy\r\nINCR name\r\nGET name\r\n" | nc localhost 6379
+OK
-ERR value is not an integer or out of range
$6
huangxy

Pipelining 中第二条指令执行失败, Pipelining 并不会停止,而是会继续执行,等所有命令都执行完的时候,再将结果返回给客户端,其中第二条指令返回的是错误信息

Pipelining 的这个特性会导致一个问题,就是当 Pipelining 中的指令需要读取之前指令设置 key 的时候,需要额外小心,因为 key 的值有可能会被其他客户端修改。此时 Pipelining 的执行结果往往就不是我们所预期的

Pipelining 使用场景

  • 对性能有要求
  • 需要发送多个指令到服务端
  • 不需要上个命令的返回结果作为下个命令的输入

Transactions(事务)

redis 中的事务,跟我们之前在学关系型数据库的时候所了解到的事务概念有点区别。 redis 中的事务机制主要是用来对多个命令进行排队,并在最后决定是否需要执行事务中的所有命令与否

与管道不同,事务使用特殊的命令来标记事务的开始和结束( MULTI 、 EXEC 、 DISCARD )。服务器还可以对事务中的命令进行排队(这样客户端可以一次发送一条命令)。除此之外,一些第三方库还喜欢在客户端中对事务的命令进行缓存,然后通过在管道中发送整个事务的方式对其进行优化

事务的优点

事务提供了 WATCH 命令,使我们可以实现 CAS 功能,比如通过事务,我们可以实现跟 INCR 命令一样的功能

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

事务的原子性

redis 事务具备原子性,当一个事务正在执行时,服务端会阻塞其接收到的其他命令,只有在事务执行完成时,才会执行接下来的命令,因此事务具备原子性

事务的局限性

跟 Pipelining 一样,只有在事务执行完成时,才会把事务中多个命令的结果一并返回给客户端,因此客户端在事务还没有执行完的时候,无法获取其命令的执行结果

如果事务中的其中一个命令发生错误,会有以下两种可能性:

  • 当发生语法错误,在执行  EXEC 命令的时候,事务将会被丢弃,不会执行
  • 当发生运行时错误(操作了错误的数据类型)时,  redis 会将报错信息缓存起来,继续执行后面的命令,并在最后将所有命令的执行结果返回给客户端(报错信息也会返回)。这意味着  redis 事务中没有回滚机制

事务使用场景

  • 需要原子地执行多个命令
  • 不需要事务中间命令的执行结果来编排后面的命令

Lua 脚本

redis 从 2.6 版本开始引入对 Lua 脚本的支持,通过在服务器中嵌入 Lua 环境, redis 客户端可以直接使用 Lua 脚本,在服务端原子地执行多个 redis 命令

Lua 脚本的优势

与 Pipelining 和 事务不同的是,在脚本内部,我们可以在脚本中获取中间命令的返回结果,然后根据结果值做相应的处理(如 if 判断)

local key = KEYS[1]
local new = ARGV[1]

local current = redis.call('GET', key)
if (current == falseor (tonumber(new) < tonumber(current)) then
  redis.call('SET', key, new)
  return 1
else
  return 0
end

同时, redis 服务端还支持对 Lua 脚本进行缓存(使用 SCRIPT LOAD 或 EVAL 执行过的脚本服务端都会对其进行缓存),下次可以使用 EVALSHA 命令调用缓存的脚本,节省带宽

Lua 脚本的原子性

Lua 脚本跟事务一样具备原子性,当脚本执行中时,服务端接收到的命令会被阻塞

Lua 脚本的局限性

Lua 脚本在功能上没有过多的限制,但要注意的一点是,Lua 脚本在执行的时候,会阻塞其他命令的执行,所以不宜在脚本中写太耗时的处理逻辑

Lua 脚本的使用场景

  • 需要原子性地执行多个命令
  • 需要中间值来组合后面的命令
  • 需要中间值来编排后面的命令
  • 常用于扩展  redis 功能,实现符合自己业务场景的命令

参考文档

  • https://redis.io/topics/pipelining
  • https://redis.io/topics/transactions
  • https://redis.io/commands/eval
  • https://rafaeleyng.github.io/redis-pipelining-transactions-and-lua-scripts
  • 《Redis 设计与实现》 黄健宏著