[聊聊架构] 日请求量过亿,谈陌陌的 Feed 服务优化之路


场景和架构介绍
    先从产品层⾯面介绍一下Feed业务。Feed本⾝身就是一段简短文字加一张图片,带有位置信息,发布之后可以被好友和附近的人看到,通过点赞评论的方式互动。类似微博和朋友圈。
    陌陌上季度的MAU为6980万,Feed作为主要的社交业务,从2013年上线到现在,日请求量超过亿,总数据量超过百亿。下面是Feed系统的整体架构图:
    [聊聊架构] 日请求量过亿,谈陌陌的 Feed 服务优化之路
        * 资源层主要使用Redis、MongoDB、HBase等NoSQL类型数据库。
        * 存储层是内部RPC服务,根据业务场景和存储特性,组合各种数据库资源。
        * 业务层调用存储层读写数据,实现产品逻辑,直接面向用户使用。


内容存储(Feed Content)
    首先介绍Feed内容海量数据存储优化。Feed内容是json格式,对schema没有做严格限制,可以根据业务灵活扩展,下面是一个基础的结构:
        {"content":"今天天⽓气不错啊","id":"88888888","time":1460198781,"owner":"25927525"}
    最初使用MongoDB做持久化存储,MongoDB本身对JSON⽀支持非常好,这样从前端API的输出格式到底层数据存储都是统一的,非常简洁。    
    MongoDB另外一个优势是不需要预先定义结构,灵活的增减字段,支持复杂查询,非常适合创业阶段快速迭代开发。

    Feed内容存储是整个系统访问量最大的部分,QPS达到几十万,MongoDB的查询性能不能满足线上要求,在前端使用Redis集群做LRU缓存,缓存id和对应的content。

    移动社交产品的热点数据大部分是最近产生的,LRU缓存可以扛住99.5%的线上请求通过监控miss(每秒穿透)和evict(每秒逐出)两个指标,用来评估缓存容量,适时扩容。

    这里特别说一下,为了快速大规模扩容,Redis的LRU缓存集群没有采用一致性hash,而是最简单Mod取余的hash方式通过节点数据复制,快速的翻倍扩容,省去了缓存预热的过程。

    随着数据量的增长,MongoDB需要不断扩容,单个MongoDB实例占用空间接近硬盘的上限。而且读性能太低成为瓶颈。最终将持久化迁移到Hbase,废弃掉了MongoDB在线上的使用。


好友动态( Feed timeline)
    接下来介绍好友动态(timeline)实现和优化过程。好友动态(timeline)通过好友关系聚合内容,按时间排序,类似微信的朋友圈。
    Timeline使用Redis的zset结构做存储,天然有序,支持原子的增/删/查询操作。和早期SNS系统MySQL+Memcached相比,实现简单很多,大部分业务一行代码搞定:
        1. ZADD timeline 1460198781 88888888     //插入一条feed_id为88888888的Feed,插入时间为1460198781
        2. ZREVRANGE timeline 0 100                     //查看最近的100条Feed, Redis Zrevrange 命令返回有序集中,指定区间内的成员。 其中成员的位置按分数值递减(从大到小)来排列。

    关于Feed系统的推(push)模式和拉(pull)模式有很多讨论。
    陌陌最初使用的是推的模式,也就是发布Feed后,异步插入到每个好友的timeline。这种方式读取效率高,可以看作O(1)的操作。但是写操作开销大,每秒1000条Feed,每人N个好友,会产生1000*N的OPS,而且一个feed_id重复保存N次,产生大量冗余数据
    随着用户产生数据的积累,长尾效应明显,冷数据占比会越来越高。而且redis对小zset采用ziplist的方式紧凑存储,列表增长会转换为skiplist,内存利用率下降。存储timeline的Redis集群近百台服务器,成本太高,推动改造为拉的模式
    通过timeline聚合层,根据用户的好友关系和个人Feed列表,找到上次访问之后产生的新Feed,增量实时聚合新内容。大致步骤:
        1. 遍历我的好友,找到最近发表过Feed的人
        2. 遍历最近发表过Feed的人,得到id和time
        3. 合并到我的timeline
    聚合过程采用多线程并行执行,总体聚合时间平均20ms以下,对查询性能影响很小。
    改为拉模式后,timeline从存储变为缓存,冷数据可以被淘汰删除,timeline不存在的则触发全量聚合,性能上也可以接受。redis集群只缓存最近的热点数据,解决了存储成本高的问题,服务器规模下降了一个数量级。


附近动态 (Nearby Feed)
    最后介绍LBS的附近动态空间查询性能优化,也是有特色的地方。
    陌陌上每一条Feed都带有经纬度信息,附近动态是基于位置的timeline,可以看到附近5公里范围内最新的Feed。技术上的难点在于每个人的位置都不一样,每个人看到内容也不同,需要实时计算无法缓存。
    第一个版本用mongo的2D索引实现空间查询:
        feeds.find({location : {"$near" : [39.9937,116.4361]}}).sort({time:-1});
    由于mongo的2D查询不能建立联合索引,按时间排序的话,性能比较低,超过100ms。通过数据文件挂载在内存盘上和按地理位置partition的方法,做了一些优化,效果还是不理想。
    第二个版本,采用geohash算法实现了更高效的空间查询。
    首先介绍geohash。geohash将二维的经纬度转换成字符串,例如经纬度39.9937,116.4361对应的geohash为wx4g9。每个geohash对应一个矩形区域,矩形范围内的经纬度的geohash是相同的。
    根据Feed的经纬度,计算geohash,空间索引使用Redis的zset结构,将geohash作为空间索引的key,feed_id作为member,时间作为score。
