计算机存储与I/O系统基础原理笔记

一、存储器层次结构

1. CPU中的寄存器(Register)与其说是存储器,其实更像是CPU本身的一部分,只能存放极其有限的信息,但是速度非常快,和CPU同步。而CPU Cache(CPU高速缓存)用的是一种叫作SRAM(Static Random-Access Memory,静态随机存取存储器)的芯片。SRAM之所以被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在SRAM里面一个比特的数据需要6~8个晶体管。所以SRAM的存储密度不高,同样的物理空间下,能够存储的数据有限。不过,因为SRAM的电路简单,所以访问速度非常快

在CPU里通常会有L1、L2、L3这样三层高速缓存。每个CPU核心都有一块属于自己的L1 高速缓存,通常分成指令缓存和数据缓存,分开存放CPU使用的指令和数据,这里的指令缓存和数据缓存其实就是来自于哈佛架构。L1的Cache往往就嵌在CPU核心的内部。L2的Cache同样是每个CPU核心都有的,不过它往往不在CPU核心内部,所以L2 Cache的访问速度会比L1稍微慢一些。而L3 Cache则通常是多个CPU核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。可以把L1 Cache理解为大脑短期记忆,把L2/L3 Cache理解成长期记忆,把内存当成拥有的书架或者书桌。数据从内存中加载到CPU的寄存器和Cache 中,然后通过CPU进行处理和运算

2. 内存用的芯片和Cache有所不同,它用的是一种叫作DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起SRAM来说它的密度更高,有更大的容量,而且也比SRAM芯片便宜不少。DRAM被称为“动态”存储器,是因为DRAM需要靠不断地“刷新”,才能保持数据被存储起来。DRAM的一个比特,只需要一个晶体管和一个电容就能存储。所以DRAM在同样的物理空间下,能够存储的数据也就更多,也就是存储的“密度”更大。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM的数据访问电路和刷新电路都比SRAM更复杂,所以访问延时也就更长。各个存储介质的关系图如下所示:

CPU并不是直接和每一种存储器设备打交道,而是每一种存储器设备只和它相邻的存储设备打交道。比如CPU Cache是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到CPU Cache中,而是先加载到内存,再从内存加载到Cache

二、局部性原理与缓存

3. 进行服务端软件开发的时候,通常会把数据存储在数据库里。而服务端系统遇到的第一个性能瓶颈,往往就发生在访问数据库的时候。这个时候往往会通过Redis或者Memcache这样的开源软件,在数据库前面提供一层缓存的数据,来缓解数据库面临的压力,提升服务端的程序性能。

尽管CPU缓存、内存、硬盘之间的容量、价格与速度差距巨大,用户依然希望既享受CPU Cache的速度,又享受内存、硬盘巨大的容量和低廉的价格。想要同时享受到这几点,前辈们探索出的答案就是存储器中数据的局部性原理(Principle of Locality)。可以利用这个局部性原理来制定管理和访问数据的策略,包括时间局部性(temporal locality)和空间局部性(spatial locality)这两种策略。

(1)时间局部性。指如果一个数据被访问了,那么它在短时间内还会被再次访问。比如在一个电商App中,用户看到了首屏,可以推断他应该很快还会再次访问购物的其他内容或者页面,就将这个用户的个人信息,从存储在硬盘的数据库读取到内存的缓存中来。

(2)空间局部性。指如果一个数据被访问了,那么和它相邻的数据也很快会被访问。例如一个程序在访问了数组的首项之后,多半会循环访问它的下一项。因为在存储数据的时候,数组内的多项数据会存储在相邻的位置。

因此有了时间局部性和空间局部性的特性,就不用为了追求速度把所有数据都放在内存里,而是把访问次数多的数据,放在贵但是快一点的存储器里,把访问次数少的数据,放在慢但是大一点的存储器里。这样组合使用内存、SSD以及HDD,可以用最低的成本提供实际所需要的数据存储、管理和访问的需求。

例如要提供一个电商网站。假设里面有6亿件商品,如果每件商品需要4MB的存储空间,那么一共需要2400TB的数据存储。如果把数据都放在内存里面,那就需要约3600万美元。但是这6亿件商品中,不是每一件商品都会被经常访问。如果只在内存里放前1%的热门商品,而把剩下的商品放在机械式的HDD硬盘上,那么需要的存储成本就下降到约45.6万美元,是原来成本的1.3%左右。

这里用的就是时间局部性。把有用户访问过的数据加载到内存中,一旦内存里面放不下了,就把最长时间没有在内存中被访问过的数据从内存中移走,这个其实就是常用的LRU(Least Recently Used)缓存算法。热门商品被访问得多,就会始终被保留在内存里,而冷门商品被访问得少,就只存放在HDD硬盘上,数据的读取也都是直接访问硬盘,即使加载到内存中,也会很快被移除。越是热门的商品,越容易在内存中找到,也就更好地利用了内存的随机访问性能

但是内容的响应速度和LRU的缓存命中率(Hit Rate/Hit Ratio)有关,也就是访问的数据中,可以在设置的内存缓存中找到的,占有多大比例。内存的随机访问请求一次需要约100ns。这也就意味着,在极限情况下内存可以支持 每秒1000万次随机访问。如果数据没有命中内存,那么对应的数据请求就要访问到HDD磁盘了,而HDD只能支撑每秒约100次的随机访问。

因此局部性原理是计算机各类优化的基石,小到cpu cache,大到cdn。而且不仅仅是存储,java的jit也是利于局部性优化性能。任何东西只要不是均匀分布的,就有优化空间

4. 关于CPU缓存,先来看下面的代码例子:

int[] arr = new int[64 * 1024 * 1024];

// 循环1
for (int i = 0; i < arr.length; i++) arr[i] *= 3;

// 循环2
for (int i = 0; i < arr.length; i += 16) arr[i] *= 3

在循环1里,遍历整个数组将数组中每一项的值变成了原来的3倍;在循环2里,每隔16个索引访问一个数组元素,将这一项的值变成了原来的3倍。按道理来说,循环2只访问循环1中1/16的数组元素,只进行了循环1中1/16的乘法计算,那循环2花费的时间应该是循环1的1/16左右。但实际上循环1在电脑上运行花了50毫秒,循环2却花了46毫秒,这两个循环花费时间之差在15%之内。这和CPU Cache有关。

按照摩尔定律,CPU的访问速度每18个月便会翻一番,相当于每年增长60%。内存的访问速度虽然也在不断增长,却远没有这么快,每年只增长7%左右。而这两个增长速度的差异,使得CPU性能和内存访问性能的差距不断拉大。今天一次内存的访问,大约需要120个 CPU Cycle,所以CPU和内存的访问速度已经有了120倍的差距。为了弥补两者之间的性能差异,把CPU的性能提升用起来,而不是让它在那儿空转,现代CPU中引入了高速缓存

自从CPU Cache被加入到CPU里后,内存中的指令、数据,会被加载到L1-L3 Cache中,而不是直接由CPU访问内存。在95%的情况下CPU都只需要访问L1-L3 Cache从里面读取指令和数据,而无需访问内存。这里的CPU Cache不是一个单纯的、概念上的缓存(比如拿内存作为硬盘的缓存),而是指特定的SRAM组成的物理芯片

因此在上面的程序中,运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到CPU Cache。CPU从内存中读取数据到CPU Cache的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在CPU Cache里面叫作Cache Line(缓存块)。在Intel服务器或者PC里,Cache Line的大小通常是64字节。而在上面的循环2里面每隔16个整型数计算一次,16个整型数正好是64个字节。于是循环1和循环2,需要把同样数量的Cache Line数据从内存中读取到CPU Cache中,最终两个程序花费的时间就差别不大了。

5. 现代CPU进行数据读取的时候,无论数据是否已经存储在Cache中,CPU始终会首先访问Cache。只有当CPU在Cache中找不到数据的时候,才会去访问内存,并将读取到的数据写入Cache之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而Cache的访问速度远快于内存,这样CPU花在等待内存访问上的时间就大大变短了,如下所示:

那CPU如何知道要访问的内存数据存储在Cache的哪个位置呢?这要从直接映射Cache(Direct Mapped Cache)说起。因为CPU访问内存数据是一小块一小块数据来读取的。对于读取内存中的数据,首先拿到的是数据所在的内存块(Block)地址。而直接映射Cache采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的CPU Cache地址(Cache Line)。而这个映射关系,通常用mod运算(求余运算)来实现

例如主内存被分成0~31号这样32个块。一共有8个缓存块,用户想要访问第21号内存块。如果21号内存块内容在缓存块中的话,它一定在5号缓存块(21 mod 8 = 5)中,如下所示:

实际计算中为了取巧,通常会把缓存块的数量设置成2的N次方。这样在计算取模的时候,可以直接取地址的低N位,也就是二进制里面的后几位。比如这里的8个缓存块,就是2的3次方。那么在对21取模的时候,可以对21的二进制表示10101取地址的低三位,也就是101对应十进制的5,就是对应的缓存块地址。取Block地址的低位,就能得到对应的Cache Line地址,除了21号内存块外,13号、5号等很多内存块的数据都对应着5号缓存块中。如下所示:

既然多个block地址在5号缓存块里,怎么知道读取时里面的数据是不是21号对应的数据呢?这时候在对应的CPU缓存块中,会存储一个组标记(Tag),这个组标记会记录当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示内存访问地址的低N位。例如21的低3位101,缓存块本身的地址已经涵盖了对应的信息、对应的组标记,只需要记录21剩余的高2位的信息,也就是10就可以了。

除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的数据,另一个是有效位(valid bit)。它其实就是用来标记,对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据如果有效位是0,无论其中的组标记和Cache Line里的数据内容是什么,CPU都不会管这些数据,会直接访问内存重新加载数据。CPU在读取数据的时候,并不是要读取一整个Block,而是读取一个需要的整数。这样的数据叫作CPU里的一个(Word)。具体是哪个字,就用这个字在整个Block里面的位置来决定,这个位置叫作偏移量(Offset)。因此,一个内存的访问地址,最终在CPU Cache中的映射包括高位代表的组标记、低位代表的索引,以及在对应Data Block中定位对应字的位置偏移量。如下所示:

如果内存中的数据已经在CPU Cache里了,那一个内存地址的访问就会经历这样4个步骤:

(1)根据内存地址的低位,计算在Cache中的索引;

(2)判断有效位,确认Cache中的数据是有效的;

(3)对比内存访问地址的高位,和Cache中的组标记,确认Cache中的数据就是要访问的内存数据,从Cache Line中读取到对应的数据块(Data Block);

(4)根据内存地址的Offset位,从Data Block中读取希望读取到的字。

如果在中间两个步骤中,CPU发现Cache中的数据并不是要访问的内存地址的数据,那CPU就会访问内存并把对应的Block Data更新到Cache Line中,同时更新对应的有效位和组标记的数据。当然,现代CPU已经很少使用直接映射Cache的方法了,通常用的是组相连Cache(set associative cache)。一般二维数组在内存中存放是按行来优先存放的,所以在加载数据时候就会把一行数据加载进Cache里,这样Cache的命中率就大大提高。如果按列迭代cache就很难命中,从而CPU就要经常从内存中读数据。

6. 对于Java中的volatile关键字,有以下的2种误解:

// 一种错误的理解,是把volatile关键词,当成是一个锁,可以把long/double这样的数的操作自动加锁
private volatile long synchronizedValue = 0;

// 另一种错误的理解,是把volatile关键词,当成可以让整数自增的操作也变成原子性的
private volatile int atomicInt = 0;
amoticInt++;

实际上,volatile关键字的核心知识点,要关系到Java内存模型(JMM,Java Memory Model)。虽然JMM只是Java虚拟机这个进程级虚拟机里的一个内存模型,但是这个内存模型,和计算机组成原理中的CPU、高速缓存和主内存组合在一起的硬件体系非常相似。理解了JMM可以很容易理解计算机组成里CPU、高速缓存和主内存之间的关系。以下代码例子可以说明volatile真正的作用:

