取法其上,得乎其中

读《高并发系统设计 40 问》缓存篇、消息队列篇及设计篇笔记

写在前

最近可能有些无聊……所以整理了一批小册去读,一边觉得很多内容无需记录,又一边觉得形成笔记才能衍生更多的思考。

这篇有点水,因为其实大部分内容依然都是概述形式的东西,但毕竟当初跟自己立下了必须要形成笔记的要求...hhh

笔记产生于极客时间小册:高并发系统设计 40 问

缓存篇

当数据库成为性能瓶颈后,动态数据的查询如何加速?答:缓存。

什么是缓存?主要指两种硬件存在很大的速度差值的时候,用来协调两者数据传输差异的结构。

比如Linux内存管理为了实现虚拟内存,导致每次都要运算一次虚拟地址到物理地址的转换,于是产生了TBL结构去缓存之前运算过的地址,从而下次就不需要运算了。

再比如说抖音每次打开尽管它的信息载体是比较庞大的视频,但仍然可以做到秒开,以及一个接一个视频的连贯性,它主要的设计思路就是提前预存好几个视频,然后在播放的过程中提前推送接下来的视频。

什么是缓冲区?主要是临时存储数据的区域,用来弥补高速设备和低速设备通信时的速度差。比如写入磁盘后为了性能考虑并不是直接落盘,而是写到一个缓冲区里面,当这个缓冲区的大小达到一定阈值或一定时间后才进行刷盘。从宕机概率的角度来作取舍的激进的提升性能策略。

缓存有哪些不足?

  1. 适合读多写少的业务场景,且最好带有一定的热点熟悉(确保命中的比例高)
  2. 缓存会给整体系统带来复杂度,并且数据有不一致的风险
  3. 缓存的空间不是无限的,相较于硬盘反而成本更高

**基于缓存的使用,形成的不同的读写策略,
而其本质就是“读数据库、写数据库、写缓存、读缓存”都是一种独立的操作,而由于网络的不可靠性会导致系统在并发执行时无法保证各个请求之间的隔离性,然后以各种各样的方式产生了数据不一致问题。**

说白了,既然你都使用缓存了,就不要寄希望于它里面存储的数据是一致性的,另外很多读写策略还专门强调所谓高并发写+高并发读,然后又要一致性的场景,无非是吹毛求疵,闲得慌。

而反过来说,如果将缓存作为主要的信息源,那么性能必然是数据库的很多倍。

缓存如何做到高可用呢?涉及到高可用无非是做多副本,为了性能再用哈希算法做切片,分摊给多个节点。

如何解决缓存穿透问题?一般是指客户端对大量系统不存在的值进行请求,因此这些请求都往往落到了数据库中,缓存无法去做出拦截。一般这种情况要么是前台发生了数据紊乱,要不然就是遭到了攻击。思路有两个:

  1. 对空值也做缓存,保证后续对应的key无法到达数据库,但如果是随机值那就没办法了
  2. 布隆过滤器,将所有key以哈希算法均匀地分布在二进制数列之上

然后还有一种是热点数据的缓存突然失效了,这种一般要考虑在请求数据库前去对key做分类,如果请求的是热点数据的话就起一个线程去读对应的数据,而其他的全部快速失败。比如一瞬间很多相同的请求,那么只允许进入数据库服务一个,其他的立即返回,这样提前拦住就可以了。

消息队列篇

什么是消息队列呢?可以直接了解它是一个堆积消息的等待队列,然后提供消息的收发功能,基于前面所说,也可以理解为它是一个缓冲区。

比如你现在有两个服务,一个是控制器服务A,一个是真实调用数据库的服务B。服务B由于每次都要访问数据库,所以性能远远低于服务A,每秒仅能处理100个请求,而服务A每秒可以处理1000个。那么如果某个时候服务A的性能拉满运转了,然后把这一千个请求直接打到服务B,将给服务B造成严重的影响,将导致大量请求执行失败,严重甚至导致服务B宕机。

