温斯顿吴 ☀️摩诃般若波罗蜜

《构建高性能Web站点》读书笔记

2017-04-05

第1章 绪论

(略)

第2章 数据的网络传输

(通过一个构建铁路系统的故事复习TCP的基础知识,略)

带宽

数据发送的过程(数据从主机进入线路的过程):

  1. 应用程序将要发送的数据写入进程的内存地址空间中;(运行时变量赋值)
  2. 应用程序向内核发出系统调用,内核将数据从用户态内存复制到由内核维护的内核缓冲区中。(内核缓冲区的大小是有限的,所有要发送的数据以队列形式进入,这些数据可能来自于多个进程)
  3. 内核通知网卡控制器前来取数据,同时CPU转而处理其他进程。网卡控制器根据网卡驱动信息得知对应的内核缓冲区的地址,并将要发送的数据复制到网卡的缓冲区中。在以上的数据复制过程中,数据始终按照连接两端设备的内部总线宽度来复制,比如在32位总线的主机系统中,网卡一般也使用32位总线宽度,那么从内核缓冲区到网卡缓冲区的数据复制过程中,任何时刻只能复制32位的比特信息。
  4. 网卡将缓冲区中的数据按位转换成不同的光电信号,然后将数据的每个位按照顺序依次发出。

带宽指数据的发送速度。具体取决于以下两个因素:

  1. 信号传输频率:即数据发送装置将二进制信号传送至线路的能力以及另一端的数据接收装置对二进制信号的接收能力,同时也包含线路对传输频率的支持程度。
  2. 数据传播介质的并行度,完全等价于计算机系统总线宽度的概念。比如将若干条光纤并行组成光缆,这样就可以在同一个横截面上同时传输多个信号。 所以,要提高计算机总线的带宽,包括提高总线频率和总线宽度两种方法。

网络通信相比于计算机内部的数据传输而言有一个很大的不同,其传输距离比较大,所以信号在传播介质中会衰减,为此需要借助中继器来延续信号,而每次中继器转发信号又会消耗一些发送时间。

共享和独享

运营商会在所有的基础交换节点上设置关卡,即限制数据从用户主机流入路由器转发队列的速度。 如果某台主机使用了独享10M带宽,且路由器的出口带宽为100M,那么交换机的设置应该保证来自该广播域内的其他主机的数据发送速度总和不超过90Mbit/s,以此保证该主机任何时刻都可以以10Mbit/s的速度发送数据。即该主机独享路由器的一部分出口带宽。 如果是共享100M带宽,则交换机不保证主机的出口带宽能达到100M。

响应时间

响应时间即数据从服务器开始发送直到完全到达用户PC的这段时间。 响应时间 = 发送时间 + 传播时间 + 处理时间 发送时间 = 数据量/带宽,当存在多个交换节点时,也应该包含每个节点的转发时间 传播时间 = 传播距离/传播速度 处理时间指数据在交换节点中为存储转发而进行一些必要处理所花费的时间,主要是数据在缓冲区队列中排队所花费的时间。

下载速度 = 数据量字节数/响应时间

在实际的互联网中,瓶颈也可能出现在各互联网运营商之间的网络互连上。

第3章 服务器并发处理能力

吞吐率

吞吐率指服务器单位时间内处理的请求数,是在一定并发用户数的情况下服务器处理请求能力的量化体现。 通常所讲的最大并发数是有一定的利益前提的,即服务器和用户双方所期待的最大收益的平衡。得出最大并发数的意义在于了解服务器的承载能力。 需要注意的是,所谓的最大并发数并非指和真实用户的一一对应关系,因为一个真实用户可能会给服务器带来多个并发用户数压力。 从Web服务器的角度来看,实际并发用户数也可以理解为Web服务器当前维护的、代表不同用户的文件描述符总数,Web服务器一般会限制同时服务的最多用户数,当实际并发用户数大于服务器所维护的文件描述符总数时,多出来的用户请求将在服务器内核的数据接收缓冲区中等待处理。

需要注意区别用户平均请求等待时间和服务器平均请求处理时间。当只有一个用户时,可以近似地认为用户平均请求等待时间等于服务器的平均请求处理时间。当并发用户数增加时,Web服务器会通过多个执行流来同时处理多个并发用户的请求(轮流交替使用时间片),所以每个执行流花费的时间都被拉长,因此用户的平均等待时间必然增加,而对于服务器而言,如果并发策略得当,每个请求的平均处理时间可能减少。

进程

进程是CPU多执行流的实现方式。多进程的好处不仅仅在于对CPU时间的轮流使用,还在于对CPU计算和I/O操作进行了很好的重叠利用。 进程的调度由内核进行,从内核的观点看,进程就是担当分配系统资源的实体,也可以理解为记录程序实例当前运行到什么程度的一组数据。 每个进程都有自己的独立内存地址空间和生命周期,当子进程被父进程创建后便将父进程地址空间的所有数据复制到自己的地址空间,完全继承父进程的所有上下文信息。 进程的创建使用fork()系统调用。

轻量级进程

由于进程之间相对独立,各自维护其庞大的地址空间和上下文信息,所以采用大量进程的Web服务器在处理大量并发请求时内存的大量消耗有时候会成为性能提升的制约因素。但是,进程的优越性也恰恰体现在其相互独立所带来的稳定性和健壮性。 Linux2.0之后提供对轻量级进程的支持,由新的系统调用clone()来创建,由内核直接管理,像普通的进程一样独立存在,拥有自己的进程描述符。但是这些进程已经允许共享一些资源,比如地址空间、打开的文件等,从而减少了内存的开销。当然,其上下文切换的开销在所难免。

线程

Linux线程的接口定义为pthread,它有多种实现,有些只是在普通的进程中由用户态通过一些库函数模拟实现的多执行流,这种情况下多线程的管理完全在用户态完成,线程的切换开销相比于进程和轻量级进程要少,但是在多处理器的服务器中表现较差。另一种实现是内核级的线程库,通过clone()来创建线程,即每个线程实际上就是一个轻量级进程,这使得线程完全由内核的进程调度器来管理,对多处理器服务器支持较好,但线程切换的开销也比用户态线程多。

进程调度器

内核中的进程调度器维护着各种状态的进程队列,其中包括所有可运行进程的队列称为运行队列。如果运行队列中有不止一个进程,进程调度器的一项主要工作就是决定下一个运行的进程,这项工作基于每个进程的进程优先级进行。 进程优先级除了可以由进程自己决定,进程调度器在进程运行时也可以动态调整它们的优先级。Linux对进程的动态调整体现在进程的nice属性中,在top命令中,进程的优先级属性为PR,优先级的动态调整值用NI表示。 PR所代表的值其实就是进程调度器分配给进程的时间片长度,单位是时钟个数。

系统负载

通过查看/proc/loadavg可以了解到运行队列的情况:

cat /proc/loadavg
1.63 0.48 0.21 10/200 17145

其中: 1.63 0.48 0.21是不同时间内(最近1分钟、5分钟、15分钟)的系统负载,是单位时间内运行队列中就绪等待的进程数的平均值,如果值为0,说明每个进程只要就绪后就可以马上获得CPU,无需等待,这时系统的响应速度最快。 10/200中的10代表此时运行队列中的进程个数,而200代表此时的进程总数。 17145代表最后创建的一个进程ID。 也可以通过top命令或者w命令来获得系统负载,它们其实仍然来自于/proc/loadavg。

进程切换

一个进程被挂起的本质就是将它在CPU寄存器中的数据拿出来暂存在内核态堆栈中,而一个进程恢复工作的本质就是将它的数据重新装入CPU寄存器中,这些移出和装入的数据称为进程的硬件上下文。 可以使用Nmon工具监视系统的平均上下文切换情况。

IOWait

指CPU空闲并且等待I/O操作完成的时间比例。 (详略)

锁竞争

除非我们自己编写Web服务器,否则不需要太担心目前流行的Web服务器的锁设计本身。 (详略)

系统调用

Linux为进程设计了两种运行级别:用户态和内核态。进程通过系统调用实现在两种状态间的切换。系统调用的开销比较昂贵。

应该优化Web服务器的配置,去掉不必要的系统调用,如: Apache支持通过.htaccess文件来为htdocs下的各个目录进行局部的参数配置,但它有一定的副作用。当将httpd.conf的AllowOverride设置为All时,使用strace来跟踪Apache的一个子进程可以发现,在某次请求处理中的一系列系统调用中有很多open系统调用,目的在于检查被访问的文件路径中各级目录下是否存在.htaccess文件。

内存分配

Nginx的内存分配策略优于Apache,它使用多线程来处理请求,使得多个线程之间可以共享内存资源。此外,使用分阶段的内存分配策略,按需分配,及时释放,使得内存使用量保持在很小的数量范围。 Nginx声称维持10000个非活跃HTTP持久链接只需要2.5M内存。

持久链接

持久链接也称长连接,它本身是TCP通信的一种普通方式,即在一次TCP链接中持续发送多份数据而不断开连接,与它相反的方式称为短连接,短连接每次发送数据都需要建立新的TCP连接。 HTTP长连接的实现需要浏览器和Web服务器的共同协作,一方面浏览器需要保持一个TCP连接并重复利用(表现在HTTP头中的Connection:Keep-Alive),不断地发送多个请求,另一方面,服务器不能过早地主动关闭连接。 浏览器和Web服务器各自的长连接超时时间的设置可能不一致,所以在实际运行中是以最短的超时时间为准。

I/O模型

一开始磁盘和内存之间的数据传输是由CPU控制的,即数据需要经过CPU存储转发,这种方式称为PIO。后来有了DMA(Direct Memory Access),可以不经过CPU而直接进行磁盘和内存的数据交换。CPU只需要向DMA控制器下达指令,让其处理数据传送,DMA控制器使用系统总线传输数据,完毕后再通知CPU。

同步阻塞I/O

同步阻塞I/O指当进程调用某些涉及I/O操作的系统调用或函数库时(accept()、send()、recv()等),进程暂停,等待I/O操作完成后再继续运行。

同步非阻塞I/O

同步非阻塞I/O的调用不会等待数据的就绪,如果数据不可读或者不可写,它会立即告诉进程。相比于阻塞I/O这种非阻塞I/O结合反复轮询来尝试数据是否就绪,最大的好处是便于在一个进程里同时处理多个I/O操作。缺点在于会花费大量的CPU时间,使得进程处于忙碌等待状态。 非阻塞I/O一般只对网络I/O有效,比如在socket的选项中设置O_NONBLOCK。

多路I/O就绪通知

多路I/O就绪通知允许进程通过一种方法来同时监视所有文件描述符,并可以快速获得所有就绪的文件描述符,然后只对这些文件描述符进行数据访问。 I/O就绪通知只是有助于快速获得就绪的文件描述符,当得知数据就绪后,就访问数据本身而言,仍然需要选择阻塞或者非阻塞的访问方式。

select 监视包含多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符会被修改标志位使得进程可以获得这些文件描述符从而进行后续的读写操作。有点在于几乎所有平台都支持,缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上为1024,假如维持的连接已达上限,新的连接请求将被拒绝。此外,select所维护的数据结构复制开销较大,对于非活跃状态的TCP连接也会进行线性扫描等,也是缺陷。

poll 本质上和select没有太大区别(实现者不同),包含大量文件描述符的数组被整体复制于用户态和内核态的地址空间,而不论这些文件描述符是否就绪,但是poll没有最大文件描述符的数量限制。

