7

一个feed流系统的演进

 3 years ago
source link: https://jiajunhuang.com/articles/2020_03_02-feed_system_design.md.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一个feed流系统的演进

这几年我持续开发维护了一个类feed流系统。类feed流在结构上都有这么一个共性,即类feed流系统是一个中心节点,将多个用户和 多个feed来源连接起来:

User 1                     System 1
User 2   \              /  System 2
User 3  --- Feed流系统 --- System 3
...      /              \  ...
User n                     System n

而架构上,则一般是这么三种:

  • 混合模式(推拉模式)

目前这套系统正在服务数百万用户,能够做到关键接口平均响应时间20-30ms以内。接下来,我们从它的诞生讲起。


最开始这套系统是一个单体应用中的一环,最开始的架构模式是使用拉的模式,拉的模式,也就是每次用户要求获取feed时,临时 去数据库或各数据源拉取数据,这么设计的好处是,非常节省空间,比如一条全国feed只存储一条feed,而当用户读取时,才往数据库 写入一条已读记录,如果没有这条记录,那么就意味着用户未读。

那么这种模式的缺点是什么呢?计算机中的一切都是权衡取舍(trade-off),有得必有舍。试想这么一个场景:当发出一条全国feed之后, 用户蜂拥而至,此时DB的要面临:

- 大量请求读取用户与feed的关系,以便返回feed流
- 当用户读取feed时,App会发送一个请求将该feed设置为已读,此时DB面临的就是大量的写入

大量的并发读与并发写,导致DB负载急速升高,当时由于这是单体应用,便会拖垮整个应用。

我加入团队之后,提出要将这个系统重构,说干就干。于是采取了另外一种模式,推的模式,这种模式在此后两年内工作良好,一直到 最近,随着用户的不断增长,当再次发送全国feed时,数据库会出现报警,响应时间会出现抖动,但此时仍然仅靠一个单机数据库, 存储了不到10亿的关系,证明了MySQL的可靠性(当然是在合理优化数据库查询和使用缓存的前提下)。

推的模式很简单,就是将大量的读和写分开,先把所有的用户与feed的关系铺开,写入数据库,然后再发全国feed,这时候用户 蜂拥而至,此时的DB面临的是:

- 大量用户读取用户与feed的关系,返回feed流
- 当用户读取feed时,App会发送一个请求将该feed设置为已读,此时DB面临的是更新用户feed的读取状态

看起来和上面差不多?不对,首先第一条,在拉的模式下,由于feed、用户与feed的关系及状态、用户非群推的关系都在不同的表里, 为了查出feed流,查询数据库的次数很多,而推的模式由于把全国feed和非全国feed平铺开了,减少了查询数据库的次数; 第二条,拉的模式下,要插入feed与用户的关系,由于要构建索引的关系,DB负载会比较高,而第二种模式只需要顺着索引找到数据, 更新一个字段,不需要构建或者构建少量的索引。因此这套系统良好的运行了数年时间。

没有银弹,这是真的,第二种模式有一个很大的缺陷,那就是feed不是所有人都会读的,一般来说,feed的读取率都不高,平铺开 所有的关系之后,数据库里存储了大量的“无用”的关系,导致单表数据量非常之大(如上,不超过10亿),尽管目前在发送全国feed 之后响应只慢一点,负载高一些,但是这种模式没有办法持续下去。

用户会增长,日活会变高,数据量会变大,而MySQL能承担的负载有上限,经费预算也是有上限的。意味着,为了不久的将来这套系统 能承受更高的并发和多的请求及feed,需要做第二次大型优化。

我们所遇到的主要是全国feed之后带来的并发问题。全国feed的写入量很大,但是由于读取率不会很高,意味着其中超过一半的feed 关系是浪费存储空间的,所以我们要结合推拉两种模式。

我提出的解决方案是:将全国feed与用户的关系放在Redis里,在一段时间后将关系转储到MySQL。这样的好处是结合了Redis优秀的 性能和MySQL近乎无限的存储空间。为啥要这样做呢?由于这是一种结合推和拉的模式,而我们知道了拉的模式的缺点,因此我们使用 Redis的空间(或者说内存)来换取时间,当全国feed发送出去之后一段时间,该feed已经不再是热点(这是业务特点),这个时候 我们再将该feed转储到数据库(或者干脆点,直接归档,我们用的后者,具体是否直接归档需要取决于具体业务和产品设计的取舍), 此后零星的读取对数据库的性能影响就不再那么大了。

既然没有银弹,这么做的缺点是什么呢?那就是业务代码变得复杂很多,原本我们只要从用户与feed的关系表中读取,然后join出 feed就可以得到feed流,现在在读取时,需要读取数据库中的关系和feed,需要读取Redis中的关系和feed,还要把他们拼一起。 在用户变更读取状态时,也要根据feed的属性去不同地方更新,各处都需要改动。

软件设计中,无处不是权衡取舍(trade off),而这就是一个活生生的典型例子。

希望这篇文章能够给做此类系统的同行一些有用的点。


微信公众号
关注公众号,获得及时更新

Web开发系列(四):Flask, Tornado和WSGI

Web开发系列(三):什么是HTML,CSS,JS?

Web开发系列(二):HTTP协议

Web开发系列(一):从输入网址到最后,这个过程经历了什么?

SNI: 让Nginx在一个IP上使用多个证书

Haskell: infixl, infixr, infix

Haskell简明教程(五):处理JSON

Haskell简明教程(四):Monoid, Applicative, Monad

HTTPS 的详细流程

OAuth2 为什么需要 Authorization Code?

任务队列怎么写?python rq源码阅读与分析

XMonad 配置教程

Haskell简明教程(三):Haskell语法

Haskell简明教程(二):从命令式语言进行抽象

Haskell简明教程(一):从递归说起




About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK