mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
del
This commit is contained in:
@@ -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. 低成本:我们还是从广告系统的角度来考虑。广告系统的收入通常用CPM(Cost 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硬盘,又会带来哪些好处和坏处呢?
|
||||
|
||||
请你仔细思考一下,也可以和周围的朋友分享讨论。如果你觉得有所收获,也请你把你的想法写在留言区,分享给其他的同学。
|
||||
119
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/53 | 设计大型DMP系统(下):SSD拯救了所有的DBA.md
Normal file
119
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/53 | 设计大型DMP系统(下):SSD拯救了所有的DBA.md
Normal 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过滤掉,而不需要访问硬盘。
|
||||
|
||||
这样,只有当数据在内存里面没有,并且在硬盘的某个特定文件上的时候,才会触发一次对于硬盘的读请求。
|
||||
|
||||
## SSD:DBA们的大救星
|
||||
|
||||
Cassandra是Facebook在2008年开源的。那个时候,SSD硬盘还没有那么普及。可以看到,它的读写设计充分考虑了硬件本身的特性。在写入数据进行持久化上,Cassandra没有任何的随机写请求,无论是Commit Log还是Dump,全部都是顺序写。
|
||||
|
||||
在数据读的请求上,最新写入的数据都会更新到内存。如果要读取这些数据,会优先从内存读到。这相当于是一个使用了LRU的缓存机制。只有在万般无奈的情况下,才会有对于硬盘的随机读请求。即使在这样的情况下,Cassandra也在文件之前加了一层BloomFilter,把本来因为Dump文件带来的需要多次读硬盘的问题,简化成多次内存读和一次硬盘读。
|
||||
|
||||
这些设计,使得Cassandra即使是在HDD硬盘上,也能有不错的访问性能。因为所有的写入都是顺序写或者写入到内存,所以,写入可以做到高并发。HDD硬盘的吞吐率还是很不错的,每秒可以写入100MB以上的数据,如果一条数据只有1KB,那么10万的WPS(Writes per seconds)也是能够做到的。这足够支撑我们DMP期望的写入压力了。
|
||||
|
||||
而对于数据的读,就有一些挑战了。如果数据读请求有很强的局部性,那我们的内存就能搞定DMP需要的访问量。
|
||||
|
||||
但是,问题就出在这个局部性上。DMP的数据访问分布,其实是缺少局部性的。你仔细想一想DMP的应用场景就明白了。DMP里面的Row Key都是用户的唯一标识符。普通用户的上网时长怎么会有局部性呢?每个人上网的时间和访问网页的次数就那么多。上网多的人,一天最多也就24小时。大部分用户一天也要上网2~3小时。我们没办法说,把这些用户的数据放在内存里面,那些用户不放。
|
||||
|
||||
<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的并发写入性能极高。
|
||||
|
||||
在数据读取层面,通过内存缓存和BloomFilter,Cassandra已经尽可能地减少了需要随机读取硬盘里面数据的情况。不过挑战在于,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这样的数据库里面的数据是怎么存储在硬盘上的。
|
||||
|
||||
欢迎把你研究的结果写在留言区,和大家一起分享、交流。如果觉得有帮助,你也可以把这篇文章分享给你的朋友,和他一起讨论、学习。
|
||||
@@ -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这个类里面定义了p1,p2一直到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<E> extends RingBufferPad
|
||||
{
|
||||
......
|
||||
private final long indexMask;
|
||||
private final Object[] entries;
|
||||
protected final int bufferSize;
|
||||
protected final Sequencer sequencer;
|
||||
......
|
||||
}
|
||||
|
||||
public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E>
|
||||
{
|
||||
......
|
||||
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),看看运行的结果是什么样的。
|
||||
|
||||
欢迎你把你的测试结果写在留言区,和大家一起讨论、分享。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -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 < max) {
|
||||
counter++;
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end-start) + "ms without lock");
|
||||
}
|
||||
|
||||
|
||||
public static void runIncrementWithLock()
|
||||
{
|
||||
Lock lock = new ReentrantLock();
|
||||
long counter = 0;
|
||||
long max = 500000000L;
|
||||
long start = System.currentTimeMillis();
|
||||
while (counter < max) {
|
||||
if (lock.tryLock()){
|
||||
counter++;
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end-start) + "ms with lock");
|
||||
}
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
runIncrement();
|
||||
runIncrementWithLock();
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Time spent is 207ms without lock
|
||||
Time spent is 9603ms with lock
|
||||
|
||||
```
|
||||
|
||||
## 无锁的RingBuffer
|
||||
|
||||
加锁很慢,所以Disruptor的解决方案就是“无锁”。这个“无锁”指的是没有操作系统层面的锁。实际上,Disruptor还是利用了一个CPU硬件支持的指令,称之为CAS(Compare 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]< == [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() < max) {
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("Time spent is " + (end-start) + "ms with cas");
|
||||
}
|
||||
|
||||
|
||||
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&rep=rep1&type=pdf)这篇论文。你可以更深入地学习一下,怎么实现一个无锁队列。
|
||||
|
||||
## 课后思考
|
||||
|
||||
最后,给你留一道思考题。这道题目有点儿难,不过也很有意思。
|
||||
|
||||
请你阅读一下Disruptor开源库里面的Sequence这个类的代码,看看它和一个普通的AtomicLong到底有什么区别,以及为什么它要这样实现。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,和大家一起探讨应用层和硬件层之间的关联性。如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
|
||||
|
||||
55
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/结束语 | 知也无涯,愿你也享受发现的乐趣.md
Normal file
55
极客时间专栏/geek/深入浅出计算机组成原理/应用篇/结束语 | 知也无涯,愿你也享受发现的乐趣.md
Normal 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)
|
||||
Reference in New Issue
Block a user