SIGIO 通过实时信号来通知,不同于select/poll,对于变为就绪状态的文件描述符,SIGIO只通知一遍。所以存在事件丢失的情况,需要采用其他方法弥补。

/dev/poll 使用虚拟的/dev/poll设备,可以将要监视的文件描述符数组写入这个设备,然后通过ioctl()来等待事件通知,当ioctl()返回就绪的文件描述符后,可以从/dev/poll中读取所有就绪的文件描述符数组,节省了扫描所有文件描述符的开销。

/dev/epoll 在/dev/poll的基础上增加了内存映射(mmap)的技术

epoll Linux2.6中由内核直接支持的实现方法,公认的Linux2.6下性能最好的多路I/O就绪通知方法。 epoll的本质改进在于其基于事件的就绪通知方式,事先通过epoll_ctl()来注册每一个文件描述符,一旦某个文件描述符就绪时,内核会采用类似callback的回调机制迅速激活这个文件描述符,当进程调用epoll_wait()时得到通知,获得就绪的文件描述符的数量的值,然后只需要去epoll指定的数组中依次取得相应数量的文件描述符即可。

kqueue 性能和epoll接近,但很多平台不支持。

内存映射

内存映射是一种访问磁盘文件的特殊方式,可以将内存中某块地址空间和指定的磁盘文件相关联,从而把对这块内存的访问转换成对磁盘文件的访问。内存映射无需使用read()和write()等系统调用来访问文件,而是通过mmap()系统调用来建立内存和磁盘文件的关联,然后像访问内存一样自由地访问文件。

直接I/O

在Linux2.6中,内存映射和直接访问文件没有本质上的差异,因为数据从进程用户态内存空间到磁盘都要经过两次复制:磁盘到内核缓冲区、内核缓冲区到用户态内存空间。 对于一些复杂的应用,比如数据库服务器,为了提高性能希望绕过内核缓冲区由自己在用户态空间实现并管理I/O缓冲区以支持独特的查询机制。Linux提供了对这种需求的支持,即在open()系统调用中添加参数O_DIRECT,有效避免CPU和内存的多余时间开销。

sendfile

通常向Web服务器请求静态文件的过程是:磁盘文件的数据经过内核缓冲区到达用户内存空间,然后被送到网卡对应的内核缓冲区,接着被送进网卡并发送。数据从内核出去,没有经过任何变化,又回到了内核,因此浪费时间。 sendfile()系统调用可以将磁盘文件的特定部分直接送到代表客户端的socket描述符中,从而加快静态文件的请求速度,同时减少CPU和内存的开销。 Apache对于较小的静态文件选择使用内存映射来读取,对于较大的静态文件使用sendfile来传送文件。

异步I/O

同步和异步、阻塞和非阻塞修饰的是不同的对象。 阻塞和非阻塞指当进程访问的数据未就绪时,进程是直接返回还是继续等待。 同步和异步指访问数据的机制,同步指主动请求并等待I/O操作完毕,在数据就绪后,读写时必须阻塞。异步指主动请求数据后便可以继续处理其他任务,随后等待I/O操作完毕的通知,使得进程在数据读写时也不发生阻塞。

服务器并发策略

从本质上讲所有到达Web服务器的请求都封装在IP包中,位于网卡的接收缓冲区内。Web服务器软件要做的事情就是不断地读取这些请求,进行处理,再将结果写到发送缓冲区。这个过程中涉及很多I/O操作和CPU计算,并发策略的目的就是让I/O操作和CPU计算尽量重叠进行,让CPU在I/O等待时不要空闲,同时在I/O调度上尽量花费最少的时间。

  1. 一个进程处理一个连接,非阻塞I/O fork()模式、prefork()模式; 并发连接数有限,但是稳定性和兼容性较好。

  2. 一个线程处理一个连接,非阻塞I/O 比如Apache的worker模型,这里的线程实际是轻量级进程,实际并不比prefork有太大优势。

  3. 一个进程处理多个连接,非阻塞I/O 这种模式下,多路I/O就绪通知的性能成为关键。 这种处理多个连接的进程称为work进程,通常数量可配,比如在Nginx中:worker_processes 2;

  4. 一个线程处理多个连接,异步I/O 对于磁盘文件的操作,设置文件描述符为非阻塞没有任何意义:如果需要读取的数据不在磁盘缓冲区,磁盘便开始动用物理设备来读取数据,这时整个进程的其他工作必须等待。目前几乎没有Web服务器支持基于Linux AIO的工作方式。

第4章 动态内容缓存

Smarty缓存

require '../libsmarty/Smarty.class.php';
$this->smarty = new Smarty();
$this->smarty->caching = true;

$this->template_page = 'place_posts.html';
$this->cache_id = $this->marker_id;

if( $this->smarty->is_cached($this->template_page, $this->cache_id) ){
   $this->smarty->display( $this->template_page, $this->cache_id ); 
   exit(0);
}
do_some_db_query();
...
$this->smarty->display( $this->template_page, $this->cache_id ); 

缓存保存、查找、过期检查等,主要是Smarty的API,略。

APC

Smarty的存储基于磁盘,而APC基于内存。

$this->key = $this->template_page . $this->cache_id;
$html = apc_fetch( $this->key );
if( $html !== false ){
    echo $html;
    exit(0);
}
do_some_db_query();
$html = $this->smarty->fetch($this->template_page , $this->cache_id);
apc_add($this->key, $html, $this->smarty->cache_lifetime);
echo $html;

XCache

和APC类似,略。

memcached

memcached支持将缓存存储在独立的缓存服务器中。 有Redis了,略。

局部无缓存

基本实现方式都是自定义一组标签,然后在模板中将局部无缓存的内容用标签包含,比如Smarty:

function smarty_block_dynamic( $params, $content, &$smarty){
    return $content;
}
$this->smarty->register_block('dynamic', 'smarty_block_dynamic', false);

{dynamic}
$user->user_nick;
{dynamic}

