取法其上,得乎其中

读《高并发系统设计 40 问》通识篇与数据库篇笔记

写在前

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

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

基础篇

为什么要学习高并发系统设计?

  1. 除了基础外的进阶方向
  2. 能够应对突发事件
  3. 对于未来给自己更多的可能性

实际上以我目前的角度去看,国内的非互联网公司普遍并不需要此类设计,就算在某个时间段会遇到突发流量,只要它不总是这样,那么从成本考虑也不会选择推到已有设计而选择复杂的高并发设计。比如之前一次面试,一位面试官问我:你有没有想过对于你的协同文档系统来说单独再添加一环消息队列中间件是否“太重”了?

但话又说过来,身为程序员某种程度上总要认真的审视自己的设计是否存在问题,也算是自己的职责,能否落地倒不是自己应该管的事情了。

总之,无非是有更多的可能性罢了,这类问题我认为可以推广到“我买菜可不需要求导和求积分”。

通用设计方法

从硬件的角度来讲一共有两种设计方法:

  • Scale-up 垂直扩展,通过购买性能更高的硬件来提升并发处理能力
  • Scale-out 横向扩展,通过将多台机器组成分布式集群共同抵御流量冲击

垂直扩展有哪些问题?

  1. 单点问题,如果崩了可不就整体宕机咯
  2. 绝对的性能瓶颈,一台主机肯定有自己的性能极限

横向扩展有哪些问题?

  1. 状态同步问题
  2. 事务问题
  3. 节点上下线

如何选择呢?实际上在项目的起始阶段,按道理理应选择单机方式进行开发,而对于性能的瓶颈以垂直扩展就行了,这样的设计足够简单,也方便于定位各种问题以实现业务完整落地的目的。当系统进入性能瓶颈再进行横向扩展。

所以某种程度上来讲,横向扩展即使因为分布式引入的诸多问题,但是没有任何办法,是必须要解决的。

以上是从硬件的角度去讲,那么还有一些从项目本身的角度:

  1. 缓存,以空间换时间
  2. 异步,以时间换空间

其实无非是两个很经典的思路,一个是以空间换时间,即内存读速度往往高于硬盘速度快几十上百倍,那么你把经常用到的数据丢到内存里面存,可不就提升性能了?
其次,是以时间换空间,你把对应的请求存起来,告诉请求方:我待会处理完告诉你,然后可不就能以当前机器的性能去慢慢解决这些超出瓶颈的流量了,不过导致用户需要慢慢等待。

架构分层

什么是架构分层?本质来讲就是对系统本身去做分层设计,比如我们常用的开发模式:MVC,有Modle、View、Controller三个层级组成,又有七层计算机网络模型和四层TCP/IP模型。

有什么好处?

  1. 抽象出各层级同时开发
  2. 将问题分而治之,化大为小
  3. 更好的复用
  4. 更容易做横向扩展

前三者不言自明,第四最为重要。比如在一个项目中,一些设计到复杂计算的业务代码单独抽离出来作为一层,那么当服务器CPU达到瓶颈的时候可以专门的为它进行单独进行横向扩展。

从这里可以想到两个软件设计原则:

  • 单一职责原则,规定每个类只有单一的功能,在分层设计的体现就是每一层级拥有单一的职责,且层与层之间边界清晰。
  • 迪米特法则,每个对象尽可能少的了解其他对象,在分层设计的体现就是每一层只允许与上下两层进行交互,不允许跨层交互。

系统设计目标

系统性能

系统性能的真正体现是什么?请求从发起到响应的时间。

提升系统性能有哪些原则?

  1. 以问题为导向,盲目提早优化会增加系统的复杂度,浪费时间
  2. 性能优化要有数据支撑,在优化过程中要时刻了解你的优化让响应时间减少了多少,提升了多少吞吐量
  3. 性能优化的过程是持续的,需要设定目标,然后不断地寻找性能瓶颈,制定优化方案,直到达到目标

