数据库 Redis 实战之薄荷 timeline 的优化

teddy_1004 · 2015年06月05日 · 最后由 flamingtree 回复于 2015年10月17日 · 13071 次阅读
本帖已被管理员设置为精华贴

Redis 可谓开发工具中的瑞士军刀,小巧易用而又功能强大。但是毕竟只是工具,合适的用法加上不断优化的算法组合起来方能达到高效。最近在优化薄荷的 timeline, 积累了一些小小的经验,这里来和大家分享一下。

薄荷是中国领先移动健康公司,主要产品包括“薄荷”、“轻卡减肥”和“食物库”长期位于 App Store 健康健美榜前列,长期位于各大 Android 应用市场健康或生活类应用前列,总用户已达千万级。

timeline 是一个类似于微博的系统,薄荷的用户可以在上面交流一些自己减肥、健身等等等一堆事。对于薄荷这样一个用户量级的 APP, timeline 这里面临的挑战还是挺大的。合理的利用 cache 等等之类的这些大体都提过,这里主要来说一些容易忽视的地方。

Redis 中的 N + 1 Query

都说过早优化比 gcd 还邪恶,真到了要优化的时候我们也应该根据数据来寻找突破口,而不是靠猜。这是 timeline 首页接口的数据,它返回了固定条数的关注用户发布的动态,可以看出,这个接口确实挺慢的。尤其是 Redis 的请求那里,简直慢的不正常,居然用了 36.9 ms !!! 这完全不应该是 Redis 应有的表现。

检查了一下代码,发现是构建 response 的过程中,每一条动态都会要获取这条动态获得了多少赞、有多少评论、自己是否点过赞以及是否转发过。而这四个属性都是从 Redis 中读取出来的。也就是说如果这个接口返回了 20 条动态的话,我们需要 call 80 次 Redis。在以前的观念中,Redis 中的数据就像是一个 local variable 一样,可以随便调来调去。但是 Redis 也毕竟是数据库,这每一次调用都是一次 IO 操作。

Redis 中直接为我们提供了解决的办法,pipeline 指令,通过这个指令我们可以把这些操作批量执行,原本 80 次请求降到 1 次,最终的效果也是立竿见影。可见即使对于 Redis 这样的内存数据库,我们也是会遇到所谓的 N + 1 query 的问题的。

整个请求的响应时间也是骤减,不得不说,这感觉真爽 ^..^

大 V 用户发动态时的处理

大 V 发动态的时候也是让我们一直头疼的一个问题,我们的 timeline 采用的是主动推的策略,每个用户的 timeline 是由一个 Redis list 来维护,里面存放着用户关注的好友发布的动态的 ID。每当一个用户发布动态的时候,会把动态的 ID 推到他的粉丝的 timeline 中。大部分时候,这个工作的都是很好的,但是对于那些大 V 用户,这个让人有些头痛了。拿薄荷的官号来说,他的粉丝数已经过千万了,每当发布一条动态的时候,会需要推 ID 的异步任务去向上千万个用户的 timeline 里去推。这样一来,我们遇到了下面几个问题:

  • 执行很慢,有时需要 2 个小时以上,消耗资源大
  • 速度慢导致了实效性差,别人发一条动态自己肯能过两个小时才能看到,进而降低了用户使用时长
  • 由于这些都是在一个 sidekiq 任务中,所以如果执行过程中重启了,又会从头再来,天啊。。。

期间我们想过很多解决的办法,譬如统计活跃用户只给活跃用户推,执行异步任务的时候统计已经推过的用户,这样防止遇到重启之后重复执行任务的情况。总之想了一堆的解决方案,后来发现事情越来越复杂。拿统计活跃用户来说,即使活跃用户只有 1/5, 拿薄荷的官号来说还是有 200 万的用户要推,而且说别人不活跃,万一刚推完别人就登陆了呢。。。最后想来想去,既然主动推这么麻烦,那就用户主动去拉取吧。大致流程如下:

首先

对于粉丝数超过一定数量的用户,他们发动态的时候直接推到一个专门的 sorted set 中,sorted set 是 Redis 中一个很强大的数据结构,感觉类似于 list 和 hash 的结合。存储的方式还是有一些小小的设计,key 是 "#{user_id}/#{post_id}" 的形式,score 是一个时间戳。为什么要这么做呢,后面细细说来。同时存放一个 redis 的 value, 是大 V 用户最后发动态的时间,每当这些大 V 用户发动态的时候更新一下这个时间。

然后

每次用户去刷 timeline 的时候,会去检查他最后刷 timeline 的时间,和那些大 V 用户最后发动态的时间对比,如果大的话,说明 timeline 已经是最新的了,就不用管了。如果小的话,说明上一次到这一次刷动态的时间段内有大 V 发动态了。如果小的话,说明可能自己关注的大 V 发动态了,因为前面那个 sorted set 维护的是所有大 V 用户的动态,所以并不一定是自己关注的大 V 用户发布的动态。因此,