详略。

静态化内容

一般基于CMS来管理静态化内容。

服务器端包含

SSI(服务器端包含)技术实现各个局部页面的独立更新,比如Apache中的mod_include模块:

AddType text/html .shtml
AddOutputFilter INCLUDES .shtml

一旦网页支持SSI(按如上配置,即后缀为.shtml),那么每次请求的时候服务器必须要通读网页内容查找include标签,这需要大量的CPU开销。

SSI语法略。

第5章 动态脚本加速

opcode

脚本语言的解释器会事先经过词法分析、语法分析、语义分析等一系列步骤将源代码编译为操作码(Operate Code),然后再执行。PHP解释器的核心引擎为Zend Engine。

PHP的parsekit扩展提供运行时API来查看PHP代码的opcode。

var_dump( parsekit_compile_string('print 1+1;') );

opcode的格式类似于汇编代码(都是三地址码的格式),因此可以方便地翻译为不同平台的本地代码。

APC

开启:

apc.cache_by_default = on

也可以配置apc.filters让APC只对特定范围的动态程序进行opcode缓存。 APC同时提供跳过过期检查的机制,如果动态程序长期不会变化,那么可以跳过过期检查以获得更好的性能:

apc.stat = off

XCache

和APC差不多,详略。

解释器扩展模块

(略)

Xdebug

xdebug_time_index();  // 返回从脚本开始处执行到当前位置所花费的时间
xdebug_call_line();   // 当前函数在哪一行被调用
xdebug_call_function();  // 当前函数在哪个函数中被调用

// 代码覆盖率
xdebug_start_code_coverage();
...
var_dump(xdebug_get_code_coverage());

函数跟踪

根据程序在实际运行时的执行顺序跟踪记录所有函数的执行时间,以及函数调用时的上下文,包括实际参数和返回值。

在php.ini中配置记录文件的存储目录和文件名前缀:

xdebug.trace_output_dir = /tmp/xdebug
xdebug.trace_output_name trace.%c

%c代表函数调用。

输出示例:

0.0167  1009988  -> MarkerInfo->getMarkerInfo()
0.0167  1009988    -> DataAccess->selectDb(string(11))
0.0168  1010040      -> DataAccess->connect()
...
0.0170  1010288      -> mysql_connect(string(9), string(4), string(0))
0.0207  1011320      -> ...

通过以上片段可以分析出代码执行的时间主要消耗在mysql_connect()处。

瓶颈分析

Xdebug提供性能跟踪器:

xdebug.profiler_output_dir = /tmp/xdebug
xdebug.profiler_output_name = cachegrind.out.%p

其中%p是运行时PHP解释器所在进程的PID。 可以使用图形界面工具CacheGrind分析日志。

第6章 浏览器缓存

浏览器会为缓存的每个文件打上一些标记,比如过期时间,上次修改时间、上次检查时间等。

缓存协商

缓存协商基于HTTP头信息进行,动态内容本身并不受浏览器缓存机制的排斥,只要HTTP头信息中包含相应的缓存协商信息,动态内容一样可以被浏览器缓存。不过对于POST类型的请求,浏览器一般不启用本地缓存。

Last-Modified

<?php
  header('Last-Modified:' . gmdate('D, d M Y H:i:s') . ' GMT');
  echo time();
?>

此时再通过浏览器请求该动态文件,HTTP响应中将会添加一个头信息:

Last-Modified:Fri, 20 Mar 2009 07:53:02 GMT

对于带有Last-Modified的响应,浏览器会对文件进行缓存,并打上一些标记,下次再发出请求时会带上如下的HTTP头信息:

If-Modified-Since:Fri, 20 Mar 2009 07:53:02 GMT

如果没有修改,服务器会返回304信息:

HTTP/1.1 304 Not Modified
...

意味着浏览器可以直接使用本地缓存的内容。

使用基于最后修改时间的缓存协商存在一些缺点:

  1. 很可能文件内容没有变化,而只是时间被更新,此时浏览器仍然会获取全部内容。
  2. 当使用多台机器实现负载均衡时,用户请求会在多台机器之间轮询,而不同机器上的相同文件最后修改时间很难保持一致,可能导致用户的请求每次切换到新的服务器时就需要重新获取所有内容。

ETag

比如服务器返回如下带ETag的响应:

ETag:"74123-b-938fny4nfi8"

浏览器在下次请求该内容时会在HTTP头中添加如下信息:

If-None-Match:"74123-b-938fny4nfi8"

如果相同的话,服务器返回304。 Web服务器可以自由定义ETag的格式和计算方法。

Expires

Expires告诉浏览器该内容在何时过期,暗示浏览器在该内容过期之前不需要再询问服务器(彻底消灭请求),而是直接使用本地缓存即可。

