一个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),而这就是一个活生生的典型例子。
希望这篇文章能够给做此类系统的同行一些有用的点。
更多文章
- socks5 协议详解
- zerotier简明教程
- 搞定面试中的系统设计题
- frp 源码阅读与分析(一):流程和概念
- 用peewee代替SQLAlchemy
- Golang(Go语言)中实现典型的fork调用
- DNSCrypt简明教程
- 一个Gunicorn worker数量引发的血案
- Golang validator使用教程
- Docker组件介绍(一):runc和containerd
- Docker组件介绍(二):shim, docker-init和docker-proxy
- 使用Go语言实现一个异步任务框架
- 协程(coroutine)简介 - 什么是协程?
- SQLAlchemy简明教程
- Go Module 简明教程