跳转至

系统设计范式

前面提到的是在编写一个服务器程序时的微观的范式,而一个真正的后台系统通常还涉及更多的宏观系统设计范式。

分而治之

在数据量比较大,超过单机承载量的时候,通常会采用分片(Sharding)的方法来将数据划分成更小的互不相交的子集方便处理。通常来说,分片是通过某一个 Key 来作为切分的依据,并通过范围(Range)或者哈希(Hash)进行分片。

例如,用户量太大的时候,可以根据用户的 ID,来对用户数据进行分片。举个例子,如果采用范围分片,我们可以指定 ID 是 1~10000 的用户分配到机器 A,10001~20000 的用户分配到机器 B 等。如果采用哈希分片,可以将用户的 ID 哈希后取模,分配到指定机器上。范围分片可以进行高效的范围扫描,而哈希分片进行范围扫描的效率就差一些,需要到每台机器上去查询。哈希分片则有助于打散数据,避免数据热点集中在某台机器上。

在根据 Key 查询数据的时候,需要先计算出 Key 所在的分片,如果是静态分片,那么直接将上面提到的规则写到代码里即可。

当然,静态的分片并不实用,因为实际上我们的数据会不断的增删,而集群机器也可能发生变化,静态分片很难适应动态的变化。所以,分片也可以是动态的,假如我们往集群中新增了一台机器,那么我们当然希望一部分的数据可以迁移到这台新机器上,进一步均衡负载。而当某一台机器需要下线的时候,则需要将分片数据均匀地迁移回其他机器上。如果机器没有发生变动,但是随着数据的增删,某个机器上的分片数据量过多或者过少,同样需要进行平衡。

分片数据的动态迁移是一个比较复杂的问题,难点在于如何保证迁移过程中的数据可用以及一致性。

动态分片通常需要一个查询表来负责查询 Key 到分片的功能。例如,范围分片的话,需要保存以下信息:

struct RangeInfo {
    int start;
    int end;
    int serverID;
};

vector<RangeInfo> ranges;

分片信息一般是有序存储的,这样可以使用二分查找快速定位,也可以使用搜索树来保存数据。

避免单点故障

主备模式

主备模式通常是由多台机器同时运行服务进程,但同一时刻最多只有一个进程正常对外服务,其他进程作为备份进程,只同步主进程的状态,而不响应请求。或者,从节点只响应只读请求而不能对数据修改,也就是单写多读。通常这种模式用于重要的少量的元数据的存储,例如上面提到的分片信息,就可以使用主备模式来保存。使用了 raft 的 etcd 便可以看作是主备模式的一种。

对等模式

对等模式也是由多台机器同时运行服务进程,而且每一个进程的功能都是相同的,都可以对外服务,互相之间会同步数据。这种模式既能保证高可用,又可以实现负载均衡,不像主备模式中主节点承担了所有功能导致负载集中。而且,一般对等模式中各个节点功能相同,可读可写。但对等模式常常难以设计,开发复杂度相比主备模式会高很多。主要问题来自于多写造成的数据冲突问题,这种系统通常也会提供冲突检测功能,并返回错误给客户端自行处理。例如 riak 数据库。

计算存储分离

在实际场景中,有时我们对计算的需求会大于对存储的需求,例如,一个购物网站首页,可能会从数据库里拉取一批商品信息,然后根据用户的喜好排序。如果我们将存储和计算耦合起来的话,那么就会出现由于计算需求量比较大,增加了比较多的节点,但是由于存储和计算耦合,这些节点还要消耗额外的存储空间。另外如果计算过程崩溃,那么也可能导致存储一并遭殃。

当然,最主流的做法还是将计算和存储分离。比如说,我们通过网络去连接 MySQL 等数据库,然后执行 SQL 语句把数据取回来后再做业务逻辑计算,这就可以算是一种计算存储分离。而一些分布式的数据库,例如 TiDB、YugabyteDB,就采用了计算与存储相分离的结构。这样的好处在于计算可以单独扩容,也一定程度上隔离了计算可能带来的 bug。

计算存储耦合

由于计算存储分离之后数据的存取需要通过网络进行,而常见的以太网络所能提供的带宽有限,而且一旦计算服务器与存储服务器物理距离变大,那么数据存取的延迟也会上升。为了节省带宽以及减少延迟,可以采取将计算程序尽可能靠近存储程序的策略。一个很常见的例子就是,我们在使用 SQL 数据库的时候,既可以使用简单的 SQL 语句将大量数据发送到应用程序再进行计算,也可以编写一些复杂的 SQL 甚至是存储过程,将计算交给数据库本身去做。

一些分布式数据库例如 TiDB,尽管采取了计算节点与存储节点分离的设计,但 TiKV 本身仍有 Coprocessor 功能,也可以承担一部分的计算任务,降低网络占用以及网络延迟。

当然,当计算量上升的时候,就会对存储节点本身造成计算压力,而存储节点扩容则没有那么容易,由此可能造成存储服务宕机等严重后果。而存储服务扩容时,还会占用一定计算资源对数据进行复制和平衡,搞不好更加剧了情况的恶化。因此,计算与存储分离与耦合的界限并不是那么清晰,需要按照应用的特点,去进行全方面的权衡,平衡好计算节点和存储节点的压力,必要时做好限流以保护服务。

存储分级

在计算机组成原理中,我们都学过 CPU Cache、主存、硬盘等分级存储结构,它们充分利用了访存的时空局部性。在实际的系统中,宏观层面来讲,同样存在这样的时空局部性,例如,一个正在逛购物网站的用户,短时间内它的用户信息肯定会被反复存取,而他购物车里的商品也会很可能被反复访问。根据局部性原理,我们也可以在系统中设计类似的分级存储结构,提高系统的响应速度。

缓存

缓存的思想和分级存储的思想完全一致,一般来说,远程访问一个分布式的关系型数据库,可能会有比较大的延迟,但是数据库存储了最全的信息。如果我们觉得一条查询结果短时间内会反复被访问,那么我们就可以将它保存在服务端的内存中,或者使用内存数据库,加快响应速度。当然,内存的容量不如分布式数据库,所以也需要指定一个缓存淘汰策略,例如 LRU 等。

另外,如果知道某些信息可能会被查询到,我们也可以预先拉取这些信息到缓存中,比如,我们可以先把购物车里的商品信息先从数据库读出来,放到缓存中。

和 CPU Cache 等一样,系统中的缓存同样会面临一致性的问题。比如,用户修改了昵称之后,要如何更新缓存和数据库?有几个简单的方法:

  1. 先更新一次缓存,再将结果写到数据库里。这样的问题在于如果缓存更新了,但是数据库没更新,那么这次修改实际上没成功,但却造成了更新成功的假象。
  2. 先将结果写到数据库,再更新一次缓存。这样的问题在于如果数据库更新了,但是缓存没有更新,那么用户会以为这次修改没成功。
  3. 先淘汰缓存,再写数据库。这样的好处在于,即使数据库写失败了,用户看到的还是旧数据。问题在于,如果在缓存读取旧数据之后,才更新了数据库,就会造成不一致。
  4. 先写数据库,再淘汰缓存。这样和 2 一样,如果数据库更新了,但是缓存由于某些原因没能淘汰成功,也会有不一致。

还有更多更复杂的办法,例如,让数据库提供 binlog 等修改流,通过监听数据库数据变化事件,来更新缓存。

冷热分离

冷热分离和分片的想法类似,都是按照某种方法来将数据划分成子集。不过,数据分片,不同分片之间存储的机器配置通常是一样的。而冷热分离,则相当于给某些分片打上了不同属性,不同属性的数据根据特点存放到不同配置的机器上。例如,我们刷朋友圈,看到的几乎都是几天以内的照片,最好是马上就能加载出来,这部分照片占的容量比较小;几天以外的照片则很少被访问到,而且对访问速度要求也不高,但是它们加起来的占用空间非常大。提供高速访问的 SSD 容量小、价格高,而便宜量大的 HDD 访问速度则比较慢。根据这种特点,我们就可以将比较“旧”的照片存放到 HDD 存储,而将最近的照片存放到 SSD。同时,比较“旧”的照片可以使用压缩算法,用解压时间来换取更少的空间占用。

限流

一些恶意的攻击者可能会在短时间内大量发送请求,造成系统来不及处理这些请求而死机的后果。又或者是所有请求都是正常请求,但是应用太受欢迎了,很多人发送了请求,系统同样会来不及处理请求而死机。在系统内部,也可能因为某些服务出现了 bug 等,不小心向其他内部服务发送了过量的请求。种种情况下,为了避免计算资源耗尽,需要对系统进行限流。

限流的思想就是用较少的计算资源先判断一次系统能不能、应不应该处理一个请求,如果不行,就直接丢弃或者返回一个错误,保护更多的计算资源。限流除了在服务端做,还可以在请求发送方做,例如抢红包等应用,可以在 app 里直接显示动画说没抢到,连真正的请求都不会有,又或者是让客户端收到限流错误时,降低发送请求的频率。另外,还可以请一个“交警”,获取全局的流量信息,并直接发送指令到客户端限流。

一般来说,我们不会让系统满载,一方面后台运行的管理服务需要一定的计算资源来对系统进行管控,同时,根据排队理论,假如请求平均到达速率接近系统处理请求极限速率,那么期望的排队长度将达到无限长,也就是过载了。


最后更新: 2021-10-29 00:20:32
本页作者: Howard Lau