...
header('Last-Modified:' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Expires:' . gmdate('D, d M Y H:i:s', time() + 3600) . ' GMT');

请求页面的不同方式

  1. Ctrl+F5:强制刷新,使网页中所有组件都直接向Web服务器发送请求,并且不使用缓存协商。
  2. F5:等同于浏览器的刷新按钮,允许浏览器在请求中附加必要的缓存协商,但不允许浏览器直接使用本地缓存,即可以使用Last-Modified,但Expires无效。
  3. 在浏览器地址栏输入URL后回车,或者通过超链接跳转到该页面 浏览器会对所有没有过期的内容直接使用本地缓存。

Cache-Control

Expire使用的是绝对过期时间,存在一些不足之处,比如浏览器和服务器的时间不一致。 HTTP/1.1提供Cache-Control,使用相对时间来弥补Expires的不足,格式如下:

Cache-Control:max-age=<second>

目前主流的浏览器都将HTTP/1.1作为首选,所以当HTTP响应头中同时含有Expires和Cache-Control时,浏览器会优先考虑Cache-Control。

第7章 Web服务器缓存

缓存响应内容

一个URL在一段较长的时间内对应一个唯一的响应内容,主流的Web服务器软件都提供对URL映射内容的缓存,比如Apache的mod_cache,在URL映射开始时检查缓存,如果缓存有效就直接取出作为响应内容返回给浏览器。 Web服务器的缓存机制实质上是以URL为主键的key-value结构缓存。

LoadModule cache_module modules/mod_cache.so
LoadModule disk_cache_module modules/mod_disk_cache.so
CacheRoot /data/apache_cache
CacheEnable disk /
CacheDirLevels 5
CacheDirLength 3

CacheMaxExpire 3600
CacheIgnoreHeaders Set-Cookie

Apache将缓存的HTTP头信息和正文信息独立存储,这样为缓存过期检查提供了方便:只要检查HTTP头信息的文件即可。

Web服务器的缓存仍然基于HTTP的缓存协商进行控制。 (详略)

缓存文件描述符

Apache提供了mod_file_cache模块来将已打开的文件描述符缓存在Web服务器内存中。需要指定希望缓存的文件列表:

CacheFile /data/www/htdocs/test.htm

Apache在启动时会打开这些文件,并持续到Apache关闭为止。 缓存文件描述符只适合小的静态文件,因为处理较大的静态文件所花费的主要时间在传送数据上,而不是打开文件。

第8章 反向代理缓存

目前更多使用的是NAT技术而不是代理服务器,代理服务器工作在应用层,需要能够支持应用层的协议,NAT工作在应用层以下,可以透明地转发应用层协议的数据。

反向代理的主要作用在于屏蔽后端服务器(安全性)和基于缓存的加速(性能)。

和Web服务器缓存、浏览器缓存一样,反向代理服务器缓存也可以基于HTTP/1.1,只要站点是面向HTTP缓存友好的内容,就可以直接放在代理服务器的后端达到反向代理缓存的目的。

通常会将Web服务器和应用服务器分离,即前端的Web服务器处理一些静态内容,同时又作为反向代理将动态内容的请求转发给后端的应用服务器处理,比如如下配置Nginx,当处理静态文件时,直接由Nginx返回(基于epoll,快),如果是动态内容,则交由启在80端口的Apache处理:

location ~ \.php$ {
  proxy_pass location:80;
}

反向代理只是Nginx的一个扩展模块,并且其缓存机制目前还不算完善,更专业的工具是Squid、Varnish等。

(Varnish实例,亮点是有脚本语言,可以实现逻辑控制,略)

缓存命中率和后端吞吐率的理想计算模型

假设反向代理服务器向后端服务器请求内容的次数为“活跃内容数”(缓存不命中),那么: 缓存命中率 = ( 活跃内容数/(实际吞吐率 * 平均缓存有效期) ) * 100% 同样的: 缓存命中率 = (1 - (后端吞吐率 / 实际吞吐率)) * 100% (详细分析,略)

ESI

反向代理服务器可以支持部分内容更新,但是前提是网页必须实现ESI(Edge Side Includes),ESI是W3C指定的标准,语法非常类似SSI,不同的是SSI在Web服务器上组装内容,而ESI在HTTP代理服务器上组装内容:

<HTML>
<BODY>
...
新闻内容
...
推荐新闻:<esi:include src="/recommand.php" />
</BODY>
</HTML>

(显然ajax更好,详略)

穿过代理

有些时候需要穿过代理,比如获取用户的实际IP,这一般是通过自定义一些服务器变量(反向代理服务器请求后端服务器时会设置)来实现,如Nginx:

location / {
  proxy_pass http://location:8001;
  proxy_set_header X-Real-IP $remote_addr;
}

这样,后端程序可以通过访问服务器变量$_SERVER[‘HTTP_X_REAL_IP’]来获得用户的实际IP。

同样的,如果后端服务器想要透过反向代理来告诉浏览器一些额外信息(比如当存在多个后端服务器时),也可以通过在响应HTTP头信息中携带一定的自定义信息来实现:

header('X-Real-Server-IP:10.0.0.1');

这样,最终反向代理返回的HTTP头信息中有如下内容:

X-Real-Server-IP:10.0.0.1

第9章 Web组件分离

不同的Web组件使用不同的域名、服务器: www.xxx.com img.xxx.com js.xxx.com 等。

需要注意的是,当使用站点的二级域名作为组件服务器地址,并且将站点的cookies作用域设置为顶级域名时,每次对图片、js文件等组件的请求都会带上本地的cookies,这将增加HTTP头信息的长度。一个常见的解决方案是为Web组件启用新的域名。

对组件进行分离的好处:

  1. 实现了服务器端的负载均衡;
  2. 提高浏览器在下载Web组件时的并发数;

(详略)

第10章 分布式缓存

(memcached,用Redis,略)

第11章 数据库性能优化

查看数据库状态

第三方MySQL状态报告工具mysqlreport,略。

正确使用索引

索引的代价是更大的存储依赖,以及写操作的性能降低。 (看《高性能MySQL》,略)

慢查询分析

long_query_time = 1
log-slow-queries = /data/var/mysql_slow.log

log-queries-not-using-indexes选项用于控制记录所有没有使用索引的查询。

使用mysqlsla分析慢查询日志,略。

show processlist;查看当前所有查询的状态,当查询被锁阻塞时,State字段为Locked。

事务

innodb_flush_log_at_txr_commit选项用于控制事务日志何时写入磁盘。

查询缓存

query_cache_size = 5678765456
query_cache_type = 1
query_cache_limit = 134567

MySQL的缓存过期策略是,当数据表有更新操作时,缓存即失效。

临时表

explain中出现Using temporary表示查询过程中需要创建临时表来存储中间数据(应该通过合理的索引来避免)。当临时表难以避免时,应该尽量减少临时表本身的开销。可以配置tmp_table_size来设置存储临时表的内存空间大小,一旦不够,MySQL将会启用磁盘来保存数据。

线程池

thread_cache_size = 100

反范式化设计

(略)

非关系型数据库

(略)

第12章 负载均衡

HTTP重定向

HTTP/1.1 302 Found
...
Location: http://xxx.com/xxxx

基于HTTP重定向的负载均衡实现比较简单,通过Web程序即可实现:

<?php
  $domains = [
    'www1.xxx.com',
    'www2.xxx.com',
    'www3.xxx.com',
  ];
  
  $index = substr(microtime(),5,3) % count($domains);
  $domain = $domains[$index];

  header("Location:http://$domain");

注意,这里采用随机方式,而没有采用轮询(RR,Round Robin)的方式,因为HTTP协议是无状态的,如果要实现轮询,需要额外的工作,比如维持一个互斥的计数变量(任何时候只有一个请求可以修改),对性能不利。

Apache的mod_rewrite模块可以支持RR重定向:

<VirtualHost *:80>
  DocumentRoot /data/www/highperfweb/htdocs
  ServerName www.xxx.com
  RewriteEngine on
  RewriteMap    lb prg:/data/www/lb.pl
  RewriteRule   ^/(.+)$ $(lb:$l)
</VirtualHost>

/data/www/lb.pl是一个脚本,实现了轮询均衡负载的逻辑。

顺序调度的性能总是比不上随机调度的性能,好处在于可以实现绝对的均衡。

注意,实际生产环境中次数的均衡不一定等于负载的均衡,比如不同用户的访问深度是不一样的。

DNS负载均衡

DNS完成域名到IP的映射,这种映射也可以是一对多的,这时DNS服务器便起到了负载均衡调度器的作用。 可以使用dig命令来查看指定域名DNS的A记录设置:

dig www.qq.com

可以看到有些域名可能有多个A记录设置,因而多次ping同一个域名IP可能变化。可以结合使用A记录和CNAME实现基于DNS服务器的,类似HTTP重定向的负载均衡:

www1.xxx.com IN A 10.0.1.1
www2.xxx.com IN A 10.0.1.2
www3.xxx.com IN A 10.0.1.3
www.xxx.com  IN CNAME www1.xxx.com
www.xxx.com  IN CNAME www2.xxx.com
www.xxx.com  IN CNAME www3.xxx.com

不用担心DNS服务器本身的性能,因为DNS记录可以被用户浏览器、互联网接入服务商的各级DNS服务器缓存。

反向代理负载均衡

因为Web反向代理服务器的核心工作就是转发(而不是转移)HTTP请求,工作在应用层,因此反向代理的负载均衡也称为七层负载均衡。主流的Web服务器都支持基于反向代理的负载均衡。

使用Nginx实现反向代理服务器:

upstream backend {
  server 10.0.1.200:80 weight=3;
  server 10.0.1.201:80 weight=1;
}

主流的反向代理服务器HAProxy,略。

接口人瓶颈:反向代理服务器进行转发操作本身是需要一定开销的,比如创建线程、与后端服务器建立TCP连接、接收后端服务器返回的处理结果、分析HTTP头信息、用户空间和内核空间的频繁切换等,当后端服务器处理请求的时间非常短时,转发的开销就显得很突出。 所以,工作在HTTP层面的反向代理服务器扩展能力的制约不仅取决于自身的并发处理能力,同时也取决于其转发开销是否上升为主要时间。(当后端服务器比较少,且处理数据的时间比较短时(比如静态文件),反向代理的转发开销上升为主要时间,极端情况下有可能造成整体的吞吐率不及单台后端服务器)

使用Varnish作为调度器来监控后端服务器的可用性,可脚本编程,判断HTTP状态等,略。

粘滞会话

所谓粘滞会话就是通过调整调度策略,让同一用户在一次会话周期内的所有请求始终转发到一台特定的后端服务器上(毫无规律的转发会使服务器上的缓存利用率下降,此外当后端服务器启用Session来本地化保存用户数据时,如果用户的再次请求被转发到其他的机器,将导致Session数据无法访问)。对于Nginx,只需要在upstream中声明ip_hash即可:

upstream backend {
  ip_hash;
  server 10.0.1.200:80 weight=3;
  server 10.0.1.201:80 weight=1;
}

当然,也可以利用Cookies机制来设计持久性算法,比如将后端服务器的编号追加到用户的Cookies中。

粘滞会话破坏了均衡策略,应该使用分布式Session、分布式缓存。

NAT负载均衡

NAT工作在传输层,可以对数据包中的IP地址和端口信息进行修改,也称四层负载均衡。 Linux内核中的Netfilter模块可以修改IP数据包,它在内核中维护着一些数据包过滤表,这些表包含了用于控制数据包过滤的规则。当网络数据包到达服务器的网卡并且进入某个进程的地址空间之前先要通过内核缓冲区,此时Netfilter便对数据包有着绝对的控制权,可以修改数据包、改变路由规则。 Netfilter位于内核中,Linux提供了命令行工具iptables来对Netfilter的过滤表进行操作。

使用iptables为Web服务器配置防火墙,只允许外部网络通过TCP与当前机器的80端口建立连接:

iptables -F INPUT
iptables -A INPUT -i eth0 -p tcp --dport 80 -j ACCEPT
iptables -P INPUT DROP

使用iptables实现本机端口重定向,将所有从外网进入80端口的请求转移到8000端口:

iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REJECT -- to-port 8000

使用iptables实现NAT,将外网网卡8001端口接收的所有请求转发给10.0.1.210这台服务器的8000端口:

echo 1 > /proc/sys/net/ipv4/ip_forward  # 打开调度器的数据包转发选项
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8001 -j DNAT -- to-destination 10.0.1.210:8000

注意,还需要同时将实际服务器的默认网关设置为NAT服务器(NAT服务器必须为实际服务器的网关):

route add default gw 10.0.1.50

IPVS

IP Virtual Server,类似于Netfilter,也工作于Linux内核中,但是更专注于实现IP负载均衡。不仅可以实现NAT的负载均衡,还可以实现直接路由、IP隧道等负载均衡。 Linux提供ipvsadm工具来管理IPVS,可以用来快速实现负载均衡系统,也称为LVS(Linux Virtual Server):

echo 1 > /proc/sys/net/ipv4/ip_forward  # 打开调度器的数据包转发选项
route add default gw 10.0.1.50  # 将实际服务器的默认网关设置为NAT服务器

ipvsadm -A -t 125.12.12.12:80 -s rr  # 添加一台虚拟服务器(负载均衡调度器)
ipvsadm -a -t 125.12.12.12:80 -r 10.0.1.210:8000 -m  # 实际服务器
ipvsadm -a -t 125.12.12.12:80 -r 10.0.1.211:8000 -m  # 实际服务器

LVS还提供一系列的动态调度策略,如LC(最小连接)、WLC(带权重的最小连接)、SED(最短期望时间延迟)等。

瓶颈:NAT负载均衡服务器的转发能力主要取决于NAT服务器的网络带宽,包括内部网络和外部网络。

直接路由负载均衡

直接路由负载均衡调度器工作在数据链路层(第二层),它通过修改数据包的目标MAC地址将数据包转发到实际服务器上,更重要的是实际服务器的响应数据包直接发送给客户端,而不经过调度器(基于IP别名实现)。实际服务器必须直接接入外部网络。

可以为一个网络接口(物理网卡eth0、eth1,或者回环接口)配置多个IP地址(IP别名),一个网络接口最多可以设置256个IP别名。(即一个网卡可以设置多个IP地址,且它们拥有相同的MAC地址)

ifconfig eth0:0 125.12.12.77

此时通过ifconfig可以看到eth0、eth0:0两个配置。在同网络的另一台机器上查看ARP表有以下记录:

Address        HWtype   HWaddress          Flags Mask        Iface
125.12.12.12   ether    00:19:B9:DF:B0:52  C                 eth0
125.12.12.77   ether    00:19:B9:DF:B0:52  C                 eth0

给实际服务器添加和调度器IP地址相同的IP别名,当调度器收到数据包时,修改数据包的目标MAC地址(IP地址不变),将它转发给实际服务器。

LVS-DR负载均衡系统网络结构: 服务器 外网IP 默认网关 IP别名 负载均衡调度器 125.12.12.12 125.12.12.1 125.12.12.77 实际服务器1 125.12.12.20 125.12.12.1 125.12.12.77 实际服务器2 125.12.12.21 125.12.12.1 125.12.12.77

将站点的域名指向125.12.12.77这个IP别名,同时将IP别名添加到回环接口lo上,并设置路由规则,让服务器不要去寻找其他拥有这个IP别名的服务器:

ifconfig lo:0 125.12.12.77 broadcast 125.12.12.77 netmark 255.255.255.255 up
route add -host 125.12.12.77 dev lo:0

此外还要防止实际服务器响应来自网络中针对IP别名的ARP广播:

echo "1" > /proc/sys/net/ipv4/conf/lo/arp_ignore
echo "2" > /proc/sys/net/ipv4/conf/lo/arp_announce
echo "1" > /proc/sys/net/ipv4/conf/all/arp_ignore
echo "2" > /proc/sys/net/ipv4/conf/all/arp_announce

在调度服务器上通过ipvsadm进行以下配置,设置通过直接路由的方式转发数据包:

ipvsadm -A -t 125.12.12.77:80 -s rr
ipvsadm -a -t 125.12.12.77:80 -r 125.12.12.20:80 -q
ipvsadm -a -t 125.12.12.77:80 -r 125.12.12.21:80 -q

LVS-DR相对于LVS-NAT的优势在于实际服务器的响应数据包可以不经过调度器而直接发往客户端,显然要发挥这种优势需要响应数据包的数量和长度远远大于请求数据包(事实上大多数Web服务的响应和请求并不对称)。 对于LVS-DR,一旦调度器失效,可以通过增加几条DNS记录的方式快速将LVS-DR切换到DNS-RR模式。

IP隧道

LVS-TUN与LVS-DR类似,主要区别在于实际服务器和调度器可以不在同一个WAN网段,调度器通过IP隧道技术来转发请求到实际服务器。即将调度器收到的IP数据包封装在一个新的IP数据包中,转交给实际服务器,然后实际服务器的响应数据包可以直接到达用户端。 前提条件是所有的服务器都必须支持IP Tunneling或者IP Encapsulation协议。

是选择LVS-TUN还是LVS-DR更多地不是因为性能和扩展性,而是取决于网络部署需要,比如CDN服务需要将实际服务器部署在不同的IDC,从而必须使用IP隧道技术。

可用性

使用Heartbeat对主调度器进行心跳检测,一旦发生故障则立即接管。整个过程包括IP别名变更、相关服务的启动等。为了避免主调度器和备用调度器之间线路的单点故障,最好采用多条独立线路进行连接。详略。

第13章 共享文件系统

共享文件系统的意义并不是一个磁盘文件系统,并不能用于存储和管理磁盘数据,而只是定义了文件在网络传输过程中的组织格式和传输协议。一个文件从网络一端到达另一端的过程中需要进行两次格式转换,分别发生在进入网络和离开网络的时候。

NFS

NFS并没有设计自己的传输协议,而是基于RPC协议,工作在应用层,负责客户端和服务器端之间请求与相应数据的传输控制。NFS服务器端和客户端软件包一般在Linux中默认安装。

vi /etc/exports  /data  10.0.1.201(rw,sync)  # 将本机的/data目录共享给10.0.1.201
/etc/init.d/nfsserver startStarting kernel based NFS server done # 启动Server

然后需要在目标机器上执行mount操作,将共享的目录绑定到自己的文件系统中:

mount -t nfs 10.0.1.200:/data /mnt/data

NFS服务器采用多进程模型。

通过nfsstat查看统计信息等,略。

共享文件系统只能同时为很少的服务器提供文件共享服务,其本身就是一个不强调扩展性的概念。

第14章 内容分发和同步

SSH

Secure Shell是建立在应用层和传输层基础上的安全协议,可以用于传输任何数据,包括文件复制。主要优点是安全,且会对传输的数据进行压缩。

SCP、SFTP

两个PECL扩展,略。

WebDAV

WebDAV是HTTP的扩展协议,它允许基于HTTP/1.1协议来对Web服务器进行远程文件操作,包括文件和目录的创建、修改,以及版本控制等(SVN的HTTP工作方式)。 目前主流的Web服务器软件都支持WebDAV扩展。

创建目录的请求:

MKCOL /files/2009/ HTTP/1.1
Host:www.xxx.com

创建成功的响应:

HTTP/1.1 201 Created

rsync

SCP和WebDAV属于主动分发方式的文件复制,Linux的rsync工具属于被动同步的方式,即接收文件的一端主动向服务器发起同步请求,并根据两端文件列表的差异有选择地进行更新。 可以配置crontab脚本定期同步。 (详略)

inotify

Linux的inotify模块基于Hash Tree(即一旦某个文件发生更新,就更新从它开始至根目录的整个路径上的所有目录)来监控文件的更改,以此提高文件更新的效率。原生提供C语言的API,PECL有相应的扩展。

第15章 分布式文件系统

分布式文件系统并不是传统意义上的文件系统,它工作在操作系统的用户空间由应用程序实现,这使得分布式文件系统可以不依赖于底层文件系统的具体实现,只要是POSIX兼容的文件系统即可。

分布式文件系统拥有自己独特的内容组织结构,便于对大规模存储和复制进行合理规划,例如Hadoop:

hadoop dfs -ls
hadoop dfs -mkdir html
hadoop dfs -put /data/www/htdocs/index.htm html/
...

MogileFS,基于perl实现,略。

存储节点与追踪器

分布式文件系统内部可能跨越多个服务器,并根据规则进行自动的文件复制。在分布式文件系统中,所有的文件都存储在被称为存储节点的地方,一个存储节点往往对应物理磁盘上的一个实际目录。追踪器负责存储节点之间的调度(负载均衡、故障转移、控制复制策略等),并响应用户的请求。

分布式文件系统的好处

  1. 组建包含大量廉价服务器的海量存储系统;
  2. 通过内部的冗余复制,保证文件的可用性;
  3. 拥有非常好的可扩展性;
  4. 可以通过扩展来保证性能;

详略。

第16章 数据库扩展

复制和分离

(主从复制、读写分离,略)

MySQL Proxy做数据库反向代理,使用LUA脚本控制转发规则(比如实现读写分离等),略。

垂直分区

对于数据库写操作频繁的站点,仅仅采用主从复制和读写分离效果并不好,因为从服务器将花费更多的时间用来同步数据,因此用来出来查询请求的时间就变少了,这时增加从服务器所获得的回报也将越来越小。

垂直分区:将不同业务的数据库(不需要JOIN查询)分不到不同的服务器上。

水平分区

水平分区:将同一个数据表中的记录通过特定的算法进行分离,分别保存在不同的数据表中,从而可以部署在不同的数据库服务器上。

比如原本有一个博客表,现在可以按照用户ID的奇偶性将所有用户划分为两部分,并分别存储到不同的数据库服务器上。当然在查询时需要额外传递用户ID参数用于进行奇偶性判断。

<?php
  // http://api.xxx.com/blog_post.php?post_id=123&user_id=321

  $db = new DataAccess($user_id);
  $db->selectDb("db_blog");
  $sql = "select * from tpl_posts where post_id=" . $post_id";
  $result = $db->query($sql);

分表

实际上在考虑水平分区之前一般会对数据库进行分表,比如按user_id%10将原来的表分为10个表。 分表属于单台数据库的优化策略,当已经实现了分表策略时,将可以更容易地实现水平分区,因为数据已经是分离的,只需要迁移到其他服务器。

分表算法和分区算法可能不一致(比如将分表得到的10个表分布在2台机器上),因此需要在应用程序中维护一份映射关系:

<?php 
  $db = new DataAccess($user_id);  // 分区逻辑
  $db->selectDb("db_blog");
  $tbl_name = getTblName($user_id); // 用户ID
  $sql = "select * from " .$tbl_name. " where post_id=" . $post_id";
  $result = $db->query($sql);
?>

显然用于分区的字段不能是auto_increment自增类型。

常见的分区算法有:

  1. 哈希,如user_id%10,这种方法不太友好,当扩展节点数时,所有数据需要重新分区;
  2. 范围,扩展性较好,但是会造成各个分区的工作量存在较大差异,比如老用户所在分区压力相对较大;
  3. 映射关系,将分区映射关系存到数据库中,当确定在哪个分区时,需要通过查询数据库(当然需要使用缓存)来获得答案;这种方案最可控;

分区反向代理

Spock Proxy基于MySQL Proxy实现,可以帮助应用程序实现水平分区的访问调度,意味着无需再在应用程序中维护那些分区对应关系。

第17章 分布式计算

异步计算

分布式消息队列,Gearman,MemcacheQ详略。 只使用异步计算并没有减少任务的计算时间。

并行计算

将计算任务拆分、计算、合并。当然不是所有任务都适合拆分,也很难设计出一套通用的、具体的并行计算方法。

分布式并行计算开发框架Map/Reduce,Hadoop的实现,略。

第18章 性能监控

Nmon,工作在服务器本地的实时监控软件; Cacti; jiankongbao;


微信公众号:时空波隐者
文章目录