陌陌上季度的MAU为6980万,Feed作为主要的社交业务,从2013年上线到现在,日请求量超过亿,总数据量超过百亿。下面是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相比,实现简单很多,大部分业务一行代码搞定:
-
ZADD timeline 1460198781 88888888 //插入一条feed_id为88888888的Feed,插入时间为1460198781
-
ZREVRANGE timeline 0 100 //查看最近的100条Feed
关于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, 增量实时聚合新内容。大致步骤:
-
遍历我的好友,找到最近发表过Feed的人
-
遍历最近发表过Feed的人,得到id和time
-
合并到我的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不一定在同一个矩形区域内。如下图:
解决这个问题可以在查询时扩大范围,除了查询用户所在的矩形外,还扩散搜索相邻的8个矩形,将9个矩形合并(如下图),按时间排序,过滤掉超出距离范围的Feed,最后做分页查询。
wx4g9相邻的8个 geohash :wx4gb,wx4gc,wx4gf,wx4g8,wx4gd,wx4g2,wx4g3,wx4g6。
归纳为四个步骤: ExtendSearch -> MergeAndSort -> DistanceFilter -> Skip 。
但是这种方式查询效率比较低,作为读远远大于写的场景,换了一种思路,在 更新Feed空间索引 时,将Feed写入相邻的8个矩形,这样每个矩形还包含了相邻矩形的Feed,查询省去了
ExtendSearch和MergeAndSort两个步骤。
通过数据冗余的方式,换取了更高的查询效率。将复杂的geo查询,简化为redis的zrange操作,性能 提高了一个数量级 ,平均耗时降到3ms。空间索引通过geohash分片到redis节点,具有 数据分布均匀 、 方便扩容 的优势。
总结
陌陌的Feed服务大规模使用Redis作为缓存和存储,Redis的性能非常高,了解它的特性,并且正确使用可以解决很多大规模请求的性能问题。通常内存的故障率远低于硬盘的故障率,生产环境Redis的稳定性是非常高的。通过合理的持久化策略和一主多从的部署结构,可以确保数据丢失的风险降到最低。
另外,陌陌的Feed服务构建在许多内部技术框架和基础组件之上,本文偏重于业务方面,没有深入展开,后续有机会可以再做介绍。
互动问答
问题:MongoDB采用什么集群方式部署的,如果数据量太大,采用什么方式来提高查询性能?
我们通过在mongo客户端按id做hash的方式分片。当时MongoDB版本比较低,复制集(repl-set)还不太成熟,没有在生产环境使用。除了建索引以外,还可以通过把mongo数据文件挂载在内存盘(tmpfs)上提高查询性能,不过有重启丢数据的风险。
问题:用户的关系是怎么存储的呢 还有就是获取好友动态时每条feed的用户信息是动态从Redis或者其他地方读取呢?
陌陌的用户关系使用Redis存储的。获取好友动态是的用户信息是通过feed的owner,再去另外一个用户资料服务(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方式分片,添加机器时进行数据复制,复制的过程需要停机么,如果不停数据在动态变化,如何处理?
主从同步的方式复制数据不需要停机,扩容的过程中一直保持数据同步,从库和主库数据一致,扩容完成之后从库提升为主库,关闭主从同步。
转载请注明来源网站:blog.ytso.com谢谢!
原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/6744.html