dot Redis 8 来了——而且是开源的

了解更多

Redis 的第七个原则:我们为了快乐而优化

正如 Redis 宣言 中阐述的第七个原则,确实符合我的信念和观点,可能也符合所有工程师(无论经验如何)的信念和观点。世界上没有什么比你从设计一种巧妙的方法来做一些新的和/或更好的事情时所获得的兴奋感更令人满足的了。反过来,这种快乐的冲击可以而且经常会导致严重的上瘾,正如 Donald Knuth(“过早的优化是万恶之源”)和 Randall Munroe(例如 http://xkcd.com/1205/, http://xkcd.com/1445/http://xkcd.com/1319/) 早已认识到的那样。

然而,我发现,每次我想要开始优化之旅时,通过运用常识和冷静的逻辑,我可以保持我的瘾得到满足和控制。这篇文章是关于过去一周的三个这样的冲动。

通过分享知识进行优化

作为开源软件的倡导者和一个正派的互联网公民,我努力在所有与 Redis 相关的事情上帮助他人。当然,Redis 可以用作一个愚蠢的键值存储,但它远不止于此,它几乎恳求被智能地使用。

所以,在我每天巡视互联网时,我遇到了这个 SO 问题 并开始回答它。在我的回复中,我特意省略了关于如何扫描大列表的细节——OP 似乎是一个初学者,我不认为他会理解它——但我仍然感到一阵快乐,因为我帮助别人做得更好。希望我也鼓励他先学习和理解他正在使用的命令,然后再投入编码(也许我还顺便为世界节省了一些 CPU 和网络资源)。

通过探索进行优化

虽然那个 SO 问题本身没有什么值得夸耀的——或者写博客的(尽管我刚刚做了)——但它让我想到了那个缺失的 LSCAN 命令。有 SCAN 用于扫描键空间,HSCAN 用于哈希,SSCAN 用于集合,ZSCAN 用于排序集合,但没有用于列表的。由于 Redis 的列表(和 Quicklists)是常规列表(或 ziplist 的链表),因此随机访问它们的元素是在 O(N) 复杂度内完成的。拥有一个高效的 LSCAN 命令意味着以不同的方式实现 Redis 列表,并且由于没有免费的午餐,因此需要付出代价。列表对于 pops 和 pushes 来说是好的(即 O(1)),并且考虑到宣言的原则 #3(“基本 API 的基本数据结构”)和 #5(“我们反对复杂性”),如果你正在使用一个列表并且需要从一端到另一端扫描它,那么你可能一开始就不应该使用列表。

但是,为了争论和仅仅为了好玩,你能以某种方式做 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

这是一个非常好的技巧——通过转储和恢复进行复制要快得多,因为你绕过了所有数据结构的管理逻辑(有点像古老的 POKE 在 BASIC 中,也是你所能走得最远的地方,直到将值的指针引入 Redis :P)。它比幼稚的方法好近 20 倍,并且理论上它应该适用于任何数据类型——列表、哈希、集合和排序集合。我必须记住这个小技巧,以便在我需要快速复制值时使用。

通过黑客攻击进行优化

但是等等!我甚至可以想到一些更糟糕的事情——如何让 Redis 比 Redis 运行得更快?如果有人编写了一段代码,可以像 DUMP 那样吐出与 Redis 兼容的值序列化?有了这个功能,你可以真的很快地将数据加载到 Redis 中。除非试图直接写入 Redis 进程的内存空间,否则这种方法可以通过将 Redis 的一些负载转移到客户端本身,从而有可能胜过任何基于 RESP 的传统客户端。太棒了!

我知道至少有一段这样的代码,所以现在我真的很想看看 DUMP 的实现 [插入链接],看看我是否可以把它提取出来并移植它,比如说移植到哈希映射。然后我可以测试通过预处理和直接注入它们的最终序列化而不是使用 HMSET 来加载 JSON。可能性确实是无穷无尽的,但是使其发生所需的努力也是如此。即使这样有效并且相对没有错误(是的,没错),我也将依赖于 Redis 的内部私有 API,因此潜在的破坏性是巨大的。撇开这些担忧不谈,这只是 感觉 不对。也许一个更好的方向是在客户端应用程序中嵌入 Redis 并实现主 <-> 主复制以在本地和远程实例之间进行同步...

所以我就在这里停止了,让我的思路仍然在它的轨道上,而我大部分的狂野优化幻想都没有实现。我告诉过你我可以控制我对快乐的瘾,常识终于拉下了紧急制动器,这就是证明。但是尽管如此,前几天 Salvatore 告诉我(在另一件事上):“打破事物是好的,是黑客的精髓 :-)”,所以也许我应该...

有问题吗?反馈?随时 发推文给我发电子邮件 – 我随时待命 🙂