public class VolatileTest {
    private static volatile int COUNTER = 0;

    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }

    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
            }
        }
    }

    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}

可以看到分别启动了两个单独的线程ChangeListener和ChangeMaker。ChangeListener线程运行时先取COUNTER当前的值,然后一直监听着这个COUNTER的值。一旦COUNTER的值发生了变化,就把新的值打印出来,直到COUNTER的值达到5为止。这个监听的过程,通过一个永不停歇的while循环等待来实现。ChangeMaker线程运行时同样是取到COUNTER的值,在COUNTER小于5时每隔500毫秒,就让COUNTER自增1。在自增之前把自增后的值打印出来。运行结果如下所示:

Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5

此时如果把上面的程序修改一行代码,把定义COUNTER这个变量时设置的volatile关键字给去掉:

private static int COUNTER = 0;

运行结果会完全不同,ChangeMaker还是能正常工作,每隔500ms仍然能够对COUNTER自增1。但是ChangeListener不再工作了,它似乎一直觉得COUNTER的值还是一开始的0,如下所示:

Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5

如果再对程序做一些修改,不再让ChangeListener进行完全的忙等待,而是在while循环里面等待上5毫秒,看看会发生什么情况:

static class ChangeListener extends Thread {
    @Override
    public void run() {
        int threadValue = COUNTER;
        while ( threadValue < 5){
            if( threadValue!= COUNTER){
                System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
                threadValue= COUNTER;
            }
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) { e.printStackTrace(); }
        }
    }
}

有了沉睡5ms的逻辑后,虽然COUNTER变量仍然没有设置volatile这个关键字,但是ChangeListener又能够正常取到 COUNTER 的值了,运行结果如下:

Incrementing COUNTER to : 1
Sleep 5ms, Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Sleep 5ms, Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Sleep 5ms, Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Sleep 5ms, Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Sleep 5ms, Got Change for COUNTER : 5

这些有趣现象,其实来自于Java内存模型以及关键字volatile的含义。volatile关键字会确保对于这个变量的读取和写入,都一定会同步到主内存里,而不是从Cache里面读取。一开始第一个使用了volatile关键字的例子里,因为所有数据的读和写都来自主内存,那么自然ChangeMaker和ChangeListener之间,看到的COUNTER值就是一样的。

到了第二个例子去掉volatile的时候,ChangeListener又是一个忙等待的循环,它尝试不停地获取COUNTER的值,这样就会从当前线程的“Cache”里面获取。于是,这个线程就没有时间从主内存里面同步更新后的COUNTER。这样,它就一直卡死在COUNTER=0的死循环上了。

而到了再次修改的第三段代码里面,虽然还是没有使用volatile关键字,但是短短5ms的Thead.Sleep给了这个线程喘息之机。既然这个线程没有这么忙了,它就有机会把最新的数据从主内存同步到自己的高速缓存里面了。于是ChangeListener在下一次查看COUNTER值的时候,就能看到ChangeMaker造成的变化了。

7. volatile关键字在用C语言编写嵌入式软件里面用得很多,不使用volatile关键字的代码比使用volatile关键字的代码效率要高一些,但就无法保证数据的一致性。volatile的本意是告诉编译器,此变量的值是易变的,每次读写该变量的值时务必从该变量的内存地址中读取或写入,不能为了效率使用对一个“临时”变量的读写来代替对该变量的直接读写。

编译器看到了volatile关键字,就一定会生成内存访问指令,每次读写该变量就一定会执行内存访问指令直接读写该变量。若是没有volatile关键字,编译器为了效率,只会在循环开始前使用读内存指令将该变量读到寄存器中,之后在循环内都是用寄存器访问指令来操作这个“临时”变量,在循环结束后再使用内存写指令将这个寄存器中的“临时”变量写回内存。在这个过程中,如果内存中的这个变量被别的因素(其他线程、中断函数、信号处理函数、DMA控制器、其他硬件设备)所改变了,就产生数据不一致的问题

另外,寄存器访问指令的速度要比内存访问指令的速度快,这里说的内存也包括缓存,也就是说内存访问指令实际上也有可能访问的是缓存里的数据,但即便如此,还是不如访问寄存器快的缓存对于编译器也是透明的,编译器使用内存读写指令时只会认为是在读写内存,内存和缓存间的数据同步由CPU保证。虽然Java内存模型是一个隔离了硬件实现的虚拟机内的抽象模型,但是它给了一个很好的“缓存同步”问题的示例。也就是说如果数据在不同的线程或者CPU核里面去更新,因为不同的线程或CPU核有着自己各自的缓存,很有可能在A线程的更新,到B线程里面是看不见的

8. 有了上面的例子,事实上可以把Java内存模型和计算机组成里的CPU结构对照起来看。现代Intel CPU通常都是多核的,每一个CPU核里面都有独立属于自己的L1、L2的Cache,还有多个CPU核共用的L3 Cache和主内存。因为CPU Cache的访问速度要比主内存快很多,而在CPU Cache里L1/L2的Cache也要比L3的Cache快,所以CPU始终都是尽可能地从CPU Cache中获取数据,而不是每一次都要从主内存里面去读取数据,如下所示:

这个层级结构,就好像在Java内存模型里面,每一个线程都有属于自己的线程栈。在没有volatile关键字时,线程在读取COUNTER的数据时,其实是从本地线程栈的Cache副本里面读取数据,而不是从主内存里面读取数据。如果对于数据仅仅只是读问题还不大,因为Cache Line的存在会从内存里面把对应的数据加载到Cache里。

9. 但是对于数据,不光要读还要去写入修改。这个时候有这样的问题:写入Cache的性能比写入主内存要快,那写入的数据到底应该写到Cache里还是主内存呢?如果直接写入到主内存里,Cache里的数据是否会失效呢?

为了解决这个问题,最简单的一种写入策略叫作写直达(Write-Through)。在这个策略里,每一次数据都要写入到主内存里面。写入前会先去判断数据是否已经在Cache里面,如果数据已经在Cache里了,就先把数据写入更新到Cache里面,再写入到主内存里面;如果数据不在Cache里,就只更新主内存。写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在Cache里面,都需要把数据写到主内存里面。这个方式就有点儿像上面的volatile关键字,始终都要把数据同步到主内存里面

但是既然去读数据也是默认从Cache里面加载,能否不用把所有的写入都同步到主内存里,只写入CPU Cache里呢?当然是可以的。在CPU Cache的写入策略里,还有一种就叫作写回(Write-Back)。这个策略里不再是每次都把数据写入到主内存,而是只写到CPU Cache里,只有当CPU Cache里面的数据要被“替换”的时候,才把数据写入到主内存里面去。如下所示:

(1)如果发现要写入的数据就在CPU Cache里面,那么就只是更新CPU Cache里面的数据,同时会标记CPU Cache里的这个Block是脏(Dirty)的,所谓脏的就是指这个时候CPU Cache里面的这个Block的数据,和主内存是不一致的。

(2)如果发现要写入的数据所对应的Cache Block里,放的是别的内存地址的数据,那么就要看一看那个Cache Block里的数据有没有被标记成脏的,如果是脏的要先把这个Cache Block里面的数据写入到主内存里面,然后再把当前要写入的数据写入到Cache里,同时把Cache Block标记成脏的。

(3)如果Block里面的数据没有被标记成脏的,那么直接把数据写入到Cache里面,然后再把Cache Block标记成脏的就好了。

用了写回这个策略之后,在加载内存数据到Cache里面的时候,也要多出一步同步脏Cache的动作如果加载内存里的数据到Cache时,发现Cache Bloc里面有脏标记,我们要先把Cache Block里的数据写回到主内存,才能加载数据覆盖掉Cache可以看到,如果大量的操作都能够命中缓存。那么大部分时间里都不需要读写主内存,自然性能会比写直达的效果好很多

10. 然而,无论是写回还是写直达,其实都还没有解决上面volatile程序示例中遇到的问题,也就是多个线程或者是多个CPU核的缓存一致性的问题。这也就是在写入修改缓存后,需要解决的另一个问题。CPU Cache解决的是内存访问速度和CPU的速度差距太大的问题。而多核CPU提供的是在主频难以提升的时候,通过增加CPU核心来提升CPU吞吐率的办法。把多核和CPU Cache两者一结合,就带来了一个新的挑战:因为CPU的每个核各有各的缓存,互相之间的操作又是各自独立的,就会带来缓存一致性(Cache Coherence)的问题。

那什么是缓存一致性问题呢?比如说一个有两个核心的 CPU:

比方说iPhone降价了,要把iPhone最新的价格更新到内存里。为了性能问题它采用了写回策略,先把数据写入到L2 Cache里面,然后把Cache Block标记成脏的。这个时候,数据其实并没有被同步到L3 Cache或者主内存里。1号核心希望在这个Cache Block要被交换出去的时候,数据才写入到主内存里。这个时候,2号核心尝试从内存里面去读取iPhone的价格,结果读到的是一个错误的价格。这是因为iPhone的价格刚刚被1号核心更新过。但是这个更新的信息,只出现在1号核心的L2 Cache里,而没有出现在2号核心的L2 Cache或者主内存里面。这个问题就是所谓的缓存一致性问题,1号核心和2号核心的缓存在这个时候是不一致的

为了解决这个缓存不一致的问题,就需要有一种机制来同步两个不同核心里面的缓存数据。这样的机制能够做到下面两点就是合理的:

(1)写传播(Write Propagation)。在一个CPU核心里,Cache数据更新必须能够传播到其他的对应节点的Cache Line里。

(2)事务的串行化(Transaction Serialization)。在一个CPU核心里面的读取和写入,在其他的节点看起来顺序是一样的。关于事务串行化,例如一个有4个核心的CPU,1号核心先把iPhone的价格改成了5000块。差不多在同时,2号核心把iPhone的价格改成了6000块,这里两个修改都会传播到3号核心和4号核心,如下所示:

然而这里有个问题,3号核心先收到了2号核心的写传播,再收到1号核心的写传播。所以3号核心看到iPhone价格是先变成了6000块,再变成了5000块。而4号核心是反过来的,先看到变成了5000块再变成6000块。虽然写传播是做到了,但是各个Cache里面的数据是不一致的。事实上需要的是,从1号到4号核心,都能看到相同顺序的数据变化。比如都是先变成了5000块再变成了6000块,这样才能称之为实现了事务的串行化。

三、MESI协议

11. 事务的串行化不仅仅是缓存一致性中所必须的。比如平时所用到的系统当中,最需要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,必须保障事务的串行化,做不到事务的串行化的数据库根本没法作为可靠的商业数据库来使用。而在CPU Cache里做到事务串行化,需要做到两点:

(1)一个CPU核心对于数据的操作,需要同步通信给到其他CPU核心

(2)如果两个CPU核心里有同一个数据的Cache,那么对于这个Cache数据的更新需要有一个“锁”的概念。只有拿到了对应Cache Block的“锁”之后,才能进行对应的数据更新

要解决缓存一致性问题,首先要解决的是多个CPU核心之间的数据传播问题。最常见的一种解决方案叫作总线嗅探(Bus Snooping)。这个策略本质上就是把所有的读写请求都通过总线(Bus)广播给所有的CPU核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。总线本身就是一个特别适合广播进行数据传输的机制,所以总线嗅探这个办法也是Intel CPU进行缓存一致性处理的解决方案。基于总线嗅探机制,其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的叫作MESI协议。这是一个维护缓存一致性协议,不仅可以用在CPU Cache之间,也可以广泛用于各种需要使用缓存,同时缓存之间需要同步的场景下

MESI协议是一种叫作写失效(Write Invalidate)的协议。在写失效协议里只有一个CPU核心负责写入数据,其他的核心只是同步读取到这个写入。在这个CPU核心写入Cache之后,它会去广播一个“失效”请求告诉所有其他CPU核心。其他的CPU核心只是去判断自己是否也有一个“失效”版本的Cache Block,然后把这个block也标记成失效的就好了。写失效的协议的好处是,不需要在总线上传输数据内容,而只需要传输操作信号和地址信号就好了,不会那么占总线带宽。

