Redis作为MySQL的写回式浏览量缓存

8
我有一个非常高的吞吐量站点,我想为每个页面在mySQL数据库中存储“查看次数”(由于遗留原因,它们最终必须在mySQL中结束)。
庞大的访问量使得使用SQL“UPDATE ITEM SET VIEW_COUNT=VIEW_COUNT+1”类型的语句变得不切实际。有数百万个项目,但大多数只被浏览了少数几次,其他则被浏览多次。
因此,我考虑使用Redis来收集查看次数,并使用后台线程将计数写入mySQL。如何执行此操作?这种方法存在一些问题:
  • 后台线程运行的频率是多少?
  • 如何确定要写回mySQL的内容?
  • 是否应该为每个被访问的ITEM存储一个Redis KEY?
  • 应该使用什么TTL?
  • 是否已经有一些预先构建的解决方案或演示文稿可以帮助我解决部分问题等。
我在StackOverflow上看到了非常相似的问题,但没有一个很好的答案...希望现在有更多关于Redis的知识。

大家好,提供的答案非常有教育意义。我知道其中可能存在一些问题,但是作为一个开端,我会点赞它们,因为这些想法和讨论非常棒。 - OneSolitaryNoob
最后,纯编码只有几行,“复杂性”通常来自“如果”即错误情况。写实际代码会很有趣。;-) - flaschenpost
3个回答

7
我认为您需要换个角度看待一些问题,才能得出答案。
“后台线程运行频率如何?”要回答这个问题,需要先回答以下几个问题:您可以承受多少数据丢失?MySQL中的数据是基于什么原因存储的,并且有多频繁访问该数据?例如,如果DB每天只需查询一次以生成报告,则可能只需要每天更新一次。另一方面,如果Redis实例关闭了怎么办?您可以丢失多少次增量并仍然保持“正常”?这些将提供有关更新MySQL实例的频率的答案,我们无法为您提供解答。
我会使用非常不同的策略来在Redis中存储此数据。假设您决定每小时“刷新到DB”,则可以使用哈希表将每个命中存储在具有以下结构的键名中:
interval_counter:DD:HH
interval_counter:total

使用页面ID(例如URI的MD5摘要、URI本身或您当前使用的任何ID)作为哈希键,并对页面视图进行两次递增;每个哈希值各一次。这为您提供了每个页面的当前总数和要更新的页面子集。
然后,您可以设置cron作业在整点后一分钟左右运行,通过获取前一个小时的哈希值来下载所有具有更新视图计数的页面。这为您提供了一种非常快速的获取数据以更新MySQL DB的方法,同时避免了任何需要进行时间戳等计算或玩弄技巧的需要。通过从不再进行增量的键中提取数据,可以避免由于时钟偏移而导致的竞争条件。
您可以在每日键上设置过期时间,但我更愿意使用cron作业在成功更新DB后将其删除。这意味着如果cron作业失败或未能执行,则仍然存在数据。它还通过不更改键来为前端提供了完整的已知点击计数数据集。如果您愿意,甚至可以保留每日数据以便能够查看页面的流行程度。例如,如果您通过cron作业设置到期时间而不是删除来保留每日哈希值7天,则可以显示每个页面过去一周每天的流量。
执行两个hincr操作可以单独完成或者通过流水线完成,仍然表现良好,并且比在代码中进行计算和数据操作更有效率。
现在是关于过期低流量页面与内存使用的问题。首先,您的数据集似乎不需要大量内存。当然,很大程度上取决于您如何识别每个页面。如果您有数字ID,则内存要求将相当小。如果您仍然有太多的内存,可以通过配置进行调整,如果需要,甚至可以使用32位编译的redis来显著减少内存使用。例如,我在互联网十大繁忙论坛之一中管理的数据(在此答案中描述)消耗了不到3GB的数据。我还将计数器存储在比我在此处描述的“时间窗口”键中更多。
话虽如此,在这种用例中,Redis是缓存。如果在上述选项之后仍然使用太多内存,则可以在键上设置过期时间并将过期命令添加到每个ht中。更具体地说,如果按照上述模式执行,您将执行以下操作:
hincr -> total
hincr -> daily
expire -> total

这使您可以通过每次访问时延长其过期时间来保持任何活跃使用的内容的新鲜度。当然,要做到这一点,您需要包装您的显示调用以捕获总哈希上hget的空答案,并从MySQL DB中填充它,然后递增。您甚至可以将两者作为递增操作。这将保留上述结构,并且如果Redis节点需要重新填充,则可能需要相同的代码库来更新Redis服务器。为此,您需要考虑并决定哪个数据源将被视为权威。
您可以通过根据您从早期问题确定的数据完整性参数修改间隔来调整cron作业的性能。要获得更快运行的cron作业,您可以缩小窗口。使用此方法,缩小窗口意味着您应该有一个较小的要更新的页面集合。这里的一个重要优势是您不需要找出需要更新的键,然后去获取它们。您可以执行hgetall并迭代哈希的键以进行更新。这也通过一次检索所有数据来节省了许多往返。在任一情况下,如果您可能还需要考虑第二个Redis实例从主实例中读取。您仍将对主服务器执行删除,但这些操作速度更快,不太可能在写入密集的实例中引入延迟。
如果您需要Redis DB的磁盘持久性,那么一定将其放在从实例上。否则,如果您有很多数据经常更改,则RDB转储将不断运行。
希望这可以帮助您。没有“标准答案”,因为要正确使用Redis,您需要首先考虑如何访问数据,而这与用户和项目之间存在很大差异。在这里,我基于此描述采取了以下路线:两个消费者访问数据,一个仅用于显示,另一个用于确定更新其他数据源。

  1. 通常情况下,Redis在关闭时不会丢失所有数据,而是在可调节的时间间隔内写入文件。
  2. 我所说的计算是指获取小时和分钟 - 当进行数十亿次操作时,获取当前小时/分钟可能是真正的工作(涉及夏令时等)。
  3. 我很高兴看到一个使用Redis的真实大型用例的程序员建议的解决方案与我的理论思考基本相同;-)
- flaschenpost
我非常了解 Redis 的持久化。我提到它如果出现故障并不是简单地重新启动 Redis 服务。例如,如果 Redis 服务器由于磁盘故障而崩溃,这会对 SLA 和数据授权产生什么影响?虽然可以进行调整,但在所描述的顺序更新方面,它基本上是无关紧要的。如果每秒有数千次更新,您要么每秒更新一次,要么花费很多时间每分钟保存成千上万次,并希望它们不重叠。对于每秒有许多更改的情况,您真的需要注意权衡。 - The Real Bill

3

整理一下我的其他回答:

定义一个时间间隔,指定从redis到mysql的转移应该在其中发生,例如每分钟、每小时或每天。以一种可以快速轻松地获取标识键的方式定义它。这个键必须是有序的,也就是说,较小的时间应该给出较小的键。

假设为每小时,并将键设为YYYYMMDD_HH,以便阅读。

定义一个前缀,如“hitcount_”。

然后,对于每个时间间隔,您在redis中设置一个哈希表hitcount_<timekey>,其中包含该时间间隔内所有请求的项,形式为ITEM => count。

