万字长文漫谈高并发技术

互联网应用通常面向海量用户,其后台系统必须支撑高并发请求。在后端开发面试中,高并发技术也是一个常见的考察点。那么,高并发系统通常是怎么设计的呢?需要采用哪些技术呢?本文就简单聊一聊高并发背后的各种技术栈。

必须明确高并发本身是目的,而不是某一项技术;只要能够提高连接数或系统处理吞吐的技术都算。因此,这是一个涉及面非常广的话题,本文无法事无巨细地展开。

我会重点罗列实现高并发的常见技术栈,并简单介绍每种技术的工作原理和应用场景,帮大家快速建立整体的知识脉络。虽然每种技术的讲解篇幅有限,但我会列出更详细的学习资料,方便查阅学习。

大家可以以此知识地图为指引,制定学习计划,查漏补缺。

并发与并行

通常大家对并发的理解就是同时处理多个任务,虽说没有错,但不够具体。因此,开始之前,我们有必要更准确地理解:什么是 并发concurrentcy )?并发跟 并行parallel )有何不同?

以文件下载场景为例,假设你需要下载 10 个文件,服务器响应时间是 1 秒,那么串行下载需要耗时 10 秒。文件下载这种 IO密集型应用 ,绝大部分时间都消耗在等待上。如果我们同时建立 10 个连接发出请求,再逐个等待服务器响应,那么最终可能只需消耗 1 秒左右。

这就是一个典型的并发场景,最显著的特征是 同时发起 多个任务。

再来看另一个场景,假设你有一台单核的电脑,正在压缩一个很大的文件。压缩程序需要消耗很长时间,期间你要处理邮件,编辑文档。操作系统提供分时机制,压缩程序时间片用完就调度其他程序到 CPU 上执行。

这也是一个并发的场景,用户同时发起多个任务进程,它们轮流使用 CPU 。这样用户不用等待压缩程序执行完毕就能同时收发邮件,操作体验好很多。

如果你同时压缩两个大文件,假设每个都要消耗 2 分钟,最终还是要至少消耗 4 分钟。因为 CPU 是单核的,就算两个压缩程序都在运行,但同一时间只有一个能在 CPU 上执行。因此,像这种 计算密集型应用 ,并发并不能缩短执行时间。

这时您可能会想到多核 CPU ——没错!多核 CPU 每个核心可以近似看成一个完整的 CPU ,能够独立执行程序。这样一来,两个压缩程序可以分布在不同核心上,同时执行,整体耗时将减半!

这个场景就是所谓的 并行parallel ),一种比并发更进一步的形态,最显著的特征是 同时执行

  • 并发concurrency ),同时发起,但不一定同时执行;
  • 并行parallel ),同时执行;

应用程序

互联网应用系统想要支持高并发,势必要具备同时处理大量 TCP 连接的能力。应用进程通过操作系统提供的套接字进行 TCP 通信,如果采用经典同步阻塞模式,一个程序同一时间只能处理一个套接字连接,何谈高并发?

在同步阻塞 IO 编程模型中,套接字默认是阻塞的。程序读写套接字时,recvsend 等系统调用会一直等待直到对方数据到达,或者本方数据发出。在阻塞等待期间,程序无法执行其他任务。

那么,如何实现在同一个程序中同时处理多个 TCP 连接呢?

多进程

在远古时代,我们通常利用多进程技术来处理并发 TCP 连接:

  • 主进程负责监听端口,收到新连接后 fork 子进程进行处理;
  • 新连接套接字被子进程继承,子进程为新连接提供服务,处理完毕后便退出;

这个方案在同步阻塞 IO 模式下采用多进程实现并发处理能力,胜在开发简单,但缺点也很明显:

  • 每个连接占用一个进程,资源开销将严重制约程序的最大并发数;
  • 多个进程轮流竞争 CPU 执行权,上下文切换开销大;

多线程

由于进程的资源开销相对较大,操作系统提供了一种更轻量的进程——线程。一个进程可以创建多个执行线程,它们共享进程内存等资源,因此开销相对较小。编程模式上跟前面提到的经典多进程方案类似:

  • 主线程负责监听端口,收到新连接后创建一个子线程进行处理;
  • 子线程启动后为新连接提供服务,服务完毕后则退出;

相比多进程,多线程方案有不少优点:

  • 所有线程共用进程中的内存资源,内存开销相对较少;
  • 线程切换成本比进程小,因为内存是共享的,页表不用切换,TLB 缓存也不用刷;

现代操作系统进程采用虚拟地址来访问内存,由 CPU 负责将虚拟地址转换成物理地址。虚拟地址和物理地址的映射关系由 页表page table )维护。CPU 根据页表做地址转换,并采用 TLB 缓存提升转换速度。

CPU 发生进程切换,需要先加载新进程的页表,并刷新 TLB 缓存。这是一个开销相对较大的操作,会制约系统的最终性能。因此,应用工作进程通常不能超过 CPU 核数,有些场景甚至会将主要进程跟 CPU 核心一一绑定,以避免进程切换开销。

那么,线程是不是就没有开销了呢?当然不是啦!

  • 线程有独立的执行栈,线程数一多也要消耗很多内存;
  • 线程切换要保存旧进程 CPU 寄存器(执行状态),然后加载新线程的寄存器,由此导致的 CPU 缓存和指令流水线失效,也会带来不少损耗;

关于多进程和多线程编程技术,本文就简单介绍这些。想要进一步理解进程和线程的工作原理、区别和联系,可以复习一下操作系统课程:

想要学习 CPU 的运行原理,更深入理解页表和 TLB 缓存的作用,可以复习计算机组成原理:

想要学习多进程和多线程编程方法,掌握相关系统调用和代码开发技巧,可以看看 APUE

非阻塞轮询

既然一个连接开一个进程或线程来处理开销太大,那有办法在一个执行流中同时处理多个连接吗?

同步阻塞模式下最棘手的问题是,套接字读写操作是阻塞的,会卡住进程或线程执行流。为此,操作系统支持将套接字设置成非阻塞模式:就算数据仍未就绪,读写操作也不会一直等待;而是直接返回错误,程序可以接着执行。

  • 阻塞模式:读写操作会等待,直到数据就绪(函数调用迟迟不返回);
  • 非阻塞模式:读写操作不会等待(函数调用立马返回);

由于应用程序不知道每个连接的数据何时就绪,因此只能进行轮询。举个例子,假设服务器进程通过 accept 系统调用接收到 3 个新连接,它并不知道这三个连接何时发请求上来。它只能采用轮询策略,定期尝试读取每个新套接字,看哪个有发数据过来。

非阻塞轮询方案也不完美,主要硬伤还是性能问题:

  • 轮询时无差别读取,空闲连接也要读一遍,开销较大;
  • 连接数据发上来后,要下一次轮询才能读到,及时性差;
  • 为提升及时性,势必要提高轮询频率,但这样开销更大;

IO多路复用

为克服非阻塞轮询方案的种种缺点,操作系统又提供了 IO多路复用IO multiplexing )技术。

IO 多路复用顾名思义,就是通过一次系统调用同时监视多个 IO 对象(套接字):应用进程执行系统调用告诉操作系统监视哪些文件描述符,当它们有可读、可写或错误事件时,系统调用返回告知应用程序。

IO 多路复用技术发展至今,已历经若干代,以 Linux 为例:

  • 第一代:select ,解决有无问题;
  • 第二代:poll ,优化文件描述符传递,突破数量限制;
  • 第三代:epoll ,全面解决性能问题;

select

应用进程执行 select 系统调用,将要监视的文件描述符(套接字)列表传给内核:

1
2
int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

文件描述符列表分为 3 组,分别是:

  • readfds ,监视可读事件,套接字上有数据到底时触发;
  • writefds ,监视可写事件,数据送达对端套接字发送缓冲区释放时触发;
  • exceptfds ,监视异常事件,内核网络协议栈检测到异常时触发;

内核接到系统调用后,逐一遍历每个文件描述符;当有文件描述符就绪时,select 系统调用便返回。就绪的套接字同样通过这几个参数,传回应用程序(通过指针修改数据)。

如果当前所有文件描述符均为就绪,select 系统调用会阻塞等待,直到:

  1. 有文件描述符就绪;
  2. 进程通过 timeout 指定的超时时间到达;

应用进程只需执行一次 select 系统调用,即可监视多个套接字,效率比应用进程自己轮询显著提高。不过,您可能也猜到了,select 也有它的局限性:

  1. 文件描述符列表 fd_set 大小有限制,通常只支持 1024 个文件描述符;
    • 该限制由一个宏定义指定,虽然可以调节,但需要重新编译内核
  2. 每次调用需要将关注的全部文件描述符拷贝到内核,开销很大;
  3. 内核需要遍历每个文件描述符,本质上还是轮询;

select 在内核中轮询比进程在用户空间自己轮询高效,因为系统调用的开销降低了:本来每检查一个套接字就需要执行一次系统调用,现在总共只需要执行一次。

进程执行系统调用是有开销的,因为需要进行用户态和内核态切换( CPU 执行栈和寄存器)。只不过跟进、线程切换相比,系统调用上下文切换开销要小很多。

进程切换 > 线程切换 > 用户态内核态切换

更多关于 select IO多路复用的编程技巧,可以参考 APUE 或《Unix网络编程》:

更多关于内核态、用户态、系统调用、上下文切换的细节,请查阅操作系统和 Linux 内核相关资料:

poll

poll 主要解决 select 文件描述符列表大小限制问题,它采用 pollfd 结构,列表大小可灵活调节:

1
int poll(struct pollfd *fds, nfds_t nfds, int timeout)
  • fds ,即待监控文件描述符列表,它是一个 pollfd 结构体数组,数组大小是动态的;
  • nfdsfds 数组长度;

虽然 poll 解决了文件描述符大小限制问题,但其他实现跟 select 差不多:

  • 文件描述符集合仍需在用户态和内核态间复制;
  • 检测仍需要轮询遍历每个文件描述符;

总体而言,随着监控套接字集合的增加,poll 性能会线性下降,因此也不适用于大并发场景。

epoll

为解决 selectpoll 的性能问题,Linux 内核后来实现了 epoll 机制,有针对性地进行了优化:

  • 文件描述符只需注册一次,不用每次都传进内核;
    • 执行 epoll_ctl 系统调用,注册/修改/取消要监视的文件描述符;
    • 内核用红黑树维护注册的文件描述符,提高查找效率(避免遍历);
  • 采用回调函数机制订阅 IO 事件,避免轮询;
    • epoll_ctl 添加新文件描述符的同时,内核会注册 IO 事件回调函数;
    • 当相关 IO 事件发生,回调函数就会执行,内核就知道哪些文件描述符就绪了;
  • 只返回就绪文件描述符到用户空间;
    • 回调函数执行时,内核会将对应的活跃描述符加入就绪链表;
    • epoll_wait 等待文件描述符就绪,然后只需取出就绪链表并返回,不用遍历;
    • 应用进程只处理就绪文件描述符,无需遍历每个文件描述符进行判断;

采用 epoll 机制,应用进程通常这样操作:

  1. 执行 epoll_create 系统调用,创建 epoll 专用文件描述符(保存相关上下文);
    • 参数 size 告诉内核监听规模,这不是一个限制,只是一个建议值,内核据此分配资源初始化相关数据结构;
    • epoll 创建红黑树用于保存待监听文件描述符;
    • epoll 创建就绪链表用于保存就绪的文件描述符;
  2. 执行 epoll_ctl 系统调用,注册、修改、移除待监听文件描述符;
    • EPOLL_CTL_ADD 注册新的文件描述符,内核将该文件描述符保存到红黑树,然后注册 IO 事件回调函数;
    • IO 事件发生,回调函数被执行,对应文件描述符将被放入就绪链表;
    • EPOLL_CTL_MOD 修改已注册的文件描述符,根据要订阅的事件,调整回调函数注册;
    • EPOLL_CTL_DEL 移除文件描述符注册,删除回调函数注册;
  3. 执行 epoll_wait 系统调用,等待文件描述符就绪;
    • 内核直接从链表中取出就绪文件描述符并返回;
    • 应用程序直接处理就绪文件描述符,无需逐个遍历;
    • 如果就行链表为空,epoll_wait 可以阻塞直到其中有一个就绪;

另外,epoll 还支持不同的触发模式:

  • 水平触发,以读事件为例,只要文件描述符可读,就会一直返回;
  • 边缘触发,以读事件为例,仅当文件描述符从不可读变成可读时,才会返回;

边缘触发模式通常效率更高——因为 epoll 只在文件描述符变为可读时通知一次,不会重复通知。但边缘触发模式代码编写起来要复杂一些,应用进程必须自己持续读取,以免遗漏。

更多关于 epoll 的工作原理和编程技巧,可以参考《Unix网络编程》:

操作系统调优

采用 IO 多路复用之后,进程连接数可能还是上不去,这时需要检查 Linux 系统参数。有两个非常重要的参数制约一个进程能够打开的文件数上限,包括套接字。第一个是内核参数 /proc/sys/fs/file-max

1
2
$ cat /proc/sys/fs/file-max
100000

这个参数控制 Linux 系统最多允许同时打开多少个文件,是系统级别的硬限制。如果这个参数设置过小,进程连接数肯定上不去。通常 Linux 内核会根据系统硬件资源(内存)状况自动计算该限制,如无特殊需要不必调整。

如果该参数当前设置太小,可以手工调大:

1
$ echo 1000000 > /proc/sys/fs/file-max

还有另一个限制是 ulimit ,更具体讲是其中的 nofile ,它控制一个进程能够打开的最大文件数。nofile 参数分两个值来设置,其中:

  • hard ,设置硬限制,进程能自己调节,但只能调小;
  • soft ,设置软限制,进程能自己调节,既能调大也能调小,但不能超过 hard

最终进程能打开的最大文件数受 soft 值直接控制,受 hard 值间接控制。之所以将参数分成两个值,我猜是内核想想让进程自主控制最大文件数,又不想其超过既定范围,分成两个值更灵活一些。

很多 Linux 系统 ulimit.nofile 默认设置为 1024 ,这意味着进程最多能同时处理的连接数只有一千出头!如果需要调大,可以执行 ulimit 命令或编辑 /etc/security/limits.conf 文件,本文不再赘述。

关于 Linux 资源限制resource limitrlimit )更多细节,可以查阅 Linux 经典书籍:

采用 IO 多路复用技术,并调好关键内核参数,单个应用进程能够实现相当可观的并发。由于现代 CPU 通常有很多核心,因此我们还可以采用多进程技术,进一步提高并发能力。

这里的多进程跟之前的已有本质区别,每个进程都能同时支持很多并发连接,而不仅仅是一个。如果还想继续优化,将单机性能发挥到极致,就只能深挖处理器架构和 Unix 内核相关知识了。

处理器方面要了解 SMPMUMA 架构、流水线以及缓存原理,才能有针对性地进行优化。举个例子,处理同样的大矩阵,按行还是按列遍历性能可能会相差一个数量级,因为不好的访问方式会让 CPU 缓存失效。

这方面我推荐看《深入理解计算机系统》,里面有很多例子:

Linux 内核方面则可以看看《Linux内核设计与实现》,补全关于进程调度、内存管理等知识:

负载均衡

采用 IO 多路复用技术配合多进程,我们可以将多核机器的性能发挥到极致。然而性能再好的机器,它能够支撑的并发负载也是有限的,毕竟 CPU 核心就那么多个。这时又该如何优化呢?

我们可以将应用从 单机架构 升级到 分布式架构 ,以实现 水平扩展 。水平扩展的思路简单粗暴,一台机器不够,那就两台;两台不够就三台,还不够还可以继续加!

那么,如何实现能够水平扩展的分布式架构呢?

我们可以将应用部署到若干台机器上,请求可以送到任意一台机器上处理。这样一来,一个 N 节点组成的应用,其处理能力理论上可以达到单机模式的 N 倍。

换句话讲,系统的处理能力,跟系统的节点数成线性关系。这是一种非常有弹性的系统架构,特别适用于应用负载会随时间变化的场景。当系统负载上升时,只需为系统扩容新机器;当负载下降时,则反过来缩容一些。

还有一个问题,怎么将请求分发到不同的机器上执行,并且尽量均匀呢?总不能让客户端自己来决定请求那个节点吧?这就需要引入 负载均衡load balancing )技术。

负载均衡顾名思义就是负责将请求负载,均匀地分发到背后的工作节点。按负载均衡工作的网络层次,可以进一步分为两种:

  • 四层负载均衡,工作在传输层;
  • 七层负载均衡,工作在应用层;

业界的负载均衡技术很多,常用的有

  • Nginx
  • HAProxy
  • LVS
  • DNS
  • etc

Nginx

Nginx 是一款非常强大的 Web 服务器,常用于 路由转发负载均衡 场景。

假设有一个电商站点,后台应用原来部署在一台机器上。现在由于用户量快速上涨,急需扩容。我们可以再找一台机器,部署上后台应用,然后前面再部署一个 Nginx 来分发流量:

如上图,Nginx 充当 反向代理reversed proxy )的角色,客户端直接将请求发给 Nginx ,由 Nginx 负责转发到其后面的两台 Web 应用服务器。这一切对客户端来说是完全透明的,它们只跟 Nginx 打交道,因此反向代理常被称为透明代理。

Nginx 反向代理配置起来也很简单,我们来看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
server {
    # 监听端口
    listen 80;
    # 代理对外域名
    server_name proxy-site.com;

    location / {
        # 转向服务器
        proxy_pass http://dest-site.com;
        proxy_redirect default;
    }
}

# 服务器集群及权重(可选)
upstream dest-site.com {
    server 10.0.0.1:80 weight=1;
    server 10.0.0.2:80 weight=2;
}

这个例子定义了一个 upstream ,包含两台应用服务器,假设 IP 分别是 10.0.0.110.0.0.2server 节则定义 proxy_pass 将所有流量转到这个 upstream 。注意到,upstream server 还能设置权重,给性能好点的服务器分发更多流量。

简单业务场景,请求通常可以落在任一台服务器处理,因此 Nginx 只需随机转发。但在某些复杂的业务场景可能就不行,比如有的需要实现会话保持。

举个例子:如果应用服务器将登陆会话保存在本地,那么 Nginx 必须保证登录后的请求都落在原来那台。否则若登录时落在 A 节点上处理,之后又落在 B 节点就会有问题,因为 B 并没有保存该请求的会话信息。

那么,Nginx 如何配置会话保持呢?常用的配置手段有两种:

  • ip hash ,根据客户端 IP 哈希决定转发到哪个节点;
    • 客户端 IP 如果发生变化,转发节点很可能也会变化,进而导致会话失效;
    • 根据 IP 哈希,理论上可以将请求均匀地分布在不同节点;
    • 但来自同一局域网的客户端,由于出口 IP 相同,会被转发到同一节点,又可能导致负载失衡;
  • sticky_cookie_insert ,启用会话亲缘关系,Nginx 设置 cookie 并据此决定转发;
    • 基于 cookie 而非客户端 IP 来判断,可以避免同一局域网或前端代理导致的负载均衡;

哈希映射 是一种简单高效的数据映射方法,做法很简单:

  1. 对映射节点进行编号,N 个节点可以编号成 01 ,…… , N-1
  2. 对数据(如 IP 地址)求哈希值,并对 N 取模( hash % N ),得到一个 0N-1 的数值,就是映射到的节点编号;

Nginx 还支持配置健康检查,对 upstream 后端服务进行监控,发现故障就将其剔除:

如上图,Nginx 背后配了 3Web 应用服务器,其中第一台服务器发生故障;Nginx 通过健康检查发现了故障,并将其从 upstream 中摘除;新进来的请求将分发到正常的服务上,因此客户端对此完全无感。这种集群架构允许集群部分节点故障,系统功能完全不受影响,因此同时实现了 高可用high availability )。

因此,利用负载均衡技术,我们可以实现高并发,也可以实现高可用,或者兼而有之。

HAProxy

Nginx 通常应用于七层(应用层),它可以配置复杂策略,根据 HTTP 请求内容(如 cookie )来转发请求。如果采用其他应用层协议,或者不关心应用层协议,只需按端口转发,则可以采用 HAProxy

HAProxy 本质上是一个端口转发器:它监听自己的服务端口,然后把客户端的连接转发到背后的服务器上:

  1. 一个客户端连上了后,HAProxy 会拿到一个连接套接字;
  2. HAProxy 根据转发策略,代表该客户端与背后的服务器建立连接,得到另一个套接字;
  3. 此后 HAProxy 负责在这两个套接字间来回拷贝数据;

由于 HAProxy 只是一个四层转发转发器,它配置起来比 Nginx 要简单一些。当然了,功能也更为单一,但在简单的业务场景下,也是够用的。

LVS

根据套接字编程接口,HAProxy 转发数据时需要先将数据从一个套接字读到( recv )用户空间,再通过对应的套接字发送( send )出去。很显然,这会导致了大量的数据拷贝:读取时从内核空间拷贝到用户空间,发送时从用户空间又重新拷贝回内核空间。

为避免不必要的数据拷贝,内核提供了 sendfile 系统调用进行优化。sendfile 系统调用将数据从一个文件描述符读取出来,并写到另一个,期间无需在用户态和内核态间拷贝数据。

虽然 sendfile 避免了数据拷贝,但系统调用还是避免不了。如果端口转发可以绕过进程,直接在内核中做,那性能肯定还会大幅提升。为此,章文嵩博士在内核中实现了 LVSLinux Virtual Server )。

LVS 是一个基于四层,工作于内核且性能十分强悍的反向代理服务器,支持很多模式:

  • DR 模式;
  • NAT 模式;
  • FULLNAT 模式;

对比 HAProxyLVS 的工作原理要复杂很多。因篇幅关系,本文就不再深入介绍了。后续有机会,我再写篇文章详细讲解一下。现在,我们只这样理解 LVS

  • 工作于内核;
  • 实现四层转发;
  • 无数据拷贝,性能强悍;
  • 支持回程流量不经过代理接入节点直接回给客户端,能有效缓解接入节点的带宽瓶颈;

F5

LVS 性能比 HAProxy 要好很多,但毕竟只是软件负载均衡技术,性能仍达不到极致。如果想要得到更好的性能,可以采用一些硬件解决方案,比如 F5

硬件负载均衡技术,本质上跟前面介绍的软件解决方案是一样的。只不过硬件不存在操作系统中断、上下文切换等软件处理开销,因此性能会好很多。当然了,硬件解决方案通常也比较贵,因为需要买专用设备。

DNS

根据 DNS 协议,一个域名可以解析到多个不同的 IP 上。因此,DNS 也可以用来做负载均衡,水平扩展系统的处理能力。