相对于写失效协议,还有一种叫作写广播(Write Broadcast)的协议。在这个协议里,一个写入请求广播到所有的CPU核心,同时更新各个核心里的Cache。写广播在实现上很简单,但是需要占用更多的总线带宽。而写失效只需要告诉其他CPU核心,哪一个内存地址的缓存失效了,但是写广播还需要把对应的数据传输给其他CPU核心。如下所示:

12. MESI协议的名字来自于对Cache Line的四个不同的标记,分别是:

(1)M:代表已修改(Modified)。

(2)E:代表独占(Exclusive)。E状态的缓存和主内存是一样的。

(3)S:代表共享(Shared)。

(4)I:代表已失效(Invalidated)。

“已修改”和“已失效”这两个状态比较容易理解。所谓的“已修改”就是“脏”的Cache Block,即Cache Block里面的内容已经更新过了,但是还没有写回到主内存里面。而所谓的“已失效“,自然是这个Cache Block里面的数据已经失效了,不可以相信这个Cache Block里面的数据。

而“独占”和“共享”这两个状态就是MESI协议的精华所在。无论是独占状态还是共享状态,缓存里面的数据都是“干净”的也就是说这个时候Cache Block里面的数据和主内存里面的数据是一致的。在独占状态下,对应的Cache Line只加载到了当前CPU核所拥有的Cache里,其他的CPU核并没有加载对应的数据到自己的Cache。这时如果要向独占的Cache Block写入数据,就可以自由地写入数据,而不需要告知其他CPU核。在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,此时另外一个CPU核心也把对应的Cache Block,从内存里面加载到了自己的Cache里来

而在共享状态下,因为同样的数据在多个CPU核心的Cache里都有。所以当想要更新Cache里面的数据时,不能直接修改,而是要先向所有的其他CPU核心广播一个请求,要求先把其他CPU核心里面的Cache都变成无效的状态,然后再更新当前Cache里面的数据。这个广播操作,一般叫作RFO(Request For Ownership),也就是获取当前对应Cache Block数据的所有权。这个操作有点儿像在多线程里面用到的读写锁,在共享状态下各线程都可以并行去读对应的数据。但是如果要写,就需要通过一个锁获取当前写入位置的所有权。

对于不同状态触发的事件操作,可能来自于当前CPU核心,也可能来自总线里其他CPU核心广播出来的信号。独占和共享状态,就好像在多线程应用开发里面的读写锁机制,确保了缓存一致性。而整个MESI的状态变更,则是根据来自自己CPU核心的请求,以及来自其他CPU核心通过总线传输过来的操作信号和地址信息,进行状态流转的一个有限状态机。如下所示:

13. 关于上述的缓存一致性,再回到Java中的volatile变量,它修饰的共享变量在进行写操作时候会多出一行汇编:

```
    0x01a3de1d:movb $0×0,0×1104800(%esi);0x01a3de24:lock addl $0×0,(%esp);
    ```

lock前缀的指令在多核处理器下会:

(1)将当前CPU核缓存的数据写回到系统内存。

(2)这个写回内存的操作会使其他CPU核里缓存了该内存地址的数据无效。

对于多核CPU的总线嗅探来说,为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但写回操作不知道这个更改何时回写到内存。对变量使用volatile进行写操作时,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在的cache line的数据写回到系统内存

在多核处理器中,为了保证各个核的缓存一致性,每个核通过嗅探在总线上传播的数据类型来检查自己的缓存值是否过期了,如果某个核发现自己cache line对应的内存地址被修改,就会将当前核的cache line设置为无效状态,就相当于写回时发现状态标识为0失效,当这个核对数据进行修改操作时,会重新从系统内存中读取数据到CPU缓存中

四、内存

14. 计算机有五大组成部分,分别是:运算器、控制器、存储器、输入设备和输出设备。如果说计算机最重要的组件,是承担了运算器和控制器作用的CPU,那内存就是第二重要的组件了。内存是五大组成部分里面的存储器,指令和数据都需要先加载到内存里面,才会被CPU拿去执行。根据程序装载到内存的过程可以知道,在日常使用的Linux或者Windows操作系统下,程序并不能直接访问物理内存内存需要被分成固定大小的页(Page),然后再通过虚拟内存地址(Virtual Address)到物理内存地址(Physical Address)的地址转换(Address Translation),才能到达实际存放数据的物理内存位置。而程序看到的内存地址,都是虚拟内存地址。

那么虚拟内存地址是怎么转换成物理内存地址的呢?最直观的办法就是建一张映射表。这个映射表能够实现虚拟内存里的页,到物理内存里的页的一一映射。这个映射表在计算机里就叫作页表(Page Table)。页表会把一个虚拟内存地址分成页号(Directory)和偏移量(Offset)两个部分。以一个32位的内存地址为例,其实前面的高位就是内存地址的页号,后面的低位就是内存地址里面的偏移量。做地址转换的页表,只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存,在物理层面是连续的。以一个页的大小是4K字节(4KB)为例,就需要20位的高位和12位的低位。如下所示:

因此,对于一个内存地址转换,其实就是这样三个步骤:(1)把虚拟内存地址,切分成页号和偏移量的组合;(2)从页表里面,查询出虚拟页号,对应的物理页号;(3)直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。如下所示

当然只是这样简单会有页表占用空间的问题。以32位的内存地址空间为例,页表一共需要记录2^20个到物理页号的映射关系,就好比一个2^20大小的数组。一个页号是完整的32位的4字节(Byte),这样一个页表就需要4MB的空间(因为32位计算机系统的物理页一页保存信息是4K比特,如果需要找到第4K比特上面的数据,2^12 = 4096,最多需要12位才能找到第4K比特上的那个比特值0或者1,所以偏移量需要12位,剩下的高位为32-12 =20位,所以数据长度为2^20=1048576,每个数组中只需要保存物理页号信息就行了,虚拟页号高位是从全0到全1的,每个页号保存的也是32位=4个字节(1字节8位),所以总共大小为1048576 * 4字节 = 4194304字节 = 4194KB = 4M)。不过,这个空间不是只占用一份,每一个进程都有属于自己独立的虚拟内存地址空间。这也就意味着,每一个进程都需要这样一个页表。对于计算机众多的进程来说,这样简单实现的页表占用的内存就太大了。

15. 然而,计算机其实没有必要存下这2^20个物理页表的映射,大部分进程所占用的内存是有限的,需要的页也自然是很有限的,只需要去存那些用到的页之间的映射关系就好了。在实践中,采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。一个进程的内存地址空间分配,通常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间内存地址则是从底部往上不断分配占用的。所以,在一个实际的程序进程里面,虚拟内存占用的地址空间通常是两段连续的空间,而不是完全散落的随机内存地址。而多级页表,就特别适合这样的内存地址分布。

以一个4级的多级页表为例,同样一个虚拟内存地址,偏移量的部分和上面简单页表一样不变,但是原先的页号部分把它拆成四段,从高到低分成4级到1级这样4个页表索引,如下所示:

对应的,一个进程会有一个4级页表。先通过4级页表索引找到4级页表里面对应的条目(Entry)。这个条目里存放的是一张3级页表所在的位置。4级页面里面的每一个条目都对应着一张3级页表,所以可能有多张3级页表。找到对应这张3级页表之后,用3级索引去找到对应的3级索引条目。3级索引的条目再会指向一个2级页表。同样2级页表里可以用2级索引指向一个1级页表。而最后一层的1级页表里面的条目,对应的数据内容就是物理页号了。在拿到物理页号之后,同样可以用“页号 + 偏移量”的方式,来获取最终的物理内存地址。

因为进程中实际的虚拟内存空间通常是连续的,很可能只需要很少的2级页表,甚至只需要1张3级页表就够了。多级页表就像一个多叉树的数据结构,所以常常称它为页表树(Page Table Tree)。因为虚拟内存地址分布的连续性,树的第一层节点的指针很多就是空的,也就不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的2级、3级的页表。因为栈的虚拟内存地址是从上往下存储,堆是内存地址是从下往上存储,例如栈占用512KB,堆占用另外512KB,所以不太可能在同一个3级索引表中,索引表中没有子节点的就不用创建他的子节点索引表了,只有存在才会创建,所以每一级索引表不一定都是32位,部分位置上是可以空出来的。找到最终的物理页号,就好像通过一个特定的访问路径,走到树最底层的叶子节点。如下所示:

以这样分成4级的多级页表来看,每一级索引表的地址如果都用5个比特表示,那么每一张某一级的页表只需要2^5=32个条目。如果每个索引条目地址还是4个字节(32位),那么一张索引表一共需要128个字节,而一个1级索引表对应32个4KB的页也就是能索引128KB的内存页。一个填满的2级索引表,对应的就是32个1级索引表,也就是能索引4MB大小的内存页。一个进程如果占用了8MB的内存空间,分成了2个4MB的连续空间(栈和堆)。那么,它一共需要2个独立的、填满的2级索引表,也就意味着64个1级索引表,2个独立的3级索引表(栈和堆的存储地址不同基本上不在同一个索引表里),1个4级索引表。一共需要69个索引表,每个128字节,四级索引表总共大概占9KB的空间。比起上面简单实现的4MB单级页表来说,只有差不多1/500。

16. 在优化页表的过程中,可以看到数组这样的紧凑的数据结构,以及树这样稀疏的数据结构,在时间复杂度和空间复杂度的差异,软件的数据结构和硬件的设计也是高度相关的。不过,多级页表虽然节约了存储空间,却带来了时间上的开销,所以它其实是一个“以时间换空间”的策略。原本进行一次地址转换只需要访问一次内存就能找到物理页号,算出物理内存地址。但是用了4级页表,就需要访问4次内存才能找到物理页号了。内存访问其实比Cache要慢很多,本来只是要做一个简单的地址转换,反而是一下子要多访问好多次内存。

机器指令里面的内存地址都是虚拟内存地址,程序里面的每一个进程,都有一个属于自己的虚拟内存地址空间,可以通过地址转换来获得最终的实际物理地址,每一个指令和数据都存放在内存里面。因此,“地址转换”是一个非常高频的动作,它的性能就变得至关重要了。因为指令、数据都存放在内存里面,这里就会遇到内存安全问题。如果被人修改了内存里面的内容,CPU就可能会去执行计划之外的指令。这个指令可能是破坏服务器里面的数据,也可能是被人获取到服务器里面的敏感信息。

为了解决访问多次内存的性能问题,可以用加个缓存的思路来解决。因为程序所需要使用的指令,都顺序存放在虚拟内存里面。执行的指令也是一条条顺序执行下去的,也就是说对于指令地址的访问,存在前面所说的“空间局部性”和“时间局部性”,而需要访问的数据也是一样的。假如连续执行5条指令。因为内存地址都是连续的,所以这5条指令通常都在同一个“虚拟页”里。因此,这连续5次的内存地址转换,其实都来自于同一个虚拟页号,转换的结果自然也就是同一个物理页号。那就可以把之前的内存转换地址(物理内存地址)缓存下来,使得不需要反复去访问内存来进行内存地址转换。如下所示:

于是,工程师们专门在CPU里放了一块缓存芯片,称之为TLB,全称是地址变换高速缓冲(Translation-Lookaside Buffer),存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候,可以直接在TLB里面查询结果,而不需要多次访问内存来完成一次转换。TLB和CPU的高速缓存类似,可以分成指令的TLB和数据的TLB,也就是ITLB和DTLB。同样的,也可以根据大小对它进行分级,变成L1、L2这样多层的TLB。除此之外,还有一点和CPU里的高速缓存也是一样的,需要用脏标记这样的标记位,来实现“写回”这样缓存管理策略。为了性能,整个内存转换过程也要由硬件来执行,在CPU芯片里封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和TLB的访问和交互,都是由这个 MMU 控制的。如下所示:

17. 关于地址转换产生的内存安全问题中,实际程序指令的执行,是通过程序计数器里面的地址去读取内存里的内容,然后运行对应的指令使用相应的数据。虽然现代操作系统和CPU已经做了各种权限的管控,正常情况下已经通过虚拟内存地址和物理内存地址的区分,隔离了各个进程。但是,无论是CPU还是操作系统都太复杂了,难免还是会被黑客们找到各种各样的漏洞。在内存管理方面,计算机也有一些最底层的安全保护机制。这些机制统称为内存保护(Memory Protection)。主要有以下2种保护措施:

