一个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),而这就是一个活生生的典型例子。

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


更多文章
  • Gin源码阅读与分析
  • 如何面试-作为面试官得到的经验
  • 自己写一个容器
  • Golang(Go语言)中实现典型的fork调用
  • 软件开发之禅---大事化小,各个击破
  • 程序员的自我修养:链接,装载与库 阅读笔记
  • Redis源码阅读与分析二:双链表
  • Redis源码阅读与分析三:哈希表
  • Redis源码阅读与分析一:sds
  • Golang runtime 源码阅读与分析
  • Golang的一些坑
  • GC 垃圾回收
  • 设计一个路由
  • Go语言性能优化实战
  • 那些年开发的时候踩过的坑