日请求量过亿,谈陌陌的 Feed 服务优化之路
e21er2131
8年前
<p>摘要: 先从产品层⾯面介绍一下Feed业务。Feed本⾝身就是一段简短文字加一张图片,带有位置信息,发布之后可以被好友和附近的人看到,通过点赞评论的方式互动。类似微博和朋友圈。</p> <p><img src="https://simg.open-open.com/show/395b5e635b9260935e417c967fabe36f.jpg"></p> <p>先从产品层⾯面介绍一下Feed业务。Feed本⾝身就是一段简短文字加一张图片,带有位置信息,发布之后可以被好友和附近的人看到,通过点赞评论的方式互动。类似微博和朋友圈。</p> <p><img src="https://simg.open-open.com/show/12ed2c54e3f5bd0b8a695ee90012b774.jpg"></p> <p>陌陌上季度的MAU为6980万,Feed作为主要的社交业务,从2013年上线到现在,日请求量超过亿,总数据量超过百亿。下面是Feed系统的整体架构图:</p> <p><img src="https://simg.open-open.com/show/85cd6cefe8ae29204eb51593e61942b5.jpg"></p> <ul> <li> <p>资源层主要使用Redis、MongoDB、HBase等NoSQL类型数据库。</p> </li> <li> <p>存储层是内部RPC服务,根据业务场景和存储特性,组合各种数据库资源。</p> </li> <li> <p>业务层调用存储层读写数据,实现产品逻辑,直接面向用户使用。</p> </li> </ul> <p>内容存储(Feed Content)</p> <p>首先介绍Feed内容海量数据存储优化。</p> <p>Feed内容是json格式,对schema没有做严格限制,可以根据业务灵活扩展,下面是一个基础的结构:</p> <p>{"content":"今天天⽓气不错啊","id":"88888888","time":1460198781,"owner":"25927525"}</p> <p>最初使用MongoDB做持久化存储,MongoDB本身对JSON⽀支持非常好,这样从前端API的输出格式到底层数据存储都是统一的,非常简洁。MongoDB另外一个优势是不需要预先定义结构,灵活的增减字段,支持复杂查询,非常适合创业阶段 <strong>快速迭代</strong> 开发。</p> <p>Feed内容存储是整个系统访问量最大的部分,QPS达到几十万,MongoDB的查询性能不能满足线上要求,在前端使用Redis集群做LRU缓存,缓存id和对应的content。</p> <p>移动社交产品的热点数据大部分是最近产生的,LRU缓存可以扛住99.5%的线上请求。通过监控,miss(每秒穿透)和evict(每秒逐出)两个指标,用来评估缓存容量,适时扩容。</p> <p>这里特别说一下,为了快速大规模扩容,Redis的LRU缓存集群没有采用一致性hash,而是最简单 <strong>Mod取余的hash</strong> 方式。通过节点数据复制,快速的翻倍扩容,省去了缓存预热的过程。</p> <p>随着数据量的增长,MongoDB需要不断扩容,单个MongoDB实例占用空间接近硬盘的上限。而且读性能太低成为瓶颈。最终将持久化迁移到Hbase,废弃掉了MongoDB在线上的使用。</p> <p>好友动态( Feed timeline)</p> <p>接下来介绍好友动态(timeline)实现和优化过程。好友动态(timeline)通过好友关系聚合内容,按时间排序,类似微信的朋友圈。</p> <p>Timeline使用Redis的zset结构做存储,天然有序,支持原子的增/删/查询操作。和早期SNS系统MySQL+Memcached相比,实现简单很多,大部分业务一行代码搞定:</p> <ol> <li> <p>ZADD timeline 1460198781 88888888 //插入一条feed_id为88888888的Feed,插入时间为1460198781</p> </li> <li> <p>ZREVRANGE timeline 0 100 //查看最近的100条Feed</p> </li> </ol> <p>关于Feed系统的 <strong>推(push)模式</strong> 和 <strong>拉(pull)模式</strong> 有很多讨论。</p> <p>陌陌最初使用的是推的模式,也就是发布Feed后,异步插入到每个好友的timeline。这种方式读取效率高,可以看作O(1)的操作。但是写操作开销大,每秒1000条Feed,每人N个好友,会产生1000*N的OPS,而且一个feed_id重复保存N次,产生大量冗余数据。</p> <p>随着用户产生数据的积累,长尾效应明显,冷数据占比会越来越高。而且redis对小zset采用ziplist的方式紧凑存储,列表增长会转换为skiplist,内存利用率下降。存储timeline的Redis集群近百台服务器,成本太高,推动改造为拉的模式。</p> <p>通过timeline聚合层,根据用户的好友关系和个人Feed列表,找到上次访问之后产生的新Feed, <strong>增量实时聚合</strong> 新内容。大致步骤:</p> <ol> <li> <p>遍历我的好友,找到最近发表过Feed的人</p> </li> <li> <p>遍历最近发表过Feed的人,得到id和time</p> </li> <li> <p>合并到我的timeline</p> </li> </ol> <p>聚合过程采用多线程并行执行,总体聚合时间平均20ms以下,对查询性能影响很小。</p> <p>改为拉模式后,timeline从 <strong>存储变为缓存</strong> ,冷数据可以被淘汰删除,timeline不存在的则触发全量聚合,性能上也可以接受。redis集群只缓存最近的热点数据,解决了存储成本高的问题,服务器规模下降了 <strong>一个数量级</strong> 。</p> <p>附近动态 (Nearby Feed)</p> <p>最后介绍LBS的附近动态空间查询性能优化,也是有特色的地方。</p> <p>陌陌上每一条Feed都带有经纬度信息,附近动态是基于位置的timeline,可以看到附近5公里范围内最新的Feed。技术上的难点在于每个人的位置都不一样,每个人看到内容也不同, <strong>需要实时计算无法缓存</strong> 。</p> <p>第一个版本用mongo的2D索引实现空间查询:</p> <p>feeds.find({location : {"$near" : [39.9937,116.4361]}}).sort({time:-1});</p> <p>由于mongo的2D查询不能建立联合索引,按时间排序的话,性能比较低,超过100ms。通过数据文件挂载在内存盘上和按地理位置partition的方法,做了一些优化,效果还是不理想。</p> <p>第二个版本,采用geohash算法实现了更高效的空间查询。</p> <p>首先介绍geohash。geohash将二维的经纬度转换成字符串,例如经纬度39.9937,116.4361对应的geohash为wx4g9。每个geohash对应一个矩形区域,矩形范围内的经纬度的geohash是相同的。</p> <p>根据Feed的经纬度,计算geohash,空间索引使用Redis的zset结构,将geohash作为空间索引的key,feed_id作为member,时间作为score。</p> <p>查询时根据用户当前经纬度,计算geohash,就能找到他附近的Feed。但存在 <strong>边界问题</strong> ,附近的Feed不一定在同一个矩形区域内。如下图:</p> <p><img src="https://simg.open-open.com/show/9841d926cd32a48d6e17529acad84757.jpg"></p> <p>解决这个问题可以在查询时扩大范围,除了查询用户所在的矩形外,还扩散搜索相邻的8个矩形,将9个矩形合并(如下图),按时间排序,过滤掉超出距离范围的Feed,最后做分页查询。</p> <p><img src="https://simg.open-open.com/show/28b56c928c1d2792f1476227da15f942.jpg"></p> <p>wx4g9相邻的8个 geohash :wx4gb,wx4gc,wx4gf,wx4g8,wx4gd,wx4g2,wx4g3,wx4g6。</p> <p>归纳为四个步骤: <strong>ExtendSearch</strong> -> <strong>MergeAndSort</strong> -> <strong>DistanceFilter</strong> -> <strong>Skip</strong> 。</p> <p>但是这种方式查询效率比较低,作为读远远大于写的场景,换了一种思路,在 <strong>更新Feed空间索引</strong> 时,将Feed写入相邻的8个矩形,这样每个矩形还包含了相邻矩形的Feed,查询省去了</p> <p>ExtendSearch和MergeAndSort两个步骤。</p> <p>通过数据冗余的方式,换取了更高的查询效率。将复杂的geo查询,简化为redis的zrange操作,性能 <strong>提高了一个数量级</strong> ,平均耗时降到3ms。空间索引通过geohash分片到redis节点,具有 <strong>数据分布均匀</strong> 、 <strong>方便扩容</strong> 的优势。</p> <p>总结</p> <p>陌陌的Feed服务大规模使用Redis作为缓存和存储,Redis的性能非常高,了解它的特性,并且正确使用可以解决很多大规模请求的性能问题。通常内存的故障率远低于硬盘的故障率,生产环境Redis的稳定性是非常高的。通过合理的持久化策略和一主多从的部署结构,可以确保数据丢失的风险降到最低。</p> <p>另外,陌陌的Feed服务构建在许多内部技术框架和基础组件之上,本文偏重于业务方面,没有深入展开,后续有机会可以再做介绍。</p> <p>互动问答</p> <p>问题:MongoDB采用什么集群方式部署的,如果数据量太大,采用什么方式来提高查询性能?</p> <p>我们通过在mongo客户端按id做hash的方式分片。当时MongoDB版本比较低,复制集(repl-set)还不太成熟,没有在生产环境使用。除了建索引以外,还可以通过把mongo数据文件挂载在内存盘(tmpfs)上提高查询性能,不过有重启丢数据的风险。</p> <p>问题:用户的关系是怎么存储的呢 还有就是获取好友动态时每条feed的用户信息是动态从Redis或者其他地方读取呢?</p> <p>陌陌的用户关系使用Redis存储的。获取好友动态是的用户信息是通过feed的owner,再去另外一个用户资料服务(profile服务)读取的,用户资料服务是陌陌请求量最大的服务,QPS超过50W。</p> <p>问题:具体实现用到Redis解决性能问题,那Redis的可用性是如何保证的?万一某台旦旦机数据怎么保证不丢失的?</p> <p>Redis通过一主一从或者多从的方式部署,一台机器宕机会切换到备用的实例。另外Redis的数据会定时持久化到rdb文件,如果一主多从都挂了,可以恢复到上一次rdb的数据,会有少量数据丢失。</p> <p>问题:Redis这么高性能是否有在应用服务器上做本地存储,如果有是如何做Redis集群与本地数据同步的?</p> <p>没有在本地部署Redis,应用服务器部署的都是无状态的RPC服务。</p> <p>问题:Redis一个集群大概有多少个点? 主从之间同步用的什么机制? 直接mod问题多吗?</p> <p>一个Redis集群几个节点到上百个节点都有。大的集群通过分号段再mod的方式hash。Redis 3.0的cluster模式还没在生产环节使用。使用的Redis自带的主从同步机制。</p> <p>问题:文中提到Redis使用mod方式分片,添加机器时进行数据复制,复制的过程需要停机么,如果不停数据在动态变化,如何处理?</p> <p>主从同步的方式复制数据不需要停机,扩容的过程中一直保持数据同步,从库和主库数据一致,扩容完成之后从库提升为主库,关闭主从同步。</p> <p>问题:Redis宕机后的主从切换是通过的哨兵机制吗?在主从切换的时候,是有切换延时的,这段时间的写入主的数据是否会丢失,如果没丢,怎么保证的?</p> <p>通过内部开发的Sentinel系统,检测Reids是否可用。为了防止误切,切换会有一定延迟,多次检测失败才会切换。如果主库不可用会有数据丢失,重要数据的写入,在业务上有重试机制</p> <p>来自: https://www.sdk.cn/news/3344</p>