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,114 @@
<audio id="audio" title="35 | 存储器层次结构全景:数据存储的大金字塔长什么样?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/50/a4e57ab2fac101456f3d8805c0a96c50.mp3"></audio>
今天开始,我们要进入到计算机另一个重要的组成部分,存储器。
如果你自己组装过PC机你肯定知道想要CPU我们只要买一个就好了但是存储器却有不同的设备要买。比方说我们要买内存还要买硬盘。买硬盘的时候不少人会买一块SSD硬盘作为系统盘还会买上一块大容量的HDD机械硬盘作为数据盘。内存和硬盘都是我们的存储设备。而且像硬盘这样的持久化存储设备同时也是一个I/O设备。
在实际的软件开发过程中我们常常会遇到服务端的请求响应时间长吞吐率不够的情况。在分析对应问题的时候相信你没少听过类似“主要瓶颈不在CPU而在I/O”的论断。可见存储在计算机中扮演着多么重要的角色。那接下来这一整个章节我会为你梳理和讲解整个存储器系统。
这一讲,我们先从存储器的层次结构说起,让你对各种存储器设备有一个整体的了解。
## 理解存储器的层次结构
在有计算机之前,我们通常把信息和数据存储在书、文件这样的物理介质里面。有了计算机之后,我们通常把数据存储在计算机的存储器里面。而存储器系统是一个通过各种不同的方法和设备,一层一层组合起来的系统。下面,我们把计算机的存储器层次结构和我们日常生活里处理信息、阅读书籍做个对照,好让你更容易理解、记忆存储器的层次结构。
我们常常把CPU比喻成计算机的“大脑”。我们思考的东西就好比CPU中的**寄存器**Register。寄存器与其说是存储器其实它更像是CPU本身的一部分只能存放极其有限的信息但是速度非常快和CPU同步。
而我们大脑中的记忆,就好比**CPU Cache**CPU高速缓存我们常常简称为“缓存”。CPU Cache用的是一种叫作**SRAM**Static Random-Access Memory静态随机存取存储器的芯片。
### SRAM
SRAM之所以被称为“静态”存储器是因为只要处在通电状态里面的数据就可以保持存在。而一旦断电里面的数据就会丢失了。在SRAM里面一个比特的数据需要68个晶体管。所以SRAM的存储密度不高。同样的物理空间下能够存储的数据有限。不过因为SRAM的电路简单所以访问速度非常快。
<img src="https://static001.geekbang.org/resource/image/25/99/25c619a683c161d3678c7339aa34d399.png" alt="">
在CPU里通常会有L1、L2、L3这样三层高速缓存。每个CPU核心都有一块属于自己的L1高速缓存通常分成**指令缓存**和**数据缓存**分开存放CPU使用的指令和数据。
不知道你还记不记得我们在[第22讲](https://time.geekbang.org/column/article/100569)讲过的哈佛架构这里的指令缓存和数据缓存其实就是来自于哈佛架构。L1的Cache往往就嵌在CPU核心的内部。
L2的Cache同样是每个CPU核心都有的不过它往往不在CPU核心的内部。所以L2 Cache的访问速度会比L1稍微慢一些。而L3 Cache则通常是多个CPU核心共用的尺寸会更大一些访问速度自然也就更慢一些。
你可以把CPU中的L1 Cache理解为我们的短期记忆把L2/L3 Cache理解成长期记忆把内存当成我们拥有的书架或者书桌。 当我们自己记忆中没有资料的时候可以从书桌或者书架上拿书来翻阅。这个过程中就相当于数据从内存中加载到CPU的寄存器和Cache中然后通过“大脑”也就是CPU进行处理和运算。
### DRAM
内存用的芯片和Cache有所不同它用的是一种叫作**DRAM**Dynamic Random Access Memory动态随机存取存储器的芯片比起SRAM来说它的密度更高有更大的容量而且它也比SRAM芯片便宜不少。
DRAM被称为“动态”存储器是因为DRAM需要靠不断地“刷新”才能保持数据被存储起来。DRAM的一个比特只需要一个晶体管和一个电容就能存储。所以DRAM在同样的物理空间下能够存储的数据也就更多也就是存储的“密度”更大。但是因为数据是存储在电容里的电容会不断漏电所以需要定时刷新充电才能保持数据不丢失。DRAM的数据访问电路和刷新电路都比SRAM更复杂所以访问延时也就更长。
<img src="https://static001.geekbang.org/resource/image/be/b8/befed615bf50df878b26455288eccbb8.png" alt="">
### 存储器的层级结构
整个存储器的层次结构其实都类似于SRAM和DRAM在性能和价格上的差异。SRAM更贵速度更快。DRAM更便宜容量更大。SRAM好像我们的大脑中的记忆而DRAM就好像属于我们自己的书桌。
大脑CPU中的记忆L1 Cache不仅受成本层面的限制更受物理层面的限制。这就好比L1 Cache不仅昂贵其访问速度和它到CPU的物理距离有关。芯片造得越大总有部分离CPU的距离会变远。电信号的传输速度又受物理原理的限制没法超过光速。所以想要快并不是靠多花钱就能解决的。
我们自己的书房和书桌(也就是内存)空间一般是有限的,没有办法放下所有书(也就是数据)。如果想要扩大空间的话,就相当于要多买几平方米的房子,成本就会很高。于是,想要放下更多的书,我们就要寻找更加廉价的解决方案。
没错,我们想到了公共图书馆。对于内存来说,**SSD**Solid-state drive或Solid-state disk固态硬盘、**HDD**Hard Disk Drive硬盘这些被称为**硬盘**的外部存储设备,就是公共图书馆。于是,我们就可以去家附近的图书馆借书了。图书馆有更多的空间(存储空间)和更多的书(数据)。
你应该也在自己的个人电脑上用过SSD硬盘。过去几年SSD这种基于NAND芯片的高速硬盘价格已经大幅度下降。
而HDD硬盘则是一种完全符合“磁盘”这个名字的传统硬件。“磁盘”的硬件结构决定了它的访问速度受限于它的物理结构是最慢的。
这些我们后面都会详细说,你可以对照下面这幅图了解一下,对存储器层次之间的作用和关联有个大致印象就可以了。
<img src="https://static001.geekbang.org/resource/image/ab/0a/ab345017c3f662b15e15e97e0ca1db0a.png" alt="">
从Cache、内存到SSD和HDD硬盘一台现代计算机中就用上了所有这些存储器设备。其中容量越小的设备速度越快而且CPU并不是直接和每一种存储器设备打交道而是每一种存储器设备只和它相邻的存储设备打交道。比如CPU Cache是从内存里加载而来的或者需要写回内存并不会直接写回数据到硬盘也不会直接从硬盘加载数据到CPU Cache中而是先加载到内存再从内存加载到Cache中。
**这样,各个存储器只和相邻的一层存储器打交道,并且随着一层层向下,存储器的容量逐层增大,访问速度逐层变慢,而单位存储成本也逐层下降,也就构成了我们日常所说的存储器层次结构。**
## 使用存储器的时候,该如何权衡价格和性能?
存储器在不同层级之间的性能差异和价格差异都至少在一个数量级以上。L1 Cache的访问延时是1纳秒ns而内存就已经是100纳秒了。在价格上这两者也差出了400倍。
我这里放了一张各种存储器成本的对比表格,你可以看看。你也可以在点击这个[链接](https://people.eecs.berkeley.edu/~rcs/research/interactive_latency.html)通过拖拉查看19902020年随着硬件设备的进展访问延时的变化。
<img src="https://static001.geekbang.org/resource/image/d3/a6/d39b0f2b3962d646133d450541fb75a6.png" alt="">
因为这个价格和性能的差异,你会看到,我们实际在进行电脑硬件配置的时候,会去组合配置各种存储设备。
我们可以找一台现在主流的笔记本电脑来看看比如一款入门级的惠普战66的笔记本电脑。今天在京东上的价格是4999人民币。它的配置是下面这样的。
1. Intle i5-8265U的CPU这是一块4核的CPU
- 这块CPU每个核有32KB一共128KB的L1指令Cache。
- 同样每个核还有32KB一共128KB的L1数据Cache指令Cache和数据Cache都是采用8路组相连的放置策略。
- 每个核有256KB一共1MB的L2 Cache。L2 Cache是用4路组相连的放置策略。
- 最后还有一块多个核心共用的12MB的L3 Cache采用的是12路组相连的放置策略。
1. 8GB的内存
1. 一块128G的SSD硬盘
1. 一块1T的HDD硬盘
你可以看到在一台实际的计算机里面越是速度快的设备容量就越小。这里一共十多兆的Cache成本只是几十美元。而8GB的内存、128G的SSD以及1T的HDD大概零售价格加在一起也就和我们的高速缓存的价格差不多。
## 总结延伸
这节的内容不知道你掌握了多少呢?为了帮助你记忆,我这里再带你复习一下本节的重点。
我们常常把CPU比喻成高速运转的大脑那么和大脑同步的寄存器Register就存放着我们当下正在思考和处理的数据。而L1-L3的CPU Cache好比存放在我们大脑中的短期到长期的记忆。我们需要小小花费一点时间就能调取并进行处理。
我们自己的书桌书架就好比计算机的内存能放下更多的书也就是数据但是找起来和看起来就要慢上不少。而图书馆更像硬盘这个外存能够放下更多的数据找起来也更费时间。从寄存器、CPU Cache到内存、硬盘这样一层层下来的存储器速度越来越慢空间越来越大价格也越来越便宜。
这三个“越来越”的特性,使得我们在组装计算机的时候,要组合使用各种存储设备。越是快且贵的设备,实际在一台计算机里面的存储空间往往就越小。而越是慢且便宜的设备,在实际组装的计算机里面的存储空间就会越大。
在后面的关于存储器的内容里,我会带着你进一步深入了解,各个层次的存储器是如何运作的,在不同类型的应用和性能要求下,是否可以靠人工添加一层缓存层来解决问题,以及在程序开发层面,如何利用好不同层次的存储器设备的访问原理和特性。
## 补充阅读
如果你学有余力,关于不同存储器的访问延时数据,有两篇文章推荐给你阅读。
第一个是Peter Novig的[**Teach Yourself Programming in Ten Years**](http://norvig.com/21-days.html#answers)。我推荐你在了解这些数据之后读一读这篇文章。这些数字随着摩尔定律的发展在不断缩小,但是在数量级上仍然有着很强的参考价值。
第二个是Jeff Dean的[**Build Software Systems at Google and Lessons Learned**](https://research.google.com/people/jeff/Stanford-DL-Nov-2010.pdf)。这份PPT中不仅总结了这些数字还有大量的硬件故障、高可用和系统架构的血泪经验。尽管这是一份10年前的PPT但也非常值得阅读。
## 课后思考
最后给你留一道思考题。在上世纪8090年代3.5寸的磁盘大行其道。它的存储空间只有1.44MB比起当时40MB的硬盘它却被大家认为是“海量”存储的主要选择。你猜一猜这是为什么
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。

View File

@@ -0,0 +1,83 @@
<audio id="audio" title="36 | 局部性原理:数据库性能跟不上,加个缓存就好了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/e3/04f47d75c5910a87a46bf2aa880f57e3.mp3"></audio>
平时进行服务端软件开发的时候我们通常会把数据存储在数据库里。而服务端系统遇到的第一个性能瓶颈往往就发生在访问数据库的时候。这个时候大部分工程师和架构师会拿出一种叫作“缓存”的武器通过使用Redis或者Memcache这样的开源软件在数据库前面提供一层缓存的数据来缓解数据库面临的压力提升服务端的程序性能。
<img src="https://static001.geekbang.org/resource/image/67/89/675341b47057e483713395b55eef7089.png" alt="">
那么不知道你有没有想过这种添加缓存的策略一定是有效的吗或者说这种策略在什么情况下是有效的呢如果从理论角度去分析添加缓存一定是我们的最佳策略么进一步地如果我们对于访问性能的要求非常高希望数据在1毫秒乃至100微秒内完成处理我们还能用这个添加缓存的策略么
## 理解局部性原理
我们先来回顾一下,上一讲的这张不同存储器的性能和价目表。可以看到,不同的存储器设备之间,访问速度、价格和容量都有几十乃至上千倍的差异。
<img src="https://static001.geekbang.org/resource/image/d3/a6/d39b0f2b3962d646133d450541fb75a6.png" alt="">
以上一讲的Intel 8265U的CPU为例它的L1 Cache只有256KL2 Cache有个1MBL3 Cache有12MB。一共13MB的存储空间如果按照7美元/1MB的价格计算就要91美元。
我们的内存有8GB容量是CPU Cache的600多倍按照表上的价格差不多就是120美元。如果按照今天京东上的价格恐怕不到40美元。128G的SSD和1T的HDD现在的价格加起来也不会超过100美元。虽然容量是内存的16倍乃至128倍但是它们的访问速度却不到内存的1/1000。
性能和价格的巨大差异,给我们工程师带来了一个挑战:**我们能不能既享受CPU Cache的速度又享受内存、硬盘巨大的容量和低廉的价格呢**你可以停下来自己思考一下,或者点击文章右上方的“请朋友读”,邀请你的朋友一起来思考这个问题。然后,再一起听我的讲解。
好了,现在我公布答案。想要同时享受到这三点,前辈们已经探索出了答案,那就是,存储器中数据的**局部性原理**Principle of Locality。我们可以利用这个局部性原理来制定管理和访问数据的策略。这个局部性原理包括**时间局部性**temporal locality和**空间局部性**spatial locality这两种策略。
我们先来看**时间局部性**。这个策略是说,如果一个数据被访问了,那么它在短时间内还会被再次访问。这么看这个策略有点奇怪是吧?我用一个简单的例子给你解释下,你一下就能明白了。
比如说《哈利波特与魔法石》这本小说我今天读了一会儿没读完明天还会继续读。同理在一个电子商务型系统中如果一个用户打开了App看到了首屏。我们推断他应该很快还会再次访问网站的其他内容或者页面我们就将这个用户的个人信息从存储在硬盘的数据库读取到内存的缓存中来。这利用的就是时间局部性。
<img src="https://static001.geekbang.org/resource/image/53/d3/53cb2d05d9bc8e3131466e9802d2c6d3.png" alt="">
我们再来看**空间局部性**。这个策略是说,如果一个数据被访问了,那么和它相邻的数据也很快会被访问。
我们还拿刚才读《哈利波特与魔法石》的例子来说。我读完了这本书之后感觉这书不错所以就会借阅整套“哈利波特”。这就好比我们的程序在访问了数组的首项之后多半会循环访问它的下一项。因为在存储数据的时候数组内的多项数据会存储在相邻的位置。这就好比图书馆会把“哈利波特”系列放在一个书架上摆放在一起加载的时候也会一并加载。我们去图书馆借书往往会一次性把7本都借回来。
<img src="https://static001.geekbang.org/resource/image/1e/67/1ecca5bc07486a4d829263c8d78df667.png" alt="">
有了时间局部性和空间局部性我们不用再把所有数据都放在内存里也不用都放在HDD硬盘上而是把访问次数多的数据放在贵但是快一点的存储器里把访问次数少的数据放在慢但是大一点的存储器里。这样组合使用内存、SSD硬盘以及HDD硬盘使得我们可以用最低的成本提供实际所需要的数据存储、管理和访问的需求。
## 如何花最少的钱,装下亚马逊的所有商品?
了解了局部性原理,下面我用一些真实世界中的数据举个例子,带你做个小小的思维体操,来看一看通过局部性原理,利用不同层次存储器的组合,究竟会有什么样的好处。
我们现在要提供一个亚马逊这样的电商网站。我们假设里面有6亿件商品如果每件商品需要4MB的存储空间考虑到商品图片的话4MB已经是一个相对较小的估计了那么一共需要2400TB = 6亿 × 4MB的数据存储。
如果我们把数据都放在内存里面那就需要3600万美元 = 2400TB/1MB × 0.015美元 = 3600万美元。但是这6亿件商品中不是每一件商品都会被经常访问。比如说有Kindle电子书这样的热销商品也一定有基本无人问津的商品比如偏门的缅甸语词典。
如果我们只在内存里放前1%的热门商品也就是600万件热门商品而把剩下的商品放在机械式的HDD硬盘上那么我们需要的存储成本就下降到45.6万美元( = 3600 万美元 × 1% + 2400TB / 1MB × 0.00004 美元是原来成本的1.3%左右。
这里我们用的就是时间局部性。我们把有用户访问过的数据,加载到内存中,一旦内存里面放不下了,我们就把最长时间没有在内存中被访问过的数据,从内存中移走,这个其实就是我们常用的**LRU**Least Recently Used**缓存算法**。热门商品被访问得多就会始终被保留在内存里而冷门商品被访问得少就只存放在HDD硬盘上数据的读取也都是直接访问硬盘。即使加载到内存中也会很快被移除。越是热门的商品越容易在内存中找到也就更好地利用了内存的随机访问性能。
那么只放600万件商品真的可以满足我们实际的线上服务请求吗这个就要看LRU缓存策略的**缓存命中率**Hit Rate/Hit Ratio也就是访问的数据中可以在我们设置的内存缓存中找到的占有多大比例。
内存的随机访问请求需要100ns。这也就意味着在极限情况下内存可以支持1000万次随机访问。我们用了24TB内存如果8G一条的话意味着有3000条内存可以支持每秒300亿次 = 24TB/8GB × 1s/100ns访问。以亚马逊2017年3亿的用户数来看我们估算每天的活跃用户为1亿这1亿用户每人平均会访问100个商品那么平均每秒访问的商品数量就是12万次。
但是如果数据没有命中内存那么对应的数据请求就要访问到HDD磁盘了。刚才的图表中我写了一块HDD硬盘只能支撑每秒100次的随机访问2400TB的数据以4TB一块磁盘来计算有600块磁盘也就是能支撑每秒 6万次 = 2400TB/4TB × 1s/10ms )的随机访问。
这就意味着所有的商品访问请求都直接到了HDD磁盘HDD磁盘支撑不了这样的压力。我们至少要50%的缓存命中率HDD磁盘才能支撑对应的访问次数。不然的话我们要么选择添加更多数量的HDD硬盘做到每秒12万次的随机访问或者将HDD替换成SSD硬盘让单个硬盘可以支持更多的随机访问请求。
<img src="https://static001.geekbang.org/resource/image/fb/58/fb32dd8a5847745d07a1b17254c75158.png" alt="">
当然,这里我们只是一个简单的估算。在实际的应用程序中,查看一个商品的数据可能意味着不止一次的随机内存或者随机磁盘的访问。对应的数据存储空间也不止要考虑数据,还需要考虑维护数据结构的空间,而缓存的命中率和访问请求也要考虑均值和峰值的问题。
通过这个估算过程,你需要理解,如何进行存储器的硬件规划。你需要考虑硬件的成本、访问的数据量以及访问的数据分布,然后根据这些数据的估算,来组合不同的存储器,能用尽可能低的成本支撑所需要的服务器压力。而当你用上了数据访问的局部性原理,组合起了多种存储器,你也就理解了怎么基于存储器层次结构,来进行硬件规划了。
## 总结延伸
这一讲,我们讲解了计算机存储器层次结构中最重要的一个优化思路,就是局部性原理。
在实际的计算机日常的开发和应用中,我们对于数据的访问总是会存在一定的局部性。有时候,这个局部性是时间局部性,就是我们最近访问过的数据还会被反复访问。有时候,这个局部性是空间局部性,就是我们最近访问过数据附近的数据很快会被访问到。
而局部性的存在,使得我们可以在应用开发中使用缓存这个有利的武器。比如,通过将热点数据加载并保留在速度更快的存储设备里面,我们可以用更低的成本来支撑服务器。
通过亚马逊这个例子,我们可以看到,我们可以通过快速估算的方式,来判断这个添加缓存的策略是否能够满足我们的需求,以及在估算的服务器负载的情况下,需要规划多少硬件设备。这个“估算+规划”的能力,是每一个期望成长为架构师的工程师,必须掌握的能力。
最后回到这一讲的开头我问了你这样一个问题在遇到性能问题特别是访问存储器的性能问题的时候是否可以简单地添加一层数据缓存就能让问题迎刃而解呢今天这个亚马逊网站商品数据的例子似乎给了我们一个“Yes”的答案。那么这个答案是否放之四海皆准呢后面的几讲我们会深入各种应用场景进一步来回答这个问题。
## 推荐阅读
想要仔细了解各种存储器和局部性原理,你还是可以去读一读教科书。《计算机组成与设计:硬件/软件接口》的5.15.2小节,是一个很好的阅读材料。
## 课后思考
我们今天拿了亚马逊的商品和用户访问数据做了例子。请你想一下如果是拿商品数量更多的淘宝网来看你可以估算一下至少需要使用多少DRAM的内存或者其他存储设备呢
欢迎留言和我分享你的思考过程和最终答案。如果自己的力量无法解决,你也可以拉上你的朋友一起讨论。

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="37 | 高速缓存“4毫秒”究竟值多少钱" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/c6/c1029221178435f1b6d678db4a0f2bc6.mp3"></audio>
在这一节内容开始之前我们先来看一个3行的小程序。你可以猜一猜这个程序里的循环1和循环2运行所花费的时间会差多少你可以先思考几分钟然后再看我下面的解释。
```
int[] arr = new int[64 * 1024 * 1024];
// 循环1
for (int i = 0; i &lt; arr.length; i++) arr[i] *= 3;
// 循环2
for (int i = 0; i &lt; arr.length; i += 16) arr[i] *= 3
```
在这段Java程序中我们首先构造了一个64×1024×1024大小的整型数组。在循环1里我们遍历整个数组将数组中每一项的值变成了原来的3倍在循环2里我们每隔16个索引访问一个数组元素将这一项的值变成了原来的3倍。
按道理来说循环2只访问循环1中1/16的数组元素只进行了循环1中1/16的乘法计算那循环2花费的时间应该是循环1的1/16左右。但是实际上循环1在我的电脑上运行需要50毫秒循环2只需要46毫秒。这两个循环花费时间之差在15%之内。
为什么会有这15%的差异呢这和我们今天要讲的CPU Cache有关。之前我们看到了内存和硬盘之间存在的巨大性能差异。在CPU眼里内存也慢得不行。于是聪明的工程师们就在CPU里面嵌入了CPU Cache高速缓存来解决这一问题。
## 我们为什么需要高速缓存?
按照[摩尔定律](https://zh.wikipedia.org/wiki/%E6%91%A9%E5%B0%94%E5%AE%9A%E5%BE%8B)CPU的访问速度每18个月便会翻一番相当于每年增长60%。内存的访问速度虽然也在不断增长却远没有这么快每年只增长7%左右。而这两个增长速度的差异使得CPU性能和内存访问性能的差距不断拉大。到今天来看一次内存的访问大约需要120个CPU Cycle这也意味着在今天CPU和内存的访问速度已经有了120倍的差距。
如果拿我们现实生活来打个比方的话CPU的速度好比风驰电掣的高铁每小时350公里然而它却只能等着旁边腿脚不太灵便的老太太也就是内存以每小时3公里的速度缓慢步行。因为CPU需要执行的指令、需要访问的数据都在这个速度不到自己1%的内存里。
<img src="https://static001.geekbang.org/resource/image/4f/4b/4fc459f42a67d3949402865a998bf34b.png" alt="">
为了弥补两者之间的性能差异我们能真实地把CPU的性能提升用起来而不是让它在那儿空转我们在现代CPU中引入了高速缓存。
从CPU Cache被加入到现有的CPU里开始内存中的指令、数据会被加载到L1-L3 Cache中而不是直接由CPU访问内存去拿。在95%的情况下CPU都只需要访问L1-L3 Cache从里面读取指令和数据而无需访问内存。要注意的是这里我们说的CPU Cache或者L1/L3 Cache不是一个单纯的、概念上的缓存比如之前我们说的拿内存作为硬盘的缓存而是指特定的由SRAM组成的物理芯片。
这里是一张Intel CPU的放大照片。这里面大片的长方形芯片就是这个CPU使用的20MB的L3 Cache。
<img src="https://static001.geekbang.org/resource/image/c1/15/c1dc0e3453f469fc4607557dab9d5215.jpg" alt="">
在这一讲一开始的程序里运行程序的时间主要花在了将对应的数据从内存中读取出来加载到CPU Cache里。CPU从内存中读取数据到CPU Cache的过程中是一小块一小块来读取数据的而不是按照单个数组元素来读取数据的。这样一小块一小块的数据在CPU Cache里面我们把它叫作Cache Line缓存块
在我们日常使用的Intel服务器或者PC里Cache Line的大小通常是64字节。而在上面的循环2里面我们每隔16个整型数计算一次16个整型数正好是64个字节。于是循环1和循环2需要把同样数量的Cache Line数据从内存中读取到CPU Cache中最终两个程序花费的时间就差别不大了。
知道了为什么需要CPU Cache接下来我们就来看一看CPU究竟是如何访问CPU Cache的以及CPU Cache是如何组织数据使得CPU可以找到自己想要访问的数据的。因为Cache作为“缓存”的意思在很多别的存储设备里面都会用到。为了避免你混淆在表示抽象的“缓存“概念时用中文的“缓存”如果是CPU Cache我会用“高速缓存“或者英文的“Cache”来表示。
## Cache的数据结构和读取过程是什么样的
现代CPU进行数据读取的时候无论数据是否已经存储在Cache中CPU始终会首先访问Cache。只有当CPU在Cache中找不到数据的时候才会去访问内存并将读取到的数据写入Cache之中。当时间局部性原理起作用后这个最近刚刚被访问的数据会很快再次被访问。而Cache的访问速度远远快于内存这样CPU花在等待内存访问上的时间就大大变短了。
<img src="https://static001.geekbang.org/resource/image/3a/cc/3a6fcfd1155e03f4f2781dbb6ddaf6cc.png" alt="">
这样的访问机制和我们自己在开发应用系统的时候“使用内存作为硬盘的缓存”的逻辑是一样的。在各类基准测试Benchmark和实际应用场景中CPU Cache的命中率通常能达到95%以上。
问题来了CPU如何知道要访问的内存数据存储在Cache的哪个位置呢接下来我就从最基本的**直接映射Cache**Direct Mapped Cache说起带你来看整个Cache的数据结构和访问逻辑。
在开头的3行小程序里我说过CPU访问内存数据是一小块一小块数据来读取的。对于读取内存中的数据我们首先拿到的是数据所在的**内存块**Block的地址。而直接映射Cache采用的策略就是确保任何一个内存块的地址始终映射到一个固定的CPU Cache地址Cache Line。而这个映射关系通常用mod运算求余运算来实现。下面我举个例子帮你理解一下。
比如说我们的主内存被分成031号这样32个块。我们一共有8个缓存块。用户想要访问第21号内存块。如果21号内存块内容在缓存块中的话它一定在5号缓存块21 mod 8 = 5中。
<img src="https://static001.geekbang.org/resource/image/52/22/522eade51bbfad19fd25eb4f3ce80f22.png" alt="">
实际计算中有一个小小的技巧通常我们会把缓存块的数量设置成2的N次方。这样在计算取模的时候可以直接取地址的低N位也就是二进制里面的后几位。比如这里的8个缓存块就是2的3次方。那么在对21取模的时候可以对21的2进制表示10101取地址的低三位也就是101对应的5就是对应的缓存块地址。
<img src="https://static001.geekbang.org/resource/image/ca/80/caadd2728b5cfcd2bd704103570f3a80.png" alt="">
取Block地址的低位就能得到对应的Cache Line地址除了21号内存块外13号、5号等很多内存块的数据都对应着5号缓存块中。既然如此假如现在CPU想要读取21号内存块在读取到5号缓存块的时候我们怎么知道里面的数据究竟是不是21号对应的数据呢同样建议你借助现有知识先自己思考一下然后再看我下面的分析这样会印象比较深刻。
这个时候,在对应的缓存块中,我们会存储一个**组标记**Tag。这个组标记会记录当前缓存块内存储的数据对应的内存块而缓存块本身的地址表示访问地址的低N位。就像上面的例子21的低3位101缓存块本身的地址已经涵盖了对应的信息、对应的组标记我们只需要记录21剩余的高2位的信息也就是10就可以了。
除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的数据,另一个是**有效位**valid bit。啥是有效位呢它其实就是用来标记对应的缓存块中的数据是否是有效的确保不是机器刚刚启动时候的空数据。如果有效位是0无论其中的组标记和Cache Line里的数据内容是什么CPU都不会管这些数据而要直接访问内存重新加载数据。
CPU在读取数据的时候并不是要读取一整个Block而是读取一个他需要的数据片段。这样的数据我们叫作CPU里的一个字Word。具体是哪个字就用这个字在整个Block里面的位置来决定。这个位置我们叫作偏移量Offset
总结一下,**一个内存的访问地址最终包括高位代表的组标记、低位代表的索引以及在对应的Data Block中定位对应字的位置偏移量。**
<img src="https://static001.geekbang.org/resource/image/13/d4/1313fe1e4eb3b5c949284c8b215af8d4.png" alt="">
而内存地址对应到Cache里的数据结构则多了一个有效位和对应的数据由“**索引 + 有效位** **+ 组标记 + 数据**”组成。如果内存中的数据已经在CPU Cache里了那一个内存地址的访问就会经历这样4个步骤
1. 根据内存地址的低位计算在Cache中的索引
1. 判断有效位确认Cache中的数据是有效的
1. 对比内存访问地址的高位和Cache中的组标记确认Cache中的数据就是我们要访问的内存数据从Cache Line中读取到对应的数据块Data Block
1. 根据内存地址的Offset位从Data Block中读取希望读取到的字。
如果在2、3这两个步骤中CPU发现Cache中的数据并不是要访问的内存地址的数据那CPU就会访问内存并把对应的Block Data更新到Cache Line中同时更新对应的有效位和组标记的数据。
好了讲到这里相信你明白现代CPU是如何通过直接映射Cache来定位一个内存访问地址在Cache中的位置了。其实除了直接映射Cache之外我们常见的缓存放置策略还有全相连CacheFully Associative Cache、组相连CacheSet Associative Cache。这几种策略的数据结构都是相似的理解了最简单的直接映射Cache其他的策略你很容易就能理解了。
## 减少4毫秒公司挣了多少钱?
刚才我花了很多篇幅讲了CPU和内存之间的性能差异以及我们如何通过CPU Cache来尽可能解决这两者之间的性能鸿沟。你可能要问了这样做的意义和价值究竟是什么毕竟一次内存的访问只不过需要100纳秒而已。1秒钟时间内足有1000万个100纳秒。别着急我们先来看一个故事。
2008年一家叫作Spread Networks的通信公司花费3亿美元做了一个光缆建设项目。目标是建设一条从芝加哥到新泽西总长1331公里的光缆线路。建设这条线路的目的其实是为了将两地之间原有的网络访问延时从17毫秒降低到13毫秒。
你可能会说仅仅缩短了4毫秒时间啊却花费3个亿真的值吗为这4毫秒时间买单的其实是一批高频交易公司。它们以5年1400万美元的价格使用这条线路。利用这短短的4毫秒的时间优势这些公司通过高性能的计算机程序在芝加哥和新泽西两地的交易所进行高频套利以获得每年以10亿美元计的利润。现在你还觉得这个不值得吗
其实只要350微秒的差异就足够高频交易公司用来进行无风险套利了。而350微秒如果用来进行100纳秒一次的内存访问大约只够进行3500次。而引入CPU Cache之后我们可以进行的数据访问次数提升了数十倍使得各种交易策略成为可能。
## 总结延伸
很多时候程序的性能瓶颈来自使用DRAM芯片的内存访问速度。
根据摩尔定律自上世纪80年代以来CPU和内存的性能鸿沟越拉越大。于是现代CPU的设计者们直接在CPU中嵌入了使用更高性能的SRAM芯片的Cache来弥补这一性能差异。通过巧妙地将内存地址拆分成“索引+组标记+偏移量”的方式使得我们可以将很大的内存地址映射到很小的CPU Cache地址里。而CPU Cache带来的毫秒乃至微秒级别的性能差异又能带来巨大的商业利益十多年前的高频交易行业就是最好的例子。
在搞清楚从内存加载数据到Cache以及从Cache里读取到想要的数据之后我们又要面临一个新的挑战了。CPU不仅要读数据还需要写数据我们不能只把数据写入到Cache里面就结束了。下一讲我们就来仔细讲讲CPU要写入数据的时候怎么既不牺牲性能又能保证数据的一致性。
## 推荐阅读
如果你学有余力,这里有两篇文章推荐给你阅读。
如果想深入了解CPU和内存之间的访问性能你可以阅读[What Every Programmer Should Know About Memory](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf)。
现代CPU已经很少使用直接映射Cache了通常用的是组相连Cacheset associative cache想要了解组相连Cache你可以阅读《计算机组成与设计硬件/软件接口》的5.4.1小节。
## 课后思考
对于二维数组的访问,按行迭代和按列迭代的访问性能是一样的吗?你可以写一个程序测试一下,并思考一下原因。
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="38 | 高速缓存(下):你确定你的数据更新了么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/c8/a103021b2e107995dc95629e81ce80c8.mp3"></audio>
在我工作的十几年里写了很多Java的程序。同时我也面试过大量的Java工程师。对于一些表示自己深入了解和擅长多线程的同学我经常会问这样一个面试题“**volatile这个关键字有什么作用**”如果你或者你的朋友写过Java程序不妨来一起试着回答一下这个问题。
就我面试过的工程师而言即使是工作了多年的Java工程师也很少有人能准确说出volatile这个关键字的含义。这里面最常见的理解错误有两个一个是把volatile当成一种锁机制认为给变量加上了volatile就好像是给函数加了sychronized关键字一样不同的线程对于特定变量的访问会去加锁另一个是把volatile当成一种原子化的操作机制认为加了volatile之后对于一个变量的自增的操作就会变成原子性的了。
```
// 一种错误的理解是把volatile关键词当成是一个锁可以把long/double这样的数的操作自动加锁
private volatile long synchronizedValue = 0;
// 另一种错误的理解是把volatile关键词当成可以让整数自增的操作也变成原子性的
private volatile int atomicInt = 0;
amoticInt++;
```
事实上这两种理解都是完全错误的。很多工程师容易把volatile关键字当成和锁或者数据数据原子性相关的知识点。而实际上volatile关键字的最核心知识点要关系到Java内存模型JMMJava Memory Model上。
虽然JMM只是Java虚拟机这个进程级虚拟机里的一个内存模型但是这个内存模型和计算机组成里的CPU、高速缓存和主内存组合在一起的硬件体系非常相似。理解了JMM可以让你很容易理解计算机组成里CPU、高速缓存和主内存之间的关系。
## “隐身”的变量
我们先来一起看一段Java程序。这是一段经典的volatile代码来自知名的Java开发者网站[dzone.com](https://dzone.com/articles/java-volatile-keyword-0),后续我们会修改这段代码来进行各种小实验。
```
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 &lt; 5){
if( threadValue!= COUNTER){
System.out.println(&quot;Got Change for COUNTER : &quot; + COUNTER + &quot;&quot;);
threadValue= COUNTER;
}
}
}
}
static class ChangeMaker extends Thread{
@Override
public void run() {
int threadValue = COUNTER;
while (COUNTER &lt;5){
System.out.println(&quot;Incrementing COUNTER to : &quot; + (threadValue+1) + &quot;&quot;);
COUNTER = ++threadValue;
try {
Thread.sleep(500);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
}
```
我们先来看看这个程序做了什么。在这个程序里我们先定义了一个volatile的int类型的变量COUNTER。
然后我们分别启动了两个单独的线程一个线程我们叫ChangeListener。另一个线程我们叫ChangeMaker。
ChangeListener这个线程运行的任务很简单。它先取到COUNTER当前的值然后一直监听着这个COUNTER的值。一旦COUNTER的值发生了变化就把新的值通过println打印出来。直到COUNTER的值达到5为止。这个监听的过程通过一个永不停歇的while循环的忙等待来实现。
ChangeMaker这个线程运行的任务同样很简单。它同样是取到COUNTER的值在COUNTER小于5的时候每隔500毫秒就让COUNTER自增1。在自增之前通过println方法把自增后的值打印出来。
最后在main函数里我们分别启动这两个线程来看一看这个程序的执行情况。程序的输出结果并不让人意外。ChangeMaker函数会一次一次将COUNTER从0增加到5。因为这个自增是每500毫秒一次而ChangeListener去监听COUNTER是忙等待的所以每一次自增都会被ChangeListener监听到然后对应的结果就会被打印出来。
```
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上发生了我们的ChangeListener不再工作了。在ChangeListener眼里它似乎一直觉得COUNTER的值还是一开始的0。似乎COUNTER的变化对于我们的ChangeListener彻底“隐身”了。
```
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 &lt; 5){
if( threadValue!= COUNTER){
System.out.println(&quot;Sleep 5ms, Got Change for COUNTER : &quot; + COUNTER + &quot;&quot;);
threadValue= COUNTER;
}
try {
Thread.sleep(5);
} catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
```
好了不知道你有没有自己动手试一试呢又一个令人惊奇的现象要发生了。虽然我们的COUNTER变量仍然没有设置volatile这个关键字但是我们的ChangeListener似乎“睡醒了”。在通过Thread.sleep(5)在每个循环里“睡上“5毫秒之后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造成的变化了。
虽然Java内存模型是一个隔离了硬件实现的虚拟机内的抽象模型但是它给了我们一个很好的“缓存同步”问题的示例。也就是说如果我们的数据在不同的线程或者CPU核里面去更新因为不同的线程或CPU核有着自己各自的缓存很有可能在A线程的更新到B线程里面是看不见的。
## CPU高速缓存的写入
事实上我们可以把Java内存模型和计算机组成里的CPU结构对照起来看。
我们现在用的Intel CPU通常都是多核的的。每一个CPU核里面都有独立属于自己的L1、L2的Cache然后再有多个CPU核共用的L3的Cache、主内存。
因为CPU Cache的访问速度要比主内存快很多而在CPU Cache里面L1/L2的Cache也要比L3的Cache快。所以上一讲我们可以看到CPU始终都是尽可能地从CPU Cache中去获取数据而不是每一次都要从主内存里面去读取数据。
<img src="https://static001.geekbang.org/resource/image/07/41/0723f72f3016fede96b545e2898c0541.jpeg" alt="">
这个层级结构就好像我们在Java内存模型里面每一个线程都有属于自己的线程栈。线程在读取COUNTER的数据的时候其实是从本地的线程栈的Cache副本里面读取数据而不是从主内存里面读取数据。如果我们对于数据仅仅只是读问题还不大。我们在上一讲里已经看到Cache Line的组成以及如何从内存里面把对应的数据加载到Cache里。
但是,对于数据,我们不光要读,还要去写入修改。这个时候,有两个问题来了。
**第一个问题是写入Cache的性能也比写入主内存要快那我们写入的数据到底应该写到Cache里还是主内存呢如果我们直接写入到主内存里Cache里的数据是否会失效呢**为了解决这些疑问,下面我要给你介绍两种写入策略。
### 写直达Write-Through
<img src="https://static001.geekbang.org/resource/image/8b/d3/8b9ad674953bf36680e815247de235d3.jpeg" alt="">
最简单的一种写入策略叫作写直达Write-Through。在这个策略里每一次数据都要写入到主内存里面。在写直达的策略里面写入前我们会先去判断数据是否已经在Cache里面了。如果数据已经在Cache里面了我们先把数据写入更新到Cache里面再写入到主内存里面如果数据不在Cache里我们就只更新主内存。
写直达的这个策略很直观但是问题也很明显那就是这个策略很慢。无论数据是不是在Cache里面我们都需要把数据写到主内存里面。这个方式就有点儿像我们上面用volatile关键字始终都要把数据同步到主内存里面。
### 写回Write-Back
<img src="https://static001.geekbang.org/resource/image/67/0d/67053624d6aa2a5c27c295e1fda4890d.jpeg" alt="">
这个时候我们就想了既然我们去读数据也是默认从Cache里面加载能否不用把所有的写入都同步到主内存里呢只写入CPU Cache里面是不是可以
当然是可以的。在CPU Cache的写入策略里还有一种策略就叫作写回Write-Back。这个策略里我们不再是每次都把数据写入到主内存而是只写到CPU Cache里。只有当CPU Cache里面的数据要被“替换”的时候我们才把数据写入到主内存里面去。
写回策略的过程是这样的如果发现我们要写入的数据就在CPU Cache里面那么我们就只是更新CPU Cache里面的数据。同时我们会标记CPU Cache里的这个Block是脏Dirty的。所谓脏的就是指这个时候我们的CPU Cache里面的这个Block的数据和主内存是不一致的。
如果我们发现我们要写入的数据所对应的Cache Block里放的是别的内存地址的数据那么我们就要看一看那个Cache Block里面的数据有没有被标记成脏的。如果是脏的话我们要先把这个Cache Block里面的数据写入到主内存里面。然后再把当前要写入的数据写入到Cache里同时把Cache Block标记成脏的。如果Block里面的数据没有被标记成脏的那么我们直接把数据写入到Cache里面然后再把Cache Block标记成脏的就好了。
在用了写回这个策略之后我们在加载内存数据到Cache里面的时候也要多出一步同步脏Cache的动作。如果加载内存里面的数据到Cache的时候发现Cache Block里面有脏标记我们也要先把Cache Block里的数据写回到主内存才能加载数据覆盖掉Cache。
可以看到,在写回这个策略里,如果我们大量的操作,都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。
然而无论是写回还是写直达其实都还没有解决我们在上面volatile程序示例中遇到的问题也就是**多个线程或者是多个CPU核的缓存一致性的问题。这也就是我们在写入修改缓存后需要解决的第二个问题。**
要解决这个问题我们需要引入一个新的方法叫作MESI协议。这是一个维护缓存一致性协议。这个协议不仅可以用在CPU Cache之间也可以广泛用于各种需要使用缓存同时缓存之间需要同步的场景下。今天的内容差不多了我们放在下一讲仔细讲解缓存一致性问题。
## 总结延伸
最后我们一起来回顾一下这一讲的知识点。通过一个使用Java程序中使用volatile关键字程序我们可以看到在有缓存的情况下会遇到一致性问题。volatile这个关键字可以保障我们对于数据的读写都会到达主内存。
进一步地我们可以看到Java内存模型和CPU、CPU Cache以及主内存的组织结构非常相似。在CPU Cache里对于数据的写入我们也有写直达和写回这两种解决方案。写直达把所有的数据都直接写入到主内存里面简单直观但是性能就会受限于内存的访问速度。而写回则通常只更新缓存只有在需要把缓存里面的脏数据交换出去的时候才把数据同步到主内存里。在缓存经常会命中的情况下性能更好。
但是,除了采用读写都直接访问主内存的办法之外,如何解决缓存一致性的问题,我们还是没有解答。这个问题的解决方案,我们放到下一讲来详细解说。
## 推荐阅读
如果你是一个Java程序员我推荐你去读一读 [Fixing Java Memory Model](https://www.ibm.com/developerworks/java/library/j-jtp03304/index.html) 这篇文章。读完这些内容相信你会对Java里的内存模型和多线程原理有更深入的了解并且也能更好地和我们计算机底层的硬件架构联系起来。
对于计算机组成的CPU高速缓存的写操作处理你也可以读一读《计算机组成与设计硬件/软件接口》的5.3.3小节。
## 课后思考
最后给你留一道思考题。既然volatile关键字会让所有的数据写入都要到主内存。你可以试着写一个小的程序看看使用volatile关键字和不使用volatile关键字在数据写入的性能上会不会有差异以及这个差异到底会有多大。
欢迎把你写的程序分享到留言区。如果有困难,你也可以把这个问题分享给你朋友,拉上他一起讨论完成,并在留言区写下你们讨论后的结果。

View File

@@ -0,0 +1,104 @@
<audio id="audio" title="39 | MESI协议如何让多核CPU的高速缓存保持一致" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/67/a5/67084c8e497bd47cf7c1f46d2b799ea5.mp3"></audio>
你平时用的电脑应该都是多核的CPU。多核CPU有很多好处其中最重要的一个就是它使得我们在不能提升CPU的主频之后找到了另一种提升CPU吞吐率的办法。
不知道上一讲的内容你还记得多少上一节我们讲到多核CPU里的每一个CPU核都有独立的属于自己的L1 Cache和L2 Cache。多个CPU之间只是共用L3 Cache和主内存。
我们说CPU Cache解决的是内存访问速度和CPU的速度差距太大的问题。而多核CPU提供的是在主频难以提升的时候通过增加CPU核心来提升CPU的吞吐率的办法。我们把多核和CPU Cache两者一结合就给我们带来了一个新的挑战。因为CPU的每个核各有各的缓存互相之间的操作又是各自独立的就会带来[**缓存一致性**](https://en.wikipedia.org/wiki/Cache_coherence)Cache Coherence的问题。
<img src="https://static001.geekbang.org/resource/image/07/41/0723f72f3016fede96b545e2898c0541.jpeg" alt="">
## 缓存一致性问题
那什么是缓存一致性呢我们拿一个有两个核心的CPU来看一下。你可以看这里这张图我们结合图来说。
<img src="https://static001.geekbang.org/resource/image/a6/da/a6146ddd5c78f2cbc1af56b0ee3292da.jpeg" alt="">
在这两个CPU核心里1号核心要写一个数据到内存里。这个怎么理解呢我拿一个例子来给你解释。
比方说iPhone降价了我们要把iPhone最新的价格更新到内存里。为了性能问题它采用了上一讲我们说的写回策略先把数据写入到L2 Cache里面然后把Cache Block标记成脏的。这个时候数据其实并没有被同步到L3 Cache或者主内存里。1号核心希望在这个Cache Block要被交换出去的时候数据才写入到主内存里。
如果我们的CPU只有1号核心这一个CPU核那这其实是没有问题的。不过我们旁边还有一个2号核心呢这个时候2号核心尝试从内存里面去读取iPhone的价格结果读到的是一个错误的价格。这是因为iPhone的价格刚刚被1号核心更新过。但是这个更新的信息只出现在1号核心的L2 Cache里而没有出现在2号核心的L2 Cache或者主内存里面。**这个问题就是所谓的缓存一致性问题1号核心和2号核心的缓存在这个时候是不一致的。**
为了解决这个缓存不一致的问题,我们就需要有一种机制,来同步两个不同核心里面的缓存数据。那这样的机制需要满足什么条件呢?我觉得能够做到下面两点就是合理的。
第一点叫**写传播**Write Propagation。写传播是说在一个CPU核心里我们的Cache数据更新必须能够传播到其他的对应节点的Cache Line里。
第二点叫**事务的串行化**Transaction Serialization事务串行化是说我们在一个CPU核心里面的读取和写入在其他的节点看起来顺序是一样的。
第一点写传播很容易理解。既然我们数据写完了自然要同步到其他CPU核的Cache里。但是第二点事务的串行化可能没那么好理解我这里仔细解释一下。
我们还拿刚才修改iPhone的价格来解释。这一次我们找一个有4个核心的CPU。1号核心呢先把iPhone的价格改成了5000块。差不多在同一个时间2号核心把iPhone的价格改成了6000块。这里两个修改都会传播到3号核心和4号核心。
<img src="https://static001.geekbang.org/resource/image/fe/6c/fe0c449e71800c5ad2e4b84af0d6e46c.jpeg" alt="">
然而这里有个问题3号核心先收到了2号核心的写传播再收到1号核心的写传播。所以3号核心看到的iPhone价格是先变成了6000块再变成了5000块。而4号核心呢是反过来的先看到变成了5000块再变成6000块。虽然写传播是做到了但是各个Cache里面的数据是不一致的。
事实上我们需要的是从1号到4号核心都能看到相同顺序的数据变化。比如说都是先变成了5000块再变成了6000块。这样我们才能称之为实现了事务的串行化。
事务的串行化,不仅仅是缓存一致性中所必须的。比如,我们平时所用到的系统当中,最需要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,我们必须保障事务的串行化,做不到事务的串行化的数据库,根本没法作为可靠的商业数据库来使用。
而在CPU Cache里做到事务串行化需要做到两点第一点是一个CPU核心对于数据的操作需要同步通信给到其他CPU核心。第二点是如果两个CPU核心里有同一个数据的Cache那么对于这个Cache数据的更新需要有一个“锁”的概念。只有拿到了对应Cache Block的“锁”之后才能进行对应的数据更新。接下来我们就看看实现了这两个机制的MESI协议。
## 总线嗅探机制和MESI协议
要解决缓存一致性问题首先要解决的是多个CPU核心之间的数据传播问题。最常见的一种解决方案呢叫作**总线嗅探**Bus Snooping。这个名字听起来你多半会很陌生但是其实特很好理解。
这个策略本质上就是把所有的读写请求都通过总线Bus广播给所有的CPU核心然后让各个核心去“嗅探”这些请求再根据本地的情况进行响应。
总线本身就是一个特别适合广播进行数据传输的机制所以总线嗅探这个办法也是我们日常使用的Intel CPU进行缓存一致性处理的解决方案。关于总线这个知识点我们会放在后面的I/O部分更深入地进行讲解这里你只需要了解就可以了。
基于总线嗅探机制其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的就是今天我们要讲的MESI协议。和很多现代的CPU技术一样MESI协议也是在Pentium时代被引入到Intel CPU中的。
MESI协议是一种叫作**写失效**Write Invalidate的协议。在写失效协议里只有一个CPU核心负责写入数据其他的核心只是同步读取到这个写入。在这个CPU核心写入Cache之后它会去广播一个“失效”请求告诉所有其他的CPU核心。其他的CPU核心只是去判断自己是否也有一个“失效”版本的Cache Block然后把这个也标记成失效的就好了。
相对于写失效协议,还有一种叫作**写广播**Write Broadcast的协议。在那个协议里一个写入请求广播到所有的CPU核心同时更新各个核心里的Cache。
写广播在实现上自然很简单但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的CPU核心哪一个内存地址的缓存失效了但是写广播还需要把对应的数据传输给其他CPU核心。
<img src="https://static001.geekbang.org/resource/image/4e/59/4ed6d05049cbbc8603346f617206cd59.jpeg" alt="">
MESI协议的由来呢来自于我们对Cache Line的四个不同的标记分别是
- M代表已修改Modified
- E代表独占Exclusive
- S代表共享Shared
- 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里面的数据。这个广播操作一般叫作RFORequest For Ownership也就是获取当前对应Cache Block数据的所有权。
有没有觉得这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。
整个MESI的状态可以用一个有限状态机来表示它的状态流转。需要注意的是对于不同状态触发的事件操作可能来自于当前CPU核心也可能来自总线里其他CPU核心广播出来的信号。我把对应的状态机流转图放在了下面你可以对照着[Wikipedia里面MESI的内容](https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE),仔细研读一下。
<img src="https://static001.geekbang.org/resource/image/fa/d1/fa98835c78c879ab69fd1f29193e54d1.jpeg" alt="">
## 总结延伸
好了关于CPU Cache的内容我们介绍到这里就结束了。我们来总结一下。这一节我们其实就讲了两块儿内容一个是缓存一致性另一个是MESI协议。
想要实现缓存一致性关键是要满足两点。第一个是写传播也就是在一个CPU核心写入的内容需要传播到其他CPU核心里。更重要的是第二点保障事务的串行化才能保障我们的数据是真正一致的我们的程序在各个不同的核心上运行的结果也是一致的。这个特性不仅在CPU的缓存层面很重要在数据库层面更加重要。
之后我介绍了基于总线嗅探机制的MESI协议。MESI协议是一种基于写失效的缓存一致性协议。写失效的协议的好处是我们不需要在总线上传输数据内容而只需要传输操作信号和地址信号就好了不会那么占总线带宽。
MESI协议是已修改、独占、共享以及已失效这四个缩写的合称。独占和共享状态就好像我们在多线程应用开发里面的读写锁机制确保了我们的缓存一致性。而整个MESI的状态变更则是根据来自自己CPU核心的请求以及来自其他CPU核心通过总线传输过来的操作信号和地址信息进行状态流转的一个有限状态机。
## 推荐阅读
大部分计算机组成或者体系结构的教科书都没有提到缓存一致性问题。不过最近有一本国人写的计算机底层原理的书《大话计算机》里面的6.9章节比较详细地讲解了多核CPU的访问存储数据的一致性问题很值得仔细读一读。
## 课后思考
今天我们所讲的MESI缓存一致性协议其实是对于MSI缓存一致性协议的一个优化。你可以通过搜索引擎研究一下什么是MSI协议以及MESI相对于MSI协议究竟做了哪些优化
欢迎把你研究的结果写在留言区和大家分享。如果有收获,也欢迎你把这篇文章分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="40 | 理解内存(上):虚拟内存和内存保护是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/c8/ef862f267e7cbcebbd4cbcf2a8a6a2c8.mp3"></audio>
我们在专栏一开始说过计算机有五大组成部分分别是运算器、控制器、存储器、输入设备和输出设备。如果说计算机最重要的组件是承担了运算器和控制器作用的CPU那内存就是我们第二重要的组件了。内存是五大组成部分里面的存储器我们的指令和数据都需要先加载到内存里面才会被CPU拿去执行。
专栏[第9讲](https://time.geekbang.org/column/article/95223)我们讲了程序装载到内存的过程。可以知道在我们日常使用的Linux或者Windows操作系统下程序并不能直接访问物理内存。
<img src="https://static001.geekbang.org/resource/image/0c/f0/0cf2f08e1ceda473df71189334857cf0.png" alt="">
我们的内存需要被分成固定大小的页Page然后再通过虚拟内存地址Virtual Address到物理内存地址Physical Address的地址转换Address Translation才能到达实际存放数据的物理内存位置。而我们的程序看到的内存地址都是虚拟内存地址。
既然如此,这些虚拟内存地址究竟是怎么转换成物理内存地址的呢?这一讲里,我们就来看一看。
## 简单页表
想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的一一映射。这个映射表,在计算机里面,就叫作**页表**Page Table
页表这个地址转换的办法,会把一个内存地址分成**页号**Directory和**偏移量**Offset两个部分。这么说太理论了我以一个32位的内存地址为例帮你理解这个概念。
其实前面的高位就是内存地址的页号。后面的低位就是内存地址里面的偏移量。做地址转换的页表只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存在物理层面是连续的。以一个页的大小是4K字节4KB为例我们需要20位的高位12位的低位。
<img src="https://static001.geekbang.org/resource/image/22/0f/22bb79129f6363ac26be47b35748500f.jpeg" alt="">
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
1. 把虚拟内存地址,切分成页号和偏移量的组合;
1. 从页表里面,查询出虚拟页号,对应的物理页号;
1. 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
<img src="https://static001.geekbang.org/resource/image/07/dd/07cd4c3344690055240f215404a286dd.jpeg" alt="">
看起来这个逻辑似乎很简单很容易理解不过问题马上就来了。你能算一算这样一个页表需要多大的空间吗我们以32位的内存地址空间为例你可以暂停一下拿出纸笔算一算。
不知道你算出的数字是多少32位的内存地址空间页表一共需要记录2^20个到物理页号的映射关系。这个存储关系就好比一个2^20大小的数组。一个页号是完整的32位的4字节Byte这样一个页表就需要4MB的空间。听起来4MB的空间好像还不大啊毕竟我们现在的内存至少也有4GB服务器上有个几十GB的内存和很正常。
<img src="https://static001.geekbang.org/resource/image/81/c9/8190dc8a065b06786f26ece596a8e9c9.jpg" alt="">
不过这个空间可不是只占用一份哦。我们每一个进程都有属于自己独立的虚拟内存地址空间。这也就意味着每一个进程都需要这样一个页表。不管我们这个进程是个本身只有几KB大小的程序还是需要几GB的内存空间都需要这样一个页表。如果你用的是Windows你可以打开你自己电脑上的任务管理器看看现在你的计算机里同时在跑多少个进程用这样的方式页表需要占用多大的内存。
这还只是32位的内存地址空间现在大家用的内存多半已经超过了4GB也已经用上了64位的计算机和操作系统。这样的话用上面这个数组的数据结构来保存页面内存占用就更大了。那么我们有没有什么更好的解决办法呢你可以先仔细思考一下。
## 多级页表
仔细想一想我们其实没有必要存下这2^20个物理页表啊。大部分进程所占用的内存是有限的需要的页也自然是很有限的。我们只需要去存那些用到的页之间的映射关系就好了。如果你对数据结构比较熟悉你可能要说了那我们是不是应该用哈希表Hash Map这样的数据结构呢
很可惜你猜错了。在实践中我们其实采用的是一种叫作多级页表Multi-Level Page Table的解决方案。这是为什么呢为什么我们不用哈希表而用多级页表呢别着急听我慢慢跟你讲。
我们先来看一看,一个进程的内存地址空间是怎么分配的。在整个进程的内存地址空间,通常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。
所以,在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。
我们以一个4级的多级页表为例来看一下。同样一个虚拟内存地址偏移量的部分和上面简单页表一样不变但是原先的页号部分我们把它拆成四段从高到低分成4级到1级这样4个页表索引。
<img src="https://static001.geekbang.org/resource/image/61/76/614034116a840ef565feda078d73cb76.jpeg" alt="">
对应的一个进程会有一个4级页表。我们先通过4级页表索引找到4级页表里面对应的条目Entry。这个条目里存放的是一张3级页表所在的位置。4级页面里面的每一个条目都对应着一张3级页表所以我们可能有多张3级页表。
找到对应这张3级页表之后我们用3级索引去找到对应的3级索引的条目。3级索引的条目再会指向一个2级页表。同样的2级页表里我们可以用2级索引指向一个1级页表。
而最后一层的1级页表里面的条目对应的数据内容就是物理页号了。在拿到了物理页号之后我们同样可以用“页号+偏移量”的方式,来获取最终的物理内存地址。
我们可能有很多张1级页表、2级页表乃至3级页表。但是因为实际的虚拟内存空间通常是连续的我们很可能只需要很少的2级页表甚至只需要1张3级页表就够了。
事实上,多级页表就像一个多叉树的数据结构,所以我们常常称它为**页表树**Page Table Tree。因为虚拟内存地址分布的连续性树的第一层节点的指针很多就是空的也就不需要有对应的子树了。所谓不需要子树其实就是不需要对应的2级、3级的页表。找到最终的物理页号就好像通过一个特定的访问路径走到树最底层的叶子节点。
<img src="https://static001.geekbang.org/resource/image/5b/4e/5ba17a3ecf3f9ce4a65546de480fcc4e.jpeg" alt="">
以这样的分成4级的多级页表来看每一级如果都用5个比特表示。那么每一张某1级的页表只需要2^5=32个条目。如果每个条目还是4个字节那么一共需要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。
不过多级页表虽然节约了我们的存储空间却带来了时间上的开销所以它其实是一个“以时间换空间”的策略。原本我们进行一次地址转换只需要访问一次内存就能找到物理页号算出物理内存地址。但是用了4级页表我们就需要访问4次内存才能找到物理页号了。
我们在前面两讲讲过内存访问其实比Cache要慢很多。我们本来只是要做一个简单的地址转换反而是一下子要多访问好多次内存。对于这个时间层面的性能损失我们有没有什么更好的解决办法呢那请你一定要关注下一讲的内容哦
## 总结延伸
好了,这一讲的内容差不多了,我们来总结一下。
我们从最简单的进行虚拟页号一一映射的简单页表说起,仔细讲解了现在实际应用的多级页表。多级页表就像是一颗树。因为一个进程的内存地址相对集中和连续,所以采用这种页表树的方式,可以大大节省页表所需要的空间。而因为每个进程都需要一个独立的页表,这个空间的节省是非常可观的。
在优化页表的过程中,我们可以观察到,数组这样的紧凑的数据结构,以及树这样稀疏的数据结构,在时间复杂度和空间复杂度的差异。另外,纯粹理论软件的数据结构和硬件的设计也是高度相关的。
## 推荐阅读
对于虚拟内存的知识点,你可以再深入读一读《计算机组成与设计:硬件/软件接口》的第5.7章节。如果你觉得还不过瘾,可以进一步去读一读[《What Every Programmer Should Know About Memory》](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf)的第4部分也就是Virtual Memory。
## 课后思考
在实际的虚拟内存地址到物理内存地址的地址转换的过程里,我们没有采用哈希表,而是采用了多级页表的解决方案。你能想一想,使用多级页表,对于哈希表有哪些优点,又有哪些缺点吗?
欢迎留言和我分享你的想法,如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="41 | 理解内存解析TLB和内存保护" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/c8/b137c580feb4446b71b7bd4f003050c8.mp3"></audio>
机器指令里面的内存地址都是虚拟内存地址。程序里面的每一个进程,都有一个属于自己的虚拟内存地址空间。我们可以通过地址转换来获得最终的实际物理地址。我们每一个指令都存放在内存里面,每一条数据都存放在内存里面。因此,“地址转换”是一个非常高频的动作,“地址转换”的性能就变得至关重要了。这就是我们今天要讲的**第一个问题**,也就是**性能问题**。
因为我们的指令、数据都存放在内存里面,这里就会遇到我们今天要谈的**第二个问题**,也就是**内存安全问题**。如果被人修改了内存里面的内容我们的CPU就可能会去执行我们计划之外的指令。这个指令可能是破坏我们服务器里面的数据也可能是被人获取到服务器里面的敏感信息。
现代的CPU和操作系统会通过什么样的方式来解决这两个问题呢别着急等讲完今天的内容你就知道答案了。
## 加速地址转换TLB
上一节我们说了,从虚拟内存地址到物理内存地址的转换,我们通过页表这个数据结构来处理。为了节约页表的内存存储空间,我们会使用多级页表数据结构。
不过多级页表虽然节约了我们的存储空间但是却带来了时间上的开销变成了一个“以时间换空间”的策略。原本我们进行一次地址转换只需要访问一次内存就能找到物理页号算出物理内存地址。但是用了4级页表我们就需要访问4次内存才能找到物理页号。
我们知道内存访问其实比Cache要慢很多。我们本来只是要做一个简单的地址转换现在反而要一下子多访问好多次内存。这种情况该怎么处理呢你是否还记得之前讲过的“加个缓存”的办法呢我们来试一试。
程序所需要使用的指令都顺序存放在虚拟内存里面。我们执行的指令也是一条条顺序执行下去的。也就是说我们对于指令地址的访问存在前面几讲所说的“空间局部性”和“时间局部性”而需要访问的数据也是一样的。我们连续执行了5条指令。因为内存地址都是连续的所以这5条指令通常都在同一个“虚拟页”里。
因此这连续5次的内存地址转换其实都来自于同一个虚拟页号转换的结果自然也就是同一个物理页号。那我们就可以用前面几讲说过的用一个“加个缓存”的办法。把之前的内存转换地址缓存下来使得我们不需要反复去访问内存来进行内存地址转换。
<img src="https://static001.geekbang.org/resource/image/ef/27/ef754d9b2c816acff1dad63875ffea27.jpeg" alt="">
于是计算机工程师们专门在CPU里放了一块缓存芯片。这块缓存芯片我们称之为**TLB**,全称是**地址变换高速缓冲**Translation-Lookaside Buffer。这块缓存存放了之前已经进行过地址转换的查询结果。这样当同样的虚拟地址需要进行地址转换的时候我们可以直接在TLB里面查询结果而不需要多次访问内存来完成一次转换。
TLB和我们前面讲的CPU的高速缓存类似可以分成指令的TLB和数据的TLB也就是**ITLB**和**DTLB**。同样的我们也可以根据大小对它进行分级变成L1、L2这样多层的TLB。
除此之外还有一点和CPU里的高速缓存也是一样的我们需要用脏标记这样的标记位来实现“写回”这样缓存管理策略。
<img src="https://static001.geekbang.org/resource/image/43/d9/432050446f68569a37c7699cccda75d9.jpeg" alt="">
为了性能我们整个内存转换过程也要由硬件来执行。在CPU芯片里面我们封装了内存管理单元MMUMemory Management Unit芯片用来完成地址转换。和TLB的访问和交互都是由这个MMU控制的。
## 安全性与内存保护
讲完了虚拟内存和物理内存的转换,我们来看看内存保护和安全性的问题。
进程的程序也好,数据也好,都要存放在内存里面。实际程序指令的执行,也是通过程序计数器里面的地址,去读取内存内的内容,然后运行对应的指令,使用相应的数据。
虽然我们现代的操作系统和CPU已经做了各种权限的管控。正常情况下我们已经通过虚拟内存地址和物理内存地址的区分隔离了各个进程。但是无论是CPU这样的硬件还是操作系统这样的软件都太复杂了难免还是会被黑客们找到各种各样的漏洞。
就像我们在软件开发过程中,常常会有一个“兜底”的错误处理方案一样,在对于内存的管理里面,计算机也有一些最底层的安全保护机制。这些机制统称为**内存保护**Memory Protection。我这里就为你简单介绍两个。
### 可执行空间保护
第一个常见的安全机制,叫**可执行空间保护**Executable Space Protection
这个机制是说我们对于一个进程使用的内存只把其中的指令部分设置成“可执行”的对于其他部分比如数据部分不给予“可执行”的权限。因为无论是指令还是数据在我们的CPU看来都是二进制的数据。我们直接把数据部分拿给CPU如果这些数据解码后也能变成一条合理的指令其实就是可执行的。
这个时候黑客们想到了一些搞破坏的办法。我们在程序的数据区里放入一些要执行的指令编码后的数据然后找到一个办法让CPU去把它们当成指令去加载那CPU就能执行我们想要执行的指令了。对于进程里内存空间的执行权限进行控制可以使得CPU只能执行指令区域的代码。对于数据区域的内容即使找到了其他漏洞想要加载成指令来执行也会因为没有权限而被阻挡掉。
其实,在实际的应用开发中,类似的策略也很常见。我下面给你举两个例子。
比如说在用PHP进行Web开发的时候我们通常会禁止PHP有eval函数的执行权限。这个其实就是害怕外部的用户所以没有把数据提交到服务器而是把一段想要执行的脚本提交到服务器。服务器里在拼装字符串执行命令的时候可能就会执行到预计之外被“注入”的破坏性脚本。这里我放了一个例子用这个办法可以去删除服务器上的数据。
```
script.php?param1=xxx
//我们的PHP接受一个传入的参数这个参数我们希望提供计算功能
```
```
$code = eval($_GET[&quot;param1&quot;]);
// 我们直接通过 eval 计算出来对应的参数公式的计算结果
```
```
script.php?param1=&quot;;%20echo%20exec('rm -rf ~/');%20//
// 用户传入的参数里面藏了一个命令
```
```
$code = &quot;&quot;; echo exec('rm -rf ~/'); //&quot;;
// 执行的结果就变成了删除服务器上的数据
```
还有一个例子就是SQL注入攻击。如果服务端执行的SQL脚本是通过字符串拼装出来的那么在Web请求里面传输的参数就可以藏下一些我们想要执行的SQL让服务器执行一些我们没有想到过的SQL语句。这样的结果就是或者破坏了数据库里的数据或者被人拖库泄露了数据。
### 地址空间布局随机化
第二个常见的安全机制,叫**地址空间布局随机化**Address Space Layout Randomization
内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防。上面的可执行空间保护就是一个很好的例子。但是,内存层面的漏洞还有其他的可能性。
这里的核心问题是,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。
原先我们一个进程的内存布局空间是固定的所以任何第三方很容易就能知道指令在哪里程序栈在哪里数据在哪里堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制就是让这些区域的位置不再固定在内存空间随机去分配这些进程里不同部分所在的内存空间地址让破坏者猜不出来。猜不出来呢自然就没法找到想要修改的内容的位置。如果只是随便做点修改程序只会crash掉而不会去执行计划之外的代码。
<img src="https://static001.geekbang.org/resource/image/db/b9/dbda1bd1d43d6fa9d7b552ca57d223b9.jpeg" alt="">
这样的“随机化”策略其实也是我们日常应用开发中一个常见的策略。一个大家都应该接触过的例子就是密码登陆功能。网站和App都会需要你设置用户名和密码之后用来登陆自己的账号。然后在服务器端我们会把用户名和密码保存下来在下一次用户登陆的时候使用这个用户名和密码验证。
我们的密码当然不能明文存储在数据库里,不然就会有安全问题。如果明文存储在数据库里,意味着能拿到数据库访问权限的人,都能看到用户的明文密码。这个可能是因为安全漏洞导致被人拖库,而且网站的管理员也能直接看到所有的用户名和密码信息。
比如前几年CSDN就发生过被人拖库的事件。虽然用户名和密码都是明文保存的别人如果只是拿到了CSDN网站的用户名密码用户的损失也不会太大。但是很多用户可能会在不同的网站使用相同的密码如果拿到这些用户名和密码的人能够成功登录用户的银行、支付、社交等等其他网站的话用户损失就大了去了。
于是大家会在数据库里存储密码的哈希值比如用现在常用的SHA256生成一一个验证的密码哈希值。但是这个往往还是不够的。因为同样的密码对应的哈希值都是相同的大部分用户的密码又常常比较简单。于是拖库成功的黑客可以通过[彩虹表](https://zh.wikipedia.org/wiki/%E5%BD%A9%E8%99%B9%E8%A1%A8)的方式,来推测出用户的密码。
这个时候,我们的“随机化策略”就可以用上了。我们可以在数据库里,给每一个用户名生成一个随机的、使用了各种特殊字符的**盐值**Salt。这样我们的哈希值就不再是仅仅使用密码来生成的了而是密码和盐值放在一起生成的对应的哈希值。哈希值的生成中包括了一些类似于“乱码”的随机字符串所以通过彩虹表碰撞来猜出密码的办法就用不了了。
```
$password = &quot;goodmorning12345&quot;;
// 我们的密码是明文存储的
$hashed_password = hash('sha256', password);
// 对应的hash值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
// 但是这个hash值里可以用彩虹表直接“猜出来”原始的密码就是goodmorning12345
$salt = &quot;#21Pb$Hs&amp;Xi923^)?&quot;;
$salt_password = $salt.$password;
$hashed_salt_password = hash('sha256', salt_password);
// 这个hash后的slat因为有部分随机的字符串不会在彩虹表里面出现。
// 261e42d94063b884701149e46eeb42c489c6a6b3d95312e25eee0d008706035f
```
可以看到,通过加入“随机”因素,我们有了一道最后防线。即使在出现安全漏洞的时候,我们也有了更多的时间和机会去补救这些问题。
虽然安全机制似乎在平时用不太到,但是在开发程序的时候,还是要有安全意识。毕竟谁也不想看到,被拖库的新闻里出现的是自己公司的名字,也不希望用户因为我们的错误遭受到损失。
## 总结延伸
为了节约页表所需要的内存空间我们采用了多级页表这样一个数据结构。但是多级页表虽然节省空间了却要花费更多的时间去多次访问内存。于是我们在实际进行地址转换的MMU旁边放上了TLB这个用于地址转换的缓存。TLB也像CPU Cache一样分成指令和数据部分也可以进行L1、L2这样的分层。
然后,我为你介绍了内存保护。无论是数据还是代码,我们都要存放在内存里面。为了防止因为各种漏洞,导致一个进程可以访问别的进程的数据或者代码,甚至是执行对应的代码,造成严重的安全问题,我们介绍了最常用的两个内存保护措施,可执行空间保护和地址空间布局随机化。
通过让数据空间里面的内容不能执行,可以避免了类似于“注入攻击”的攻击方式。通过随机化内存空间的分配,可以避免让一个进程的内存里面的代码,被推测出来,从而不容易被攻击。
## 推荐阅读
对于内存保护的相关知识,你可以通过[Wikipedia里面的相关条目](https://en.wikipedia.org/wiki/Memory_protection#Capability-based_addressing)来进一步了解相关的信息。
另外2017年暴露出来的[Spectre和Meltdown漏洞](https://zh.wikipedia.org/wiki/%E5%B9%BD%E7%81%B5%E6%BC%8F%E6%B4%9E)的相关原理你也可以在Wikipedia里面找到相关的信息来了解一下。
Spectre和Meltdown漏洞出现在CPU的高速缓存和虚拟机结合的层面。理解这个漏洞可以让你看到安全问题是如何出现各种让人难以想到的结果。这也是为什么我们需要可执行空间保护和地址空间布局随机化这样的“防卫性”的安全策略。即使我们不知道漏洞可以从哪里来即使漏洞可能已经发生了这些策略也能够使得我们的系统更不容易被攻破。
## 课后思考
除了我们今天说的可执行空间保护和地址空间布局随机化之外,你还知道其他内存保护策略吗?你想到的这些内存保护策略,和你日常的开发工作中,是否也有类似思路的应用呢?
欢迎留言和我分享你日常开发中用到的内存保护策略,如果这篇文章对你有帮助,你也可以把它分享给你的朋友,和他一起讨论和学习。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="42 | 总线:计算机内部的高速公路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/bd/9f43bd9f37fd1a10c96dfe8b25c765bd.mp3"></audio>
专栏讲到现在,如果我再问你,计算机五大组成部分是什么,应该没有人不知道了吧?我们这一节要讲的内容,依然要围绕这五大部分,控制器、运算器、存储器、输入设备和输出设备。
CPU所代表的控制器和运算器要和存储器也就是我们的主内存以及输入和输出设备进行通信。那问题来了CPU从我们的键盘、鼠标接收输入信号向显示器输出信号这之间究竟是怎么通信的呢换句话说计算机是用什么样的方式来完成CPU和内存、以及外部输入输出设备的通信呢
这个问题就是我们今天要讲的主题,也就是**总线**。之前很多同学留言问,我什么时候会讲一讲总线。那这一讲,你就要听仔细了。
## 降低复杂性:总线的设计思路来源
计算机里其实有很多不同的硬件设备除了CPU和内存之外我们还有大量的输入输出设备。可以说你计算机上的每一个接口键盘、鼠标、显示器、硬盘乃至通过USB接口连接的各种外部设备都对应了一个设备或者模块。
如果各个设备间的通信,都是互相之间单独进行的。如果我们有$N$个不同的设备,他们之间需要各自单独连接,那么系统复杂度就会变成$N^2$。每一个设备或者功能电路模块,都要和其他$N-1$个设备去通信。为了简化系统的复杂度,我们就引入了总线,把这个$N^2$的复杂度,变成一个$N$的复杂度。
那怎么降低复杂度呢与其让各个设备之间互相单独通信不如我们去设计一个公用的线路。CPU想要和什么设备通信通信的指令是什么对应的数据是什么都发送到这个线路上设备要向CPU发送什么信息呢也发送到这个线路上。这个线路就好像一个高速公路各个设备和其他设备之间不需要单独建公路只建一条小路通向这条高速公路就好了。
<img src="https://static001.geekbang.org/resource/image/af/58/afdf06aeb84a92a9dfe5e9d2299e6958.jpeg" alt="">
这个设计思路,就是我们今天要说的**总线**Bus
总线其实就是一组线路。我们的CPU、内存以及输入和输出设备都是通过这组线路进行相互间通信的。总线的英文叫作Bus就是一辆公交车。这个名字很好地描述了总线的含义。我们的“公交车”的各个站点就是各个接入设备。要想向一个设备传输数据我们只要把数据放上公交车在对应的车站下车就可以了。
其实,对应的设计思路,在软件开发中也是非常常见的。我们在做大型系统开发的过程中,经常会用到一种叫作[事件总线](https://dzone.com/articles/design-patterns-event-bus)Event Bus的设计模式。
进行大规模应用系统开发的时候,系统中的各个组件之间也需要相互通信。模块之间如果是两两之间单独去定义协议,这个软件系统一样会遇到一个复杂度变成了$N^2$的问题。所以常见的一个解决方案,就是事件总线这个设计模式。
在事件总线这个设计模式里各个模块触发对应的事件并把事件对象发送到总线上。也就是说每个模块都是一个发布者Publisher。而各个模块也会把自己注册到总线上去监听总线上的事件并根据事件的对象类型或者是对象内容来决定自己是否要进行特定的处理或者响应。
<img src="https://static001.geekbang.org/resource/image/1c/53/1c6002fabbb80407a34afec76cdb5f53.jpeg" alt="">
这样的设计下,注册在总线上的各个模块就是松耦合的。模块互相之间并没有依赖关系。无论代码的维护,还是未来的扩展,都会很方便。
## 理解总线:三种线路和多总线架构
理解了总线的设计概念,我们来看看,总线在实际的计算机硬件里面,到底是什么样。
现代的Intel CPU的体系结构里面通常有好几条总线。
首先CPU和内存以及高速缓存通信的总线这里面通常有两种总线。这种方式我们称之为**双独立总线**Dual Independent Bus缩写为DIB。CPU里有一个快速的**本地总线**Local Bus以及一个速度相对较慢的**前端总线**Front-side Bus
我们在前面几讲刚刚讲过现代的CPU里通常有专门的高速缓存芯片。这里的高速本地总线就是用来和高速缓存通信的。而前端总线则是用来和主内存以及输入输出设备通信的。有时候我们会把本地总线也叫作后端总线Back-side Bus和前面的前端总线对应起来。而前端总线也有很多其他名字比如处理器总线Processor Bus、内存总线Memory Bus
<img src="https://static001.geekbang.org/resource/image/4d/f9/4ddbb489ceaac5e7a2c8491178db1cf9.jpeg" alt="">
除了前端总线呢我们常常还会听到PCI总线、I/O总线或者系统总线System Bus。看到这么多总线的名字你是不是已经有点晕了。这些名词确实容易混为一谈。其实各种总线的命名一直都很混乱我们不如直接来看一看**CPU的硬件架构图**。对照图来看,一切问题就都清楚了。
CPU里面的北桥芯片把我们上面说的前端总线一分为二变成了三个总线。
我们的前端总线,其实就是**系统总线**。CPU里面的内存接口直接和系统总线通信然后系统总线再接入一个I/O桥接器I/O Bridge。这个I/O桥接器一边接入了我们的内存总线使得我们的CPU和内存通信另一边呢又接入了一个I/O总线用来连接I/O设备。
事实上真实的计算机里这个总线层面拆分得更细。根据不同的设备还会分成独立的PCI总线、ISA总线等等。
<img src="https://static001.geekbang.org/resource/image/f5/66/f58610f211422d71ff50eeeeb729d166.jpeg" alt="">
在物理层面,其实我们完全可以把总线看作一组“电线”。不过呢,这些电线之间也是有分工的,我们通常有三类线路。
1. 数据线Data Bus用来传输实际的数据信息也就是实际上了公交车的“人”。
1. 地址线Address Bus用来确定到底把数据传输到哪里去是内存的某个位置还是某一个I/O设备。这个其实就相当于拿了个纸条写下了上面的人要下车的站点。
1. 控制线Control Bus用来控制对于总线的访问。虽然我们把总线比喻成了一辆公交车。那么有人想要做公交车的时候需要告诉公交车司机这个就是我们的控制信号。
尽管总线减少了设备之间的耦合,也降低了系统设计的复杂度,但同时也带来了一个新问题,那就是总线不能**同时**给多个设备提供通信功能。
我们的总线是很多个设备公用的,那多个设备都想要用总线,我们就需要有一个机制,去决定这种情况下,到底把总线给哪一个设备用。这个机制,就叫作**总线裁决**Bus Arbitraction。总线裁决的机制有很多种不同的实现如果你对这个实现的细节感兴趣可以去看一看Wiki里面关于[裁决器](https://en.wikipedia.org/wiki/Arbiter_(electronics))的对应条目,这里我们就不多说了。
## 总结延伸
好了,你现在明白计算机里的总线、各种不同的总线到底是什么意思了吧?希望这一讲能够帮你厘清计算机总线的知识点。现在我们一起来总结梳理一下这节的内容。
这一讲,我为你讲解了计算机里各个不同的组件之间用来通信的渠道,也就是总线。总线的设计思路,核心是为了减少多个模块之间交互的复杂性和耦合度。实际上,总线这个设计思路在我们的软件开发过程中也经常会被用到。事件总线就是我们常见的一个设计模式,通常事件总线也会和订阅者发布者模式结合起来,成为大型系统的各个松耦合的模块之间交互的一种主要模式。
在实际的硬件层面总线其实就是一组连接电路的线路。因为不同设备之间的速度有差异所以一台计算机里面往往会有多个总线。常见的就有在CPU内部和高速缓存通信的本地总线以及和外部I/O设备以及内存通信的前端总线。
前端总线通常也被叫作系统总线。它可以通过一个I/O桥接器拆分成两个总线分别来和I/O设备以及内存通信。自然这样拆开的两个总线就叫作I/O总线和内存总线。总线本身的电路功能又可以拆分成用来传输数据的数据线、用来传输地址的地址线以及用来传输控制信号的控制线。
总线是一个各个接入的设备公用的线路,所以自然会在各个设备之间争夺总线所有权的情况。于是,我们需要一个机制来决定让谁来使用总线,这个决策机制就是总线裁决。
## 推荐阅读
总线是一个抽象的设计模式它不仅在我们计算机的硬件设计里出现。在日常的软件开发中也是一个常见的设计模式你可以去读一读Google开源的Java的一个常用的工具库Guava的[相关资料和代码](https://github.com/google/guava/wiki/EventBusExplained),进一步理解事件总线的设计模式,看看在软件层面怎么实现它。
对于计算机硬件层面的总线很多教科书里讲得都比较少你可以去读一读Wiki里面[总线](https://en.wikipedia.org/wiki/Bus_(computing))和[系统总线](https://en.wikipedia.org/wiki/System_bus)的相关条目。
## 课后思考
2008年之后我们的Intel CPU其实已经没有前端总线了。Intel发明了[快速通道互联](https://en.wikipedia.org/wiki/Intel_QuickPath_Interconnect)Intel Quick Path Interconnect简称为QPI技术替代了传统的前端总线。这个QPI技术你可以搜索和翻阅一下相关资料了解一下它引入了什么新的设计理念。
欢迎在留言区分享你查阅到的资料,以及阅读之后的思考总结,和大家一起交流。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,103 @@
<audio id="audio" title="43 | 输入输出设备我们并不是只能用灯泡显示“0”和“1”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/ae/71254c57539bec271f9b7348eab61fae.mp3"></audio>
我们在前面的章节搭建了最简单的电路在这里面计算机的输入设备就是一个一个开关输出设备呢是一个一个灯泡。的确早期发展的时候计算机的核心是做“计算”。我们从“计算机”这个名字上也能看出这一点。不管是中文名字“计算机”还是英文名字“Computer”核心都是在”计算“这两个字上。不过到了今天这些“计算”的工作更多的是一个幕后工作。
我们无论是使用自己的PC还是智能手机大部分时间都是在和计算机进行各种“交互操作”。换句话说就是在和输入输出设备打交道。这些输入输出设备也不再是一个一个开关或者一个一个灯泡。你在键盘上直接敲击的都是字符而不是“0”和“1”你在显示器上看到的也是直接的图形或者文字的画面而不是一个一个闪亮或者关闭的灯泡。想要了解这其中的关窍那就请你和我一起来看一看计算机里面的输入输出设备。
## 接口和设备:经典的适配器模式
我们在前面讲解计算机的五大组成部分的时候,我看到这样几个留言。
一个同学问像蓝牙、WiFi无线网卡这样的设备也是输入输出设备吗还有一个同学问我们的输入输出设备的寄存器在哪里到底是在主板上还是在硬件设备上
这两个问题问得很好。其实你只要理解了这两个问题,也就理解输入输出设备是怎么回事儿了。
实际上,输入输出设备,并不只是一个设备。大部分的输入输出设备,都有两个组成部分。第一个是它的**接口**Interface第二个才是**实际的I/O设备**Actual I/O Device。我们的硬件设备并不是直接接入到总线上和CPU通信的而是通过接口用接口连接到总线上再通过总线和CPU通信。
<img src="https://static001.geekbang.org/resource/image/ce/bf/ce9d22a0dafa20b9574411b810c0aabf.jpg" alt="">
你平时听说的并行接口Parallel Interface、串行接口Serial Interface、USB接口都是计算机主板上内置的各个接口。我们的实际硬件设备比如使用并口的打印机、使用串口的老式鼠标或者使用USB接口的U盘都要插入到这些接口上才能和CPU工作以及通信的。
接口本身就是一块电路板。CPU其实不是和实际的硬件设备打交道而是和这个接口电路板打交道。我们平时说的设备里面有三类寄存器其实都在这个设备的接口电路上而不在实际的设备上。
那这三类寄存器是哪三类寄存器呢它们分别是状态寄存器Status Register、 命令寄存器Command Register以及数据寄存器Data Register
除了内置在主板上的接口之外,有些接口可以集成在设备上。你可能都没有见过老一点儿的硬盘,我来简单给你介绍一下。
上世纪90年代的时候大家用的硬盘都叫作**IDE硬盘**。这个IDE不是像IntelliJ或者WebStorm这样的软件开发集成环境Integrated Development Environment的IDE而是代表着集成设备电路Integrated Device Electronics。也就是说设备的接口电路直接在设备上而不在主板上。我们需要通过一个线缆把集成了接口的设备连接到主板上去。
<img src="https://static001.geekbang.org/resource/image/30/47/30c96ac2fd8a0deffcff86e7b66acf47.png" alt="">
把接口和实际设备分离,这个做法实际上来自于计算机走向[开放架构](https://en.wikipedia.org/wiki/Open_architecture)Open Architecture的时代。
当我们要对计算机升级我们不会扔掉旧的计算机直接买一台全新的计算机而是可以单独升级硬盘这样的设备。我们把老硬盘从接口上拿下来换一个新的上去就好了。各种输入输出设备的制造商也可以根据接口的控制协议来设计和制造硬盘、鼠标、键盘、打印机乃至其他种种外设。正是这样的分工协作带来了PC时代的繁荣。
其实在软件的设计模式里也有这样的思路。面向对象里的面向接口编程的接口就是Interface。如果你做iOS的开发Objective-C里面的Protocol其实也是这个意思。而Adaptor设计模式更是一个常见的、用来解决不同外部应用和系统“适配”问题的方案。可以看到计算机的软件和硬件在逻辑抽象上其实是相通的。
如果你用的是Windows操作系统你可以打开设备管理器里面有各种各种的Devices设备、Controllers控制器、Adaptors适配器。这些其实都是对于输入输出设备不同角度的描述。被叫作Devices看重的是实际的I/O设备本身。被叫作Controllers看重的是输入输出设备接口里面的控制电路。而被叫作Adaptors则是看重接口作为一个适配器后面可以插上不同的实际设备。
## CPU是如何控制I/O设备的
无论是内置在主板上的接口还是集成在设备上的接口除了三类寄存器之外还有对应的控制电路。正是通过这个控制电路CPU才能通过向这个接口电路板传输信号来控制实际的硬件。
我们先来看一看,硬件设备上的这些寄存器有什么用。这里,我拿我们平时用的打印机作为例子。
<img src="https://static001.geekbang.org/resource/image/fd/38/fd788de17028e8b1dbce58de5da31e38.jpeg" alt="">
1. 首先是数据寄存器Data Register。CPU向I/O设备写入需要传输的数据比如要打印的内容是“GeekTime”我们就要先发送一个“G”给到对应的I/O设备。
1. 然后是命令寄存器Command Register。CPU发送一个命令告诉打印机要进行打印工作。这个时候打印机里面的控制电路会做两个动作。第一个是去设置我们的状态寄存器里面的状态把状态设置成not-ready。第二个就是实际操作打印机进行打印。
1. 而状态寄存器Status Register就是告诉了我们的CPU现在设备已经在工作了所以这个时候CPU你再发送数据或者命令过来都是没有用的。直到前面的动作已经完成状态寄存器重新变成了ready状态我们的CPU才能发送下一个字符和命令。
当然在实际情况中打印机里通常不只有数据寄存器还会有数据缓冲区。我们的CPU也不是真的一个字符一个字符这样交给打印机去打印的而是一次性把整个文档传输到打印机的内存或者数据缓冲区里面一起打印的。不过通过上面这个例子相信你对CPU是怎么操作I/O设备的应该有所了解了。
## 信号和地址:发挥总线的价值
搞清楚了实际的I/O设备和接口之间的关系一个新的问题就来了。那就是我们的CPU到底要往总线上发送一个什么样的命令才能和I/O接口上的设备通信呢
CPU和I/O设备的通信一样是通过CPU支持的机器指令来执行的。
如果你回头去看一看[第5讲](https://time.geekbang.org/column/article/93359)MIPS的机器指令的分类你会发现我们并没有一种专门的和I/O设备通信的指令类型。那么MIPS的CPU到底是通过什么样的指令来和I/O设备来通信呢
答案就是和访问我们的主内存一样使用“内存地址”。为了让已经足够复杂的CPU尽可能简单计算机会把I/O设备的各个寄存器以及I/O设备内部的内存地址都映射到主内存地址空间里来。主内存的地址空间里会给不同的I/O设备预留一段一段的内存地址。CPU想要和这些I/O设备通信的时候呢就往这些地址发送数据。这些地址信息就是通过上一讲的地址线来发送的而对应的数据信息呢自然就是通过数据线来发送的了。
而我们的I/O设备呢就会监控地址线并且在CPU往自己地址发送数据的时候把对应的数据线里面传输过来的数据接入到对应的设备里面的寄存器和内存里面来。CPU无论是向I/O设备发送命令、查询状态还是传输数据都可以通过这样的方式。这种方式呢叫作**内存映射**IOMemory-Mapped I/O简称MMIO
<img src="https://static001.geekbang.org/resource/image/bb/22/bb8c1c007f7263bee41b7c649304c722.jpeg" alt="">
那么MMIO是不是唯一一种CPU和设备通信的方式呢答案是否定的。精简指令集MIPS的CPU特别简单所以这里只有MMIO。而我们有2000多个指令的Intel X86架构的计算机自然可以设计专门的和I/O设备通信的指令也就是 in 和 out 指令。
Intel CPU虽然也支持MMIO不过它还可以通过特定的指令来支持端口映射I/OPort-Mapped I/O简称PMIO或者也可以叫独立输入输出Isolated I/O
其实PMIO的通信方式和MMIO差不多核心的区别在于PMIO里面访问的设备地址不再是在内存地址空间里面而是一个专门的端口Port。这个端口并不是指一个硬件上的插口而是和CPU通信的一个抽象概念。
无论是PMIO还是MMIOCPU都会传送一条二进制的数据给到I/O设备的对应地址。设备自己本身的接口电路再去解码这个数据。解码之后的数据呢就会变成设备支持的一条指令再去通过控制电路去操作实际的硬件设备。对于CPU来说它并不需要关心设备本身能够支持哪些操作。它要做的只是在总线上传输一条条数据就好了。
这个其实也有点像我们在设计模式里面的Command模式。我们在总线上传输的是一个个数据对象然后各个接受这些对象的设备再去根据对象内容进行实际的解码和命令执行。
<img src="https://static001.geekbang.org/resource/image/4e/a7/4e66bafd713fed95a4957df71b3bd8a7.png" alt="">
这是一张我自己的显卡在设备管理器里面的资源Resource信息。你可以看到里面既有Memory Range这个就是设备对应映射到的内存地址也就是我们上面所说的MMIO的访问方式。同样的里面还有I/O Range这个就是我们上面所说的PMIO也就是通过端口来访问I/O设备的地址。最后里面还有一个IRQ也就是会来自于这个设备的中断信号了。
## 总结延伸
好了讲到这里不知道现在你是不是可以把CPU的指令、总线和I/O设备之间的关系彻底串联起来了呢我来带你回顾一下。
CPU并不是发送一个特定的操作指令来操作不同的I/O设备。因为如果是那样的话随着新的I/O设备的发明我们就要去扩展CPU的指令集了。
在计算机系统里面CPU和I/O设备之间的通信是这么来解决的。
首先在I/O设备这一侧我们把I/O设备拆分成能和CPU通信的接口电路以及实际的I/O设备本身。接口电路里面有对应的状态寄存器、命令寄存器、数据寄存器、数据缓冲区和设备内存等等。接口电路通过总线和CPU通信接收来自CPU的指令和数据。而接口电路中的控制电路再解码接收到的指令实际去操作对应的硬件设备。
而在CPU这一侧对CPU来说它看到的并不是一个个特定的设备而是一个个内存地址或者端口地址。CPU只是向这些地址传输数据或者读取数据。所需要的指令和操作内存地址的指令其实没有什么本质差别。通过软件层面对于传输的命令数据的定义而不是提供特殊的新的指令来实际操作对应的I/O硬件。
## 推荐阅读
想要进一步了解CPU和I/O设备交互的技术细节我推荐你去看一看北京大学在Coursera上的视频课程《计算机组成》[第10周的](https://www.coursera.org/learn/jisuanji-zucheng/home/week/10)[内容](https://www.coursera.org/learn/jisuanji-zucheng/home/week/10)。这个课程在Coursera上是中文的而且可以免费观看。相信这一个小时的视频课程对于你深入理解输入输出设备会很有帮助。
## 课后思考
我们还是回到这节开始的时候同学留言的问题。如果你买的是一个带无线接收器的蓝牙鼠标你需要把蓝牙接收器插在电脑的USB接口上然后你的鼠标会和这个蓝牙接收器进行通信。那么你能想一下我们的CPU和蓝牙鼠标这个输入设备之间的通信是怎样的吗
你可以好好思考一下,然后在留言区写下你的想法。当然,你也可以把这个问题分享给你的朋友,拉上他一起学习。

View File

@@ -0,0 +1,198 @@
<audio id="audio" title="44 | 理解IO_WAITI/O性能到底是怎么回事儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/32/af27bf0cc177f474bc67c2529813ae32.mp3"></audio>
在专栏一开始的时候我和你说过在计算机组成原理这门课里面很多设计的核心思路都来源于性能。在前面讲解CPU的时候相信你已经有了切身的感受了。
大部分程序员开发的都是应用系统。在开发应用系统的时候我们遇到的性能瓶颈大部分都在I/O上。在[第36讲](https://time.geekbang.org/column/article/107447)讲解局部性原理的时候,我们一起看了通过把内存当作是缓存,来提升系统的整体性能。在[第37讲](https://time.geekbang.org/column/article/107477)讲解CPU Cache的时候我们一起看了CPU Cache和主内存之间性能的巨大差异。
然而我们知道并不是所有问题都能靠利用内存或者CPU Cache做一层缓存来解决。特别是在这个“大数据”的时代。我们在硬盘上存储了越来越多的数据一个MySQL数据库的单表有个几千万条记录早已经不算是什么罕见现象了。这也就意味着用内存当缓存存储空间是不够用的。大部分时间我们的请求还是要打到硬盘上。那么这一讲我们就来看看硬盘I/O性能的事儿。
## IO性能、顺序访问和随机访问
如果去看硬盘厂商的性能报告,通常你会看到两个指标。一个是**响应时间**Response Time另一个叫作**数据传输率**Data Transfer Rate。没错这个和我们在专栏的一开始讲的CPU的性能一样前面那个就是响应时间后面那个就是吞吐率了。
我们先来看一看后面这个指标,数据传输率。
我们现在常用的硬盘有两种。一种是HDD硬盘也就是我们常说的机械硬盘。另一种是SSD硬盘一般也被叫作固态硬盘。现在的HDD硬盘用的是SATA 3.0的接口。而SSD硬盘呢通常会用两种接口一部分用的也是SATA 3.0的接口另一部分呢用的是PCI Express的接口。
现在我们常用的SATA 3.0的接口带宽是6Gb/s。这里的“b”是比特。这个带宽相当于每秒可以传输768MB的数据。而我们日常用的HDD硬盘的数据传输率差不多在200MB/s左右。
<img src="https://static001.geekbang.org/resource/image/a2/ff/a227f1a299a3774c4e1067436decf7ff.png" alt="">
当我们换成SSD的硬盘性能自然会好上不少。比如我最近刚把自己电脑的HDD硬盘换成了一块Crucial MX500的SSD硬盘。它的数据传输速率能到差不多500MB/s比HDD的硬盘快了一倍不止。不过SATA接口的硬盘差不多到这个速度性能也就到顶了。因为SATA接口的速度也就这么快。
<img src="https://static001.geekbang.org/resource/image/57/a4/57443821861f73e4d04ab4d64e6908a4.png" alt="">
不过实际SSD硬盘能够更快所以我们可以换用PCI Express的接口。我自己电脑的系统盘就是一块使用了PCI Express的三星SSD硬盘。它的数据传输率在读取的时候就能做到2GB/s左右差不多是HDD硬盘的10倍而在写入的时候也能有1.2GB/s。
除了数据传输率这个吞吐率指标另一个我们关心的指标响应时间其实也可以在AS SSD的测试结果里面看到就是这里面的Acc.Time指标。
这个指标其实就是程序发起一个硬盘的写入请求直到这个请求返回的时间。可以看到在上面的两块SSD硬盘上大概时间都是在几十微秒这个级别。如果你去测试一块HDD的硬盘通常会在几毫秒到十几毫秒这个级别。这个性能的差异就不是10倍了而是在几十倍乃至几百倍。
光看响应时间和吞吐率这两个指标似乎我们的硬盘性能很不错。即使是廉价的HDD硬盘接收一个来自CPU的请求也能够在几毫秒时间返回。一秒钟能够传输的数据也有200MB左右。你想一想我们平时往数据库里写入一条记录也就是1KB左右的大小。我们拿200MB去除以1KB那差不多每秒钟可以插入20万条数据呢。但是这个计算出来的数字似乎和我们日常的经验不符合啊这又是为什么呢
答案就来自于硬盘的读写。在**顺序读写**和**随机读写**的情况下,硬盘的性能是完全不同的。
我们回头看一下上面的AS SSD的性能指标。你会看到里面有一个“4K”的指标。这个指标是什么意思呢它其实就是我们的程序去随机读取磁盘上某一个4KB大小的数据一秒之内可以读取到多少数据。
你会发现在这个指标上我们使用SATA 3.0接口的硬盘和PCI Express接口的硬盘性能差异变得很小。这是因为在这个时候接口本身的速度已经不是我们硬盘访问速度的瓶颈了。更重要的是你会发现即使我们用PCI Express的接口在随机读写的时候数据传输率也只能到40MB/s左右是顺序读写情况下的几十分之一。
我们拿这个40MB/s和一次读取4KB的数据算一下。
也就是说一秒之内这块SSD硬盘可以随机读取1万次的4KB的数据。如果是写入的话呢会更多一些90MB /4KB 差不多是2万多次。
这个每秒读写的次数,我们称之为[IOPS](https://en.wikipedia.org/wiki/IOPS)也就是每秒输入输出操作的次数。事实上比起响应时间我们更关注IOPS这个性能指标。IOPS和DTRData Transfer Rate数据传输率才是输入输出性能的核心指标。
这是因为我们在实际的应用开发当中对于数据的访问更多的是随机读写而不是顺序读写。我们平时所说的服务器承受的“并发”其实是在说会有很多个不同的进程和请求来访问服务器。自然它们在硬盘上访问的数据是很难顺序放在一起的。这种情况下随机读写的IOPS才是服务器性能的核心指标。
好了回到我们引出IOPS这个问题的HDD硬盘。我现在要问你了那一块HDD硬盘能够承受的IOPS是多少呢其实我们应该已经在第36讲说过答案了。
HDD硬盘的IOPS通常也就在100左右而不是在20万次。在后面讲解机械硬盘的原理和性能优化的时候我们还会再来一起看一看这个100是怎么来的以及我们可以有哪些优化的手段。
## 如何定位IO_WAIT
我们看到即使是用上了PCI Express接口的SSD硬盘IOPS也就是在2万左右。而我们的CPU的主频通常在2GHz以上也就是每秒可以做20亿次操作。
即使CPU向硬盘发起一条读写指令需要很多个时钟周期一秒钟CPU能够执行的指令数和我们硬盘能够进行的操作数也有好几个数量级的差异。这也是为什么我们在应用开发的时候往往会说“性能瓶颈在I/O上”。因为很多时候CPU指令发出去之后不得不去“等”我们的I/O操作完成才能进行下一步的操作。
那么在实际遇到服务端程序的性能问题的时候我们怎么知道这个问题是不是来自于CPU等I/O来完成操作呢别着急我们接下来就通过top和iostat这些命令一起来看看CPU到底有没有在等待io操作。
```
# top
```
你一定在Linux下用过 top 命令。对于很多刚刚入门Linux的同学会用top去看服务的负载也就是load average。不过在top命令里面我们一样可以看到CPU是否在等待IO操作完成。
```
top - 06:26:30 up 4 days, 53 min, 1 user, load average: 0.79, 0.69, 0.65
Tasks: 204 total, 1 running, 203 sleeping, 0 stopped, 0 zombie
%Cpu(s): 20.0 us, 1.7 sy, 0.0 ni, 77.7 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st
KiB Mem: 7679792 total, 6646248 used, 1033544 free, 251688 buffers
KiB Swap: 0 total, 0 used, 0 free. 4115536 cached Mem
```
在top命令的输出结果里面有一行是以%CPU开头的。这一行里有一个叫作wa的指标这个指标就代表着iowait也就是CPU等待IO完成操作花费的时间占CPU的百分比。下一次当你自己的服务器遇到性能瓶颈load很大的时候你就可以通过top看一看这个指标。
知道了iowait很大那么我们就要去看一看实际的I/O操作情况是什么样的。这个时候你就可以去用iostat这个命令了。我们输入“iostat”就能够看到实际的硬盘读写情况。
```
$ iostat
```
```
avg-cpu: %user %nice %system %iowait %steal %idle
17.02 0.01 2.18 0.04 0.00 80.76
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
sda 1.81 2.02 30.87 706768 10777408
```
你会看到这个命令里不仅有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 : 15.75 K/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 35.44 K/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO&gt; COMMAND
104 be/3 root 0.00 B/s 7.88 K/s 0.00 % 0.18 % [jbd2/sda1-8]
383 be/4 root 0.00 B/s 3.94 K/s 0.00 % 0.00 % rsyslogd -n [rs:main Q:Reg]
1514 be/4 www-data 0.00 B/s 3.94 K/s 0.00 % 0.00 % nginx: worker process
```
通过iotop这个命令你可以看到具体是哪一个进程实际占用了大量I/O那么你就可以有的放矢去优化对应的程序了。
上面的这些示例里不管是wa也好tps也好它们都很小。那么接下来我就给你用Linux下用stress命令来模拟一个高I/O复杂的情况来看看这个时候的iowait是怎么样的。
我在一台云平台上的单个CPU核心的机器上输入“stress -i 2”让stress这个程序模拟两个进程不停地从内存里往硬盘上写数据。
```
$ stress -i 2
```
```
$ top
```
你会看到在top的输出里面CPU就有大量的sy和wa也就是系统调用和iowait。
```
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
```
```
$ iostat 2 5
```
如果我们通过iostat查看硬盘的I/O你会看到里面的tps很快就到了4万左右占满了对应硬盘的IOPS。
```
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
```
如果这个时候我们去看一看iotop你就会发现我们的I/O占用都来自于stress产生的两个进程了。
```
$ 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&gt; 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
```
相信到了这里你也应该学会了怎么通过top、iostat以及iotop一步一步快速定位服务器端的I/O带来的性能瓶颈了。你也可以自己通过Linux的man命令看一看这些命令还有哪些参数以及通过stress来模拟其他更多不同的性能压力看看我们的机器负载会发生什么变化。
## 总结延伸
这一讲里我们从硬盘的两个核心指标响应时间和数据传输率来理解和研究I/O的性能问题。你也自己可以通过as ssd这样的性能评测软件看一看自己的硬盘性能。
在顺序读取的情况下无论是HDD硬盘还是SSD硬盘性能看起来都是很不错的。不过等到进行随机读取测试的时候硬盘的性能才能见了真章。因为在大部分的应用开发场景下我们关心的并不是在顺序读写下的数据量而是每秒钟能够进行输入输出的操作次数也就是IOPS这个核心性能指标。
你会发现即使是使用PCI Express接口的SSD硬盘IOPS也就只是到了2万左右。这个性能和我们CPU的每秒20亿次操作的能力比起来可就差得远了。所以很多时候我们的程序对外响应慢其实都是CPU在等待I/O操作完成。
在Linux下我们可以通过top这样的命令来看整个服务器的整体负载。在应用响应慢的时候我们可以先通过这个指令来看CPU是否在等待I/O完成自己的操作。进一步地我们可以通过iostat这个命令来看到各个硬盘这个时候的读写情况。而 iotop 这个命令能够帮助我们定位到到底是哪一个进程在进行大量的I/O操作。
这些命令的组合可以快速帮你定位到是不是我们的程序遇到了I/O的瓶颈以及这些瓶颈来自于哪些程序你就可以根据定位的结果来优化你自己的程序了。
## 推荐阅读
关于IO_WAIT的文章在互联网上已经有不少了。你可以读一读这一篇[Understanding IOPS Latency and Storage Performance](https://louwrentius.com/understanding-iops-latency-and-storage-performance.html)进一步理解一下什么是IOPS和IO_WAIT。
## 课后思考
你能去下载一个AS SSD软件测试一下你自己硬盘的性能吗特别是如果你手上还有HDD硬盘的话可以尝试测试一下HDD硬盘的性能是怎么样的。
在上面的性能指标上我们已经讲解了Seq4K以及Acc.Time这三个指标那么4K-Thrd这个指标又是什么意思呢测试这个指标对应的应用场景又是怎么样的呢
请你研究一下,把你得到的答案写在留言区,和大家一起分享讨论吧。另外,如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,101 @@
<audio id="audio" title="45 | 机械硬盘Google早期用过的“黑科技”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/1a/4810c0eec6136b53c1e4774b8dfa0e1a.mp3"></audio>
在1991年我刚接触计算机的时候很多计算机还没有硬盘。整个操作系统都安装在5寸或者3.5寸的软盘里。不过,很快大部分计算机都开始用上了直接安装在主板上的机械硬盘。到了今天,更早的软盘早已经被淘汰了。在个人电脑和服务器里,更晚出现的光盘也已经很少用了。
机械硬盘的生命力仍然非常顽强。无论是作为个人电脑的数据盘,还是在数据中心里面用作海量数据的存储,机械硬盘仍然在被大量使用。不仅如此,随着成本的不断下降,机械硬盘还替代掉了很多传统的存储设备,比如,以前常常用来备份冷数据的磁带。
那这一讲里,我们就从机械硬盘的物理构造开始,从原理到应用剖析一下,看看我们可以怎么样用好机械硬盘。
## 拆解机械硬盘
上一讲里我们提到过机械硬盘的IOPS。我们说机械硬盘的IOPS大概只能做到每秒100次左右。那么这个100次究竟是怎么来的呢
我们把机械硬盘拆开来看一看看看它的物理构造是怎么样的你就自然知道为什么它的IOPS是100左右了。
我们之前看过整个硬盘的构造里面有接口有对应的控制电路版以及实际的I/O设备也就是我们的机械硬盘。这里我们就拆开机械硬盘部分来看一看。
<img src="https://static001.geekbang.org/resource/image/51/14/5146a2a881afb81b3a076e4974df8614.jpg" alt="">
一块机械硬盘是由盘面、磁头和悬臂三个部件组成的。下面我们一一来看每一个部件。
首先,自然是**盘面**Disk Platter。盘面其实就是我们实际存储数据的盘片。如果你剪开过软盘的外壳或者看过光盘DVD那你看到盘面应该很熟悉。盘面其实和它们长得差不多。
盘面本身通常是用的铝、玻璃或者陶瓷这样的材质做成的光滑盘片。然后,盘面上有一层磁性的涂层。我们的数据就存储在这个磁性的涂层上。盘面中间有一个受电机控制的转轴。这个转轴会控制我们的盘面去旋转。
我们平时买硬盘的时候经常会听到一个指标,叫作这个硬盘的**转速**。我们的硬盘有5400转的、7200转的乃至10000转的。这个多少多少转指的就是盘面中间电机控制的转轴的旋转速度英文单位叫**RPM**,也就是**每分钟的旋转圈数**Rotations Per Minute。所谓7200转其实更准确地说是7200RPM指的就是一旦电脑开机供电之后我们的硬盘就可以一直做到每分钟转上7200圈。如果折算到每一秒钟就是120圈。
说完了盘面,我们来看**磁头**Drive Head。我们的数据并不能直接从盘面传输到总线上而是通过磁头从盘面上读取到然后再通过电路信号传输给控制电路、接口再到总线上的。
通常,我们的一个盘面上会有两个磁头,分别在盘面的正反面。盘面在正反两面都有对应的磁性涂层来存储数据,而且一块硬盘也不是只有一个盘面,而是上下堆叠了很多个盘面,各个盘面之间是平行的。每个盘面的正反两面都有对应的磁头。
最后我们来看**悬臂**Actutor Arm。悬臂链接在磁头上并且在一定范围内会去把磁头定位到盘面的某个特定的磁道Track上。这个磁道是怎么来呢想要了解这个问题我们要先看一看我们的数据是怎么存放在盘面上的。
一个盘面通常是圆形的,由很多个同心圆组成,就好像是一个个大小不一样的“甜甜圈”嵌套在一起。每一个“甜甜圈”都是一个磁道。每个磁道都有自己的一个编号。悬臂其实只是控制,到底是读最里面那个“甜甜圈”的数据,还是最外面“甜甜圈”的数据。
<img src="https://static001.geekbang.org/resource/image/51/14/5146a2a881afb81b3a076e4974df8614.jpg" alt="">
知道了我们硬盘的物理构成,现在我们就可以看一看,这样的物理结构,到底是怎么来读取数据的。
我们刚才说的一个磁道会分成一个一个扇区Sector。上下平行的一个一个盘面的相同扇区呢我们叫作一个柱面Cylinder
读取数据,其实就是两个步骤。一个步骤,就是把盘面旋转到某一个位置。在这个位置上,我们的悬臂可以定位到整个盘面的某一个子区间。这个子区间的形状有点儿像一块披萨饼,我们一般把这个区间叫作**几何扇区**Geometrical Sector意思是在“几何位置上”所有这些扇区都可以被悬臂访问到。另一个步骤就是把我们的悬臂移动到特定磁道的特定扇区也就在这个“几何扇区”里面找到我们实际的扇区。找到之后我们的磁头会落下就可以读取到正对着扇区的数据。
<img src="https://static001.geekbang.org/resource/image/38/ca/384cf31520dc0d080490d627c3a209ca.jpg" alt="">
所以,我们进行一次硬盘上的随机访问,需要的时间由两个部分组成。
第一个部分,叫作**平均延时**Average Latency。这个时间其实就是把我们的盘面旋转把几何扇区对准悬臂位置的时间。这个时间很容易计算它其实就和我们机械硬盘的转速相关。随机情况下平均找到一个几何扇区我们需要旋转半圈盘面。上面7200转的硬盘那么一秒里面就可以旋转240个半圈。那么这个平均延时就是
第二个部分,叫作**平均寻道时间**Average Seek Time也就是在盘面选转之后我们的悬臂定位到扇区的的时间。我们现在用的HDD硬盘的平均寻道时间一般在4-10ms。
这样,我们就能够算出来,如果随机在整个硬盘上找一个数据,需要 8-14 ms。我们的硬盘是机械结构的只有一个电机转轴也只有一个悬臂所以我们没有办法并行地去定位或者读取数据。那一块7200转的硬盘我们一秒钟随机的IO访问次数也就是
现在你明白我们上一讲所说的HDD硬盘的IOPS每秒100次左右是怎么来的吧好了现在你再思考一个问题。如果我们不是去进行随机的数据访问而是进行顺序的数据读写我们应该怎么最大化读取效率呢
我们可以选择把顺序存放的数据尽可能地存放在同一个柱面上。这样我们只需要旋转一次盘面进行一次寻道就可以去写入或者读取同一个垂直空间上的多个盘面的数据。如果一个柱面上的数据不够我们也不要去动悬臂而是通过电机转动盘面这样就可以顺序读完一个磁道上的所有数据。所以其实对于HDD硬盘的顺序数据读写吞吐率还是很不错的可以达到200MB/s左右。
## Partial Stroking根据场景提升性能
只有100的IOPS其实很难满足现在互联网海量高并发的请求。所以今天的数据库都会把数据存储在SSD硬盘上。不过如果我们把时钟倒播20年那个时候我们可没有现在这么便宜的SSD硬盘。数据库里面的数据只能存放在HDD硬盘上。
今天即便是数据中心用的HDD硬盘一般也是7200转的因为如果要更快的随机访问速度我们会选择用SSD硬盘。但是在当时SSD硬盘价格非常昂贵还没有能够商业化。硬盘厂商们在不断地研发转得更快的硬盘。在数据中心里往往我们会用上10000转乃至15000转的硬盘。甚至直到2010年SSD硬盘已经开始逐步进入市场了西数还在尝试研发20000转的硬盘。转速更高、寻道时间更短的机械硬盘才能满足实际的数据库需求。
不过10000转乃至15000转的硬盘也更昂贵。如果你想要节约成本提高性价比那就得想点别的办法。你应该听说过Google早年用家用PC乃至二手的硬件通过软件层面的设计来解决可靠性和性能的问题。那么我们是不是也有什么办法能提高机械硬盘的IOPS呢
还真的有。这个方法,就叫作**Partial Stroking**或者**Short Stroking**。我没有看到过有中文资料给这个方法命名。在这里,我就暂时把它翻译成“**缩短行程**”技术。
其实这个方法的思路很容易理解,我一说你就明白了。既然我们访问一次数据的时间,是“平均延时+寻道时间”那么只要能缩短这两个之一不就可以提升IOPS了吗
一般情况下硬盘的寻道时间都比平均延时要长。那么我们自然就可以想一下有什么办法可以缩短平均的寻道时间。最极端的办法就是我们不需要寻道也就是说我们把所有数据都放在一个磁道上。比如我们始终把磁头放在最外道的磁道上。这样我们的寻道时间就基本为0访问时间就只有平均延时了。那样我们的IOPS就变成了
不过呢只用一个磁道我们能存的数据就比较有限了。这个时候可能我们还不如把这些数据直接都放到内存里面呢。所以实践当中我们可以只用1/2或者1/4的磁道也就是最外面1/4或者1/2的磁道。这样我们硬盘可以使用的容量可能变成了1/2或者1/4。但是呢我们的寻道时间也变成了1/4或者1/2因为悬臂需要移动的“行程”也变成了原来的1/2或者1/4我们的IOPS就能够大幅度提升了。
比如说我们一块7200转的硬盘正常情况下平均延时是4.17ms而寻道时间是9ms。那么它原本的IOPS就是
如果我们只用其中1/4的磁道那么它的IOPS就变成了
你看这个结果IOPS提升了一倍和一块15000转的硬盘的性能差不多了。不过这个情况下我们的硬盘能用的空间也只有原来的1/4了。不过要知道在当时同样容量的15000转的硬盘的价格可不止是7200转硬盘的4倍啊。所以这样通过软件去格式化硬盘只保留部分磁道让系统可用的情况可以大大提升硬件的性价比。
在2000-2010年这10年间正是这些奇思妙想让海量数据下的互联网蓬勃发展起来的。在没有SSD的硬盘的时候聪明的工程师们从硬件到软件设计了各种有意思的方案解决了我们遇到的各类性能问题。而对于计算机底层知识的深入了解也是能够找到这些解决办法的核心因素。
## 总结延伸
好了相信通过这一讲你对传统的HDD硬盘应该有了深入的了解。我们来总结一下。
机械硬盘的硬件,主要由盘面、磁头和悬臂三部分组成。我们的数据在盘面上的位置,可以通过磁道、扇区和柱面来定位。实际的一次对于硬盘的访问,需要把盘面旋转到某一个“几何扇区”,对准悬臂的位置。然后,悬臂通过寻道,把磁头放到我们实际要读取的扇区上。
受制于机械硬盘的结构我们对于随机数据的访问速度就要包含旋转盘面的平均延时和移动悬臂的寻道时间。通过这两个时间我们能计算出机械硬盘的IOPS。
7200转机械硬盘的IOPS只能做到100左右。在互联网时代的早期我们也没有SSD硬盘可以用所以工程师们就想出了Partial Stroking这个浪费存储空间但是可以缩短寻道时间来提升硬盘的IOPS的解决方案。这个解决方案也是一个典型的、在深入理解了硬件原理之后的软件优化方案。
## 推荐阅读
想要对机械硬盘的各种性能指标有更深入的理解你可以读一读Symantec写的Getting The Hang Of IOPS的白皮书以及后面的深入阅读内容对你应该会很有帮助。我把对应的[链接](https://www.symantec.com/connect/articles/getting-hang-iops-v13)放在这里,你可以看一看。
## 课后思考
如果是用更慢的5400转的硬盘使用Partial Stroking技术只使用一半的硬盘空间我们的IOPS能够提升多少呢
你可以拿出纸和笔算一算,然后把你的答案写在留言区。如果觉得有帮助,你可以把这篇文章分享给你的朋友,和他一起学习。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="46 | SSD硬盘如何完成性能优化的KPI" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/bf/d115f9c1f7af989a3a44e0fed9948dbf.mp3"></audio>
随着3D垂直封装技术和QLC技术的出现今年的“618”SSD硬盘的价格进一步大跳水趁着这个机会我把自己电脑上的仓库盘从HDD换成了SSD硬盘。我的个人电脑彻底摆脱了机械硬盘。
随着智能手机的出现互联网用户在2008年之后开始爆发性增长大家在网上花的时间也越来越多。这也就意味着隐藏在精美App和网页之后的服务端数据请求量呈数量级的上升。
无论是用10000转的企业级机械硬盘还是用Short Stroking这样的方式进一步提升IOPSHDD硬盘已经满足不了我们的需求了。上面这些优化措施无非就是把IOPS从100提升到300、500也就到头了。
于是SSD硬盘在2010年前后进入了主流的商业应用。我们在[第44讲](https://time.geekbang.org/column/article/113809)看过一块普通的SSD硬盘可以轻松支撑10000乃至20000的IOPS。那个时候不少互联网公司想要完成性能优化的KPI最后的解决方案都变成了换SSD的硬盘。如果这还不够那就换上使用PCI Express接口的SSD。
不过只是简单地换一下SSD硬盘真的最大限度地用好了SSD硬盘吗另外即便现在SSD硬盘很便宜了大部分公司的批量数据处理系统仍然在用传统的机械硬盘这又是为什么呢
那么接下来这两讲就请你和我一起来看一看SSD硬盘的工作原理以及怎么最大化利用SSD的工作原理使得访问的速度最快硬盘的使用寿命最长。
## SSD的读写原理
SSD没有像机械硬盘那样的寻道过程所以它的随机读写都更快。我在下面列了一个表格对比了一下SSD和机械硬盘的优缺点。
<img src="https://static001.geekbang.org/resource/image/a5/7c/a53e407311293609cb0753c7889a367c.jpeg" alt="">
你会发现不管是机械硬盘不擅长的随机读写还是它本身已经表现不错的顺序写入SSD在这些方面都要比HDD强。不过有一点机械硬盘要远强于SSD那就是耐用性。如果我们需要频繁地重复写入删除数据那么机械硬盘要比SSD性价比高很多。
要想知道为什么SSD的耐用性不太好我们先要理解SSD硬盘的存储和读写原理。我们之前说过CPU Cache用的SRAM是用一个电容来存放一个比特的数据。对于SSD硬盘我们也可以先简单地认为它是由一个电容加上一个电压计组合在一起记录了一个或者多个比特。
### SLC、MLC、TLC和QLC
能够记录一个比特很容易理解。给电容里面充上电有电压的时候就是1给电容放电里面没有电就是0。采用这样方式存储数据的SSD硬盘我们一般称之为**使用了SLC的颗粒**全称是Single-Level Cell也就是一个存储单元中只有一位数据。
<img src="https://static001.geekbang.org/resource/image/06/a7/0698c240459faa11254932905675dba7.jpeg" alt="">
但是这样的方式会遇到和CPU Cache类似的问题那就是同样的面积下能够存放下的元器件是有限的。如果只用SLC我们就会遇到存储容量上不去并且价格下不来的问题。于是呢硬件工程师们就陆续发明了**MLC**Multi-Level Cell、**TLC**Triple-Level Cell以及**QLC**Quad-Level Cell也就是能在一个电容里面存下2个、3个乃至4个比特。
<img src="https://static001.geekbang.org/resource/image/94/79/949106cb0ca5985a47388caef6925a79.jpeg" alt="">
只有一个电容我们怎么能够表示更多的比特呢别忘了这里我们还有一个电压计。4个比特一共可以从0000-1111表示16个不同的数。那么如果我们能往电容里面充电的时候充上15个不同的电压并且我们电压计能够区分出这15个不同的电压。加上电容被放空代表的0就能够代表从0000-1111这样4个比特了。
不过要想表示15个不同的电压充电和读取的时候对于精度的要求就会更高。这会导致充电和读取的时候都更慢所以QLC的SSD的读写速度要比SLC的慢上好几倍。如果你想要知道是什么样的物理原理导致这个QLC更慢可以去读一读这篇[文章](https://www.anandtech.com/show/5067/understanding-tlc-nand/2)。
### P/E擦写问题
如果我们去看一看SSD硬盘的硬件构造可以看到它大概是自顶向下是这么构成的。
<img src="https://static001.geekbang.org/resource/image/6a/5e/6ac3cfd51d39d3e3022effc7e4255e5e.jpeg" alt="">
首先自然和其他的I/O设备一样它有对应的**接口和控制电路**。现在的SSD硬盘用的是SATA或者PCI Express接口。在控制电路里有一个很重要的模块叫作**FTL**Flash-Translation Layer也就是**闪存转换层**。这个可以说是SSD硬盘的一个核心模块SSD硬盘性能的好坏很大程度上也取决于FTL的算法好不好。现在容我卖个关子我们晚一会儿仔细讲FTL的功能。
接下来是**实际I/O设备**它其实和机械硬盘很像。现在新的大容量SSD硬盘都是3D封装的了也就是说是由很多个裸片Die叠在一起的就好像我们的机械硬盘把很多个盘面Platter叠放再一起一样这样可以在同样的空间下放下更多的容量。
<img src="https://static001.geekbang.org/resource/image/0e/d3/0eee44535a925825b657bcac6afb72d3.jpeg" alt="">
接下来,一张裸片上可以放多个**平面**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就只在几千次了。这也是为什么你去购买SSD硬盘会看到同样的容量的价格差别很大因为它们的芯片颗粒和寿命完全不一样。
### SSD读写的生命周期
下面我们来实际看一看一块SSD硬盘在日常是怎么被用起来的。
我用三种颜色分别来表示SSD硬盘里面的页的不同状态白色代表这个页从来没有写入过数据绿色代表里面写入的是有效的数据红色代表里面的数据在我们的操作系统看来已经是删除的了。
<img src="https://static001.geekbang.org/resource/image/96/81/966e51db8354922b533e1db236337e81.jpeg" alt="">
一开始,所有块的每一个页都是白色的。随着我们开始往里面写数据,里面的有些页就变成了绿色。
然后因为我们删除了硬盘上的一些文件所以有些页变成了红色。但是这些红色的页并不能再次写入数据。因为SSD硬盘不能单独擦除一个页必须一次性擦除整个块所以新的数据我们只能往后面的白色的页里面写。这些散落在各个绿色空间里面的红色空洞就好像硬盘碎片。
如果有哪一个块的数据一次性全部被标红了那我们就可以把整个块进行擦除。它就又会变成白色可以重新一页一页往里面写数据。这种情况其实也会经常发生。毕竟一个块不大也就在几百KB到几MB。你删除一个几MB的文件数据又是连续存储的自然会导致整个块可以被擦除。
随着硬盘里面的数据越来越多红色空洞占的地方也会越来越多。于是你会发现我们就要没有白色的空页去写入数据了。这个时候我们要做一次类似于Windows里面“磁盘碎片整理”或者Java里面的“内存垃圾回收”工作。找一个红色空洞最多的块把里面的绿色数据挪到另一个块里面去然后把整个块擦除变成白色可以重新写入数据。
不过这个“磁盘碎片整理”或者“内存垃圾回收”的工作我们不能太主动、太频繁地去做。因为SSD的擦除次数是有限的。如果动不动就搞个磁盘碎片整理那么我们的SSD硬盘很快就会报废了。
说到这里你可能要问了这是不是说我们的SSD硬盘的容量是用不满的因为我们总会遇到一些红色空洞
<img src="https://static001.geekbang.org/resource/image/e7/74/e7fcd994384145eefde614aaf3b45874.jpeg" alt="">
没错一块SSD的硬盘容量是没办法完全用满的。不过为了不得罪消费者生产SSD硬盘的厂商其实是预留了一部分空间专门用来做这个“磁盘碎片整理”工作的。一块标成240G的SSD硬盘往往实际有256G的硬盘空间。SSD硬盘通过我们的控制芯片电路把多出来的硬盘空间用来进行各种数据的闪转腾挪让你能够写满那240G的空间。这个多出来的16G空间叫作**预留空间**Over Provisioning一般SSD的硬盘的预留空间都在7%-15%左右。
## 总结延伸
到这里相信你对SSD硬盘的写入和擦除的原理已经清楚了也明白了SSD硬盘的使用寿命受限于可以擦除的次数。
仔细想一想你会发现SSD硬盘特别适合读多写少的应用。在日常应用里面我们的系统盘适合用SSD。但是如果我们用SSD做专门的下载盘一直下载各种影音数据然后刻盘备份就不太好了特别是现在QLC颗粒的SSD它只有几千次可擦写的寿命啊。
在数据中心里面SSD的应用场景也是适合读多写少的场景。我们拿SSD硬盘用来做数据库存放电商网站的商品信息很合适。但是用来作为Hadoop这样的Map-Reduce应用的数据盘就不行了。因为Map-Reduce任务会大量在任务中间向硬盘写入中间数据再删除掉这样用不了多久SSD硬盘的寿命就会到了。
好了,最后让我们总结一下。
这一讲我们从SSD的物理原理也就是“电容+电压计”的组合向你介绍了SSD硬盘存储数据的原理以及从SLC、MLC、TLC直到今天的QLC颗粒是怎么回事儿。
然后我们一起看了SSD硬盘的物理构造也就是裸片、平面、块、页的层次结构。我们对于数据的写入只能是一页一页的不能对页进行覆写。对于数据的擦除只能整块进行。所以我们需要用一个类似“磁盘碎片整理”或者“内存垃圾回收”这样的机制来清理块当中的数据空洞。而SSD硬盘也会保留一定的预留空间避免出现硬盘无法写满的情况。
到了这里我们SSD硬盘在硬件层面的写入机制就介绍完了。不过更有挑战的一个问题是在这样的机制下我们怎么尽可能延长SSD的使用寿命呢如果要开发一个跑在SSD硬盘上的数据库我们可以利用SSD的哪些特性呢想要知道这些请你一定要记得回来听下一讲。
## 推荐阅读
想要对于SSD的硬件实现原理有所了解我推荐你去读一读这一篇[Understand TLC NAND](https://www.anandtech.com/show/5067/understanding-tlc-nand)。
## 课后思考
现在大家使用的数据系统里往往会有日志系统。你觉得日志系统适合存放在SSD硬盘上吗
欢迎在留言区写下你的思考。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="47 | SSD硬盘如何完成性能优化的KPI" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/9b/419a50a07dc8cbd56d479512ed3c9c9b.mp3"></audio>
如果你平时用的是Windows电脑你会发现用了SSD的系统盘就不能用磁盘碎片整理功能。这是因为一旦主动去运行磁盘碎片整理功能就会发生一次块的擦除对应块的寿命就少了一点点。这个SSD的擦除寿命的问题不仅会影响像磁盘碎片整理这样的功能其实也很影响我们的日常使用。
我们的操作系统上并没有SSD硬盘上各个块目前已经擦写的情况和寿命所以它对待SSD硬盘和普通的机械硬盘没有什么区别。
我们日常使用PC进行软件开发的时候会先在硬盘上装上操作系统和常用软件比如Office或者工程师们会装上VS Code、WebStorm这样的集成开发环境。这些软件所在的块写入一次之后就不太会擦除了所以就只有读的需求。
一旦开始开发我们就会不断添加新的代码文件还会不断修改已经有的代码文件。因为SSD硬盘没有覆写Override的功能所以这个过程中其实我们是在反复地写入新的文件然后再把原来的文件标记成逻辑上删除的状态。等SSD里面空的块少了我们会用“垃圾回收”的方式进行擦除。这样我们的擦除会反复发生在这些用来存放数据的地方。
<img src="https://static001.geekbang.org/resource/image/09/6e/09a9566eae60610b0f49d7e24ce4ee6e.jpeg" alt="">
有一天,这些块的擦除次数到了,变成了坏块。但是,我们安装操作系统和软件的地方还没有坏,而这块硬盘的可以用的容量却变小了。
## 磨损均衡、TRIM和写入放大效应
### FTL和磨损均衡
那么,我们有没有什么办法,不让这些坏块那么早就出现呢?我们能不能,匀出一些存放操作系统的块的擦写次数,给到这些存放数据的地方呢?
相信你一定想到了其实我们要的就是想一个办法让SSD硬盘各个块的擦除次数均匀分摊到各个块上。这个策略呢就叫作**磨损均衡**Wear-Leveling。实现这个技术的核心办法和我们前面讲过的虚拟内存一样就是添加一个间接层。这个间接层就是我们上一讲给你卖的那个关子就是FTL这个**闪存转换层**。
<img src="https://static001.geekbang.org/resource/image/6e/91/6e78f8da0320dc9b392b9d35ecf42091.jpeg" alt="">
就像在管理内存的时候我们通过一个页表映射虚拟内存页和物理页一样在FTL里面存放了**逻辑块地址**Logical Block Address简称LBA到**物理块地址**Physical Block Address简称PBA的映射。
操作系统访问的硬盘地址其实都是逻辑地址。只有通过FTL转换之后才会变成实际的物理地址找到对应的块进行访问。操作系统本身不需要去考虑块的磨损程度只要和操作机械硬盘一样来读写数据就好了。
操作系统所有对于SSD硬盘的读写请求都要经过FTL。FTL里面又有逻辑块对应的物理块所以FTL能够记录下来每个物理块被擦写的次数。如果一个物理块被擦写的次数多了FTL就可以将这个物理块挪到一个擦写次数少的物理块上。但是逻辑块不用变操作系统也不需要知道这个变化。
这也是我们在设计大型系统中的一个典型思路也就是各层之间是隔离的操作系统不需要考虑底层的硬件是什么完全交由硬件的控制电路里面的FTL来管理对于实际物理硬件的写入。
### TRIM指令的支持
不过操作系统不去关心实际底层的硬件是什么在SSD硬盘的使用上也会带来一个问题。这个问题就是操作系统的逻辑层和SSD的逻辑层里的块状态是不匹配的。
我们在操作系统里面去删除一个文件其实并没有真的在物理层面去删除这个文件只是在文件系统里面把对应的inode里面的元信息清理掉这代表这个inode还可以继续使用可以写入新的数据。这个时候实际物理层面的对应的存储空间在操作系统里面被标记成可以写入了。
所以,其实我们日常的文件删除,都只是一个操作系统层面的逻辑删除。这也是为什么,很多时候我们不小心删除了对应的文件,我们可以通过各种恢复软件,把数据找回来。同样的,这也是为什么,如果我们想要删除干净数据,需要用各种“文件粉碎”的功能才行。
这个删除的逻辑在机械硬盘层面没有问题因为文件被标记成可以写入后续的写入可以直接覆写这个位置。但是在SSD硬盘上就不一样了。我在这里放了一张详细的示意图。我们下面一起来看看具体是怎么回事儿。
<img src="https://static001.geekbang.org/resource/image/72/d7/72b3fc74ff567e7a0ec1f4071da946d7.jpeg" alt="">
一开始操作系统里面有好几个文件不同的文件我用不同的颜色标记出来了。下面的SSD的逻辑块里面占用的页我们也用同样的颜色标记出来文件占用的对应页。
当我们在操作系统里面,删除掉一个刚刚下载的文件,比如标记成黄色 openjdk.exe 这样一个jdk的安装文件在操作系统里面对应的inode里面就没有文件的元信息。
但是这个时候我们的SSD的逻辑块层面其实并不知道这个事情。所以在逻辑块层面openjdk.exe 仍然是占用了对应的空间。对应的物理页,也仍然被认为是被占用了的。
这个时候如果我们需要对SSD进行垃圾回收操作openjdk.exe 对应的物理页仍然要在这个过程中被搬运到其他的Block里面去。只有当操作系统再在刚才的inode里面写入数据的时候我们才会知道原来的些黄色的页其实都已经没有用了我们才会把它标记成废弃掉。
所以在使用SSD的硬盘情况下你会发现操作系统对于文件的删除SSD硬盘其实并不知道。这就导致我们为了磨损均衡很多时候在都在搬运很多已经删除了的数据。这就会产生很多不必要的数据读写和擦除既消耗了SSD的性能也缩短了SSD的使用寿命。
为了解决这个问题现在的操作系统和SSD的主控芯片都支持**TRIM命令。**这个命令可以在文件被删除的时候让操作系统去通知SSD硬盘对应的逻辑块已经标记成已删除了。现在的SSD硬盘都已经支持了TRIM命令。无论是Linux、Windows还是MacOS这些操作系统也都已经支持了TRIM命令了。
### 写入放大
其实TRIM命令的发明也反应了一个使用SSD硬盘的问题那就是SSD硬盘容易越用越慢。
当SSD硬盘的存储空间被占用得越来越多每一次写入新数据我们都可能没有足够的空白。我们可能不得不去进行垃圾回收合并一些块里面的页然后再擦除掉一些页才能匀出一些空间来。
这个时候从应用层或者操作系统层面来看我们可能只是写入了一个4KB或者4MB的数据。但是实际通过FTL之后我们可能要去搬运8MB、16MB甚至更多的数据。
我们通过“**实际的闪存写入的数据量 / 系统通过FTL写入的数据量 = 写入放大**”可以得到写入放大的倍数越多意味着实际的SSD性能也就越差会远远比不上实际SSD硬盘标称的指标。
而解决写入放大,需要我们在后台定时进行垃圾回收,在硬盘比较空闲的时候,就把搬运数据、擦除数据、留出空白的块的工作做完,而不是等实际数据写入的时候,再进行这样的操作。
## AeroSpike如何最大化SSD的使用效率
讲到这里相信你也发现了想要把SSD硬盘用好其实没有那么简单。如果我们只是简单地拿一块SSD硬盘替换掉原来的HDD硬盘而不是从应用层面考虑任何SSD硬盘特性的话我们多半还是没法获得想要的性能提升。
不过既然清楚了SSD硬盘的各种特性我们就可以依据这些特性来设计我们的应用。接下来我就带你一起看一看AeroSpike这个专门针对SSD硬盘特性设计的Key-Value数据库键值对数据库是怎么利用这些物理特性的。
首先AeroSpike操作SSD硬盘并没有通过操作系统的文件系统。而是直接操作SSD里面的块和页。因为操作系统里面的文件系统对于KV数据库来说只是让我们多了一层间接层只会降低性能对我们没有什么实际的作用。
其次AeroSpike在读写数据的时候做了两个优化。在写入数据的时候AeroSpike尽可能去写一个较大的数据块而不是频繁地去写很多小的数据块。这样硬盘就不太容易频繁出现磁盘碎片。并且一次性写入一个大的数据块也更容易利用好顺序写入的性能优势。AeroSpike写入的一个数据块是128KB远比一个页的4KB要大得多。
另外在读取数据的时候AeroSpike倒是可以读取512字节Bytes这样的小数据。因为SSD的随机读取性能很好也不像写入数据那样有擦除寿命问题。而且很多时候我们读取的数据是键值对里面的值的数据这些数据要在网络上传输。如果一次性必须读出比较大的数据就会导致我们的网络带宽不够用。
因为AeroSpike是一个对于响应时间要求很高的实时KV数据库如果出现了严重的写放大效应会导致写入数据的响应时间大幅度变长。所以AeroSpike做了这样几个动作
第一个是持续地进行磁盘碎片整理。AeroSpike用了所谓的高水位High Watermark算法。其实这个算法很简单就是一旦一个物理块里面的数据碎片超过50%,就把这个物理块搬运压缩,然后进行数据擦除,确保磁盘始终有足够的空间可以写入。
第二个是在AeroSpike给出的最佳实践中为了保障数据库的性能建议你只用到SSD硬盘标定容量的一半。也就是说我们人为地给SSD硬盘预留了50%的预留空间以确保SSD硬盘的写放大效应尽可能小不会影响数据库的访问性能。
<img src="https://static001.geekbang.org/resource/image/35/60/354d34d871dda3ef5a4792a1fe1fb860.jpeg" alt="">
正是因为做了这种种的优化在NoSQL数据库刚刚兴起的时候AeroSpike的性能把Cassandra、MongoDB这些数据库远远甩在身后和这些数据库之间的性能差距有时候会到达一个数量级。这也让AeroSpike成为了当时高性能KV数据库的标杆。你可以看一看InfoQ出的这个[Benchmark](https://www.infoq.com/news/2013/04/NoSQL-Benchmark/)里面有2013年的时候这几个NoSQL数据库巨大的性能差异。
## 总结延伸
好了,现在让我们一起来总结一下今天的内容。
因为SSD硬盘的使用寿命受限于块的擦除次数所以我们需要通过一个磨损均衡的策略来管理SSD硬盘的各个块的擦除次数。我们通过在逻辑块地址和物理块地址之间引入FTL这个映射层使得操作系统无需关心物理块的擦写次数而是由FTL里的软件算法来协调到底每一次写入应该磨损哪一块。
除了磨损均衡之外操作系统和SSD硬件的特性还有一个不匹配的地方。那就是操作系统在删除数据的时候并没有真的删除物理层面的数据而只是修改了inode里面的数据。这个“伪删除”使得SSD硬盘在逻辑和物理层面都没有意识到有些块其实已经被删除了。这就导致在垃圾回收的时候会浪费很多不必要的读写资源。
SSD这个需要进行垃圾回收的特性使得我们在写入数据的时候会遇到写入放大。明明我们只是写入了4MB的数据可能在SSD的硬件层面实际写入了8MB、16MB乃至更多的数据。
针对这些特性AeroSpike这个专门针对SSD硬盘特性的KV数据库设计了很多的优化点包括跳过文件系统直写硬盘、写大块读小块、用高水位算法持续进行磁盘碎片整理以及只使用SSD硬盘的一半空间。这些策略使得AeroSpike的性能在早年间远远超过了Cassandra等其他NoSQL数据库。
可以看到,针对硬件特性设计的软件,才能最大化发挥我们的硬件性能。
## 推荐阅读
如果你想要基于SSD硬盘本身的特性来设计开发你的系统我推荐你去读一读AeroSpike的这个[PPT](https://www.slideshare.net/AerospikeDB/getting-the-most-out-of-your-flashssds)。AeroSpike是市面上最优秀的KV数据库之一通过深入地利用了SSD本身的硬件特性最大化提升了作为一个KV数据库的性能。真正在进行系统软件开发的时候了解硬件是必不可少的一个环节。
## 课后思考
在SSD硬盘的价格大幅度下降了之后LFS也就是Log-Structured File System在业界出现了第二春。你可以去了解一下什么是LFS以及为什么LFS特别适合SSD硬盘。
欢迎在留言区分享你了解到的信息,和大家一起交流。如果有收获,你可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,149 @@
<audio id="audio" title="48 | DMA为什么Kafka这么快" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/a2/6f237ba836afd7ef908a8927408a8ea2.mp3"></audio>
过去几年里整个计算机产业界都在尝试不停地提升I/O设备的速度。把HDD硬盘换成SSD硬盘我们仍然觉得不够快用PCI Express接口的SSD硬盘替代SATA接口的SSD硬盘我们还是觉得不够快所以现在就有了傲腾Optane这样的技术。
但是无论I/O速度如何提升比起CPU总还是太慢。SSD硬盘的IOPS可以到2万、4万但是我们CPU的主频有2GHz以上也就意味着每秒会有20亿次的操作。
如果我们对于I/O的操作都是由CPU发出对应的指令然后等待I/O设备完成操作之后返回那CPU有大量的时间其实都是在等待I/O设备完成操作。
但是这个CPU的等待在很多时候其实并没有太多的实际意义。我们对于I/O设备的大量操作其实都只是把内存里面的数据传输到I/O设备而已。在这种情况下其实CPU只是在傻等而已。特别是当传输的数据量比较大的时候比如进行大文件复制如果所有数据都要经过CPU实在是有点儿太浪费时间了。
因此计算机工程师们就发明了DMA技术也就是**直接内存访问**Direct Memory Access技术来减少CPU等待的时间。
## 理解DMA一个协处理器
其实DMA技术很容易理解本质上DMA技术就是我们在主板上放一块独立的芯片。在进行内存和I/O设备的数据传输的时候我们不再通过CPU来控制数据传输而直接通过**DMA控制器**DMA Controller简称DMAC。这块芯片我们可以认为它其实就是一个**协处理器**Co-Processor
**DMAC最有价值的地方体现在当我们要传输的数据特别大、速度特别快或者传输的数据特别小、速度特别慢的时候。**
比如说我们用千兆网卡或者硬盘传输大量数据的时候如果都用CPU来搬运的话肯定忙不过来所以可以选择DMAC。而当数据传输很慢的时候DMAC可以等数据到齐了再发送信号给到CPU去处理而不是让CPU在那里忙等待。
好了现在你应该明白DMAC的价值知道了它适合用在什么情况下。那我们现在回过头来看。我们上面说DMAC是一块“协处理器芯片”这是为什么呢
注意这里面的“协”字。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的。
<img src="https://static001.geekbang.org/resource/image/99/bc/9998b67238044aad60d2aa0735b98ebc.jpeg" alt="">
不过DMAC就很有意思了它既是一个主设备又是一个从设备。对于CPU来说它是一个从设备对于硬盘这样的IO设备来说呢它又变成了一个主设备。那使用DMAC进行数据传输的过程究竟是什么样的呢下面我们来具体看看。
1.首先CPU还是作为一个主设备向DMAC设备发起请求。这个请求其实就是在DMAC里面修改配置寄存器。
2.CPU修改DMAC的配置的时候会告诉DMAC这样几个信息
<li>首先是**源地址的初始值以及传输时候的地址增减方式**。<br>
所谓源地址就是数据要从哪里传输过来。如果我们要从内存里面写入数据到硬盘上那么就是要读取的数据在内存里面的地址。如果是从硬盘读取数据到内存里那就是硬盘的I/O接口的地址。<br>
我们讲过总线的时候说过I/O的地址可以是一个内存地址也可以是一个端口地址。而地址的增减方式就是说数据是从大的地址向小的地址传输还是从小的地址往大的地址传输。</li>
- 其次是**目标地址初始值和传输时候的地址增减方式**。目标地址自然就是和源地址对应的设备,也就是我们数据传输的目的地。
- 第三个自然是**要传输的数据长度**,也就是我们一共要传输多少数据。
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被叫作“协处理器”。
<img src="https://static001.geekbang.org/resource/image/c9/8e/c9ed34b47b0cd33867c581772d8eff8e.jpeg" alt="">
最早计算机里是没有DMAC的所有数据都是由CPU来搬运的。随着人们对于数据传输的需求越来越多先是出现了主板上独立的DMAC控制器。到了今天各种I/O设备越来越多数据传输的需求越来越复杂使用的场景各不相同。加之显示器、网卡、硬盘对于数据传输的需求都不一样所以各个设备里面都有自己的DMAC芯片了。
## 为什么那么快一起来看Kafka的实现原理
了解了DMAC是怎么回事儿那你可能要问了这和我们实际进行程序开发有什么关系呢有什么API我们直接调用一下就能加速数据传输减少CPU占用吗
你还别说过去几年的大数据浪潮里面还真有一个开源项目很好地利用了DMA的数据传输方式通过DMA的方式实现了非常大的性能提升。这个项目就是**Kafka**。下面我们就一起来看看它究竟是怎么利用DMA的。
Kafka是一个用来处理实时数据的管道我们常常用它来做一个消息队列或者用来收集和落地海量的日志。作为一个处理实时数据和日志的管道瓶颈自然也在I/O层面。
Kafka里面会有两种常见的海量数据传输的情况。一种是从网络中接收上游的数据然后需要落地到本地的磁盘上确保数据不丢失。另一种情况呢则是从本地磁盘上读取出来通过网络发送出去。
我们来看一看后一种情况从磁盘读数据发送到网络上去。如果我们自己写一个简单的程序最直观的办法自然是用一个文件读操作从磁盘上把数据读到内存里面来然后再用一个Socket把这些数据发送到网络上去。
```
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
```
在这个过程中数据一共发生了四次传输的过程。其中两次是DMA的传输另外两次则是通过CPU控制的传输。下面我们来具体看看这个过程。
第一次传输是从硬盘上读到操作系统内核的缓冲区里。这个传输是通过DMA搬运的。
第二次传输需要从内核缓冲区里面的数据复制到我们应用分配的内存里面。这个传输是通过CPU搬运的。
第三次传输要从我们应用的内存里面再写到操作系统的Socket的缓冲区里面去。这个传输还是由CPU搬运的。
最后一次传输需要再从Socket的缓冲区里面写到网卡的缓冲区里面去。这个传输又是通过DMA搬运的。
<img src="https://static001.geekbang.org/resource/image/e0/d5/e0e85505e793e804e3b396fc50871cd5.jpg" alt="">
这个时候你可以回过头看看这个过程。我们只是要“搬运”一份数据结果却整整搬运了四次。而且这里面从内核的读缓冲区传输到应用的内存里再从应用的内存里传输到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写入到网卡的缓冲区里面。于是在这个过程之中我们只进行了两次数据传输。
<img src="https://static001.geekbang.org/resource/image/59/ab/596042d111ad9b871045d970a10464ab.jpg" alt="">
第一次是通过DMA从硬盘直接读到操作系统内核的读缓冲区里面。第二次则是根据Socket的描述符信息直接从读缓冲区里面写入到网卡的缓冲区里面。
这样我们同一份数据传输的次数从四次变成了两次并且没有通过CPU来进行数据搬运所有的数据都是通过DMA来进行传输的。
在这个方法里面我们没有在内存层面去“复制Copy”数据所以这个方法也被称之为**零拷贝**Zero-Copy
IBM Developer Works里面有一篇文章专门写过程序来测试过在同样的硬件下使用零拷贝能够带来的性能提升。我在这里放上这篇文章[链接](https://developer.ibm.com/articles/j-zerocopy/)。在这篇文章最后你可以看到无论传输数据量的大小传输同样的数据使用了零拷贝能够缩短65%的时间,大幅度提升了机器传输数据的吞吐量。想要深入了解零拷贝,建议你可以仔细读一读这篇文章。
## 总结延伸
讲到这里相信你对DMA的原理、作用和效果都有所理解了。那么我们一起来回顾总结一下。
如果我们始终让CPU来进行各种数据传输工作会特别浪费。一方面我们的数据传输工作用不到多少CPU核心的“计算”功能。另一方面CPU的运转速度也比I/O操作要快很多。所以我们希望能够给CPU“减负”。
于是工程师们就在主板上放上了DMAC这样一个协处理器芯片。通过这个芯片CPU只需要告诉DMAC我们要传输什么数据从哪里来到哪里去就可以放心离开了。后续的实际数据传输工作都会由DMAC来完成。随着现代计算机各种外设硬件越来越多光一个通用的DMAC芯片不够了我们在各个外设上都加上了DMAC芯片使得CPU很少再需要关心数据传输的工作了。
在我们实际的系统开发过程中利用好DMA的数据传输机制也可以大幅提升I/O的吞吐率。最典型的例子就是Kafka。
传统地从硬盘读取数据然后再通过网卡向外发送我们需要进行四次数据传输其中有两次是发生在内存里的缓冲区和对应的硬件设备之间我们没法节省掉。但是还有两次完全是通过CPU在内存里面进行数据复制。
在Kafka里通过Java的NIO里面FileChannel的transferTo方法调用我们可以不用把数据复制到我们应用程序的内存里面。通过DMA的方式我们可以把数据从内存缓冲区直接写到网卡的缓冲区里面。在使用了这样的零拷贝的方法之后呢我们传输同样数据的时间可以缩减为原来的1/3相当于提升了3倍的吞吐率。
这也是为什么Kafka是目前实时数据传输管道的标准解决方案。
## 推荐阅读
学完了这一讲之后我推荐你阅读一下Kafka的论文[Kakfa:a Distrubted Messaging System for Log Processing](http://notes.stephenholiday.com/Kafka.pdf)。Kafka的论文其实非常简单易懂是一个很好的让你了解系统、日志、分布式系统的入门材料。
如果你想要进一步去了解Kafka也可以订阅极客时间的专栏“[Kafka核心技术与实战](https://time.geekbang.org/column/intro/191)”。
## 课后思考
你可以自己尝试写一段使用零拷贝和不使用零拷贝传输数据的代码,然后看一看两者之间的性能差异。你可以看看,零拷贝能够带来多少吞吐量提升。
欢迎你把你运行程序的结果写在留言区和大家一起讨论、分享。你也可以把这个问题分享给你的朋友一起试一试看看DMA和零拷贝是否真的可以大幅度提升性能。

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="49 | 数据完整性(上):硬件坏了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/06/cde58524b1f00cf943e1f7ed2834a906.mp3"></audio>
2012年的时候我第一次在工作中遇到一个因为硬件的不可靠性引发的Bug。正是因为这个Bug让我开始逐步花很多的时间去复习回顾整个计算机系统里面的底层知识。
当时我正在MediaV带领一个20多人的团队负责公司的广告数据和机器学习算法。其中有一部分工作就是用Hadoop集群处理所有的数据和报表业务。当时我们的业务增长很快所以会频繁地往Hadoop集群里面添置机器。2012年的时候国内的云计算平台还不太成熟所以我们都是自己采购硬件放在托管的数据中心里面。
那个时候我们的Hadoop集群服务器在从100台服务器往1000台服务器走。我们觉得像Dell这样品牌厂商的服务器太贵了而且能够提供的硬件配置和我们的期望也有差异。于是运维的同学开始和OEM厂商合作自己定制服务器批量采购硬盘、内存。
那个时候大家都听过Google早期发展时为了降低成本买了很多二手的硬件来降低成本通过分布式的方式来保障系统的可靠性的办法。虽然我们还没有抠门到去买二手硬件不过当时我们选择购买了普通的机械硬盘而不是企业级的、用在数据中心的机械硬盘采购了普通的内存条而不是带ECC纠错的服务器内存条想着能省一点儿是一点儿。
## 单比特翻转:软件解决不了的硬件错误
忽然有一天,我们最大的、每小时执行一次的数据处理报表应用,完成时间变得比平时晚了不少。一开始,我们并没有太在意,毕竟当时数据量每天都在增长,慢一点就慢一点了。但是,接着糟糕的事情开始发生了。
一方面,我们发现,报表任务有时候在一个小时之内执行不完,接着,偶尔整个报表任务会执行失败。于是,我们不得不停下手头开发的工作,开始排查这个问题。
用过Hadoop的话你可能知道作为一个分布式的应用考虑到硬件的故障Hadoop本身会在特定节点计算出错的情况下重试整个计算过程。之前的报表跑得慢就是因为有些节点的计算任务失败过只是在重试之后又成功了。进一步分析我们发现程序的错误非常奇怪。有些数据计算的结果比如“34+23”结果应该是“57”但是却变成了一个美元符号“$”。
前前后后折腾了一周,我们发现,从日志上看,大部分出错的任务都在几个固定的硬件节点上。
另一方面我们发现问题出现在我们新的一批自己定制的硬件上架之后。于是和运维团队的同事沟通近期的硬件变更并且翻阅大量Hadoop社区的邮件组列表之后我们有了一个大胆的推测。
我们推测这个错误来自我们自己定制的硬件。定制的硬件没有使用ECC内存在大量的数据中内存中出现了**单比特翻转**Single-Bit Flip这个传说中的硬件错误。
那这个符号是怎么来的呢?是由于内存中的一个整数字符,遇到了一次单比特翻转转化而来的。 它的ASCII码二进制表示是0010 0100所以它完全可能来自 0011 0100 遇到一次在第4个比特的单比特翻转也就是从整数“4”变过来的。但是我们也只能**推测**是这个错误,而不能**确信**是这个错误。因为单比特翻转是一个随机现象,我们没法稳定复现这个问题。
<img src="https://static001.geekbang.org/resource/image/45/0f/45ad4eb91f48afd08c581148d5f6320f.jpeg" alt="">
**ECC内存**的全称是Error-Correcting Code memory中文名字叫作**纠错内存**。顾名思义,就是在内存里面出现错误的时候,能够自己纠正过来。
在和运维同学沟通之后我们把所有自己定制的服务器的内存替换成了ECC内存之后这个问题就消失了。这也使得我们基本确信问题的来源就是因为没有使用ECC内存。我们所有工程师的开发用机在2012年也换成了32G内存。是的换下来的内存没有别的去处都安装到了研发团队的开发机上。
## 奇偶校验和校验位:捕捉错误的好办法
其实,内存里面的单比特翻转或者错误,并不是一个特别罕见的现象。无论是因为内存的制造质量造成的漏电,还是外部的射线,都有一定的概率,会造成单比特错误。而内存层面的数据出错,软件工程师并不知道,而且这个出错很有可能是随机的。遇上随机出现难以重现的错误,大家肯定受不了。我们必须要有一个办法,避免这个问题。
其实在ECC内存发明之前工程师们已经开始通过**奇偶校验**的方式,来发现这些错误。
奇偶校验的思路很简单。我们把内存里面的N位比特当成是一组。常见的比如8位就是一个字节。然后用额外的一位去记录这8个比特里面有奇数个1还是偶数个1。如果是奇数个1那额外的一位就记录为1如果是偶数个1那额外的一位就记录成0。那额外的一位我们就称之为**校验码位**。
<img src="https://static001.geekbang.org/resource/image/e9/40/e94c642bdf41290d6a4e5eb2d6bb3c40.jpeg" alt="">
如果在这个字节里面,我们不幸发生了单比特翻转,那么数据位计算得到的校验码,就和实际校验位里面的数据不一样。我们的内存就知道出错了。
除此之外校验位有一个很大的优点就是计算非常快往往只需要遍历一遍需要校验的数据通过一个O(N)的时间复杂度的算法,就能把校验结果计算出来。
校验码的思路,在很多地方都会用到。
比方说我们下载一些软件的时候你会看到除了下载的包文件还会有对应的MD5这样的哈希值或者循环冗余编码CRC的校验文件。这样当我们把对应的软件下载下来之后我们可以计算一下对应软件的校验码和官方提供的校验码去做个比对看看是不是一样。
如果不一样,你就不能轻易去安装这个软件了。因为有可能,这个软件包是坏的。但是,还有一种更危险的情况,就是你下载的这个软件包,可能是被人植入了后门的。安装上了之后,你的计算机的安全性就没有保障了。
不过,使用奇偶校验,还是有两个比较大的缺陷。
第一个缺陷就是奇偶校验只能解决遇到单个位的错误或者说奇数个位的错误。如果出现2个位进行了翻转那么这个字节的校验位计算结果其实没有变我们的校验位自然也就不能发现这个错误。
第二个缺陷,是它只能发现错误,但是不能纠正错误。所以,即使在内存里面发现数据错误了,我们也只能中止程序,而不能让程序继续正常地运行下去。如果这个只是我们的个人电脑,做一些无关紧要的应用,这倒是无所谓了。
但是,你想一下,如果你在服务器上进行某个复杂的计算任务,这个计算已经跑了一周乃至一个月了,还有两三天就跑完了。这个时候,出现内存里面的错误,要再从头跑起,估计你内心是崩溃的。
所以我们需要一个比简单的校验码更好的解决方案一个能够发现更多位的错误并且能够把这些错误纠正过来的解决方案也就是工程师们发明的ECC内存所使用的解决方案。
我们不仅能捕捉到错误,还要能够纠正发生的错误。这个策略,我们通常叫作**纠错码**Error Correcting Code。它还有一个升级版本叫作**纠删码**Erasure Code不仅能够纠正错误还能够在错误不能纠正的时候直接把数据删除。无论是我们的ECC内存还是网络传输乃至硬盘的RAID其实都利用了纠错码和纠删码的相关技术。
想要看看我们怎么通过算法,怎么配置硬件,使得我们不仅能够发现单个位的错误,而能发现更多位的错误,你一定要记得跟上下一讲的内容。
## 总结延伸
好了,让我们一起来总结一下今天的内容。
我给你介绍了我自己亲身经历的一个硬件错误带来的Bug。由于没有采用ECC内存导致我们的数据处理中出现了大量的单比特数据翻转的错误。这些硬件带来的错误其实我们没有办法在软件层面解决。
如果对于硬件以及硬件本身的原理不够熟悉,恐怕这个问题的解决方案还是遥遥无期。如果你对计算机组成原理有所了解,并能够意识到,在硬件的存储层有着数据验证和纠错的需求,那你就能在有限的时间内定位到问题所在。
进一步地,我为你简单介绍了奇偶校验,也就是如何通过冗余的一位数据,发现在硬件层面出现的位错误。但是,奇偶校验以及其他的校验码,只能发现错误,没有办法纠正错误。所以,下一讲,我们一起来看看,怎么利用纠错码这样的方式,来解决问题。
## 推荐阅读
我推荐你去深入阅读一下Wikipedia里面关于[CRC](https://en.wikipedia.org/wiki/Cyclic_redundancy_check)的内容,了解一下,这样的校验码的详细算法。
## 课后思考
有人说奇偶校验只是循环冗余编码CRC的一种特殊情况。在读完推荐阅读里面的CRC算法的实现之后你能分析一下为什么奇偶校验只是CRC的一种特殊情况吗
欢迎把你阅读和分析的内容写在留言区,和大家一起分享。如果觉得有帮助,你也可以把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="50 | 数据完整性(下):如何还原犯罪现场?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/5b/9bae72a19d39cbc44cc4887ea003515b.mp3"></audio>
讲完校验码之后你现在应该知道无论是奇偶校验码还是CRC这样的循环校验码都只能告诉我们一个事情就是你的数据出错了。所以校验码也被称为检错码Error Detecting Code
不管是校验码,还是检错码,在硬件出错的时候,只能告诉你“我错了”。但是,下一个问题,“错哪儿了”,它是回答不了的。这就导致,我们的处理方式只有一种,那就是当成“哪儿都错了”。如果是下载一个文件,发现校验码不匹配,我们只能重新去下载;如果是程序计算后放到内存里面的数据,我们只能再重新算一遍。
这样的效率实在是太低了,所以我们需要有一个办法,不仅告诉我们“我错了”,还能告诉我们“错哪儿了”。于是,计算机科学家们就发明了**纠错码**。纠错码需要更多的冗余信息,通过这些冗余信息,我们不仅可以知道哪里的数据错了,还能直接把数据给改对。这个是不是听起来很神奇?接下来就让我们一起来看一看。
## 海明码:我们需要多少信息冗余?
最知名的纠错码就是海明码。海明码Hamming Code是以他的发明人Richard Hamming理查德·海明的名字命名的。这个编码方式早在上世纪四十年代就被发明出来了。而直到今天我们上一讲所说到的ECC内存也还在使用海明码来纠错。
最基础的海明码叫**7-4海明码**。这里的“7”指的是实际有效的数据一共是7位Bit。而这里的“4”指的是我们额外存储了4位数据用来纠错。
首先你要明白一点纠错码的纠错能力是有限的。不是说不管错了多少位我们都能给纠正过来。不然我们就不需要那7个数据位只需要那4个校验位就好了这意味着我们可以不用数据位就能传输信息了。这就不科学了。事实上在7-4海明码里面我们只能纠正某1位的错误。这是怎么做到的呢我们一起来看看。
4位的校验码一共可以表示 2^4 = 16 个不同的数。根据数据位计算出来的校验值,一定是确定的。所以,如果数据位出错了,计算出来的校验码,一定和确定的那个校验码不同。那可能的值,就是在 2^4 - 1 = 15 那剩下的15个可能的校验值当中。
15个可能的校验值其实可以对应15个可能出错的位。这个时候你可能就会问了既然我们的数据位只有7位那为什么我们要用4位的校验码呢用3位不就够了吗2^3 - 1 = 7正好能够对上7个不同的数据位啊
你别忘了单比特翻转的错误不仅可能出现在数据位也有可能出现在校验位。校验位本身也是可能出错的。所以7位数据位和3位校验位如果只有单比特出错可能出错的位数就是10位2^3 - 1 = 7 种情况是不能帮我们找到具体是哪一位出错的。
事实上如果我们的数据位有K位校验位有N位。那么我们需要满足下面这个不等式才能确保我们能够对单比特翻转的数据纠错。这个不等式就是
在有7位数据位也就是K=7的情况下N的最小值就是4。4位校验位其实最多可以支持到11位数据位。我在下面列了一个简单的数据位数和校验位数的对照表你可以自己算一算理解一下上面的公式。
<img src="https://static001.geekbang.org/resource/image/ec/1d/ec8b6bff509e1abb7453caa36a4a711d.jpeg" alt="">
## 海明码的纠错原理
现在你应该搞清楚了,在数据位数确定的情况下,怎么计算需要的校验位。那接下来,我们就一起看看海明码的编码方式是怎么样的。
为了算起来简单一点,我们少用一些位数,来算一个**4-3海明码**也就是4位数据位3位校验位。我们把4位数据位分别记作d1、d2、d3、d4。这里的d取的是数据位data bits的首字母。我们把3位校验位分别记作p1、p2、p3。这里的p取的是校验位parity bits的首字母。
从4位的数据位里面我们拿走1位然后计算出一个对应的校验位。这个校验位的计算用之前讲过的奇偶校验就可以了。比如我们用d1、d2、d4来计算出一个校验位p1用d1、d3、d4计算出一个校验位p2用d2、d3、d4计算出一个校验位p3。就像下面这个对应的表格一样
<img src="https://static001.geekbang.org/resource/image/6d/bc/6d7cf44bb41df6361e82dcd4979dc4bc.jpeg" alt="">
这个时候你去想一想如果d1这一位的数据出错了会发生什么情况我们会发现p1和p2和校验的计算结果不一样。d2出错了是因为p1和p3的校验的计算结果不一样d3出错了则是因为p2和p3如果d4出错了则是p1、p2、p3都不一样。你会发现当数据码出错的时候至少会有2位校验码的计算是不一致的。
那我们倒过来如果是p1的校验码出错了会发生什么情况呢这个时候只有p1的校验结果出错。p2和p3的出错的结果也是一样的只有一个校验码的计算是不一致的。
所以校验码不一致,一共有 2^3-1=7种情况正好对应了7个不同的位数的错误。我把这个对应表格也放在下面了你可以理解一下。
<img src="https://static001.geekbang.org/resource/image/3e/21/3edee00788294bb96cde11dace2a7721.jpeg" alt="">
可以看到,海明码这样的纠错过程,有点儿像电影里面看到的推理探案的过程。通过出错现场的额外信息,一步一步条分缕析地找出,到底是哪一位的数据出错,还原出错时候的“犯罪现场”。
看到这里,相信你一方面会觉得海明码特别神奇,但是同时也会冒出一个新的疑问,我们怎么才能用一套程序或者规则来生成海明码呢?其实这个步骤并不复杂,接下来我们就一起来看一下。
首先我们先确定编码后要传输的数据是多少位。比如说我们这里的7-4海明码就是一共11位。
然后我们给这11位数据从左到右进行编号并且也把它们的二进制表示写出来。
接着我们先把这11个数据中的二进制的整数次幂找出来。在这个7-4海明码里面就是1、2、4、8。这些数就是我们的校验码位我们把他们记录做p1p4。如果从二进制的角度看它们是这11个数当中唯四的在4个比特里面只有一个比特是1的数值。
那么剩下的7个数就是我们d1-d7的数据码位了。
然后对于我们的校验码位我们还是用奇偶校验码。但是每一个校验码位不是用所有的7位数据来计算校验码。而是p1用3、5、7、9、11来计算。也就是在二进制表示下从右往左数的第一位比特是1的情况下用p1作为校验码。
剩下的p2我们用3、6、10、11来计算校验码也就是在二进制表示下从右往左数的第二位比特是1的情况下用p2。那么p3自然是从右往左数第三位比特是1的情况下的数字校验码。而p4则是第四位比特是1的情况下的校验码。
<img src="https://static001.geekbang.org/resource/image/a7/9d/a7d5e958f9d46938e494710e090f469d.jpeg" alt="">
这个时候,你会发现,任何一个数据码出错了,就至少会有对应的两个或者三个校验码对不上,这样我们就能反过来找到是哪一个数据码出错了。如果校验码出错了,那么只有校验码这一位对不上,我们就知道是这个校验码出错了。
上面这个方法,我们可以用一段确定的程序表示出来,意味着无论是几位的海明码,我们都不再需要人工去精巧地设计编码方案了。
## 海明距离:形象理解海明码的作用
其实,我们还可以换一个角度来理解海明码的作用。对于两个二进制表示的数据,他们之间有差异的位数,我们称之为海明距离。比如 1001 和 0001 的海明距离是1因为他们只有最左侧的第一位是不同的。而1001 和 0000 的海明距离是2因为他们最左侧和最右侧有两位是不同的。
<img src="https://static001.geekbang.org/resource/image/fb/78/fb388f965a7a7631925a32cc4610ff78.jpeg" alt="">
于是你很容易可以想到所谓的进行一位纠错也就是所有和我们要传输的数据的海明距离为1的数都能被纠正回来。
而任何两个实际我们想要传输的数据海明距离都至少要是3。你可能会问了为什么不能是2呢因为如果是2的话那么就会有一个出错的数到两个正确的数据的海明距离都是1。当我们看到这个出错的数的时候我们就不知道究竟应该纠正到那一个数了。
在引入了海明距离之后,我们就可以更形象地理解纠错码了。在没有纠错功能的情况下,我们看到的数据就好像是空间里面的一个一个点。这个时候,我们可以让数据之间的距离很紧凑,但是如果这些点的坐标稍稍有错,我们就可能搞错是哪一个点。
在有了1位纠错功能之后就好像我们把一个点变成了以这个点为中心半径为1的球。只要坐标在这个球的范围之内我们都知道实际要的数据就是球心的坐标。而各个数据球不能距离太近不同的数据球之间要有3个单位的距离。
<img src="https://static001.geekbang.org/resource/image/d6/34/d65bdde974ee99b6187eac90e4b5a234.jpeg" alt="">
## 总结延伸
好了纠错码的内容到这里就讲完了。你可不要小看这个看起来简单的海明码。虽然它在上世纪40年代早早地就诞生了不过直到今天的ECC内存里面我们还在使用这个技术方案。而海明也因为海明码获得了图灵奖。
通过在数据中添加多个冗余的校验码位海明码不仅能够检测到数据中的错误还能够在只有单个位的数据出错的时候把错误的一位纠正过来。在理解和计算海明码的过程中有一个很重要的点就是不仅原来的数据位可能出错。我们新添加的校验位一样可能会出现单比特翻转的错误。这也是为什么7位数据位用3位校验码位是不够的而需要4位校验码位。
实际的海明码编码的过程也并不复杂,我们通过用不同过的校验位,去匹配多个不同的数据组,确保任何一个数据位出错,都会产生一个多个校验码位出错的唯一组合。这样,在出错的时候,我们就可以反过来找到出错的数据位,并纠正过来。当只有一个校验码位出错的时候,我们就知道实际出错的是校验码位了。
## 推荐阅读
这一讲的推荐阅读,还是让我们回到教科书。我推荐你去读一读《计算机组成与设计:软件/硬件接口》的5.5章节,关于可信存储器的部分。
另外如果你想在纠错码上进一步深入你可以去了解一下纠删码也就是Erasure Code。最好的学习入口当然还是[Wikipedia](https://en.wikipedia.org/wiki/Erasure_code)。
## 课后思考
7明码除了可以进行单个位的纠错之外。还能做到可以检测Detection到两个位的出错。也就是说虽然我们不知道是哪两个比特错了但是我们还是知道数据是错了的。为什么能够做到这一点呢
你可以好好思考一下,然后在留言区写下你的答案。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="51 | 分布式计算:如果所有人的大脑都联网会怎样?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/b6/b15a52e0b604d7f65388f6f6b48436b6.mp3"></audio>
今天是原理篇的最后一篇。过去50讲我们一起看了抽象概念上的计算机指令看了这些指令怎么拆解成一个个简单的电路以及CPU是怎么通过一个一个的电路组成的。我们还一起看了高速缓存、内存、SSD硬盘和机械硬盘以及这些组件又是怎么通过总线和CPU连在一起相互通信的。
把计算机这一系列组件组合起来我们就拿到了一台完整的计算机。现在我们每天在用的个人PC、智能手机乃至云上的服务器都是这样一台计算机。
但是,一台计算机在数据中心里是不够的。因为如果只有一台计算机,我们会遇到三个核心问题。第一个核心问题,叫作**垂直扩展和水平扩展的选择问题**,第二问题叫作**如何保持高可用性**High Availability第三个问题叫作**一致性问题**Consistency
围绕这三个问题,其实就是我们今天要讲的主题,分布式计算。当然,短短的一讲肯定讲不完这么大一个主题。分布式计算拿出来单开一门专栏也绰绰有余。我们今天这一讲的目标,是让你能理解水平扩展、高可用性这两个核心问题。对于分布式系统带来的一致性问题,我们会留在我们的实战篇里面,再用案例来为大家分析。
## 从硬件升级到水平扩展
从技术开发的角度来讲想要在2019年创业真的很幸福。只要在AWS或者阿里云这样的云服务上注册一个账号一个月花上一两百块钱你就可以有一台在数据中心里面的服务器了。而且这台服务器可以直接提供给世界各国人民访问。如果你想要做海外市场你可以把这个服务器放在美国、欧洲、东南亚任何一个你想要去的市场的数据中心里然后把自己的网站部署在这台服务器里面就可以了。
<img src="https://static001.geekbang.org/resource/image/1d/6e/1d1cb606a2ee8261c45f99686fb7946e.png" alt="">
当然,这台服务器就是我们在[第34讲](https://time.geekbang.org/column/article/107183)里说的虚拟机。不过因为只是个业余时间的小项目一开始这台服务器的配置也不会太高。我以我现在公司所用的Google Cloud为例。最低的配置差不多是1个CPU核心、3.75G内存以及一块10G的SSD系统盘。这样一台服务器每个月的价格差不多是28美元。
幸运的是,你的网站很受大家欢迎,访问量也上来了。这个时候,这台单核心的服务器的性能有点不够用了。这个时候,你需要升级你的服务器。于是,你就会面临两个选择。
第一个选择是升级现在这台服务器的硬件变成2个CPU核心、7.5G内存。这样的选择我们称之为**垂直扩展**Scale Up。第二个选择则是我们再租用一台和之前一样的服务器。于是我们有了2台1个CPU核心、3.75G内存的服务器。这样的选择我们称之为**水平扩展**Scale Out
在这个阶段这两个选择从成本上看起来没有什么差异。2核心、7.5G内存的服务器成本是56.61美元而2台1核心、3.75G内存的服务器价格成本是57美元这之间的价格差异不到1%。
不过垂直扩展和水平扩展看似是两个不同的选择但是随着流量不断增长。到最后只会变成一个选择。那就是既会垂直扩展又会水平扩展并且最终依靠水平扩展来支撑Google、Facebook、阿里、腾讯这样体量的互联网服务。
垂直扩展背后的逻辑和优势都很简单。一般来说,垂直扩展通常不需要我们去改造程序,也就是说,我们**没有研发成本**。那为什么我们最终还是要用水平扩展呢?你可以先自己想一想。
原因其实很简单因为我们没有办法不停地去做垂直扩展。我们在Google Cloud上现在能够买到的性能最好的服务器是96个CPU核心、1.4TB的内存。如果我们的访问量逐渐增大一台96核心的服务器也支撑不了了那么我们就没有办法再去做垂直扩展了。这个时候我们就不得不采用水平扩展的方案了。
96个CPU核心看起来是个很强大的服务器但是你算一算就知道其实它的计算资源并没有多大。你现在多半在用一台4核心或者至少也是2核心的CPU。96个CPU也就是3050台日常使用的开发机的计算性能。而我们今天在互联网上遇到的问题是每天数亿的访问量靠3050台个人电脑的计算能力想要支撑这样的计算需求可谓是天方夜谭了。
然而,一旦开始采用水平扩展,我们就会面临在软件层面改造的问题了。也就是我们需要开始进行**分布式计算**了。我们需要引入**负载均衡**Load Balancer这样的组件来进行流量分配。我们需要拆分应用服务器和数据库服务器来进行垂直功能的切分。我们也需要不同的应用之间通过消息队列来进行异步任务的执行。
<img src="https://static001.geekbang.org/resource/image/03/91/034fdb2fcf4a371d1eeb331e86fa4491.jpeg" alt="">
所有这些软件层面的改造其实都是在做分布式计算的一个核心工作就是通过消息传递Message Passing而不是共享内存Shared Memory的方式让多台不同的计算机协作起来共同完成任务。
而因为我们最终必然要进行水平扩展,我们需要在系统设计的早期就基于消息传递而非共享内存来设计系统。即使这些消息只是在同一台服务器上进行传递。
事实上有不少增长迅猛的公司早期没有准备好通过水平扩展来支撑访问量的情况而一味通过提升硬件配置Scale Up来支撑更大的访问量最终影响了公司的存亡。最典型的例子就是败在Facebook手下的[MySpace](https://en.wikipedia.org/wiki/Myspace)。
## 理解高可用性和单点故障
尽管在1个CPU核心的服务器支撑不了我们的访问量的时候选择垂直扩展是一个最简单的办法。不过如果是我的话第一次扩展我会选择水平扩展。
选择水平扩展的一个很好的理由,自然是可以“强迫”从开发的角度,尽早地让系统能够支持水平扩展,避免在真的流量快速增长的时候,垂直扩展的解决方案跟不上趟。不过,其实还有一个更重要的理由,那就是系统的可用性问题。
上面的1核变2核的垂直扩展的方式扩展完之后我们还是只有1台服务器。如果这台服务器出现了一点硬件故障比如CPU坏了那我们的整个系统就坏了就**不可用**了。
如果采用了水平扩展即便有一台服务器的CPU坏了我们还有另外一台服务器仍然能够提供服务。负载均衡能够通过健康检测Health Check发现坏掉的服务器没有响应了就可以自动把所有的流量切换到第2台服务器上这个操作就叫作**故障转移**Failover我们的系统仍然是**可用**的。
系统的**可用性**Avaiability指的就是我们的系统可以正常服务的时间占比。无论是因为软硬件故障还是需要对系统进行停机升级都会让我们损失系统的可用性。可用性通常是用一个百分比的数字来表示比如99.99%。我们说系统每个月的可用性要保障在99.99%也就是意味着一个月里你的服务宕机的时间不能超过4.32分钟。
有些系统可用性的损失是在我们计划内的。比如上面说的停机升级这个就是所谓的计划内停机时间Scheduled Downtime。有些系统可用性的损失是在我们计划外的比如一台服务器的硬盘忽然坏了这个就是所谓的计划外停机时间Unscheduled Downtime
我们的系统是一定不可能做到100%可用的,特别是计划外的停机时间。从简单的硬件损坏,到机房停电、光缆被挖断,乃至于各种自然灾害,比如地震、洪水、海啸,都有可能使得我们的系统不可用。作为一个工程师和架构师,我们要做的就是尽可能低成本地提高系统的可用性。
咱们的专栏是要讲计算机组成原理,那我们先来看一看硬件服务器的可用性。
现在的服务器的可用性都已经很不错了通常都能保障99.99%的可用性了。如果我们有一个小小的三台服务器组成的小系统一台部署了Nginx来作为负载均衡和反向代理一台跑了PHP-FPM作为Web应用服务器一台用来作为MySQL数据库服务器。每台服务器的可用性都是99.99%。那么我们整个系统的可用性是多少呢?你可以先想一想。
答案是99.99% × 99.99% × 99.99% = 99.97%。在这个系统当中这个数字看起来似乎没有那么大区别。不过反过来看我们是从损失了0.01%的可用性变成了损失0.03%的可用性不可用的时间变成了原来的3倍。
如果我们有1000台服务器那么整个的可用性就会变成 99.99% ^ 1000 = 90.5%。也就是说,我们的服务一年里有超过一个月是不可用的。这可怎么办呀?
<img src="https://static001.geekbang.org/resource/image/e9/35/e9ef478712d744d07c82c54534564335.jpeg" alt="">
我们先来分析一下原因。之所以会出现这个问题,是因为在这个场景下,任何一台服务器出错了,整个系统就没法用了。这个问题就叫作**单点故障问题**Single Point of FailureSPOF。我们这里的这个假设特别糟糕。我们假设这1000台服务器每一个都存在单点故障问题。所以我们的服务也就特别脆弱随便哪台出现点风吹草动整个服务就挂了。
要解决单点故障问题,第一点就是要移除单点。其实移除单点最典型的场景,在我们水平扩展应用服务器的时候就已经看到了,那就是让两台服务器提供相同的功能,然后通过负载均衡把流量分发到两台不同的服务器去。即使一台服务器挂了,还有一台服务器可以正常提供服务。
不过光用两台服务器是不够的,单点故障其实在数据中心里面无处不在。我们现在用的是云上的两台虚拟机。如果这两台虚拟机是托管在同一台物理机上的,那这台物理机本身又成为了一个单点。那我们就需要把这两台虚拟机分到两台不同的物理机上。
不过这个还是不够。如果这两台物理机在同一个机架Rack那机架上的交换机Switch就成了一个单点。即使放到不同的机架上还是有可能出现整个数据中心遭遇意外故障的情况。
<img src="https://static001.geekbang.org/resource/image/ab/a4/ab09e9a2b9670d61c2391906189694a4.jpeg" alt="">
去年我自己就遇到过部署在Azure上的服务所在的数据中心因为散热问题触发了整个数据中心所有服务器被关闭的问题。面对这种情况我们就需要设计进行**异地多活**的系统设计和部署。所以在现代的云服务你在买服务器的时候可以选择服务器的area地区和zone区域而要不要把服务器放在不同的地区或者区域里也是避免单点故障的一个重要因素。
只是能够去除单点其实我们的可用性问题还没有解决。比如上面我们用负载均衡把流量均匀地分发到2台服务器上当一台应用服务器挂掉的时候我们的确还有一台服务器在提供服务。但是负载均衡会把一半的流量发到已经挂掉的服务器上所以这个时候只能算作一半可用。
想要让整个服务完全可用我们就需要有一套故障转移Failover机制。想要进行故障转移就首先要能发现故障。
以我们这里的PHP-FPM的Web应用为例负载均衡通常会定时去请求一个Web应用提供的健康检测Health Check的地址。这个时间间隔可能是5秒钟如果连续23次发现健康检测失败负载均衡就会自动将这台服务器的流量切换到其他服务器上。于是我们就自动地产生了一次故障转移。故障转移的自动化在大型系统里是很重要的因为服务器越多出现故障基本就是个必然发生的事情。而自动化的故障转移既能够减少运维的人手需求也能够缩短从故障发现到问题解决的时间周期提高可用性。
<img src="https://static001.geekbang.org/resource/image/09/02/0907c25d6f2fd80401ee3d9bf5d17802.png" alt="">
那么,让我们算一算,通过水平扩展相同功能的服务器来去掉单点故障,并且通过健康检查机制来触发自动的故障转移,这样的可用性会变成多少呢?你可以拿出纸和笔来试一下。
不知道你想明白应该怎么算了没有,在这种情况下,我们其实只要有任何一台服务器能够正常运转,就能正常提供服务。那么,我们的可用性就是:
可以看出,不能提供服务的时间就减少到了原来的万分之一。
当然,在实际情况中,可用性没法做到那么理想的地步。光从硬件的角度,从服务器到交换机,从网线连接到机房电力,从机房的整体散热到外部的光纤线路等等,可能出现问题的地方太多了。这也是为什么,我们需要从整个系统层面,去设计系统的高可用性。
## 总结延伸
讲到这里,相信你已经很清楚,为什么我们需要水平扩展了。对于怎么去设计整个硬件的部署,来保障高可用性,你应该也有了一个清晰的认识。这两点也是分布式计算在实践中非常重要的应用场景。
不过,光有这两点还是不够的。一旦系统里面有了很多台服务器。特别是,为了保障可用性,对于同样功能的、有状态的数据库进行了水平的扩展,我们就会面临一个新的挑战,那就是分区一致性问题。不过,这个问题更多的是一个软件设计问题,我把它留在后面的实战篇再进行讲解。
我们下面来回顾一下这一讲的内容。我们讲了通过升级硬件规格来提升服务能力的垂直扩展。除此之外,也可以通过增加服务器数量来提升服务能力。不过归根到底,我们一定要走上水平扩展的路径。
一方面是因为垂直扩展不可持续;另一方面,则是只有水平扩展才能保障高可用性。而通过水平扩展保障高可用性,则需要我们做三件事情。第一个是理解可用性是怎么计算的。服务器硬件的损坏只是可能导致可用性损失的因素之一,机房内的电力、散热、交换机、网络线路,都有可能导致可用性损失。而外部的光缆、自然灾害,也都有可能造成我们整个系统的不可用。
所以,在分析设计系统的时候,我们需要尽可能地排除单点故障。进一步地,对于硬件的故障,我们还要有自动化的故障转移策略。在这些策略都齐全之后,我们才能真的长舒一口气,在海量的负载和流量下安心睡个好觉。
## 推荐阅读
今天的推荐阅读,不是读一篇具体的文章,我推荐你可以常常去浏览一下[http://highscalability.com/](http://highscalability.com/)这个网站,里面有不少有价值的、讲解怎么做到高扩展性的小文章。
## 课后思考
你可以想一想,你现在在学习和工作中开发的系统,是否考虑到高可用性呢?你能找找这些系统中,你们做了哪些高可用性的硬件层面的设计,是否还存在哪些单点故障,以及做了哪些故障转移的措施来保持可用性吗?
你可以写在留言区,和大家一起分享一下实际的应用经验,也可以看看其他同学的工作中做了什么样的设计和相关工作,和大家一起交流、分享。