查询时根据用户当前经纬度,计算geohash,就能找到他附近的Feed。但存在边界问题,附近的Feed不一定在同一个矩形区域内。如下图:
    [聊聊架构] 日请求量过亿,谈陌陌的 Feed 服务优化之路
    解决这个问题可以在查询时扩大范围,除了查询用户所在的矩形外,还扩散搜索相邻的8个矩形,将9个矩形合并(如下图),按时间排序,过滤掉超出距离范围的Feed,最后做分页查询。
    归纳为四个步骤:ExtendSearch -> MergeAndSort -> DistanceFilter -> Skip。
    但是这种方式查询效率比较低,作为读远远大于写的场景,换了一种思路,在更新Feed空间索引时,将Feed写入相邻的8个矩形,这样每个矩形还包含了相邻矩形的Feed,查询省去了ExtendSearch和MergeAndSort两个步骤通过数据冗余的方式,换取了更高的查询效率。
    (通过GEOHash)将复杂的geo查询,简化为redis的zrange操作,性能提高了一个数量级,平均耗时降到3ms。空间索引通过geohash分片到redis节点具有数据分布均匀、方便扩容的优势。


总结
    陌陌的Feed服务大规模使用Redis作为缓存和存储,Redis的性能非常高,了解它的特性,并且正确使用可以解决很多大规模请求的性能问题。通常内存的故障率远低于硬盘的故障率,生产环境Redis的稳定性是非常高的。通过合理的持久化策略和一主多从的部署结构,可以确保数据丢失的风险降到最低。
    另外,陌陌的Feed服务构建在许多内部技术框架和基础组件之上,本文偏重于业务方面,没有深入展开,后续有机会可以再做介绍。


互动问答
问题:MongoDB采用什么集群方式部署的,如果数据量太大,采用什么方式来提高查询性能?
    我们通过在mongo客户端按id做hash的方式分片。当时MongoDB版本比较低,复制集(repl-set)还不太成熟,没有在生产环境使用。除了建索引以外,还可以通过把mongo数据文件挂载在内存盘(tmpfs)上提高查询性能,不过有重启丢数据的风险。

问题:用户的关系是怎么存储的呢 还有就是获取好友动态时每条feed的用户信息是动态从Redis或者其他地方读取呢?
    陌陌的用户关系使用Redis存储的。获取好友动态是的用户信息是通过feed的ownerId,再去另外一个用户资料服务(profile服务)读取的,用户资料服务是陌陌请求量最大的服务,QPS超过50W。

问题:具体实现用到Redis解决性能问题,那Redis的可用性是如何保证的?万一某台旦旦机数据怎么保证不丢失的?
    Redis通过一主一从或者多从的方式部署,一台机器宕机会切换到备用的实例。另外Redis的数据会定时持久化到rdb文件,如果一主多从都挂了,可以恢复到上一次rdb的数据,会有少量数据丢失。

问题:Redis这么高性能是否有在应用服务器上做本地存储,如果有是如何做Redis集群与本地数据同步的?
    没有在本地部署Redis,应用服务器部署的都是无状态的RPC服务

问题:Redis一个集群大概有多少个点? 主从之间同步用的什么机制? 直接mod问题多吗?
    一个Redis集群几个节点到上百个节点都有。大的集群通过分号段再mod的方式hash。Redis 3.0的cluster模式还没在生产环节使用。使用的Redis自带的主从同步机制。

问题:文中提到Redis使用mod方式分片,添加机器时进行数据复制,复制的过程需要停机么,如果不停数据在动态变化,如何处理?
    主从同步的方式复制数据不需要停机,扩容的过程中一直保持数据同步,从库和主库数据一致,扩容完成之后从库提升为主库,关闭主从同步。

问题:Redis宕机后的主从切换是通过的哨兵机制吗?在主从切换的时候,是有切换延时的,这段时间的写入主的数据是否会丢失,如果没丢,怎么保证的?
    通过内部开发的Sentinel系统,检测Reids是否可用。为了防止误切,切换会有一定延迟,多次检测失败才会切换。如果主库不可用会有数据丢失,重要数据的写入,在业务上有重试机制。



陌陌Feed读后总结

Redis 采用Mod取余Hash的方式.
通过节点数据复制,快速的翻倍扩容,省去了缓存预热的过程。
原文这部分没有细说.
[聊聊架构] 日请求量过亿,谈陌陌的 Feed 服务优化之路
假如原来有三个redis实例, 并且各自带了Slave. 通过 id mod 3 取余,判断存放位置.
扩容的时候,仅仅需要把Slave Readonly去掉, 前端通过 id mod 6 取余, 判断存放位置即可,同时断掉Redis 主从复制. 这时候备机就成了写入机。
同时,将这三个原来三个主机标号成为 4,5,6。
因为 id mod 6 == 0 的情况 必定 id mod 3 == 0。比如 10 mod 3 = 1 ,现在改为 10 mod 6 = 4 正好落在原来的备机上.
待完成之后,需要清理每个实例上一半冗余的数据.不过一般设置了过期时间,可以等待他自然过期.
这样的扩容方式,每次需要扩一倍。