再然后

前面说到的那个 key 派上用途了,先来说说我们拿数据的过程吧。Redis sorted set 可以根据 score 的范围来拿出 key(ZRANGEBYSCORE min max), 我们使用这条指令根据用户上一次刷动态的时间和当前的时间来做范围来拿出没刷的这个时间段的新动态。这个数据的结构大概类似于下面这样。

users_posts = ["21/13", "22/15", "22/16", "28/33", "55/60"]

前面说到了我们并不一定关注了所有大 V, 所以对于大 V 用户我们专门用一个 set 来存放他们的 ID, 自己关注的用户的 ID 存在一个 set 中。这样我们只需要使用 SINTER 来求两个 set 的交集。假如返回的数据是 ['21', '55']

我们需要对 users_posts 的数据进行结构化,因为我们只需要自己关注的大 V 的动态的 ID, 这也是前面 sorted set 的 key 这样设置的原因。结构化后的数据如下:

{
  '21' => ['13'],
  '22' => ['15', '16'],
  '28' => ['33'],
  '55' => ['60']
}

再根据我们关注的大 V 的 ID 我们就可以很轻松的拿到需要的动态的 ID 了,再合并到自己的 timeline 中即可。最后再更新一下自己最后自己同步 timeline 的时间即可。

上线之后效果果然还是很赞的,薄荷发动态也是直接秒刷了。不过这个解决方案还是存在一些问题的,比如:

  • 当大 V 数量增多的时候,这个的维护是一个问题
  • 当大 V 数量增多时,比对选出自己关注的耗时也会增长
  • 特定情况会存在有些动态丢失刷不到的问题
  • 暂时想到这几个,不过肯定还是有不少问题

所以,欢迎一起来和我们迎接挑战,把它优化的更棒~ 有兴趣请看,薄荷热聘

大 v 问题总算找到解决方案了,牛!看来我留了不少坑在里面啊..

在薄荷确实有很多有挑战且有意思的事情。 文笔很赞,语言诙谐,行文如流水般。。。

匿名 #3 2015年06月06日

:plus1:

Timeline 的存储说两点:

  1. Sorted set 是 key / member / score 三层的,文中只提到了 key / score。应该是 user_id 做 key,post_id 做 member 比较合理。
  2. 用户的 timeline 如果能忍受稍大的内存开销,用 sorted set 是更方便的,天生就能去重。而且大 V 们的 posts 已经存到各自的 sorted set 了,所以拉的时候只要调用一次 ZUNIONSTORE 就行了。

好文!解决我很多犹豫不决的地方。

大 V 状态分发有试过拆成多个子任务并行执行吗?

#7 楼 @rei 因为 follower 的 id 是放在一个 set 中的,所以拆分起来有点麻烦,就没有这样处理了

@keakon 还不知道 sorted set 可以这样啊,一会儿去试试看。第二个问题确实觉得 sorted set 来做 timeline 是方便一些,而且用来分页的时候查询效率也更高。但是因为之前的 timeline 都是基于 list 来做的,包括它的 rebuild 等等,换起来工作量有点大,就暂时先还是用 list 了。

