This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
<audio id="audio" title="23 | 基础篇Linux 文件系统是怎么工作的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/90/8b7e1b50b634ca14c9c204fe16fd8a90.mp3"></audio>
你好,我是倪朋飞。
通过前面CPU和内存模块的学习我相信你已经掌握了CPU和内存的性能分析以及优化思路。从这一节开始我们将进入下一个重要模块——文件系统和磁盘的I/O性能。
同CPU、内存一样磁盘和文件系统的管理也是操作系统最核心的功能。
<li>
磁盘为系统提供了最基本的持久化存储。
</li>
<li>
文件系统则在磁盘的基础上,提供了一个用来管理文件的树状结构。
</li>
那么,磁盘和文件系统是怎么工作的呢?又有哪些指标可以衡量它们的性能呢?
今天我就带你先来看看Linux文件系统的工作原理。磁盘的工作原理我们下一节再来学习。
## 索引节点和目录项
文件系统,本身是对存储设备上的文件,进行组织管理的机制。组织方式不同,就会形成不同的文件系统。
你要记住最重要的一点在Linux中一切皆文件。不仅普通的文件和目录就连块设备、套接字、管道等也都要通过统一的文件系统来管理。
为了方便管理Linux文件系统为每个文件都分配两个数据结构索引节点index node和目录项directory entry。它们主要用来记录文件的元信息和目录结构。
<li>
索引节点简称为inode用来记录文件的元数据比如inode编号、文件大小、访问权限、修改日期、数据的位置等。索引节点和文件一一对应它跟文件内容一样都会被持久化存储到磁盘中。所以记住索引节点同样占用磁盘空间。
</li>
<li>
目录项简称为dentry用来记录文件的名字、索引节点指针以及与其他目录项的关联关系。多个关联的目录项就构成了文件系统的目录结构。不过不同于索引节点目录项是由内核维护的一个内存数据结构所以通常也被叫做目录项缓存。
</li>
换句话说,索引节点是每个文件的唯一标志,而目录项维护的正是文件系统的树状结构。目录项和索引节点的关系是多对一,你可以简单理解为,一个文件可以有多个别名。
举个例子,通过硬链接为文件创建的别名,就会对应不同的目录项,不过这些目录项本质上还是链接同一个文件,所以,它们的索引节点相同。
索引节点和目录项纪录了文件的元数据,以及文件间的目录关系,那么具体来说,文件数据到底是怎么存储的呢?是不是直接写到磁盘中就好了呢?
实际上磁盘读写的最小单位是扇区然而扇区只有512B 大小如果每次都读写这么小的单位效率一定很低。所以文件系统又把连续的扇区组成了逻辑块然后每次都以逻辑块为最小单元来管理数据。常见的逻辑块大小为4KB也就是由连续的8个扇区组成。
为了帮助你理解目录项、索引节点以及文件数据的关系,我画了一张示意图。你可以对照着这张图,来回忆刚刚讲过的内容,把知识和细节串联起来。
<img src="https://static001.geekbang.org/resource/image/32/47/328d942a38230a973f11bae67307be47.png" alt="">
不过,这里有两点需要你注意。
第一目录项本身就是一个内存缓存而索引节点则是存储在磁盘中的数据。在前面的Buffer和Cache原理中我曾经提到过为了协调慢速磁盘与快速CPU的性能差异文件内容会缓存到页缓存Cache中。
那么,你应该想到,这些索引节点自然也会缓存到内存中,加速文件的访问。
第二,磁盘在执行文件系统格式化时,会被分成三个存储区域,超级块、索引节点区和数据块区。其中,
<li>
超级块,存储整个文件系统的状态。
</li>
<li>
索引节点区,用来存储索引节点。
</li>
<li>
数据块区,则用来存储文件数据。
</li>
## 虚拟文件系统
目录项、索引节点、逻辑块以及超级块构成了Linux文件系统的四大基本要素。不过为了支持各种不同的文件系统Linux内核在用户进程和文件系统的中间又引入了一个抽象层也就是虚拟文件系统VFSVirtual File System
VFS 定义了一组所有文件系统都支持的数据结构和标准接口。这样用户进程和内核中的其他子系统只需要跟VFS 提供的统一接口进行交互就可以了,而不需要再关心底层各种文件系统的实现细节。
这里我画了一张Linux文件系统的架构图帮你更好地理解系统调用、VFS、缓存、文件系统以及块存储之间的关系。
<img src="https://static001.geekbang.org/resource/image/72/12/728b7b39252a1e23a7a223cdf4aa1612.png" alt="">
通过这张图你可以看到在VFS的下方Linux支持各种各样的文件系统如Ext4、XFS、NFS等等。按照存储位置的不同这些文件系统可以分为三类。
<li>
第一类是基于磁盘的文件系统也就是把数据直接存储在计算机本地挂载的磁盘中。常见的Ext4、XFS、OverlayFS等都是这类文件系统。
</li>
<li>
第二类是基于内存的文件系统,也就是我们常说的虚拟文件系统。这类文件系统,不需要任何磁盘分配存储空间,但会占用内存。我们经常用到的 /proc 文件系统,其实就是一种最常见的虚拟文件系统。此外,/sys 文件系统也属于这一类,主要向用户空间导出层次化的内核对象。
</li>
<li>
第三类是网络文件系统也就是用来访问其他计算机数据的文件系统比如NFS、SMB、iSCSI等。
</li>
这些文件系统,要先挂载到 VFS 目录树中的某个子目录(称为挂载点),然后才能访问其中的文件。拿第一类,也就是基于磁盘的文件系统为例,在安装系统时,要先挂载一个根目录(/),在根目录下再把其他文件系统(比如其他的磁盘分区、/proc文件系统、/sys文件系统、NFS等挂载进来。
## 文件系统I/O
把文件系统挂载到挂载点后你就能通过挂载点再去访问它管理的文件了。VFS 提供了一组标准的文件访问接口。这些接口以系统调用的方式,提供给应用程序使用。
就拿cat 命令来说,它首先调用 open() ,打开一个文件;然后调用 read() ,读取文件的内容;最后再调用 write() ,把文件内容输出到控制台的标准输出中:
```
int open(const char *pathname, int flags, mode_t mode);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
```
文件读写方式的各种差异,导致 I/O的分类多种多样。最常见的有缓冲与非缓冲I/O、直接与非直接I/O、阻塞与非阻塞I/O、同步与异步I/O等。 接下来,我们就详细看这四种分类。
第一种根据是否利用标准库缓存可以把文件I/O分为缓冲I/O与非缓冲I/O。
<li>
缓冲I/O是指利用标准库缓存来加速文件的访问而标准库内部再通过系统调度访问文件。
</li>
<li>
非缓冲I/O是指直接通过系统调用来访问文件不再经过标准库缓存。
</li>
注意,这里所说的“缓冲”,是指标准库内部实现的缓存。比方说,你可能见到过,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来。
无论缓冲I/O还是非缓冲I/O它们最终还是要经过系统调用来访问文件。而根据上一节内容我们知道系统调用后还会通过页缓存来减少磁盘的I/O操作。
第二根据是否利用操作系统的页缓存可以把文件I/O分为直接I/O与非直接I/O。
<li>
直接I/O是指跳过操作系统的页缓存直接跟文件系统交互来访问文件。
</li>
<li>
非直接I/O正好相反文件读写时先要经过系统的页缓存然后再由内核或额外的系统调用真正写入磁盘。
</li>
想要实现直接I/O需要你在系统调用中指定 O_DIRECT 标志。如果没有设置过默认的是非直接I/O。
不过要注意直接I/O、非直接I/O本质上还是和文件系统交互。如果是在数据库等场景中你还会看到跳过文件系统读写磁盘的情况也就是我们通常所说的裸I/O。
第三根据应用程序是否阻塞自身运行可以把文件I/O分为阻塞I/O和非阻塞I/O
<li>
所谓阻塞I/O是指应用程序执行I/O操作后如果没有获得响应就会阻塞当前线程自然就不能执行其他任务。
</li>
<li>
所谓非阻塞I/O是指应用程序执行I/O操作后不会阻塞当前的线程可以继续执行其他的任务随后再通过轮询或者事件通知的形式获取调用的结果。
</li>
比方说,访问管道或者网络套接字时,设置 O_NONBLOCK 标志,就表示用非阻塞方式访问;而如果不做任何设置,默认的就是阻塞访问。
第四根据是否等待响应结果可以把文件I/O分为同步和异步I/O
<li>
所谓同步I/O是指应用程序执行I/O操作后要一直等到整个I/O完成后才能获得I/O响应。
</li>
<li>
所谓异步I/O是指应用程序执行I/O操作后不用等待完成和完成后的响应而是继续执行就可以。等到这次 I/O完成后响应会用事件通知的方式告诉应用程序。
</li>
举个例子,在操作文件时,如果你设置了 O_SYNC 或者 O_DSYNC 标志就代表同步I/O。如果设置了O_DSYNC就要等文件数据写入磁盘后才能返回而O_SYNC则是在O_DSYNC基础上要求文件元数据也要写入磁盘后才能返回。
再比如在访问管道或者网络套接字时设置了O_ASYNC选项后相应的I/O就是异步I/O。这样内核会再通过SIGIO或者SIGPOLL来通知进程文件是否可读写。
你可能发现了这里的好多概念也经常出现在网络编程中。比如非阻塞I/O通常会跟select/poll配合用在网络套接字的I/O中。
你也应该可以理解“Linux 一切皆文件”的深刻含义。无论是普通文件和块设备、还是网络套接字和管道等它们都通过统一的VFS 接口来访问。
## 性能观测
学了这么多文件系统的原理,你估计也是迫不及待想上手,观察一下文件系统的性能情况了。
接下来打开一个终端SSH登录到服务器上然后跟我一起来探索如何观测文件系统的性能。
### 容量
对文件系统来说,最常见的一个问题就是空间不足。当然,你可能本身就知道,用 df 命令,就能查看文件系统的磁盘空间使用情况。比如:
```
$ df /dev/sda1
Filesystem 1K-blocks Used Available Use% Mounted on
/dev/sda1 30308240 3167020 27124836 11% /
```
你可以看到我的根文件系统只使用了11%的空间。这里还要注意总空间用1K-blocks的数量来表示你可以给df加上-h选项以获得更好的可读性
```
$ df -h /dev/sda1
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 29G 3.1G 26G 11% /
```
不过有时候明明你碰到了空间不足的问题可是用df查看磁盘空间后却发现剩余空间还有很多。这是怎么回事呢
不知道你还记不记得刚才我强调的一个细节。除了文件数据索引节点也占用磁盘空间。你可以给df命令加上 -i 参数,查看索引节点的使用情况,如下所示:
```
$ df -i /dev/sda1
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda1 3870720 157460 3713260 5% /
```
索引节点的容量也就是Inode个数是在格式化磁盘时设定好的一般由格式化工具自动生成。当你发现索引节点空间不足但磁盘空间充足时很可能就是过多小文件导致的。
所以,一般来说,删除这些小文件,或者把它们移动到索引节点充足的其他磁盘中,就可以解决这个问题。
### 缓存
在前面Cache案例中我已经介绍过可以用 free 或 vmstat来观察页缓存的大小。复习一下free输出的Cache是页缓存和可回收Slab缓存的和你可以从 /proc/meminfo ,直接得到它们的大小:
```
$ cat /proc/meminfo | grep -E &quot;SReclaimable|Cached&quot;
Cached: 748316 kB
SwapCached: 0 kB
SReclaimable: 179508 kB
```
话说回来,文件系统中的目录项和索引节点缓存,又该如何观察呢?
实际上内核使用Slab机制管理目录项和索引节点的缓存。/proc/meminfo只给出了Slab的整体大小具体到每一种Slab缓存还要查看/proc/slabinfo这个文件。
比如,运行下面的命令,你就可以得到,所有目录项和各种文件系统索引节点的缓存情况:
```
$ cat /proc/slabinfo | grep -E '^#|dentry|inode'
# name &lt;active_objs&gt; &lt;num_objs&gt; &lt;objsize&gt; &lt;objperslab&gt; &lt;pagesperslab&gt; : tunables &lt;limit&gt; &lt;batchcount&gt; &lt;sharedfactor&gt; : slabdata &lt;active_slabs&gt; &lt;num_slabs&gt; &lt;sharedavail&gt;
xfs_inode 0 0 960 17 4 : tunables 0 0 0 : slabdata 0 0 0
...
ext4_inode_cache 32104 34590 1088 15 4 : tunables 0 0 0 : slabdata 2306 2306 0hugetlbfs_inode_cache 13 13 624 13 2 : tunables 0 0 0 : slabdata 1 1 0
sock_inode_cache 1190 1242 704 23 4 : tunables 0 0 0 : slabdata 54 54 0
shmem_inode_cache 1622 2139 712 23 4 : tunables 0 0 0 : slabdata 93 93 0
proc_inode_cache 3560 4080 680 12 2 : tunables 0 0 0 : slabdata 340 340 0
inode_cache 25172 25818 608 13 2 : tunables 0 0 0 : slabdata 1986 1986 0
dentry 76050 121296 192 21 1 : tunables 0 0 0 : slabdata 5776 5776 0
```
这个界面中dentry行表示目录项缓存inode_cache行表示VFS索引节点缓存其余的则是各种文件系统的索引节点缓存。
/proc/slabinfo 的列比较多,具体含义你可以查询 man slabinfo。在实际性能分析中我们更常使用 slabtop ,来找到占用内存最多的缓存类型。
比如下面就是我运行slabtop得到的结果
```
# 按下c按照缓存大小排序按下a按照活跃对象数排序
$ slabtop
Active / Total Objects (% used) : 277970 / 358914 (77.4%)
Active / Total Slabs (% used) : 12414 / 12414 (100.0%)
Active / Total Caches (% used) : 83 / 135 (61.5%)
Active / Total Size (% used) : 57816.88K / 73307.70K (78.9%)
Minimum / Average / Maximum Object : 0.01K / 0.20K / 22.88K
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
69804 23094 0% 0.19K 3324 21 13296K dentry
16380 15854 0% 0.59K 1260 13 10080K inode_cache
58260 55397 0% 0.13K 1942 30 7768K kernfs_node_cache
485 413 0% 5.69K 97 5 3104K task_struct
1472 1397 0% 2.00K 92 16 2944K kmalloc-2048
```
从这个结果你可以看到在我的系统中目录项和索引节点占用了最多的Slab缓存。不过它们占用的内存其实并不大加起来也只有23MB左右。
## 小结
今天我带你梳理了Linux文件系统的工作原理。
文件系统是对存储设备上的文件进行组织管理的一种机制。为了支持各类不同的文件系统Linux在各种文件系统实现上抽象了一层虚拟文件系统VFS
VFS 定义了一组所有文件系统都支持的数据结构和标准接口。这样,用户进程和内核中的其他子系统,就只需要跟 VFS 提供的统一接口进行交互。
为了降低慢速磁盘对性能的影响,文件系统又通过页缓存、目录项缓存以及索引节点缓存,缓和磁盘延迟对应用程序的影响。
在性能观测方面今天主要讲了容量和缓存的指标。下一节我们将会学习Linux磁盘 I/O的工作原理并掌握磁盘I/O的性能观测方法。
## 思考
最后,给你留一个思考题。在实际工作中,我们经常会根据文件名字,查找它所在路径,比如:
```
$ find / -name file-name
```
今天的问题就是,这个命令,会不会导致系统的缓存升高呢?如果有影响,又会导致哪种类型的缓存升高呢?你可以结合今天内容,自己先去操作和分析,看看观察到的结果跟你分析的是否一样。
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="24 | 基础篇Linux 磁盘I/O是怎么工作的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/a8/75059dfe1178fb7fd7a2ff95b0e775a8.mp3"></audio>
你好,我是倪朋飞。
上一节,我们学习了 Linux 文件系统的工作原理。简单回顾一下文件系统是对存储设备上的文件进行组织管理的一种机制。而Linux 在各种文件系统实现上又抽象了一层虚拟文件系统VFS它定义了一组所有文件系统都支持的数据结构和标准接口。
这样,对应用程序来说,只需要跟 VFS 提供的统一接口交互,而不需要关注文件系统的具体实现;对具体的文件系统来说,只需要按照 VFS 的标准,就可以无缝支持各种应用程序。
VFS 内部又通过目录项、索引节点、逻辑块以及超级块等数据结构,来管理文件。
<li>
目录项,记录了文件的名字,以及文件与其他目录项之间的目录关系。
</li>
<li>
索引节点,记录了文件的元数据。
</li>
<li>
逻辑块,是由连续磁盘扇区构成的最小读写单元,用来存储文件数据。
</li>
<li>
超级块,用来记录文件系统整体的状态,如索引节点和逻辑块的使用情况等。
</li>
其中,目录项是一个内存缓存;而超级块、索引节点和逻辑块,都是存储在磁盘中的持久化数据。
那么,进一步想,磁盘又是怎么工作的呢?又有哪些指标可以用来衡量它的性能呢?
接下来,我就带你一起看看, Linux 磁盘I/O的工作原理。
## 磁盘
磁盘是可以持久化存储的设备,根据存储介质的不同,常见磁盘可以分为两类:机械磁盘和固态磁盘。
第一类机械磁盘也称为硬盘驱动器Hard Disk Driver通常缩写为 HDD。机械磁盘主要由盘片和读写磁头组成数据就存储在盘片的环状磁道中。在读写数据前需要移动读写磁头定位到数据所在的磁道然后才能访问数据。
显然,如果 I/O 请求刚好连续,那就不需要磁道寻址,自然可以获得最佳性能。这其实就是我们熟悉的,连续 I/O的工作原理。与之相对应的当然就是随机 I/O它需要不停地移动磁头来定位数据位置所以读写速度就会比较慢。
第二类固态磁盘Solid State Disk通常缩写为SSD由固态电子元器件组成。固态磁盘不需要磁道寻址所以不管是连续I/O还是随机I/O的性能都比机械磁盘要好得多。
其实,无论机械磁盘,还是固态磁盘,相同磁盘的随机 I/O 都要比连续 I/O 慢很多,原因也很明显。
<li>
对机械磁盘来说我们刚刚提到过的由于随机I/O需要更多的磁头寻道和盘片旋转它的性能自然要比连续I/O慢。
</li>
<li>
而对固态磁盘来说虽然它的随机性能比机械硬盘好很多但同样存在“先擦除再写入”的限制。随机读写会导致大量的垃圾回收所以相对应的随机I/O的性能比起连续I/O来也还是差了很多。
</li>
<li>
此外连续I/O还可以通过预读的方式来减少I/O请求的次数这也是其性能优异的一个原因。很多性能优化的方案也都会从这个角度出发来优化I/O性能。
</li>
此外,机械磁盘和固态磁盘还分别有一个最小的读写单位。
<li>
机械磁盘的最小读写单位是扇区一般大小为512字节。
</li>
<li>
而固态磁盘的最小读写单位是页通常大小是4KB、8KB等。
</li>
在上一节中,我也提到过,如果每次都读写 512 字节这么小的单位的话效率很低。所以文件系统会把连续的扇区或页组成逻辑块然后以逻辑块作为最小单元来管理数据。常见的逻辑块的大小是4KB也就是说连续8个扇区或者单独的一个页都可以组成一个逻辑块。
除了可以按照存储介质来分类,另一个常见的分类方法,是按照接口来分类,比如可以把硬盘分为 IDEIntegrated Drive Electronics、SCSISmall Computer System Interface 、SASSerial Attached SCSI 、SATASerial ATA 、FCFibre Channel 等。
不同的接口,往往分配不同的设备名称。比如, IDE 设备会分配一个 hd 前缀的设备名SCSI和SATA设备会分配一个 sd 前缀的设备名。如果是多块同类型的磁盘就会按照a、b、c等的字母顺序来编号。
除了磁盘本身的分类外,当你把磁盘接入服务器后,按照不同的使用方式,又可以把它们划分为多种不同的架构。
最简单的,就是直接作为独立磁盘设备来使用。这些磁盘,往往还会根据需要,划分为不同的逻辑分区,每个分区再用数字编号。比如我们前面多次用到的 /dev/sda ,还可以分成两个分区 /dev/sda1和/dev/sda2。
另一个比较常用的架构是把多块磁盘组合成一个逻辑磁盘构成冗余独立磁盘阵列也就是RAIDRedundant Array of Independent Disks从而可以提高数据访问的性能并且增强数据存储的可靠性。
根据容量、性能和可靠性需求的不同RAID一般可以划分为多个级别如RAID0、RAID1、RAID5、RAID10等。
<li>
RAID0有最优的读写性能但不提供数据冗余的功能。
</li>
<li>
而其他级别的RAID在提供数据冗余的基础上对读写性能也有一定程度的优化。
</li>
最后一种架构是把这些磁盘组合成一个网络存储集群再通过NFS、SMB、iSCSI等网络存储协议暴露给服务器使用。
其实在 Linux 中,**磁盘实际上是作为一个块设备来管理的**,也就是以块为单位读写数据,并且支持随机读写。每个块设备都会被赋予两个设备号,分别是主、次设备号。主设备号用在驱动程序中,用来区分设备类型;而次设备号则是用来给多个同类设备编号。
## 通用块层
跟我们上一节讲到的虚拟文件系统VFS类似为了减小不同块设备的差异带来的影响Linux 通过一个统一的通用块层,来管理各种不同的块设备。
通用块层,其实是处在文件系统和磁盘驱动中间的一个块设备抽象层。它主要有两个功能 。
<li>
第一个功能跟虚拟文件系统的功能类似。向上,为文件系统和应用程序,提供访问块设备的标准接口;向下,把各种异构的磁盘设备抽象为统一的块设备,并提供统一框架来管理这些设备的驱动程序。
</li>
<li>
第二个功能,通用块层还会给文件系统和应用程序发来的 I/O 请求排队,并通过重新排序、请求合并等方式,提高磁盘读写的效率。
</li>
其中,对 I/O 请求排序的过程,也就是我们熟悉的 I/O 调度。事实上Linux 内核支持四种I/O调度算法分别是NONE、NOOP、CFQ以及DeadLine。这里我也分别介绍一下。
第一种 NONE ,更确切来说,并不能算 I/O 调度算法。因为它完全不使用任何I/O调度器对文件系统和应用程序的I/O其实不做任何处理常用在虚拟机中此时磁盘I/O调度完全由物理机负责
第二种 NOOP ,是最简单的一种 I/O 调度算法。它实际上是一个先入先出的队列,只做一些最基本的请求合并,常用于 SSD 磁盘。
第三种 CFQCompletely Fair Scheduler也被称为完全公平调度器是现在很多发行版的默认 I/O 调度器,它为每个进程维护了一个 I/O 调度队列,并按照时间片来均匀分布每个进程的 I/O 请求。
类似于进程 CPU 调度CFQ 还支持进程 I/O 的优先级调度,所以它适用于运行大量进程的系统,像是桌面环境、多媒体应用等。
最后一种 DeadLine 调度算法,分别为读、写请求创建了不同的 I/O 队列可以提高机械磁盘的吞吐量并确保达到最终期限deadline的请求被优先处理。DeadLine 调度算法,多用在 I/O 压力比较重的场景,比如数据库等。
## I/O栈
清楚了磁盘和通用块层的工作原理再结合上一期我们讲过的文件系统原理我们就可以整体来看Linux存储系统的 I/O 原理了。
我们可以把Linux 存储系统的 I/O 栈由上到下分为三个层次分别是文件系统层、通用块层和设备层。这三个I/O层的关系如下图所示这其实也是 Linux 存储系统的 I/O 栈全景图。
<img src="https://static001.geekbang.org/resource/image/14/b1/14bc3d26efe093d3eada173f869146b1.png" alt=""><br>
(图片来自 [Linux Storage Stack Diagram](https://www.thomas-krenn.com/en/wiki/Linux_Storage_Stack_Diagram)
根据这张 I/O 栈的全景图,我们可以更清楚地理解,存储系统 I/O 的工作原理。
<li>
文件系统层,包括虚拟文件系统和其他各种文件系统的具体实现。它为上层的应用程序,提供标准的文件访问接口;对下会通过通用块层,来存储和管理磁盘数据。
</li>
<li>
通用块层,包括块设备 I/O 队列和 I/O 调度器。它会对文件系统的 I/O 请求进行排队,再通过重新排序和请求合并,然后才要发送给下一级的设备层。
</li>
<li>
设备层包括存储设备和相应的驱动程序负责最终物理设备的I/O操作。
</li>
存储系统的 I/O ,通常是整个系统中最慢的一环。所以, Linux 通过多种缓存机制来优化 I/O 效率。
比方说,为了优化文件访问的性能,会使用页缓存、索引节点缓存、目录项缓存等多种缓存机制,以减少对下层块设备的直接调用。
同样,为了优化块设备的访问效率,会使用缓冲区,来缓存块设备的数据。
不过,抽象的原理讲了这么多,具体操作起来,应该怎么衡量磁盘的 I/O 性能呢?我先卖个关子,下节课我们一起来看,最常用的磁盘 I/O 性能指标,以及 I/O 性能工具。
## 小结
在今天的文章中,我们梳理了 Linux 磁盘 I/O 的工作原理,并了解了由文件系统层、通用块层和设备层构成的 Linux 存储系统 I/O 栈。
其中,通用块层是 Linux 磁盘 I/O 的核心。向上,它为文件系统和应用程序,提供访问了块设备的标准接口;向下,把各种异构的磁盘设备,抽象为统一的块设备,并会对文件系统和应用程序发来的 I/O 请求进行重新排序、请求合并等,提高了磁盘访问的效率。
## 思考
最后,我想邀请你一起来聊聊,你所理解的磁盘 I/O。我相信你很可能已经碰到过文件或者磁盘的 I/O 性能问题,你是怎么分析这些问题的呢?你可以结合今天的磁盘 I/O 原理和上一节的文件系统原理,记录你的操作步骤,并总结出自己的思路。
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,155 @@
<audio id="audio" title="25 | 基础篇Linux 磁盘I/O是怎么工作的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/14/7441a367d3701e7b5897132699baf314.mp3"></audio>
你好,我是倪朋飞。
上一节我们学习了 Linux 磁盘 I/O 的工作原理,并了解了由文件系统层、通用块层和设备层构成的 Linux 存储系统 I/O 栈。
其中,通用块层是 Linux 磁盘 I/O 的核心。向上,它为文件系统和应用程序,提供访问了块设备的标准接口;向下,把各种异构的磁盘设备,抽象为统一的块设备,并会对文件系统和应用程序发来的 I/O 请求,进行重新排序、请求合并等,提高了磁盘访问的效率。
掌握了磁盘 I/O 的工作原理,你估计迫不及待想知道,怎么才能衡量磁盘的 I/O 性能。
接下来,我们就来看看,磁盘的性能指标,以及观测这些指标的方法。
## 磁盘性能指标
说到磁盘性能的衡量标准必须要提到五个常见指标也就是我们经常用到的使用率、饱和度、IOPS、吞吐量以及响应时间等。这五个指标是衡量磁盘性能的基本指标。
<li>
使用率是指磁盘处理I/O的时间百分比。过高的使用率比如超过80%),通常意味着磁盘 I/O 存在性能瓶颈。
</li>
<li>
饱和度,是指磁盘处理 I/O 的繁忙程度。过高的饱和度,意味着磁盘存在严重的性能瓶颈。当饱和度为 100% 时,磁盘无法接受新的 I/O 请求。
</li>
<li>
IOPSInput/Output Per Second是指每秒的 I/O 请求数。
</li>
<li>
吞吐量,是指每秒的 I/O 请求大小。
</li>
<li>
响应时间,是指 I/O 请求从发出到收到响应的间隔时间。
</li>
这里要注意的是,使用率只考虑有没有 I/O而不考虑 I/O 的大小。换句话说,当使用率是 100% 的时候,磁盘依然有可能接受新的 I/O 请求。
这些指标很可能是你经常挂在嘴边的一讨论磁盘性能必定提起的对象。不过我还是要强调一点不要孤立地去比较某一指标而要结合读写比例、I/O类型随机还是连续以及 I/O 的大小,综合来分析。
举个例子在数据库、大量小文件等这类随机读写比较多的场景中IOPS 更能反映系统的整体性能;而在多媒体等顺序读写较多的场景中,吞吐量才更能反映系统的整体性能。
一般来说,我们在为应用程序的服务器选型时,要先对磁盘的 I/O 性能进行基准测试,以便可以准确评估,磁盘性能是否可以满足应用程序的需求。
这一方面,我推荐用性能测试工具 fio 来测试磁盘的IOPS、吞吐量以及响应时间等核心指标。但还是那句话因地制宜灵活选取。在基准测试时一定要注意根据应用程序 I/O 的特点,来具体评估指标。
当然,这就需要你测试出,不同 I/O 大小(一般是 512B 至 1MB 中间的若干值)分别在随机读、顺序读、随机写、顺序写等各种场景下的性能情况。
用性能工具得到的这些指标,可以作为后续分析应用程序性能的依据。一旦发生性能问题,你就可以把它们作为磁盘性能的极限值,进而评估磁盘 I/O 的使用情况。
了解磁盘的性能指标只是我们I/O性能测试的第一步。接下来又该用什么方法来观测它们呢这里我给你介绍几个常用的I/O性能观测方法。
## **磁盘I/O观测**
第一个要观测的,是每块磁盘的使用情况。
iostat 是最常用的磁盘I/O性能观测工具它提供了每个磁盘的使用率、IOPS、吞吐量等各种常见的性能指标当然这些指标实际上来自 /proc/diskstats。
iostat 的输出界面如下。
```
# -d -x表示显示所有磁盘I/O的指标
$ iostat -d -x 1
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz svctm %util
loop0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
loop1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
```
从这里你可以看到iostat 提供了非常丰富的性能指标。第一列的 Device 表示磁盘设备的名字,其他各列指标,虽然数量较多,但是每个指标的含义都很重要。为了方便你理解,我把它们总结成了一个表格。
<img src="https://static001.geekbang.org/resource/image/cf/8d/cff31e715af51c9cb8085ce1bb48318d.png" alt="">
这些指标中,你要注意:
<li>
%util 就是我们前面提到的磁盘I/O使用率
</li>
<li>
r/s+ w/s ,就是 IOPS
</li>
<li>
rkB/s+wkB/s ,就是吞吐量;
</li>
<li>
r_await+w_await ,就是响应时间。
</li>
在观测指标时,也别忘了结合请求的大小( rareq-sz 和wareq-sz一起分析。
你可能注意到,从 iostat 并不能直接得到磁盘饱和度。事实上,饱和度通常也没有其他简单的观测方法,不过,你可以把观测到的,平均请求队列长度或者读写请求完成的等待时间,跟基准测试的结果(比如通过 fio进行对比综合评估磁盘的饱和情况。
## **进程I/O观测**
除了每块磁盘的 I/O 情况,每个进程的 I/O 情况也是我们需要关注的重点。
上面提到的 iostat 只提供磁盘整体的 I/O 性能数据缺点在于并不能知道具体是哪些进程在进行磁盘读写。要观察进程的I/O情况你还可以使用 pidstat 和 iotop 这两个工具。
pidstat 是我们的老朋友了,这里我就不再啰嗦它的功能了。给它加上 -d 参数你就可以看到进程的I/O情况如下所示
```
$ pidstat -d 1
13:39:51 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
13:39:52 102 916 0.00 4.00 0.00 0 rsyslogd
```
从pidstat的输出你能看到它可以实时查看每个进程的I/O情况包括下面这些内容。
<li>
用户IDUID和进程IDPID
</li>
<li>
每秒读取的数据大小kB_rd/s ,单位是 KB。
</li>
<li>
每秒发出的写请求数据大小kB_wr/s ,单位是 KB。
</li>
<li>
每秒取消的写请求数据大小kB_ccwr/s ,单位是 KB。
</li>
<li>
块I/O延迟iodelay包括等待同步块I/O和换入块I/O结束的时间单位是时钟周期。
</li>
除了可以用 pidstat 实时查看,根据 I/O 大小对进程排序,也是性能分析中一个常用的方法。这一点,我推荐另一个工具, iotop。它是一个类似于 top 的工具,你可以按照 I/O 大小对进程排序然后找到I/O较大的那些进程。
iotop 的输出如下所示:
```
$ iotop
Total DISK READ : 0.00 B/s | Total DISK WRITE : 7.85 K/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 0.00 B/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO&gt; COMMAND
15055 be/3 root 0.00 B/s 7.85 K/s 0.00 % 0.00 % systemd-journald
```
从这个输出你可以看到前两行分别表示进程的磁盘读写大小总数和磁盘真实的读写大小总数。因为缓存、缓冲区、I/O合并等因素的影响它们可能并不相等。
剩下的部分则是从各个角度来分别表示进程的I/O情况包括线程ID、I/O优先级、每秒读磁盘的大小、每秒写磁盘的大小、换入和等待I/O的时钟百分比等。
这两个工具,是我们分析磁盘 I/O 性能时最常用到的。你先了解它们的功能和指标含义,具体的使用方法,接下来的案例实战中我们一起学习。
## 小结
今天,我们梳理了 Linux 磁盘 I/O 的性能指标和性能工具。我们通常用IOPS、吞吐量、使用率、饱和度以及响应时间等几个指标来评估磁盘的 I/O 性能。
你可以用 iostat 获得磁盘的 I/O 情况,也可以用 pidstat、iotop 等观察进程的 I/O 情况。不过在分析这些性能指标时你要注意结合读写比例、I/O 类型以及 I/O 大小等,进行综合分析。
## 思考
最后,我想请你一起来聊聊,你碰到过的磁盘 I/O 问题。在碰到磁盘 I/O 性能问题时,你是怎么分析和定位的呢?你可以结合今天学到的磁盘 I/O 指标和工具,以及上一节学过的磁盘 I/O 原理,来总结你的思路。
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,299 @@
<audio id="audio" title="26 | 案例篇:如何找出狂打日志的“内鬼”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/8a/62f4d5f6ab23e928dc35143379ddb08a.mp3"></audio>
你好,我是倪朋飞。
前两节,我们学了文件系统和磁盘的 I/O 原理,我先带你复习一下。
文件系统是对存储设备上的文件进行组织管理的一种机制。为了支持各类不同的文件系统Linux在各种文件系统上抽象了一层虚拟文件系统VFS。
它定义了一组所有文件系统都支持的数据结构和标准接口。这样,应用程序和内核中的其他子系统,就只需要跟 VFS 提供的统一接口进行交互。
在文件系统的下层为了支持各种不同类型的存储设备Linux又在各种存储设备的基础上抽象了一个通用块层。
通用块层,为文件系统和应用程序提供了访问块设备的标准接口;同时,为各种块设备的驱动程序提供了统一的框架。此外,通用块层还会对文件系统和应用程序发送过来的 I/O 请求进行排队,并通过重新排序、请求合并等方式,提高磁盘读写的效率。
通用块层的下一层,自然就是设备层了,包括各种块设备的驱动程序以及物理存储设备。
文件系统、通用块层以及设备层,就构成了 Linux 的存储 I/O 栈。存储系统的 I/O 通常是整个系统中最慢的一环。所以Linux 采用多种缓存机制,来优化 I/O 的效率,比方说,
<li>
为了优化文件访问的性能,采用页缓存、索引节点缓存、目录项缓存等多种缓存机制,减少对下层块设备的直接调用。
</li>
<li>
同样的,为了优化块设备的访问效率,使用缓冲区来缓存块设备的数据。
</li>
不过,在碰到文件系统和磁盘的 I/O 问题时,具体应该怎么定位和分析呢?今天,我就以一个最常见的应用程序记录大量日志的案例,带你来分析这种情况。
## 案例准备
本次案例还是基于 Ubuntu 18.04,同样适用于其他的 Linux 系统。我使用的案例环境如下所示:
<li>
机器配置2 CPU8GB 内存
</li>
<li>
预先安装 docker、sysstat 等工具,如 apt install [docker.io](http://docker.io/) sysstat
</li>
这里要感谢唯品会资深运维工程师阳祥义帮忙,分担了今天的案例。这个案例,是一个用 Python 开发的小应用,为了方便运行,我把它打包成了一个 Docker 镜像。这样,你只要运行 Docker 命令,就可以启动它。
接下来打开一个终端SSH 登录到案例所用的机器中,并安装上述工具。跟以前一样,案例中所有命令,都默认以 root 用户运行。如果你是用普通用户身份登陆系统,请运行 sudo su root 命令,切换到 root 用户。
到这里,准备工作就完成了。接下来,我们正式进入操作环节。
>
温馨提示:案例中 Python 应用的核心逻辑比较简单,你可能一眼就能看出问题,但实际生产环境中的源码就复杂多了。所以,我依旧建议,操作之前别看源码,避免先入为主,要把它当成一个黑盒来分析。这样,你可以更好把握住,怎么从系统的资源使用问题出发,分析出瓶颈所在的应用,以及瓶颈在应用中大概的位置。
## 案例分析
首先,我们在终端中执行下面的命令,运行今天的目标应用:
```
$ docker run -v /tmp:/tmp --name=app -itd feisky/logapp
```
然后,在终端中运行 ps 命令,确认案例应用正常启动。如果操作无误,你应该可以在 ps 的输出中,看到一个 app.py 的进程:
```
$ ps -ef | grep /app.py
root 18940 18921 73 14:41 pts/0 00:00:02 python /app.py
```
接着,我们来看看系统有没有性能问题。要观察哪些性能指标呢?前面文章中,我们知道 CPU、内存和磁盘 I/O 等系统资源,很容易出现资源瓶颈,这就是我们观察的方向了。我们来观察一下这些资源的使用情况。
当然,动手之前你应该想清楚,要用哪些工具来做,以及工具的使用顺序又是怎样的。你可以先回忆下前面的案例和思路,自己想一想,然后再继续下面的步骤。
我的想法是,我们可以先用 top ,来观察 CPU 和内存的使用情况;然后再用 iostat ,来观察磁盘的 I/O 情况。
所以,接下来,你可以在终端中运行 top 命令,观察 CPU 和内存的使用情况:
```
# 按1切换到每个CPU的使用情况
$ top
top - 14:43:43 up 1 day, 1:39, 2 users, load average: 2.48, 1.09, 0.63
Tasks: 130 total, 2 running, 74 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.7 us, 6.0 sy, 0.0 ni, 0.7 id, 92.7 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 0.3 sy, 0.0 ni, 92.3 id, 7.3 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 8169308 total, 747684 free, 741336 used, 6680288 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 7113124 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
18940 root 20 0 656108 355740 5236 R 6.3 4.4 0:12.56 python
1312 root 20 0 236532 24116 9648 S 0.3 0.3 9:29.80 python3
```
观察 top 的输出你会发现CPU0 的使用率非常高它的系统CPU使用率sys%)为 6%,而 iowait 超过了 90%。这说明 CPU0 上,可能正在运行 I/O 密集型的进程。不过,究竟是什么原因呢?这个疑问先保留着,我们先继续看完。
接着我们来看,进程部分的 CPU 使用情况。你会发现, python 进程的 CPU 使用率已经达到了 6%,而其余进程的 CPU 使用率都比较低,不超过 0.3%。看起来 python 是个可疑进程。记下 python 进程的 PID 号 18940我们稍后分析。
最后再看内存的使用情况,总内存 8G剩余内存只有 730 MB而 Buffer/Cache占用内存高达6GB 之多,这说明内存主要被缓存占用。虽然大部分缓存可回收,我们还是得了解下缓存的去处,确认缓存使用都是合理的。
到这一步你基本可以判断出CPU 使用率中的 iowait 是一个潜在瓶颈,而内存部分的缓存占比较大,那磁盘 I/O 又是怎么样的情况呢?
我们在终端中按 Ctrl+C ,停止 top 命令,再运行 iostat 命令,观察 I/O 的使用情况:
```
# -d表示显示I/O性能指标-x表示显示扩展统计即所有I/O指标
$ iostat -x -d 1
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz svctm %util
loop0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sda 0.00 64.00 0.00 32768.00 0.00 0.00 0.00 0.00 0.00 7270.44 1102.18 0.00 512.00 15.50 99.20
```
还记得这些性能指标的含义吗?先自己回忆一下,如果实在想不起来,查看上一节的内容,或者用 man iostat 查询。
观察 iostat 的最后一列,你会看到,磁盘 sda 的 I/O 使用率已经高达 99%,很可能已经接近 I/O 饱和。
再看前面的各个指标,每秒写磁盘请求数是 64 ,写大小是 32 MB写请求的响应时间为 7 秒,而请求队列长度则达到了 1100。
超慢的响应时间和特长的请求队列长度,进一步验证了 I/O 已经饱和的猜想。此时sda 磁盘已经遇到了严重的性能瓶颈。
到这里,也就可以理解,为什么前面看到的 iowait 高达 90% 了,这正是磁盘 sda 的 I/O 瓶颈导致的。接下来的重点就是分析 I/O 性能瓶颈的根源了。那要怎么知道,这些 I/O请求相关的进程呢
不知道你还记不记得,上一节我曾提到过,可以用 pidstat 或者 iotop ,观察进程的 I/O 情况。这里,我就用 pidstat 来看一下。
使用 pidstat 加上 -d 参数,就可以显示每个进程的 I/O 情况。所以,你可以在终端中运行如下命令来观察:
```
$ pidstat -d 1
15:08:35 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
15:08:36 0 18940 0.00 45816.00 0.00 96 python
15:08:36 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
15:08:37 0 354 0.00 0.00 0.00 350 jbd2/sda1-8
15:08:37 0 18940 0.00 46000.00 0.00 96 python
15:08:37 0 20065 0.00 0.00 0.00 1503 kworker/u4:2
```
从 pidstat 的输出,你可以发现,只有 python 进程的写比较大,而且每秒写的数据超过 45 MB比上面 iostat 发现的 32MB 的结果还要大。很明显,正是 python 进程导致了 I/O 瓶颈。
再往下看 iodelay 项。虽然只有 python 在大量写数据,但你应该注意到了,有两个进程 kworker 和 jbd2 )的延迟,居然比 python 进程还大很多。
这其中kworker 是一个内核线程,而 jbd2 是 ext4 文件系统中,用来保证数据完整性的内核线程。他们都是保证文件系统基本功能的内核线程,所以具体细节暂时就不用管了,我们只需要明白,它们延迟的根源还是大量 I/O。
综合pidstat的输出来看还是python进程的嫌疑最大。接下来我们来分析 python 进程到底在写什么。
首先留意一下 python 进程的 PID 号, 18940。看到 18940 你有没有觉得熟悉其实前面在使用top时我们记录过的 CPU 使用率最高的进程也正是它。不过虽然在top中使用率最高也不过是 6%并不算高。所以以I/O问题为分析方向还是正确的。
知道了进程的 PID 号,具体要怎么查看写的情况呢?
其实,我在系统调用的案例中讲过,读写文件必须通过系统调用完成。观察系统调用情况,就可以知道进程正在写的文件。想起 strace 了吗,它正是我们分析系统调用时最常用的工具。
接下来我们在终端中运行strace 命令,并通过 -p 18940 指定 python 进程的 PID 号:
```
$ strace -p 18940
strace: Process 18940 attached
...
mmap(NULL, 314576896, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f0f7aee9000
mmap(NULL, 314576896, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f0f682e8000
write(3, &quot;2018-12-05 15:23:01,709 - __main&quot;..., 314572844
) = 314572844
munmap(0x7f0f682e8000, 314576896) = 0
write(3, &quot;\n&quot;, 1) = 1
munmap(0x7f0f7aee9000, 314576896) = 0
close(3) = 0
stat(&quot;/tmp/logtest.txt.1&quot;, {st_mode=S_IFREG|0644, st_size=943718535, ...}) = 0
```
从 write() 系统调用上,我们可以看到,进程向文件描述符编号为 3 的文件中,写入了 300MB 的数据。看来它应该是我们要找的文件。不过write() 调用中只能看到文件的描述符编号,文件名和路径还是未知的。
再观察后面的 stat() 调用,你可以看到,它正在获取 /tmp/logtest.txt.1 的状态。 这种“点+数字格式”的文件,在日志回滚中非常常见。我们可以猜测,这是第一个日志回滚文件,而正在写的日志文件路径,则是/tmp/logtest.txt。
当然,这只是我们的猜测,自然还需要验证。这里,我再给你介绍一个新的工具 lsof。它专门用来查看进程打开文件列表不过这里的“文件”不只有普通文件还包括了目录、块设备、动态库、网络套接字等。
接下来,我们在终端中运行下面的 lsof 命令,看看进程 18940 都打开了哪些文件:
```
$ lsof -p 18940
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python 18940 root cwd DIR 0,50 4096 1549389 /
python 18940 root rtd DIR 0,50 4096 1549389 /
python 18940 root 2u CHR 136,0 0t0 3 /dev/pts/0
python 18940 root 3w REG 8,1 117944320 303 /tmp/logtest.txt
```
这个输出界面中有几列我简单介绍一下FD 表示文件描述符号TYPE 表示文件类型NAME 表示文件路径。这也是我们需要关注的重点。
再看最后一行,这说明,这个进程打开了文件 /tmp/logtest.txt并且它的文件描述符是 3 号而3 后面的 w ,表示以写的方式打开。
这跟刚才 strace 完我们猜测的结果一致,看来这就是问题的根源:进程 18940 以每次 300MB 的速度,在“疯狂”写日志,而日志文件的路径是 /tmp/logtest.txt。
既然找出了问题根源,接下来按照惯例,就该查看源代码,然后分析为什么这个进程会狂打日志了。
你可以运行 docker cp 命令,把案例应用的源代码拷贝出来,然后查看它的内容。(你也可以点击[这里](https://github.com/feiskyer/linux-perf-examples/tree/master/logging-app)查看案例应用的源码):
```
#拷贝案例应用源代码到当前目录
$ docker cp app:/app.py .
#查看案例应用的源代码
$ cat app.py
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
rHandler = RotatingFileHandler(&quot;/tmp/logtest.txt&quot;, maxBytes=1024 * 1024 * 1024, backupCount=1)
rHandler.setLevel(logging.INFO)
def write_log(size):
'''Write logs to file'''
message = get_message(size)
while True:
logger.info(message)
time.sleep(0.1)
if __name__ == '__main__':
msg_size = 300 * 1024 * 1024
write_log(msg_size)
```
分析这个源码,我们发现,它的日志路径是 /tmp/logtest.txt默认记录 INFO 级别以上的所有日志,而且每次写日志的大小是 300MB。这跟我们上面的分析结果是一致的。
一般来说,生产系统的应用程序,应该有动态调整日志级别的功能。继续查看源码,你会发现,这个程序也可以调整日志级别。如果你给它发送 SIGUSR1 信号,就可以把日志调整为 INFO 级;发送 SIGUSR2 信号,则会调整为 WARNING 级:
```
def set_logging_info(signal_num, frame):
'''Set loging level to INFO when receives SIGUSR1'''
logger.setLevel(logging.INFO)
def set_logging_warning(signal_num, frame):
'''Set loging level to WARNING when receives SIGUSR2'''
logger.setLevel(logging.WARNING)
signal.signal(signal.SIGUSR1, set_logging_info)
signal.signal(signal.SIGUSR2, set_logging_warning)
```
根据源码中的日志调用 logger. info(message) ,我们知道,它的日志是 INFO 级,这也正是它的默认级别。那么,只要把默认级别调高到 WARNING 级,日志问题应该就解决了。
接下来,我们就来检查一下,刚刚的分析对不对。在终端中运行下面的 kill 命令,给进程 18940 发送 SIGUSR2 信号:
```
$ kill -SIGUSR2 18940
```
然后,再执行 top 和 iostat 观察一下:
```
$ top
...
%Cpu(s): 0.3 us, 0.2 sy, 0.0 ni, 99.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
```
```
$ iostat -d -x 1
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz svctm %util
loop0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
```
观察top 和 iostat 的输出你会发现稍等一段时间后iowait 会变成 0而 sda 磁盘的 I/O 使用率也会逐渐减少到 0。
到这里,我们不仅定位了狂打日志的应用程序,并通过调高日志级别的方法,完美解决了 I/O 的性能瓶颈。
案例最后,当然不要忘了运行下面的命令,停止案例应用:
```
$ docker rm -f app
```
## 小结
日志,是了解应用程序内部运行情况,最常用、也最有效的工具。无论是操作系统,还是应用程序,都会记录大量的运行日志,以便事后查看历史记录。这些日志一般按照不同级别来开启,比如,开发环境通常打开调试级别的日志,而线上环境则只记录警告和错误日志。
在排查应用程序问题时,我们可能需要,在线上环境临时开启应用程序的调试日志。有时候,事后一不小心就忘了调回去。没把线上的日志调高到警告级别,可能会导致 CPU 使用率、磁盘 I/O 等一系列的性能问题,严重时,甚至会影响到同一台服务器上运行的其他应用程序。
今后,在碰到这种“狂打日志”的场景时,你可以用 iostat、strace、lsof 等工具来定位狂打日志的进程,找出相应的日志文件,再通过应用程序的接口,调整日志级别来解决问题。
如果应用程序不能动态调整日志级别,你可能还需要修改应用的配置,并重启应用让配置生效。
## 思考
最后,给你留一个思考题。
在今天的案例开始时,我们用 top 和 iostat 查看了系统资源的使用情况。除了 CPU 和磁盘 I/O外剩余内存也比较少而内存主要被 Buffer/Cache 占用。
那么,今天的问题就是,这些内存到底是被 Buffer 还是 Cache 占用了呢?有没有什么方法来确认你的分析结果呢?
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,390 @@
<audio id="audio" title="27 | 案例篇为什么我的磁盘I/O延迟很高" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/f8/cd3d4d911c166dc81536d166a14e6df8.mp3"></audio>
你好,我是倪朋飞。
上一节,我们研究了一个狂打日志引发 I/O 性能问题的案例,先来简单回顾一下。
日志,是了解应用程序内部运行情况,最常用也是最有效的工具。日志一般会分为调试、信息、警告、错误等多个不同级别。
通常,生产环境只用开启警告级别的日志,这一般不会导致 I/O 问题。但在偶尔排查问题时,可能需要我们开启调试日志。调试结束后,很可能忘了把日志级别调回去。这时,大量的调试日志就可能会引发 I/O 性能问题。
你可以用 iostat ,确认是否有 I/O 性能瓶颈。再用 strace 和 lsof ,来定位应用程序以及它正在写入的日志文件路径。最后通过应用程序的接口调整日志级别,完美解决 I/O 问题。
不过,如果应用程序没有动态调整日志级别的功能,你还需要修改应用配置并重启应用,以便让配置生效。
今天,我们再来看一个新的案例。这次案例是一个基于 Python Flask 框架的 Web 应用,它提供了一个查询单词热度的 API但是API 的响应速度并不让人满意。
非常感谢携程系统研发部资深后端工程师董国星,帮助提供了今天的案例。
## **案例准备**
本次案例还是基于 Ubuntu 18.04,同样适用于其他的 Linux 系统。我使用的案例环境如下所示:
<li>
机器配置2 CPU8GB 内存
</li>
<li>
预先安装 docker、sysstat 等工具,如 apt install [docker.io](http://docker.io) sysstat
</li>
为了方便你运行今天的案例,我把它打包成了一个 Docker 镜像。这样,你就只需要运行 Docker 命令就可以启动它。
今天的案例需要两台虚拟机,其中一台是案例分析的目标机器,运行 Flask 应用,它的 IP 地址是 192.168.0.10;而另一台作为客户端,请求单词的热度。我画了一张图表示它们的关系,如下所示:
<img src="https://static001.geekbang.org/resource/image/a8/bf/a8cc1b02b8c896380d2c53b8018bddbf.png" alt="">
接下来,打开两个终端,分别 SSH 登录到这两台虚拟机中,并在第一台虚拟机中,安装上述工具。
跟以前一样,案例中所有命令都默认以 root 用户运行,如果你是用普通用户身份登陆系统,请运行 sudo su root 命令切换到 root 用户。
到这里,准备工作就完成了。接下来,我们正式进入操作环节。
>
温馨提示:案例中 Python 应用的核心逻辑比较简单,你可能一眼就能看出问题,但实际生产环境中的源码就复杂多了。所以,我依旧建议,操作之前别看源码,避免先入为主,而要把它当成一个黑盒来分析。这样,你可以更好把握,怎么从系统的资源使用问题出发,分析出瓶颈所在的应用,以及瓶颈在应用中大概的位置。
## **案例分析**
首先,我们在第一个终端中执行下面的命令,运行本次案例要分析的目标应用:
```
$ docker run --name=app -p 10000:80 -itd feisky/word-pop
```
然后,在第二个终端中运行 curl 命令,访问 [http://192.168.0.10:1000/](http://192.168.0.10:1000/),确认案例正常启动。你应该可以在 curl 的输出界面里,看到一个 hello world 的输出:
```
$ curl http://192.168.0.10:10000/
hello world
```
接下来,在第二个终端中,访问案例应用的单词热度接口,也就是 [http://192.168.0.10:1000/popularity/word](http://192.168.0.10:1000/popularity/word)。
```
$ curl http://192.168.0.10:1000/popularity/word
```
稍等一会儿,你会发现,这个接口居然这么长时间都没响应,究竟是怎么回事呢?我们先回到终端一来分析一下。
我们试试在第一个终端里,随便执行一个命令,比如执行 df 命令,查看一下文件系统的使用情况。奇怪的是,这么简单的命令,居然也要等好久才有输出。
```
$ df
Filesystem 1K-blocks Used Available Use% Mounted on
udev 4073376 0 4073376 0% /dev
tmpfs 816932 1188 815744 1% /run
/dev/sda1 30308240 8713640 21578216 29% /
```
通过df我们知道系统还有足够多的磁盘空间。那为什么响应会变慢呢看来还是得观察一下系统的资源使用情况像是 CPU、内存和磁盘 I/O 等的具体使用情况。
这里的思路其实跟上一个案例比较类似,我们可以先用 top 来观察 CPU 和内存的使用情况,然后再用 iostat 来观察磁盘的 I/O 情况。
为了避免分析过程中curl 请求突然结束,我们回到终端二,按 Ctrl+C 停止刚才的应用程序然后把curl 命令放到一个循环里执行;这次我们还要加一个 time 命令,观察每次的执行时间:
```
$ while true; do time curl http://192.168.0.10:10000/popularity/word; sleep 1; done
```
继续回到终端一来分析性能。我们在终端一中运行 top 命令,观察 CPU 和内存的使用情况:
```
$ top
top - 14:27:02 up 10:30, 1 user, load average: 1.82, 1.26, 0.76
Tasks: 129 total, 1 running, 74 sleeping, 0 stopped, 0 zombie
%Cpu0 : 3.5 us, 2.1 sy, 0.0 ni, 0.0 id, 94.4 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 2.4 us, 0.7 sy, 0.0 ni, 70.4 id, 26.5 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 8169300 total, 3323248 free, 436748 used, 4409304 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 7412556 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
12280 root 20 0 103304 28824 7276 S 14.0 0.4 0:08.77 python
16 root 20 0 0 0 0 S 0.3 0.0 0:09.22 ksoftirqd/1
1549 root 20 0 236712 24480 9864 S 0.3 0.3 3:31.38 python3
```
观察 top 的输出可以发现两个CPU的 iowait 都非常高。特别是 CPU0 iowait 已经高达 94 %,而剩余内存还有 3GB看起来也是充足的。
再往下看,进程部分有一个 python 进程的CPU使用率稍微有点高达到了 14%。虽然 14% 并不能成为性能瓶颈,不过有点嫌疑——可能跟 iowait 的升高有关。
那这个PID 号为 12280 的 python 进程,到底是不是我们的案例应用呢?
我们在第一个终端中,按下 Ctrl+C停止 top 命令;然后执行下面的 ps 命令,查找案例应用 [app.py](http://app.py) 的 PID 号:
```
$ ps aux | grep app.py
root 12222 0.4 0.2 96064 23452 pts/0 Ss+ 14:37 0:00 python /app.py
root 12280 13.9 0.3 102424 27904 pts/0 Sl+ 14:37 0:09 /usr/local/bin/python /app.py
```
从 ps 的输出,你可以看到,这个 CPU 使用率较高的进程,正是我们的案例应用。不过先别着急分析 CPU 问题,毕竟 iowait 已经高达 94% I/O 问题才是我们首要解决的。
接下来,我们在终端一中,运行下面的 iostat 命令,其中:
<li>
-d 选项是指显示出 I/O 的性能指标;
</li>
<li>
-x 选项是指显示出扩展统计信息即显示所有I/O指标
</li>
```
$ iostat -d -x 1
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz svctm %util
loop0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sda 0.00 71.00 0.00 32912.00 0.00 0.00 0.00 0.00 0.00 18118.31 241.89 0.00 463.55 13.86 98.40
```
再次看到 iostat 的输出,你还记得这个界面中的性能指标含义吗?先自己回忆一下,如果实在想不起来,一定要先查看上节内容,或者用 man iostat 查明白。
明白了指标含义,再来具体观察 iostat 的输出。你可以发现,磁盘 sda 的 I/O 使用率已经达到 98% ,接近饱和了。而且,写请求的响应时间高达 18 秒,每秒的写数据为 32 MB显然写磁盘碰到了瓶颈。
那要怎么知道,这些 I/O请求到底是哪些进程导致的呢我想你已经还记得上一节我们用到的 pidstat。
在终端一中,运行下面的 pidstat 命令,观察进程的 I/O 情况:
```
$ pidstat -d 1
14:39:14 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
14:39:15 0 12280 0.00 335716.00 0.00 0 python
```
从 pidstat 的输出我们再次看到了PID 号为 12280的结果。这说明正是案例应用引发I/O 的性能瓶颈。
走到这一步,你估计觉得,接下来就很简单了,上一个案例不刚刚学过吗?无非就是,先用 strace 确认它是不是在写文件,再用 lsof 找出文件描述符对应的文件即可。
到底是不是这样呢?我们不妨来试试。还是在终端一中,执行下面的 strace 命令:
```
$ strace -p 12280
strace: Process 12280 attached
select(0, NULL, NULL, NULL, {tv_sec=0, tv_usec=567708}) = 0 (Timeout)
stat(&quot;/usr/local/lib/python3.7/importlib/_bootstrap.py&quot;, {st_mode=S_IFREG|0644, st_size=39278, ...}) = 0
stat(&quot;/usr/local/lib/python3.7/importlib/_bootstrap.py&quot;, {st_mode=S_IFREG|0644, st_size=39278, ...}) = 0
```
从 strace 中,你可以看到大量的 stat 系统调用,并且大都为 python 的文件,但是,请注意,这里并没有任何 write 系统调用。
由于 strace 的输出比较多,我们可以用 grep ,来过滤一下 write比如
```
$ strace -p 12280 2&gt;&amp;1 | grep write
```
遗憾的是,这里仍然没有任何输出。
难道此时已经没有性能问题了吗?重新执行刚才的 top 和 iostat 命令,你会不幸地发现,性能问题仍然存在。
我们只好综合 strace、pidstat 和 iostat 这三个结果来分析了。很明显你应该发现了这里的矛盾iostat 已经证明磁盘 I/O 有性能瓶颈,而 pidstat 也证明了,这个瓶颈是由 12280 号进程导致的,但 strace 跟踪这个进程,却没有找到任何 write 系统调用。
这就奇怪了。难道因为案例使用的编程语言是 Python 而Python 是解释型的,所以找不到?还是说,因为案例运行在 Docker 中呢?这里留个悬念,你自己想想。
文件写明明应该有相应的write系统调用但用现有工具却找不到痕迹这时就该想想换工具的问题了。怎样才能知道哪里在写文件呢
这里我给你介绍一个新工具, [filetop](https://github.com/iovisor/bcc/blob/master/tools/filetop.py)。它是 [bcc](https://github.com/iovisor/bcc) 软件包的一部分,基于 Linux 内核的 eBPFextended Berkeley Packet Filters机制主要跟踪内核中文件的读写情况并输出线程IDTID、读写大小、读写类型以及文件名称。
eBPF 的工作原理,你暂时不用深究,后面内容我们会逐渐接触到,先会使用就可以了。
至于老朋友 bcc 的安装方法,可以参考它的 Github 网站 [https://github.com/iovisor/bcc](https://github.com/iovisor/bcc)。比如在 Ubuntu 16 以上的版本中,你可以运行下面的命令来安装它:
```
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 4052245BD4284CDD
echo &quot;deb https://repo.iovisor.org/apt/$(lsb_release -cs) $(lsb_release -cs) main&quot; | sudo tee /etc/apt/sources.list.d/iovisor.list
sudo apt-get update
sudo apt-get install bcc-tools libbcc-examples linux-headers-$(uname -r)
```
安装后bcc 提供的所有工具,就全部安装到了 /usr/share/bcc/tools 这个目录中。接下来我们就用这个工具,观察一下文件的读写情况。
首先,在终端一中运行下面的命令:
```
# 切换到工具目录
$ cd /usr/share/bcc/tools
# -C 选项表示输出新内容时不清空屏幕
$ ./filetop -C
TID COMM READS WRITES R_Kb W_Kb T FILE
514 python 0 1 0 2832 R 669.txt
514 python 0 1 0 2490 R 667.txt
514 python 0 1 0 2685 R 671.txt
514 python 0 1 0 2392 R 670.txt
514 python 0 1 0 2050 R 672.txt
...
TID COMM READS WRITES R_Kb W_Kb T FILE
514 python 2 0 5957 0 R 651.txt
514 python 2 0 5371 0 R 112.txt
514 python 2 0 4785 0 R 861.txt
514 python 2 0 4736 0 R 213.txt
514 python 2 0 4443 0 R 45.txt
```
你会看到filetop 输出了 8 列内容分别是线程ID、线程命令行、读写次数、读写的大小单位KB、文件类型以及读写的文件名称。
这些内容里,你可能会看到很多动态链接库,不过这不是我们的重点,暂且忽略即可。我们的重点,是一个 python 应用,所以要特别关注 python 相关的内容。
多观察一会儿,你就会发现,每隔一段时间,线程号为 514 的 python 应用就会先写入大量的 txt 文件,再大量地读。
线程号为 514 的线程,属于哪个进程呢?我们可以用 ps 命令查看。先在终端一中,按下 Ctrl+C ,停止 filetop ;然后,运行下面的 ps 命令。这个输出的第二列内容,就是我们想知道的进程号:
```
$ ps -efT | grep 514
root 12280 514 14626 33 14:47 pts/0 00:00:05 /usr/local/bin/python /app.py
```
我们看到,这个线程正是案例应用 12280的线程。终于可以先松一口气不过还没完filetop 只给出了文件名称,却没有文件路径,还得继续找啊。
我再介绍一个好用的工具opensnoop 。它同属于 bcc 软件包,可以动态跟踪内核中的 open 系统调用。这样,我们就可以找出这些 txt 文件的路径。
接下来,在终端一中,运行下面的 opensnoop 命令:
```
$ opensnoop
12280 python 6 0 /tmp/9046db9e-fe25-11e8-b13f-0242ac110002/650.txt
12280 python 6 0 /tmp/9046db9e-fe25-11e8-b13f-0242ac110002/651.txt
12280 python 6 0 /tmp/9046db9e-fe25-11e8-b13f-0242ac110002/652.txt
```
这次,通过 opensnoop 的输出,你可以看到,这些 txt 路径位于 /tmp 目录下。你还能看到,它打开的文件数量,按照数字编号,从 0.txt 依次增大到 999.txt这可远多于前面用 filetop 看到的数量。
综合 filetop 和 opensnoop ,我们就可以进一步分析了。我们可以大胆猜测,案例应用在写入 1000 个txt文件后又把这些内容读到内存中进行处理。我们来检查一下这个目录中是不是真的有 1000 个文件:
```
$ ls /tmp/9046db9e-fe25-11e8-b13f-0242ac110002 | wc -l
ls: cannot access '/tmp/9046db9e-fe25-11e8-b13f-0242ac110002': No such file or directory
0
```
操作后却发现,目录居然不存在了。怎么回事呢?我们回到 opensnoop 再观察一会儿:
```
$ opensnoop
12280 python 6 0 /tmp/defee970-fe25-11e8-b13f-0242ac110002/261.txt
12280 python 6 0 /tmp/defee970-fe25-11e8-b13f-0242ac110002/840.txt
12280 python 6 0 /tmp/defee970-fe25-11e8-b13f-0242ac110002/136.txt
```
原来,这时的路径已经变成了另一个目录。这说明,这些目录都是应用程序动态生成的,用完就删了。
结合前面的所有分析,我们基本可以判断,案例应用会动态生成一批文件,用来临时存储数据,用完就会删除它们。但不幸的是,正是这些文件读写,引发了 I/O 的性能瓶颈,导致整个处理过程非常慢。
当然,我们还需要验证这个猜想。老办法,还是查看应用程序的源码 [app.py](https://github.com/feiskyer/linux-perf-examples/blob/master/io-latency/app.py)
```
@app.route(&quot;/popularity/&lt;word&gt;&quot;)
def word_popularity(word):
dir_path = '/tmp/{}'.format(uuid.uuid1())
count = 0
sample_size = 1000
def save_to_file(file_name, content):
with open(file_name, 'w') as f:
f.write(content)
try:
# initial directory firstly
os.mkdir(dir_path)
# save article to files
for i in range(sample_size):
file_name = '{}/{}.txt'.format(dir_path, i)
article = generate_article()
save_to_file(file_name, article)
# count word popularity
for root, dirs, files in os.walk(dir_path):
for file_name in files:
with open('{}/{}'.format(dir_path, file_name)) as f:
if validate(word, f.read()):
count += 1
finally:
# clean files
shutil.rmtree(dir_path, ignore_errors=True)
return jsonify({'popularity': count / sample_size * 100, 'word': word})
```
源码中可以看到,这个案例应用,在每个请求的处理过程中,都会生成一批临时文件,然后读入内存处理,最后再把整个目录删除掉。
这是一种常见的利用磁盘空间处理大量数据的技巧,不过,本次案例中的 I/O 请求太重,导致磁盘 I/O 利用率过高。
要解决这一点,其实就是算法优化问题了。比如在内存充足时,就可以把所有数据都放到内存中处理,这样就能避免 I/O 的性能问题。
你可以检验一下,在终端二中分别访问 [http://192.168.0.10:10000/popularity/word](http://192.168.0.10:10000/popularity/word) 和 [http://192.168.0.10:10000/popular/word](http://192.168.0.10:10000/popular/word) ,对比前后的效果:
```
$ time curl http://192.168.0.10:10000/popularity/word
{
&quot;popularity&quot;: 0.0,
&quot;word&quot;: &quot;word&quot;
}
real 2m43.172s
user 0m0.004s
sys 0m0.007s
```
```
$ time curl http://192.168.0.10:10000/popular/word
{
&quot;popularity&quot;: 0.0,
&quot;word&quot;: &quot;word&quot;
}
real 0m8.810s
user 0m0.010s
sys 0m0.000s
```
新的接口只要8秒就可以返回明显比一开始的 3 分钟好很多。
当然,这只是优化的第一步,并且方法也不算完善,还可以做进一步的优化。不过,在实际系统中,我们大都是类似的做法,先用最简单的方法,尽早解决线上问题,然后再继续思考更好的优化方法。
## 小结
今天,我们分析了一个响应过慢的单词热度案例。
首先,我们用 top、iostat分析了系统的 CPU 和磁盘使用情况。我们发现了磁盘 I/O 瓶颈,也知道了这个瓶颈是案例应用导致的。
接着,我们试着照搬上一节案例的方法,用 strace 来观察进程的系统调用,不过这次很不走运,没找到任何 write 系统调用。
于是,我们又用了新的工具,借助动态追踪工具包 bcc 中的 filetop 和 opensnoop ,找出了案例应用的问题,发现这个根源是大量读写临时文件。
找出问题后,优化方法就相对比较简单了。如果内存充足时,最简单的方法,就是把数据都放在速度更快的内存中,这样就没有磁盘 I/O 的瓶颈了。当然,再进一步,你可以还可以利用 Trie 树等各种算法,进一步优化单词处理的效率。
## 思考
最后,给你留一个思考题,也是我在文章中提到过的,让你思考的问题。
今天的案例中iostat 已经证明,磁盘 I/O 出现了性能瓶颈, pidstat 也证明了这个瓶颈是由 12280 号进程导致的。但是strace 跟踪这个进程,却没有发现任何 write 系统调用。
这究竟是怎么回事?难道是因为案例使用的编程语言 Python 本身是解释型?还是说,因为案例运行在 Docker 中呢?
这里我小小提示一下。当你发现性能工具的输出无法解释时,最好返回去想想,是不是分析中漏掉了什么线索,或者去翻翻工具手册,看看是不是某些默认选项导致的。
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,548 @@
<audio id="audio" title="28 | 案例篇一个SQL查询要15秒这是怎么回事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/38/04d848146b4d57a6162d172d721d4538.mp3"></audio>
你好,我是倪朋飞。
上一节,我们分析了一个单词热度应用响应过慢的案例。当用 top、iostat 分析了系统的 CPU 和磁盘 I/O 使用情况后,我们发现系统出现了磁盘的 I/O 瓶颈,而且正是案例应用导致的。
接着,在使用 strace 却没有任何发现后,我又给你介绍了两个新的工具 filetop 和 opensnoop分析它们对系统调用 write() 和 open() 的追踪结果。
我们发现,案例应用正在读写大量的临时文件,因此产生了性能瓶颈。找出瓶颈后,我们又用把文件数据都放在内存的方法,解决了磁盘 I/O 的性能问题。
当然,你可能会说,在实际应用中,大量数据肯定是要存入数据库的,而不会直接用文本文件的方式存储。不过,数据库也不是万能的。当数据库出现性能问题时,又该如何分析和定位它的瓶颈呢?
今天我们就来一起分析一个数据库的案例。这是一个基于 Python Flask 的商品搜索应用,商品信息存在 MySQL 中。这个应用可以通过 MySQL 接口,根据客户端提供的商品名称,去数据库表中查询商品信息。
非常感谢唯品会资深运维工程师阳祥义,帮助提供了今天的案例。
## 案例准备
本次案例还是基于 Ubuntu 18.04,同样适用于其他的 Linux 系统。我使用的案例环境如下所示:
<li>
机器配置2 CPU8GB 内存
</li>
<li>
预先安装 docker、sysstat 、git、make 等工具,如 apt install [docker.io](http://docker.io) sysstat make git
</li>
其中docker 和 sysstat 已经用过很多次这里不再赘述git 用来拉取本次案例所需脚本,这些脚本存储在 Github 代码仓库中;最后的 make 则是一个常用构建工具,这里用来运行今天的案例。
案例总共由三个容器组成,包括一个 MySQL 数据库应用、一个商品搜索应用以及一个数据处理的应用。其中,商品搜索应用以 HTTP 的形式提供了一个接口:
<li>
/:返回 Index Page
</li>
<li>
/db/insert/products/<num>:插入指定数量的商品信息;</num>
</li>
<li>
/products/<product>:查询指定商品的信息,并返回处理时间。</product>
</li>
由于应用比较多,为了方便你运行它们,我把它们同样打包成了几个 Docker 镜像,并推送到了 Github 上。这样,你只需要运行几条命令,就可以启动了。
今天的案例需要两台虚拟机,其中一台作为案例分析的目标机器,运行 Flask 应用,它的 IP 地址是 192.168.0.10;另一台则是作为客户端,请求单词的热度。我画了一张图表示它们的关系。
<img src="https://static001.geekbang.org/resource/image/8c/5d/8c954570f6e46193505c2598a06cbc5d.png" alt="">
接下来,打开两个终端,分别 SSH 登录到这两台虚拟机中,并在第一台虚拟机中安装上述工具。
跟以前一样,案例中所有命令都默认以 root 用户运行,如果你是用普通用户身份登陆系统,请运行 sudo su root命令切换到 root 用户。
到这里,准备工作就完成了。接下来,我们正式进入操作环节。
## 案例分析
首先,我们在第一个终端中执行下面命令,拉取本次案例所需脚本:
```
$ git clone https://github.com/feiskyer/linux-perf-examples
$ cd linux-perf-examples/mysql-slow
```
接着,执行下面的命令,运行本次的目标应用。正常情况下,你应该可以看到下面的输出:
```
# 注意下面的随机字符串是容器ID每次运行均会不同并且你不需要关注它因为我们只会用到名字
$ make run
docker run --name=mysql -itd -p 10000:80 -m 800m feisky/mysql:5.6
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
4156780da5be0b9026bcf27a3fa56abc15b8408e358fa327f472bcc5add4453f
docker run --name=dataservice -itd --privileged feisky/mysql-dataservice
f724d0816d7e47c0b2b1ff701e9a39239cb9b5ce70f597764c793b68131122bb
docker run --name=app --network=container:mysql -itd feisky/mysql-slow
81d3392ba25bb8436f6151662a13ff6182b6bc6f2a559fc2e9d873cd07224ab6
```
然后,再运行 docker ps 命令确认三个容器都处在运行Up状态
```
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9a4e3c580963 feisky/mysql-slow &quot;python /app.py&quot; 42 seconds ago Up 36 seconds app
2a47aab18082 feisky/mysql-dataservice &quot;python /dataservice…&quot; 46 seconds ago Up 41 seconds dataservice
4c3ff7b24748 feisky/mysql:5.6 &quot;docker-entrypoint.s…&quot; 47 seconds ago Up 46 seconds 3306/tcp, 0.0.0.0:10000-&gt;80/tcp mysql
```
MySQL 数据库的启动过程,需要做一些初始化工作,这通常需要花费几分钟时间。你可以运行 docker logs 命令,查看它的启动过程。
当你看到下面这个输出时,说明 MySQL 初始化完成,可以接收外部请求了:
```
$ docker logs -f mysql
...
... [Note] mysqld: ready for connections.
Version: '5.6.42-log' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)
```
而商品搜索应用则是在 10000 端口监听。你可以按 Ctrl+C ,停止 docker logs 命令;然后,执行下面的命令,确认它也已经正常运行。如果一切正常,你会看到 Index Page 的输出:
```
$ curl http://127.0.0.1:10000/
Index Page
```
接下来,运行 make init 命令,初始化数据库,并插入 10000 条商品信息。这个过程比较慢,比如在我的机器中,就花了十几分钟时间。耐心等待一段时间后,你会看到如下的输出:
```
$ make init
docker exec -i mysql mysql -uroot -P3306 &lt; tables.sql
curl http://127.0.0.1:10000/db/insert/products/10000
insert 10000 lines
```
接着,我们切换到第二个终端,访问一下商品搜索的接口,看看能不能找到想要的商品。执行如下的 curl 命令:
```
$ curl http://192.168.0.10:10000/products/geektime
Got data: () in 15.364538192749023 sec
```
稍等一会儿你会发现这个接口返回的是空数据而且处理时间超过15 秒。这么慢的响应速度让人无法忍受,到底出了什么问题呢?
既然今天用了 MySQL你估计会猜到是慢查询的问题。
不过别急,在具体分析前,为了避免在分析过程中客户端的请求结束,我们把 curl 命令放到一个循环里执行。同时,为了避免给系统过大压力,我们设置在每次查询后,都先等待 5 秒,然后再开始新的请求。
所以,你可以在终端二中,继续执行下面的命令:
```
$ while true; do curl http://192.168.0.10:10000/products/geektime; sleep 5; done
```
接下来,重新回到终端一中,分析接口响应速度慢的原因。不过,重回终端一后,你会发现系统响应也明显变慢了,随便执行一个命令,都得停顿一会儿才能看到输出。
这跟上一节的现象很类似看来我们还是得观察一下系统的资源使用情况比如CPU、内存和磁盘 I/O 等的情况。
首先,我们在终端一执行 top 命令,分析系统的 CPU 使用情况:
```
$ top
top - 12:02:15 up 6 days, 8:05, 1 user, load average: 0.66, 0.72, 0.59
Tasks: 137 total, 1 running, 81 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.7 us, 1.3 sy, 0.0 ni, 35.9 id, 62.1 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.3 us, 0.7 sy, 0.0 ni, 84.7 id, 14.3 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 8169300 total, 7238472 free, 546132 used, 384696 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 7316952 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
27458 999 20 0 833852 57968 13176 S 1.7 0.7 0:12.40 mysqld
27617 root 20 0 24348 9216 4692 S 1.0 0.1 0:04.40 python
1549 root 20 0 236716 24568 9864 S 0.3 0.3 51:46.57 python3
22421 root 20 0 0 0 0 I 0.3 0.0 0:01.16 kworker/u
```
观察 top 的输出,我们发现,两个 CPU 的 iowait 都比较高,特别是 CPU0iowait 已经超过 60%。而具体到各个进程, CPU 使用率并不高,最高的也只有 1.7%。
既然CPU的嫌疑不大那问题应该还是出在了 I/O 上。我们仍然在第一个终端,按下 Ctrl+C停止 top 命令;然后,执行下面的 iostat 命令,看看有没有 I/O 性能问题:
```
$ iostat -d -x 1
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz svctm %util
...
sda 273.00 0.00 32568.00 0.00 0.00 0.00 0.00 0.00 7.90 0.00 1.16 119.30 0.00 3.56 97.20
```
iostat 的输出你应该非常熟悉。观察这个界面,我们发现,磁盘 sda 每秒的读数据为 32 MB 而 I/O 使用率高达 97% ,接近饱和,这说明,磁盘 sda 的读取确实碰到了性能瓶颈。
那要怎么知道,这些 I/O请求到底是哪些进程导致的呢当然可以找我们的老朋友 pidstat。接下来在终端一中按下 Ctrl+C 停止 iostat 命令,然后运行下面的 pidstat 命令,观察进程的 I/O 情况:
```
# -d选项表示展示进程的I/O情况
$ pidstat -d 1
12:04:11 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
12:04:12 999 27458 32640.00 0.00 0.00 0 mysqld
12:04:12 0 27617 4.00 4.00 0.00 3 python
12:04:12 0 27864 0.00 4.00 0.00 0 systemd-journal
```
从 pidstat 的输出可以看到PID 为 27458 的 mysqld 进程正在进行大量的读,而且读取速度是 32 MB/s跟刚才 iostat 的发现一致。两个结果一对比,我们自然就找到了磁盘 I/O 瓶颈的根源,即 mysqld 进程。
不过,这事儿还没完。我们自然要怀疑一下,为什么 mysqld 会去读取大量的磁盘数据呢?按照前面猜测,我们提到过,这有可能是个慢查询问题。
可是,回想一下,慢查询的现象大多是 CPU 使用率高(比如 100% ),但这里看到的却是 I/O 问题。看来,这并不是一个单纯的慢查询问题,我们有必要分析一下 MySQL 读取的数据。
要分析进程的数据读取,当然还要靠上一节用到过的 strace+ lsof 组合。
接下来,还是在终端一中,执行 strace 命令,并且指定 mysqld 的进程号 27458。我们知道MySQL 是一个多线程的数据库应用,为了不漏掉这些线程的数据读取情况,你要记得在执行 stace 命令时,加上 -f 参数:
```
$ strace -f -p 27458
[pid 28014] read(38, &quot;934EiwT363aak7VtqF1mHGa4LL4Dhbks&quot;..., 131072) = 131072
[pid 28014] read(38, &quot;hSs7KBDepBqA6m4ce6i6iUfFTeG9Ot9z&quot;..., 20480) = 20480
[pid 28014] read(38, &quot;NRhRjCSsLLBjTfdqiBRLvN9K6FRfqqLm&quot;..., 131072) = 131072
[pid 28014] read(38, &quot;AKgsik4BilLb7y6OkwQUjjqGeCTQTaRl&quot;..., 24576) = 24576
[pid 28014] read(38, &quot;hFMHx7FzUSqfFI22fQxWCpSnDmRjamaW&quot;..., 131072) = 131072
[pid 28014] read(38, &quot;ajUzLmKqivcDJSkiw7QWf2ETLgvQIpfC&quot;..., 20480) = 20480
```
观察一会,你会发现,线程 28014 正在读取大量数据,且读取文件的描述符编号为 38。这儿的 38 又对应着哪个文件呢?我们可以执行下面的 lsof 命令,并且指定线程号 28014 ,具体查看这个可疑线程和可疑文件:
```
$ lsof -p 28014
```
奇怪的是lsof 并没有给出任何输出。实际上,如果你查看 lsof 命令的返回值,就会发现,这个命令的执行失败了。
我们知道,在 SHELL 中,特殊标量 $? 表示上一条命令退出时的返回值。查看这个特殊标量你会发现它的返回值是1。可是别忘了在 Linux 中,返回值为 0 才表示命令执行成功。返回值为1显然表明执行失败。
```
$ echo $?
1
```
为什么 lsof 命令执行失败了呢?这里希望你暂停往下,自己先思考一下原因。记住我的那句话,遇到现象解释不了,先去查查工具文档。
事实上,通过查询 lsof 的文档,你会发现,-p 参数需要指定进程号,而我们刚才传入的是线程号,所以 lsof 失败了。你看,任何一个细节都可能成为性能分析的“拦路虎”。
回过头我们看mysqld 的进程号是 27458而 28014 只是它的一个线程。而且,如果你观察 一下mysqld 进程的线程你会发现mysqld 其实还有很多正在运行的其他线程:
```
# -t表示显示线程-a表示显示命令行参数
$ pstree -t -a -p 27458
mysqld,27458 --log_bin=on --sync_binlog=1
...
├─{mysqld},27922
├─{mysqld},27923
└─{mysqld},28014
```
找到了原因lsof的问题就容易解决了。把线程号换成进程号继续执行 lsof 命令:
```
$ lsof -p 27458
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
...
mysqld 27458 999 38u REG 8,1 512440000 2601895 /var/lib/mysql/test/products.MYD
```
这次我们得到了lsof的输出。从输出中可以看到 mysqld 进程确实打开了大量文件而根据文件描述符FD的编号我们知道描述符为 38 的是一个路径为 /var/lib/mysql/test/products.MYD 的文件。这里注意, 38 后面的 u 表示, mysqld 以读写的方式访问文件。
看到这个文件,熟悉 MySQL 的你可能笑了:
<li>
MYD 文件,是 MyISAM 引擎用来存储表数据的文件;
</li>
<li>
文件名就是数据表的名字;
</li>
<li>
而这个文件的父目录,也就是数据库的名字。
</li>
换句话说这个文件告诉我们mysqld 在读取数据库 test 中的 products 表。
实际上,你可以执行下面的命令,查看 mysqld 在管理数据库 test 时的存储文件。不过要注意,由于 MySQL 运行在容器中,你需要通过 docker exec 到容器中查看:
```
$ docker exec -it mysql ls /var/lib/mysql/test/
db.opt products.MYD products.MYI products.frm
```
从这里你可以发现,/var/lib/mysql/test/ 目录中有四个文件,每个文件的作用分别是:
<li>
MYD 文件用来存储表的数据;
</li>
<li>
MYI 文件用来存储表的索引;
</li>
<li>
frm 文件用来存储表的元信息(比如表结构);
</li>
<li>
opt 文件则用来存储数据库的元信息(比如字符集、字符校验规则等)。
</li>
当然,看到这些,你可能还有一个疑问,那就是,这些文件到底是不是 mysqld 正在使用的数据库文件呢?有没有可能是不再使用的旧数据呢?其实,这个很容易确认,查一下 mysqld 配置的数据路径即可。
你可以在终端一中,继续执行下面的命令:
```
$ docker exec -i -t mysql mysql -e 'show global variables like &quot;%datadir%&quot;;'
+---------------+-----------------+
| Variable_name | Value |
+---------------+-----------------+
| datadir | /var/lib/mysql/ |
+---------------+-----------------+
```
这里可以看到,/var/lib/mysql/ 确实是 mysqld 正在使用的数据存储目录。刚才分析得出的数据库 test 和数据表 products ,都是正在使用。
>
注:其实 lsof 的结果已经可以确认,它们都是 mysqld 正在访问的文件。再查询 datadir ,只是想换一个思路,进一步确认一下。
既然已经找出了数据库和表,接下来要做的,就是弄清楚数据库中正在执行什么样的 SQL 了。我们继续在终端一中,运行下面的 docker exec 命令,进入 MySQL 的命令行界面:
```
$ docker exec -i -t mysql mysql
...
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql&gt;
```
下一步你应该可以想到,那就是在 MySQL 命令行界面中,执行 show processlist 命令,来查看当前正在执行的 SQL 语句。
不过,为了保证 SQL 语句不截断,这里我们可以执行 show full processlist 命令。如果一切正常,你应该可以看到如下输出:
```
mysql&gt; show full processlist;
+----+------+-----------------+------+---------+------+--------------+-----------------------------------------------------+
| Id | User | Host | db | Command | Time | State | Info |
+----+------+-----------------+------+---------+------+--------------+-----------------------------------------------------+
| 27 | root | localhost | test | Query | 0 | init | show full processlist |
| 28 | root | 127.0.0.1:42262 | test | Query | 1 | Sending data | select * from products where productName='geektime' |
+----+------+-----------------+------+---------+------+--------------+-----------------------------------------------------+
2 rows in set (0.00 sec)
```
这个输出中,
<li>
db 表示数据库的名字;
</li>
<li>
Command 表示 SQL 类型;
</li>
<li>
Time 表示执行时间;
</li>
<li>
State 表示状态;
</li>
<li>
而 Info 则包含了完整的 SQL 语句。
</li>
多执行几次 show full processlist 命令,你可看到 select * from products where productName=geektime 这条 SQL 语句的执行时间比较长。
再回忆一下,案例开始时,我们在终端二查询的产品名称 [http://192.168.0.10:10000/products/geektime](http://192.168.0.10:10000/products/geektime),其中的 geektime 也符合这条查询语句的条件。
我们知道MySQL 的慢查询问题,很可能是没有利用好索引导致的,那这条查询语句是不是这样呢?我们又该怎么确认,查询语句是否利用了索引呢?
其实MySQL 内置的 explain 命令,就可以帮你解决这个问题。继续在 MySQL 终端中,运行下面的 explain 命令:
```
# 切换到test库
mysql&gt; use test;
# 执行explain命令
mysql&gt; explain select * from products where productName='geektime';
+----+-------------+----------+------+---------------+------+---------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------+------+---------------+------+---------+------+-------+-------------+
| 1 | SIMPLE | products | ALL | NULL | NULL | NULL | NULL | 10000 | Using where |
+----+-------------+----------+------+---------------+------+---------+------+-------+-------------+
1 row in set (0.00 sec)
```
观察这次的输出。这个界面中,有几个比较重要的字段需要你注意,我就以这个输出为例,分别解释一下:
<li>
select_type 表示查询类型而这里的SIMPLE 表示此查询不包括 UNION 查询或者子查询;
</li>
<li>
table 表示数据表的名字,这里是 products
</li>
<li>
type 表示查询类型,这里的 ALL 表示全表查询,但索引查询应该是 index 类型才对;
</li>
<li>
possible_keys 表示可能选用的索引,这里是 NULL
</li>
<li>
key 表示确切会使用的索引,这里也是 NULL
</li>
<li>
rows 表示查询扫描的行数,这里是 10000。
</li>
根据这些信息,我们可以确定,这条查询语句压根儿没有使用索引,所以查询时,会扫描全表,并且扫描行数高达 10000 行。响应速度那么慢也就难怪了。
走到这一步,你应该很容易想到优化方法,没有索引那我们就自己建立,给 productName 建立索引就可以了。不过,增加索引前,你需要先弄清楚,这个表结构到底长什么样儿。
执行下面的 MySQL 命令,查询 products 表的结构,你会看到,它只有一个 id 主键,并不包括 productName 的索引:
```
mysql&gt; show create table products;
...
| products | CREATE TABLE `products` (
`id` int(11) NOT NULL,
`productCode` text NOT NULL COMMENT '产品代码',
`productName` text NOT NULL COMMENT '产品名称',
...
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC |
...
```
接下来,我们就可以给 productName 建立索引了,也就是执行下面的 CREATE INDEX 命令:
```
mysql&gt; CREATE INDEX products_index ON products (productName);
ERROR 1170 (42000): BLOB/TEXT column 'productName' used in key specification without a key length
```
不过醒目的ERROR告诉我们这条命令运行失败了。根据错误信息productName 是一个 BLOB/TEXT 类型,需要设置一个长度。所以,想要创建索引,就必须为 productName 指定一个前缀长度。
那前缀长度设置为多大比较合适呢?这里其实有专门的算法,即通过计算前缀长度的选择性,来确定索引的长度。不过,我们可以稍微简化一下,直接使用一个固定数值(比如 64执行下面的命令创建索引
```
mysql&gt; CREATE INDEX products_index ON products (productName(64));
Query OK, 10000 rows affected (14.45 sec)
Records: 10000 Duplicates: 0 Warnings: 0
```
现在可以看到,索引已经建好了。能做的都做完了,最后就该检查一下,性能问题是否已经解决了。
我们切换到终端二中,查看还在执行的 curl 命令的结果:
```
Got data: ()in 15.383180141448975 sec
Got data: ()in 15.384996891021729 sec
Got data: ()in 0.0021054744720458984 sec
Got data: ()in 0.003951072692871094 sec
```
显然,查询时间已经从 15 秒缩短到了 3 毫秒。看来,没有索引果然就是这次性能问题的罪魁祸首,解决了索引,就解决了查询慢的问题。
## 案例思考
到这里,商品搜索应用查询慢的问题已经完美解决了。但是,对于这个案例,我还有一点想说明一下。
不知道你还记不记得,案例开始时,我们启动的几个容器应用。除了 MySQL 和商品搜索应用外,还有一个 DataService 应用。为什么这个案例开始时,要运行一个看起来毫不相关的应用呢?
实际上DataService 是一个严重影响 MySQL 性能的干扰应用。抛开上述索引优化方法不说,这个案例还有一种优化方法,也就是停止 DataService 应用。
接下来,我们就删除数据库索引,回到原来的状态;然后停止 DataService 应用,看看优化效果如何。
首先,我们在终端二中停止 curl 命令,然后回到终端一中,执行下面的命令删除索引:
```
# 删除索引
$ docker exec -i -t mysql mysql
mysql&gt; use test;
mysql&gt; DROP INDEX products_index ON products;
```
接着,在终端二中重新运行 curl 命令。当然,这次你会发现,处理时间又变慢了:
```
$ while true; do curl http://192.168.0.10:10000/products/geektime; sleep 5; done
Got data: ()in 16.884345054626465 sec
```
接下来,再次回到终端一中,执行下面的命令,停止 DataService 应用:
```
# 停止 DataService 应用
$ docker rm -f dataservice
```
最后,我们回到终端二中,观察 curl 的结果:
```
Got data: ()in 16.884345054626465 sec
Got data: ()in 15.238174200057983 sec
Got data: ()in 0.12604427337646484 sec
Got data: ()in 0.1101069450378418 sec
Got data: ()in 0.11235237121582031 sec
```
果然,停止 DataService 后,处理时间从 15 秒缩短到了 0.1 秒,虽然比不上增加索引后的 3 毫秒,但相对于 15 秒来说,优化效果还是非常明显的。
那么,这种情况下,还有没有 I/O 瓶颈了呢?
我们切换到终端一中,运行下面的 vmstat 命令(注意不是 iostat稍后解释原因观察 I/O 的变化情况:
```
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 1 0 6809304 1368 856744 0 0 32640 0 52 478 1 0 50 49 0
0 1 0 6776620 1368 889456 0 0 32640 0 33 490 0 0 50 49 0
0 0 0 6747540 1368 918576 0 0 29056 0 42 568 0 0 56 44 0
0 0 0 6747540 1368 918576 0 0 0 0 40 141 1 0 100 0 0
0 0 0 6747160 1368 918576 0 0 0 0 40 148 0 1 99 0 0
```
你可以看到磁盘读bi和 iowaitwa刚开始还是挺大的但没过多久就都变成了 0 。换句话说I/O 瓶颈消失了。
这是为什么呢?原因先留个悬念,作为今天的思考题。
回过头来解释一下刚刚的操作,在查看 I/O 情况时,我并没用 iostat 命令,而是用了 vmstat。其实相对于 iostat 来说vmstat 可以同时提供 CPU、内存和 I/O 的使用情况。
在性能分析过程中,能够综合多个指标,并结合系统的工作原理进行分析,对解释性能现象通常会有意想不到的帮助。
## 小结
今天我们分析了一个商品搜索的应用程序。我们先是通过 top、iostat 分析了系统的 CPU 和磁盘使用情况,发现了磁盘的 I/O 瓶颈。
接着,我们借助 pidstat ,发现瓶颈是 mysqld 导致的。紧接着,我们又通过 strace、lsof找出了 mysqld 正在读的文件。同时,根据文件的名字和路径,我们找出了 mysqld 正在操作的数据库和数据表。综合这些信息,我们判断,这是一个没有利用索引导致的慢查询问题。
于是,我们登录到 MySQL 命令行终端,用数据库分析工具进行验证,发现 MySQL 查询语句访问的字段,果然没有索引。所以,增加索引,就可以解决案例的性能问题了。
## 思考
最后,给你留一个思考题,也是我在案例最后部分提到过的,停止 DataService 后,商品搜索应用的处理时间,从 15 秒缩短到了 0.1 秒。这是为什么呢?
我给个小小的提示。你可以先查看 [dataservice.py](http://dataservice.py) 的[源码](https://github.com/feiskyer/linux-perf-examples/blob/master/mysql-slow/dataservice.py)你会发现DataService 实际上是在读写一个仅包括 “data” 字符串的小文件。不过在读取文件前,它会先把 /proc/sys/vm/drop_caches 改成 1。
还记得这个操作有什么作用吗?如果不记得,可以用 man 查询 proc 文件系统的文档。
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,417 @@
<audio id="audio" title="29 | 案例篇Redis响应严重延迟如何解决" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/cc/d81f154d511dcc2c4927472ed3d589cc.mp3"></audio>
你好,我是倪朋飞。
上一节,我们一起分析了一个基于 MySQL 的商品搜索案例,先来回顾一下。
在访问商品搜索接口时,我们发现接口的响应特别慢。通过对系统 CPU、内存和磁盘 I/O 等资源使用情况的分析,我们发现这时出现了磁盘的 I/O 瓶颈,并且正是案例应用导致的。
接着,我们借助 pidstat发现罪魁祸首是 mysqld 进程。我们又通过 strace、lsof找出了 mysqld 正在读的文件。根据文件的名字和路径,我们找出了 mysqld 正在操作的数据库和数据表。综合这些信息,我们猜测这是一个没利用索引导致的慢查询问题。
为了验证猜测,我们到 MySQL 命令行终端,使用数据库分析工具发现,案例应用访问的字段果然没有索引。既然猜测是正确的,那增加索引后,问题就自然解决了。
从这个案例你会发现MySQL 的 MyISAM 引擎,主要依赖系统缓存加速磁盘 I/O 的访问。可如果系统中还有其他应用同时运行, MyISAM 引擎很难充分利用系统缓存。缓存可能会被其他应用程序占用,甚至被清理掉。
所以,一般我并不建议,把应用程序的性能优化完全建立在系统缓存上。最好能在应用程序的内部分配内存,构建完全自主控制的缓存;或者使用第三方的缓存应用,比如 Memcached、Redis 等。
Redis 是最常用的键值存储系统之一常用作数据库、高速缓存和消息队列代理等。Redis 基于内存来存储数据,不过,为了保证在服务器异常时数据不丢失,很多情况下,我们要为它配置持久化,而这就可能会引发磁盘 I/O 的性能问题。
今天,我就带你一起来分析一个利用 Redis 作为缓存的案例。这同样是一个基于 Python Flask 的应用程序,它提供了一个 查询缓存的接口,但接口的响应时间比较长,并不能满足线上系统的要求。
非常感谢携程系统研发部资深后端工程师董国星,帮助提供了今天的案例。
## 案例准备
本次案例还是基于 Ubuntu 18.04,同样适用于其他的 Linux 系统。我使用的案例环境如下所示:
<li>
机器配置2 CPU8GB 内存
</li>
<li>
预先安装 docker、sysstat 、git、make 等工具,如 apt install [docker.io](http://docker.io) sysstat
</li>
今天的案例由 Python应用+Redis 两部分组成。其中Python 应用是一个基于 Flask 的应用,它会利用 Redis ,来管理应用程序的缓存,并对外提供三个 HTTP 接口:
<li>
/:返回 hello redis
</li>
<li>
/init/<num>:插入指定数量的缓存数据,如果不指定数量,默认的是 5000 条;</num>
</li>
<li>
缓存的键格式为 uuid:<uuid></uuid>
</li>
<li>
缓存的值为 good、bad 或 normal 三者之一
</li>
<li>
/get_cache/&lt;type_name&gt;查询指定值的缓存数据并返回处理时间。其中type_name 参数只支持 good, bad 和 normal也就是找出具有相同 value 的 key 列表)。
</li>
由于应用比较多,为了方便你运行,我把它们打包成了两个 Docker 镜像,并推送到了 [Github](https://github.com/feiskyer/linux-perf-examples/tree/master/redis-slow) 上。这样你就只需要运行几条命令,就可以启动了。
今天的案例需要两台虚拟机,其中一台用作案例分析的目标机器,运行 Flask 应用,它的 IP 地址是 192.168.0.10;而另一台作为客户端,请求缓存查询接口。我画了一张图来表示它们的关系。
<img src="https://static001.geekbang.org/resource/image/c8/87/c8e0ca06d70a1c7f1520d103a3edfc87.png" alt="">
接下来,打开两个终端,分别 SSH 登录到这两台虚拟机中,并在第一台虚拟机中安装上述工具。
跟以前一样,案例中所有命令都默认以 root 用户运行,如果你是用普通用户身份登陆系统,请运行 sudo su root 命令切换到 root 用户。
到这里,准备工作就完成了。接下来,我们正式进入操作环节。
## 案例分析
首先,我们在第一个终端中,执行下面的命令,运行本次案例要分析的目标应用。正常情况下,你应该可以看到下面的输出:
```
# 注意下面的随机字符串是容器ID每次运行均会不同并且你不需要关注它
$ docker run --name=redis -itd -p 10000:80 feisky/redis-server
ec41cb9e4dd5cb7079e1d9f72b7cee7de67278dbd3bd0956b4c0846bff211803
$ docker run --name=app --network=container:redis -itd feisky/redis-app
2c54eb252d0552448320d9155a2618b799a1e71d7289ec7277a61e72a9de5fd0
```
然后,再运行 docker ps 命令确认两个容器都处于运行Up状态
```
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2c54eb252d05 feisky/redis-app &quot;python /app.py&quot; 48 seconds ago Up 47 seconds app
ec41cb9e4dd5 feisky/redis-server &quot;docker-entrypoint.s…&quot; 49 seconds ago Up 48 seconds 6379/tcp, 0.0.0.0:10000-&gt;80/tcp redis
```
今天的应用在 10000 端口监听,所以你可以通过 [http://192.168.0.10:10000](http://192.168.0.10:10000) ,来访问前面提到的三个接口。
比如,我们切换到第二个终端,使用 curl 工具,访问应用首页。如果你看到 `hello redis` 的输出,说明应用正常启动:
```
$ curl http://192.168.0.10:10000/
hello redis
```
接下来,继续在终端二中,执行下面的 curl 命令,来调用应用的 /init 接口,初始化 Redis 缓存,并且插入 5000 条缓存信息。这个过程比较慢,比如我的机器就花了十几分钟时间。耐心等一会儿后,你会看到下面这行输出:
```
# 案例插入5000条数据在实践时可以根据磁盘的类型适当调整比如使用SSD时可以调大而HDD可以适当调小
$ curl http://192.168.0.10:10000/init/5000
{&quot;elapsed_seconds&quot;:30.26814079284668,&quot;keys_initialized&quot;:5000}
```
继续执行下一个命令,访问应用的缓存查询接口。如果一切正常,你会看到如下输出:
```
$ curl http://192.168.0.10:10000/get_cache
{&quot;count&quot;:1677,&quot;data&quot;:[&quot;d97662fa-06ac-11e9-92c7-0242ac110002&quot;,...],&quot;elapsed_seconds&quot;:10.545469760894775,&quot;type&quot;:&quot;good&quot;}
```
我们看到,这个接口调用居然要花 10 秒!这么长的响应时间,显然不能满足实际的应用需求。
到底出了什么问题呢?我们还是要用前面学过的性能工具和原理,来找到这个瓶颈。
不过别急,同样为了避免分析过程中客户端的请求结束,在进行性能分析前,我们先要把 curl 命令放到一个循环里来执行。你可以在终端二中,继续执行下面的命令:
```
$ while true; do curl http://192.168.0.10:10000/get_cache; done
```
接下来,再重新回到终端一,查找接口响应慢的“病因”。
最近几个案例的现象都是响应很慢,这种情况下,我们自然先会怀疑,是不是系统资源出现了瓶颈。所以,先观察 CPU、内存和磁盘 I/O 等的使用情况肯定不会错。
我们先在终端一中执行 top 命令,分析系统的 CPU 使用情况:
```
$ top
top - 12:46:18 up 11 days, 8:49, 1 user, load average: 1.36, 1.36, 1.04
Tasks: 137 total, 1 running, 79 sleeping, 0 stopped, 0 zombie
%Cpu0 : 6.0 us, 2.7 sy, 0.0 ni, 5.7 id, 84.7 wa, 0.0 hi, 1.0 si, 0.0 st
%Cpu1 : 1.0 us, 3.0 sy, 0.0 ni, 94.7 id, 0.0 wa, 0.0 hi, 1.3 si, 0.0 st
KiB Mem : 8169300 total, 7342244 free, 432912 used, 394144 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 7478748 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
9181 root 20 0 193004 27304 8716 S 8.6 0.3 0:07.15 python
9085 systemd+ 20 0 28352 9760 1860 D 5.0 0.1 0:04.34 redis-server
368 root 20 0 0 0 0 D 1.0 0.0 0:33.88 jbd2/sda1-8
149 root 0 -20 0 0 0 I 0.3 0.0 0:10.63 kworker/0:1H
1549 root 20 0 236716 24576 9864 S 0.3 0.3 91:37.30 python3
```
观察 top 的输出可以发现CPU0 的 iowait 比较高,已经达到了 84%;而各个进程的 CPU 使用率都不太高,最高的 python 和 redis-server ,也分别只有 8% 和 5%。再看内存,总内存 8GB剩余内存还有 7GB多显然内存也没啥问题。
综合top的信息最有嫌疑的就是 iowait。所以接下来还是要继续分析是不是 I/O 问题。
还在第一个终端中,先按下 Ctrl+C停止 top 命令;然后,执行下面的 iostat 命令,查看有没有 I/O 性能问题:
```
$ iostat -d -x 1
Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz svctm %util
...
sda 0.00 492.00 0.00 2672.00 0.00 176.00 0.00 26.35 0.00 1.76 0.00 0.00 5.43 0.00 0.00
```
观察 iostat 的输出,我们发现,磁盘 sda 每秒的写数据wkB/s为 2.5MBI/O 使用率(%util是 0。看来虽然有些 I/O操作但并没导致磁盘的 I/O 瓶颈。
排查一圈儿下来CPU和内存使用没问题I/O 也没有瓶颈,接下来好像就没啥分析方向了?
碰到这种情况,还是那句话,反思一下,是不是又漏掉什么有用线索了。你可以先自己思考一下,从分析对象(案例应用)、系统原理和性能工具这三个方向下功夫,回忆它们的特性,查找现象的异常,再继续往下走。
回想一下,今天的案例问题是从 Redis 缓存中查询数据慢。对查询来说,对应的 I/O 应该是磁盘的读操作,但刚才我们用 iostat 看到的却是写操作。虽说 I/O 本身并没有性能瓶颈,但这里的磁盘写也是比较奇怪的。为什么会有磁盘写呢?那我们就得知道,到底是哪个进程在写磁盘。
要知道 I/O请求来自哪些进程还是要靠我们的老朋友 pidstat。在终端一中运行下面的 pidstat 命令,观察进程的 I/O 情况:
```
$ pidstat -d 1
12:49:35 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
12:49:36 0 368 0.00 16.00 0.00 86 jbd2/sda1-8
12:49:36 100 9085 0.00 636.00 0.00 1 redis-server
```
从 pidstat 的输出我们看到I/O 最多的进程是 PID 为 9085 的 redis-server并且它也刚好是在写磁盘。这说明确实是 redis-server 在进行磁盘写。
当然,光找到读写磁盘的进程还不够,我们还要再用 strace+lsof 组合,看看 redis-server 到底在写什么。
接下来,还是在终端一中,执行 strace 命令,并且指定 redis-server 的进程号 9085
```
# -f表示跟踪子进程和子线程-T表示显示系统调用的时长-tt表示显示跟踪时间
$ strace -f -T -tt -p 9085
[pid 9085] 14:20:16.826131 epoll_pwait(5, [{EPOLLIN, {u32=8, u64=8}}], 10128, 65, NULL, 8) = 1 &lt;0.000055&gt;
[pid 9085] 14:20:16.826301 read(8, &quot;*2\r\n$3\r\nGET\r\n$41\r\nuuid:5b2e76cc-&quot;..., 16384) = 61 &lt;0.000071&gt;
[pid 9085] 14:20:16.826477 read(3, 0x7fff366a5747, 1) = -1 EAGAIN (Resource temporarily unavailable) &lt;0.000063&gt;
[pid 9085] 14:20:16.826645 write(8, &quot;$3\r\nbad\r\n&quot;, 9) = 9 &lt;0.000173&gt;
[pid 9085] 14:20:16.826907 epoll_pwait(5, [{EPOLLIN, {u32=8, u64=8}}], 10128, 65, NULL, 8) = 1 &lt;0.000032&gt;
[pid 9085] 14:20:16.827030 read(8, &quot;*2\r\n$3\r\nGET\r\n$41\r\nuuid:55862ada-&quot;..., 16384) = 61 &lt;0.000044&gt;
[pid 9085] 14:20:16.827149 read(3, 0x7fff366a5747, 1) = -1 EAGAIN (Resource temporarily unavailable) &lt;0.000043&gt;
[pid 9085] 14:20:16.827285 write(8, &quot;$3\r\nbad\r\n&quot;, 9) = 9 &lt;0.000141&gt;
[pid 9085] 14:20:16.827514 epoll_pwait(5, [{EPOLLIN, {u32=8, u64=8}}], 10128, 64, NULL, 8) = 1 &lt;0.000049&gt;
[pid 9085] 14:20:16.827641 read(8, &quot;*2\r\n$3\r\nGET\r\n$41\r\nuuid:53522908-&quot;..., 16384) = 61 &lt;0.000043&gt;
[pid 9085] 14:20:16.827784 read(3, 0x7fff366a5747, 1) = -1 EAGAIN (Resource temporarily unavailable) &lt;0.000034&gt;
[pid 9085] 14:20:16.827945 write(8, &quot;$4\r\ngood\r\n&quot;, 10) = 10 &lt;0.000288&gt;
[pid 9085] 14:20:16.828339 epoll_pwait(5, [{EPOLLIN, {u32=8, u64=8}}], 10128, 63, NULL, 8) = 1 &lt;0.000057&gt;
[pid 9085] 14:20:16.828486 read(8, &quot;*3\r\n$4\r\nSADD\r\n$4\r\ngood\r\n$36\r\n535&quot;..., 16384) = 67 &lt;0.000040&gt;
[pid 9085] 14:20:16.828623 read(3, 0x7fff366a5747, 1) = -1 EAGAIN (Resource temporarily unavailable) &lt;0.000052&gt;
[pid 9085] 14:20:16.828760 write(7, &quot;*3\r\n$4\r\nSADD\r\n$4\r\ngood\r\n$36\r\n535&quot;..., 67) = 67 &lt;0.000060&gt;
[pid 9085] 14:20:16.828970 fdatasync(7) = 0 &lt;0.005415&gt;
[pid 9085] 14:20:16.834493 write(8, &quot;:1\r\n&quot;, 4) = 4 &lt;0.000250&gt;
```
观察一会儿,有没有发现什么有趣的现象呢?
事实上,从系统调用来看, epoll_pwait、read、write、fdatasync 这些系统调用都比较频繁。那么,刚才观察到的写磁盘,应该就是 write 或者 fdatasync 导致的了。
接着再来运行 lsof 命令,找出这些系统调用的操作对象:
```
$ lsof -p 9085
redis-ser 9085 systemd-network 3r FIFO 0,12 0t0 15447970 pipe
redis-ser 9085 systemd-network 4w FIFO 0,12 0t0 15447970 pipe
redis-ser 9085 systemd-network 5u a_inode 0,13 0 10179 [eventpoll]
redis-ser 9085 systemd-network 6u sock 0,9 0t0 15447972 protocol: TCP
redis-ser 9085 systemd-network 7w REG 8,1 8830146 2838532 /data/appendonly.aof
redis-ser 9085 systemd-network 8u sock 0,9 0t0 15448709 protocol: TCP
```
现在你会发现,描述符编号为 3 的是一个 pipe 管道5 号是 eventpoll7 号是一个普通文件,而 8 号是一个 TCP socket。
结合磁盘写的现象,我们知道,只有 7 号普通文件才会产生磁盘写,而它操作的文件路径是 /data/appendonly.aof相应的系统调用包括 write 和 fdatasync。
如果你对 Redis 的持久化配置比较熟,看到这个文件路径以及 fdatasync 的系统调用,你应该能想到,这对应着正是 Redis 持久化配置中的 appendonly 和 appendfsync 选项。很可能是因为它们的配置不合理,导致磁盘写比较多。
接下来就验证一下这个猜测,我们可以通过 Redis 的命令行工具,查询这两个选项的配置。
继续在终端一中,运行下面的命令,查询 appendonly 和 appendfsync 的配置:
```
$ docker exec -it redis redis-cli config get 'append*'
1) &quot;appendfsync&quot;
2) &quot;always&quot;
3) &quot;appendonly&quot;
4) &quot;yes&quot;
```
从这个结果你可以发现appendfsync 配置的是 always而 appendonly 配置的是 yes。这两个选项的详细含义你可以从 [Redis Persistence](https://redis.io/topics/persistence) 的文档中查到,这里我做一下简单介绍。
Redis 提供了两种数据持久化的方式,分别是快照和追加文件。
**快照方式**会按照指定的时间间隔生成数据的快照并且保存到磁盘文件中。为了避免阻塞主进程Redis 还会 fork 出一个子进程,来负责快照的保存。这种方式的性能好,无论是备份还是恢复,都比追加文件好很多。
不过它的缺点也很明显。在数据量大时fork子进程需要用到比较大的内存保存数据也很耗时。所以你需要设置一个比较长的时间间隔来应对比如至少5分钟。这样如果发生故障你丢失的就是几分钟的数据。
**追加文件**,则是用在文件末尾追加记录的方式,对 Redis 写入的数据,依次进行持久化,所以它的持久化也更安全。
此外,它还提供了一个用 appendfsync 选项设置 fsync 的策略,确保写入的数据都落到磁盘中,具体选项包括 always、everysec、no 等。
<li>
always表示每个操作都会执行一次 fsync是最为安全的方式
</li>
<li>
everysec表示每秒钟调用一次 fsync 这样可以保证即使是最坏情况下也只丢失1秒的数据
</li>
<li>
而 no 表示交给操作系统来处理。
</li>
回忆一下我们刚刚看到的配置appendfsync 配置的是 always意味着每次写数据时都会调用一次 fsync从而造成比较大的磁盘 I/O 压力。
当然,你还可以用 strace ,观察这个系统调用的执行情况。比如通过 -e 选项指定 fdatasync 后,你就会得到下面的结果:
```
$ strace -f -p 9085 -T -tt -e fdatasync
strace: Process 9085 attached with 4 threads
[pid 9085] 14:22:52.013547 fdatasync(7) = 0 &lt;0.007112&gt;
[pid 9085] 14:22:52.022467 fdatasync(7) = 0 &lt;0.008572&gt;
[pid 9085] 14:22:52.032223 fdatasync(7) = 0 &lt;0.006769&gt;
...
[pid 9085] 14:22:52.139629 fdatasync(7) = 0 &lt;0.008183&gt;
```
从这里你可以看到,每隔 10ms 左右,就会有一次 fdatasync 调用,并且每次调用本身也要消耗 7~8ms。
不管哪种方式,都可以验证我们的猜想,配置确实不合理。这样,我们就找出了 Redis 正在进行写入的文件,也知道了产生大量 I/O 的原因。
不过,回到最初的疑问,为什么查询时会有磁盘写呢?按理来说不应该只有数据的读取吗?这就需要我们再来审查一下 strace -f -T -tt -p 9085 的结果。
```
read(8, &quot;*2\r\n$3\r\nGET\r\n$41\r\nuuid:53522908-&quot;..., 16384)
write(8, &quot;$4\r\ngood\r\n&quot;, 10)
read(8, &quot;*3\r\n$4\r\nSADD\r\n$4\r\ngood\r\n$36\r\n535&quot;..., 16384)
write(7, &quot;*3\r\n$4\r\nSADD\r\n$4\r\ngood\r\n$36\r\n535&quot;..., 67)
write(8, &quot;:1\r\n&quot;, 4)
```
细心的你应该记得,根据 lsof 的分析,文件描述符编号为 7 的是一个普通文件 /data/appendonly.aof而编号为 8 的是 TCP socket。而观察上面的内容8 号对应的 TCP 读写,是一个标准的“请求-响应”格式,即:
<li>
从 socket 读取 GET uuid:53522908-… 后,响应 good
</li>
<li>
再从 socket 读取 SADD good 535… 后,响应 1。
</li>
对 Redis 来说SADD是一个写操作所以 Redis 还会把它保存到用于持久化的 appendonly.aof 文件中。
观察更多的 strace 结果,你会发现,每当 GET 返回 good 时,随后都会有一个 SADD 操作这也就导致了明明是查询接口Redis 却有大量的磁盘写。
到这里,我们就找出了 Redis 写磁盘的原因。不过在下最终结论前我们还是要确认一下8 号 TCP socket 对应的 Redis 客户端,到底是不是我们的案例应用。
我们可以给 lsof 命令加上 -i 选项,找出 TCP socket 对应的 TCP 连接信息。不过,由于 Redis 和 Python 应用都在容器中运行,我们需要进入容器的网络命名空间内部,才能看到完整的 TCP 连接。
>
<p>注意:下面的命令用到的 [nsenter](http://man7.org/linux/man-pages/man1/nsenter.1.html) 工具,可以进入容器命名空间。如果你的系统没有安装,请运行下面命令安装 nsenter<br>
docker run --rm -v /usr/local/bin:/target jpetazzo/nsenter</p>
还是在终端一中,运行下面的命令:
```
# 由于这两个容器共享同一个网络命名空间所以我们只需要进入app的网络命名空间即可
$ PID=$(docker inspect --format {{.State.Pid}} app)
# -i表示显示网络套接字信息
$ nsenter --target $PID --net -- lsof -i
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
redis-ser 9085 systemd-network 6u IPv4 15447972 0t0 TCP localhost:6379 (LISTEN)
redis-ser 9085 systemd-network 8u IPv4 15448709 0t0 TCP localhost:6379-&gt;localhost:32996 (ESTABLISHED)
python 9181 root 3u IPv4 15448677 0t0 TCP *:http (LISTEN)
python 9181 root 5u IPv4 15449632 0t0 TCP localhost:32996-&gt;localhost:6379 (ESTABLISHED)
```
这次我们可以看到redis-server 的 8 号文件描述符,对应 TCP 连接 localhost:6379-&gt;localhost:32996。其中 localhost:6379 是 redis-server 自己的监听端口,自然 localhost:32996 就是 redis 的客户端。再观察最后一行localhost:32996 对应的,正是我们的 Python 应用程序(进程号为 9181
历经各种波折,我们总算找出了 Redis 响应延迟的潜在原因。总结一下,我们找到两个问题。
第一个问题Redis 配置的 appendfsync 是 always这就导致 Redis 每次的写操作,都会触发 fdatasync 系统调用。今天的案例,没必要用这么高频的同步写,使用默认的 1s 时间间隔,就足够了。
第二个问题Python 应用在查询接口中会调用 Redis 的 SADD 命令,这很可能是不合理使用缓存导致的。
对于第一个配置问题,我们可以执行下面的命令,把 appendfsync 改成 everysec
```
$ docker exec -it redis redis-cli config set appendfsync everysec
OK
```
改完后,切换到终端二中查看,你会发现,现在的请求时间,已经缩短到了 0.9s
```
{..., &quot;elapsed_seconds&quot;:0.9368953704833984,&quot;type&quot;:&quot;good&quot;}
```
而第二个问题,就要查看应用的源码了。点击 [Github](https://github.com/feiskyer/linux-perf-examples/blob/master/redis-slow/app.py) ,你就可以查看案例应用的源代码:
```
def get_cache(type_name):
'''handler for /get_cache'''
for key in redis_client.scan_iter(&quot;uuid:*&quot;):
value = redis_client.get(key)
if value == type_name:
redis_client.sadd(type_name, key[5:])
data = list(redis_client.smembers(type_name))
redis_client.delete(type_name)
return jsonify({&quot;type&quot;: type_name, 'count': len(data), 'data': data})
```
果然Python 应用把 Redis 当成临时空间,用来存储查询过程中找到的数据。不过我们知道,这些数据放内存中就可以了,完全没必要再通过网络调用存储到 Redis 中。
基于这个思路,我把修改后的代码也推送到了相同的源码文件中,你可以通过 [http://192.168.0.10:10000/get_cache_data](http://192.168.0.10:10000/get_cache_data) 这个接口来访问它。
我们切换到终端二,按 Ctrl+C 停止之前的 curl 命令;然后执行下面的 curl 命令,调用 [http://192.168.0.10:10000/get_cache_data](http://192.168.0.10:10000/get_cache_data) 新接口:
```
$ while true; do curl http://192.168.0.10:10000/get_cache_data; done
{...,&quot;elapsed_seconds&quot;:0.16034674644470215,&quot;type&quot;:&quot;good&quot;}
```
你可以发现,解决第二个问题后,新接口的性能又有了进一步的提升,从刚才的 0.9s ,再次缩短成了不到 0.2s。
当然,案例最后,不要忘记清理案例应用。你可以切换到终端一中,执行下面的命令进行清理:
```
$ docker rm -f app redis
```
## 小结
今天我带你一起分析了一个 Redis 缓存的案例。
我们先用 top、iostat ,分析了系统的 CPU 、内存和磁盘使用情况,不过却发现,系统资源并没有出现瓶颈。这个时候想要进一步分析的话,该从哪个方向着手呢?
通过今天的案例你会发现,为了进一步分析,就需要你对系统和应用程序的工作原理有一定的了解。
比如,今天的案例中,虽然磁盘 I/O 并没有出现瓶颈,但从 Redis 的原理来说,查询缓存时不应该出现大量的磁盘 I/O 写操作。
顺着这个思路,我们继续借助 pidstat、strace、lsof、nsenter 等一系列的工具,找出了两个潜在问题,一个是 Redis 的不合理配置,另一个是 Python 应用对 Redis 的滥用。找到瓶颈后,相应的优化工作自然就比较轻松了。
## 思考
最后给你留一个思考题。从上一节 MySQL 到今天 Redis 的案例分析,你有没有发现 I/O 性能问题的分析规律呢?如果你有任何想法或心得,都可以记录下来。
当然,这两个案例这并不能涵盖所有的 I/O 性能问题。你在实际工作中,还碰到过哪些 I/O 性能问题吗?你又是怎么分析的呢?
欢迎在留言区和我讨论,也欢迎把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,176 @@
<audio id="audio" title="30 | 套路篇如何迅速分析出系统I/O的瓶颈在哪里" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/ed/d14cff11e51ad60b59b0c0dfb13a36ed.mp3"></audio>
你好,我是倪朋飞。
前几节学习中,我们通过几个案例,分析了各种常见的 I/O 性能问题。通过这些实战操作,你应该已经熟悉了 I/O 性能问题的分析和定位思路,也掌握了很多 I/O 性能分析的工具。
不过,我想你可能还是会困惑,如果离开专栏,换成其他的实际工作场景,案例中提到的各种性能指标和工具,又该如何选择呢?
上一节最后,我留下了作业,让你自己整理思路。今天,我就带你一起复习,总结一下,如何“快准狠”定位系统的 I/O 瓶颈;并且梳理清楚,在不同场景下,指标工具怎么选,性能瓶颈又该如何定位。
## 性能指标
老规矩,我们先来回顾一下,描述 I/O 的性能指标有哪些?你可以先回想一下文件系统和磁盘 I/O 的原理,结合下面这张 Linux 系统的 I/O 栈图,凭着记忆和理解自己写一写。或者,你也可以打开前面的文章,挨个复习总结一下。
<img src="https://static001.geekbang.org/resource/image/9e/38/9e42aaf53ff4a544b9a7b03b6ce63f38.png" alt="">
学了这么久的 I/O 性能知识,一说起 I/O 指标,你应该首先会想到分类描述。我们要区分开文件系统和磁盘,分别用不同指标来描述它们的性能。
### 文件系统I/O性能指标
我们先来看文件系统的情况。
**首先,最容易想到的是存储空间的使用情况,包括容量、使用量以及剩余空间等**。我们通常也称这些为磁盘空间的使用量,因为文件系统的数据最终还是存储在磁盘上。
不过要注意,这些只是文件系统向外展示的空间使用,而非在磁盘空间的真实用量,因为文件系统的元数据也会占用磁盘空间。
而且,如果你配置了 RAID从文件系统看到的使用量跟实际磁盘的占用空间也会因为 RAID 级别的不同而不一样。比方说,配置 RAID10 后,你从文件系统最多也只能看到所有磁盘容量的一半。
除了数据本身的存储空间,还有一个**容易忽略的是索引节点的使用情况,它也包括容量、使用量以及剩余量等三个指标**。如果文件系统中存储过多的小文件,就可能碰到索引节点容量已满的问题。
**其次,你应该想到的是前面多次提到过的缓存使用情况,包括页缓存、目录项缓存、索引节点缓存以及各个具体文件系统(如 ext4、XFS 等)的缓存**。这些缓存会使用速度更快的内存,用来临时存储文件数据或者文件系统的元数据,从而可以减少访问慢速磁盘的次数。
除了以上这两点,文件 I/O 也是很重要的性能指标,包括 IOPS包括 r/s 和 w/s、响应时间延迟以及吞吐量B/s等。在考察这类指标时通常还要考虑实际文件的读写情况。比如结合文件大小、文件数量、I/O 类型等,综合分析文件 I/O 的性能。
诚然这些性能指标非常重要但不幸的是Linux 文件系统并没提供,直接查看这些指标的方法。我们只能通过系统调用、动态跟踪或者基准测试等方法,间接进行观察、评估。
不过,实际上,这些指标在我们考察磁盘性能时更容易见到,因为 Linux 为磁盘性能提供了更详细的数据。
### 磁盘I/O性能指标
接下来,我们就来具体看看,哪些性能指标可以衡量磁盘 I/O 的性能。
在磁盘 I/O 原理的文章中,我曾提到过四个核心的磁盘 I/O 指标。
<li>
**使用率**是指磁盘忙处理I/O请求的百分比。过高的使用率比如超过60%通常意味着磁盘I/O存在性能瓶颈。
</li>
<li>
**IOPS**Input/Output Per Second是指每秒的 I/O 请求数。
</li>
<li>
**吞吐量**,是指每秒的 I/O 请求大小。
</li>
<li>
**响应时间**,是指从发出 I/O 请求到收到响应的间隔时间。
</li>
考察这些指标时,一定要注意综合 I/O 的具体场景来分析比如读写类型顺序还是随机、读写比例、读写大小、存储类型有无RAID以及RAID级别、本地存储还是网络存储等。
不过,这里有个大忌,就是把不同场景的 I/O 性能指标,直接进行分析对比。这是很常见的一个误区,你一定要避免。
除了这些指标外,在前面 Cache 和 Buffer 原理的文章中,我曾多次提到,**缓冲区Buffer**也是要重点掌握的指标,它经常出现在内存和磁盘问题的分析中。
文件系统和磁盘 I/O 的这些指标都很有用,需要我们熟练掌握,所以我总结成了一张图,帮你分类和记忆。你可以保存并打印出来,方便随时查看复习,也可以把它当成 I/O 性能分析的“指标筛选”清单使用。
<img src="https://static001.geekbang.org/resource/image/b6/20/b6d67150e471e1340a6f3c3dc3ba0120.png" alt="">
## 性能工具
掌握文件系统和磁盘 I/O 的性能指标后,我们还要知道,怎样去获取这些指标,也就是搞明白工具的使用问题。
你还记得前面的基础篇和案例篇中,都分别用了哪些工具吗?我们一起回顾下这些内容。
第一,在文件系统的原理中,我介绍了查看文件系统容量的工具 df。它既可以查看文件系统数据的空间容量也可以查看索引节点的容量。至于文件系统缓存我们通过/proc/meminfo、/proc/slabinfo 以及 slabtop 等各种来源,观察页缓存、目录项缓存、索引节点缓存以及具体文件系统的缓存情况。
第二,在磁盘 I/O 的原理中,我们分别用 iostat 和 pidstat 观察了磁盘和进程的 I/O 情况。它们都是最常用的 I/O 性能分析工具。通过 iostat ,我们可以得到磁盘的 I/O 使用率、吞吐量、响应时间以及 IOPS 等性能指标;而通过 pidstat ,则可以观察到进程的 I/O 吞吐量以及块设备 I/O 的延迟等。
第三,在狂打日志的案例中,我们先用 top 查看系统的 CPU 使用情况,发现 iowait 比较高;然后,又用 iostat 发现了磁盘的 I/O 使用率瓶颈,并用 pidstat 找出了大量 I/O 的进程;最后,通过 strace 和 lsof我们找出了问题进程正在读写的文件并最终锁定性能问题的来源——原来是进程在狂打日志。
第四,在磁盘 I/O 延迟的单词热度案例中,我们同样先用 top、iostat ,发现磁盘有 I/O 瓶颈,并用 pidstat 找出了大量 I/O 的进程。可接下来,想要照搬上次操作的我们失败了。在随后的 strace 命令中,我们居然没看到 write 系统调用。于是,我们换了一个思路,用新工具 filetop 和 opensnoop ,从内核中跟踪系统调用,最终找出瓶颈的来源。
最后,在 MySQL 和 Redis 的案例中,同样的思路,我们先用 top、iostat 以及 pidstat ,确定并找出 I/O 性能问题的瓶颈来源,它们正是 mysqld 和 redis-server。随后我们又用 strace+lsof 找出了它们正在读写的文件。
关于 MySQL 案例,根据 mysqld 正在读写的文件路径,再结合 MySQL 数据库引擎的原理,我们不仅找出了数据库和数据表的名称,还进一步发现了慢查询的问题,最终通过优化索引解决了性能瓶颈。
至于 Redis 案例,根据 redis-server 读写的文件,以及正在进行网络通信的 TCP Socket再结合 Redis 的工作原理,我们发现 Redis 持久化选项配置有问题;从 TCP Socket 通信的数据中,我们还发现了客户端的不合理行为。于是,我们修改 Redis 配置选项并优化了客户端使用Redis的方式从而减少网络通信次数解决性能问题。
一下子复习了这么多,你是不是觉得头昏脑胀,再次想感叹性能工具的繁杂呀!其实,只要把相应的系统工作原理捋明白,工具使用并不难
## 性能指标和工具的联系
同前面CPU和内存板块的学习一样我建议从指标和工具两个不同维度出发整理记忆。
<li>
从I/O指标出发你更容易把性能工具同系统工作原理关联起来对性能问题有宏观的认识和把握。
</li>
<li>
而从性能工具出发,可以让你更快上手使用工具,迅速找出我们想观察的性能指标。特别是在工具有限的情况下,我们更要充分利用好手头的每一个工具,少量工具也要尽力挖掘出大量信息。
</li>
**第一个维度,从文件系统和磁盘 I/O 的性能指标出发。换句话说,当你想查看某个性能指标时,要清楚知道,哪些工具可以做到。**
根据不同的性能指标,对提供指标的性能工具进行分类和理解。这样,在实际排查性能问题时,你就可以清楚知道,什么工具可以提供你想要的指标,而不是毫无根据地挨个尝试,撞运气。
虽然你不需要把所有相关的工具背下来,但如果能记清楚每个指标对应的工具特性,实际操作起来,一定能更高效、灵活。
这里,我把提供 I/O 性能指标的工具做成了一个表格,方便你梳理关系和理解记忆。你可以把它保存并打印出来,随时记忆。当然,你也可以把它当成一个“指标工具”指南来使用。
<img src="https://static001.geekbang.org/resource/image/6f/98/6f26fa18a73458764fcda00212006698.png" alt="">
下面,我们再来看第二个维度。
**第二个维度,从工具出发。也就是当你已经安装了某个工具后,要知道这个工具能提供哪些指标。**
这在实际环境中,特别是生产环境中也是非常重要的。因为很多情况下,你并没有权限安装新的工具包,只能最大化地利用好系统已有的工具,而这就需要你对它们有足够的了解。
具体到每个工具的使用方法,一般都支持丰富的配置选项。不过不用担心,这些配置选项并不用背下来。你只要知道有哪些工具,以及这些工具的基本功能是什么就够了。真正要用到的时候, 通过 man 命令,查它们的使用手册就可以了。
同样的,我也将这些常用工具汇总成了一个表格,方便你区分和理解。自然,你也可以当成一个“工具指标”指南使用,需要时查表即可。
<img src="https://static001.geekbang.org/resource/image/ee/f3/ee11664d015f034e4042b9fa4fyycff3.jpg" alt="">
## 如何迅速分析I/O的性能瓶颈
到这里,相信你对内存的性能指标已经非常熟悉,也清楚每种性能指标分别能用什么工具来获取。
你应该发现了,比起前两个板块,虽然文件系统和磁盘的 I/O 性能指标仍比较多,但核心的性能工具,其实就是那么几个。熟练掌握它们,再根据实际系统的现象,并配合系统和应用程序的原理, I/O 性能分析就很清晰了。
不过,不管怎么说,如果每次一碰到 I/O 的性能问题,就把上面提到的所有工具跑一遍,肯定是不现实的。
在实际生产环境中,我们希望的是,尽可能**快**地定位系统的瓶颈,然后尽可能**快**地优化性能,也就是要又快又准地解决性能问题。
那有没有什么方法可以又快又准地找出系统的I/O 瓶颈呢?答案是肯定的。
还是那句话,找关联。多种性能指标间都有一定的关联性,不要完全孤立的看待他们。**想弄清楚性能指标的关联性,就要通晓每种性能指标的工作原理**。这也是为什么我在介绍每个性能指标时,都要穿插讲解相关的系统原理,再次希望你能记住这一点。
以我们前面几期的案例为例,如果你仔细对比前面的几个案例,从 I/O延迟的案例到 MySQL 和 Redis 的案例,就会发现,虽然这些问题千差万别,但从 I/O 角度来分析,最开始的分析思路基本上类似,都是:
<li>
先用 iostat 发现磁盘 I/O 性能瓶颈;
</li>
<li>
再借助 pidstat ,定位出导致瓶颈的进程;
</li>
<li>
随后分析进程的 I/O 行为;
</li>
<li>
最后,结合应用程序的原理,分析这些 I/O 的来源。
</li>
**所以,为了缩小排查范围,我通常会先运行那几个支持指标较多的工具,如 iostat、vmstat、pidstat 等。**然后再根据观察到的现象,结合系统和应用程序的原理,寻找下一步的分析方向。我把这个过程画成了一张图,你可以保存下来参考使用。
<img src="https://static001.geekbang.org/resource/image/18/8a/1802a35475ee2755fb45aec55ed2d98a.png" alt="">
图中列出了最常用的几个文件系统和磁盘 I/O 性能分析工具以及相应的分析流程箭头则表示分析方向。这其中iostat、vmstat、pidstat 是最核心的几个性能工具,它们也提供了最重要的 I/O 性能指标。举几个例子你可能更容易理解。
例如,在前面讲过的 MySQL 和 Redis 案例中,我们就是通过 iostat 确认磁盘出现 I/O 性能瓶颈,然后用 pidstat 找出 I/O 最大的进程,接着借助 strace 找出该进程正在读写的文件,最后结合应用程序的原理,找出大量 I/O 的原因。
再如,当你用 iostat 发现磁盘有 I/O 性能瓶颈后,再用 pidstat 和 vmstat 检查,可能会发现 I/O 来自内核线程,如 Swap 使用大量升高。这种情况下,你就得进行内存分析了,先找出占用大量内存的进程,再设法减少内存的使用。
另外注意,我在这个图中只列出了最核心的几个性能工具,并没有列出前面表格中的所有工具。这么做,一方面是不想用大量的工具列表吓到你。在学习之初就接触所有核心或小众的工具,不见得是好事。另一方面,也是希望你能先把重心放在核心工具上,毕竟熟练掌握它们,就可以解决大多数问题。
所以你可以保存下这张图作为文件系统和磁盘I/O性能分析的思路图谱。从最核心的这几个工具开始通过我提供的那些案例自己在真实环境里实践拿下它们。
## 小结
今天,我们一起复习了常见的文件系统和磁盘 I/O 性能指标,梳理了常见的 I/O 性能观测工具,并建立了性能指标和工具的关联。最后,我们还总结了快速分析 I/O 性能问题的思路。
还是那句话,虽然 I/O 的性能指标很多,相应的性能分析工具也有不少,但熟悉了各指标含义后,你就会自然找到它们的关联。顺着这个思路往下走,掌握常用的分析套路也并不难。
## 思考
专栏学习中,我只列举了几个最常见的案例,帮你理解文件系统和磁盘 I/O 性能的原理和分析方法。你肯定也碰到过不少其他 I/O 性能问题吧。我想请你一起聊聊,你碰到过哪些 I/O 性能问题呢?你又是怎么分析出它的瓶颈呢?
欢迎在留言区和我讨论,也欢迎你把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="31 | 套路篇:磁盘 I/O 性能优化的几个思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/77/c1/771462a4baa52bc952a4cc17a8875ec1.mp3"></audio>
你好,我是倪朋飞。
上一节,我们一起回顾了常见的文件系统和磁盘 I/O 性能指标,梳理了核心的 I/O 性能观测工具,最后还总结了快速分析 I/O 性能问题的思路。
虽然 I/O 的性能指标很多,相应的性能分析工具也有好几个,但理解了各种指标的含义后,你就会发现它们其实都有一定的关联。
顺着这些关系往下理解,你就会发现,掌握这些常用的瓶颈分析思路,其实并不难。
找出了 I/O 的性能瓶颈后,下一步要做的就是优化了,也就是如何以最快的速度完成 I/O操作或者换个思路减少甚至避免磁盘的 I/O 操作。
今天,我就来说说,优化 I/O 性能问题的思路和注意事项。
## I/O 基准测试
按照我的习惯,优化之前,我会先问自己, I/O 性能优化的目标是什么换句话说我们观察的这些I/O 性能指标(比如 IOPS、吞吐量、延迟等要达到多少才合适呢
事实上I/O 性能指标的具体标准,每个人估计会有不同的答案,因为我们每个人的应用场景、使用的文件系统和物理磁盘等,都有可能不一样。
为了更客观合理地评估优化效果,我们首先应该对磁盘和文件系统进行基准测试,得到文件系统或者磁盘 I/O 的极限性能。
[fio](https://github.com/axboe/fio)Flexible I/O Tester正是最常用的文件系统和磁盘 I/O 性能基准测试工具。它提供了大量的可定制化选项,可以用来测试,裸盘或者文件系统在各种场景下的 I/O 性能,包括了不同块大小、不同 I/O 引擎以及是否使用缓存等场景。
fio 的安装比较简单,你可以执行下面的命令来安装它:
```
# Ubuntu
apt-get install -y fio
# CentOS
yum install -y fio
```
安装完成后,就可以执行 man fio 查询它的使用方法。
fio 的选项非常多, 我会通过几个常见场景的测试方法,介绍一些最常用的选项。这些常见场景包括随机读、随机写、顺序读以及顺序写等,你可以执行下面这些命令来测试:
```
# 随机读
fio -name=randread -direct=1 -iodepth=64 -rw=randread -ioengine=libaio -bs=4k -size=1G -numjobs=1 -runtime=1000 -group_reporting -filename=/dev/sdb
# 随机写
fio -name=randwrite -direct=1 -iodepth=64 -rw=randwrite -ioengine=libaio -bs=4k -size=1G -numjobs=1 -runtime=1000 -group_reporting -filename=/dev/sdb
# 顺序读
fio -name=read -direct=1 -iodepth=64 -rw=read -ioengine=libaio -bs=4k -size=1G -numjobs=1 -runtime=1000 -group_reporting -filename=/dev/sdb
# 顺序写
fio -name=write -direct=1 -iodepth=64 -rw=write -ioengine=libaio -bs=4k -size=1G -numjobs=1 -runtime=1000 -group_reporting -filename=/dev/sdb
```
在这其中,有几个参数需要你重点关注一下。
<li>
direct表示是否跳过系统缓存。上面示例中我设置的 1 ,就表示跳过系统缓存。
</li>
<li>
iodepth表示使用异步 I/Oasynchronous I/O简称AIO同时发出的 I/O 请求上限。在上面的示例中,我设置的是 64。
</li>
<li>
rw表示 I/O 模式。我的示例中, read/write 分别表示顺序读/写,而 randread/randwrite 则分别表示随机读/写。
</li>
<li>
ioengine表示 I/O 引擎它支持同步sync、异步libaio、内存映射mmap、网络net等各种 I/O 引擎。上面示例中,我设置的 libaio 表示使用异步 I/O。
</li>
<li>
bs表示 I/O 的大小。示例中,我设置成了 4K这也是默认值
</li>
<li>
filename表示文件路径当然它可以是磁盘路径测试磁盘性能也可以是文件路径测试文件系统性能。示例中我把它设置成了磁盘 /dev/sdb。不过注意用磁盘路径测试写会破坏这个磁盘中的文件系统所以在使用前你一定要事先做好数据备份。
</li>
下面就是我使用 fio 测试顺序读的一个报告示例。
```
read: (g=0): rw=read, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=64
fio-3.1
Starting 1 process
Jobs: 1 (f=1): [R(1)][100.0%][r=16.7MiB/s,w=0KiB/s][r=4280,w=0 IOPS][eta 00m:00s]
read: (groupid=0, jobs=1): err= 0: pid=17966: Sun Dec 30 08:31:48 2018
read: IOPS=4257, BW=16.6MiB/s (17.4MB/s)(1024MiB/61568msec)
slat (usec): min=2, max=2566, avg= 4.29, stdev=21.76
clat (usec): min=228, max=407360, avg=15024.30, stdev=20524.39
lat (usec): min=243, max=407363, avg=15029.12, stdev=20524.26
clat percentiles (usec):
| 1.00th=[ 498], 5.00th=[ 1020], 10.00th=[ 1319], 20.00th=[ 1713],
| 30.00th=[ 1991], 40.00th=[ 2212], 50.00th=[ 2540], 60.00th=[ 2933],
| 70.00th=[ 5407], 80.00th=[ 44303], 90.00th=[ 45351], 95.00th=[ 45876],
| 99.00th=[ 46924], 99.50th=[ 46924], 99.90th=[ 48497], 99.95th=[ 49021],
| 99.99th=[404751]
bw ( KiB/s): min= 8208, max=18832, per=99.85%, avg=17005.35, stdev=998.94, samples=123
iops : min= 2052, max= 4708, avg=4251.30, stdev=249.74, samples=123
lat (usec) : 250=0.01%, 500=1.03%, 750=1.69%, 1000=2.07%
lat (msec) : 2=25.64%, 4=37.58%, 10=2.08%, 20=0.02%, 50=29.86%
lat (msec) : 100=0.01%, 500=0.02%
cpu : usr=1.02%, sys=2.97%, ctx=33312, majf=0, minf=75
IO depths : 1=0.1%, 2=0.1%, 4=0.1%, 8=0.1%, 16=0.1%, 32=0.1%, &gt;=64=100.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, &gt;=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.1%, &gt;=64=0.0%
issued rwt: total=262144,0,0, short=0,0,0, dropped=0,0,0
latency : target=0, window=0, percentile=100.00%, depth=64
Run status group 0 (all jobs):
READ: bw=16.6MiB/s (17.4MB/s), 16.6MiB/s-16.6MiB/s (17.4MB/s-17.4MB/s), io=1024MiB (1074MB), run=61568-61568msec
Disk stats (read/write):
sdb: ios=261897/0, merge=0/0, ticks=3912108/0, in_queue=3474336, util=90.09%
```
这个报告中,需要我们重点关注的是, slat、clat、lat ,以及 bw 和 iops 这几行。
先来看刚刚提到的前三个参数。事实上slat、clat、lat 都是指 I/O 延迟latency。不同之处在于
<li>
slat ,是指从 I/O 提交到实际执行 I/O 的时长Submission latency
</li>
<li>
clat ,是指从 I/O 提交到 I/O 完成的时长Completion latency
</li>
<li>
而 lat 指的是从fio 创建 I/O 到 I/O 完成的总时长。
</li>
这里需要注意的是,对同步 I/O 来说,由于 I/O 提交和I/O完成是一个动作所以 slat 实际上就是 I/O 完成的时间,而 clat 是 0。而从示例可以看到使用异步 I/Olibaiolat 近似等于 slat + clat之和。
再来看bw ,它代表吞吐量。在我上面的示例中,你可以看到,平均吞吐量大约是 16 MB17005 KiB/1024
最后的iops ,其实就是每秒 I/O 的次数,上面示例中的平均 IOPS 为 4250。
通常情况下,应用程序的 I/O 都是读写并行的而且每次的I/O大小也不一定相同。所以刚刚说的这几种场景并不能精确模拟应用程序的 I/O 模式。那怎么才能精确模拟应用程序的 I/O 模式呢?
幸运的是fio 支持 I/O 的重放。借助前面提到过的 blktrace再配合上 fio就可以实现对应用程序 I/O 模式的基准测试。你需要先用 blktrace ,记录磁盘设备的 I/O 访问情况;然后使用 fio ,重放 blktrace 的记录。
比如你可以运行下面的命令来操作:
```
# 使用blktrace跟踪磁盘I/O注意指定应用程序正在操作的磁盘
$ blktrace /dev/sdb
# 查看blktrace记录的结果
# ls
sdb.blktrace.0 sdb.blktrace.1
# 将结果转化为二进制文件
$ blkparse sdb -d sdb.bin
# 使用fio重放日志
$ fio --name=replay --filename=/dev/sdb --direct=1 --read_iolog=sdb.bin
```
这样,我们就通过 blktrace+fio 的组合使用,得到了应用程序 I/O 模式的基准测试报告。
## I/O 性能优化
得到 I/O 基准测试报告后,再用上我们上一节总结的性能分析套路,找出 I/O 的性能瓶颈并优化,就是水到渠成的事情了。当然, 想要优化I/O 性能,肯定离不开 Linux 系统的 I/O 栈图的思路辅助。你可以结合下面的 I/O 栈图再回顾一下。
<img src="https://static001.geekbang.org/resource/image/9e/38/9e42aaf53ff4a544b9a7b03b6ce63f38.png" alt="">
下面,我就带你从应用程序、文件系统以及磁盘角度,分别看看 I/O 性能优化的基本思路。
### 应用程序优化
首先,我们来看一下,从应用程序的角度有哪些优化 I/O 的思路。
应用程序处于整个 I/O 栈的最上端,它可以通过系统调用,来调整 I/O 模式(如顺序还是随机、同步还是异步), 同时,它也是 I/O 数据的最终来源。在我看来,可以有这么几种方式来优化应用程序的 I/O 性能。
第一,可以用追加写代替随机写,减少寻址开销,加快 I/O 写的速度。
第二,可以借助缓存 I/O ,充分利用系统缓存,降低实际 I/O 的次数。
第三,可以在应用程序内部构建自己的缓存,或者用 Redis 这类外部缓存系统。这样,一方面,能在应用程序内部,控制缓存的数据和生命周期;另一方面,也能降低其他应用程序使用缓存对自身的影响。
比如,在前面的 MySQL 案例中,我们已经见识过,只是因为一个干扰应用清理了系统缓存,就会导致 MySQL 查询有数百倍的性能差距0.1s vs 15s
再如, C 标准库提供的 fopen、fread 等库函数,都会利用标准库的缓存,减少磁盘的操作。而你直接使用 open、read 等系统调用时,就只能利用操作系统提供的页缓存和缓冲区等,而没有库函数的缓存可用。
第四,在需要频繁读写同一块磁盘空间时,可以用 mmap 代替 read/write减少内存的拷贝次数。
第五,在需要同步写的场景中,尽量将写请求合并,而不是让每个请求都同步写入磁盘,即可以用 fsync() 取代 O_SYNC。
第六,在多个应用程序共享相同磁盘时,为了保证 I/O 不被某个应用完全占用,推荐你使用 cgroups 的 I/O 子系统,来限制进程 / 进程组的 IOPS 以及吞吐量。
最后,在使用 CFQ 调度器时,可以用 ionice 来调整进程的 I/O 调度优先级,特别是提高核心应用的 I/O 优先级。ionice 支持三个优先级类Idle、Best-effort 和 Realtime。其中 Best-effort 和 Realtime 还分别支持 0-7 的级别,数值越小,则表示优先级别越高。
### 文件系统优化
应用程序访问普通文件时,实际是由文件系统间接负责,文件在磁盘中的读写。所以,跟文件系统中相关的也有很多优化 I/O 性能的方式。
第一你可以根据实际负载场景的不同选择最适合的文件系统。比如Ubuntu 默认使用 ext4 文件系统,而 CentOS 7 默认使用 xfs 文件系统。
相比于 ext4 xfs 支持更大的磁盘分区和更大的文件数量,如 xfs 支持大于16TB 的磁盘。但是 xfs 文件系统的缺点在于无法收缩,而 ext4 则可以。
第二在选好文件系统后还可以进一步优化文件系统的配置选项包括文件系统的特性如ext_attr、dir_index、日志模式如journal、ordered、writeback、挂载选项如noatime等等。
比如, 使用tune2fs 这个工具可以调整文件系统的特性tune2fs 也常用来查看文件系统超级块的内容)。 而通过 /etc/fstab ,或者 mount 命令行参数,我们可以调整文件系统的日志模式和挂载选项等。
第三,可以优化文件系统的缓存。
比如,你可以优化 pdflush 脏页的刷新频率(比如设置 dirty_expire_centisecs 和 dirty_writeback_centisecs以及脏页的限额比如调整 dirty_background_ratio 和 dirty_ratio等
再如,你还可以优化内核回收目录项缓存和索引节点缓存的倾向,即调整 vfs_cache_pressure/proc/sys/vm/vfs_cache_pressure默认值100数值越大就表示越容易回收。
最后,在不需要持久化时,你还可以用内存文件系统 tmpfs以获得更好的 I/O性能 。tmpfs 把数据直接保存在内存中,而不是磁盘中。比如 /dev/shm/ ,就是大多数 Linux 默认配置的一个内存文件系统,它的大小默认为总内存的一半。
### 磁盘优化
数据的持久化存储,最终还是要落到具体的物理磁盘中,同时,磁盘也是整个 I/O 栈的最底层。从磁盘角度出发,自然也有很多有效的性能优化方法。
第一,最简单有效的优化方法,就是换用性能更好的磁盘,比如用 SSD 替代 HDD。
第二,我们可以使用 RAID ,把多块磁盘组合成一个逻辑磁盘,构成冗余独立磁盘阵列。这样做既可以提高数据的可靠性,又可以提升数据的访问性能。
第三,针对磁盘和应用程序 I/O 模式的特征,我们可以选择最适合的 I/O 调度算法。比方说SSD 和虚拟机中的磁盘,通常用的是 noop 调度算法。而数据库应用,我更推荐使用 deadline 算法。
第四,我们可以对应用程序的数据,进行磁盘级别的隔离。比如,我们可以为日志、数据库等 I/O 压力比较重的应用,配置单独的磁盘。
第五,在顺序读比较多的场景中,我们可以增大磁盘的预读数据,比如,你可以通过下面两种方法,调整 /dev/sdb 的预读大小。
<li>
调整内核选项 /sys/block/sdb/queue/read_ahead_kb默认大小是 128 KB单位为KB。
</li>
<li>
使用 blockdev 工具设置,比如 blockdev --setra 8192 /dev/sdb注意这里的单位是 512B0.5KB),所以它的数值总是 read_ahead_kb 的两倍。
</li>
第六,我们可以优化内核块设备 I/O 的选项。比如,可以调整磁盘队列的长度 /sys/block/sdb/queue/nr_requests适当增大队列长度可以提升磁盘的吞吐量当然也会导致 I/O 延迟增大)。
最后,要注意,磁盘本身出现硬件错误,也会导致 I/O 性能急剧下降,所以发现磁盘性能急剧下降时,你还需要确认,磁盘本身是不是出现了硬件错误。
比如,你可以查看 dmesg 中是否有硬件 I/O 故障的日志。 还可以使用 badblocks、smartctl等工具检测磁盘的硬件问题或用 e2fsck 等来检测文件系统的错误。如果发现问题,你可以使用 fsck 等工具来修复。
## 小结
今天,我们一起梳理了常见的文件系统和磁盘 I/O 的性能优化思路和方法。发现 I/O 性能问题后,不要急于动手优化,而要先找出最重要的、可以最大程度提升性能的问题,然后再从 I/O 栈的不同层入手,考虑具体的优化方法。
记住,磁盘和文件系统的 I/O ,通常是整个系统中最慢的一个模块。所以,在优化 I/O 问题时,除了可以优化 I/O 的执行流程还可以借助更快的内存、网络、CPU 等减少I/O 调用。
比如,你可以充分利用系统提供的 Buffer、Cache ,或是应用程序内部缓存, 再或者Redis 这类的外部缓存系统。
## 思考
在整个板块的学习中,我只列举了最常见的几个 I/O 性能优化思路。除此之外,还有很多从应用程序、系统再到磁盘硬件的优化方法。我想请你一起来聊聊,你还知道哪些其他优化方法吗?
欢迎在留言区跟我讨论,也欢迎你把这篇文章分享给你的同事、朋友。我们一起在实战中演练,在交流中进步。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="32 | 答疑(四):阻塞、非阻塞 I/O 与同步、异步 I/O 的区别和联系" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/aa/294a44f3338344bb8d6bbd41a8d78faa.mp3"></audio>
你好,我是倪朋飞。
专栏更新至今,四大基础模块的第三个模块——文件系统和磁盘 I/O 篇,我们就已经学完了。很开心你还没有掉队,仍然在积极学习思考和实践操作,并且热情地留言与讨论。
今天是性能优化的第四期。照例,我从 I/O 模块的留言中摘出了一些典型问题,作为今天的答疑内容,集中回复。同样的,为了便于你学习理解,它们并不是严格按照文章顺序排列的。
每个问题,我都附上了留言区提问的截屏。如果你需要回顾内容原文,可以扫描每个问题右下方的二维码查看。
## 问题1阻塞、非阻塞 I/O 与同步、异步 I/O 的区别和联系
<img src="https://static001.geekbang.org/resource/image/1c/b0/1c3237118d1c55792ac0d9cc23f14bb0.png" alt="">
在[文件系统的工作原理](https://time.geekbang.org/column/article/76876)篇中,我曾经介绍了阻塞、非阻塞 I/O 以及同步、异步 I/O 的含义,这里我们再简单回顾一下。
首先我们来看阻塞和非阻塞 I/O。根据应用程序是否阻塞自身运行可以把 I/O 分为阻塞 I/O 和非阻塞 I/O。
<li>
所谓阻塞I/O是指应用程序在执行I/O操作后如果没有获得响应就会阻塞当前线程不能执行其他任务。
</li>
<li>
所谓非阻塞I/O是指应用程序在执行I/O操作后不会阻塞当前的线程可以继续执行其他的任务。
</li>
再来看同步 I/O 和异步 I/O。根据 I/O 响应的通知方式的不同,可以把文件 I/O 分为同步 I/O 和异步 I/O。
<li>
所谓同步 I/O是指收到 I/O 请求后,系统不会立刻响应应用程序;等到处理完成,系统才会通过系统调用的方式,告诉应用程序 I/O 结果。
</li>
<li>
所谓异步 I/O是指收到 I/O 请求后,系统会先告诉应用程序 I/O 请求已经收到,随后再去异步处理;等处理完成后,系统再通过事件通知的方式,告诉应用程序结果。
</li>
你可以看出,阻塞/非阻塞和同步/异步,其实就是两个不同角度的 I/O 划分方式。它们描述的对象也不同,阻塞/非阻塞针对的是 I/O 调用者(即应用程序),而同步/异步针对的是 I/O 执行者(即系统)。
我举个例子来进一步解释下。比如在 Linux I/O 调用中,
<li>
系统调用 read 是同步读所以在没有得到磁盘数据前read 不会响应应用程序。
</li>
<li>
而 aio_read 是异步读,系统收到 AIO 读请求后不等处理就返回了,而具体的 read 结果,再通过回调异步通知应用程序。
</li>
再如,在网络套接字的接口中,
<li>
使用 send() 直接向套接字发送数据时,如果套接字没有设置 O_NONBLOCK 标识,那么 send() 操作就会一直阻塞,当前线程也没法去做其他事情。
</li>
<li>
当然,如果你用了 epoll系统会告诉你这个套接字的状态那就可以用非阻塞的方式使用。当这个套接字不可写的时候你可以去做其他事情比如读写其他套接字。
</li>
## 问题2“文件系统”课后思考
<img src="https://static001.geekbang.org/resource/image/40/a6/40c924ea4b11e12d6d34181a00f292a6.jpg" alt="">
在[文件系统原理](https://time.geekbang.org/column/article/76876)[文章](https://time.geekbang.org/column/article/76876)的最后,我给你留了一道思考题,那就是执行 find 命令时,会不会导致系统的缓存升高呢?如果会导致,升高的又是哪种类型的缓存呢?
关于这个问题,白华和 coyang 的答案已经很准确了。通过学习Linux 文件系统的原理我们知道文件名以及文件之间的目录关系都放在目录项缓存中。而这是一个基于内存的数据结构会根据需要动态构建。所以查找文件时Linux 就会动态构建不在缓存中的目录项结构,导致 dentry 缓存升高。
<img src="https://static001.geekbang.org/resource/image/48/c5/488110263a9c7ff801a3e04c010f0bc5.png" alt=""><img src="https://static001.geekbang.org/resource/image/57/58/57e4cf5a42a91392ebebf106f992a858.png" alt="">
事实上除了目录项缓存增加Buffer 的使用也会增加。如果你用 vmstat 观察一下,会发现 Buffer 和 Cache 都在增长:
```
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 1 0 7563744 6024 225944 0 0 3736 0 574 3249 3 5 89 3 0
1 0 0 7542792 14736 236856 0 0 8708 0 13494 32335 8 19 66 7 0
0 1 0 7494452 27280 272284 0 0 12544 0 4550 17084 5 15 68 13 0
0 1 0 7475084 42380 276320 0 0 15096 0 2541 14253 2 6 78 13 0
0 1 0 7455728 57600 280436 0 0 15220 0 2025 14518 2 6 70 22 0
```
这里Buffer 的增长是因为,构建目录项缓存所需的元数据(比如文件名称、索引节点等),需要从文件系统中读取。
## 问题3“磁盘 I/O 延迟”课后思考
在[磁盘 I/O 延迟案例](https://time.geekbang.org/column/article/78409)的最后,我给你留了一道思考题。
我们通过 iostat ,确认磁盘 I/O 已经出现了性能瓶颈,还用 pidstat 找出了大量磁盘 I/O 的进程。但是,随后使用 strace 跟踪这个进程,却找不到任何 write 系统调用。这是为什么呢?
<img src="https://static001.geekbang.org/resource/image/64/09/6408b3aa2aa9a98a930d1a5b2e2fef09.jpg" alt="">
很多同学的留言都准确回答了这个问题。比如,划时代和 jeff 的留言都指出,在这个场景中,我们需要加 -f 选项,以便跟踪多进程和多线程的系统调用情况。
<img src="https://static001.geekbang.org/resource/image/e4/55/e4e9a070022f7b49cb8d5554b9a60055.png" alt=""><img src="https://static001.geekbang.org/resource/image/71/05/71a6df4144ce59d9e1a01c26453acf05.png" alt="">
你看,仅仅是不恰当的选项,都可能会导致性能工具“犯错”,呈现这种看起来不合逻辑的结果。非常高兴看到,这么多同学已经掌握了性能工具使用的核心思路——弄清楚工具本身的原理和问题。
## 问题4“MySQL 案例”课后思考
在 [MySQL 案例](https://time.geekbang.org/column/article/78633)的最后,我给你留了一个思考题。
为什么 DataService 应用停止后即使仍没有索引MySQL 的查询速度还是快了很多,并且磁盘 I/O 瓶颈也消失了呢?
<img src="https://static001.geekbang.org/resource/image/92/78/924fbc974313b1e0fe6b8d14e7a44178.png" alt="">
ninuxer 的留言基本解释了这个问题,不过还不够完善。
事实上,当你看到 DataService 在修改 **/proc/sys/vm/drop_caches** 时,就应该想到前面学过的 Cache 的作用。
我们知道,案例应用访问的数据表,基于 MyISAM 引擎,而 MyISAM 的一个特点,就是只在内存中缓存索引,并不缓存数据。所以,在查询语句无法使用索引时,就需要数据表从数据库文件读入内存,然后再进行处理。
所以,如果你用 vmstat 工具,观察缓存和 I/O 的变化趋势,就会发现下面这样的结果:
```
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
# 备注: DataService正在运行
0 1 0 7293416 132 366704 0 0 32516 12 36 546 1 3 49 48 0
0 1 0 7260772 132 399256 0 0 32640 0 37 463 1 1 49 48 0
0 1 0 7228088 132 432088 0 0 32640 0 30 477 0 1 49 49 0
0 0 0 7306560 132 353084 0 0 20572 4 90 574 1 4 69 27 0
0 2 0 7282300 132 368536 0 0 15468 0 32 304 0 0 79 20 0
# 备注DataService从这里开始停止
0 0 0 7241852 1360 424164 0 0 864 320 133 1266 1 1 94 5 0
0 1 0 7228956 1368 437400 0 0 13328 0 45 366 0 0 83 17 0
0 1 0 7196320 1368 470148 0 0 32640 0 33 413 1 1 50 49 0
...
0 0 0 6747540 1368 918576 0 0 29056 0 42 568 0 0 56 44 0
0 0 0 6747540 1368 918576 0 0 0 0 40 141 1 0 100 0 0
```
在 DataService 停止前cache 会连续增长三次后再降回去,这正是因为 DataService 每隔3秒清理一次页缓存。而 DataService 停止后cache 就会不停地增长,直到增长为 918576 后,就不再变了。
这时磁盘的读bi降低到 0同时iowaitwa也降低到 0这说明此时的所有数据都已经在系统的缓存中了。我们知道缓存是内存的一部分它的访问速度比磁盘快得多这也就能解释为什么 MySQL 的查询速度变快了很多。
从这个案例你会发现MySQL 的 MyISAM 引擎,本身并不缓存数据,而要依赖系统缓存来加速磁盘 I/O 的访问。一旦系统中还有其他应用同时运行MyISAM 引擎就很难充分利用系统缓存。因为系统缓存可能被其他应用程序占用,甚至直接被清理掉。
所以,一般来说,我并不建议,把应用程序的性能优化完全建立在系统缓存上。还是那句话,最好能在应用程序的内部分配内存,构建完全自主控制的缓存,比如 MySQL 的 InnoDB 引擎,就同时缓存了索引和数据;或者,可以使用第三方的缓存应用,比如 Memcached、Redis 等。
今天主要回答这些问题,同时也欢迎你继续在留言区写下疑问和感想,我会持续不断地解答。希望借助每一次的答疑,可以和你一起,把文章知识内化为你的能力,我们不仅在实战中演练,也要在交流中进步。