【问题标题】:Optimize returning top results from intersecting Sorted Set and Set in Redis优化Redis中Sorted Set和Set相交返回top结果
【发布时间】:2014-08-22 06:04:00
【问题描述】:

我正在尝试优化我的 Redis 代码,但我目前在扩展我的解决方案时遇到了一些困难。 Redis 托管在 Redis Cloud 上,这是 Heroku 上的应用程序,我使用 Ruby 作为我的语言。

我的 Redis 设置:

我有一系列有序集合,每个集合包含大约 1,000 个评分成员和系统中每个用户的相应集合(可以是散列、字符串、列表、集合、有序集合或任何其他结构)。

例如在 news:sports 键中,我有以下结构。其他关键示例是 news:entertainment、news:business。

news:sports -- 会员评分

StoryOID1 1000
StoryOID2 999
StoryOID3 998
StoryOID4 997
StoryOID5 996
...

对于每个主排序集 (user1:news:sports),我还有一个用户特定的键(一组),其中包含用户已经看过的故事列表。即

seen:user1:sports StoryOID2 

我的挑战:

在每个用户请求中,我需要返回用户尚未看到的特定排序集中的前 3 名成员(得分最高,尽管我不需要知道分数)。我不想将结果保存在 Redis 中,因为没有长期使用,我只需要删除密钥。

鉴于上面的例子,用户 1 请求体育:新闻,我会返回:

StoryOID1
StoryOID3
StoryOID4

作为我的代码的一部分,我循环遍历 10 个排序集(10 个流派),从每个集返回前 3 个不可见的故事,每个请求总共返回 30 个 OID。

对于每个用户请求:

Do this 10 times:         
     ZRANGEBYSCORE top 24 members
     loop user genres key using SISMEMBER until I return 3 unseen members
end

在 60 dynos (heroku) 上进行基准测试时,我只能达到 500 个同时连接(并且响应时间为 1000 毫秒),下面的 Redis 循环是瓶颈。我的目标是在当前设置上扩展几个倍数。任何东西都可以改变以扩展这个过程。

我当前的进程(在 Ruby 中):

def newslist (userOID,genres)

    #pull top 24 stories for the given news:genres -- 24 could be replaced by 1,3,6,12 etc
    newsscores = @@redis.zrevrangebyscore("news:#{genres}", "+inf", "-inf", :limit => [0, 24],:with_scores => true)  

    newsstories = Array.new(3)
    i = 0 #news acceptance counter
    loopcnt = 0 #loop counter
    while i < 3 
      if newsscores.count == loopcnt - 1 #loop to the max number of news returned in news news
        break #breakout of loop
      end
      seen = @@redis.sismember("seen:#{userOID}:#{genres}", newsscores[loopcnt][0])
      if seen == false
        newsstories[i] = newsscores[loopcnt][0]
        i+=1
      end
      loopcnt += 1
    end
    if i==3
      return newsstories #return 3 news newss
    else
      return 0 #return 0 -- this should cause a repick
    end
    return 0 #return 0 -- this should cause a repick
end

我知道我为大量的 Redis requets 付出了巨大的代价。我目前的思考过程是基本上将上述内容转换为可以在服务器端运行的 Lua 脚本,但我不禁觉得有一个更优雅的解决方案可以更好地扩展。

有没有更好的办法?

【问题讨论】:

  • 你每次都打电话给 sismember 有什么原因吗?您可以在循环 IIUC 之前调用它一次。 (顺便说一句 - 感谢您使用我们的服务!)
  • 挑战是不知道调用多少次。
  • 我在循环中使用 sismembers,因为一旦获得前 3 个未见过的成员,我就会停止。我试图限制 puling 成员 4 -24 的 sismembers 的开销(除非需要它们)。循环的嵌入式特性和扩展到 1000 个并发请求似乎会在服务器上产生大量不需要的负载。
  • 是的 - 我没有想清楚 :) 让我再考虑一下。

标签: ruby optimization heroku redis scalability