这不是和天面试的差不多╭(°A°`)╮

:plus1: 关注是一个挺麻烦的问题,特别是当数据量上去之后,很容易出现性能瓶颈,为了解决这个问题薄荷用了不少方法,这篇文章里提到了 2 个比较有效的做法,但不仅限于此。

@rei 考虑过分批处理。从逻辑上分析一定可以提升速度的,但是有些大 V 关注量实在太大,比如说 500 万,如果分成 100 批同时处理,估计频繁数据库读写瞬间对系统造成大的冲击,所以后来没有采用这种方法。

@lithium4010 这个问题和你聊的的问题有些不一样。那个是解决大批量用户的系统广播问题,不过一样的地方在于:当数据量并发量上去之后,看似简单的设计很容易出现瓶颈,必须使用一些算法策略解决。

文章里没有提到的一个大招是,区分活跃用户和僵尸用户(大部分互联网系统中,海量用户至少一半以上是僵尸用户),优秀处理活跃用户数据,尽量避免处理僵尸用户数据,如果僵尸用户再次造访,通过一个任务更新他的数据。

#11 楼 @vincent 看“然后”这一段里面的做法,是同一个思路啊 (๑• . •๑) 学习了,期待更多分享

@teddy_1004 @vincent 用 setbit 和 getbit 来处理可以不?用大 v 的 id 做 key,使用用户的 id 作为 offset,每次用户获取 timeline 的时候,就 getbit 一次,如果是 1 表示不需要新获取记录,如果是 0 表示有新纪录,获取新纪录后,放到 timeline 的缓存中,然后将 0 置为 1。大 v 每次发新的 post,直接 del 掉 key。

这样数据的存储量要少很多。

动态用主动拉的话,直接查询动态表不行吗?

select * from activities where user_id in (select user_id from followers where follower_id = CURRENT_USER_ID) and id > LAST_VIEW_ID

#14 楼 @quakewang 薄荷里面动态可以设置不对他人可见、可以屏蔽别人,所以最后查询其实还是要比这个复杂一些,查询效率也不高,访问量大的时候数据库压力还是很大的。

如果区分 普通关注 和 关注大 V 分别存两个 Set,这样可以省掉 求交集的一步

这就是传说的推拉结合...

#13 楼 @rubyu2 offset 大的时候,第一次内存分配有点多,用户 id 自增话,意味着 offset 也越大。

#18 楼 @onemagicant http://redis.io/commands/setbit

4294967296 的时候需要 80ms。 268435455 的时候需要 30ms。 67108863 的时候,才 8m 内存,set 需要 8ms。

如果每个用户存一个时间戳,远远不止 8m 内存。

#19 楼 @rubyu2 不是耗时间的问题啊,第一次的时候需要消耗的内存随着 offset 的增大,会变大

#19 楼 @rubyu2 走数据库的话,20ms 好不啦

#20 楼 @onemagicant

用时间戳存更消耗内存。每个用户都需要有个记录。

恩,存时间戳是更耗内存,不过如何解决 redis 收集大量内存处理 setbit 大 offset 情况?会不会堵塞?

#23 楼 @onemagicant

redis 收集大量内存处理 setbit 大 offset 情况?

这是什么意思?

require "benchmark"
require "redis"

class MemoryWatcher
  def self.measure(no_gc = true, current_redis, &block)
    current_redis.flushdb
    no_gc ? GC.disable : GC.start
    memory_before = current_memory
    gc_before = GC.stat

    info_before = MemoryWatcher.redis_info(current_redis)
    time = Benchmark.realtime { yield(current_redis) }
    info_after  = MemoryWatcher.redis_info(current_redis)

    gc_after = GC.stat
    GC.start unless no_gc
    memory_after = current_memory

    puts <<-INFOS
      gc: #{no_gc ? "关闭" : "开启"}
      time: #{time}
      gc_count: #{gc_after[:count] - gc_before[:count]}
      memory: #{(memory_after - memory_before).round(2)}M
      redis used_memory: #{info_after['used_memory'].to_i - info_before['used_memory'].to_i}
    INFOS
    current_redis.flushdb
  end

  def self.current_memory
    `ps -o rss= -p #{Process.pid}`.to_i/1024
  end

  def self.redis_info(current_redis)
    current_redis.info
  end
end

# 用户IDs 100_000_000..110_000_000
# 动态IDs 1_000_000..1_100_000
# 时间戳做 分值
user_ids = (100_000_000..100_001_000).to_a
post_ids = (100_000_000..100_000_100).to_a
timestamps = Time.now.to_i # 写死

current_redis = Redis.current

MemoryWatcher.measure(current_redis) do |redis|  
  redis.pipelined do
    user_ids.each do |user_id|
      post_ids.each do |post_id|
        redis.ZADD "#{user_id}/#{post_id}", timestamps, timestamps
      end
    end
  end
end

p '-' * 60

# 假定每个大v发一个 文章
v_ids = post_ids

MemoryWatcher.measure(current_redis) do |redis|
  redis.pipelined do
    # 不计算其他存储消耗, 因为这里没有存业务需要的两个时间戳, post_id
    user_ids.each do |user_id|
      v_ids.each do |v_id|
        redis.SETBIT v_id, user_id, 1
      end
    end
  end
end

我测出来,随着 id 的增大,setbit 会耗时变久,setbit 会超出值的范围. redis 内存使用 后者比前者多很多 喷喷看哪里有问题,谢谢

按照你这个代码运行结果:

➜  ~  ruby test.rb
      gc: 关闭
      time: 2.471569
      gc_count: 0
      memory: 259.0M
      redis used_memory: 12371888
"------------------------------------------------------------"
      gc: 关闭
      time: 2.339922
      gc_count: 0
      memory: 246.0M
      redis used_memory: 232506256

但是你的数据有严重的倾向。用户 id 在一亿左右,大 v 的粉丝却只有 1000。现在 setbit 的内存消耗时比较多的。但是现在的需求是大 v,所以粉丝少说也得有 100 万。而且大 v 的数量并不影响测试结果。 所以我这里修改为:

user_ids = (100_000_000..100_010_000).to_a
post_ids = [1]

1w 粉丝的时候:

➜  ~  ruby test.rb 
      gc: 关闭
      time: 0.239513
      gc_count: 0
      memory: 24.0M
      redis used_memory: 1250160
"------------------------------------------------------------"
      gc: 关闭
      time: 0.238195
      gc_count: 0
      memory: 25.0M
      redis used_memory: 16777312

10w 粉丝的时候:

➜  ~  ruby test.rb
      gc: 关闭
      time: 2.424201
      gc_count: 0
      memory: 254.0M
      redis used_memory: 12248688
"------------------------------------------------------------"
      gc: 关闭
      time: 2.438514
      gc_count: 0
      memory: 243.0M
      redis used_memory: 16777312

100w 粉丝的时候

➜  ~  ruby test.rb
      gc: 关闭
      time: 24.655293
      gc_count: 0
      memory: 2549.0M
      redis used_memory: 120388720
"------------------------------------------------------------"
      gc: 关闭
      time: 26.910435
      gc_count: 0
      memory: 1847.0M
      redis used_memory: 16777568

粉丝数变化 1:10:100 第一种方法:内存变化 1250160:12248688:120388720,约等于 1:10:100。 第二种方法:内存变化 16777312:16777312:16777568,约等于 1:1:1。这个 1 所占内存也跟官方文档介绍的接近 2**26 -1 (8MB allocation)

在用户 id 为 1 亿,粉丝为 100 万的时候,大 v 的存储采用第二种方式比第一张方式内存比 1:7。但是如果大 v 粉丝数是 10 万的时候,内存接近,当粉丝数只有 1w 时,第二种内存消耗更多。不过第二种方案本来也就是出于粉丝太多的情况而设计的。

当有 1 亿用户 id,大 v 有 1000w 粉丝的时候,第一种的大概需要 1140m 内存,第二种方案需要 16m 内存。,大概是 70:1。

因此当大 v 粉丝数,越大越接近用户总数时,第二种方案内存的节省约明显。超过 10 亿用户 id 的时候,这种方法查询时间 30ms 也是可以接受的。但是用户量再大可能查询和设置就会有瓶颈。可以根据系统用户数和粉丝数选择不同方案。

@rubyu2 先给你点个赞,存业务需要的两个时间戳, post_id 这个怎么算的

#27 楼 @onemagicant 不需要存,我只关心是否有新的 post,如果有新的 post,获取 post 就可以了,反正 timeline 里还要显示其他内容。

有点像文件系统,大文件和小文件处理效率问题。

文章很棒,讨论更棒 👍

我最近也想这个问题,我们最后采用了等级方式,根据活跃度,分了好几个等级的用户,最活跃的用户干脆就放到了 elasticsearch 里。

@teddy_1004 请问大 V 的 feed 后插入 timeline list 是怎么做的?重新取出 timeline list,插入后再全部 push 进去?

#35 楼 @flamingtree 用户刷 timeline 时候去主动检查,自己关注的大 V 有新的 post 就合到自己的 timeline 就好了

#36 楼 @teddy_1004 我想要知道的是怎么合并 是要从 redis 里把列表全取出来 在程序里合并完后 rpush 到一个临时列表 然后 rename?

#37 楼 @flamingtree 不用这么多操作了,这里的解决办法其实针对的是大 V 用户的数量比较少,也就不到 10 个,用一个专门的 sorted_set 来维护这些大 V 用户的 post。key 是时间戳,value 是 "#{user_id}:#{post_id}" 的组合。每个用户会有一个最后刷新 time line 的时间戳,然后刷 timeline 的时候根据这个时间戳去取出到当前时间所有的大 V 用户的 post_id,再把其中自己关注的大 V 用户的 post_id 筛选出来合并到自己的 timeline 中 (是一个 redis list)。这个其实对于大 V 用户很多的那种系统并不是好的解决方案,但是在薄荷里面粉丝数上百万的用户并不多,所以用这种方式能比较好的解决,这一系列的操作的速度也是挺快的。

#38 楼 @teddy_1004 假设已经取出大 V 的 post_id 怎么合并到 redis list 里 直接 lpush/rpush

#39 楼 @flamingtree 嗯,直接 push 进去再 sort 一下,这里效率其实有点低了,觉得用户的 timeline 也用 sorted_set 来更好一些,但是因为历史的代码是用 list 实现的,而且这里其实也不算是那时优化的瓶颈,就这样来实现了。

#40 楼 @teddy_1004 谢谢 明白了 twitter 也是用 list 存的 sorted_set 的 score 是 double 类型的 整形的精确度只能到 54 位 如果 post_id 是 int64 话就不行了 我就遇到这个问题~

需要 登录 后方可回复, 如果你还没有账号请 注册新账号