一般来说性能的度量指标是系统接口的响应时间,但单次的响应时间是没有意义的,需要知道一段时间的性能情况是什么样的,所以需要去收集一个时间段的响应时间,然后再根据一些统计方法进行量化。

  • 平均值,一个时间周期内响应时间的平均值。
  • 最大值,顾名思义。
  • 分位值,将一个时间段的请求以请求时间为顺序,假设存在100个请求,那么取第90个作为反映这个时间段的响应时间,称为90分值,那么这样就可以过滤掉偶发的慢请求,分值越高代表对慢请求越敏感。

首先,平均值当遇到大批量请求的时候会因为请求的数量足够多,而抹平部分超时请求产生的影响。如在三十秒内有一万次请求,而其中一百个请求的时间为100ms,而其他请求为1ms。那么落到最终的平均值不过是2ms。但那一百个请求的时间增长了100倍,是否可能存在严重的问题?

而最大值很显然不能满足要求。最后比较适合的就是采用分位值,再求分位值的平均值是一个不错的选择。

那么响应时间应该是控制多长呢?

  • 200ms,用户无感知
  • 1s,用户感觉到延迟
  • 1s,有明显等待的感觉

所以,健康系统的99分值的响应时间要控制在200ms内,不超过1s的请求占比要在99%以上。

如何优化呢?

  1. 提高系统的处理核心
  2. 分清业务类型,是IO密集还是CPU密集,针对性解决问题。

高可用

高可用,系统具备较高的无故障运行时间,即系统出现故障的概率非常低,但这里所说的系统一般对应的是一个集群。即单点系统是几乎无法保证高可用性的,只能通过集群的方式让外界看起来系统仍然在运转。

一共有两个衡量高可用性的指标:

  • MTBF(Mean Time Between Failure)是平均故障间隔的意思
  • MTTR(Mean Time To Repair)表示故障的平均恢复时间

可用性 = MTBF / (MTBF + MTTR)

这个公式计算出来是一个比例,一般用这个来代表系统的可用性,经常用几个九来描述对应的级别:

  • 90% 一个九,年故障36.5d,日故障2.4h
  • ……
  • 99.9% 三个九,8h,7.44m
  • 99.99% 四个九,52m,8.6s
  • 99.999% 五个九,5m,0.86s

一般来说,核心业务的可用性要达到四个九,非核心的可用性最多容忍三个九。而想要到达四个九,基本上在这个级别的可用性下必须要建立完善的运维值班体系,而五个九之后故障就要依赖于系统的容灾、自动恢复能力。

那么从系统设计的角度上有哪些方法可以做到高可用性呢?

  1. 故障转移,当A服务调用B服务时发生错误,A服务可以采用重试,再不行就更换B服务的其他节点
  2. 超时控制,根据以往的日志计算出合理的超时阈值,如果超出阈值则代表对应服务可能存在一些问题,为了防止该服务拖累其他服务造成雪崩效应,应该即使进行服务降级
  3. 限流,按照服务的瓶颈进行控制流量的流入,保证服务一直处于安全状态

而从系统运维的角度来说有:

其一是灰度发布,指一次上线并非同时发布所有版本,而是混合着之前稳定的版本,使两个版本各占一定比例共存,根据监控、日志判断进行动态调整比例,最后形成新版本完全回退和新版本替换所有老版本两个状态。

其二是故障演练,指随机对系统进行一些破坏性的手段,观察出现局部故障后系统的表现是怎样的,从而发现系统中是否存在潜在问题。一个高并发的系统必然是复杂的,所以根据这种方式去测试,当出现故障后是否会如蝴蝶效应一样造成整体不可用。

数据库篇

池化技术

因为数据库每次发起连接都要消耗一定的开销,如果将数据库连接一直持久的存储起来以备使用,那么自然可以提升性能。其实还是以空间换时间的思路,线程池也是这样。
一般情况下最重要的参数有两个:

  1. 最小连接数,如果达不到则新建
  2. 最大连接数,如果超出则响应失败(或丢入队列排队)

主从分离