【解决方案1】:

首先,是的:你应该 100% 使用 Lua。检查 Redis 机器上的 CPU。我敢打赌它不会燃烧。此时的瓶颈几乎肯定是网络吞吐量,因为每次点击 SISMEMBER 时都需要来回调用(每个用户最多 24 次)。这是很多不必要的网络活动。当您考虑到您在 SISMEMBER 之上执行的逻辑可以很容易地在服务器端完成时,这尤其不必要,并且在您完全完成循环之前没有理由将任何内容发送回您的客户端。该逻辑也适用于最初的ZRANGEBYSCORE top 24 members。你可以直接翻译整个:

Do this 10 times:         
     ZRANGEBYSCORE top 24 members
     loop user genres key using SISMEMBER until I return 3 unseen members
end

进入 Lua 并从每位用户 250 次网络点击减少到每位用户仅 1 次。这将是一个巨大的、巨大的胜利。最重要的是,当您发起 Redis 调用时,您将向 Redis 发送和返回的信息要少得多。这里有一些 Lua 伪代码,应该让你知道你想做什么:

local genres = {KEYS[1], KEYS[2], KEYS[3], KEYS[4], KEYS[5], KEYS[6], KEYS[7], KEYS[8], KEYS[9], KEYS[10]}
local user_seen_genre_sets = {KEYS[11], KEYS[12], KEYS[13], KEYS[14], KEYS[15], KEYS[16], KEYS[17], KEYS[18], KEYS[19], KEYS[20]}
local user_id = ARGV[1]

to_return = {{},{},{},{},{},{},{},{},{},{}}
for i = 1, #genres do
  possible_stories = redis.call('ZREVRANGEBYSCORE', genres[i], 'inf', 0, 'LIMIT', 0, 24)
  --call SISMEMBER on each story above with the appropriate user_unseen_genre_sets key
  --add the first 3 results to to_return[i], then stop the loop.
end

return to_return

为什么使用 Lua 而不是管道?

Itamar Haber 提出了一个很好的观点,即您可能希望为此使用分解的管道而不是单个 Lua 脚本,因为 Lua 脚本可能会阻塞您的 Redis 服务器太长时间。以下是为什么要使用 Lua 脚本而不是分解管道的几个原因:

  1. 我从来没有在 Redis 上看到一个 Lua 脚本不执行 KEYS(*) 之类的操作需要超过 10 毫秒的时间。所提到的操作成本都不应超过 log(n),因此如果您期望大量数据增长,您也可以很好地适应未来。如果您的 Redis 服务器被阻塞的时间过长,这更多地表明您需要更大的服务器,因为您正在运行的所有操作都不是非常密集的(如前所述,最多 log(n))。

  2. Lua 脚本的主要好处之一是您将逻辑发送到服务器端运行,而不是来回发送一堆数据来运行您的逻辑客户端(即获取所有可能的故事并将它们发送给客户端。现在将它们一一发送回 Redis 以运行 ISMEMBER)。与在 Redis 和 Lua 中运行更多操作相比,通过网络发送的所有数据将是一个更大的瓶颈,这两者都非常非常快。

因此,总而言之,尽管提出了有效的问题,但我坚定地支持 Lua 方法。如果您愿意运行基准测试并与我们分享,那真是太棒了,因为我猜切换它会改善大约两个数量级的情况。