举个例子,假设我的站点 fasionchan.com 的访问量很大,我可以多部署多几台服务器,然后把 IP 都配置到 DNS 。用户访问站点需要先解析域名,由于得到的 IP 有多个,它会随机挑一个来访问。正因 IP 选择是随机的,每台服务器的负载大致是均匀的。

数据库

互联网后台通常是由应用程序和数据库组成的大型系统,如果数据库性能跟不上,就算应用程序优化得再好也是白搭。那这部分我们就一起来研究一下,如何对数据库进行优化。

数据库调优

数据库调优这个话题还是很大,涉及面非常广。小到字段类型,大到索引设计,SQL 语句写法都会影响查询性能。

举个例子,有一个长度很大的字段需要建索引,最好另存一个 MD5 字段,把索引建在 MD5 字段上。因为 MD5 值的长度是固定的,而且长度相对较短,不管是索引空间还是查询效率都会显著提高。

由于本文只是指引性介绍,姑且以这个例子抛砖引玉,有兴趣的读者可以深入学习《高性能MySQL》之类的权威著作:

主从同步高可用

前面提到,应用程序可以部署多个节点,配合负载均衡技术实现高可用。那么,数据库也可以这样实现高可用吗?

我们来考察一个简单的场景,假设我们有一个类似微信朋友圈的应用:

我们部署了多台应用服务器,提供后端 API 接口,它们都连同一个数据库。客户端的请求上来后,先经过一个负载均衡器,由它转发给应用服务器。应用服务器则根据业务逻辑,对数据库进行读写。

这个架构应用服务器是高可用的,因为不管哪个节点挂了,负载均衡器都可以将流量送到健康节点。想要容忍更多服务器故障,我们只需部署更多节点。只要不是所有服务器都挂点,我们的服务就不受影响。

但是,我们的数据库只有一台!但由于数据库是典型的有状态服务,不能无差别地部署多实例。试想用户发朋友圈写 A 库,读朋友圈读 B 库不就发现自己的数据不见了吗?

因此,数据库部署多实例后还需要同步数据,确保每个实例数据是一致的。由于多点写入会给数据同步带来挑战,数据冲突可能难以解决,因此通常采用主从同步模式:

如上图,应用连接主库读写数据,从库连接主库同步数据。平时应用不会连接从库,因此从库只起到备份数据的作用。当主库故障无法恢复时,管理员可以将应用切到从库,从库转为主库,继续提供服务:

  • 由于数据同步存在时延,因此从库跟主库可能不一致;
  • 主从切换需要人工判断是否安全,以及切换后是否需要进行数据维护,以保证数据一致性;
  • 当主库恢复后,可以转为从库,连接新主库同步数据;

采用主从同步架构后,就算主数据库故障短时间无法恢复,管理员也可以将应用切到从库从而恢复服务,可用性在一定程度上得到有效保障。

读写分离

假设我们要实现一个类型朋友圈的功能,后端提供一个 API 模块。用户发朋友圈时,前端调用 API 写数据库;用户刷朋友圈时则调 API 读数据库。这是一个应用的最小化模型,很简单对吧?

假设现在应用的用户量很大, 很多人都在刷朋友圈,数据库查询压力很大,怎么办呢?

最简单的做法是部署主从同步架构来做读写分离:

如上图,数据库部署主从同步结构,应用写操作连到主库上,读操作连接到从库上。这样读压力就从主库分散到从库上,从而获得更大的吞吐量。如果读压力很大,我们还可以多部署几个从库,进一步分散读压力。

因此,主从同步结构特别适用于读压力很大的业务场景。

数据同步延迟

用户发朋友圈,数据写入主库后,还要经过一小段时间之后才能同步到从库。假设你发了朋友圈,数据还没同步到从库,这时你刷新自己的首页,岂不是发现自己发的朋友圈丢了!

因此,采用读写分离架构的应用,必须关注数据同步延迟,并加以处理。拿这个例子来说,应用服务只要保证当前用户的朋友圈总是到主库读,就不会有问题。

单调读

假设好友发了一条朋友圈,写入主库,但只有从库①已经同步,从库②和③尚未完成同步,这时你刷朋友圈会发生什么现象?

由于刷朋友圈是一个读操作,应用服务器会连接从库读取数据。如果连的是从库①,这时可以读到好友的朋友圈。假设这时你刷新了页面,应用服务器连接从库②读取数据, 这时你会发现好友发的朋友圈消失了!你再刷新,如果又连回从库①,这时朋友圈又出现了,再刷一下可能又消失了……