(1)可执行空间保护(Executable Space Protection)。即对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他比如数据部分,不给予“可执行”的权限。因为无论是指令还是数据,在CPU看来都是二进制的数据。把数据部分拿给CPU后,如果这些数据解码后也能变成一条合理的指令,其实就是可执行的。

这个时候黑客们想到了一些搞破坏的办法,即在程序的数据区里,放入一些要执行的指令编码后的数据,然后找到一个办法让CPU去把它们当成指令去加载,那CPU就能执行黑客想要执行的指令了。现在对于进程里内存空间的执行权限进行控制,可以使得CPU只能执行指令区域的代码。对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡掉

其实在实际的应用开发中,类似的策略也很常见。比如说,在用PHP进行Web开发的时候,通常会禁止PHP有eval函数的执行权限。这个其实就是害怕外部的用户没有把数据提交到服务器,而是把一段想要执行的脚本提交到服务器。服务器里在拼装字符串执行命令的时候,可能就会执行到预计之外被“注入”的破坏性脚本。例如如下代码可以删除服务器上的数据:

script.php?param1=xxx   //PHP接受一个传入的参数,这个参数希望提供计算功能
$code = eval($_GET["param1"]);   // 直接通过eval计算出来对应的参数公式的计算结果
script.php?param1=";%20echo%20exec('rm -rf ~/');%20//   // 用户传入的参数里面藏了一个命令
$code = ""; echo exec('rm -rf ~/'); //";   // 执行的结果就变成了删除服务器上的数据

还有一个例子就是SQL注入攻击。如果服务端执行的SQL脚本是通过字符串拼装出来的,那么在Web请求里面传输的参数就可以藏下一些黑客想要执行的SQL,让服务器执行一些管理员没有想到过的SQL语句。这样的结果就是可能破坏了数据库里的数据,或者被人拖库泄露了数据。

(2)地址空间布局随机化(Address Space Layout Randomization)。内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防,上面的可执行空间保护就是一个例子。但是,内存层面的漏洞还有其他的可能性,例如其他人、进程、程序会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,需要知道这些指令和数据所在的位置才行

以前一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会crash掉,而不会去执行计划之外的代码。如下所示:

这样的“随机化”策略,其实也是日常应用开发中一个常见的策略。例如密码登陆功能,在服务器端会把用户名和密码保存下来,用户密码当然不能明文存储在数据库里,否则意味着能拿到数据库访问权限的人,都能看到用户的明文密码。于是,大家会在数据库里存储密码的哈希值,比如用现在常用的SHA256,生成一个验证的密码哈希值。但是这个往往还不够,因为同样的密码对应的哈希值都是相同的,大部分用户的密码又常常比较简单。于是,拖库成功的黑客可以通过彩虹表的方式,来推测出用户的密码。

这个时候“随机化策略”就可以用上了。可以在数据库里给每一个用户名生成一个随机的、使用了各种特殊字符的盐值(Salt)。这样哈希值就不再是仅仅使用密码来生成的了,而是密码和盐值放在一起生成的对应哈希值。哈希值的生成中,包括了一些类似于“乱码”的随机字符串,所以通过彩虹表碰撞来猜出密码的办法就用不了了。例如下面的代码:

$password = "goodmorning12345";
// 密码是明文存储的

$hashed_password = hash('sha256', password);
// 对应的hash值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
// 但是这个hash值里可以用彩虹表直接“猜出来”原始的密码就是goodmorning12345

$salt = "#21Pb$Hs&Xi923^)?";
$salt_password = $salt.$password;
$hashed_salt_password = hash('sha256', salt_password);
// 这个hash后的salt因为有部分随机的字符串,不会在彩虹表里面出现。
// 261e42d94063b884701149e46eeb42c489c6a6b3d95312e25eee0d008706035f

可以看到,通过加入“随机”因素有了一道最后防线。即使在出现安全漏洞的时候,也有了更多的时间和机会去补救这些问题。

五、总线

18. CPU所代表的控制器和运算器,要和存储器也就是主内存,以及输入和输出设备进行通信。那么计算机是用什么样的方式来完成它们的通信呢?如果各个设备间的通信,都是互相之间单独进行的。如果有N个不同的设备,它们之间需要各自单独连接,那么系统复杂度就会变成N^2。每一个设备或者功能电路模块,都要和其他N−1个设备去通信。为了简化系统的复杂度就引入了总线(Bus),把这个N^2的复杂度变成一个N的复杂度,即与其让各个设备之间互相单独通信,不如去设计一个公用的线路。CPU想要和什么设备通信都发送到这个线路上;设备要向CPU发送什么信息也发送到这个线路上。这个线路就好像一个高速公路,各个设备和其他设备之间不需要单独建公路,只建一条小路通向这条高速公路就好了,如下所示:

总线其实就是一组线路。CPU、内存以及输入和输出设备,都是通过这组线路进行相互间通信的。各个接入设备要想向一个设备传输数据,只要把数据放上“公交车”,在对应的车站下车就可以了。对应的设计思路,在软件开发中也是非常常见的。在做大型系统开发的过程中,经常会用到一种叫作事件总线(Event Bus)的设计模式,大规模应用系统中的各个组件之间也需要相互通信,模块之间如果是两两单独去定义协议,这个软件系统一样会遇到一个复杂度变成了N^2的问题。所以解决方案就是事件总线这个设计模式,各个模块触发对应的事件,并把事件对象发送到总线上。也就是说每个模块都是一个发布者(Publisher)。各个模块也会把自己注册到总线上,去监听总线上的事件,并根据事件的对象类型或者是对象内容,来决定自己是否要进行特定的处理或者响应。如下所示:

这样的设计下,注册在总线上的各个模块就是松耦合的。模块互相之间并没有依赖关系。无论代码的维护,还是未来的扩展,都会很方便

19. 现代Intel CPU的体系结构里面,通常有好几条总线。首先CPU和内存以及高速缓存通信的总线通常有两种,称之为双独立总线(Dual Independent Bus,缩写为 DIB)。CPU里有一个快速的本地总线(Local Bus),以及一个速度相对较慢的前端总线(Front-side Bus)。这里的高速本地总线就是CPU用来和高速缓存通信的,而前端总线则是用来和主内存以及输入输出设备通信的。有时候也会把本地总线也叫作后端总线(Back-side Bus),而前端总线也有很多其他名字,比如处理器总线(Processor Bus)、内存总线(Memory Bus)。2008年之后,Intel CPU已经没有前端总线,发明了快速通道互联(Intel Quick Path Interconnect,简称为 QPI)技术替代了传统的前端总线。总线连接各设备的示意图如下所示:

CPU里面的北桥芯片,把上面的前端总线一分为二变成了三个总线,前端总线其实就是系统总线。CPU里面的内存接口直接和系统总线通信,然后系统总线再接入一个I/O桥接器(I/O Bridge)。这个I/O桥接器一边接入了内存总线,使得CPU和内存通信;另一边又接入了一个I/O总线用来连接I/O设备。事实上真实的计算机里,这个总线层面拆分得更细。根据不同的设备还会分成独立的PCI总线、ISA总线等等,如下所示:

在物理层面,其实完全可以把总线看作一组“电线”。不过这些电线之间也是有分工的,通常有三类线路:

(1)数据线(Data Bus),用来传输实际的数据信息,也就是实际上了公交车的“人”。

(2)地址线(Address Bus),用来确定到底把数据传输到哪里去,是内存的某个位置还是某一个I/O设备,相当于拿了个纸条写下了上面的人要下车的站点。

(3)控制线(Control Bus),用来控制对于总线的访问。

虽然把总线比喻成了一辆公交车。那么有人想要做公交车的时候,需要告诉公交车司机,这个就是控制信号。尽管总线减少了设备之间的耦合,也降低了系统设计的复杂度,但同时也带来了一个新问题,那就是总线不能同时给多个设备提供通信功能。线是很多个设备公用的,那多个设备都想要用总线就需要有一个机制,去决定这种情况下,到底把总线给哪一个设备用。这个机制就叫作总线裁决(Bus Arbitraction)。

六、IO设备与IO性能

20. 实际上输入输出设备并不只是一个设备。大部分的输入输出设备都有两个组成部分,一个是它的接口(Interface),第二个才是实际的I/O设备(Actual I/O Device)。硬件设备并不是直接接入到总线上和CPU通信的,而是通过接口连接到总线上,再通过总线和CPU通信。接口本身就是一块电路板,CPU其实不是和实际的硬件设备打交道,而是和这个接口电路板打交道。设备里面的三类寄存器都在这个设备的接口电路上,而不在实际的设备上,它们分别是状态寄存器(Status Register)、 命令寄存器(Command Register)以及数据寄存器(Data Register)。

Windows系统中可以打开设备管理器,里面有各种的Devices(设备)、Controllers(控制器)、Adaptors(适配器)。这些其实都是对于输入输出设备不同角度的描述。被叫作Devices看重的是实际的I/O设备本身,被叫作Controllers看重的是输入输出设备接口里面的控制电路,被叫作Adaptors则是看重接口作为一个适配器后面可以插上不同的实际设备。如下所示:

21. 无论是内置在主板上的接口,还是集成在设备上的接口,除了上面三类寄存器之外,还有对应的控制电路。正是通过这个控制电路,CPU才能通过向这个接口电路板传输信号来控制实际的硬件。关于硬件设备上这些寄存器的作用,可以举个下面的打印机例子:

(1)首先是数据寄存器(Data Register),CPU向I/O设备写入需要传输的数据。

(2)然后是命令寄存器(Command Register)。CPU发送一个命令,告诉打印机要进行打印工作。这个时候打印机里面的控制电路会做两个动作。第一是去设置状态寄存器里面的状态,把状态设置成not-ready。第二就是实际操作打印机进行打印。

(3)状态寄存器(Status Register),就是告诉了CPU现在设备已经在工作了,所以这个时候CPU再发送数据或者命令过来都是没有用的。直到前面的动作已经完成,状态寄存器重新变成了ready状态,CPU才能发送下一个字符和命令。

当然,在实际情况中,打印机里通常不只有数据寄存器,还会有数据缓冲区,CPU也是一次性把整个文档传输到打印机的内存或者数据缓冲区里面一起打印的。

22. CPU和I/O设备的通信,一样是通过CPU支持的机器指令来执行的。而例如MIPS的机器指令分类中,并没有一种专门的和I/O设备通信的指令类型。那么MIPS的CPU到底是通过什么样的指令来和I/O设备来通信呢?答案就是和访问主内存一样,使用“内存地址”。为了让已经很复杂的CPU尽可能简单,计算机会把I/O设备的各个寄存器以及I/O设备内部的内存地址,都映射到主内存地址空间里来。主内存的地址空间里,会给不同的I/O设备预留一段段的内存地址。CPU想要和这些I/O设备通信的时候就往这些地址发送数据。这些地址信息就是通过上面说过的地址线来发送的,而对应的数据信息是通过数据线来发送的。I/O设备会监控地址线,并且在CPU往自己地址发送数据的时候,把对应数据线里面传输过来的数据,接入到对应设备里的寄存器和内存里面来。CPU无论是向I/O设备发送命令、查询状态还是传输数据,都可以通过这样的方式。这种方式叫作内存映射IO(Memory-Mapped I/O,简称 MMIO),如下所示:

当然MMIO并不是唯一一种CPU和设备通信的方式。精简指令集MIPS的CPU特别简单,所以只有MMIO。而有2000多个指令的Intel X86架构计算机,自然可以设计专门和I/O设备通信的指令,也就是in和out指令。Intel CPU虽然也支持MMIO,不过它还可以通过特定的指令来支持端口映射I/O(Port-Mapped I/O,简称 PMIO)或者也叫独立输入输出(Isolated I/O)。

