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,97 @@
<audio id="audio" title="52 | 设计大型DMP系统MongoDB并不是什么灵丹妙药" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/e3/00daa3d6d41fa409d32ee3f6dee1dee3.mp3"></audio>
如果你一讲一讲跟到现在那首先要恭喜你马上就看到胜利的曙光了。过去的50多讲里我把计算机组成原理中的各个知识点一点一点和你拆解了。对于其中的很多知识点我也给了相应的代码示例和实际的应用案例。
不过呢相信你和我一样觉得只了解这样一个个零散的知识点和案例还不过瘾。那么从今天开始我们就进入应用篇。我会通过两个应用系统的案例串联起计算机组成原理的两大块知识点一个是我们的整个存储器系统另一个自然是我们的CPU和指令系统了。
我们今天就先从搭建一个大型的DMP系统开始利用组成原理里面学到的存储器知识来做选型判断从而更深入地理解计算机组成原理。
## DMP数据管理平台
我们先来看一下什么是DMP系统。DMP系统的全称叫作数据管理平台Data Management Platform目前广泛应用在互联网的广告定向Ad Targeting、个性化推荐Recommendation这些领域。
通常来说DMP系统会通过处理海量的互联网访问数据以及机器学习算法给一个用户标注上各种各样的标签。然后在我们做个性化推荐和广告投放的时候再利用这些这些标签去做实际的广告排序、推荐等工作。无论是Google的搜索广告、淘宝里千人千面的商品信息还是抖音里面的信息流推荐背后都会有一个DMP系统。
<img src="https://static001.geekbang.org/resource/image/17/ef/170004db8634a3a7f9dc47c4a4d5bfef.jpg" alt="">
那么一个DMP系统应该怎么搭建呢对于外部使用DMP的系统或者用户来说可以简单地把DMP看成是一个键-值对Key-Value数据库。我们的广告系统或者推荐系统可以通过一个客户端输入用户的唯一标识ID然后拿到这个用户的各种信息。
这些信息中有些是用户的人口属性信息Demographic比如性别、年龄有些是非常具体的行为Behavior比如用户最近看过的商品是什么用户的手机型号是什么有一些是我们通过算法系统计算出来的兴趣Interests比如用户喜欢健身、听音乐还有一些则是完全通过机器学习算法得出的用户向量给后面的推荐算法或者广告算法作为数据输入。
基于此对于这个KV数据库我们的期望也很清楚那就是**低响应时间**Low Response Time、**高可用性**High Availability、**高并发**High Concurrency、**海量数据**Big Data同时我们需要**付得起对应的成本**Affordable Cost。如果用数字来衡量这些指标那么我们的期望就会具体化成下面这样。
1. 低响应时间一般的广告系统留给整个广告投放决策的时间也就是10ms左右所以对于访问DMP获取用户数据预期的响应时间都在1ms之内。
1. 高可用性DMP常常用在广告系统里面。DMP系统出问题往往就意味着我们整个的广告收入在不可用的时间就没了所以我们对于可用性的追求可谓是没有上限的。Google 2018年的广告收入是1160亿美元折合到每一分钟的收入是22万美元。即使我们做到 99.99% 的可用性也意味着每个月我们都会损失100万美元。
1. 高并发还是以广告系统为例如果每天我们需要响应100亿次的广告请求那么我们每秒的并发请求数就在 100亿 / (86400) ~= 12K 次左右所以我们的DMP需要支持高并发。
1. 数据量如果我们的产品针对中国市场那么我们需要有10亿个Key对应的假设每个用户有500个标签标签有对应的分数。标签和分数都用一个4字节Bytes的整数来表示那么一共我们需要 10亿 x 500 x (4 + 4) Bytes = 4 TB 的数据了。
1. 低成本我们还是从广告系统的角度来考虑。广告系统的收入通常用CPMCost Per Mille也就是千次曝光来统计。如果千次曝光的利润是 0.10美元那么每天100亿次的曝光就是100万美元的利润。这个利润听起来非常高了。但是反过来算一下你会发现DMP每1000次的请求的成本不能超过 0.10美元。最好只有0.01美元,甚至更低,我们才能尽可能多赚到一点广告利润。
这五个因素一结合,听起来是不是就不那么简单了?不过,更复杂的还在后面呢。
虽然从外部看起来DMP特别简单就是一个KV数据库但是生成这个数据库需要做的事情更多。我们下面一起来看一看。
<img src="https://static001.geekbang.org/resource/image/b5/33/b5d8c56d840b9e824cfbc186c19d0733.jpg" alt="">
为了能够生成这个KV数据库我们需要有一个在客户端或者Web端的数据采集模块不断采集用户的行为向后端的服务器发送数据。服务器端接收到数据就要把这份数据放到一个**数据管道**Data Pipeline里面。数据管道的下游需要实际将数据落地到**数据仓库**Data Warehouse把所有的这些数据结构化地存储起来。后续我们就可以通过程序去分析这部分日志生成报表或者或者利用数据运行各种机器学习算法。
除了这个数据仓库之外我们还会有一个实时数据处理模块Realtime Data Processing也放在数据管道的下游。它同样会读取数据管道里面的数据去进行各种实时计算然后把需要的结果写入到DMP的KV数据库里面去。
## MongoDB真的万能吗
面对这里的KV数据库、数据管道以及数据仓库这三个不同的数据存储的需求最合理的技术方案是什么呢你可以先自己思考一下我这里先卖个关子。
我共事过的不少不错的Web程序员面对这个问题的时候常常会说“这有什么难的用MongoDB就好了呀”如果你也选择了MongoDB那最终的结果一定是一场灾难。我为什么这么说呢
MongoDB的设计听起来特别厉害不需要预先数据Schema访问速度很快还能够无限水平扩展。作为KV数据库我们可以把MongoDB当作DMP里面的KV数据库除此之外MongoDB还能水平扩展、跑MQL我们可以把它当作数据仓库来用。至于数据管道只要我们能够不断往MongoDB里面插入新的数据就好了。从运维的角度来说我们只需要维护一种数据库技术栈也变得简单了。看起来MongoDB这个选择真是相当完美
但是作为一个老程序员第一次听到MongoDB这样“万能”的解决方案我的第一反应是“天底下哪有这样的好事”。所有的软件系统都有它的适用场景想通过一种解决方案适用三个差异非常大的应用场景显然既不合理又不现实。接下来我们就来仔细看一下这个“不合理”“不现实”在什么地方。
上面我们已经讲过DMP的KV数据库期望的应用场景和性能要求了这里我们就来看一下**数据管道**和**数据仓库**的性能取舍。
对于数据管道来说我们需要的是高吞吐量它的并发量虽然和KV数据库差不多但是在响应时间上要求就没有那么严格了1-2秒甚至再多几秒的延时都是可以接受的。而且和KV数据库不太一样数据管道的数据读写都是顺序读写没有大量的随机读写的需求。
**数据仓库**就更不一样了数据仓库的数据读取的量要比管道大得多。管道的数据读取就是我们当时写入的数据一天有10TB日志数据管道只会写入10TB。下游的数据仓库存放数据和实时数据模块读取的数据再加上个2倍的10TB也就是20TB也就够了。
但是数据仓库的数据分析任务要读取的数据量就大多了。一方面我们可能要分析一周、一个月乃至一个季度的数据。这一次分析要读取的数据可不是10TB而是100TB乃至1PB。我们一天在数据仓库上跑的分析任务也不是1个而是成千上万个所以数据的读取量是巨大的。另一方面我们存储在数据仓库里面的数据也不像数据管道一样存放几个小时、最多一天的数据而是往往要存上3个月甚至是1年的数据。所以我们需要的是1PB乃至5PB这样的存储空间。
我把KV数据库、数据管道和数据仓库的应用场景总结成了一个表格放在这里。你可以对照着看一下想想为什么MongoDB在这三个应用场景都不合适。
<img src="https://static001.geekbang.org/resource/image/fe/41/fe9fc1ade3611ed2acf3ba3a23267f41.jpg" alt="">
在KV数据库的场景下需要支持高并发。那么MongoDB需要把更多的数据放在内存里面但是这样我们的存储成本就会特别高了。
在数据管道的场景下我们需要的是大量的顺序读写而MongoDB则是一个文档数据库系统并没有为顺序写入和吞吐量做过优化看起来也不太适用。
而在数据仓库的场景下主要的数据读取时顺序读取并且需要海量的存储。MongoDB这样的文档式数据库也没有为海量的顺序读做过优化仍然不是一个最佳的解决方案。而且文档数据库里总是会有很多冗余的字段的元数据还会浪费更多的存储空间。
那我们该选择什么样的解决方案呢?
拿着我们的应用场景去找方案其实并不难找。对于KV数据库最佳的选择方案自然是使用SSD硬盘选择AeroSpike这样的KV数据库。高并发的随机访问并不适合HDD的机械硬盘而400TB的数据如果用内存的话成本又会显得太高。
对于数据管道最佳选择自然是Kafka。因为我们追求的是吞吐率采用了Zero-Copy和DMA机制的Kafka最大化了作为数据管道的吞吐率。而且数据管道的读写都是顺序读写所以我们也不需要对随机读写提供支持用上HDD硬盘就好了。
到了数据仓库存放的数据量更大了。在硬件层面使用HDD硬盘成了一个必选项。否则我们的存储成本就会差上10倍。这么大量的数据在存储上我们需要定义清楚Schema使得每个字段都不需要额外存储元数据能够通过Avro/Thrift/ProtoBuffer这样的二进制序列化的方存储下来或者干脆直接使用Hive这样明确了字段定义的数据仓库产品。很明显MongoDB那样不限制Schema的数据结构在这个情况下并不好用。
2012年前后做广告系统的时候我们也曾经尝试使用MongoDB尽管只是用作DMP中的数据报表部分。事实证明即使是已经做了数据层面的汇总的报表MongoDB都无法很好地支撑我们需要的复杂需求。最终我们也不得不选择在整个DMP技术栈里面彻底废弃MongoDB而只在Web应用里面用用MongoDB。事实证明我最初的直觉是正确的并没有什么万能的解决方案。
## 总结延伸
好了,相信到这里,你应该对怎么从最基本的原理出发,来选择技术栈有些感觉了。你应该更多地从底层的存储系统的特性和原理去考虑问题。一旦能够从这个角度去考虑问题,那么你对各类新的技术项目和产品的公关稿,自然会有一定的免疫力了,而不会轻易根据商业公司的宣传来做技术选型了。
因为低延时、高并发、写少读多的DMP的KV数据库最适合用SSD硬盘并且采用专门的KV数据库是最合适的。我们可以选择之前文章里提过的AeroSpike也可以用开源的Cassandra来提供服务。
对于数据管道因为主要是顺序读和顺序写所以我们不一定要选用SSD硬盘而可以用HDD硬盘。不过对于最大化吞吐量的需求使用zero-copy和DMA是必不可少的所以现在的数据管道的标准解决方案就是Kafka了。
对于数据仓库我们通常是一次写入、多次读取。并且由于存储的数据量很大我们还要考虑成本问题。于是一方面我们会用HDD硬盘而不是SSD硬盘另一方面我们往往会预先给数据规定好Schema使得单条数据的序列化不需要像存JSON或者MongoDB的BSON那样存储冗余的字段名称这样的元数据。所以最常用的解决方案是用Hadoop这样的集群采用Hive这样的数据仓库系统或者采用Avro/Thrift/ProtoBuffer这样的二进制序列化方案。
在大型的DMP系统设计当中我们需要根据各个应用场景面临的实际情况选择不同的硬件和软件的组合来作为整个系统中的不同组件。
## 推荐阅读
如果通过这一讲的内容,能让你对大型数据系统的设计有了兴趣,那就再好不过了。我推荐你去读一读[《数据密集型应用系统设计》](https://book.douban.com/subject/30329536/)这本书,深入了解一下,设计数据系统需要关注的各个核心要点。
## 课后思考
这一讲里我们讲到了数据管道通常所使用的开源系统Kafka并且选择了使用机械硬盘。在Kafka的使用上我们有没有必要使用SSD硬盘呢如果用了SSD硬盘又会带来哪些好处和坏处呢
请你仔细思考一下,也可以和周围的朋友分享讨论。如果你觉得有所收获,也请你把你的想法写在留言区,分享给其他的同学。

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="53 | 设计大型DMP系统SSD拯救了所有的DBA" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/11/7069b3fbeb47d8229c004335e6607b11.mp3"></audio>
上一讲里根据DMP系统的各个应用场景我们从抽象的原理层面选择了AeroSpike作为KV数据库Kafka作为数据管道Hadoop/Hive来作为数据仓库。
不过呢肯定有不信邪的工程师会问为什么MongoDB甚至是MySQL这样的文档数据库或者传统的关系型数据库不适用呢为什么不能通过优化SQL、添加缓存这样的调优手段解决这个问题呢
今天DMP的下半场我们就从数据库实现的原理一起来看一看这背后的原因。如果你能弄明白今天的这些更深入、更细节的原理对于什么场景使用什么数据库就会更加胸有成竹而不是只有跑了大量的性能测试才知道。下次做数据库选型的时候你就可以“以理服人”了。
## 关系型数据库:不得不做的随机读写
我们先来想一想,如果现在让你自己写一个最简单的关系型数据库,你的数据要怎么存放在硬盘上?
最简单最直观的想法是用一个CSV文件格式。一个文件就是一个数据表。文件里面的每一行就是这个表里面的一条记录。如果要修改数据库里面的某一条记录那么我们要先找到这一行然后直接去修改这一行的数据。读取数据也是一样的。
要找到这样数据最笨的办法自然是一行一行读也就是遍历整个CSV文件。不过这样的话相当于随便读取任何一条数据都要扫描全表太浪费硬盘的吞吐量了。那怎么办呢我们可以试试给这个CSV文件加一个索引。比如给数据的行号加一个索引。如果你学过数据库原理或者算法和数据结构那你应该知道通过B+树多半是可以来建立这样一个索引的。
索引里面没有一整行的数据,只有一个映射关系,这个映射关系可以让行号直接从硬盘的某个位置去读。所以,索引比起数据小很多。我们可以把索引加载到内存里面。即使不在内存里面,要找数据的时候快速遍历一下整个索引,也不需要读太多的数据。
加了索引之后我们要读取特定的数据就不用去扫描整个数据表文件了。直接从特定的硬盘位置就可以读到想要的行。索引不仅可以索引行号还可以索引某个字段。我们可以创建很多个不同的独立的索引。写SQL的时候where子句后面的查询条件可以用到这些索引。
不过,这样的话,写入数据的时候就会麻烦一些。我们不仅要在数据表里面写入数据,对于所有的索引也都需要进行更新。这个时候,写入一条数据就要触发好几个随机写入的更新。
<img src="https://static001.geekbang.org/resource/image/f3/3c/f3c01bc2de99dbb83ad17cef1fb38a3c.jpeg" alt="">
在这样一个数据模型下查询操作很灵活。无论是根据哪个字段查询只要有索引我们就可以通过一次随机读很快地读到对应的数据。但是这个灵活性也带来了一个很大的问题那就是无论干点什么都有大量的随机读写请求。而随机读写请求如果请求最终是要落到硬盘上特别是HDD硬盘的话我们就很难做到高并发了。毕竟HDD硬盘只有100左右的QPS。
而这个随时添加索引可以根据任意字段进行查询这样表现出的灵活性又是我们的DMP系统里面不太需要的。DMP的KV数据库主要的应用场景是根据主键的随机查询不需要根据其他字段进行筛选查询。数据管道的需求则只需要不断追加写入和顺序读取就好了。即使进行数据分析的数据仓库通常也不是根据字段进行数据筛选而是全量扫描数据进行分析汇总。
后面的两个场景还好说大不了我们让程序去扫描全表或者追加写入。但是在KV数据库这个需求上刚才这个最简单的关系型数据库的设计就会面临大量的随机写入和随机读取的挑战。
所以在实际的大型系统中大家都会使用专门的分布式KV数据库来满足这个需求。那么下面我们就一起来看一看Facebook开源的Cassandra的数据存储和读写是怎么做的这些设计是怎么解决高并发的随机读写问题的。
## Cassandra顺序写和随机读
### Cassandra的数据模型
作为一个分布式的KV数据库Cassandra的键一般被称为Row Key。其实就是一个16到36个字节的字符串。每一个Row Key对应的值其实是一个哈希表里面可以用键值对再存入很多你需要的数据。
Cassandra本身不像关系型数据库那样有严格的Schema在数据库创建的一开始就定义好了有哪些列Column。但是它设计了一个叫作列族Column Family的概念我们需要把经常放在一起使用的字段放在同一个列族里面。比如DMP里面的人口属性信息我们可以把它当成是一个列族。用户的兴趣信息可以是另外一个列族。这样既保持了不需要严格的Schema这样的灵活性也保留了可以把常常一起使用的数据存放在一起的空间局部性。
往Cassandra的里面读写数据其实特别简单就好像是在一个巨大的分布式的哈希表里面写数据。我们指定一个Row Key然后插入或者更新这个Row Key的数据就好了。
### Cassandra的写操作
<img src="https://static001.geekbang.org/resource/image/02/58/02d58b12403f7907975e00549a008c58.jpeg" alt="">
Cassandra解决随机写入数据的解决方案简单来说就叫作“不随机写只顺序写”。对于Cassandra数据库的写操作通常包含两个动作。第一个是往磁盘上写入一条提交日志Commit Log。另一个操作则是直接在内存的数据结构上去更新数据。后面这个往内存的数据结构里面的数据更新只有在提交日志写成功之后才会进行。每台机器上都有一个可靠的硬盘可以让我们去写入提交日志。写入提交日志都是顺序写Sequential Write而不是随机写Random Write这使得我们最大化了写入的吞吐量。
如果你不明白这是为什么,可以回到[第47讲](https://time.geekbang.org/column/article/118191)看看硬盘的性能评测。无论是HDD硬盘还是SSD硬盘顺序写入都比随机写入要快得多。
内存的空间比较有限一旦内存里面的数据量或者条目超过一定的限额Cassandra就会把内存里面的数据结构dump到硬盘上。这个Dump的操作也是顺序写而不是随机写所以性能也不会是一个问题。除了Dump的数据结构文件Cassandra还会根据row key来生成一个索引文件方便后续基于索引来进行快速查询。
随着硬盘上的Dump出来的文件越来越多Cassandra会在后台进行文件的对比合并。在很多别的KV数据库系统里面也有类似这种的合并动作比如AeroSpike或者Google的BigTable。这些操作我们一般称之为Compaction。合并动作同样是顺序读取多个文件在内存里面合并完成再Dump出来一个新的文件。整个操作过程中在硬盘层面仍然是顺序读写。
### Cassandra的读操作
<img src="https://static001.geekbang.org/resource/image/68/b0/68855c2861f07417bbc2eb64672d36b0.jpeg" alt="">
当我们要从Cassandra读数据的时候会从内存里面找数据再从硬盘读数据然后把两部分的数据合并成最终结果。这些硬盘上的文件在内存里面会有对应的Cache只有在Cache里面找不到我们才会去请求硬盘里面的数据。
如果不得不访问硬盘因为硬盘里面可能Dump了很多个不同时间点的内存数据的快照。所以找数据的时候我们也是按照时间从新的往旧的里面找。
这也就带来另外一个问题我们可能要查询很多个Dump文件才能找到我们想要的数据。所以Cassandra在这一点上又做了一个优化。那就是它会为每一个Dump的文件里面所有Row Key生成一个BloomFilter然后把这个BloomFilter放在内存里面。这样如果想要查询的Row Key在数据文件里面不存在那么99%以上的情况下它会被BloomFilter过滤掉而不需要访问硬盘。
这样,只有当数据在内存里面没有,并且在硬盘的某个特定文件上的时候,才会触发一次对于硬盘的读请求。
## SSDDBA们的大救星
Cassandra是Facebook在2008年开源的。那个时候SSD硬盘还没有那么普及。可以看到它的读写设计充分考虑了硬件本身的特性。在写入数据进行持久化上Cassandra没有任何的随机写请求无论是Commit Log还是Dump全部都是顺序写。
在数据读的请求上最新写入的数据都会更新到内存。如果要读取这些数据会优先从内存读到。这相当于是一个使用了LRU的缓存机制。只有在万般无奈的情况下才会有对于硬盘的随机读请求。即使在这样的情况下Cassandra也在文件之前加了一层BloomFilter把本来因为Dump文件带来的需要多次读硬盘的问题简化成多次内存读和一次硬盘读。
这些设计使得Cassandra即使是在HDD硬盘上也能有不错的访问性能。因为所有的写入都是顺序写或者写入到内存所以写入可以做到高并发。HDD硬盘的吞吐率还是很不错的每秒可以写入100MB以上的数据如果一条数据只有1KB那么10万的WPSWrites per seconds也是能够做到的。这足够支撑我们DMP期望的写入压力了。
而对于数据的读就有一些挑战了。如果数据读请求有很强的局部性那我们的内存就能搞定DMP需要的访问量。
但是问题就出在这个局部性上。DMP的数据访问分布其实是缺少局部性的。你仔细想一想DMP的应用场景就明白了。DMP里面的Row Key都是用户的唯一标识符。普通用户的上网时长怎么会有局部性呢每个人上网的时间和访问网页的次数就那么多。上网多的人一天最多也就24小时。大部分用户一天也要上网23小时。我们没办法说把这些用户的数据放在内存里面那些用户不放。
<img src="https://static001.geekbang.org/resource/image/cf/ca/cf55146f8cf79029af6d1f86f3de86ca.jpeg" alt="">
那么我们可不可能有一定的时间局部性呢如果是Facebook那样的全球社交网络那可能还有一定的时间局部性。毕竟不同国家的人的时区不一样。我们可以说在印度人民的白天把印度人民的数据加载到内存里面美国人民的数据就放在硬盘上。到了印度人民的晚上再把美国人民的数据换到内存里面来。
如果你的主要业务是在国内,那这个时间局部性就没有了。大家的上网高峰时段,都是在早上上班路上、中午休息的时候以及晚上下班之后的时间,没有什么区分度。
面临这个情况如果你们的CEO或者CTO问你是不是可以通过优化程序来解决这个问题如果你没有仔细从数据分布和原理的层面思考这个问题而直接一口答应下来那你可能之后要头疼了因为这个问题很有可能是搞不定的。
因为缺少了时间局部性我们内存的缓存能够起到的作用就很小了大部分请求最终还是要落到HDD硬盘的随机读上。但是HDD硬盘的随机读的性能太差了我们在[第45讲](https://time.geekbang.org/column/article/116104)看过也就是100QPS左右。而如果全都放内存那就太贵了成本在HDD硬盘100倍以上。
不过幸运的是从2010年开始SSD硬盘的大规模商用帮助我们解决了这个问题。它的价格在HDD硬盘的10倍但是随机读的访问能力在HDD硬盘的百倍以上。也就是说用上了SSD硬盘我们可以用1/10的成本获得和内存同样的QPS。同样的价格的SSD硬盘容量则是内存的几十倍也能够满足我们的需求用较低的成本存下整个互联网用户信息。
不夸张地说过去十年的“大数据”“高并发”“千人千面”有一半的功劳应该归在让SSD容量不断上升、价格不断下降的硬盘产业上。
回到我们看到的Cassandra的读写设计你会发现Cassandra的写入机制完美匹配了我们在第46和47讲所说的SSD硬盘的优缺点。
在数据写入层面Cassandra的数据写入都是Commit Log的顺序写入也就是不断地在硬盘上往后追加内容而不是去修改现有的文件内容。一旦内存里面的数据超过一定的阈值Cassandra又会完整地Dump一个新文件到文件系统上。这同样是一个追加写入。
数据的对比和紧凑化Compaction同样是读取现有的多个文件然后写一个新的文件出来。写入操作只追加不修改的特性正好天然地符合SSD硬盘只能按块进行擦除写入的操作。在这样的写入模式下Cassandra用到的SSD硬盘不需要频繁地进行后台的Compaction能够最大化SSD硬盘的使用寿命。这也是为什么Cassandra在SSD硬盘普及之后能够获得进一步快速发展。
## 总结延伸
好了关于DMP和存储器的内容讲到这里就差不多了。希望今天的这一讲能够让你从Cassandra的数据库实现的细节层面彻底理解怎么运用好存储器的性能特性和原理。
传统的关系型数据库我们把一条条数据存放在一个地方同时再把索引存放在另外一个地方。这样的存储方式其实很方便我们进行单次的随机读和随机写数据的存储也可以很紧凑。但是问题也在于此大部分的SQL请求都会带来大量的随机读写的请求。这使得传统的关系型数据库其实并不适合用在真的高并发的场景下。
我们的DMP需要的访问场景其实没有复杂的索引需求但是会有比较高的并发性。我带你一看了Facebook开源的Cassandra这个分布式KV数据库的读写设计。通过在追加写入Commit Log和更新内存Cassandra避开了随机写的问题。内存数据的Dump和后台的对比合并同样也都避开了随机写的问题使得Cassandra的并发写入性能极高。
在数据读取层面通过内存缓存和BloomFilterCassandra已经尽可能地减少了需要随机读取硬盘里面数据的情况。不过挑战在于DMP系统的局部性不强使得我们最终的随机读的请求还是要到硬盘上。幸运的是SSD硬盘在数据海量增长的那几年里价格不断下降使得我们最终通过SSD硬盘解决了这个问题。
而SSD硬盘本身的擦除后才能写入的机制正好非常适合Cassandra的数据读写模式最终使得Cassandra在SSD硬盘普及之后得到了更大的发展。
## 推荐阅读
今天的推荐阅读,是一篇相关的论文。我推荐你去读一读[Cassandra - A Decentralized Structured Storage System](https://www.cs.cornell.edu/projects/ladis2009/papers/lakshman-ladis2009.pdf)。读完这篇论文一方面你会对分布式KV数据库的设计原则有所了解了解怎么去做好数据分片、故障转移、数据复制这些机制另一方面你可以看到基于内存和硬盘的不同存储设备的特性Cassandra是怎么有针对性地设计数据读写和持久化的方式的。
## 课后思考
除了MySQL这样的关系型数据库还有Cassandra这样的分布式KV数据库。实际上在海量数据分析的过程中还有一种常见的数据库叫作列式存储的OLAP的数据库比如[Clickhouse](https://clickhouse.yandex/)。你可以研究一下Clickhouse这样的数据库里面的数据是怎么存储在硬盘上的。
欢迎把你研究的结果写在留言区,和大家一起分享、交流。如果觉得有帮助,你也可以把这篇文章分享给你的朋友,和他一起讨论、学习。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="54 | 理解Disruptor带你体会CPU高速缓存的风驰电掣" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/97/b6b22346c2a148eb3396dc52b8952f97.mp3"></audio>
坚持到底就是胜利终于我们一起来到了专栏的最后一个主题。让我一起带你来看一看CPU到底能有多快。在接下来的两讲里我会带你一起来看一个开源项目Disruptor。看看我们怎么利用CPU和高速缓存的硬件特性来设计一个对于性能有极限追求的系统。
不知道你还记不记得,在[第37讲](https://time.geekbang.org/column/article/107477)里为了优化4毫秒专门铺设光纤的故事。实际上最在意极限性能的并不是互联网公司而是高频交易公司。我们今天讲解的Disruptor就是由一家专门做高频交易的公司LMAX开源出来的。
有意思的是Disruptor的开发语言并不是很多人心目中最容易做到性能极限的C/C++而是性能受限于JVM的Java。这到底是怎么一回事呢那通过这一讲你就能体会到其实只要通晓硬件层面的原理即使是像Java这样的高级语言也能够把CPU的性能发挥到极限。
## Padding Cache Line体验高速缓存的威力
我们先来看看Disruptor里面一段神奇的代码。这段代码里Disruptor在RingBufferPad这个类里面定义了p1p2一直到p7 这样7个long类型的变量。
```
abstract class RingBufferPad
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
```
我在看到这段代码的第一反应是变量名取得不规范p1-p7这样的变量名没有明确的意义啊。不过当我深入了解了Disruptor的设计和源代码才发现这些变量名取得恰如其分。因为这些变量就是没有实际意义只是帮助我们进行**缓存行填充**Padding Cache Line使得我们能够尽可能地用上CPU高速缓存CPU Cache。那么缓存行填充这个黑科技到底是什么样的呢我们接着往下看。
不知道你还记不记得,我们在[35讲](https://time.geekbang.org/column/article/107422)里面的这个表格。如果访问内置在CPU里的L1 Cache或者L2 Cache访问延时是内存的1/15乃至1/100。而内存的访问速度其实是远远慢于CPU的。想要追求极限性能需要我们尽可能地多从CPU Cache里面拿数据而不是从内存里面拿数据。
<img src="https://static001.geekbang.org/resource/image/d3/a6/d39b0f2b3962d646133d450541fb75a6.png" alt="">
CPU Cache装载内存里面的数据不是一个一个字段加载的而是加载一整个缓存行。举个例子如果我们定义了一个长度为64的long类型的数组。那么数据从内存加载到CPU Cache里面的时候不是一个一个数组元素加载的而是一次性加载固定长度的一个缓存行。
我们现在的64位Intel CPU的计算机缓存行通常是64个字节Bytes。一个long类型的数据需要8个字节所以我们一下子会加载8个long类型的数据。也就是说一次加载数组里面连续的8个数值。这样的加载方式使得我们遍历数组元素的时候会很快。因为后面连续7次的数据访问都会命中缓存不需要重新从内存里面去读取数据。这个性能层面的好处我在第37讲的第一个例子里面为你演示过印象不深的话可以返回去看看。
但是在我们不使用数组而是使用单独的变量的时候这里就会出现问题了。在Disruptor的RingBuffer环形缓冲区的代码里面定义了一个RingBufferFields类里面有indexMask和其他几个变量用来存放RingBuffer的内部状态信息。
<img src="https://static001.geekbang.org/resource/image/23/f6/23adbbc656243ce85fdb8c7fab42ecf6.jpeg" alt="">
CPU在加载数据的时候自然也会把这个数据从内存加载到高速缓存里面来。不过这个时候高速缓存里面除了这个数据还会加载这个数据前后定义的其他变量。这个时候问题就来了。Disruptor是一个多线程的服务器框架在这个数据前后定义的其他变量可能会被多个不同的线程去更新数据、读取数据。这些写入以及读取的请求会来自于不同的 CPU Core。于是为了保证数据的同步更新我们不得不把CPU Cache里面的数据重新写回到内存里面去或者重新从内存里面加载数据。
而我们刚刚说过这些CPU Cache的写回和加载都不是以一个变量作为单位的。这些动作都是以整个Cache Line作为单位的。所以当INITIAL_CURSOR_VALUE 前后的那些变量被写回到内存的时候,这个字段自己也写回到了内存,这个常量的缓存也就失效了。当我们要再次读取这个值的时候,要再重新从内存读取。这也就意味着,读取速度大大变慢了。
```
......
abstract class RingBufferPad
{
protected long p1, p2, p3, p4, p5, p6, p7;
}
abstract class RingBufferFields&lt;E&gt; extends RingBufferPad
{
......
private final long indexMask;
private final Object[] entries;
protected final int bufferSize;
protected final Sequencer sequencer;
......
}
public final class RingBuffer&lt;E&gt; extends RingBufferFields&lt;E&gt; implements Cursored, EventSequencer&lt;E&gt;, EventSink&lt;E&gt;
{
......
protected long p1, p2, p3, p4, p5, p6, p7;
......
}
```
<img src="https://static001.geekbang.org/resource/image/93/b1/9330b8fb1e8de3f62d34c6f85f268db1.jpeg" alt="">
面临这样一个情况Disruptor里发明了一个神奇的代码技巧这个技巧就是缓存行填充。Disruptor 在 RingBufferFields里面定义的变量的前后分别定义了7个long类型的变量。前面的7个来自继承的 RingBufferPad 类后面的7个则是直接定义在 RingBuffer 类里面。这14个变量没有任何实际的用途。我们既不会去读他们也不会去写他们。
而RingBufferFields里面定义的这些变量都是final的第一次写入之后不会再进行修改。所以一旦它被加载到CPU Cache之后只要被频繁地读取访问就不会再被换出Cache了。这也就意味着对于这个值的读取速度会是一直是CPU Cache的访问速度而不是内存的访问速度。
## 使用RingBuffer利用缓存和分支预测
其实这个利用CPU Cache的性能的思路贯穿了整个Disruptor。Disruptor整个框架其实就是一个高速的[生产者-消费者模型](https://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem)Producer-Consumer下的队列。生产者不停地往队列里面生产新的需要处理的任务而消费者不停地从队列里面处理掉这些任务。
<img src="https://static001.geekbang.org/resource/image/65/56/659082942118e7c69eb3807b00f5f556.jpeg" alt="">
如果你熟悉算法和数据结构,那你应该非常清楚,如果要实现一个队列,最合适的数据结构应该是链表。我们只要维护好链表的头和尾,就能很容易实现一个队列。生产者只要不断地往链表的尾部不断插入新的节点,而消费者只需要不断从头部取出最老的节点进行处理就好了。我们可以很容易实现生产者-消费者模型。实际上Java自己的基础库里面就有LinkedBlockingQueue这样的队列库可以直接用在生产者-消费者模式上。
<img src="https://static001.geekbang.org/resource/image/45/0e/45d4c7c8b0cb1f056684199e39660f0e.jpeg" alt="">
不过Disruptor里面并没有用LinkedBlockingQueue而是使用了一个RingBuffer这样的数据结构这个RingBuffer的底层实现则是一个固定长度的数组。比起链表形式的实现数组的数据在内存里面会存在空间局部性。
就像上面我们看到的数组的连续多个元素会一并加载到CPU Cache里面来所以访问遍历的速度会更快。而链表里面各个节点的数据多半不会出现在相邻的内存空间自然也就享受不到整个Cache Line加载后数据连续从高速缓存里面被访问到的优势。
除此之外数据的遍历访问还有一个很大的优势就是CPU层面的分支预测会很准确。这可以使得我们更有效地利用了CPU里面的多级流水线我们的程序就会跑得更快。这一部分的原理如果你已经不太记得了可以回过头去复习一下[第25讲](https://time.geekbang.org/column/article/102166)关于分支预测的内容。
## 总结延伸
好了不知道讲完这些你有没有体会到Disruptor这个框架的神奇之处呢
CPU从内存加载数据到CPU Cache里面的时候不是一个变量一个变量加载的而是加载固定长度的Cache Line。如果是加载数组里面的数据那么CPU就会加载到数组里面连续的多个数据。所以数组的遍历很容易享受到CPU Cache那风驰电掣的速度带来的红利。
对于类里面定义的单独的变量就不容易享受到CPU Cache红利了。因为这些字段虽然在内存层面会分配到一起但是实际应用的时候往往没有什么关联。于是就会出现多个CPU Core访问的情况下数据频繁在CPU Cache和内存里面来来回回的情况。而Disruptor很取巧地在需要频繁高速访问的变量也就是RingBufferFields里面的indexMask这些字段前后各定义了7个没有任何作用和读写请求的long类型的变量。
这样无论在内存的什么位置上这些变量所在的Cache Line都不会有任何写更新的请求。我们就可以始终在Cache Line里面读到它的值而不需要从内存里面去读取数据也就大大加速了Disruptor的性能。
这样的思路其实渗透在Disruptor这个开源框架的方方面面。作为一个生产者-消费者模型Disruptor并没有选择使用链表来实现一个队列而是使用了RingBuffer。RingBuffer底层的数据结构则是一个固定长度的数组。这个数组不仅让我们更容易用好CPU Cache对CPU执行过程中的分支预测也非常有利。更准确的分支预测可以使得我们更好地利用好CPU的流水线让代码跑得更快。
## 推荐阅读
今天讲的是Disruptor推荐的阅读内容自然是Disruptor的官方文档。作为一个开源项目Disruptor在自己[GitHub](https://github.com/LMAX-Exchange/disruptor/wiki/Introduction)上有很详细的设计文档,推荐你好好阅读一下。
这里面不仅包含了怎么用好Disruptor也包含了整个Disruptor框架的设计思路是一份很好的阅读学习材料。另外Disruptor的官方文档里还有很多文章、演讲详细介绍了这个框架很值得深入去看一看。Disruptor的源代码其实并不复杂很适合用来学习怎么阅读开源框架代码。
## 课后思考
今天我们讲解了缓存行填充你可以试试修改Disruptor的代码看看在没有缓存行填充和有缓存行填充的情况下的性能差异。你也可以尝试直接修改Disruptor的源码和[性能测试代码](https://github.com/LMAX-Exchange/disruptor/blob/master/src/perftest/java/com/lmax/disruptor/immutable/CustomPerformanceTest.java),看看运行的结果是什么样的。
欢迎你把你的测试结果写在留言区,和大家一起讨论、分享。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,208 @@
<audio id="audio" title="55 | 理解Disruptor不需要换挡和踩刹车的CPU有多快" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/4c/5953ad076c9473cd3bfa3dd03be9064c.mp3"></audio>
上一讲我们学习了一个精妙的想法Disruptor通过缓存行填充来利用好CPU的高速缓存。不知道你做完课后思考题之后有没有体会到高速缓存在实践中带来的速度提升呢
不过利用CPU高速缓存只是Disruptor“快”的一个因素那今天我们就来看一看Disruptor快的另一个因素也就是“无锁”而尽可能发挥CPU本身的高速处理性能。
## 缓慢的锁
Disruptor作为一个高性能的生产者-消费者队列系统一个核心的设计就是通过RingBuffer实现一个无锁队列。
上一讲里我们讲过Java里面的基础库里就有像LinkedBlockingQueue这样的队列库。但是这个队列库比起Disruptor里用的RingBuffer要慢上很多。慢的第一个原因我们说过因为链表的数据在内存里面的布局对于高速缓存并不友好而RingBuffer所使用的数组则不然。
<img src="https://static001.geekbang.org/resource/image/9c/69/9ce732cb22c49a8a26e870dddde66b69.jpeg" alt="">
LinkedBlockingQueue慢有另外一个重要的因素那就是它对于锁的依赖。在生产者-消费者模式里,我们可能有多个消费者,同样也可能有多个生产者。多个生产者都要往队列的尾指针里面添加新的任务,就会产生多个线程的竞争。于是,在做这个事情的时候,生产者就需要拿到对于队列尾部的锁。同样地,在多个消费者去消费队列头的时候,也就产生竞争。同样消费者也要拿到锁。
那只有一个生产者,或者一个消费者,我们是不是就没有这个锁竞争的问题了呢?很遗憾,答案还是否定的。一般来说,在生产者-消费者模式下,消费者要比生产者快。不然的话,队列会产生积压,队列里面的任务会越堆越多。
一方面,你会发现越来越多的任务没有能够及时完成;另一方面,我们的内存也会放不下。虽然生产者-消费者模型下,我们都有一个队列来作为缓冲区,但是大部分情况下,这个缓冲区里面是空的。也就是说,即使只有一个生产者和一个消费者者,这个生产者指向的队列尾和消费者指向的队列头是同一个节点。于是,这两个生产者和消费者之间一样会产生锁竞争。
在LinkedBlockingQueue上这个锁机制是通过ReentrantLock这个Java 基础库来实现的。这个锁是一个用Java在JVM上直接实现的加锁机制这个锁机制需要由JVM来进行裁决。这个锁的争夺会把没有拿到锁的线程挂起等待也就需要经过一次上下文切换Context Switch
不知道你还记不记得,我们在[第28讲](https://time.geekbang.org/column/article/103717)讲过的异常和中断,这里的上下文切换要做的和异常和中断里的是一样的。上下文切换的过程,需要把当前执行线程的寄存器等等的信息,保存到线程栈里面。而这个过程也必然意味着,已经加载到高速缓存里面的指令或者数据,又回到了主内存里面,会进一步拖慢我们的性能。
我们可以按照Disruptor介绍资料里提到的Benchmark写一段代码来看看是不是真是这样的。这里我放了一段Java代码代码的逻辑很简单就是把一个long类型的counter从0自增到5亿。一种方式是没有任何锁另外一个方式是每次自增的时候都要去取一个锁。
你可以在自己的电脑上试试跑一下这个程序。在我这里两个方式执行所需要的时间分别是207毫秒和9603毫秒性能差出了将近50倍。
```
package com.xuwenhao.perf.jmm;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockBenchmark{
public static void runIncrement()
{
long counter = 0;
long max = 500000000L;
long start = System.currentTimeMillis();
while (counter &lt; max) {
counter++;
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end-start) + &quot;ms without lock&quot;);
}
public static void runIncrementWithLock()
{
Lock lock = new ReentrantLock();
long counter = 0;
long max = 500000000L;
long start = System.currentTimeMillis();
while (counter &lt; max) {
if (lock.tryLock()){
counter++;
lock.unlock();
}
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end-start) + &quot;ms with lock&quot;);
}
public static void main(String[] args) {
runIncrement();
runIncrementWithLock();
```
```
Time spent is 207ms without lock
Time spent is 9603ms with lock
```
## 无锁的RingBuffer
加锁很慢所以Disruptor的解决方案就是“无锁”。这个“无锁”指的是没有操作系统层面的锁。实际上Disruptor还是利用了一个CPU硬件支持的指令称之为CASCompare And Swap比较和交换。在Intel CPU里面这个对应的指令就是 cmpxchg。那么下面我们就一起从Disruptor的源码到具体的硬件指令来看看这是怎么一回事儿。
Disruptor的RingBuffer是这么设计的它和直接在链表的头和尾加锁不同。Disruptor的RingBuffer创建了一个Sequence对象用来指向当前的RingBuffer的头和尾。这个头和尾的标识呢不是通过一个指针来实现的而是通过一个**序号**。这也是为什么对应源码里面的类名叫Sequence。
<img src="https://static001.geekbang.org/resource/image/b6/ec/b64487a7b6b45393fdfa7e2d63e176ec.jpeg" alt="">
在这个RingBuffer当中进行生产者和消费者之间的资源协调采用的是对比序号的方式。当生产者想要往队列里加入新数据的时候它会把当前的生产者的Sequence的序号加上需要加入的新数据的数量然后和实际的消费者所在的位置进行对比看看队列里是不是有足够的空间加入这些数据而不会覆盖掉消费者还没有处理完的数据。
在Sequence的代码里面就是通过compareAndSet这个方法并且最终调用到了UNSAFE.compareAndSwapLong也就是直接使用了CAS指令。
```
public boolean compareAndSet(final long expectedValue, final long newValue)
{
return UNSAFE.compareAndSwapLong(this, VALUE_OFFSET, expectedValue, newValue);
}
public long addAndGet(final long increment)
{
long currentValue;
long newValue;
do
{
currentValue = get();
newValue = currentValue + increment;
}
while (!compareAndSet(currentValue, newValue));
return newValue;
```
这个CAS指令也就是比较和交换的操作并不是基础库里的一个函数。它也不是操作系统里面实现的一个系统调用而是**一个CPU硬件支持的机器指令**。在我们服务器所使用的Intel CPU上就是cmpxchg这个指令。
```
compxchg [ax] (隐式参数EAX累加器), [bx] (源操作数地址), [cx] (目标操作数地址)
```
cmpxchg指令一共有三个操作数第一个操作数不在指令里面出现是一个隐式的操作数也就是EAX累加寄存器里面的值。第二个操作数就是源操作数并且指令会对比这个操作数和上面的累加寄存器里面的值。
如果值是相同的那一方面CPU会把ZF也就是条件码寄存器里面零标志位的值设置为1然后再把第三个操作数也就是目标操作数设置到源操作数的地址上。如果不相等的话就会把源操作数里面的值设置到累加器寄存器里面。
我在这里放了这个逻辑对应的伪代码,你可以看一下。如果你对汇编指令、条件码寄存器这些知识点有点儿模糊了,可以回头去看看[第5](https://time.geekbang.org/column/article/93359)[](https://time.geekbang.org/column/article/93359)、[第6讲](https://time.geekbang.org/column/article/94075)关于汇编指令的部分。
```
IF [ax]&lt; == [bx] THEN [ZF] = 1, [bx] = [cx]
ELSE [ZF] = 0, [ax] = [bx]
```
单个指令是原子的这也就意味着在使用CAS操作的时候我们不再需要单独进行加锁直接调用就可以了。
没有了锁CPU这部高速跑车就像在赛道上行驶不会遇到需要上下文切换这样的红灯而停下来。虽然会遇到像CAS这样复杂的机器指令就好像赛道上会有U型弯一样不过不用完全停下来等待我们CPU运行起来仍然会快很多。
那么CAS操作到底会有多快呢我们还是用一段Java代码来看一下。
```
package com.xuwenhao.perf.jmm;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockBenchmark {
public static void runIncrementAtomic()
{
AtomicLong counter = new AtomicLong(0);
long max = 500000000L;
long start = System.currentTimeMillis();
while (counter.incrementAndGet() &lt; max) {
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end-start) + &quot;ms with cas&quot;);
}
public static void main(String[] args) {
runIncrementAtomic();
}
```
```
Time spent is 3867ms with cas
```
和上面的counter自增一样只不过这一次自增我们采用了AtomicLong这个Java类。里面的incrementAndGet最终到了CPU指令层面在实现的时候用的就是CAS操作。可以看到它所花费的时间虽然要比没有任何锁的操作慢上一个数量级但是比起使用ReentrantLock这样的操作系统锁的机制还是减少了一半以上的时间。
## 总结延伸
好了咱们专栏的正文内容到今天就要结束了。今天最后一讲我带着你一起看了Disruptor代码的一个核心设计也就是它的RingBuffer是怎么做到无锁的。
Java基础库里面的BlockingQueue都需要通过显示地加锁来保障生产者之间、消费者之间乃至生产者和消费者之间不会发生锁冲突的问题。
但是加锁会大大拖慢我们的性能。在获取锁过程中CPU没有去执行计算的相关指令而要等待操作系统或者JVM来进行锁竞争的裁决。而那些没有拿到锁而被挂起等待的线程则需要进行上下文切换。这个上下文切换会把挂起线程的寄存器里的数据放到线程的程序栈里面去。这也意味着加载到高速缓存里面的数据也失效了程序就变得更慢了。
Disruptor里的RingBuffer采用了一个无锁的解决方案通过CAS这样的操作去进行序号的自增和对比使得CPU不需要获取操作系统的锁。而是能够继续顺序地执行CPU指令。没有上下文切换、没有操作系统锁自然程序就跑得快了。不过因为采用了CAS这样的忙等待Busy-Wait的方式会使得我们的CPU始终满负荷运转消耗更多的电算是一个小小的缺点。
程序里面的CAS调用映射到我们的CPU硬件层面就是一个机器指令这个指令就是cmpxchg。可以看到当想要追求最极致的性能的时候我们会从应用层、贯穿到操作系统乃至最后的CPU硬件搞清楚从高级语言到系统调用乃至最后的汇编指令这整个过程是怎么执行代码的。而这个也是学习组成原理这门专栏的意义所在。
## 推荐阅读
不知道上一讲说的Disruptor相关材料你有没有读完呢如果没有读完的话我建议你还是先去研读一下。
如果你已经读完了,这里再给你推荐一些额外的阅读材料,那就是著名的[Implement Lock-Free Queues](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.53.8674&amp;rep=rep1&amp;type=pdf)这篇论文。你可以更深入地学习一下,怎么实现一个无锁队列。
## 课后思考
最后,给你留一道思考题。这道题目有点儿难,不过也很有意思。
请你阅读一下Disruptor开源库里面的Sequence这个类的代码看看它和一个普通的AtomicLong到底有什么区别以及为什么它要这样实现。
欢迎在留言区写下你的思考和答案,和大家一起探讨应用层和硬件层之间的关联性。如果有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,55 @@
<audio id="audio" title="结束语 | 知也无涯,愿你也享受发现的乐趣" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/01/75ddc98274e0698bc47e39dc8eaf4b01.mp3"></audio>
你好,我是徐文浩。伴随着无数个不眠之夜,“深入浅出计算机组成原理”专栏终于来到了结束语。
去年11月份极客时间找到我我开始构思这个专栏。本以为今年4、5月份就能把专栏写完。结果一方面因为创业过程中时间总是不够用另一方面写出有价值内容的并不是一件容易的事情直到9月10号的凌晨我才写完这最后一篇结束语。原本计划的45讲也在这个过程中变成了近60讲。现在回过去看写这个“深入浅出计算机组成原理”专栏是一个远比想象中要困难的挑战但同时也是一个有趣的发现之旅。
## 完成比完美更好
Facebook的文化里面喜欢用各种小标语其中有一条我很喜欢“Done is better than perfect”。翻译成中文就是“完成比完美更好”。写这个专栏的时候我对这一点的体会特别深刻。在学习更多深入知识的时候我希望你也可以抱有这样的态度。
在初期构思专栏的时候,我期望写成一个完美的专栏。不过随着时间的推移,我发现其实并没有什么完美可言。
一方面,组成原理的知识点很多,如果每一个都写下来,没有个一两百讲怕是讲不完。更何况有那么多大师的教科书珠玉在前,只是做解读知识点、覆盖已有的知识点,我觉得价值不大。思来想去,我希望尽可能找到最重要、最核心的知识点,以及能和大多数工程师日常工作有结合的知识点,希望能够从应用中多给你一些启发。
另一方面写专栏和我们写程序一样都是有deadline的。无论是在系统发版之后的午夜里还是去美国出差的飞机上乃至偶尔忘带了录音笔的时候总是要打起精神想尽方法写出一篇让自己满意的文章来。同时也有不少同学给我挑出了错漏或者不准确的部分一起把这个专栏打磨地更“完美”。
不知道正在读结束语的你有没有在过去5个月里坚持学习这个专栏呢有没有认真阅读我每一节后的推荐阅读呢有没有尝试去做一做每一讲后面的思考题呢
如果你能够坚持下来,那首先要恭喜你,我相信能够学完的同学并不太多。如果你还没有学完,也不要紧,先跟着整个课程走一遍,有个大致印象。与其半途而费,不如先囫囵吞枣,硬着头皮看完再说。**新的知识第一遍没有百分百看懂,而随着时间的推移,慢慢领悟成长了,这才是人生的常态。而我所见到的优秀的工程师大都会经历这样的成长过程。**
我们这个行业,经常喜欢把软件开发和建筑放在一起类比,所以才会有经典的《设计模式》这样的书。甚至有不少人干脆从《建筑的永恒之道》里面去寻找灵感。然而,建筑能够在历史上留下长久的刻印,但是软件却完全不同。无论多么完美的代码都会不断迭代,就好像新陈代谢一样。几年过去之后,最初那些代码的踪影早已经没有了。软件工程师放弃了追求永恒,而是投身在创作的快乐之中。
希望在日后的学习过程中,你也能抱着“日拱一卒、不期速成”的心态坚持下去,不断地学习、反思、练习、再学习,这样的迭代才是最快的成长之路。
## 知也无涯,愿你享受发现的乐趣
说实话,从构思到写作这个专栏,这整个过程对我来说,还是有些忐忑的。组成原理是一门离大部分工程师的日常工作比较远的话题,却又是一个很多经典教材会讲的主题。“到底从什么角度去切入讲解”,我在构思文章的时候常常问自己。
组成原理其实是一门类似于“计算机科学101”的课程固然我可以在里面讲VHDL这样的硬件编程语言不过说实话这样的知识对于大部分的人意义并不大。我期望能够通过这个专栏让你体会到计算机科学知识是真的有用的能够让你把学专栏的过程变成一个发现之旅。
比如在学习HDD硬盘原理的时候你能知道为什么用它来记录日志很好但是拿来作为KV数据库就很糟糕在学习CPU Cache的时候你实际用代码体会一下它有多快为什么Disruptor里面的缓存行填充这样的小技巧能够把性能发挥到极致。
除此之外,撰写整个专栏的过程,也是我对自己的一个发现之旅。
虽然在过去开发大型系统的时候,已经体会到掌握各种计算机科学基础知识的重要性,但是,这个专栏还是给了我一个系统性地、对基础知识回顾和整理的机会,在忙碌的日常工作之外,在离开学校那么多年后,重新把基础的理论知识和实际的系统开发做了一一印证。
在这个过程中,对我自己是一个温故而知新的过程,我自己新学到不少过去不了解的知识点,也因此重新找到了很多新的技术兴奋点。乃至在专栏写了一半的时候,我特地在出差的空隙跑了一趟计算机历史博物馆,去感受创造新事物的那种激动人心的感觉。
不过,在这整个过程中,我也深深体会到了内容创作的难。
过去这10个月里持续地写稿、画图、写实验程序在编辑的反馈下再改稿和录音对我也是一个全新的体验。没有思路、时间不够、工作和写稿压力太大的时候抓狂、发脾气、骂人都发生过。如果没有编辑在背后一直督促着只靠自律我想我无论如何也不可能写完这样一个规模的专栏。
但是,我相信只有不断地逼迫自己走出习惯的舒适区,去尝试、体验新的挑战,才会进一步的成长。而很多未来的机会,也孕育在其间。就像史蒂夫·乔布斯说的,我们未来生活的可能性就是靠这些点点滴滴串联起来的。
也许你今天只是在学校写简单的课程管理系统可能会觉得有些无聊。抽一些时间出来去了解计算机科学的底层知识可能会让你找到求知的乐趣无形中这也为你去解决更有挑战的问题做好了铺垫。就像我自己在过去研究底层的数据系统、写技术博客的时候也没有想到会有机会写上这样一个20万字以上的专栏。
就像罗素说的那样,“对爱的渴望,对知识的追求,对人类苦难不可遏制的同情,是支配我一生的单纯而强烈的三种情感”。
我希望,在学习成长的过程中,你能够摆脱一些功利性,不用去回避遇到的痛苦和挫败感,多从这个过程中找到获得知识的快乐。
希望这个专栏能够给你带来发现的乐趣,也能够为你在未来的生活里铺垫上那小小的一步。相信这个专栏不是你学习的终点,也也不是我探索和发现新主题的终点。说不定,在不久的未来我们还会有缘再见。
对了我在文章末尾放了一个毕业调查问卷。在这5个月的学习过程中如果你对这个专栏或者我本人有什么建议可以通过这个问卷给我反馈我一定会认真查看每一封的内容。期待你的反馈
[<img src="https://static001.geekbang.org/resource/image/9a/9b/9a6d167383d98db7d72770ba07f8009b.jpg" alt="">](https://jinshuju.net/f/Nurf3L)