由于从库的同步速度不是完全一致的,而且步调也难以控制,因此应用肯定会读到不一致的数据。那么,我们应该怎么解决这个问题呢?

其实很简单,应用服务器可以根据用户算哈希值,来决定连哪个从库,以此保证同个用户会固定读一个从库。这样一来,只要朋友圈记录从主库同步到当前从库,被用户读到之后就不会再消失。

从库数据的同步进度,决定了其数据快照的时间点。一旦读取到新的数据快照,就不会重新读到旧快照,这就是所谓的 单调读

关于读写分离架构就先简单介绍这些,更多细节大家可以阅读《数据密集型应用系统设计》深入学习:

数据缓存

数据库读压力太大,还可以通过引入缓存来解决。举个例子,网络论坛应用首页展示热门帖子,这需要查询数据库。而且这不是一个简单查询,开销可能较大。如果每个用户刷新首页都要查一遍数据库,压力可想而知。

热门帖子通常在短时间内不会变化,因此我们可以将查询结构缓存起来:

  • 应用服务器先查缓存——①;
  • 如果缓存数据不存在或者已经过期,查询数据库——②;
  • 将数据库查询结构写到缓存,同时可以设置有效期——③;
  • 只要缓存数据尚未失效,应用程序可以直接从缓存中读取——④;

有了缓存挡在前面,应用服务器仅当缓存失效时才会查询数据库,因此数据库压力大幅降低。如果应用服务器采用多节点部署,查库写缓存操作(如图②和③)可能需要用分布式锁加以保护。

至于缓存模块的实现,可以采用目前很流行的中间件,比如:

  • Redis
  • Memcached

数据分片

在海量业务面前,以上这些套路还是远远不够的。假设你有一个国民级应用,你光用户表就得有十几亿条数据!那问题来了,什么样的数据库才能支持单表十几亿规模的在线查询呢?

常用数据库对此可能都无能为力,以 MySQL 为例,单表数据规模达到千万级以上后,性能就会有明显下降。那问题是否就完全无解了呢?

计算机科学中有一个非常有名的分治思想,几乎放之四海而皆准。我们可以对大表进行划分,分成若干小表,然后存放到不同的数据库上。

这样一来,每个数据库中的数据量下降了,压力也就下降了。不过我们还有一个问题尚未解决,数据应该如何划分呢?如果是随机划分,查询时如何确定查哪个库呢?难不成每个库都查一遍?

数据划分

一般而言,可以采用以下两种策略来划分数据:

  • 哈希划分,对数据键(唯一用户名)求哈希值,决定保存在哪个库;
  • 范围划分,对数据区间进行分段后保存,比如身份证号为 44 开头的保存在某个库;

哈希划分

回到前面的例子,我们可以先对用户名求哈希值,再根据哈希值决定数据保存到哪个子库。这种划分方式通常可以保证数据基本上是均匀的,因为哈希映射是随机的。

查询时,应用需要根据查询条件决定查询哪个数据库。举个例子,如果查询 tom 的用户信息,计算 tom 的哈希值并进行映射即可知道应该查询数据库②。

不过有些查询无法确定子数据库,只能每一个都查一遍,再合并结果。例如查询 tom 的好友,查询条件没有包含哈希用的字段,也就无法做哈希映射;而 tom 的好友可能保存在任一个子库中,因此只能全部查一遍。

一致性哈希

顺便提一下,如果只是采用普通哈希方法,增减子库时会导致大量的数据迁移。因为 N 一变,哈希值取模出来的序号也跟着变,那映射关系也就全变了。映射关系一变意味着我们需要将数据从旧库,迁移到新映射的库上去。

这种问题可以采用 一致性哈希consistent hashing )算法加以解决,它通过引入一层虚拟节点,来减少映射关系的变动。

范围划分

范围划分,顾名思义就是将字段值的范围划分为若干区间,再分别保存到不同的子库。举个例子,有个应用用户 ID 的范围在 1~1000000 之间,用户表需要分 5 个子库存储,可以这样分:

  • 1~200000 ,存在子库①;
  • 200001~400000 ,存在子库②;
  • 400001~600000 ,存在子库③;
  • 600001~800000 ,存在子库④;
  • 800001~1000000 ,存在子库⑤;

分段太大通常不太灵活,例如数据分别可能不均匀。因此,我们可能需要缩小分段范围,比如以 100 为一个段,1 百万的 ID 范围可以划分为 1 万个区间,再映射到子库上去,而映射关系作为元数据保存。

如果发现某个子库负载较高,可以将上面的某些区间数据迁移到其他节点上,数据库维护起来就灵活多了。

MongoDB案例