其实PMIO的通信方式和MMIO差不多,核心区别在于PMIO里面访问的设备地址,不再是在内存地址空间里面,而是一个专门的端口(Port)。这个端口并不是指一个硬件上的插口,而是和CPU通信的一个抽象概念。无论是PMIO还是MMIO,CPU都会传送一条二进制的数据给到I/O设备的对应地址。设备自己本身的接口电路,再去解码这个数据。解码之后的数据就会变成设备支持的一条指令,再去通过控制电路去操作实际的硬件设备。对于CPU来说并不需要关心设备本身能够支持哪些操作,它要做的只是在总线上传输一条条数据就好了。这其实也有点像设计模式里面的Command模式,在总线上传输的是一个个数据对象,然后各个接受这些对象的设备,再去根据对象内容,进行实际的解码和命令执行。

例如下面的显卡,在设备管理器里面的资源(Resource)信息可以看到,里面既有Memory Range,就是设备对应映射到的内存地址,也就是上面所说的MMIO的访问方式,同样里面还I/O Range,这个就是上面所说的PMIO,也就是通过端口来访问I/O设备的地址。最后里面还有一个IRQ,也就是来自于这个设备的中断信号了:

23. 因此,CPU并不是发送一个特定的操作指令来操作不同的I/O设备。因为如果那样的话,随着新I/O设备的发明,就要去扩展CPU的指令集了。在计算机系统里面,CPU和I/O设备之间的通信是这么来解决的。首先I/O设备这一侧,把I/O设备拆分成能和CPU通信的接口电路,以及实际的I/O设备本身。接口电路里面有对应的状态寄存器、命令寄存器、数据寄存器、数据缓冲区和设备内存等等。接口电路通过总线和CPU通信,接收来自CPU的指令和数据。

而接口电路中的控制电路,再解码接收到的指令,实际去操作对应的硬件设备。在CPU这一侧,它看到的并不是一个个特定的设备,而是一个个内存地址或者端口地址,CPU只是向这些地址传输数据或者读取数据。所需要的指令和操作内存地址的指令其实没有什么本质差别。通过软件层面对于传输的命令数据的定义,而不是提供特殊的新的指令,来实际操作对应的I/O硬件。

24. HDD硬盘接收一个来自CPU的请求能够在几毫秒时间返回,传输数据的速度也有200MB/s左右。平时往数据库里写入一条记录,也就是1KB左右的大小,拿200MB去除以1KB差不多每秒钟可以插入20万条数据,但是这个计算出来的数字和日常的经验不符合,答案就来自于硬盘的读写。在顺序读写和随机读写的情况下,硬盘的性能是完全不同的。比如AS SSD的性能指标里面有一个“4K”的指标,其实就是程序去随机读取磁盘上某一个4KB大小的数据,一秒之内可以读取到多少数据。

在4K随机访问这个指标上,使用SATA 3.0接口的硬盘和PCI Express接口的硬盘,性能差异变得很小。这是因为在这个时候,接口本身的速度已经不是硬盘访问速度的瓶颈了。即使用PCI Express的接口,在随机读写的时候数据传输率也只能到40MB/s左右,是顺序读写情况下的几十分之一。拿这个40MB/s和一次读取4KB的数据相除,也就是说一秒之内这块SSD硬盘可以随机读取1万次的4KB的数据。如果是写入的话会更多一些,90MB /4KB差不多是2万多次(随机读性能弱于随机写)。这个4K随机每秒读写的次数称之为IOPS,即每秒输入输出操作的次数。事实上比起硬盘响应时间,一般更关注IOPS这个性能指标。IOPS和DTR(Data Transfer Rate,数据传输率)才是输入输出性能的核心指标。因此,HDD硬盘的IOPS通常也就在100左右,而不是上面顺序读取的20万次。

25. 即使是用上了PCI Express接口的SSD硬盘,IOPS也就是在2万左右,而CPU的主频通常在2GHz以上,也就是每秒可以做20亿次操作。即使CPU向硬盘发起一条读写指令需要很多个时钟周期,一秒钟CPU能够执行的指令数,和硬盘能够进行的操作数相比也有好几个数量级的差异。这也是为什么,在应用开发的时候往往会说“性能瓶颈在I/O上”因为很多时候,CPU指令发出去之后,不得不去“等”I/O操作完成,才能进行下一步的操作

在实际遇到服务端程序的性能问题时,为了知道问题是不是来自于CPU在等待I/O来完成操作,可以通过top和iostat命令查看,如下所示:

$ top
top - 06:56:02 up 3 days, 19:34,  2 users,  load average: 5.99, 1.82, 0.63
Tasks:  88 total,   3 running,  85 sleeping,   0 stopped,   0 zombie
%Cpu(s):  3.0 us, 29.9 sy,  0.0 ni,  0.0 id, 67.2 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  1741304 total,  1004404 free,   307152 used,   429748 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  1245700 avail Mem

在top命令的输出结果里,有一行是以%CPU开头的。这一行里有一个叫wa的指标,代表着iowait也就是CPU等待IO完成操作花费的时间占CPU的百分比。当服务器遇到性能瓶颈load很大的时候,就可以通过top看一看这个指标。知道了iowait很大,那么就要去看一看实际的I/O操作情况是什么样的。这个时候就可以去用iostat命令了,可以看到实际的硬盘读写情况

$ iostat
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           5.03    0.00   67.92   27.04    0.00    0.00
Device:            tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn
sda           39762.26         0.00         0.00          0          0

这个命令里不仅有iowait这个CPU等待时间的百分比,还有一些更加具体的指标,并且它还是按照机器上安装的多块不同硬盘划分的。这里的tps指标其实就对应着硬盘的IOPS性能,而kB_read/s和kB_wrtn/s指标就对应着数据传输率的指标。知道实际硬盘读写的tps、kB_read/s和kb_wrtn/s的指标,基本上可以判断出机器的性能是不是卡在I/O上了

那么接下来就是要找出到底是哪一个进程是这些I/O读写的来源了。这个时候需要iotop命令,如下所示:

$ iotop
Total DISK READ :       0.00 B/s | Total DISK WRITE :       0.00 B/s
Actual DISK READ:       0.00 B/s | Actual DISK WRITE:       0.00 B/s
  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND                                             
29161 be/4 xuwenhao    0.00 B/s    0.00 B/s  0.00 % 56.71 % stress -i 2
29162 be/4 xuwenhao    0.00 B/s    0.00 B/s  0.00 % 46.89 % stress -i 2
1 be/4 root        0.00 B/s    0.00 B/s  0.00 %  0.00 % init

通过iotop这个命令,可以看到具体是哪一个进程实际占用了大量I/O,那就可以去优化对应的程序了。

七、机械硬盘(HDD)

26. 机械硬盘的IOPS大概只能做到每秒100次左右。这个次数和机械硬盘的物理构造有关,如下所示:

(1)首先是盘面(Disk Platter),就在实际存储数据的盘片上。盘面上有一层磁性的涂层,数据就存储在这个磁性涂层上。盘面中间有一个受电机控制的转轴,会控制盘面去旋转。硬盘转速有5400、7200转等,单位也叫RPM即每分钟的旋转圈数(Rotations Per Minute)。

(2)接着是磁头(Drive Head)。数据并不能直接从盘面传输到总线上,而是通过磁头从盘面上读取到,然后再通过电路信号传输给控制电路、接口,再到总线上的。通常一个盘面上会有两个磁头,分别在盘面的正反面,盘面在正反两面都有对应的磁性涂层来存储数据,而且一块硬盘也不只一个盘面,而是上下堆叠了很多个盘面,各个盘面之间是平行的,每个盘面正反两面都有对应的磁头。

(3)最后是悬臂(Actutor Arm)。悬臂与磁头相连,并且在一定范围内会把磁头定位到盘面的某个特定磁道(Track)上。一个盘面由很多个同心圆组成,就好像是一个个大小不一样的“甜甜圈”嵌套在一起。每一个“甜甜圈”都是一个磁道,每个磁道都有自己的编号。悬臂其实只是控制到底是读最里面那个“甜甜圈”的数据,还是最外面“甜甜圈”的数据。

一个磁道会分成一个一个扇区(Sector)。上下平行的一个个盘面的相同扇区叫作一个柱面(Cylinder)。如下所示:

读取数据有两个步骤:

(1)把盘面旋转到某一个位置,在这个位置上悬臂可以定位到整个盘面的某一个子区间。这个子区间的形状有点像一块披萨饼,一般把这个区间叫作几何扇区(Geometrical Sector),意思是在“几何位置上”所有这些扇区都可以被悬臂访问到。

(2)把悬臂移动到特定磁道的特定扇区,也就是在这个“几何扇区”里面找到实际的扇区。找到之后磁头会落下,就可以读取到正对着扇区的数据。

因此,进行一次硬盘上的随机访问,需要的时间由两个部分组成:

(1)平均延时(Average Latency)。这个时间其实就是把盘面旋转,把几何扇区对准悬臂位置的时间。它其实就和机械硬盘的转速相关。随机情况下平均找到一个几何扇区需要旋转半圈盘面,例如7200转的硬盘一秒里面就可以旋转240个半圈(1秒120整圈),那么这个平均延时就是1s / 240 = 4.17ms。

(2)平均寻道时间(Average Seek Time)。就是在盘面旋转之后,悬臂定位到扇区的的时间。HDD硬盘的平均寻道时间一般在4-10ms,这样就能够算出,如果随机在整个硬盘上找一个数据需要8~14ms。由于硬盘是机械结构的,只有一个电机转轴也只有一个悬臂,所以没有办法并行地去定位或者读取数据。因此一块7200转的硬盘一秒钟随机的IO访问次数,也就是1s / 8 ms = 125 IOPS或者1s / 14ms = 70 IOPS。

如果不是去进行随机的数据访问,而是进行顺序的数据读写,为了最大化读取效率可以选择把顺序存放的数据尽可能地存放在同一个柱面上,这样只需要旋转一次盘面进行一次寻道,就可以去写入或者读取同一个垂直空间上的多个盘面的数据。如果一个柱面上的数据不够也不用去动悬臂,而是通过电机转动盘面,这样就可以顺序读完一个磁道上的所有数据。所以其实对于HDD硬盘的顺序数据读写,吞吐率还是很不错的,可以达到200MB/s左右。

27. 为了进一步提高机械硬盘的IOPS,目前有个方法叫做Partial Stroking或者Short Stroking(缩短行程)技术。这个技术优化的思路是既然访问一次数据的时间是“平均延时 + 寻道时间”,那么只要能缩短这两个之一就可以提升IOPS。一般情况下,硬盘的寻道时间都比平均延时要长,缩短平均寻道时间最极端的办法就是不需要寻道,也就是说把所有数据都放在一个磁道上,这样寻道时间就基本为0,访问时间就只有平均延时了。那样IOPS就变成了1s / 4ms = 250 IOPS。

不过只用一个磁道能存的数据就比较有限了,所以实践当中可以只用最外1/4或1/2的磁道。这样硬盘可以使用的容量可能变成了1/2或者1/4,但是寻道时间也变成了1/4或者1/2,因为悬臂需要移动的“行程”也变成了原来的1/2或者1/4,IOPS就能够大幅度提升了。

比如说一块7200转的硬盘,正常情况下平均延时是4.17ms,而寻道时间是9ms,那么它原本的IOPS就是1s / (4.17ms + 9ms) = 75.9 IOPS。如果只用其中1/4的磁道,那么它的IOPS就变成了1s / (4.17ms + 9ms/4) = 155.8 IOPS。这样IOPS提升了一倍,和一块15000转硬盘的性能差不多了,而15000转硬盘的价格远不止7200转硬盘的四倍,这样通过软件去格式化硬盘,只保留部分磁道让系统可用的情况,可以大大提升硬件的性价比。多年前的谷歌在SSD不像如今普遍的情况下,就考虑了类似的解决方案。

由于机械硬盘分区是由外到内的,C盘往往在最外侧磁道,所以机械硬盘里面C盘的性能是所有分区里最好的。要想只用最外侧1/4的磁道,只需要简单地把C盘分成整个硬盘1/4的容量,剩下的容量弃而不用就可以达到相对较高的IOPS效果了。