主从分离一般指的是对流量整体去做切片,将不同类型的流量对应不同的数据库。比如,一般情况下我们的业务系统往往是读多余写,那么我们可以单独的将读和写的请求分散到不同的数据库,进行针对性的优化。亦或者根据请求的核心与否去分担到不同的数据库。

而后续所讲的大多是围绕着「读写分离」去讲述。

一般来说主从读写分离的机制下,会有一个主库,一个及多个从库。一般情况下负责写的库称为主库,每当发起写操作之后会将数据异步的传递给从库,这样就可以保证当读请求达到从库的时候能够检索到主库的数据变更。那么就存在问题咯,因为从库的更新过程是异步的(如果是同步的,岂不是要慢死了),而在这个过程中肯定存在某个时间段中,从库并没有更新主库的数据,那么用户通过从库去读的时候,自然也就读不到刚才生效的信息。话说过来,在以前很多平台大多都能感知到这一点,修改之后需要刷新好些次才能发现数据成功变更了。

那么为了实现这样的功能,拆分问题后会得到两个子问题:

  1. 主从数据库的拷贝
  2. 客户端如何无感的发起请求(像单一节点一样),总不能每次手动的修改数据源吧?

那么我们以MySQL为例。MySQL的主从复制主要依赖于binlog,即记录所有变化并存储为二进制的日志文件。

像MySQL的主从同步的过程如下:

  1. 从库连接到主节点时会创建一个IO线程用来接收主库传递来的binlog

    1. 将接受的binlog写入到relay log之中
    2. 创建一个SQL线程读取relay log并在从库中进行回放
  2. 主库创建 log dump线程将更新的binlog传递给各个从库

如上,因为基于性能的考虑,复制阶段往往是异步进行的,那么如果在极端情况下主库的binlog还没来得及落盘,然后就宕机了或者磁盘损坏,这样导致从库有而主库无,从而造成了主从数据不一致,如此有没有问题?答案是有,但作者讲述:这种情况出现的概率很低,对于互联网的项目来说是可以容忍的。

当然肯定是有解决办法的,从节点选举主节点,而主节点恢复后变为从节点进行学习就是了。不过我目前还没有详细的了解过MySQL集群。。

那么,当业务承受大量流量的时候,只需要增加从节点就能抵御,有没有极限呢?极限取决于哪里?一般来说一个主库最多有3~5个从库,极限在于主库将更新产生的binlog传递给各个从节点也是有很大的性能消耗的,不过解决办法可以是主-从-从嘛,我记的Redis中可以这样用,不过这将导致最后一个从的数据延迟将翻倍,用户更容易读取到老数据。

第二个子问题,客户端如何访问集群呢。

一般可以采用市面上的中间件作为中间的代理层,根据某些规则将请求自动转发到不同的数据库之中,主要有两个区别,一是淘宝的TDDL为代表的在系统内部以代码形式内嵌的代理,其二是如MyCat、Atlas、DBProxy这些需要单独部署的代理方案。

前者会导致代码的复杂度变高,而后者的好处是代码在操作的时候跟单节点一样,由代理去进行改写SQL,但缺点是每一个请求都要走一次中间层,会增加一些时间。

分库分表

当系统的不断发展,各个表的数据量到达了千万甚至亿级别,那么即使使用了索引,索引所占的空间也不能完整的在内存中展示,还是需要频繁的进行IO操作读取索引数据,那么就将产生很严重的性能问题。

除此之外,单库的数据量过于庞大也会导致数据库在备份和恢复的时间变长。同样的,亦可以把一个库拆开去分别承担压力以抵御单点故障和单点性能瓶颈。

为了解决这些问题,往往都需要去考虑分库分表,即:

  1. 将单独的库拆分成多个库
  2. 将一张表拆分成多个表

不同于主从复制时是将数据全量地拷贝到多个节点,分库分表后每个节点只保存部分数据,这样就可以有效地减少单个数据库节点和单个表中的数据量。

常见的分库分表有两种思路:

  1. 垂直拆分,将数据库中的表按照业务类型进行迁移到不同的数据库之中
  2. 水平切分,将数据表按照某种规则拆分到多个数据库和多个数据表中