MongoDB 是一个典型的分布式数据库,我们就以它为学习案例,深入考察 数据复制数据分片 相关架构设计。

  • 数据复制(),通常为了实现高可用,同时也实现压力分散;
  • 数据分片(),通常为了实现水平扩展;

MongoDB 支持主从同步,通常是一个主节点加两个从节点组成一个 复制集replica set ),检查 RS

如上图是一个三节点组成的复制集,三个节点保持通信,并通过分布式共识算法选举主节点。主节点接收写请求,从节点从主节点同步复制数据。读请求可以视业务场景配置读取策略:

  • 只读 primary
  • 只读 secondary(读写分离);
  • 两者都可读;

应用程序通常将 RS 每个节点的 IP 都配上,它可以连接任一节点,读取 RS 集群信息,然后再连接主节点。选举时要满足过半数原则,因此节点数通常为奇数,即 $2N+1$ ,$N+1$ 则为过半数。

由此一来,一个 RS 至少需要有 3 个节点:一主两从,数据为三副本。如果只要一主一从两副本模式,可以加一个仲裁节点。仲裁节点只参与选举,不存储数据。

RS 实现数据多副本,达到高可用和分散压力的目的。一方面,RS 允许有节点挂掉,只要故障节点不超过半数,数据库服务就不受影响;另一方面,通过读写分离,可以将读压力分散到 secondary 节点,从而降低 primary 的压力。

除了数据副本复制功能,MongoDB 还提供了数据分片功能:

如上图,这是一个 MongoDB 分片集群:集群中有 5 个分片,每个分片的数据由一个 RS 负责存储,RS 做数据副本复制以实现高可用;MongoDB 支持通过哈希或者范围划分,将数据分到某一个 RS 上保存;数据划分策略、分片映射关系则由一个独立的 RS 保存,称为 config ;由于这类元数据不大,因此 config 通常都很小;mongos 则负责查询路由,它解析客户端请求,根据 config 中的元数据判断数据位于哪个 RS ;如果查询条件无法确定数据位置,它会向每个 RS 都发起查询,再合并结果;mongos 是无状态的,通常可以部署多个实例,来实现高可用;此外,MongoDB 还可以自动平衡数据,将数据从负载高的 RS 迁移到负载低的 RS (迁移好后更新 config 元数据)。

队列异步化

应用负载通常不是均匀的,比如电商大促时请求量可能是平时的好几倍,甚至几十倍。面对突发而来的极高并发,系统通常难以应对。何况按瞬时峰值负载来规划系统容量,放在平时会造成极大的浪费。

笔者曾负责一个主机监控系统的研发,最开始的架构大致如下:

每台服务器主机上都部署 agent ,定期采集性能指标,并提交给后端处理。由于定时任务总是在整点执行,因此会出现主机总在采集时点同时请求 api 提交数据的情况。

所有主机几乎在同一时间请求 api 提交数据,但由于数据库写入吞吐是有限的,经常会有数据写入失败。如果 agent 没有失败重试机制,则意味着数据丢失。

然而瞬间请求过后,系统就又空闲下来了。如果可以将数据写入分摊到整个提交周期,系统的表现应该要好很多。就像拦河大坝,发生洪灾时先把水拦住,再慢慢放水,就不会造成严重损失。

因此,我们可以引入消息队列来做水库,对 api 进行异步化改造:

  1. api 收到数据提交后,先把数据写入 消息队列message queue 简称 MQ );
    • 消息队列通常是顺序写入磁盘,因此写性能要比数据库高很多;
  2. 后台服务 writer 负责从 MQ 消费数据,并写入数据库;

这样一来,当采集时刻瞬时数据提交并发上来后,api 可以从容地将全部数据写到 MQ 。这时数据可能仍堆积在 MQ ,尚未写入数据库。writer 模块会慢慢消费数据,完成写库任务。

只要在下次提交时间到达前可以顺利写完,系统就可平稳运行。由此可见,MQ 就像一个水库,对数据流起到 削峰平谷 的作用。

此外,MQ 的引入还有效降低了系统模块间的耦合。回到这个例子,如果 writer 或数据库发生故障或正在维护,对 api 完全没有影响。agent 还能继续提交数据,只是数据会堆积在 MQ 中。 writer 和数据库恢复后,再慢慢追数据即可。

总结一下,引入消息队列可以发挥以下效果:

  • 流量削峰平谷;
  • 处理异步化;
  • 模块解耦;

目前,比较流行的消息队列系统有 KafkaRabbitMQ 等等,有兴趣的同学可以自行了解一下。

推荐书单

订阅更新,获取更多学习资料,请关注我们的公众号:

【随笔】系列文章首发于公众号【小菜学编程】,敬请关注: