This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
<audio id="audio" title="37 | 计数系统设计(一):面对海量数据的计数器要如何做?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/53/7935a2021faee0a81d5a1ff04968b553.mp3"></audio>
你好,我是唐扬。
从今天开始,我们正式进入最后的实战篇。在之前的课程中,我分别从数据库、缓存、消息队列和分布式服务化的角度,带你了解了面对高并发的时候要如何保证系统的高性能、高可用和高可扩展。课程中虽然有大量的例子辅助你理解理论知识,但是没有一个完整的实例帮你把知识串起来。
所以,为了将我们提及的知识落地,在实战篇中,我会以微博为背景,用两个完整的案例带你从实践的角度应对高并发大流量的冲击,期望给你一个更加具体的感性认识,为你在实现类似系统的时候提供一些思路。今天我要讲的第一个案例是如何设计一个支持高并发大存储量的计数系统。
**来看这样一个场景:** 在地铁上,你也许会经常刷微博、点赞热搜,如果有抽奖活动,再转发一波,而这些与微博息息相关的数据,其实就是微博场景下的计数数据,细说起来,它主要有几类:
1. 微博的评论数、点赞数、转发数、浏览数、表态数等等;
1. 用户的粉丝数、关注数、发布微博数、私信数等等。
微博维度的计数代表了这条微博受欢迎的程度,用户维度的数据(尤其是粉丝数),代表了这个用户的影响力,因此大家会普遍看重这些计数信息。并且在很多场景下,我们都需要查询计数数据(比如首页信息流页面、个人主页面),计数数据访问量巨大,所以需要设计计数系统维护它。
但在设计计数系统时,不少人会出现性能不高、存储成本很大的问题,比如,把计数与微博数据存储在一起,这样每次更新计数的时候都需要锁住这一行记录,降低了写入的并发。在我看来,之所以出现这些问题,还是因为你对计数系统的设计和优化不甚了解,所以要想解决痛点,你有必要形成完备的设计方案。
## 计数在业务上的特点
首先,你要了解这些计数在业务上的特点是什么,这样才能针对特点设计出合理的方案。在我看来,主要有这样几个特点。
- 数据量巨大。据我所知微博系统中微博条目的数量早已经超过了千亿级别仅仅计算微博的转发、评论、点赞、浏览等核心计数其数据量级就已经在几千亿的级别。更何况微博条目的数量还在不断高速地增长并且随着微博业务越来越复杂微博维度的计数种类也可能会持续扩展比如说增加了表态数因此仅仅是微博维度上的计数量级就已经过了万亿级别。除此之外微博的用户量级已经超过了10亿用户维度的计数量级相比微博维度来说虽然相差很大但是也达到了百亿级别。那么如何存储这些过万亿级别的数字对我们来说就是一大挑战。
- 访问量大对于性能的要求高。微博的日活用户超过2亿月活用户接近5亿核心服务比如首页信息流访问量级达到每秒几十万次计数系统的访问量级也超过了每秒百万级别而且在性能方面它要求要毫秒级别返回结果。
- 最后对于可用性、数字的准确性要求高。一般来讲用户对于计数数字是非常敏感的比如你直播了好几个月才涨了1000个粉突然有一天粉丝数少了几百个那么你是不是会琢磨哪里出现问题或者打电话投诉直播平台
那么,面临着高并发、大数据量、数据强一致要求的挑战,微博的计数系统是如何设计和演进的呢?你又能从中借鉴什么经验呢?
## 支撑高并发的计数系统要如何设计
刚开始设计计数系统的时候微博的流量还没有现在这么夸张我们本着KISSKeep It Simple and Stupid原则尽量将系统设计得简单易维护所以我们使用MySQL存储计数的数据因为它是我们最熟悉的团队在运维上经验也会比较丰富。举个具体的例子。
假如要存储微博维度微博的计数转发数、点赞数等等的数据你可以这么设计表结构以微博ID为主键转发数、评论数、点赞数和浏览数分别为单独一列这样在获取计数时用一个SQL语句就搞定了。
```
select repost_count, comment_count, praise_count, view_count from t_weibo_count where weibo_id = ?
```
在数据量级和访问量级都不大的情况下,这种方式最简单,所以如果你的系统量级不大,你可以直接采用这种方式来实现。
后来,随着微博的不断壮大,之前的计数系统面临了很多的问题和挑战。
比如微博用户量和发布的微博量增加迅猛计数存储数据量级也飞速增长而MySQL数据库单表的存储量级达到几千万的时候性能上就会有损耗。所以我们考虑使用分库分表的方式分散数据量提升读取计数的性能。
我们用“weibo_id”作为分区键在选择分库分表的方式时考虑了下面两种
- 一种方式是选择一种哈希算法对weibo_id计算哈希值然后根据这个哈希值计算出需要存储到哪一个库哪一张表中具体的方式你可以回顾一下第9讲数据库分库分表的内容
- 另一种方式是按照weibo_id生成的时间来做分库分表我们在第10讲谈到发号器的时候曾经提到ID的生成最好带有业务意义的字段比如生成ID的时间戳。所以在分库分表的时候可以先依据发号器的算法反解出时间戳然后按照时间戳来做分库分表比如一天一张表或者一个月一张表等等。
因为越是最近发布的微博,计数数据的访问量就越大,所以虽然我考虑了两种方案,但是按照时间来分库分表会造成数据访问的不均匀,最后用了哈希的方式来做分库分表。
<img src="https://static001.geekbang.org/resource/image/50/9c/508201de80dd909d8b7dff1d34be9f9c.jpg" alt="">
与此同时计数的访问量级也有质的飞跃。在微博最初的版本中首页信息流里面是不展示计数数据的那么使用MySQL也可以承受当时读取计数的访问量。但是后来在首页信息流中也要展示转发、评论和点赞等计数数据了。而信息流的访问量巨大仅仅靠数据库已经完全不能承担如此高的并发量了。于是我们考虑使用Redis来加速读请求通过部署多个从节点来提升可用性和性能并且通过Hash的方式对数据做分片也基本上可以保证计数的读取性能。然而这种数据库+缓存的方式有一个弊端无法保证数据的一致性比如如果数据库写入成功而缓存更新失败就会导致数据的不一致影响计数的准确性。所以我们完全抛弃了MySQL全面使用Redis来作为计数的存储组件。
<img src="https://static001.geekbang.org/resource/image/7c/62/7c8ed7992ec206671a18b8d537eaef62.jpg" alt="">
除了考虑计数的读取性能之外,由于热门微博的计数变化频率相当高,也需要考虑如何提升计数的写入性能。比如,每次在转发一条微博的时候,都需要增加这条微博的转发数,那么如果明星发布结婚、离婚的微博,瞬时就可能会产生几万甚至几十万的转发。如果是你的话,要如何降低写压力呢?
你可能已经想到用消息队列来削峰填谷了也就是说我们在转发微博的时候向消息队列写入一条消息然后在消息处理程序中给这条微博的转发计数加1。**这里需要注意的一点,** 我们可以通过批量处理消息的方式进一步减小Redis的写压力比如像下面这样连续更改三次转发数我用SQL来表示来方便你理解
```
UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1;
UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1;
UPDATE t_weibo_count SET repost_count = repost_count +1 WHERE weibo_id = 1;
```
这个时候,你可以把它们合并成一次更新:
```
UPDATE t_weibo_count SET repost_count = repost_count + 3 WHERE weibo_id = 1;
```
## 如何降低计数系统的存储成本
讲到这里,我其实已经告诉你一个支撑高并发查询请求的计数系统是如何实现的了。但是在微博的场景下,计数的量级是万亿的级别,这也给我们提出了更高的要求,**就是如何在有限的存储成本下实现对于全量计数数据的存取。**
你知道Redis是使用内存来存储信息相比于使用磁盘存储数据的MySQL来说存储的成本不可同日而语比如一台服务器磁盘可以挂载到2个T但是内存可能只有128G这样磁盘的存储空间就是内存的16倍。而Redis基于通用性的考虑对于内存的使用比较粗放存在大量的指针以及额外数据结构的开销如果要存储一个KV类型的计数信息Key是8字节Long类型的weibo_idValue是4字节int类型的转发数存储在Redis中之后会占用超过70个字节的空间空间的浪费是巨大的。**如果你面临这个问题,要如何优化呢?**
我建议你先对原生Redis做一些改造采用新的数据结构和数据类型来存储计数数据。我在改造时主要涉及了两点
- 一是原生的Redis在存储Key时是按照字符串类型来存储的比如一个8字节的Long类型的数据需要8sdshdr数据结构长度+ 198字节数字的长度+1\0=28个字节如果我们使用Long类型来存储就只需要8个字节会节省20个字节的空间
- 二是去除了原生Redis中多余的指针如果要存储一个KV信息就只需要8weibo_id+4转发数=12个字节相比之前有很大的改进。
同时我们也会使用一个大的数组来存储计数信息存储的位置是基于weibo_id的哈希值来计算出来的具体的算法像下面展示的这样
```
插入时:
h1 = hash1(weibo_id) //根据微博ID计算Hash
h2 = hash2(weibo_id) //根据微博ID计算另一个Hash用以解决前一个Hash算法带来的冲突
for s in 0,1000
pos = (h1 + h2*s) % tsize //如果发生冲突就多算几次Hash2
if(isempty(pos) || isdelete(pos))
t[ pos ] = item //写入数组
查询时:
for s in 0,1000
pos = (h1 + h2*s) % tsize //依照插入数据时候的逻辑,计算出存储在数组中的位置
if(!isempty(pos) &amp;&amp; t[pos]==weibo_id)
return t[pos]
return 0
删除时:
insert(FFFF) //插入一个特殊的标
```
在对原生的Redis做了改造之后你还需要进一步考虑如何节省内存的使用。比如微博的计数有转发数、评论数、浏览数、点赞数等等如果每一个计数都需要存储weibo_id那么总共就需要8weibo_id*44个微博ID+4转发数 + 4评论数 + 4点赞数 + 4浏览数= 48字节。但是我们可以把相同微博ID的计数存储在一起这样就只需要记录一个微博ID省掉了多余的三个微博ID的存储开销存储空间就进一步减少了。
不过,即使经过上面的优化,由于计数的量级实在是太过巨大,并且还在以极快的速度增长,所以如果我们以全内存的方式来存储计数信息,就需要使用非常多的机器来支撑。
然而微博计数的数据具有明显的热点属性越是最近的微博越是会被访问到时间上久远的微博被访问的几率很小。所以为了尽量减少服务器的使用我们考虑给计数服务增加SSD磁盘然后将时间上比较久远的数据dump到磁盘上内存中只保留最近的数据。当我们要读取冷数据的时候使用单独的I/O线程异步地将冷数据从SSD磁盘中加载到一块儿单独的Cold Cache中。
<img src="https://static001.geekbang.org/resource/image/16/93/16cb144c96a0ab34214c966f686c9693.jpg" alt="">
在经过了上面这些优化之后,我们的计数服务就可以支撑高并发大数据量的考验,无论是在性能上、成本上和可用性上都能够达到业务的需求了。
总的来说我用微博设计计数系统的例子并不是仅仅告诉你计数系统是如何做的而是想告诉你在做系统设计的时候需要了解自己系统目前的痛点是什么然后再针对痛点来做细致的优化。比如微博计数系统的痛点是存储的成本那么我们后期做的事情很多都是围绕着如何使用有限的服务器存储全量的计数数据即使是对开源组件Redis做深度的定制会带来很大的运维成本也只能被认为是为了实现计数系统而必须要做的权衡。
## 课程小结
以上就是本节课的全部内容了。本节课我以微博为例带你了解了如何实现一套存储千亿甚至万亿数据的高并发计数系统,这里你需要了解的重点如下:
1. 数据库+缓存的方案是计数系统的初级阶段,完全可以支撑中小访问量和存储量的存储服务。如果你的项目还处在初级阶段,量级还不是很大,那么你一开始可以考虑使用这种方案。
1. 通过对原生Redis组件的改造我们可以极大地减小存储数据的内存开销。
1. 使用SSD+内存的方案可以最终解决存储计数数据的成本问题。这个方式适用于冷热数据明显的场景,你在使用时需要考虑如何将内存中的数据做换入换出。
其实随着互联网技术的发展已经有越来越多的业务场景需要使用上百G甚至几百G的内存资源来存储业务数据但是对于性能或者延迟并没有那么高的要求如果全部使用内存来存储无疑会带来极大的成本浪费。因此在业界有一些开源组件也在支持使用SSD替代内存存储冷数据比如[Pika](https://github.com/Qihoo360/pika)[SSDB](https://github.com/ideawu/ssdb),这两个开源组件,我建议你可以了解一下它们的实现原理,这样可以在项目中需要的时候使用。而且,在微博的计数服务中也采用了类似的思路,如果你的业务中也需要使用大量的内存,存储热点比较明显的数据,不妨也可以考虑使用类似的思路。
## 一课一思
你的系统中是否也有大量的计数类的需求呢?你是如何设计方案来存储和读取这些计数的呢?欢迎在留言区与我分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,114 @@
<audio id="audio" title="38 | 计数系统设计50万QPS下如何设计未读数系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/7b/de66c0218096fffd09a385d5b1e3a57b.mp3"></audio>
你好,我是唐扬。
在上一节课中我带你了解了如何设计一套支撑高并发访问和存储大数据量的通用计数系统我们通过缓存技术、消息队列技术以及对于Redis的深度改造就能够支撑万亿级计数数据存储以及每秒百万级别读取请求了。然而有一类特殊的计数并不能完全使用我们提到的方案那就是未读数。
未读数也是系统中一个常见的模块,以微博系统为例,你可看到有多个未读计数的场景,比如:
- 当有人@你、评论你、给你的博文点赞或者给你发送私信的时候,你会收到相应的未读提醒;
- 在早期的微博版本中有系统通知的功能,也就是系统会给全部用户发送消息,通知用户有新的版本或者有一些好玩的运营活动,如果用户没有看,系统就会给他展示有多少条未读的提醒。
- 我们在浏览信息流的时候,如果长时间没有刷新页面,那么信息流上方就会提示你在这段时间有多少条信息没有看。
那当你遇到第一个需求时,要如何记录未读数呢?其实,这个需求可以用上节课提到的通用计数系统来实现,因为二者的场景非常相似。
你可以在计数系统中增加一块儿内存区域以用户ID为Key存储多个未读数当有人@ 你时,增加你的未读@的计数;当有人评论你时,增加你的未读评论的计数,以此类推。当你点击了未读数字进入通知页面,查看@ 你或者评论你的消息时,重置这些未读计数为零。相信通过上一节课的学习,你已经非常熟悉这一类系统的设计了,所以我不再赘述。
那么系统通知的未读数是如何实现的呢?我们能用通用计数系统实现吗?答案是不能的,因为会出现一些问题。
## 系统通知的未读数要如何设计
来看具体的例子。假如你的系统中只有A、B、C三个用户那么你可以在通用计数系统中增加一块儿内存区域并且以用户ID为Key来存储这三个用户的未读通知数据当系统发送一个新的通知时我们会循环给每一个用户的未读数加1这个处理逻辑的伪代码就像下面这样
```
List&lt;Long&gt; userIds = getAllUserIds();
for(Long id : userIds) {
incrUnreadCount(id);
}
```
这样看来,似乎简单可行,但随着系统中的用户越来越多,这个方案存在两个致命的问题。
首先,获取全量用户就是一个比较耗时的操作,相当于对用户库做一次全表的扫描,这不仅会对数据库造成很大的压力,而且查询全量用户数据的响应时间是很长的,对于在线业务来说是难以接受的。如果你的用户库已经做了分库分表,那么就要扫描所有的库表,响应时间就更长了。**不过有一个折中的方法,** 那就是在发送系统通知之前先从线下的数据仓库中获取全量的用户ID并且存储在一个本地的文件中然后再轮询所有的用户ID给这些用户增加未读计数。
这似乎是一个可行的技术方案然而它给所有人增加未读计数会消耗非常长的时间。你计算一下假如你的系统中有一个亿的用户给一个用户增加未读数需要消耗1ms那么给所有人都增加未读计数就需要100000000 * 1 /1000 = 100000秒也就是超过一天的时间即使你启动100个线程并发的设置也需要十几分钟的时间才能完成而用户很难接受这么长的延迟时间。
另外,使用这种方式需要给系统中的每一个用户都记一个未读数的值,而在系统中,活跃用户只是很少的一部分,大部分的用户是不活跃的,甚至从来没有打开过系统通知,为这些用户记录未读数显然是一种浪费。
通过上面的内容,你可以知道为什么我们不能用通用计数系统实现系统通知未读数了吧?那正确的做法是什么呢?
要知道系统通知实际上是存储在一个大的列表中的这个列表对所有用户共享也就是所有人看到的都是同一份系统通知的数据。不过不同的人最近看到的消息不同所以每个人会有不同的未读数。因此你可以记录一下在这个列表中每个人看过最后一条消息的ID然后统计这个ID之后有多少条消息这就是未读数了。
<img src="https://static001.geekbang.org/resource/image/a5/10/a5f0b6776246dc6b4c7e96c72d74a210.jpg" alt="">
这个方案在实现时有这样几个关键点:
- 用户访问系统通知页面需要设置未读数为0我们需要将用户最近看过的通知ID设置为最新的一条系统通知ID
- 如果最近看过的通知ID为空则认为是一个新的用户返回未读数为0
- 对于非活跃用户比如最近一个月都没有登录和使用过系统的用户可以把用户最近看过的通知ID清空节省内存空间。
**这是一种比较通用的方案,既节省内存,又能尽量减少获取未读数的延迟。** 这个方案适用的另一个业务场景是全量用户打点的场景,比如像下面这张微博截图中的红点。<br>
<img src="https://static001.geekbang.org/resource/image/ae/3f/ae6a5e9e04be08d18c493729458d543f.jpg" alt="">
这个红点和系统通知类似,也是一种通知全量用户的手段,如果逐个通知用户,延迟也是无法接受的。**因此你可以采用和系统通知类似的方案。**
首先,我们为每一个用户存储一个时间戳,代表最近点过这个红点的时间,用户点了红点,就把这个时间戳设置为当前时间;然后,我们也记录一个全局的时间戳,这个时间戳标识最新的一次打点时间,如果你在后台操作给全体用户打点,就更新这个时间戳为当前时间。而我们在判断是否需要展示红点时,只需要判断用户的时间戳和全局时间戳的大小,如果用户时间戳小于全局时间戳,代表在用户最后一次点击红点之后又有新的红点推送,那么就要展示红点,反之,就不展示红点了。
<img src="https://static001.geekbang.org/resource/image/55/98/553e7da158a7eca56369e23c9b672898.jpg" alt="">
这两个场景的共性是全部用户共享一份有限的存储数据,每个人只记录自己在这份存储中的偏移量,就可以得到未读数了。
你可以看到,系统消息未读的实现方案不是很复杂,它通过设计避免了操作全量数据未读数,如果你的系统中有这种打红点的需求,那我建议你可以结合实际工作灵活使用上述方案。
最后一个需求关注的是微博信息流的未读数,在现在的社交系统中,关注关系已经成为标配的功能,而基于关注关系的信息流也是一种非常重要的信息聚合方式,因此,如何设计信息流的未读数系统就成了你必须面对的一个问题。
## 如何为信息流的未读数设计方案
信息流的未读数之所以复杂主要有这样几点原因。
<li>
首先微博的信息流是基于关注关系的未读数也是基于关注关系的就是说你关注的人发布了新的微博那么你作为粉丝未读数就要增加1。如果微博用户都是像我这样只有几百粉丝的“小透明”就简单了你发微博的时候系统给你粉丝的未读数增加1不是什么难事儿。但是对于一些动辄几千万甚至上亿粉丝的微博大V就麻烦了增加未读数可能需要几个小时。假设你是杨幂的粉丝想了解她实时发布的博文那么如果当她发布博文几个小时之后你才收到提醒这显然是不能接受的。所以未读数的延迟是你在设计方案时首先要考虑的内容。
</li>
<li>
其次信息流未读数请求量极大、并发极高这是因为接口是客户端轮询请求的不是用户触发的。也就是说用户即使打开微博客户端什么都不做这个接口也会被请求到。在几年前请求未读数接口的量级就已经接近每秒50万次这几年随着微博量级的增长请求量也变得更高。而作为微博的非核心接口我们不太可能使用大量的机器来抗未读数请求因此如何使用有限的资源来支撑如此高的流量是这个方案的难点。
</li>
<li>
最后,它不像系统通知那样有共享的存储,因为每个人关注的人不同,信息流的列表也就不同,所以也就没办法采用系统通知未读数的方案。
</li>
那要如何设计能够承接每秒几十万次请求的信息流未读数系统呢?你可以这样做:
- 首先,在通用计数器中记录每一个用户发布的博文数;
- 然后在Redis或者Memcached中记录一个人所有关注人的博文数快照当用户点击未读消息重置未读数为0时将他关注所有人的博文数刷新到快照中
- 这样,他关注所有人的博文总数减去快照中的博文总数就是他的信息流未读数。
<img src="https://static001.geekbang.org/resource/image/a5/8a/a563b121ae1147a2d877a7bb14c9658a.jpg" alt="">
假如用户A像上图这样关注了用户B、C、D其中B发布的博文数是10C发布的博文数是8D发布的博文数是14而在用户A最近一次查看未读消息时记录在快照中的这三个用户的博文数分别是6、7、12因此用户A的未读数就是10-6+8-7+14-12=7。
这个方案设计简单并且是全内存操作性能足够好能够支撑比较高的并发事实上微博团队仅仅用16台普通的服务器就支撑了每秒接近50万次的请求这就足以证明这个方案的性能有多出色因此它完全能够满足信息流未读数的需求。
当然了这个方案也有一些缺陷比如说快照中需要存储关注关系如果关注关系变更的时候更新不及时那么就会造成未读数不准确快照采用的是全缓存存储如果缓存满了就会剔除一些数据那么被剔除用户的未读数就变为0了。但是好在用户对于未读数的准确度要求不高未读10条还是11条其实用户有时候看不出来因此这些缺陷也是可以接受的。
通过分享未读数系统设计这个案例,我想给你一些建议:
1. 缓存是提升系统性能和抵抗大并发量的神器,像是微博信息流未读数这么大的量级我们仅仅使用十几台服务器就可以支撑,这全都是缓存的功劳;
1. 要围绕系统设计的关键困难点想解决办法,就像我们解决系统通知未读数的延迟问题一样;
1. 合理分析业务场景明确哪些是可以权衡的哪些是不行的会对你的系统设计增益良多比如对于长久不登录用户我们就会记录未读数为0通过这样的权衡可以极大地减少内存的占用减少成本。
## 课程小结
以上就是本节课的全部内容了,本节课我带你了解了未读数系统的设计,这里你需要了解的重点是:
1. 评论未读、@未读、赞未读等一对一关系的未读数可以使用上节课讲到的通用计数方案来解决;
1. 在系统通知未读、全量用户打点等存在有限的共享存储的场景下,可以通过记录用户上次操作的时间或者偏移量,来实现未读方案;
1. 最后,信息流未读方案最为复杂,采用的是记录用户博文数快照的方式。
这里你可以看到,这三类需求虽然都和未读数有关,但是需求场景不同、对于量级的要求不同,设计出来的方案也就不同。因此,就像我刚刚提到的样子,你在做方案设计的时候,要分析需求的场景,比如说数据的量级是怎样的,请求的量级是怎样的,有没有一些可以利用的特点(比如系统通知未读场景下的有限共享存储、信息流未读场景下关注人数是有限的等等),然后再制定针对性的方案,切忌盲目使用之前的经验套用不同的场景,否则就可能造成性能的下降,甚至危害系统的稳定性。
## 一课一思
结合实际项目聊一聊在你的系统中有哪些未读计数的场景呢?你是如何设计方案来实现未读计数的呢?欢迎在留言区与我分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="39 | 信息流设计(一):通用信息流系统的推模式要如何做?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/1e/04a81be015e0cadf93ae7426ab89e81e.mp3"></audio>
你好,我是唐扬。
前两节课中,我带你探究了如何设计和实现互联网系统中一个常见模块——计数系统。它的业务逻辑其实非常简单,基本上最多只有三个接口,获取计数、增加计数和重置计数。所以我们在考虑方案的时候考察点也相对较少,基本上使用缓存就可以实现一个兼顾性能、可用性和鲁棒性的方案了。然而大型业务系统的逻辑会非常复杂,在方案设计时通常需要灵活运用多种技术,才能共同承担高并发大流量的冲击。那么接下来,我将带你了解如何设计社区系统中最为复杂、并发量也最高的信息流系统。这样,你可以从中体会怎么应用之前学习的组件了。
最早的信息流系统起源于微博我们知道微博是基于关注关系来实现内容分发的也就是说如果用户A关注了用户B那么用户A就需要在自己的信息流中实时地看到用户B发布的最新内容**这是微博系统的基本逻辑,也是它能够让信息快速流通、快速传播的关键。** 由于微博的信息流一般是按照时间倒序排列的所以我们通常把信息流系统称为TimeLine时间线。那么当我们设计一套信息流系统时需要考虑哪些点呢
## 设计信息流系统的关注点有哪些
首先,我们需要关注延迟数据,也就是说,你关注的人发了微博信息之后,信息需要在短时间之内出现在你的信息流中。
其次,我们需要考虑如何支撑高并发的访问。信息流是微博的主体模块,是用户进入到微博之后最先看到的模块,因此它的并发请求量是最高的,可以达到每秒几十万次请求。
最后信息流拉取性能直接影响用户的使用体验。微博信息流系统中需要聚合的数据非常多你打开客户端看一看想一想其中需要聚合哪些数据主要是微博的数据用户的数据除此之外还需要查询微博是否被赞、评论点赞转发的计数、是否被关注拉黑等等。聚合这么多的数据就需要查询多次缓存、数据库、计数器而在每秒几十万次的请求下如何保证在100ms之内完成这些查询操作展示微博的信息流呢这是微博信息流系统最复杂之处也是技术上最大的挑战。
那么我们怎么设计一套支撑高并发大流量的信息流系统呢?一般来说,会有两个思路:一个是基于推模式,另一个是基于拉模式。
## 如何基于推模式实现信息流系统
什么是推模式呢?推模式是指用户发送一条微博后,主动将这条微博推送给他的粉丝,从而实现微博的分发,也能以此实现微博信息流的聚合。
假设微博系统是一个邮箱系统,那么用户发送的微博可以认为是进入到一个发件箱,用户的信息流可以认为是这个人的收件箱。推模式的做法是在用户发布一条微博时,除了往自己的发件箱里写入一条微博,同时也会给他的粉丝收件箱里写入一条微博。
假如用户A有三个粉丝B、C、D如果用SQL表示A发布一条微博时系统做的事情那么就像下面展示的这个样子
```
insert into outbox(userId, feedId, create_time) values(&quot;A&quot;, $feedId, $current_time); //写入A的发件箱
insert into inbox(userId, feedId, create_time) values(&quot;B&quot;, $feedId, $current_time); //写入B的收件箱
insert into inbox(userId, feedId, create_time) values(&quot;C&quot;, $feedId, $current_time); //写入C的收件箱
insert into inbox(userId, feedId, create_time) values(&quot;D&quot;, $feedId, $current_time); //写入D的收件箱
```
当我们要查询B的信息流时只需要执行下面这条SQL就可以了
```
select feedId from inbox where userId = &quot;B&quot;;
```
如果你想要提升读取信息流的性能,可以把收件箱的数据存储在缓存里面,每次获取信息流的时候直接从缓存中读取就好了。
## 推模式存在的问题和解决思路
你看,按照这个思路就可以实现一套完整的微博信息流系统,也比较符合我们的常识。但是,这个方案会存在一些问题。
首先,就是消息延迟。在讲系统通知未读数的时候,我们曾经提到过,不能采用遍历全量用户给他们加未读数的方式,原因是遍历一次全量用户的延迟很高,而推模式也有同样的问题。对明星来说,他们的粉丝数庞大,如果在发微博的同时还要将微博写入到上千万人的收件箱中,那么发微博的响应时间会非常慢,用户根本没办法接受。因此,我们一般会使用消息队列来消除写入的峰值,但即使这样,由于写入收件箱的消息实在太多,你还是有可能在几个小时之后才能够看到明星发布的内容,这会非常影响用户的使用体验。
<img src="https://static001.geekbang.org/resource/image/c2/b0/c2e64231a2b6c52082567f8422069cb0.jpg" alt="">
在推模式下,你需要关注的是微博的写入性能,因为用户每发一条微博,都会产生多次的数据库写入。为了尽量减少微博写入的延迟,我们可以从两方面来保障。
- 一方面,在消息处理上,你可以启动多个线程并行地处理微博写入的消息。
- 另一方面,由于消息流在展示时可以使用缓存来提升读取性能,所以我们应该尽量保证数据写入数据库的性能,必要时可以采用写入性能更好的数据库存储引擎。
比如我在网易微博的时候就是采用推模式来实现微博信息流的。当时为了提升数据库的插入性能我们采用了TokuDB作为MySQL的存储引擎这个引擎架构的核心是一个名为分形树的索引结构Fractal Tree Indexes。我们知道数据库在写入的时候会产生对磁盘的随机写入造成磁盘寻道影响数据写入的性能而分形树结构和我们在[11讲](https://time.geekbang.org/column/article/147946)中提到的LSM一样可以将数据的随机写入转换成顺序写入提升写入的性能。另外TokuDB相比于InnoDB来说数据压缩的性能更高经过官方的测试TokuDB可以将存储在InnoDB中的4TB的数据压缩到200G这对于写入数据量很大的业务来说也是一大福音。然而相比于InnoDB来说TokuDB的删除和查询性能都要差一些不过可以使用缓存加速查询性能而微博的删除频率不高因此这对于推模式下的消息流来说影响有限。
其次,存储成本很高。**在这个方案中我们一般会这么来设计表结构:**
先设计一张Feed表这个表主要存储微博的基本信息包括微博ID、创建人的ID、创建时间、微博内容、微博状态删除还是正常等等它使用微博ID做哈希分库分表
另外一张表是用户的发件箱和收件箱表也叫做TimeLine表时间线表主要有三个字段用户ID、微博ID和创建时间。它使用用户的ID做哈希分库分表。
<img src="https://static001.geekbang.org/resource/image/71/6c/71b4b33d966a7e34a62f635a1a23646c.jpg" alt="">
由于推模式需要给每一个用户都维护一份收件箱的数据所以数据的存储量极大你可以想一想谢娜的粉丝目前已经超过1.2亿那么如果采用推模式的话谢娜每发送一条微博就会产生超过1.2亿条的数据,多么可怕!**我们的解决思路是:** 除了选择压缩率更高的存储引擎之外还可以定期地清理数据因为用户更加关注最近几天发布的数据通常不会翻阅很久之前的微博所以你可以定期地清理用户的收件箱比如只保留最近1个月的数据就可以了。
除此之外推模式下我们还通常会遇到扩展性的问题。在微博中有一个分组的功能它的作用是你可以将关注的人分门别类比如你可以把关注的人分为“明星”“技术”“旅游”等类别然后把杨幂放入“明星”分类里将InfoQ放在“技术”类别里。**那么引入了分组之后,会对推模式有什么样的影响呢?** 首先是一个用户不会只有一个收件箱,比如我有一个全局收件箱,还会针对每一个分组再分别创建一个收件箱,而一条微博在发布之后也需要被复制到更多的收件箱中了。
如果杨幂发了一条微博,那么不仅需要插入到我的收件箱中,还需要插入到我的“明星”收件箱中,这样不仅增加了消息分发的压力,同时由于每一个收件箱都需要单独存储,所以存储成本也就更高。
最后,在处理取消关注和删除微博的逻辑时会更加复杂。比如当杨幂删除了一条微博,那么如果要删除她所有粉丝收件箱中的这条微博,会带来额外的分发压力,我们还是尽量不要这么做。
而如果你将一个人取消关注,那么需要从你的收件箱中删除这个人的所有微博,假设他发了非常多的微博,那么即使你之后很久不登录,也需要从你的收件箱中做大量的删除操作,有些得不偿失。**所以你可以采用的策略是:** 在读取自己信息流的时候,判断每一条微博是否被删除以及你是否还关注这条微博的作者,如果没有的话,就不展示这条微博的内容了。使用了这个策略之后,就可以尽量减少对于数据库多余的写操作了。
**那么说了这么多,推模式究竟适合什么样的业务的场景呢?** 在我看来它比较适合于一个用户的粉丝数比较有限的场景比如说微信朋友圈你可以理解为我在微信中增加一个好友是关注了他也被他关注所以好友的上限也就是粉丝的上限朋友圈应该是5000。有限的粉丝数可以保证消息能够尽量快地被推送给所有的粉丝增加的存储成本也比较有限。如果你的业务中粉丝数是有限制的那么在实现以关注关系为基础的信息流时也可以采用推模式来实现。
## 课程小结
以上就是本节课的全部内容了。本节课我带你了解以推模式实现信息流的方案以及这个模式会存在哪些问题和解决思路,这里你需要了解的重点是:
1. 推模式就是在用户发送微博时,主动将微博写入到他的粉丝的收件箱中;
1. 推送信息是否延迟、存储的成本、方案的可扩展性以及针对取消关注和微博删除的特殊处理是推模式的主要问题;
1. 推模式比较适合粉丝数有限的场景。
你可以看到,其实推模式并不适合微博这种动辄就有上千万粉丝的业务,因为这种业务特性带来的超高的推送消息延迟以及存储成本是难以接受的,因此,我们要么会使用基于拉模式的实现,要么会使用基于推拉结合模式的实现。那么这两种方案是如何实现的呢?他们在实现中会存在哪些坑呢?又要如何解决呢?我将在下节课中带你着重了解。
## 一课一思
你是否设计过这种信息流系统呢?如果你来设计的话,要如何解决推模式下的延迟问题呢?欢迎在留言区与我分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,99 @@
<audio id="audio" title="40 | 信息流设计(二):通用信息流系统的拉模式要如何做?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/fd/10d0a5536c70c6f83e7d085c2ec164fd.mp3"></audio>
你好,我是唐扬。
在前一节课中我带你了解了如何用推模式来实现信息流系统从中你应该了解到了推模式存在的问题比如它在面对需要支撑很大粉丝数量的场景时会出现消息推送延迟、存储成本高、方案可扩展性差等问题。虽然我们也会有一些应对的措施比如说选择插入性能更高的数据库存储引擎来提升数据写入速度降低数据推送延迟定期删除冷数据以减小存储成本等等但是由于微博大V用户粉丝量巨大如果我们使用推模式实现信息流系统那么只能缓解这些用户的微博推送延迟问题没有办法彻底解决。
这个时候你可能会问了:那么有没有一种方案可以一劳永逸地解决这个问题呢?当然有了,你不妨试试用拉模式来实现微博信息流系统。那么具体要怎么做呢?
## 如何使用拉模式设计信息流系统
所谓拉模式,就是指用户主动拉取他关注的所有人的微博,将这些微博按照发布时间的倒序进行排序和聚合之后,生成信息流数据的方法。
按照这个思路实现微博信息流系统的时候你会发现:用户的收件箱不再有用,因为信息流数据不再出自收件箱,而是出自发件箱。发件箱里是用户关注的所有人数据的聚合。因此用户在发微博的时候就只需要写入自己的发件箱,而不再需要推送给粉丝的收件箱了,这样在获取信息流的时候,就要查询发件箱的数据了。
这个逻辑我还用SQL的形式直观地表达出来方便你理解。假设用户A关注了用户B、C、D那么当用户B发送一条微博的时候他会执行这样的操作
```
insert into outbox(userId, feedId, create_time) values(&quot;B&quot;, $feedId, $current_time); //写入B的发件箱
```
当用户A想要获取他的信息流的时候就要聚合B、C、D三个用户收件箱的内容了
```
select feedId from outbox where userId in (select userId from follower where fanId = &quot;A&quot;) order by create_time desc
```
**你看,拉模式的实现思想并不复杂,并且相比推模式来说,它有几点明显的优势。**
首先拉模式彻底解决了推送延迟的问题大V发微博的时候不再需要推送到粉丝的收件箱自然就不存在延迟的问题了。
其次存储成本大大降低了。在推模式下谢娜的粉丝有1.2亿那么谢娜发送一条微博就要被复制1.2亿条,写入到存储系统中。在拉模式下只保留了发件箱,微博数据不再需要复制,成本也就随之降低了。
最后功能扩展性更好了。比如微博增加了分组的功能而你想把关注的A和B分成一个单独的组那么A和B发布的微博就形成了一个新的信息流这个信息流要如何实现呢很简单你只需要查询这个分组下所有用户也就是A和B然后查询这些用户的发件箱再把发件箱中的数据按照时间倒序重新排序聚合就好了。
```
List&lt;Long&gt; uids = getFromGroup(groupId); //获取分组下的所有用户
Long&lt;List&lt;Long&gt;&gt; ids = new ArrayList&lt;List&lt;Long&gt;&gt;();
for(Long id : uids) {
ids.add(getOutboxByUid(id)); //获取发件箱的内容id列表
}
return merge(ids); //合并排序所有的id
```
拉模式之所以可以解决推模式下的所有问题,是因为在业务上关注数始终是有上限的,那么它是不是一个无懈可击的方案呢?**当然不是,拉模式也会有一些问题,在我看来主要有这样两点。**
第一点不同于推模式下获取信息流的时候只是简单地查询收件箱中的数据在拉模式下我们需要对多个发件箱的数据做聚合这个查询和聚合的成本比较高。微博的关注上限是2000假如你关注了2000人就要查询这2000人发布的微博信息然后再对查询出来的信息做聚合。
那么如何保证在毫秒级别完成这些信息的查询和聚合呢答案还是缓存。我们可以把用户发布的微博ID放在缓存中不过如果把全部用户的所有微博都缓存起来消耗的硬件成本也是很高的。所以我们需要关注用户浏览信息流的特点看看是否可能对缓存的存储成本做一些优化。
在实际执行中我们对用户的浏览行为做了分析发现97%的用户都是在浏览最近5天之内的微博也就是说用户很少翻看五天之前的微博内容所以我们只缓存了每个用户最近5天发布的微博ID。假设我们部署6个缓存节点来存储这些微博ID在每次聚合时并行从这几个缓存节点中批量查询多个用户的微博ID获取到之后再在应用服务内存中排序后就好了这就是对缓存的6次请求可以保证在5毫秒之内返回结果。
第二缓存节点的带宽成本比较高。你想一下假设微博信息流的访问量是每秒10万次请求也就是说每个缓存节点每秒要被查询10万次。假设一共部署6个缓存节点用户人均关注是90平均来说每个缓存节点要存储15个用户的数据。如果每个人平均每天发布2条微博5天就是发布10条微博15个用户就要存储150个微博ID。每个微博ID要是8个字节150个微博ID大概就是1kB的数据单个缓存节点的带宽就是1kB * 10万 = 100MB基本上跑满了机器网卡带宽了。**那么我们要如何对缓存的带宽做优化呢?**
在[14讲](https://time.geekbang.org/column/article/151949)中我提到部署多个缓存副本提升缓存可用性其实缓存副本也可以分摊带宽的压力。我们知道在部署缓存副本之后请求会先查询副本中的数据只有不命中的请求才会查询主缓存的数据。假如原本缓存带宽是100M我们部署4组缓存副本缓存副本的命中率是60%那么主缓存带宽就降到100M * 40% = 40M而每组缓存副本的带宽为100M / 4 = 25M这样每一组缓存的带宽都降为可接受的范围之内了。
<img src="https://static001.geekbang.org/resource/image/67/3a/679c081c73c30ccc6dafc3f2cae0a13a.jpg" alt="">
在经过了上面的优化之后,基本上完成了基于拉模式信息流系统方案的设计,你在设计自己的信息流系统时可以参考借鉴这个方案。另外,使用缓存副本来抗流量也是一种常见的缓存设计思路,你在项目中必要的时候也可以使用。
## 推拉结合的方案是怎样的
但是,有的同学可能会说:我在系统搭建初期已经基于推模式实现了一套信息流系统,如果把它推倒重新使用拉模式实现的话,系统的改造成本未免太高了。有没有一种基于推模式的折中的方案呢?
其实我在网易微博的时候,网易微博的信息流就是基于推模式来实现的,当用户的粉丝量大量上涨之后,**我们通过对原有系统的改造实现了一套推拉结合的方案,也能够基本解决推模式存在的问题,具体怎么做呢?**
方案的核心在于大V用户在发布微博的时候不再推送到全量用户而是只推送给活跃的用户。这个方案在实现的时候有几个关键的点。
首先我们要如何判断哪些是大V用户呢或者说哪些用户在发送微博时需要推送全量用户哪些用户需要推送活跃用户呢在我看来还是应该以粉丝数作为判断标准比如粉丝数超过50万就算作大V需要只推送活跃用户。
其次,我们要如何标记活跃用户呢?活跃用户可以定义为最近几天内在微博中有过操作的用户,比如说刷新过信息流、发布过微博、转发评论点赞过微博,关注过其他用户等等,一旦有用户有过这些操作,我们就把他标记为活跃的用户。
而对大V来说我们可以存储一个活跃粉丝的列表这个列表里面就是我们标记的活跃用户。当某一个用户从不活跃用户变为活跃用户时我们会查询这个用户的关注者中哪些是大V然后把这个用户写入到这些大V的活跃粉丝列表里面这个活跃粉丝列表是定长的如果活跃粉丝数量超过了长度就把最先加入的粉丝从列表里剔除这样可以保证推送的效率。
最后一个用户被从活跃粉丝列表中剔除或者是他从不活跃变成了活跃后由于他不在大V用户的活跃粉丝列表中所以也就不会收到微博的实时推送因此我们需要异步地把大V用户最近发布的微博插入到他的收件箱中保证他的信息流数据的完整性。
<img src="https://static001.geekbang.org/resource/image/4a/55/4a92721244bd0c696abbbe03dafa5955.jpg" alt="">
采用推拉结合的方式可以一定程度上弥补推模式的缺陷,不过也带来了一些维护的成本,比如说系统需要维护用户的在线状态,还需要多维护一套活跃的粉丝列表数据,在存储上的成本就更高了。
因此这种方式一般适合中等体量的项目当粉丝量级在百万左右活跃粉丝数量在10万级别时一般可以实现比较低的信息传播延迟以及信息流获取延迟但是当你的粉丝数量继续上涨流量不断提升之后无论是活跃粉丝的存储还是推送的延迟都会成为瓶颈所以改成拉模式会更好的支撑业务。
## 课程小结
以上就是本节课的全部内容了。本节课我带你了解了基于拉模式和推拉结合模式实现信息流系统的方案,这里你需要了解的几个重点是:
1. 在拉模式下,我们只需要保存用户的发件箱,用户的信息流是通过聚合关注者发件箱数据来实现的;
1. 拉模式会有比较大的聚合成本,缓存节点也会存在带宽的瓶颈,所以我们可以通过一些权衡策略尽量减少获取数据的大小,以及部署缓存副本的方式来抗并发;
1. 推拉结合的模式核心是只推送活跃的粉丝用户,需要维护用户的在线状态以及活跃粉丝的列表,所以需要增加多余的空间成本来存储,这个你需要来权衡。
拉模式和推拉结合模式比较适合微博这种粉丝量很大的业务场景,因为它们都会有比较可控的消息推送延迟。你可以看到,在这两节课程中我们灵活使用数据库分库分表、缓存消息队列、发号器等技术,实现了基于推模式、拉模式以及推拉结合模式的信息流系统,你在做自己系统的方案设计时,应该充分发挥每种技术的优势,权衡业务自身的特性,最终实现技术和业务上的平衡,也就是既能在业务上满足用户需求,又能在技术上保证系统的高性能和高可用。
## 一课一思
在你的项目中是否有使用过拉模式来实现信息流系统呢?在方案设计过程中都遇到过哪些问题呢?你是如何解决的呢?欢迎在留言区与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。