本文先简述Feed流系统核心业务点,了解Feed流的关键技术点,结合到家发现频道业务特点,逐步描述到家Feed系统的架构迭代历程。
概念
常见的Feed流产品对比
分类说明
关注关系说明
同步方式说明
到家发现频道介绍
业务功能介绍
业务特点介绍
系统设计方案迭代
基于DB的最简方案
引入Elasticsearch
引入缓存(jimdb)
存储结构深度优化
总结
Feed一词源自早期的RSS,是将内容呈现给用户并持续更新的一种方式,用户可以选择订阅多个资源,网站提供Feed网址,用户将Feed网址登记到阅读器里,在阅读器里形成的聚合页就是Feed流。
在信息学里,Feed其实是一个信息单元,比如一条朋友圈状态、一条微博、一条资讯或一条短视频等,所以Feed流就是不停更新的信息单元,只要关注某些发布者就能获取到源源不断的新鲜信息,既而用户也就可以在移动设备上逐条去浏览这些信息单元,如下图,最基本的结构即为生产消费者模式。
以上是最常见的分类,但是很容易产生第三种分类,Timeline与Rank混合展示。
拉模式(读扩散):消息生产者发送消息后,不直接推到消费者,而是存储在与消费者相关的位置,当消费者需要获取消息时,再进行读取,如此一条数据只会写入一次,单读取消息次数与消费者数量有关,因此读会放大即为读扩散;此方式对于读取性能要求比较高,且容易产生流量热点。
推模式(写扩散):消息生产者发送消息后,立刻将消息推送到消费者进行消费,消费者不一定会在线等待,但消息会绑定在消费者维度上,一条消息需要按照消费者数量进行写入,一条数据被重复复制多次,将写放大此为写扩散;此模式对于写入稳定性及存储成本要求都比较高,但读取消息时较为简单,直接按照消费者维度读取消息即可。
以上从概念、分类、关注关系、同步方式几方面熟悉了下Feed流系统基本业务点及功能点,接下来我们看下Feed流系统在到家业务中的实践,对于在O2O领域,基于到家亿级用户量的架构迭代之路。
京东到家为消费者提供超市便利、生鲜果蔬、医药健康、手机数码、鲜花、蛋糕、服饰、家居、个护美妆等海量商品 1 小时配送到家的极致服务体验。在整个服务过程中,京东到家基于LBS(基于位置服务)将消费者和线下商家联系到一起,因此到家业务中产生的Feed业务即为用户与门店的关系,门店与动态消息的关系。如下图即为到家APP端发现频道页面功能呈现。
到家的发现频道建设的目的是提高商家自运营能力以及帮助用户更便捷的了解商家活动动态,核心操作为商家按照门店维度进行动态的发布,以此形式保持商家与用户的粘性。以下为提炼出的门店-用户-动态(消息)的关系图,用户关注多个门店,门店发布多条动态,进而以用户维度查看关注的动态信息。
C端业务(App展示端)
B端业务(商家发布端)
商家以多门店运营的方式发布、删除门店动态,构建门店与动态的多对一关系
由于用户业务已经被用户系统承载,故Feed系统只需要表达用户pin与门店的相互关系及门店动态数据,基于DB实现,只需要「下图」(只描述核心字段)中的用户门店关系表(「user_store_relation」)及动态表(「feed_content」)。
「user_store_relation」表主要有两个字段pin、store_id,表示用户与门店的关系。
「feed_content」表主要是存储门店维度动态信息,核心字段为store_id、feed_id及Feed相关的info信息。
「user_feed_relation」表是在「user_store_relation」基础上增加feed_id字段,由于涉及到Timeline这样的展示形式,需要分页处理数据,因此采用冗余表处理
用户按Timeline方式查看关注的门店动态数据
通过user_store_relation与feed_content使用feed_id表连接,并使用Feed发布时间进行排序展示
查看附近门店动态(未关注动态)
上游系统通过定位服务获取到门店列表后,直接通过feed_content表进行store_id过滤及按照发布时间排序展示
发布/删除动态
增删feed_content动态表数据后,需要按照store_id获取所有pin列表,同步删除user_store_relation表数据,保证数据一致性
关注/取关门店
增删user_store_relation用户门店关系后,同步删除user_feed_relation表数据,保证数据一致性
随着用户量增加,user_store_relation表及user_feed_relation表数据量膨胀,如上文描述到家当前用户量已达亿级,每个用户关注几十个门店,导致user_store_relation表及user_feed_relation表数据量将达到十亿级,很容易想到的就是要进行数据表水平拆分。
拆分方案如下:
以上两个方案都难以满足业务需求,因此我们引入第二版本优化方案。
Elasticsearch天然具备分布式存储能力,且支持PB级别的数据量存储,虽然是非实时性存储,存在秒级延迟,但在到家Feed系统业务场景下可以满足需求,依靠Elasticsearch2.x版本具有的父子文档特性,创建两个type,分别是storefans(用户门店的关系)与feedInfo(动态基本信息),并建立父子文档关系,如下图所示。
「storefans」中pin为数组,构建出门店与用户的关系为一对多关系;
「feedInfo」存储门店维度动态信息,使用ES避免了分表操作;
用户以Timeline查看关注的门店动态数据
通过storefans与feedInfo父子文档实现,并使用feedInfo中发布时间进行排序展示,一次IO即可将结果返回,无需像数据库那样经过二次索引一次扫描相同的key对应的所有行记录
查看附近门店动态(未关注动态)
上游系统通过定位服务获取到门店列表后,直接通过feedInfo进行store_id过滤及按照发布时间排序展示
发布/删除动态
增删feedInfo动态表数据即可完成
关注/取关门店
增删storefans中pins中用户即可完成
ES瞬间写操作影响读取性能
引入ES后,C端业务直接迁移到ES实现,B端要保证ES数据一致性,因此动态处理要实时维护ES数据,而ES适用于读多写少的业务,在到家场景下,更多的是商家维护旗下多个门店动态发布,瞬间会导致大量请求操作ES,影响C端读操作
Feed系统读写比差异较大
Feed系统更侧重的是C端的数据读取,读写严重不平衡,读多写少,一般读写比例在1000:1以上
首页性能要求较高
新的需求要实现,首页提示用户有「未读动态」,对于性能要求比较高,ES父子文档不能满足性能要求
「未读动态」需求的实现,需要处理用户上次读取的记录,使用ES及mysql存储,实现上都会比较复杂
鉴于以上问题,我们即将引入缓存jimdb。
jimdb是京东自研的一款实现了在线伸缩、在线迁移、智能调度、异地多活、冷热分离等重要特性的分布式缓存与高速键值存储服务,支持所有redis数据结构及命令,自主研发客户端多分片路由策略,可以实现集群模式下的mget操作,减少RT耗时。
引入缓存后
用户以Timeline方式查看关注的门店动态数据
直接使用用户级门店动态缓存数据,一次IO完成结果返回,提高接口性能,如果缓存miss后,进行回源ES处理
查看附近门店动态(未关注动态)
上游系统通过定位服务获取到门店列表后,通过ES中的feedInfo进行store_id过滤及按照发布时间排序展示
未读动态数展示
在用户级门店动态缓存中根据用户最后一次访问列表的时间计算未读动态数
发布/删除动态
增删mysql及ES中的Feed动态表数据后,需要处理热点缓存数据
关注/取关门店
增删mysql及ES中的storefans中pins中用户后,需要处理热点缓存数据
发布/删除/关注/取关操作
为了保证热点缓存与数据库数据数据一致,数据变更后,需要同步处理数据库与ES,而由于缓存是异构存储,因此只能重新加载用户关注的所有(有效)门店动态,进行全量写入
随着用户量及动态数据量增加,这个操作执行成本很高
用户维度动态缓存变为大KEY
随着用户关注的门店增加或门店发布的动态数据增长,都将导致大KEY的产生
缓存大KEY非常缓存集群稳定性,容易引起IO阻塞
重复的动态存储,导致缓存内存持续上涨
门店量与用户量的比例大概是1:1000,相对应的门店动态与用户量比例也是1:1000,因此用户维度动态缓存将动态数据进行了用户维度扩展,导致了缓存内存量增长
不论是动态、关注关系的变更导致的数据一致性维度难度,或是缓存集群内存的增长,都将促使我们进一步进行结构优化
将ES升级至7.x版本,并删除父子文档结构,为避免由于动态增删及用户关注关系的变更导致门店动态降维处理,影响ES服务性能故,ES中保留「user_store_relation」(用户门店关系表),mysql保留「feed_contend」动态表。
动态维度缓存数据
动态维度缓存可以达到门店维度数据共享,降低缓存内存占用
用户维度门店动态列表数据
只存储store_feedId列表避免大KEY的产生
redis的zset(sorted set)是string类型元素的集合,且不允许重复的成员,value对象内部是使用ziplist或skiplist+hashtable来实现,每个元素都会关联一个double类型的分数,redis正是通过分数来为集合中的成员进行从小到大的排序。zset的成员是唯一的,但分数(score)可以重复。
zset中添加、删除或更新一个成员都是非常快速的操作,其时间复杂度为集合中成员数量的对数。由于zset中的成员在集合中的位置是有序的,因此,即便是访问位于集合中部的成员也仍然是非常高效的,常用于「游戏排名、微博热点话题等使用场景」
引入zset这个缓存结构有以下方面考虑:
用户以Timeline方式查看关注的门店动态数据
使用zset缓存结构分页读取到feed_id后,使用jimdb的mget方式获取feed_info数据
查看附近门店动态(未关注动态)
使用门店维度feed_id列表数据,使用jimdb的mget方式获取feed_info数据
未读动态数展示
在用户级门店动态缓存中根据用户最后一次访问列表的时间计算未读动态数
发布/删除动态
删除feed_id维度info缓存,用户维度zset中元素会在进行feed_info补全时,无数据补全进行异步删除zset数据,保证一致性
关注/取关门店
判断是否存在热点缓存数据,若存在,获取门店维度feed_id列表后直接操作zset进行元素删除
减少IO操作
动态更新避免在用户维度全量回源进行数据拉取,可直接操作动态缓存数据及用户zset结构数据
动态数据复用
共享门店动态数据,且保证唯一,降低内存占用,提高缓存命中率 缓存占用降低60%
feed_id维度会为热点key,导致jimdb单分片QPS过高问题
jimdb提供本地缓存方案,可以按照指定key前缀实现本地缓存,避免集群流量不均问题
所有的缓存均是热点缓存,缓存过期如何处理
缓存过期后可以降级到ES层处理,但避免流量过大,我们会增加ES层限流,保证集群稳定
门店与用户关系数据存储在ES中,避免了大数据量导致的分库分表操作
门店动态数据存储在ES中,缓存结构深度优化及增加了回源限流后,我们也可以采用mysql存储
redis zset结构存储「热点用户」的门店动态索引列表数据
redis list结构存储门店动态索引数据
redis string结构动态info数据,热点用户动态及门店动态可以共享同一份info数据,减少内存占用
推模式
动态发布后,热点用户会进行推模式同步,更新用户维度的门店动态索引缓存,保证数据一致性,降低业务业务复杂度,避免大V效应
拉模式
热点用户获取到门店动态索引后,需要使用动态缓存info数据进行补全
热点用户缓存过期后会回源底层存储,拉取用户维度门店动态索引及动态info缓存数据
非热点用户采取拉模式,降低了缓存内存占用及动态更新成本
对于用户量及并发量压力都比较大的系统,合理的使用推模式与拉模式,可以解决单一模式中难以解决的棘手问题