因此这个时候就可以引入消息队列了,服务A每次都把请求丢给服务队列,而服务B自己主动的去跟消息队列去拉请求,那么这个处理速度就取决于服务B本身了。

还有一种应用场景是处理非主要业务,从而提升主要业务的响应速度。比如在一个请求里除了主要业务还有:记录日志、发邮件、发短信,各种各样的,那么你完全可以只处理主要业务,然后诸如这些全丢给消息队列,然后让其他的线程或者是服务去处理,这样就可以优化速度了。

再来就是单一职责,你把自己要做的事情做好就行了,直接把其他需要做的事情丢到队列里面,相信你的伙伴(其他服务)!

如何保证消息仅仅被消费一次?幂等性啦。一般情况下如果客户端没有告知消息队列已经消费消息,或者说对应的告知在网络中丢失,那么消息队列不会认为对应的消息是失效的,其他的服务依然可以正常的去拉取这个消息并消费。

所以必须要解决这个问题,那么主要有两种思路:

  1. 在每次业务代码中都要判断一次该消息对应的操作有没有被执行完毕
  2. 在执行操作的时候引入乐观锁的机制,防止多个消费者去处理一个消息

对于前者来说也存在事务性问题,即当执行了业务 ,结果在存储对应消息ID的时候宕机了,那么第二个消费者在数据库中并没有找到对应的消息ID,因此它认为这个消息并没有被消费,所以导致重复消费。而主要的解决思路就是让消费和存储消息ID这两件事形成一个事务,或者是分布式事务。

当然,对于以上主要还是需要根据对应的不同业务场景去采用不同的策略啦。

设计篇

计数

计数有哪些特点?

  1. 数据量庞大,每条信息(评论、推文)都要维持:点赞数、评论数,等
  2. 访问量大,且有性能要求
  3. 数字的准确性也要高

起初可以直接在MySQL中以消息ID为主键去存储对应的数值,随着数量达到百万级别的时候要考虑分库分表进行分散请求。(原文中说上千万级别的时候要考虑,但后面表面在那个时候信息流中并不会显示这些数据,所以我下降了一个数量级别)

其后,当数据量又庞大了、性能要求更大了(原本只是点开一条推文的时候显示,后来在主页的feed流中也要显示),所以要考虑基于Redis去加速读请求,但后面会导致一致性存在一些问题,

所以后续全部基于Redis进行存储。而这个时候可以基于消息队列基于削峰填谷思路去提升客户端操作的性能,然后消费者在拉取消息的时候可以合并相同的操作,比如三个用户同时给一条消息点赞一次,那么可以合并成点赞三次。

接下来就要考虑Redis内存成本的问题了。作者主要是对Redis原生的数据结构做了一些改造,

  1. Redis原生使用String存储Key需要28个字节,改造成long类型只需要8个字节
  2. 去除多余的指针
  3. 设置了一个足够庞大的数组,然后通过哈希值将消息ID映射到对应存储数值位置获得对应的数字,而非KV

最后,考虑到冷热数据之分,那么Redis只存储近期的数据,而比较老的数据直接dump到SSD中进行存储,当需要冷数据的时候再单独起一个线程去读对应的信息。当某个用户被访问主页,可以直接把他几乎所有的冷数据拿出来嘛。

未读数

比如说界面有一个通知标签,如果存在系统通知就加个红点,那么如何去做呢?

比如很简单的把这个标志位设置到一个用户信息表之中,那么用户体量小的时候确实没什么问题,但作者所在的新浪要考虑上亿用户hhh。那么操作一次给用户标志有未读信息的时间假设需要一号秒,那么乘以一亿也是27个小时了,哪怕用上多线程杂七杂八的也存在很严重的延迟问题,并且如果要优化的话也没有太多更好的方法,只能是对通知的用户做下筛选,比如长时间不登录的就不标记了。

所以他们的设计思路是,首先信息通知是一个呈线性的列表,且对所有用户共享,那么每个用户记录一下自己上次浏览的位置,再获取系统内部最新的位置,不就可以直接计算出要不要通知了。

