dot 未来速度将降临您所在的城市。

加入我们参加 Redis Released

Redis 第七原则:我们优化快乐

第七原则,正如 Redis 宣言 所阐述的那样,真正符合我的信念和观点,可能也符合所有工程师(无论经验如何)的信念和观点。没有什么比想出一种聪明的方法来做一些新事物或做一些更好事情时产生的那种兴奋感更令人满意。这种快乐的冲动反过来会导致严重的成瘾,正如唐纳德·克努斯(“过早优化是万恶之源”)和兰德尔·门罗(例如 http://xkcd.com/1205/http://xkcd.com/1445/http://xkcd.com/1319/)很久以前就认识到的那样。

然而,我发现,每当我想要踏上优化之旅时,通过运用常识和冷静的逻辑,我可以让自己的成瘾得到满足和控制。这篇文章讲述了上周出现的三个这样的冲动。

通过分享知识进行优化

作为开源软件的倡导者和网络公民,我努力在所有与 Redis 相关的事情上帮助他人。当然,Redis 可以用作一个简单的键值存储,但它远不止这些,它几乎迫使你以智能的方式使用它。

所以,在我每天巡视互联网的过程中,我偶然发现了一个 SO 问题,并开始回答它。在我的回复中,我故意省略了如何扫描大型列表的细节——提问者似乎是一个初学者,我认为他不会理解——但仍然感到欣喜若狂,因为我帮助别人做得更好。希望我也鼓励他学习和理解他正在使用的命令,然后再深入到编码中(也许我还节省了世界一些 CPU 和网络资源)。

通过探索进行优化

虽然那个 SO 问题本身不值得大书特书——或者写博客(虽然我现在写了)——但它让我思考了那个缺失的 LSCAN 命令。有 SCAN 用于扫描键空间,HSCAN 用于哈希,SSCAN 用于集合,以及 ZSCAN 用于有序集合,但没有用于列表的命令。由于 Redis 的列表(以及 快速列表)是普通列表(或压缩列表的链接列表),因此以 O(N) 的复杂度随机访问它们的元素。拥有一个高效的 LSCAN 命令意味着以不同的方式实现 Redis 列表,并且由于没有免费的午餐,因此需要付出代价。列表对弹出和推送操作很有效(即 O(1)),并且考虑到宣言中的第三条原则(“用于基本 API 的基本数据结构”)和第五条原则(“我们反对复杂性”),如果你正在使用列表并且需要从一端扫描到另一端,那么你可能一开始就不应该使用列表。

但是,为了争论和好玩,你能以某种方式做到 LSCAN 会做的事情(如果它一开始就在那里)吗?有趣的问题……当然可以。首先,如果你不担心由于并发而导致的一致性问题,你可以轻松地使用带有 RPOPLPUSH 的循环,并保持一个计数来更有效地遍历列表。当然,如果并发是一个问题,你可能需要找到某种方法来复制列表并安全地遍历副本。

那么如何复制一个包含 1000 万个元素的列表呢?让我们先创建一个这样的列表(所有测试都在笔记本电脑上的 VM 中完成,所以 YMMV)。

foo@bar:~$ redis-benchmark -r 10000000 -n 10000000 -P 1000 lpush L __rand_int__
====== lpush L __rand_int__ ======
  10000000 requests completed in 1.30 seconds
…
814929.50 requests per second

foo@bar:~$ redis-cli llen  L
(integer) 10030000

快速复制需要在服务器上完成,所以复制列表的朴素方法是编写一个 Lua 脚本,它会逐个弹出元素并将它们推回到原始列表和目标列表中。我编写了这样一个脚本并在那个相当长的列表上对其进行了测试……

-- @desc:   copies a list with POP and PUSH
-- @usage:  redis-cli --eval copy_list_with_popnpush.lua <source> <dest>

local s = KEYS[1]
local d = KEYS[2]
local l = redis.call("LLEN", s)
local i = tonumber(l)

while i > 0 do
  local v = redis.call("RPOPLPUSH", s, s)
  redis.call("LPUSH", d, v)
  i = i - 1
end

return l

它运行起来非常慢,并在日志中产生了预期的 “检测到 Lua 慢速脚本” 消息。

foo@bar:~$ time redis-cli --eval copy_list_with_popnpush.lua L T
(integer) 10030000

real	0m23.579s
user	0m0.000s
sys	0m0.006s

23 秒太长了,虽然你可以对 LPUSH 的调用进行批处理,但也许 LRANGE 是一个更好的方法。

-- @desc:   copies a list with LRANGE
-- @usage:  redis-cli --eval copy_list_with_lrange.lua <source> <dest>

local s = KEYS[1]
local d = KEYS[2]
local i = tonumber(redis.call("LLEN", s))
local j = 0

while j < i do
  local l = redis.call("LRANGE", s, j, j+99)
  redis.call("LPUSH", d, unpack(l))
  j = j + 100
end
foo@bar:~$ redis-cli del T
(integer) 1
foo@bar:~$ time redis-cli --eval copy_list_with_lrange.lua L T
(integer) 10030000

real	0m11.148s
user	0m0.000s
sys	0m0.004s

11 秒比 23 秒好多了,但是如何对其进行 SORT——这样会更快吗?很可能,让我们来看看。

foo@bar:~$ redis-cli del T
(integer) 1
foo@bar:~$ time redis-cli sort L by nosort store T
(integer) 10030000

real	0m2.390s
user	0m0.000s
sys	0m0.003s

一个令人印象深刻的改进——只有 2.248 秒,大约比弹出和推送快 10 倍,但我们还能做得更好吗?让我看看 Redis 是否有任何 COPYDUPLICATECLONE 命令……没有,最接近的是 MIGRATE,但它只接受单个键名。但是等等![灯泡] DUMP 及其补充命令 RESTORE 怎么样……有可能吗?

-- @desc:   The fastest, type-agnostic way to copy a Redis key
-- @usage:  redis-cli --eval copy_key.lua <source> <dest> , [NX]

local s = KEYS[1]
local d = KEYS[2]

if redis.call("EXISTS", d) == 1 then
  if type(ARGV[1]) == "string" and ARGV[1]:upper() == "NX" then
    return nil
  else
    redis.call("DEL", d)
  end
end

redis.call("RESTORE", d, 0, redis.call("DUMP", s))
return "OK"
foo@bar:~$ redis-cli del T
(integer) 1
foo@bar:~$ time redis-cli --eval copy_key.lua L T
"OK"

real	0m1.661s
user	0m0.000s
sys	0m0.007s

这是一个非常棒的技巧——通过转储和恢复来复制速度要快得多,因为你绕过了所有数据结构的管理逻辑(有点像 BASIC 中的 POKE,并且是你可以走的最远的地方,直到指针被引入 Redis :P)。它几乎比朴素方法快 20 倍,理论上它应该适用于任何数据类型——列表、哈希、集合和有序集合。我必须记住这个小技巧,以备不时之需快速复制值。

通过黑客攻击进行优化

但是等等!我还想到了一些更糟糕的事情——如何让 Redis 比 Redis 更快?如果有人编写了一段代码可以输出与 Redis 兼容的值序列化,就像 DUMP 所做的那样呢?有了这个功能,你就可以非常非常非常快地将数据加载到 Redis 中。除了尝试直接写入 Redis 进程的内存空间之外,这种方法可以通过将 Redis 的部分负载转移到客户端本身来潜在地胜过任何传统的基于 RESP 的客户端。令人兴奋!

我知道至少有一段这样的代码,所以我真的很想看看 DUMP 的实现 [插入链接],看看是否可以将其提取出来并移植它,例如用于哈希表。然后我可以通过预处理和直接注入最终的序列化来测试加载 JSON,而不是使用 HMSET。可能性是无穷无尽的,但要实现它也需要付出巨大的努力。即使它奏效并且相对没有错误(是的,没错),我也会依赖 Redis 的内部私有 API,因此出现故障的可能性很大。除了这些担忧之外,这样做感觉也很不对劲。也许更好的方向是在客户端应用程序中嵌入 Redis,并实现主<->主复制来同步本地实例和远程实例……

所以我在此止步,我的思路仍然在轨道上,我的大部分疯狂的优化幻想尚未实现。我告诉你我可以控制我对快乐的成瘾,常识最终拉下了紧急制动,这是证明。但尽管如此,前几天 Salvatore 告诉我(关于一个完全不同的问题)说:“破坏事物是好事,是黑客精神的精髓 :-)”,所以也许我应该……

有疑问?有反馈?欢迎 发推发邮件 给我——我很乐意提供帮助 🙂