垂直拆分比较好理解,仅是迁移对应的表,但某一业务出现数据库激增的时候仍然不能解决问题,因此还要再看一下如何去进行水平切分。

假设现有16个用户库和64张用户信息表,按照以下步骤:

  1. 根据用户ID进行均匀地哈希运算,分配到对应的用户库,举例:hash(ID) % 16
  2. 根据用户ID进行均匀地哈希运算,分配到对应的用户表,举例:hash(ID) % 64

这样就可以将用户的信息分配给不同的数据库和数据表之中....总的来说主要是分而治之的思路。

还有一种常用的方式是根据某一个字段的区间进行拆分,比如根据创建的时间,每个月份产生一张表,然后每个月份单独存储,具体精度以业务要求和数据量去定。

会有什么问题?

首先,由于每条信息都需要根据用户ID进行映射(称为分区键),那么后续的操作都需要带上它才行,那么如果我想根据昵称去查某个用户?难不成要查询16 * 64嘛。对此的解决办法一般情况下是去建立一张“昵称和用户ID”的映射表,每次查询前先根据昵称去检索用户ID。

不过这样会导致涉及到类似的场景时,总要多一次查询,但这也是难免的~ 我认为,或许可以考虑第一次把这个ID传给客户端,然后客户端每次请求都带上这个ID嘛。

另外还有一个问题是,分库分表后将导致无法进行跨表执行SQL了,而这样的解决方案只能是把对应两个表的数据都读取到客户端做筛选。另外还有数据聚合的问题,比如我想知道这个用户信息的总数究竟有多少,总不能去遍历所有的表吧,这也不显示,而对应的解决方案就是创建一个表专门去记录这些聚合信息。

所有的解决方案获得了一些什么,总是要失去些什么,具体还是要根据对应的业务兴致而去决定。

分库分表的一些原则:

  1. 如果没有性能瓶颈那么尽量不做分库分表
  2. 如果要做就一次到位,拆分后的数量能够满足几年后的预计数据量
  3. 很多NoSQL数据库提供自动拆分的特性,有的业务可以考虑使用这些组件

NoSQL

为什么要提NoSQL呢?主要是它的出现解决了很多关系型数据库的痛点,

  1. 相对于传统数据库的性能要更好
  2. 数据表的结构变更很方便
  3. 天生具备分布式的能力,能够承载更庞大的数据量

好似银弹,但目前来讲还是主要作为辅助使用,是传统数据库的某些场景下的补充。

如何提升写入性能?

最常见的主要通过LSM树数据结构(Hbase、Cassandra、LevelDB),而其主要特点是牺牲了一定的读性能来换取写入数据的高性能。

首先,当写入数据的时候会写入到一个叫做MemTable的内存结构中,并根据Key进行排序,其后当累积到一定规模就生成一个叫做SSTable(Sorted String Table)的文件,当SSTbale达到一定数量就进行合并(或者定期执行)。而SSTable中的数据如其名,都是有序的,合并速度自然也会很快。

而读取的时候首先从MemTable,如果没有,再从SSTable中进行读取。

当了解这个过程就可以明显的感受到,其写入性能的高效主要来源于直接存起来就行了,类似于写入日志,而Mysql的B+Tree则在写入的时候可能导致页分裂一系列的问题,但其SSTable在读取的时候只能保证单个文件内Key的有序性而不能保证多个SSTable的有序性,因此读取性能肯定比不上B+Tree。

因此,对于此类结构的系统,往往适用于写入密集型系统,比如日志记录、大规模数据采集。

有哪些扩展?

  • 基于倒排索引的Elasticsearch
  • 原生支持分布式、大数据存储的场景,如MongoDB可提供副本、自动选举、切片、负载均衡

说白了,当遇到数据量庞大的业务的时候,可以优先思考NoSQL是否能够解决自己的问题,而不要傻傻的上来就开始分库分表。

读《高并发系统设计 40 问》通识篇与数据库篇笔记

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

作者

KuM

发布时间

2023-12-07

许可协议

CC BY 4.0

添加新评论