那么如果要精确的获取有多少未读数呢?像前面那个思路,如果想要精确的数值还是需要很庞大的计算的...

那么就有了这样的设计思路:

  1. 依照计数器的设计思路去存放所有用户的博文数量
  2. 缓存用户目前已知的所关注用户的博文数量到Redis,当客户端轮训检查未读数的时候去比较当前用户缓存的博文数量去和计数器中的数量去比较,计算出未读数

由于以上两种方式都是全内存操作,其实效率是很高的。但仍然有缺陷,比如当用户对关注关系变动的时候,那么可能会导致未读数不那么准确,比如刚取关了某个人,这个还没有在缓存中变更,然后他连续发了好几条博文,那么用户这边就会提示有未读,但打开之后却没有发现。

以上大概是原作者的思路,然后我就想到能不能干脆把已关注用户的博文数量存储在本地呢?然后我就想到,首先轮训检查肯定是频繁的,那么如果这个关系存储在本地的话,则每次请求都要携带这些数据,一个人还可能关注了几百号人,那么不仅导致发送过去增添很多时间,服务器那边解析这个数据又需要很多时间。如果同时几百万人在线,然后他们都关注了几百人,光处理这个就给服务器增加了非常大的压力。

所以关系存储在服务器也是最好的方法了。

信息流

指每个用户在打开首页的时候会形成一个属于他的关注关系对应的一个信息流,以发送时间作为前后顺序。

最主要的特点是:关注对象发送微博后需要迅速的推送给粉丝,即时效性,同时还要兼顾如何降低存储成本。

按照常见的想法,可以想到两种方式去形成这样一个信息流:

  1. 所有用户都将信息集中在一个列表,然后每个用户根据自己的关系去获取
  2. 每个用户形成独立的信息流,其他用户发送微博后将信息推送过来

首先对于前者来说,当维护一个小型的系统的时候自然可以很轻松的做到,但如果系统本身是一个体量比较大的,比如做了分库分表呢,将所有的信息集中在一个列表几乎是不可能做到的事情。我想到了根据时间去容纳所有的微博,但不可避免的每个用户想到获取自己的信息流都要在多个数据库中进行检索,然后筛选,这样将导致产生了很高的复杂度和时间消耗。

而如果采用后者,其代价是一个用户发起的一条微博将出现在他所有粉丝的信息流之中,比如某个大V有一亿粉丝,那么他每发一条就意味着存储一亿份,并且因为要插入到用户信息流里将消耗大量的时间,又不满足时效性。但是呢,如果是粉丝体量比较小的,或者类似于朋友圈这样的场景,这样是一个很好的方法。

还可以做的优化思路就是提升数据写入的性能,即使用能够将随机写转换为顺序写的存储引擎,且压缩性能更高,如MySQL的TokuDB,其采用了类似LSM的索引结构,能够将InnoDB中的4TB压缩到200G,而缺点是删除和查询性能不好,但对于信息流这样的场景,查询可以依赖于缓存加速性能,而删除一般情况整体性次数不多。

如何设计信息流表?
一般是设计字段为:用户ID、微博ID、创建时间,其中微博ID与微博表中的对应
然后设置微博表:微博ID、创建人ID、内容、创建时间

但这个,比如针对于粉丝体量大的用户,可以以主动去拉的方法去获取大V的微博,然后再跟当前信息流进行排序。仔细想想,这样其一是可以减轻存储压力,其二是根据配套的配套的缓存机制也能具备更好的时效性。

以上其实是比较主流的两种信息流设计的结合,而这两种思路即:

  1. 推模式,信息产生者主动将信息推送给关注用户
  2. 拉模式,信息接受者主动拉取信息
读《高并发系统设计 40 问》缓存篇、消息队列篇及设计篇笔记

https://ku-m.cn/index.php/archives/801/

作者

KuM

发布时间

2023-12-12

许可协议

CC BY 4.0

添加新评论