解决方案分为两部分:

  1. 需要计数的实际页面:

    a)获取当前的$timekey,例如通过日期函数

    b)获取$ITEM的值

    c)发送redis命令HINCRBY hitcount_$timekey $ITEM 1

  2. 在给定的时间间隔内运行的cronjob,不要太接近该间隔的限制(例如:不在整点)。这个cronjob:

    a)提取当前时间键(现在应该是20130527_08)

    b)使用KEYS hitcount_*从redis请求所有匹配的键(这些应该是很少的)

    c)将每个这样的哈希表与当前的hitcount_<timekey>进行比较

    d)如果该键小于当前键,则将其处理为$processing_key

    • 通过HGETALL $processing_key读取所有的ITEM => counter对,例如$item、$cnt
    • 使用`UPDATE ITEM SET VIEW_COUNT=VIEW_COUNT+$cnt where ITEM=$item"更新数据库
    • 通过HDEL $processing_key $item从哈希表中删除该键
    • 不需要删除哈希表本身——据我所知,redis中没有空哈希表

如果您想涉及TTL,例如如果清理cronjob可能不可靠(因为可能运行多个小时),那么您可以使用适当的TTL创建未来的哈希表,这意味着现在我们可以创建一个哈希表20130527_09,TTL为10小时,20130527_10,TTL为11小时,20130527_11,TTL为12小时。问题是您需要一个伪键,因为空哈希表似乎会自动删除。


1
请参见EDIT3,了解A...nswer的当前状态。
我会为每个ITEM编写一个密钥。几万个密钥绝对不是问题。
页面是否经常更改?我的意思是,您是否有很多永远不会再次调用的页面?否则,我会简单地执行以下操作:
  • 在请求页面时添加ITEM的值。
  • 每隔一分钟或5分钟调用cronjob读取redis-keys,读取值(例如7),并将其减少decrby ITEM 7。在MySQL中,您可以为该ITEM增加7的值。
如果您有很多永远不会再次调用的页面/ITEMS,则可以每天进行一次清理作业以删除值为0的密钥。这应该被锁定,以防止从网站再次递增该密钥。
我不会设置TTL,因此这些值应该永久存在。您可以检查内存使用情况,但是我看到当前GB的内存有很多不同的可能页面。
编辑:incr非常适合此类操作,因为它会在之前未设置密钥时设置密钥。

编辑2:鉴于不同页面的数量很大,您可以使用HASHES和incrby(http://redis.io/commands/hincrby)代替缓慢的“keys *”命令。但我不确定HGETALL是否比KEYS *更快,而且HASH不允许单个键的TTL。

编辑3:好吧,有时好主意来得晚。这很简单:只需在键前加上时间戳(例如day-hour),或者创建一个名为“requests_”的HASH。然后不会发生删除和增量重叠!每小时,您可以获取较早的“day_hour_*” -值可能的键,更新MySQL并删除那些旧键。唯一的条件是您的服务器在时钟上不太不同,因此请使用UTC和同步服务器,并且不要在x:01而是在x:20等时间开始cron。

这意味着:调用页面将2013年5月26日23:37的ITEM1调用转换为Hash 20130526_23,ITEM1。 HINCRBY count_20130526_23 ITEM1 1

一小时后,检查 keys count_* 列表,并处理所有到 count_20130523 的内容(通过 hgetall 读取键值对,更新 mysql),并在处理后逐个删除(hdel)。完成后,检查 hlen 是否为 0 并删除 count_...。因此,您只有少量的键(每小时一个未处理的键),这使得 keys count_* 很快,然后处理该小时的操作。如果您的 cron 被延迟、时间跳跃或暂停一段时间等情况,可以设置几小时的 TTL。

谢谢,我觉得这个可能不太适用,因为有几十亿个,所以它们可能需要设置TTL(生存时间)。每天可能只有几百万个不同的。我喜欢“每次减7”的方法,可以大大减少更新的数量。 - OneSolitaryNoob
然后我会将 TTL 设置为比“传输到数据库”的时间长几倍(10倍)。删除“未使用”的条目的问题在于它可能在删除之前被递增。您是指几十亿个不同的请求,还是总共只有几十亿个请求? - flaschenpost
@OneSolitaryNoob:一个要点是减少update语句的数量,另一个是将其从响应时间过程中剔除。计划任务有足够的时间,甚至可以花费数秒钟。;-) - flaschenpost
1
我认为这个解决方案远非高效/正确。除非我看到你如何在不使用KEYS的情况下完成它。 - Tommaso Barbugli
是的,“keys”命令本身非常慢,对于给定数量的不同页面可能需要0.1甚至1秒钟(这让我感到惊讶)。但它在用户操作之外(在cron中)工作,并且比所有单个更新语句都要快得多。对于那么大的数字,我也会考虑使用哈希而不是单个键。但哈希没有TTL! - flaschenpost
考虑使用SCAN而不是KEYS,但两者都不是最优的 :) - jocull

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接