八、固态硬盘(SSD)

28. 无论是用10000转的企业级机械硬盘,还是用Short Stroking这样的方式进一步提升IOPS,HDD硬盘的速度渐渐已经满足不了需求了,上面这些优化措施无非就是把IOPS从100提升到300、500也就到头了。而一块普通的SSD硬盘,可以轻松支撑10000乃至20000的IOPS。因为SSD没有像机械硬盘那样的寻道过程,所以它的随机读写都更快。HDD与SSD的对比如下所示:

在速度上HDD远不如SSD,但是HDD的耐用性更好,如果需要频繁地重复写入删除数据,那么机械硬盘要比SSD性价比高很多。关于SSD耐用性相对更差的原因,需要先理解SSD硬盘的存储和读写原理。CPU Cache用的SRAM是用一个电容来存放一个比特的数据。对于SSD硬盘也可以先简单地认为它由一个电容加上一个电压计组合在一起,记录了一个或者多个比特。能够记录一个比特很容易理解。给电容里充上电有电压的时候就是1,给电容放电里面没有电就是0。采用这样方式存储数据的SSD硬盘,一般称之为使用了SLC(Single-Level Cell)的颗粒,也就是一个存储单元中只有一位数据。如下所示:

但是这样的方式会遇到和CPU Cache类似的问题,那就是同样的面积下,能够存放下的元器件是有限的。如果只用SLC,就会遇到存储容量上不去,并且价格下不来的问题。于是硬件工程师们就陆续发明了MLC(Multi-Level Cell)、TLC(Triple-Level Cell)以及QLC(Quad-Level Cell),也就是能在一个电容里面存下2个、3个乃至4个比特。如下所示:

只有一个电容,为了能够表示更多的比特,就需要有一个电压计。4个比特一共可以从0000-1111表示16个不同的数。那么如果能往电容里面充电的时候,充上15个不同的电压,并且电压计能够区分出这15个不同的电压,加上电容被放空代表的0,就能够代表从0000-1111这样4个比特了。不过要想表示15个不同的电压,充电和读取的时候对于精度的要求就会更高。这会导致充电和读取的时候都更慢,所以QLC的SSD的读写速度要比SLC的慢上好几倍

29. SSD硬盘的硬件构造大致是自顶向下的几个部分构成:

(1)对应的接口和控制电路。现在SSD硬盘用的是SATA或者PCI Express接口。在控制电路里有一个很重要的模块叫作FTL(Flash-Translation Layer),也就是闪存转换层。这个可以说是SSD硬盘的一个核心模块,SSD硬盘性能的好坏,很大程度上也取决于FTL的算法好不好

(2)实际I/O设备。它其实和机械硬盘很像。现在新的大容量SSD硬盘都是3D封装的了,也就是说是由很多个裸片(Die)叠在一起的,就好像机械硬盘把很多个盘面(Platter)叠放再一起一样,这样可以在同样的空间下放下更多的容量。

(3)一张裸片上可以放多个平面(Plane),一般一个平面上的存储容量大概在GB级别。一个平面上面会划分成很多个(Block),一般一个块(Block)的存储大小通常几百KB到几MB大小。一个块里面还会区分很多个(Page),就和内存里面的页一样,一个页的大小通常是4KB。

在这一层一层的结构里,处在最下面的两层块和页非常重要。对于SSD硬盘来说,数据的写入叫作Program,写入不能像机械硬盘一样通过覆写(Overwrite)来进行,而是要先去擦除(Erase)然后再写入。SSD的读取和写入的基本单位,不是一个比特(bit)或者一个字节(byte),而是一个页(Page)。SSD的擦除单位就更夸张了,必须按照块来擦除

SSD的使用寿命,其实是每一个块(Block)的擦除的次数。可以把SSD硬盘的一个平面看成是一张白纸。在上面写入数就好像用铅笔在白纸上写字。如果想要把已经写过字的地方写入新的数据,我们要用橡皮把已经写好的字擦掉。但是如果频繁擦同一个地方,那这个地方就会破掉,之后就没有办法再写字了。SLC的芯片可以擦除的次数大概在10万次,MLC就在1万次左右,而TLC和QLC就只在几千次了

30. 对于SSD的日常使用运行,可以举一个例子。用三种颜色分别来表示SSD硬盘里的页的不同状态,白色代表这个页从来没有写入过数据,绿色代表里面写入的是有效的数据,红色代表里面的数据在操作系统看来已经是删除的了。如下所示:

一开始所有块的每一个页都是白色的。随着开始往里面写数据,里面的有些页就变成了绿色。然后因为删除了硬盘上的一些文件,所以有些页变成了红色。但是这些红色的页并不能再次写入数据,因为SSD硬盘不能单独擦除一个页,必须一次性擦除整个块,所以新的数据只能往后面的白色的页里面写。这些散落在各个绿色空间里面的红色空洞就好像磁盘碎片。如果有哪一个块的数据一次性全部被标红了,那就可以把整个块进行擦除,它就又会变成白色,可以重新一页一页往里面写数据。这种情况其实也会经常发生。毕竟一个块不也就在几百 K到几 MB。删除一个几MB的文件,数据又是连续存储的,自然会导致整个块可以被擦除。

随着硬盘里面的数据越来越多,红色空洞占的地方也会越来越多,于是渐渐就要没有白色的空页去写入数据了。这个时候要做一次类似于Windows“磁盘碎片整理”或者Java“内存垃圾回收”工作,找一个红色空洞最多的块,把里面的绿色数据挪到另一个块里面去,然后把整个块擦除变成白色,可以重新写入数据。不过这个“磁盘碎片整理”或者“内存垃圾回收”的工作不能太主动、太频繁地去做,因为SSD的擦除次数是有限的。如果动不动就搞个磁盘碎片整理,那么SSD硬盘很快就会报废了。

因此一般SSD的空间无法完全用满,因为总会遇到一些红色空洞。生产SSD硬盘的厂商其实是预留了一部分空间,专门用来做这个“磁盘碎片整理”工作。一块标成240G的SSD硬盘,往往实际256G 的硬盘空间。SSD硬盘通过控制芯片电路把多出来的硬盘空间,用来进行各种数据的转移和块擦除。这个划出来的16G空间叫作预留空间(Over Provisioning),一般SSD的硬盘的预留空间都在7%-15%左右,如下所示:

31. SSD硬盘特别适合读多写少的应用,在日常应用里系统盘适合用SSD。但是,如果用SSD做专门的下载盘以及刻盘备份就不太好了。在数据中心里面,SSD的应用场景也是适合读多写少的场景。SSD硬盘用来做数据库,存放电商网站的商品信息很合适。但是,如果用来作为Hadoop这样的MapReduce应用的数据盘就不行了,因为MapReduce任务会大量在任务中间向硬盘写入中间数据再删除掉,这样用不了多久SSD硬盘的寿命就会到了。对于日志系统,写入量大而且有些还会清除老旧的日志,反而读日志却不多,所以日志系统完全不适合存放在SSD硬盘上,应该用HDD硬盘

日常使用PC进行软件开发的时候,会先在硬盘上装上操作系统和常用软件。这些软件所在的块,写入一次之后就不太会擦除了,所以就只有读的需求。一旦开始代码开发就会不断添加新的代码文件,还会不断修改已经有的代码文件。因为SSD硬盘没有覆写(Override)的功能,所以这个过程中其实是在反复地写入新的文件,然后再把原来的文件标记成逻辑上删除的状态。等SSD里面空的块少了,就会用“垃圾回收”的方式进行擦除。这样擦除会反复发生在这些用来存放数据的地方。如下所示:

有一天这些块的擦除次数到了,变成了坏块,但是安装操作系统和软件的地方还没有坏,而这块硬盘的可以用的容量却变小了。

32. 为了不让这些坏块那么早就出现,优化的思路是考虑匀出一些存放操作系统的块的擦写次数,给到这些存放数据的地方也就是让SSD硬盘各个块的擦除次数,均匀分摊到各个块上,这个策略就叫作磨损均衡(Wear-Leveling)。实现这个技术的核心办法就和虚拟内存一样,就是添加一个间接层,这个间接层就是上面说过的FTL即闪存转换层。如下所示:

就像在管理内存的时候,通过一个页表映射虚拟内存页和物理页一样,在FTL里面存放了逻辑块地址(Logical Block Address,简称 LBA)到物理块地址(Physical Block Address,简称 PBA)的映射。操作系统访问的硬盘地址其实都是逻辑地址,只有通过FTL转换之后才会变成实际的物理地址,找到对应的块进行访问。操作系统本身不需要去考虑块的磨损程度,只要和操作机械硬盘一样来读写数据就好了。

操作系统所有对于SSD硬盘的读写请求都要经过FTL,FTL里面又有逻辑块对应的物理块,所以FTL能够记录下来每个物理块被擦写的次数。如果一个物理块被擦写的次数多了,FTL就可以将这个物理块挪到一个擦写次数少的物理块上,但是逻辑块不用变,操作系统也不需要知道这个变化,这也是在设计大型系统中的一个典型思路,也就是各层之间是隔离的,操作系统不需要考虑底层的硬件是什么,完全交由硬件控制电路里面的FTL,来管理对于实际物理硬件的写入。

33. 不过操作系统不去关心实际底层的硬件是什么,在SSD硬盘的使用上也会带来一个问题,就是操作系统的逻辑层和SSD的逻辑层里的块状态是不匹配的。在操作系统里面去删除一个文件,其实并没有真的在物理层面去删除这个文件,只是在文件系统里面把对应的inode里面的元信息清理掉,这代表这个inode还可以继续使用,可以写入新的数据。这个时候实际物理层面的对应的存储空间,在操作系统里面被标记成可以写入了。

所以其实日常的文件删除,都只是一个操作系统层面的逻辑删除。这也是为什么很多时候不小心删除了对应的文件,还可以通过各种恢复软件把数据找回来,因为物理数据还在只需要恢复元信息。同样的这也是为什么如果想要删除干净数据,需要用各种“文件粉碎”的功能才行。这个删除的逻辑在机械硬盘层面没有问题,因为文件被标记成可以写入,后续的写入可以直接覆写这个位置。但是在SSD硬盘上就不一样了,如下所示:

当在操作系统里面删除掉一个刚刚下载的文件,比如标记成黄色openjdk.exe这样的安装文件,在操作系统里对应的inode里面,就没有了文件的元信息。但是这个时候SSD的逻辑块层面其实并不知道这个事情,所以在逻辑块层面openjdk.exe仍然是占用了对应的空间,对应的物理页也仍然被认为是被占用了的。这个时候如果需要对SSD进行垃圾回收操作,openjdk.exe对应的物理页仍然要在这个过程中,被搬运到其他的Block里面去。只有当操作系统再在刚才的inode里面写入数据的时候,SSD才会知道原来黄色的页其实都已经没有用了,才会把它标记成废弃掉

所以在使用SSD的情况下,操作系统对于文件的删除SSD硬盘其实并不知道。这就导致为了磨损均衡,很多时候都在搬运很多已经删除了的数据,这就会产生很多不必要的数据读写和擦除,既消耗了SSD的性能,也缩短了SSD的使用寿命。为了解决这个问题,现在的操作系统和SSD的主控芯片都支持TRIM命令,这个命令可以在文件被删除的时候,让操作系统去通知SSD硬盘,对应的逻辑块已经标记成已删除了,现在的SSD硬盘都已经支持了TRIM命令。

34. 其实TRIM命令的发明也反应了一个使用SSD硬盘的问题,那就是SSD硬盘容易越用越慢。当SSD硬盘的存储空间被占用得越来越多,每一次写入新数据都可能没有足够的空白,不得不去进行垃圾回收,合并一些块里面的页然后再擦除掉一些页,才能匀出一些空间来。这个时候从应用层或者操作系统层面来看,可能只是写入了一个4KB或者4MB的数据,但是实际通过FTL之后,可能要去搬运8MB、16MB甚至更多的数据。