【讨论】:

  • 很好的答案@Eli - 网络是惩罚当然是关键,但我会小心 Lua 化整个事情,因为在更极端的情况下(>>24)它可能会阻止其他客户端...作为一种折衷方案,我会首先尝试管道一些东西(为了有效性[即在 3 场比赛后停止])和“批处理”一些操作。例如,执行 ZREVRANGEBYSCORE,然后流水线化所有 SISMEMBER。
  • @ItamarHaber 我已经在上面回复了。感谢您提出这一点!
  • @Eli - 感谢您的洞察力。在我的大多数情况下,我发现 ZREVRANGEBYSCORE 的 24 个成员按分数计算是多余的(最初我这样做是为了限制一些额外的 redis 调用)。在 lua 脚本中,如果 7-24 需要,我最好拉出 ZREVRANGEBYSCORE 的 6 个(或 12 个)成员并循环? Redis 文档似乎表明 big(O) 在 zrangebyscore 或 6 或 24 个成员上是等效的,只要限制是恒定的。还有其他费用我应该注意吗?
  • @tjrburgess 是的,差异将是一个常数,而且由于您没有将数据发送到任何地方,因此这两个调用都不会特别昂贵。您不太可能体验到差异,这听起来像是过早的优化。无论哪种方式,您都需要建立一个“如果需要,获得更多”子句,以防即使 24 个项目还不够。如果你真的想要,你可以对理想的数字进行基准测试,但是在你的网站变得非常非常大之前,你只是猜测它真的很好。
【解决方案2】:

此处无需使用 Lua 脚本,但根据您的数据大小,此计算的 Lua 版本可能会更快(取决于 SISMEMBER 与 ZUNIONSTORE 与 SDIFFSTORE + ZINTERSTORE 的性能)。通常,只要以下 3 个假设,您无需多次往返和无需 Lua 脚本即可计算所需的一切。

  1. 您的 ZSET 都使用正的非零分数(如果所有分数都 >1 会更容易,我会假设这一点)
  2. 您的 SET 都包含与您的排序集相同的成员
  3. ZSET 中的最高分数是固定的,或者至少可以有界(1000000 之类的值是完全合理的)

这里重要的操作是ZUNIONSTORE,它可以将SET作为输入,其成员表现得好像所有的分数都是1。

要从新闻 ZSET 中获取前 3 个故事,不包括给定用户已阅读的故事,您可以进行以下调用:

ZUNIONSTORE temp 2 news:sports seen:user1:sports WEIGHTS 1 -1000000
ZREVRANGEBYSCORE temp inf 0 LIMIT 0 3
DEL temp

您可以使用 MULTI/EXEC 事务来包装它,这样您就不会在查询之后放置任何额外的数据,将所有数据流水线化等。这个有一个限制,即您阅读的故事数​​量和阅读的数量每个类别中的故事都增加了,这个执行速度较慢。

作为替代方案,如果您的辅助 SET 具有与(例如)您的 news:sports ZSET 相同的成员,您可以改为执行:

SDIFFSTORE temp news:sports:set seen:user1:sports
ZINTERSTORE temp 2 temp news:sports WEIGHTS 0 1
ZREVRANGEBYSCORE temp inf -inf LIMIT 0 3
DEL temp

这将消除分数要求,但会通过 SET 中的条目增加每个故事的数据大小。对于新闻 SET、ZSET 和用户看到的 SET 中的每个条目,这也会变慢,但常数不同,因此可能会更快,具体取决于您的数据大小。

【讨论】:

  • ZUNIONSTORE 是一个非常缓慢的操作,它以 M*log(M) 的速度增长,其中 M 是两个集合中所有唯一元素的大小。如果你计划在未来的任何时候有很多故事,这将很快成为你的瓶颈。第二种方法几乎相同。上述两个答案都比在 Lua 中实现要快一些,但现在它们会更慢,而且在生产中要慢得多。除了稍微加快开发时间之外,您不会通过使用它们获得任何好处。我想不出任何好的理由来使用它们。
  • 对实际代码进行基准测试表明,使用故事 ZSET 大小 1000 作为提供的操作,Lua 可以比我描述的任何一种方法快约 3-13 倍。虽然在这种情况下 ZUNIONSTORE 并不是最快的选择,但将其称为“极其缓慢的操作”却没有抓住重点:它的速度与完成工作所需的速度完全一样(模优化可能会使其更快)。
猜你喜欢
  • 2020-07-22
  • 1970-01-01
  • 1970-01-01
  • 2014-06-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-12-18
相关资源
最近更新 更多