通过“实际的闪存写入的数据量 / 系统通过FTL写入的数据量 = 写入放大”,可以得到写入放大的倍数越多,意味着实际的SSD性能也就越差,会远远比不上实际SSD硬盘标称的指标。而解决写入放大,需要在后台定时进行垃圾回收,在硬盘比较空闲的时候就把搬运数据、擦除数据、留出空白的块的工作做完,而不是等实际数据写入的时候再进行这样的操作。

35. 因此,想要把SSD硬盘用好,其实没有那么简单。如果只简单地拿一块SSD硬盘替换掉原来的HDD硬盘,而不是从应用层面考虑任何SSD硬盘特性的话,多半还是没法获得想要的性能提升。例如AeroSpike这个专门针对SSD硬盘特性设计的Key-Value 数据库,很好利用了SSD的物理特性:

(1)AeroSpike操作SSD硬盘,并没有通过操作系统的文件系统而是直接操作SSD里面的块和页,因为操作系统里面的文件系统对于KV数据库来说,只是多了一层间接层,只会降低性能没有什么实际的作用。

(2)AeroSpike在读写数据的时候,做了两个优化。在写入数据的时候,AeroSpike尽可能去写一个较大的数据块,而不是频繁地去写很多小的数据块。这样硬盘就不太容易频繁出现磁盘碎片。并且一次性写入一个大的数据块,也更容易利用好顺序写入的性能优势。AeroSpike写入的一个数据块是128KB,远比一个页的4KB要大得多。另外在读取数据的时候,AeroSpike可以读取512字节(Bytes)这样的小数据,因为SSD的随机读取性能很好,也不像写入数据那样有擦除寿命问题。而且很多时候读取的数据是键值对里面的值的数据,这些数据要在网络上传输。如果一次性必须读出比较大的数据,就会导致网络带宽不够用。

因为AeroSpike是一个对于响应时间要求很高的实时KV数据库,如果出现了严重的写放大效应,会导致写入数据的响应时间大幅度变长。所以AeroSpike做了这样几个动作:

(1)持续地进行磁盘碎片整理。AeroSpike用了所谓的高水位(High Watermark)算法,就是一旦一个物理块里面的数据碎片超过50%,就把这个物理块搬运压缩,然后进行数据擦除,确保磁盘始终有足够的空间可以写入。

(2)为了保障数据库的性能,开发者建议只用到SSD硬盘标定容量的一半。也就是说人为地给SSD硬盘预留了50%的预留空间,以确保SSD硬盘的写放大效应尽可能小,不会影响数据库的访问性能。

正是因为做了种种的优化,在NoSQL数据库刚刚兴起的时候,AeroSpike的性能把Cassandra、MongoDB这些数据库远远甩在身后,和这些数据库之间的性能差距有时候会到达一个数量级,这也让AeroSpike成为了当时高性能KV数据库的标杆。

九、DMA:Kafka速度快的原因之一

36. 无论I/O速度如何提升,比起CPU总还是太慢。SSD硬盘的IOPS可以到2万和4万,但是目前CPU的主频在2GHz以上,也就意味着每秒会有20亿次的操作。如果对于I/O的操作都是由CPU发出对应的指令,然后等待I/O设备完成操作之后返回,那CPU有大量的时间其实都是在等待I/O设备完成操作。但是这个CPU的等待,在很多时候其实并没有太多的意义。对于I/O设备的大量操作,其实都只是把内存里面的数据传输到I/O设备而已。特别是当传输的数据量比较大的时候,比如进行大文件复制,如果所有数据都要经过CPU,实在是太浪费时间了。因此计算机工程师们就发明了DMA技术,也就是直接内存访问(Direct Memory Access)技术,来减少CPU等待的时间。

本质上DMA技术就是在主板上放一块独立的芯片。在进行内存和I/O设备的数据传输时,不再通过CPU来控制数据传输,而直接通过DMA控制器(DMA Controller,简称 DMAC),这块芯片可以认为它其实就是一个协处理器(Co-Processor)。DMAC最有价值的地方体现在当要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。比如说用千兆网卡或者硬盘传输大量数据的时候,如果都用CPU来控制搬运肯定忙不过来,所以可以选择让DMAC处理。而当数据传输很慢的时候,DMAC可以等数据到齐了,再发送信号给到CPU去处理,而不是让CPU在那里空转忙等待

DMAC是一块“协处理器芯片”,这里的“协”字是指“协助”CPU完成对应的数据传输工作,因此在DMAC控制数据传输的过程中,依然还是需要CPU。除此之外DMAC其实也是一个特殊的I/O设备,它和CPU以及其他I/O设备一样,通过连接到总线来进行实际的数据传输。总线上的设备其实有两种类型。一种称之为主设备(Master),另外一种称之为从设备(Slave)。

想要主动发起数据传输,必须要是一个主设备才可以,CPU就是主设备,而从设备(比如硬盘)只能接受数据传输。所以如果通过CPU来传输数据,要么是CPU从I/O设备读数据,要么是CPU向I/O设备写数据。如果是I/O设备向主设备发起请求,发送的不是数据内容,而是控制信号,I/O设备可以告诉CPU这里有数据要传输给它,但是实际数据是CPU拉走的,而不是I/O设备推给CPU。如下所示:

不过DMAC很有意思,它既是一个主设备,又是一个从设备。对于CPU来说它是一个从设备;对于硬盘这样的IO设备来说它又变成了一个主设备。使用DMAC进行数据传输的过程如下:

(1)首先CPU作为一个主设备向DMA 设备发起请求。这个请求其实就是在DMAC里面修改配置寄存器。

(2)CPU修改DMAC配置的时候,会告诉DMAC这样几个信息:

a.首先是源地址的初始值以及传输时候的地址增减方式。所谓源地址就是数据要从哪里传输过来。如果要从内存里面写入数据到硬盘上,那么就是要读取的数据在内存里面的地址;如果是从硬盘读取数据到内存里,那就是硬盘的I/O接口的地址。I/O地址可以是一个内存地址,也可以是一个端口地址。而地址的增减方式就是数据是从大的地址向小的地址传输,还是从小的地址往大的地址传输。

b.其次是目标地址初始值和传输时候的地址增减方式。目标地址自然就是和源地址对应的设备,也就是数据传输的目的地。

c.第三个是要传输的数据长度,也就是一共要传输多少数据。

(3)设置完这些信息之后,DMAC就会进入一个空闲状态(Idle)。

(4)如果要从硬盘上往内存里加载数据,这个时候硬盘就会向DMAC发起一个数据传输请求。这个请求并不是通过总线,而是通过一个额外的连线。

(5)DMAC需要再通过一个额外的连线响应这个申请。

(6)DMAC向硬盘的接口发起要总线读的传输请求。数据就从硬盘里读到了DMAC的控制器里面。

(7)DMAC再向内存发起总线写的数据传输请求,把数据写入到内存里面。

(8)DMAC会反复进行上面第6、7步的操作,直到DMAC的寄存器里设置的数据长度传输完成。

(9)数据传输完成之后,DMAC重新回到第3步的空闲状态。

所以整个数据传输的过程中,不是通过CPU来搬运数据,而是由DMAC来搬运数据。但是CPU在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,其实还是由CPU来设置的,这也是为什么DMAC被叫作“协处理器”。最早计算机里是没有DMAC的,所有数据都由CPU来搬运。随着人们对于数据传输的需求越来越多,先是出现了主板上独立的DMAC控制器。到了今天各种I/O设备越来越多,数据传输的需求越来越复杂,使用的场景各不相同,而且显示器、网卡、硬盘对于数据传输的需求都不一样,所以各个设备里面都有自己的DMAC芯片了。如下所示:

37. 有一个开源项目很好地利用了DMA的数据传输方式,通过DMA实现了非常大的性能提升,这个项目就是大数据领域的Kafka。Kafka是一个用来处理实时数据的管道,常常用它来做一个消息队列,或者用来收集和落地海量的日志。作为一个处理实时数据和日志的管道,瓶颈自然也在I/O层面。Kafka里面会有两种常见的海量数据传输的情况,一种是从网络中接收上游的数据,然后需要落地到本地的磁盘上,确保数据不丢失;另一种情况则是从本地磁盘上读取出来,通过网络发送出去。

例如后一种情况从磁盘读数据发送到网络上去,如果写一个简单的程序,最直观的办法自然是用一个文件读操作,从磁盘上把数据读到内存里面来,然后再用一个Socket把这些数据发送到网络上去。例如下面来自IBM的伪代码:

File.read(fileDesc, buf, len);

Socket.send(socket, buf, len);

在这个过程中,数据一共发生了四次传输的过程。其中两次是DMA的传输,另外两次则是通过CPU控制的传输。具体的过程如下:

(1)第一次传输是从硬盘上,读到操作系统内核的缓冲区里。这个传输是通过DMA搬运的。

(2)第二次传输需要从内核缓冲区里面的数据,复制到应用分配的内存里面。这个传输是通过CPU搬运的。

(3)第三次传输,要从应用的内存里面再写到操作系统的Socket缓冲区里面去。这个传输还是由CPU搬运的。

(4)最后一次传输,需要再从Socket的缓冲区里面,写到网卡的缓冲区里面去。这个传输又是通过DMA搬运的。

然而,真正的需求只是想要“搬运”一份数据,结果却整整搬运了四次。操作系统的内核缓冲区其实也在内存的某一个地方(只是用户访问不到),第二步复制到应用程序内存中,事实上就是从内存的某一个地方复制到内存另一个地方,使应用程序可以操作。所以从内核的读缓冲区传输到应用的内存里,再从应用的内存里传输到Socket的缓冲区里,其实都是把同一份数据在内存里面搬运来搬运去,特别没有效率。像Kafka这样的应用场景,其实大部分最终利用到的硬件资源,又都是在干这个搬运数据的事,所以就需要尽可能地减少数据搬运的需求。Kafka做的事情,就是把这个数据搬运的次数,从上面的四次变成了两次,并且只有DMA来进行数据搬运,而不需要CPU。如下面的代码所示:

@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
    return fileChannel.transferTo(position, count, socketChannel);
}

Kafka的代码调用了Java NIO库,具体是FileChannel里面的transferTo方法。数据并没有读到中间的应用内存里面,而是直接通过Channel写入到对应的网络设备里。并且对于Socket的操作,也不是写入到 Socket的Buffer里面,而是直接根据描述符(Descriptor)直接写入到网卡的缓冲区里面于是在这个过程之中,Kafka只进行了两次数据传输。如下所示:

第一次传输,是通过DMA从硬盘直接读到操作系统内核的读缓冲区里面。第二次则是根据Socket描述符信息,直接从读缓冲区里面,写入到网卡的缓冲区里面。通过砍掉两次内核空间和用户空间的数据拷贝,以及内核态和用户态的切换成本来优化,这样同一份数据传输的次数从四次变成了两次,并且没有通过CPU来进行数据搬运,所有的数据都是通过DMA来进行传输的。在这个方法里面没有在内存层面去“复制(Copy)”数据,所以这个方法也被称之为零拷贝(Zero-Copy。IBM Developer Works里有一篇文章专门写过程序来测试过,在同样的硬件下使用零拷贝能够带来的性能提升。结论是无论传输数据量的大小,传输同样的数据,使用了零拷贝能够缩短65%的时间,大幅度提升了机器传输数据的吞吐量。

十、数据完整性:检错与纠错

38. 如果在分布式计算中没有使用ECC内存(Error-Correcting Code memory,纠错内存。顾名思义就是在内存里出现错误的时候,能够自己纠正过来),可能就会出现硬件上的单比特翻转(Single-Bit Flip)错误。比如“34+23”,结果应该是“57”,但是却变成了一个美元符号“$”。这个符号出现可能是由于内存中的一个整数字符,遇到了一次单比特翻转转化而来的。4的ASCII码二进制表示是0010 0100,所以“$”完全可能来自0011 0100遇到一次在第4个比特的单比特翻转,也就是从整数“4”变过来的。如下所示:

其实内存里的单比特翻转或者错误,并不是一个特别罕见的现象。无论是因为内存的制造质量造成的漏电,还是外部的射线,都有一定的概率会造成单比特错误。而内存层面的数据出错软件工程师并不知道,而且这个出错很有可能是随机的,所以必须要有一个办法避免这个问题。在ECC内存发明之前,工程师们已经开始通过奇偶校验的方式来发现这些错误。奇偶校验的思路很简单,把内存里面N 位比特当成是一组,比如8位就是一个字节,然后用额外的一位去记录这8个比特里面有奇数个1还是偶数个1,如果是奇数个1那额外的一位就记录为1;如果是偶数个1那额外的一位就记录成0,那额外的一位就称之为校验码位。如下所示:

如果在这个字节里面发生了单比特翻转,那么数据位计算得到的校验码,就和实际校验位里面的数据不一样,内存就知道出错了。校验位有一个很大的优点,就是计算非常快,往往只需要遍历一遍需要校验的数据,通过一个O(N)的时间复杂度的算法,就能把校验结果计算出来。校验码的思路在很多地方都会用到。比如下载一些软件会看到有对应的MD5这样的哈希值或者循环冗余编码(CRC)校验文件。这样把软件下载下来之后,可以计算一下对应软件的校验码,和官方提供的校验码是不是一样,如果不一样就不能轻易去安装这个软件了,因为有可能这个软件包是坏的,或者被人篡改过。

不过使用奇偶校验,还是有两个比较大的缺陷:

(1)奇偶校验只能解决遇到单个位的错误,或者说奇数个位的错误。如果出现2个位进行了翻转,那么这个字节的校验位计算结果其实没有变,校验位自然也就不能发现这个错误。

(2)只能发现错误,但是不能纠正错误。所以即使在内存里面发现数据错误了,也只能中止程序而不能让程序继续正常地运行下去。对于庞大的分布式计算任务,无法纠错而选择从头重跑任务,肯定会让人崩溃。

因此不仅需要能捕捉到错误,还要能够纠正发生的错误,这个策略通常叫作纠错码(Error Correcting Code)。它还有一个升级版本叫作纠删码(Erasure Code),不仅能够纠正错误,还能够在错误不能纠正的时候,直接把数据删除。无论是ECC内存还是网络传输,乃至硬盘的RAID,其实都利用了纠错码和纠删码的相关技术。

39. 无论是奇偶校验码,还是CRC这样的循环校验码,都只能知道数据出错了。所以校验码也被称为检错码(Error Detecting Code)。但是,具体错在哪里校验码是回答不了的。这就导致处理方式只有一种,那就是当成“哪儿都错了”。如果是下载一个文件发现校验码不匹配,只能重新去下载;如果是程序计算后放到内存里面的数据,只能再重新算一遍,这样的效率实在是太低了,所以需要有一个办法不仅能检错还能纠错。于是计算机科学家们就发明了纠错码,纠错码需要更多的冗余信息,通过这些冗余信息不仅可以知道哪里的数据错了,还能直接把数据给改对。

最知名的纠错码就是海明码(Hamming Code)。直到今天ECC内存也还在使用海明码来纠错。最基础的海明码叫7-4海明码,这里的“7”指的是实际有效的数据一共是7位(Bit)。而这里的“4”指的是额外存储了4位数据用来纠错。纠错码的纠错能力是有限的,事实上在7-4海明码里面只能纠正某1位的错误。4位的校验码,一共可以表示 2^4 = 16个不同的数,根据各数据位计算出来的校验值一定是确定的。所以如果数据位出错了,计算出来的校验码一定和确定的那个校验码不同,那计算出的值就是在2^4 - 1 = 15那剩下的15个可能的校验值当中。

15个可能的校验值其实可以对应15个可能出错的位。既然数据位只有7位,那为什么要用4位的校验码呢,用3位不就够了吗?因为单比特翻转的错误,不仅可能出现在数据位,也有可能出现在校验位。所以7位数据位和3位校验位,如果只有单比特出错,可能出错的位数就是10位,2^3 - 1 = 7种情况是不能找到具体是哪一位出错的。事实上如果数据位有K位,校验位有N位,那么需要满足下面这个不等式,才能确保能够对单比特翻转的数据纠错

K + N + 1 ≤ 2^N

在有7位数据位,也就是K=7的情况下,N的最小值就是4。4位校验位其实最多可以支持到11位数据位。数据位数和校验位数的对照表如下所示:

40. 为了解释海明码的纠错原理,选取计算较为简单的4-3海明码(4位数据位,3位校验位)。把4位数据位分别记作d1、d2、d3、d4,这里的d取的是数据位data bits的首字母;把位校验位,分别记作p1、p2、p3,这里的p取的是校验位parity bits的首字母。从4位的数据位里面,拿走1位然后计算出一个对应的校验位。这个校验位的计算用奇偶校验就可以了,比如用d1、d2、d4来计算出一个校验位p1;用d1、d3、d4计算出一个校验位p2;用d2、d3、d4计算出一个校验位p3。如下所示:

如果d1这一位的数据出错了,会发现p1和p2的校验位计算结果与应有值不一样(因为d1参与了计算)。发现d2出错了是因为p1和p3的校验位计算结果与应有值不一致;发现d3出错了则是因为p2和p3;如果d4出错了,则是p1、p2、p3都不一致。这样当数据码出错的时候,至少会有2位校验码的计算是与应有值不一致的如果是p1的校验码出错了,这个时候只有p1的校验结果出错。p2和p3出错的结果也是一样的,即只有一个校验码的计算是不一致的。所以校验码不一致,一共有 2^3-1=7 种情况,正好对应了 7 个不同的位数的错误,如下所示:

生成海明码的步骤如下:

(1)首先要确定编码后要传输的数据是多少位。比如说7-4海明码就是一共11位。

(2)然后给这11位数据从左到右进行编号,并且也把它们的二进制(四位)表示写出来。

(3)接着先把这11个数据中的二进制的整数次幂找出来。在7-4海明码里面就是1、2、4、8,这些数就是校验码位,把它们记录作p1~p4。如果从二进制的角度看,它们是这11个数当中唯四的,在4个比特里面只有一个比特是1的数值(例如0001)。那么剩下的7个数,就是d1-d7的二进制码位了。

(4)对于校验码位还是用奇偶校验码,但是每一个校验码位不是用所有的7位数据来计算校验码,而是p1用3、5、7、9、11来计算,也就是在二进制表示下从右往左数的第一位比特是1的情况下的几个数据位(不包含1是因为1代表校验位p1);p2用3、6、7、10、11来计算校验码,也就是在二进制表示下从右往左数的第二位比特是1的几个数据位。那么p3自然是用从右往左数第三位比特是1的情况下的数据位d2到d4计算,而p4则是用第四位比特是1的情况下的数据位d5到d7计算。如下所示:

这个时候会发现,任何一个数据码出错了,就至少会有对应的两个或者三个校验码对不上,这样就能反过来找到是哪一个数据码出错了。如果校验码出错了,那么只有校验码这一位对不上,就知道是这个校验码出错了。

41. 对于两个二进制表示的数据,他们之间有差异的位数称之为海明距离。比如1001和0001的海明距离是1,因为他们只有最左侧的第一位是不同的。而1001和0000的海明距离是2,因为他们最左侧和最右侧有两位是不同的。因此所谓的进行一位纠错,也就是所有和要传输数据的海明距离为1的数,都能被纠正回来。而任何两个实际想要传输的数据,海明距离都至少要是3。因为如果是2的话,就会有一个出错的数,到两个正确数据的海明距离都为1,当看到这个出错的数的时候,就不知道究竟应该纠正到哪一个数了。

在引入了海明距离之后,就可以更形象地理解纠错码了。在没有纠错功能的情况下,看到的数据就好像是空间里面的一个个点。这个时候可以让数据之间的距离很紧凑,但是如果这些点的坐标稍稍有错,就很难搞清楚是哪一个点。在有了1位纠错功能之后,就好像把一个点变成了以这个点为中心,半径为1的球。只要坐标在这个球的范围之内,都能知道实际要的数据就是球心的坐标。而各个数据球不能距离太近,不同的数据球之间要有3个单位的距离。如下所示:

十一、分布式计算与IO

42. 一台计算机在数据中心里是不够的。因为如果只有一台计算机,会遇到三个核心问题,即垂直扩展和水平扩展的选择问题、如何保持高可用性(High Availability)和一致性问题(Consistency)。当服务器资源不够用时有2个选择,第一个选择是升级现在这台服务器的硬件,这样的选择称之为垂直扩展(Scale Up)。第二个选择则是再加一台和之前一样的服务器,这样的选择称之为水平扩展(Scale Out)。在分布式计算中,水平扩展需要引入负载均衡(Load Balancer)这样的组件来进行流量分配。同时需要拆分应用服务器和数据库服务器,来进行垂直功能的切分,而且也需要不同的应用之间通过消息队列,来进行异步任务的执行。如下所示:

所有这些软件层面的改造,其实都是在做分布式计算的一个核心工作,就是通过消息传递(Message Passing)而不是共享内存(Shared Memory)的方式,让多台不同的计算机协作起来共同完成任务。如果采用了水平扩展,即便有一台服务器的CP坏了,还有另外一台服务器仍然能够提供服务。负载均衡能够通过健康检测(Health Check)发现坏掉的服务器没有响应了,就可以自动把所有的流量切换到第2台服务器上,这个操作就叫作故障转移(Failover),系统仍然是可用的。系统的可用性(Avaiability)指的就是系统可以正常服务的时间占比。无论是因为软硬件故障,还是需要对系统进行停机升级,都会损失系统的可用性。可用性通常是用一个百分比的数字来表示,比如系统每个月的可用性要保障在99.99%,也就是意味着一个月里,服务宕机的时间不能超过4.32分钟。

例如有一个三台服务器组成的小系统,一台部署了Nginx来作为负载均衡和反向代理,一台跑了PHP-FPM作为Web应用服务器,一台用来作MySQL 数据库服务器。每台服务器的可用性都是99.99%。那么整个系统的可用性是99.99%  × 99.99%  × 99.99% = 99.97%。如下所示:

如果任何一台服务器出错了,整个系统就没法用了,这个问题就叫作单点故障问题(Single Point of Failure,SPOF)。要解决单点故障问题,首先就是要移除单点,例如让两台服务器提供相同的功能,然后通过负载均衡把流量分发到两台不同的服务器去,即使一台服务器挂了,还有一台服务器可以正常提供服务。不过光用两台服务器是不够的,单点故障其实在数据中心里面无处不在。如果是云上的两台虚拟机。如果这两台虚拟机是托管在同一台物理机上的,那这台物理机本身又成为了一个单点,那就需要把这两台虚拟机分到两台不同的物理机上。

不过这样还是不够,如果这两台物理机在同一个机架(Rack)上,那机架上的交换机(Switch)就成了一个单点。即使放到不同的机架上,还是有可能出现整个数据中心遭遇意外故障的情况。如果遇到一个数据中心内全部挂掉的情况,就需要设计进行异地多活的系统设计和部署。

只是能够去除单点,其实可用性问题还没有解决。比如上面用负载均衡把流量均匀地分发到2台服务器上,当一台应用服务器挂掉的时候,的确还有一台服务器在提供服务。但是负载均衡会把一半的流量发到已经挂掉的服务器上,所以这个时候只能算作一半可用。想要让整个服务完全可用,就需要有一套故障转移(Failover)机制。想要进行故障转移,就首先要能发现故障。例如负载均衡通常会定时去请求一个Web应用提供的健康检测(Health Check地址。这个时间间隔可能是5秒钟,如果连续2~3次发现健康检测失败,负载均衡就会自动将这台服务器的流量切换到其他服务器上。

故障转移的自动化在大型系统里是很重要的,因为服务器越多,出现故障基本就是个必然发生的事情。而自动化的故障转移既能够减少运维的人手需求,也能够缩短从故障发现到问题解决的时间周期提高可用性。

发布了35 篇原创文章 · 获赞 104 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_33588730/article/details/103002435
今日推荐