mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-16 15:03:44 +08:00
mod
This commit is contained in:
94
极客时间专栏/消息队列高手课/进阶篇/09 | 学习开源代码该如何入手?.md
Normal file
94
极客时间专栏/消息队列高手课/进阶篇/09 | 学习开源代码该如何入手?.md
Normal file
@@ -0,0 +1,94 @@
|
||||
<audio id="audio" title="09 | 学习开源代码该如何入手?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/88/b3/88e3de103ed4561832f9be077ce143b3.mp3"></audio>
|
||||
|
||||
你好,我是李玥。对于很多开源软件来说,如果我们把它作为我们业务系统的重要组成部分之一,真正地用于生产,仅仅知道如何使用是远远不够的,你必须掌握它的实现原理和很多细节,这样才能找到最佳的使用姿势,当你的系统出现问题时,你才有可能基于它的实现原理,再根据一些现象来排查问题原因。
|
||||
|
||||
掌握这些开源软件的最佳方式就是去学习它的源代码。很多同学跟我说:“我也很想去看一些开源软件的代码,也尝试去看过,但是面对上千个源码文件,几十万行代码,完全不知道从哪儿入手啊。”
|
||||
|
||||
这节课我们就针对这个情况来聊一聊,学习开源软件的代码该如何入手。
|
||||
|
||||
有一点我提前说明一下,对于这节课里面涉及到的一些名词,我会直接使用英文,主要目的是方便你直接对应到那些开源软件英文官网上的标题。
|
||||
|
||||
## 通过文档来了解开源项目
|
||||
|
||||
学习源代码应该从哪儿入手呢?**最佳的方式就是先看它的文档。**
|
||||
|
||||
通过看文档,你可以快速地掌握这个软件整体的结构,它有哪些功能特性,它涉及到的关键技术、实现原理和它的生态系统等等。在掌握了这些之后,你对它有个整体的了解,然后再去看它的源代码,就不会再有那种盲人摸象找不到头绪的感觉了。
|
||||
|
||||
首先强调一点是,你必须去看这些开源软件官方网站上的文档,尽量不要去网上搜一些翻译的中文文档。为什么呢?
|
||||
|
||||
因为这些开源软件,特别是一些社区活跃的软件,它的迭代是很快的,即使是自带官方中文翻译的项目,它的中文文档很多都会落后于英文版,你能看到的中文版本很多时候都已经过时了。那非官方的翻译,问题可能就不止是过时的问题了,可能还会出现一些错漏的地方。所以,最好还是直接来看官方的英文文档。
|
||||
|
||||
如果说你的英文阅读水平确实有限,直接阅读英文文档有困难或者看得非常慢,怎么办?你还是要按照我接下来告诉你的方法去看它的英文官网,即使阅读大段的技术文章有困难,网站的标题你总能看懂吧?找到你需要阅读的文章后,你可以在网上搜一下对应的中文版本,先看一遍中文版,然后再对着英文原版过一遍,弥补中文版可能过时或翻译不准确的问题。
|
||||
|
||||
开源社区经过这么多年的发展,它已经形成一个相对比较成熟的文化。每个开源软件,代码如何管理、社区成员如何沟通、如何协作这些都已经形成了一个比较固定的套路。大多数开源软件,它的官网和技术文档也是有一个相对比较固定的结构的。
|
||||
|
||||
接下来我们以[Kafka的官网](http://kafka.apache.org)为例子,来说下怎么来看它的文档。
|
||||
|
||||
如果说你对这个项目完全不了解,没用过这个软件,你首先需要看的文档是[Quick Start](http://kafka.apache.org/documentation/#quickstart),按照Quick Start中的指导快速把它的环境搭起来,把它运行起来,这样你会对这个项目有个感性认识,也便于你在后续深入学习的时候“跑”一些例子。
|
||||
|
||||
然后你需要找一下它的[Introduction](http://kafka.apache.org/documentation/#introduction),一般里面会有项目的基本介绍。这里面很重要的一点是,你需要找到这个项目用到的一些基本概念或者名词的介绍文档,在Kafka的文档中,这些内容就在Introduction里面,比如Topic、Producer、 Consumer、Partition这些概念在Kafka中代表的含义。
|
||||
|
||||
有些开源项目会单独有一个Basic Concepts文档来讲这些基础概念。这个文档非常重要,因为这些开源社区的开发者都有个很不好的爱好:发明概念。很多开源项目都会自己创造一些名词或者概念,了解这些基本概念才有可能看懂它项目的其他文档。
|
||||
|
||||
对项目有个基本的了解之后呢,接下来你可以看一下它的使用场景、功能特性以及相关的生态系统的介绍。在Kafka中功能相关的内容在[Use cases](http://kafka.apache.org/documentation/#uses)和[EcoSystem](http://cwiki.apache.org/confluence/display/KAFKA/Ecosystem)两篇文章中,有些项目中会有类似名为Features的文档介绍功能和特性。
|
||||
|
||||
其中项目的生态系统,也就是EcoSystem,一般会介绍它这个项目适用的一些典型的使用场景,在某个场景下适合与哪些其他的系统一起来配合使用等。如果说你的系统不是特别特殊或者说冷门的话,你大概率可以在EcoSystem里面找到和你类似的场景,可以少走很多的弯路。
|
||||
|
||||
你在读完上面这些文档之后,对这个项目的整体应该会有一个比较全面的了解了,比如说:
|
||||
|
||||
- 这个项目是干什么的?
|
||||
- 能解决哪些问题?
|
||||
- 适合在哪些场景使用?
|
||||
- 有哪些功能?
|
||||
- 如何使用?
|
||||
|
||||
对这些问题有一个初步的答案之后,接下来你就可以去深入学习它的实现原理了。这是不是意味着,你可以立即去看它的源码呢?这样做或许可行,但并不是最好的方法。
|
||||
|
||||
你知道大部分开源项目都是怎么诞生的吗?一般来说是这样的:某个大学或者大厂的科学家,某天脑海里突然出现了一个改变世界的想法,科学家们会基于这个想法做一些深入的研究,然后写了一篇论文在某个学术期刊或者会议上发表。论文发表后在业内获得很多的赞,这时候就轮到像Google、Facebook这样的大厂出手了:这个论文很有价值,不如我们把它实现出来吧?一个开源项目就这样诞生了。
|
||||
|
||||
所以,对于这样的开源项目,它背后的这篇论文就是整个项目的灵魂,你如果能把这篇论文看完并且理解透了,这个项目的实现原理也就清楚了。
|
||||
|
||||
对于Kafka来说,它的灵魂是这篇博文:[The Log: What every software engineer should know about real-time data’s unifying abstraction](http://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying),对应的中文译稿在这里:《[日志:每个软件工程师都应该知道的有关实时数据的统一抽象](http://www.kancloud.cn/kancloud/log-real-time-datas-unifying/58708)》。
|
||||
|
||||
这篇博文被评为[程序员史诗般必读文章](http://bryanpendleton.blogspot.hk/2014/01/the-log-epic-software-engineering.html),无论你是不是想了解Kafka的实现原理,我都强烈推荐你好好读一下上面这篇博文。
|
||||
|
||||
学习完项目灵魂,就可以开始阅读源码了。
|
||||
|
||||
## 用以点带面的方式来阅读源码
|
||||
|
||||
需要注意的是,你在读源码的时候,千万不要上来就找main方法这样泛泛地去看,为什么?你可以想一下,一篇文章,它是一个线性结构,你从前往后读就行了。一本书呢?如果我们看目录的话,可以认为是个树状结构,但大多数的书的内容还是按照线性结构来组织的,你可以从前往后读,也可以通过目录跳着读。
|
||||
|
||||
那程序的源代码是什么结构?那是一个网状结构,关系错综复杂,所以这种结构是非常不适合人类去阅读的。你如果是泛泛去读源代码,很容易迷失在这个代码织成的网里面。那怎么办?
|
||||
|
||||
我推荐大家阅读源码的方式是,**带着问题去读源码,最好是带着问题的答案去读源码。**你每次读源码之前,确定一个具体的问题,比如:
|
||||
|
||||
- RocketMQ的消息是怎么写到文件里的?
|
||||
- Kafka的Coordinator是怎么维护消费位置的?
|
||||
|
||||
类似这种非常细粒度的问题,粒度细到每个问题的答案就是一两个流程就可以回答,这样就可以了。如果说你就想学习一下源代码,或者说提不出这些问题怎么办呢?答案还是,**看文档。**
|
||||
|
||||
确定问题后,先不要着急看源代码,而是应该先找一下是否有对应的实现文档,一般来说,核心功能都会有专门的文档来说明它的实现原理,比如在Kafka的文档中,[DESIGN](http://kafka.apache.org/documentation/#design)和[IMPLEMENTATION](http://kafka.apache.org/documentation/#implementation)两个章节中,介绍了Kafka很多功能的实现原理和细节。一些更细节的非核心的功能不一定有专门的文档来说明,但是我们可以去找一找是否有对应的Improvement Proposal。(Kafka的所有Improvement Proposals在[这里](http://cwiki.apache.org/confluence/display/KAFKA/Kafka+Improvement+Proposals)。)
|
||||
|
||||
这个Improvement Proposal是什么呢?你可以认为它是描述一个新功能的文档,一般开源项目需要增加一个新的功能或者特性的时候,都会创建一个Improvement Proposal,一般标题都是"xIP-新功能名称",其中IP就是Improvement Proposal的缩写,x一般就是这个开源项目的名称的首字母,比如Kafka中Improvement Proposal的标题就都是以KIP来开头。
|
||||
|
||||
每个Improvement Proposal都是有固定格式的,一般要说明为什么需要增加这个功能,会对系统产生那些影响和改变,还有我们最关心的设计和实现原理的简述。
|
||||
|
||||
你读完讲解实现的文档再去看源代码,也就是我刚刚说的,不只是带着问题去读,而是带着答案去读源码。这样你在读源码的时候,不仅仅是更容易理解源代码,还可以把更多的精力放在一些实现细节上,这样阅读源码的效果会更好。
|
||||
|
||||
使用这种以问题为阅读单元的方式来读源代码,你每次只要花很短的时间,阅读很少的一部分源码,就能解决一个问题,得到一些收获。这种方式其实是通过一个一个的问题,在网状的源代码中,每次去读几个点组成的那一两条线。随着你通过阅读源码了解的问题越来越多,你对项目源码的理解也会越来越全面和深入。
|
||||
|
||||
## 小结
|
||||
|
||||
如果你想了解一个开源项目,学习它的代码,最佳的切入点就是去读它的官方文档,这些文档里面,最重要的灵魂就是项目背后的那篇论文,它一般是这个开源项目的理论基础。
|
||||
|
||||
在阅读源码的时候呢,最佳的方式是带着问题去阅读,最好是带着问题的答案去读,这样难度低、周期短、收获快。不要想着一定要从总体上去全面掌握一个项目的所有源代码,也没有必要。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后,建议你找一个你熟悉的开源项目,可以是消息相关的,也可以是无关的开源项目,确定一个问题,用这节课中讲到的“带着问题和答案去读源码”的方法,去读一点源码。然后,最重要的是,把主要的流程用流程图或者时序图画出来,把重点的算法、原理用文字写出来。
|
||||
|
||||
欢迎你在留言区分享你的阅读源码的笔记,在这个过程中,如果遇到任何问题,你也可以在留言区提问。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
216
极客时间专栏/消息队列高手课/进阶篇/10 | 如何使用异步设计提升系统性能?.md
Normal file
216
极客时间专栏/消息队列高手课/进阶篇/10 | 如何使用异步设计提升系统性能?.md
Normal file
@@ -0,0 +1,216 @@
|
||||
<audio id="audio" title="10 | 如何使用异步设计提升系统性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/88/c811b40c9ad58aa9cd0b73ad07cc8888.mp3"></audio>
|
||||
|
||||
你好,我是李玥,这一讲我们来聊一聊异步。
|
||||
|
||||
对于开发者来说,异步是一种程序设计的思想,使用异步模式设计的程序可以显著减少线程等待,从而在高吞吐量的场景中,极大提升系统的整体性能,显著降低时延。
|
||||
|
||||
因此,像消息队列这种需要超高吞吐量和超低时延的中间件系统,在其核心流程中,一定会大量采用异步的设计思想。
|
||||
|
||||
接下来,我们一起来通过一个非常简单的例子学习一下,使用异步设计是如何提升系统性能的。
|
||||
|
||||
## 异步设计如何提升系统性能?
|
||||
|
||||
假设我们要实现一个转账的微服务Transfer( accountFrom, accountTo, amount),这个服务有三个参数:分别是转出账户、转入账户和转账金额。
|
||||
|
||||
实现过程也比较简单,我们要从账户A中转账100元到账户B中:
|
||||
|
||||
1. 先从A的账户中减去100元;
|
||||
1. 再给B的账户加上100元,转账完成。
|
||||
|
||||
对应的时序图是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/58/3f7faf335a9e6c3009902d85b71d3058.jpg" alt="">
|
||||
|
||||
在这个例子的实现过程中,我们调用了另外一个微服务Add(account, amount),它的功能是给账户account增加金额amount,当amount为负值的时候,就是扣减响应的金额。
|
||||
|
||||
需要特别说明的是,在这段代码中,我为了使问题简化以便我们能专注于异步和性能优化,省略了错误处理和事务相关的代码,你在实际的开发中不要这样做。
|
||||
|
||||
#### 1. 同步实现的性能瓶颈
|
||||
|
||||
首先我们来看一下同步实现,对应的伪代码如下:
|
||||
|
||||
```
|
||||
Transfer(accountFrom, accountTo, amount) {
|
||||
// 先从accountFrom的账户中减去相应的钱数
|
||||
Add(accountFrom, -1 * amount)
|
||||
// 再把减去的钱数加到accountTo的账户中
|
||||
Add(accountTo, amount)
|
||||
return OK
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面的伪代码首先从accountFrom的账户中减去相应的钱数,再把减去的钱数加到accountTo的账户中,这种同步实现是一种很自然方式,简单直接。那么性能表现如何呢?接下来我们就来一起分析一下性能。
|
||||
|
||||
假设微服务Add的平均响应时延是50ms,那么很容易计算出我们实现的微服务Transfer的平均响应时延大约等于执行2次Add的时延,也就是100ms。那随着调用Transfer服务的请求越来越多,会出现什么情况呢?
|
||||
|
||||
在这种实现中,每处理一个请求需要耗时100ms,并在这100ms过程中是需要独占一个线程的,那么可以得出这样一个结论:每个线程每秒钟最多可以处理10个请求。我们知道,每台计算机上的线程资源并不是无限的,假设我们使用的服务器同时打开的线程数量上限是10,000,可以计算出这台服务器每秒钟可以处理的请求上限是: 10,000 (个线程)* 10(次请求每秒) = 100,000 次每秒。
|
||||
|
||||
如果请求速度超过这个值,那么请求就不能被马上处理,只能阻塞或者排队,这时候Transfer服务的响应时延由100ms延长到了:排队的等待时延 + 处理时延(100ms)。也就是说,在大量请求的情况下,我们的微服务的平均响应时延变长了。
|
||||
|
||||
这是不是已经到了这台服务器所能承受的极限了呢?其实远远没有,如果我们监测一下服务器的各项指标,会发现无论是CPU、内存,还是网卡流量或者是磁盘的IO都空闲的很,那我们Transfer服务中的那10,000个线程在干什么呢?对,绝大部分线程都在等待Add服务返回结果。
|
||||
|
||||
也就是说,**采用同步实现的方式,整个服务器的所有线程大部分时间都没有在工作,而是都在等待。**
|
||||
|
||||
如果我们能减少或者避免这种无意义的等待,就可以大幅提升服务的吞吐能力,从而提升服务的总体性能。
|
||||
|
||||
#### 2. 采用异步实现解决等待问题
|
||||
|
||||
接下来我们看一下,如何用异步的思想来解决这个问题,实现同样的业务逻辑。
|
||||
|
||||
```
|
||||
TransferAsync(accountFrom, accountTo, amount, OnComplete()) {
|
||||
// 异步从accountFrom的账户中减去相应的钱数,然后调用OnDebit方法。
|
||||
AddAsync(accountFrom, -1 * amount, OnDebit(accountTo, amount, OnAllDone(OnComplete())))
|
||||
}
|
||||
// 扣减账户accountFrom完成后调用
|
||||
OnDebit(accountTo, amount, OnAllDone(OnComplete())) {
|
||||
// 再异步把减去的钱数加到accountTo的账户中,然后执行OnAllDone方法
|
||||
AddAsync(accountTo, amount, OnAllDone(OnComplete()))
|
||||
}
|
||||
// 转入账户accountTo完成后调用
|
||||
OnAllDone(OnComplete()) {
|
||||
OnComplete()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
细心的你可能已经注意到了,TransferAsync服务比Transfer多了一个参数,并且这个参数传入的是一个回调方法OnComplete()(虽然Java语言并不支持将方法作为方法参数传递,但像JavaScript等很多语言都具有这样的特性,在Java语言中,也可以通过传入一个回调类的实例来变相实现类似的功能)。
|
||||
|
||||
这个TransferAsync()方法的语义是:请帮我执行转账操作,当转账完成后,请调用OnComplete()方法。调用TransferAsync的线程不必等待转账完成就可以立即返回了,待转账结束后,TransferService自然会调用OnComplete()方法来执行转账后续的工作。
|
||||
|
||||
异步的实现过程相对于同步来说,稍微有些复杂。我们先定义2个回调方法:
|
||||
|
||||
- **OnDebit()**:扣减账户accountFrom完成后调用的回调方法;
|
||||
- **OnAllDone()**:转入账户accountTo完成后调用的回调方法。
|
||||
|
||||
整个异步实现的语义相当于:
|
||||
|
||||
1. 异步从accountFrom的账户中减去相应的钱数,然后调用OnDebit方法;
|
||||
1. 在OnDebit方法中,异步把减去的钱数加到accountTo的账户中,然后执行OnAllDone方法;
|
||||
1. 在OnAllDone方法中,调用OnComplete方法。
|
||||
|
||||
绘制成时序图是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/0d/38ab8de8fbfaf4cd4b34fbd9ddd3360d.jpg" alt="">
|
||||
|
||||
你会发现,异步化实现后,整个流程的时序和同步实现是完全一样的,**区别只是在线程模型上由同步顺序调用改为了异步调用和回调的机制。**
|
||||
|
||||
接下来我们分析一下异步实现的性能,由于流程的时序和同步实现是一样,在低请求数量的场景下,平均响应时延一样是100ms。在超高请求数量场景下,异步的实现不再需要线程等待执行结果,只需要个位数量的线程,即可实现同步场景大量线程一样的吞吐量。
|
||||
|
||||
由于没有了线程的数量的限制,总体吞吐量上限会大大超过同步实现,并且在服务器CPU、网络带宽资源达到极限之前,响应时延不会随着请求数量增加而显著升高,几乎可以一直保持约100ms的平均响应时延。
|
||||
|
||||
看,这就是异步的魔力。
|
||||
|
||||
## 简单实用的异步框架: CompletableFuture
|
||||
|
||||
在实际开发时,我们可以使用异步框架和响应式框架,来解决一些通用的异步编程问题,简化开发。Java中比较常用的异步框架有Java8内置的[CompletableFuture](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html)和ReactiveX的[RxJava](https://github.com/ReactiveX/RxJava),我个人比较喜欢简单实用易于理解的CompletableFuture,但是RxJava的功能更加强大。有兴趣的同学可以深入了解一下。
|
||||
|
||||
Java 8中新增了一个非常强大的用于异步编程的类:CompletableFuture,几乎囊获了我们在开发异步程序的大部分功能,使用CompletableFuture很容易编写出优雅且易于维护的异步代码。
|
||||
|
||||
接下来,我们来看下,如何用CompletableFuture实现的转账服务。
|
||||
|
||||
首先,我们用CompletableFuture定义2个微服务的接口:
|
||||
|
||||
```
|
||||
/**
|
||||
* 账户服务
|
||||
*/
|
||||
public interface AccountService {
|
||||
/**
|
||||
* 变更账户金额
|
||||
* @param account 账户ID
|
||||
* @param amount 增加的金额,负值为减少
|
||||
*/
|
||||
CompletableFuture<Void> add(int account, int amount);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
/**
|
||||
* 转账服务
|
||||
*/
|
||||
public interface TransferService {
|
||||
/**
|
||||
* 异步转账服务
|
||||
* @param fromAccount 转出账户
|
||||
* @param toAccount 转入账户
|
||||
* @param amount 转账金额,单位分
|
||||
*/
|
||||
CompletableFuture<Void> transfer(int fromAccount, int toAccount, int amount);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到这两个接口中定义的方法的返回类型都是一个带泛型的CompletableFuture,尖括号中的泛型类型就是真正方法需要返回数据的类型,我们这两个服务不需要返回数据,所以直接用Void类型就可以。
|
||||
|
||||
然后我们来实现转账服务:
|
||||
|
||||
```
|
||||
/**
|
||||
* 转账服务的实现
|
||||
*/
|
||||
public class TransferServiceImpl implements TransferService {
|
||||
@Inject
|
||||
private AccountService accountService; // 使用依赖注入获取账户服务的实例
|
||||
@Override
|
||||
public CompletableFuture<Void> transfer(int fromAccount, int toAccount, int amount) {
|
||||
// 异步调用add方法从fromAccount扣减相应金额
|
||||
return accountService.add(fromAccount, -1 * amount)
|
||||
// 然后调用add方法给toAccount增加相应金额
|
||||
.thenCompose(v -> accountService.add(toAccount, amount));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在转账服务的实现类TransferServiceImpl里面,先定义一个AccountService实例,这个实例从外部注入进来,至于怎么注入不是我们关心的问题,就假设这个实例是可用的就好了。
|
||||
|
||||
然后我们看实现transfer()方法的实现,我们先调用一次账户服务accountService.add()方法从fromAccount扣减响应的金额,因为add()方法返回的就是一个CompletableFuture对象,可以用CompletableFuture的thenCompose()方法将下一次调用accountService.add()串联起来,实现异步依次调用两次账户服务完整转账。
|
||||
|
||||
客户端使用CompletableFuture也非常灵活,既可以同步调用,也可以异步调用。
|
||||
|
||||
```
|
||||
public class Client {
|
||||
@Inject
|
||||
private TransferService transferService; // 使用依赖注入获取转账服务的实例
|
||||
private final static int A = 1000;
|
||||
private final static int B = 1001;
|
||||
|
||||
public void syncInvoke() throws ExecutionException, InterruptedException {
|
||||
// 同步调用
|
||||
transferService.transfer(A, B, 100).get();
|
||||
System.out.println("转账完成!");
|
||||
}
|
||||
|
||||
public void asyncInvoke() {
|
||||
// 异步调用
|
||||
transferService.transfer(A, B, 100)
|
||||
.thenRun(() -> System.out.println("转账完成!"));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在调用异步方法获得返回值CompletableFuture对象后,既可以调用CompletableFuture的get方法,像调用同步方法那样等待调用的方法执行结束并获得返回值,也可以像异步回调的方式一样,调用CompletableFuture那些以then开头的一系列方法,为CompletableFuture定义异步方法结束之后的后续操作。比如像上面这个例子中,我们调用thenRun()方法,参数就是将转账完成打印在控台上这个操作,这样就可以实现在转账完成后,在控制台打印“转账完成!”了。
|
||||
|
||||
## 小结
|
||||
|
||||
简单的说,异步思想就是,**当我们要执行一项比较耗时的操作时,不去等待操作结束,而是给这个操作一个命令:“当操作完成后,接下来去执行什么。”**
|
||||
|
||||
使用异步编程模型,虽然并不能加快程序本身的速度,但可以减少或者避免线程等待,只用很少的线程就可以达到超高的吞吐能力。
|
||||
|
||||
同时我们也需要注意到异步模型的问题:相比于同步实现,异步实现的复杂度要大很多,代码的可读性和可维护性都会显著的下降。虽然使用一些异步编程框架会在一定程度上简化异步开发,但是并不能解决异步模型高复杂度的问题。
|
||||
|
||||
异步性能虽好,但一定不要滥用,只有类似在像消息队列这种业务逻辑简单并且需要超高吞吐量的场景下,或者必须长时间等待资源的地方,才考虑使用异步模型。如果系统的业务逻辑比较复杂,在性能足够满足业务需求的情况下,采用符合人类自然的思路且易于开发和维护的同步模型是更加明智的选择。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后给你留2个思考题:
|
||||
|
||||
第一个思考题是,我们实现转账服务时,并没有考虑处理失败的情况。你回去可以想一下,在异步实现中,如果调用账户服务失败时,如何将错误报告给客户端?在两次调用账户服务的Add方法时,如果某一次调用失败了,该如何处理才能保证账户数据是平的?
|
||||
|
||||
第二个思考题是,在异步实现中,回调方法OnComplete()是在什么线程中运行的?我们是否能控制回调方法的执行线程数?该如何做?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
116
极客时间专栏/消息队列高手课/进阶篇/11 | 如何实现高性能的异步网络传输?.md
Normal file
116
极客时间专栏/消息队列高手课/进阶篇/11 | 如何实现高性能的异步网络传输?.md
Normal file
@@ -0,0 +1,116 @@
|
||||
<audio id="audio" title="11 | 如何实现高性能的异步网络传输?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/c5/80b1c8378ab20dd5dece5250289766c5.mp3"></audio>
|
||||
|
||||
你好,我是李玥。上一节课我们学习了异步的线程模型,异步与同步模型最大的区别是,同步模型会阻塞线程等待资源,而异步模型不会阻塞线程,它是等资源准备好后,再通知业务代码来完成后续的资源处理逻辑。这种异步设计的方法,可以很好地解决IO等待的问题。
|
||||
|
||||
我们开发的绝大多数业务系统,都是IO密集型系统。跟IO密集型系统相对的另一种系统叫计算密集型系统。通过这两种系统的名字,估计你也能大概猜出来IO密集型系统是什么意思。
|
||||
|
||||
IO密集型系统大部分时间都在执行IO操作,这个IO操作主要包括网络IO和磁盘IO,以及与计算机连接的一些外围设备的访问。与之相对的计算密集型系统,大部分时间都是在使用CPU执行计算操作。我们开发的业务系统,很少有非常耗时的计算,更多的是网络收发数据,读写磁盘和数据库这些IO操作。这样的系统基本上都是IO密集型系统,特别适合使用异步的设计来提升系统性能。
|
||||
|
||||
应用程序最常使用的IO资源,主要包括磁盘IO和网络IO。由于现在的SSD的速度越来越快,对于本地磁盘的读写,异步的意义越来越小。所以,使用异步设计的方法来提升IO性能,我们更加需要关注的问题是,如何来实现高性能的异步网络传输。
|
||||
|
||||
今天,咱们就来聊一聊这个话题。
|
||||
|
||||
## 理想的异步网络框架应该是什么样的?
|
||||
|
||||
在我们开发的程序中,如果要实现通过网络来传输数据,需要用到开发语言提供的网络通信类库。大部分语言提供的网络通信基础类库都是同步的。一个TCP连接建立后,用户代码会获得一个用于收发数据的通道,每个通道会在内存中开辟两片区域用于收发数据的缓存。
|
||||
|
||||
发送数据的过程比较简单,我们直接往这个通道里面来写入数据就可以了。用户代码在发送时写入的数据会暂存在缓存中,然后操作系统会通过网卡,把发送缓存中的数据传输到对端的服务器上。
|
||||
|
||||
只要这个缓存不满,或者说,我们发送数据的速度没有超过网卡传输速度的上限,那这个发送数据的操作耗时,只不过是一次内存写入的时间,这个时间是非常快的。所以,**发送数据的时候同步发送就可以了,没有必要异步。**
|
||||
|
||||
比较麻烦的是接收数据。对于数据的接收方来说,它并不知道什么时候会收到数据。那我们能直接想到的方法就是,用一个线程阻塞在那儿等着数据,当有数据到来的时候,操作系统会先把数据写入接收缓存,然后给接收数据的线程发一个通知,线程收到通知后结束等待,开始读取数据。处理完这一批数据后,继续阻塞等待下一批数据到来,这样周而复始地处理收到的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/b2/4c94c5e1e437ac087ef3b50acf8dceb2.jpg" alt=""><br>
|
||||
这就是同步网络IO的模型。同步网络IO模型在处理少量连接的时候,是没有问题的。但是如果要同时处理非常多的连接,同步的网络IO模型就有点儿力不从心了。
|
||||
|
||||
因为,每个连接都需要阻塞一个线程来等待数据,大量的连接数就会需要相同数量的数据接收线程。当这些TCP连接都在进行数据收发的时候,会导致什么情况呢?对,会有大量的线程来抢占CPU时间,造成频繁的CPU上下文切换,导致CPU的负载升高,整个系统的性能就会比较慢。
|
||||
|
||||
所以,我们需要使用异步的模型来解决网络IO问题。怎么解决呢?
|
||||
|
||||
**你可以先抛开你知道的各种语言的异步类库和各种异步的网络IO框架,想一想,对于业务开发者来说,一个好的异步网络框架,它的API应该是什么样的呢?**
|
||||
|
||||
我们希望达到的效果,无非就是,只用少量的线程就能处理大量的连接,有数据到来的时候能第一时间处理就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/d6/49ca88d34fc5c4815d20189770cf76d6.jpg" alt="">
|
||||
|
||||
对于开发者来说,最简单的方式就是,事先定义好收到数据后的处理逻辑,把这个处理逻辑作为一个回调方法,在连接建立前就通过框架提供的API设置好。当收到数据的时候,由框架自动来执行这个回调方法就好了。
|
||||
|
||||
实际上,有没有这么简单的框架呢?
|
||||
|
||||
## 使用Netty来实现异步网络通信
|
||||
|
||||
在Java中,大名鼎鼎的Netty框架的API设计就是这样的。接下来我们看一下如何使用Netty实现异步接收数据。
|
||||
|
||||
```
|
||||
// 创建一组线性
|
||||
EventLoopGroup group = new NioEventLoopGroup();
|
||||
|
||||
try{
|
||||
// 初始化Server
|
||||
ServerBootstrap serverBootstrap = new ServerBootstrap();
|
||||
serverBootstrap.group(group);
|
||||
serverBootstrap.channel(NioServerSocketChannel.class);
|
||||
serverBootstrap.localAddress(new InetSocketAddress("localhost", 9999));
|
||||
|
||||
// 设置收到数据后的处理的Handler
|
||||
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
protected void initChannel(SocketChannel socketChannel) throws Exception {
|
||||
socketChannel.pipeline().addLast(new MyHandler());
|
||||
}
|
||||
});
|
||||
// 绑定端口,开始提供服务
|
||||
ChannelFuture channelFuture = serverBootstrap.bind().sync();
|
||||
channelFuture.channel().closeFuture().sync();
|
||||
} catch(Exception e){
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
group.shutdownGracefully().sync();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码它的功能非常简单,就是在本地9999端口,启动了一个Socket Server来接收数据。我带你一起来看一下这段代码:
|
||||
|
||||
1. 首先我们创建了一个EventLoopGroup对象,命名为group,这个group对象你可以简单把它理解为一组线程。这组线程的作用就是来执行收发数据的业务逻辑。
|
||||
1. 然后,使用Netty提供的ServerBootstrap来初始化一个Socket Server,绑定到本地9999端口上。
|
||||
1. 在真正启动服务之前,我们给serverBootstrap传入了一个MyHandler对象,这个MyHandler是我们自己来实现的一个类,它需要继承Netty提供的一个抽象类:ChannelInboundHandlerAdapter,在这个MyHandler里面,我们可以定义收到数据后的处理逻辑。这个设置Handler的过程,就是我刚刚讲的,预先来定义回调方法的过程。
|
||||
1. 最后就可以真正绑定本地端口,启动Socket服务了。
|
||||
|
||||
服务启动后,如果有客户端来请求连接,Netty会自动接受并创建一个Socket连接。你可以看到,我们的代码中,并没有像一些同步网络框架中那样,需要用户调用Accept()方法来接受创建连接的情况,在Netty中,这个过程是自动的。
|
||||
|
||||
当收到来自客户端的数据后,Netty就会在我们第一行提供的EventLoopGroup对象中,获取一个IO线程,在这个IO线程中调用接收数据的回调方法,来执行接收数据的业务逻辑,在这个例子中,就是我们传入的MyHandler中的方法。
|
||||
|
||||
Netty本身它是一个全异步的设计,我们上节课刚刚讲过,异步设计会带来额外的复杂度,所以这个例子的代码看起来会比较多,比较复杂。但是你看,其实它提供了一组非常友好API。
|
||||
|
||||
真正需要业务代码来实现的就两个部分:一个是把服务初始化并启动起来,还有就是,实现收发消息的业务逻辑MyHandler。而像线程控制、缓存管理、连接管理这些异步网络IO中通用的、比较复杂的问题,Netty已经自动帮你处理好了,有没有感觉很贴心?所以,非常多的开源项目使用Netty作为其底层的网络IO框架,并不是没有原因的。
|
||||
|
||||
在这种设计中,Netty自己维护一组线程来执行数据收发的业务逻辑。如果说,你的业务需要更灵活的实现,自己来维护收发数据的线程,可以选择更加底层的Java NIO。其实,Netty也是基于NIO来实现的。
|
||||
|
||||
## 使用NIO来实现异步网络通信
|
||||
|
||||
在Java的NIO中,它提供了一个Selector对象,来解决一个线程在多个网络连接上的多路复用问题。什么意思呢?在NIO中,每个已经建立好的连接用一个Channel对象来表示。我们希望能实现,在一个线程里,接收来自多个Channel的数据。也就是说,这些Channel中,任何一个Channel收到数据后,第一时间能在同一个线程里面来处理。
|
||||
|
||||
我们可以想一下,一个线程对应多个Channel,有可能会出现这两种情况:
|
||||
|
||||
1. 线程在忙着处理收到的数据,这时候Channel中又收到了新数据;
|
||||
1. 线程闲着没事儿干,所有的Channel中都没收到数据,也不能确定哪个Channel会在什么时候收到数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/50/a8bb4e812db8601d54933771f3614350.jpg" alt="">
|
||||
|
||||
Selecor通过一种类似于事件的机制来解决这个问题。首先你需要把你的连接,也就是Channel绑定到Selector上,然后你可以在接收数据的线程来调用Selector.select()方法来等待数据到来。这个select方法是一个阻塞方法,这个线程会一直卡在这儿,直到这些Channel中的任意一个有数据到来,就会结束等待返回数据。它的返回值是一个迭代器,你可以从这个迭代器里面获取所有Channel收到的数据,然后来执行你的数据接收的业务逻辑。
|
||||
|
||||
你可以选择直接在这个线程里面来执行接收数据的业务逻辑,也可以将任务分发给其他的线程来执行,如何选择完全可以由你的代码来控制。
|
||||
|
||||
## 小结
|
||||
|
||||
传统的同步网络IO,一般采用的都是一个线程对应一个Channel接收数据,很难支持高并发和高吞吐量。这个时候,我们需要使用异步的网络IO框架来解决问题。
|
||||
|
||||
然后我讲了Netty和NIO这两种异步网络框架的API和它们的使用方法。这里面,你需要体会一下这两种框架在API设计方面的差异。Netty自动地解决了线程控制、缓存管理、连接管理这些问题,用户只需要实现对应的Handler来处理收到的数据即可。而NIO是更加底层的API,它提供了Selector机制,用单个线程同时管理多个连接,解决了多路复用这个异步网络通信的核心问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
刚刚我们提到过,Netty本身就是基于NIO的API来实现的。课后,你可以想一下,针对接收数据这个流程,Netty它是如何用NIO来实现的呢?欢迎在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
129
极客时间专栏/消息队列高手课/进阶篇/12 | 序列化与反序列化:如何通过网络传输结构化的数据?.md
Normal file
129
极客时间专栏/消息队列高手课/进阶篇/12 | 序列化与反序列化:如何通过网络传输结构化的数据?.md
Normal file
@@ -0,0 +1,129 @@
|
||||
<audio id="audio" title="12 | 序列化与反序列化:如何通过网络传输结构化的数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/f4/c112f78acbf6e508363a565faefbfaf4.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
最近有一些同学留言说,感觉这几节课讲的内容和消息关系不大。这里我解释一下,因为我们课程其中的一个目的,是让同学们不仅会使用消息队列,还可以通过对消息队列的学习,在代码能力上有一个提升,具备“造轮子”的能力。这样,你对消息队列的理解才能足够的深入,而不只是浮于表面。如果你细心可能也会发现,很多大厂在面试时,提到消息队列的问题,也不会仅仅局限在消息队列的使用上,他更多的会考察“你为什么这么实现”。
|
||||
|
||||
所以在进阶篇的上半部分,我会把开发一个消息队列需要用到的一些底层的关键技术给大家讲解清楚,然后我们再来一起分析消息队列的源代码。
|
||||
|
||||
在上节课中,我们解决了如何实现高性能的网络传输的问题。那是不是程序之间就可以通信了呢?这里面还有一些问题需要解决。
|
||||
|
||||
我们知道,在TCP的连接上,它传输数据的基本形式就是二进制流,也就是一段一段的1和0。在一般编程语言或者网络框架提供的API中,传输数据的基本形式是字节,也就是Byte。一个字节就是8个二进制位,8个Bit,所以在这里,二进制流和字节流本质上是一样的。
|
||||
|
||||
那对于我们编写的程序来说,它需要通过网络传输的数据是什么形式的呢?是结构化的数据,比如,一条命令、一段文本或者是一条消息。对应到我们写的代码中,这些结构化的数据是什么?这些都可以用一个类(Class)或者一个结构体(Struct)来表示。
|
||||
|
||||
那显然,**要想使用网络框架的API来传输结构化的数据,必须得先实现结构化的数据与字节流之间的双向转换。**这种将结构化数据转换成字节流的过程,我们称为序列化,反过来转换,就是反序列化。
|
||||
|
||||
序列化的用途除了用于在网络上传输数据以外,另外的一个重要用途是,将结构化数据保存在文件中,因为在文件内保存数据的形式也是二进制序列,和网络传输过程中的数据是一样的,所以序列化同样适用于将结构化数据保存在文件中。
|
||||
|
||||
很多处理海量数据的场景中,都需要将对象序列化后,把它们暂时从内存转移到磁盘中,等需要用的时候,再把数据从磁盘中读取出来,反序列化成对象来使用,这样不仅可以长期保存不丢失数据,而且可以节省有限的内存空间。
|
||||
|
||||
这节课,我们就来聊聊,怎么来实现高性能的序列化和反序列化。
|
||||
|
||||
## 你该选择哪种序列化实现?
|
||||
|
||||
如果说,只是实现序列化和反序列的功能,并不难,方法也有很多,比如我们最常使用的,把一个对象转换成字符串并打印出来,这其实就是一种序列化的实现,这个字符串只要转成字节序列,就可以在网络上传输或者保存在文件中了。
|
||||
|
||||
但是,你千万不要在你的程序中这么用,这种实现的方式仅仅只是能用而已,绝不是一个好的选择。
|
||||
|
||||
有很多通用的序列化实现,我们可以直接拿来使用。Java和Go语言都内置了序列化实现,也有一些流行的开源序列化实现,比如,Google 的Protobuf、Kryo、Hessian等;此外,像JSON、XML这些标准的数据格式,也可以作为一种序列化实现来使用。
|
||||
|
||||
当然,我们也可以自己来实现私有的序列化实现。
|
||||
|
||||
面对这么多种序列化实现,我们该如何选择呢?你需要权衡这样几个因素:
|
||||
|
||||
1. 序列化后的数据最好是易于人类阅读的;
|
||||
1. 实现的复杂度是否足够低;
|
||||
1. 序列化和反序列化的速度越快越好;
|
||||
1. 序列化后的信息密度越大越好,也就是说,同样的一个结构化数据,序列化之后占用的存储空间越小越好;
|
||||
|
||||
当然,**不会存在一种序列化实现在这四个方面都是最优的**,否则我们就没必要来纠结到底选择哪种实现了。因为,大多数情况下,易于阅读和信息密度是矛盾的,实现的复杂度和性能也是互相矛盾的。所以,我们需要根据所实现的业务,来选择合适的序列化实现。
|
||||
|
||||
像JSON、XML这些序列化方法,可读性最好,但信息密度也最低。像Kryo、Hessian这些通用的二进制序列化实现,适用范围广,使用简单,性能比JSON、XML要好一些,但是肯定不如专用的序列化实现。
|
||||
|
||||
对于一些强业务类系统,比如说电商类、社交类的应用系统,这些系统的特点是,业务复杂,需求变化快,但是对性能的要求没有那么苛刻。这种情况下,我推荐你使用JSON这种实现简单,数据可读性好的序列化实现,这种实现使用起来非常简单,序列化后的JSON数据我们都可以看得懂,无论是接口调试还是排查问题都非常方便。付出的代价就是多一点点CPU时间和存储空间而已。
|
||||
|
||||
比如我们要序列化一个User对象,它包含3个属性,姓名zhangsan,年龄:23,婚姻状况:已婚。
|
||||
|
||||
```
|
||||
User:
|
||||
name: "zhangsan"
|
||||
age: 23
|
||||
married: true
|
||||
|
||||
```
|
||||
|
||||
使用JSON序列化后:
|
||||
|
||||
```
|
||||
{"name":"zhangsan","age":"23","married":"true"}
|
||||
|
||||
```
|
||||
|
||||
这里面的数据我们不需要借助工具,是直接可以看懂的。
|
||||
|
||||
序列化的代码也比较简单,直接调用JSON序列化框架提供的方法就可以了:
|
||||
|
||||
```
|
||||
byte [] serializedUser = JsonConvert.SerializeObject(user).getBytes("UTF-8");
|
||||
|
||||
```
|
||||
|
||||
如果JSON序列化的性能达不到你系统的要求,可以采用性能更好的二进制序列化实现,实现的复杂度和JSON序列化是差不多的,都很简单,但是序列化性能更好,信息密度也更高,代价就是失去了可读性。
|
||||
|
||||
比如我们用Kryo来序列化User对象,它的代码如下:
|
||||
|
||||
```
|
||||
kryo.register(User.class);
|
||||
Output output = new Output(new FileOutputStream("file.bin"));
|
||||
kryo.writeObject(output, user);
|
||||
|
||||
```
|
||||
|
||||
在这段代码里,先要向Kryo注册一下User类,然后创建一个流,最后调用writeObject方法,将user对象序列化后直接写到流中。这个过程也是非常简单的。
|
||||
|
||||
## 实现高性能的序列化和反序列化
|
||||
|
||||
绝大部分系统,使用上面这两类通用的序列化实现都可以满足需求,而像消息队列这种用于解决通信问题的中间件,它对性能要求非常高,通用的序列化实现达不到性能要求,所以,很多的消息队列都选择自己实现高性能的专用序列化和反序列化。
|
||||
|
||||
使用专用的序列化方法,可以提高序列化性能,并有效减小序列化后的字节长度。
|
||||
|
||||
在专用的序列化方法中,不必考虑通用性。比如,我们可以固定字段的顺序,这样在序列化后的字节里面就不必包含字段名,只要字段值就可以了,不同类型的数据也可以做针对性的优化:
|
||||
|
||||
对于同样的User对象,我们可以把它序列化成这样:
|
||||
|
||||
```
|
||||
03 | 08 7a 68 61 6e 67 73 61 6e | 17 | 01
|
||||
User | z h a n g s a n | 23 | true
|
||||
|
||||
```
|
||||
|
||||
我解释一下,这个序列化方法是怎么表示User对象的。
|
||||
|
||||
首先我们需要标识一下这个对象的类型,这里面我们用一个字节来表示类型,比如用03表示这是一个User类型的对象。
|
||||
|
||||
我们约定,按照name、age、married这个固定顺序来序列化这三个属性。按照顺序,第一个字段是name,我们不存字段名,直接存字段值“zhangsan”就可以了,由于名字的长度不固定,我们用第一个字节08表示这个名字的长度是8个字节,后面的8个字节就是zhangsan。
|
||||
|
||||
第二个字段是年龄,我们直接用一个字节表示就可以了,23的16进制是17 。
|
||||
|
||||
最后一个字段是婚姻状态,我们用一个字节来表示,01表示已婚,00表示未婚,这里面保存一个01。
|
||||
|
||||
可以看到,同样的一个User对象,JSON序列化后需要47个字节,这里只要12个字节就够了。
|
||||
|
||||
专用的序列化方法显然更高效,序列化出来的字节更少,在网络传输过程中的速度也更快。但缺点是,需要为每种对象类型定义专门的序列化和反序列化方法,实现起来太复杂了,大部分情况下是不划算的。
|
||||
|
||||
## 小结
|
||||
|
||||
进程之间要通过网络传输结构化的数据,需要通过序列化和反序列化来实现结构化数据和二进制数据的双向转换。在选择序列化实现的时候,需要综合考虑数据可读性,实现复杂度,性能和信息密度这四个因素。
|
||||
|
||||
大多数情况下,选择一个高性能的通用序列化框架都可以满足要求,在性能可以满足需求的前提下,推荐优先选择JSON这种可读性好的序列化方法。
|
||||
|
||||
如果说我们需要超高的性能,或者是带宽有限的情况下,可以使用专用的序列化方法,来提升序列化性能,节省传输流量。不过实现起来很复杂,大部分情况下并不划算。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后,你可以想一下这个问题:在内存里存放的任何数据,它最基础的存储单元也是二进制比特,也就是说,我们应用程序操作的对象,它在内存中也是使用二进制存储的,既然都是二进制,为什么不能直接把内存中,对象对应的二进制数据直接通过网络发送出去,或者保存在文件中呢?为什么还需要序列化和反序列化呢?欢迎在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
103
极客时间专栏/消息队列高手课/进阶篇/13 | 传输协议:应用程序之间对话的语言.md
Normal file
103
极客时间专栏/消息队列高手课/进阶篇/13 | 传输协议:应用程序之间对话的语言.md
Normal file
@@ -0,0 +1,103 @@
|
||||
<audio id="audio" title="13 | 传输协议:应用程序之间对话的语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/57/7481f97a5cb1b8a813faf77ea1b85557.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
经过前面几课的学习,我们已经可以实现高性能的结构化数据传输了。不过,应用程序之间要想互相通信,一起配合来实现业务功能,还需要有一套传输协议来支持。
|
||||
|
||||
**传输协议就是应用程序之间对话的语言。**设计传输协议,并没有太多规范和要求,只要是通信双方的应用程序都能正确处理这个协议,并且没有歧义就好了。
|
||||
|
||||
这节课,我们就来说一下设计高性能传输协议的一些方法和技巧。
|
||||
|
||||
## 如何“断句”?
|
||||
|
||||
既然传输协议也是一种语言,那么在应用程序之间“通话”的过程中,与我们人类用自然语言沟通有很多相似之处,但是需要处理的问题却又不同。
|
||||
|
||||
现代语言,无论是汉语还是英语,都是通过标点符号来分隔句子的,这个叫“断句”。古代汉语是没有标点符号的,断句全靠上下文,但这种断句方式有的时候会出现歧义,比如很著名的那个段子“下雨天留客天天留我不留”,不同的断句方式,意思完全不一样。
|
||||
|
||||
我们在传输数据的的时候,首先要解决的就是断句问题。对于传输层来说,收到的数据是什么样的?就是一段一段的字节,但是,因为网络的不确定性,你收到的分段并不一定是我们发出去的分段。比如我们发送的数据是这样的:
|
||||
|
||||
>
|
||||
下雨天 留客天 天留 我不留
|
||||
|
||||
|
||||
这样断句,意思就是,作为主人我不想让你在我这儿住。
|
||||
|
||||
经过网络传输,可能就变成这样了:
|
||||
|
||||
>
|
||||
下雨天 留客天 天留我不 留
|
||||
|
||||
|
||||
意思完全变了,客人想赖在这儿不走了。
|
||||
|
||||
所以,靠时间停顿来断句是不靠谱的。
|
||||
|
||||
你可能会想到,那我们在协议中也加上“标点符号”不就行了?而且,我们并不需要像自然语言中那么多种标点符号,只需要定义一个分隔符就可以了。
|
||||
|
||||
这个办法是可行的,也有很多传输协议采用这种方法,比如HTTP1协议,它的分隔符是换行(\r\n)。但是,这个办法有一个问题比较难处理,在自然语言中,标点符号是专用的,它没有别的含义,和文字是有天然区分的。
|
||||
|
||||
在数据传输的过程中,无论你定义什么字符作为分隔符,理论上,它都有可能会在传输的数据中出现。为了区分“数据内的分隔符”和真正的分隔符,你必须得在发送数据阶段,加上分隔符之前,把数据内的分隔符做转义,收到数据之后再转义回来。这是个比较麻烦的过程,还要损失一些性能。
|
||||
|
||||
更加实用的方法是,我们给每句话前面加一个表示这句话长度的数字,收到数据的时候,我们按照长度来读取就可以了。比如:
|
||||
|
||||
>
|
||||
03下雨天03留客天02天留03我不留
|
||||
|
||||
|
||||
这里面我们固定使用2位数字来存放长度,每句话最长可以支持到99个字。接收后的处理就比较简单了,我们先读取2位数字03,知道接下来的3个字是第一句话,那我们接下来就等着这3个字都收到了,就可以作为第一句话来处理了,接下来再按照这个方法来读第二句话、第三句话。
|
||||
|
||||
这种预置长度的方法就很好解决了断句的问题,并且它实现起来要比分隔符的方法简单很多,性能也更好,是目前普遍采用的一种分隔数据的方法。
|
||||
|
||||
掌握了断句的方法之后,我们再来看一下实现高性能协议还需要解决什么问题。
|
||||
|
||||
## 用双工收发协议提升吞吐量
|
||||
|
||||
人类之间通过语言来交流时,基本上是处于一种单工通信的状态,也就是我说你听,然后再你说我听这样。如果俩人同时说,那就不是交流了,那是两个外国人在吵架。所谓的单工通信就是,任何一个时刻,数据只能单向传输,一个人说的时候,另外一个人只能听。
|
||||
|
||||
HTTP1协议,就是这样一种单工协议,客户端与服务端建立一个连接后,客户端发送一个请求,直到服务端返回响应或者请求超时,这段时间内,这个连接通道上是不能再发送其他请求的。这种单工通信的效率是比较低的,很多浏览器和App为了解决这个问题,只能同时在服务端和客户端之间创建多个连接,这也是没有办法的办法。
|
||||
|
||||
单工通信时,一句对一句,请求和响应是按照顺序依次收发,有一个天然的对应关系。比如说,胡同口张大爷和李大爷俩大爷碰上了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/cd/bcbcec4ce8e9120b28b5627e56cb0ccd.jpg" alt="">
|
||||
|
||||
这个图里面,实线是请求,虚线是响应,一问一答,这是单工协议。
|
||||
|
||||
我们知道,TCP连接它是一个全双工的通道,你可以同时进行数据的双向收发,互相是不会受到任何影响的。要提高吞吐量,应用层的协议也必须支持双工通信。
|
||||
|
||||
如果说俩大爷有边听边说的本事,换成双工协议后,是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/e5/8a6587851a0fbc9fd0e4c9d09357aee5.jpg" alt="">
|
||||
|
||||
这时候就出现一个问题,即使俩大爷有这个边听边说的本事,问题和答案可能已经对不上了。在多线程并发的环境下,顺序也没有办法保证,这个对话就有可能变成这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/46/29d696771575b46258e4d6bd809c8c46.jpg" alt="">
|
||||
|
||||
在实际上设计协议的时候,我们一般不关心顺序,只要需要确保请求和响应能够正确对应上就可以了。
|
||||
|
||||
这个问题我们可以这样解决:发送请求的时候,给每个请求加一个序号,这个序号在本次会话内保证唯一,然后在响应中带上请求的序号,这样就可以把请求和响应对应上了。
|
||||
|
||||
加上序号后,俩大爷的就可以实现双工通信了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/18/7c944db7d136f3b9c027be3e99685f18.jpg" alt="">
|
||||
|
||||
张大爷和李大爷可以对自己发出去的请求来编号,回复对方响应的时候,带上对方请求的编号就可以了。这样就解决了双工通信的问题。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我们主要讲了传输协议,在设计传输协议的时候,只要双方应用程序能够识别传输协议,互相交流就可以了,并没有什么一定要遵循的规范。
|
||||
|
||||
在设计传输协议的时候,需要解决如何断句的问题,我们给大家提供了“分隔符”和“前置长度”两种断句的方法,你可以选择使用。
|
||||
|
||||
另外,我给大家介绍的这种“使用ID来标识请求与响应对应关系”的方法,是一种比较通用的实现双工通信的方法,可以有效提升数据传输的吞吐量。
|
||||
|
||||
解决了断句问题,实现了双工通信,配合专用的序列化方法,你就可以实现一套高性能的网络通信协议,实现高性能的进程间通信。很多的消息队列、RPC框架都是用这种方式来实现它们自己的私有应用层传输协议。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后,我希望你能真正动手去写代码,用我们这四节课讲到的方法,来实现一个简单的高性能通信程序。功能就是上面两个大爷那三组对话,服务端是张大爷,客户端是李大爷,我们让俩人在胡同口碰见一百万次,记录下总共的耗时。欢迎你在评论区秀出你的总耗时。
|
||||
|
||||
在实现过程中,有任何问题,也欢迎你在评论区留言来提问。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
92
极客时间专栏/消息队列高手课/进阶篇/14 | 内存管理:如何避免内存溢出和频繁的垃圾回收?.md
Normal file
92
极客时间专栏/消息队列高手课/进阶篇/14 | 内存管理:如何避免内存溢出和频繁的垃圾回收?.md
Normal file
@@ -0,0 +1,92 @@
|
||||
<audio id="audio" title="14 | 内存管理:如何避免内存溢出和频繁的垃圾回收?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/77/d0/777920abea0b82e5facae1e3782958d0.mp3"></audio>
|
||||
|
||||
你好,我是李玥。今天,我们来聊一聊内存管理的问题。
|
||||
|
||||
不知道你有没有发现,在高并发、高吞吐量的极限情况下,简单的事情就会变得没有那么简单了。一个业务逻辑非常简单的微服务,日常情况下都能稳定运行,为什么一到大促就卡死甚至进程挂掉?再比如,一个做数据汇总的应用,按照小时、天这样的粒度进行数据汇总都没问题,到年底需要汇总全年数据的时候,没等数据汇总出来,程序就死掉了。
|
||||
|
||||
之所以出现这些情况,大部分的原因是,程序在设计的时候,没有针对高并发高吞吐量的情况做好内存管理。要想解决这类问题,首先你要了解内存管理机制。
|
||||
|
||||
现代的编程语言,像Java、Go语言等,采用的都是自动内存管理机制。我们在编写代码的时候,不需要显式去申请和释放内存。当我们创建一个新对象的时候,系统会自动分配一块内存用于存放新创建的对象,对象使用完毕后,系统会自动择机收回这块内存,完全不需要开发者干预。
|
||||
|
||||
对于开发者来说,这种自动内存管理的机制,显然是非常方便的,不仅极大降低了开发难度,提升了开发效率,更重要的是,它完美地解决了内存泄漏的问题。是不是很厉害?当年,Java语言能够迅速普及和流行,超越C和C++,自动内存管理机制是非常重要的一个因素。但是它也会带来一些问题,什么问题呢?这就要从它的实现原理中来分析。
|
||||
|
||||
## 自动内存管理机制的实现原理
|
||||
|
||||
做内存管理,主要需要考虑申请内存和内存回收这两个部分。
|
||||
|
||||
申请内存的逻辑非常简单:
|
||||
|
||||
1. 计算要创建对象所需要占用的内存大小;
|
||||
1. 在内存中找一块儿连续并且是空闲的内存空间,标记为已占用;
|
||||
1. 把申请的内存地址绑定到对象的引用上,这时候对象就可以使用了。
|
||||
|
||||
内存回收的过程就非常复杂了,总体上,内存回收需要做这样两件事儿:先是要找出所有可以回收的对象,将对应的内存标记为空闲,然后,还需要整理内存碎片。
|
||||
|
||||
如何找出可以回收的对象呢?现代的GC算法大多采用的是“标记-清除”算法或是它的变种算法,这种算法分为标记和清除两个阶段:
|
||||
|
||||
- 标记阶段:从GC Root开始,你可以简单地把GC Root理解为程序入口的那个对象,标记所有可达的对象,因为程序中所有在用的对象一定都会被这个GC Root对象直接或者间接引用。
|
||||
- 清除阶段:遍历所有对象,找出所有没有标记的对象。这些没有标记的对象都是可以被回收的,清除这些对象,释放对应的内存即可。
|
||||
|
||||
这个算法有一个最大问题就是,在执行标记和清除过程中,必须把进程暂停,否则计算的结果就是不准确的。这也就是为什么发生垃圾回收的时候,我们的程序会卡死的原因。后续产生了许多变种的算法,这些算法更加复杂,可以减少一些进程暂停的时间,但都不能完全避免暂停进程。
|
||||
|
||||
完成对象回收后,还需要整理内存碎片。什么是内存碎片呢?我举个例子你就明白了。
|
||||
|
||||
假设,我们的内存只有10个字节,一开始这10个字节都是空闲的。我们初始化了5个Short类型的对象,每个Short占2个字节,正好占满10个字节的内存空间。程序运行一段时间后,其中的2个Short对象用完并被回收了。这时候,如果我需要创建一个占4个字节的Int对象,是否可以创建成功呢?
|
||||
|
||||
答案是,不一定。我们刚刚回收了2个Short,正好是4个字节,但是,创建一个Int对象需要连续4个字节的内存空间,2段2个字节的内存,并不一定就等于一段连续的4字节内存。如果这两段2字节的空闲内存不连续,我们就无法创建Int对象,这就是内存碎片问题。
|
||||
|
||||
所以,**垃圾回收完成后,还需要进行内存碎片整理,将不连续的空闲内存移动到一起,以便空出足够的连续内存空间供后续使用。**和垃圾回收算法一样,内存碎片整理也有很多非常复杂的实现方法,但由于整理过程中需要移动内存中的数据,也都不可避免地需要暂停进程。
|
||||
|
||||
虽然自动内存管理机制有效地解决了内存泄漏问题,带来的代价是执行垃圾回收时会暂停进程,如果暂停的时间过长,程序看起来就像“卡死了”一样。
|
||||
|
||||
## 为什么在高并发下程序会卡死?
|
||||
|
||||
在理解了自动内存管理的基本原理后,我再带你分析一下,为什么在高并发场景下,这种自动内存管理的机制会更容易触发进程暂停。
|
||||
|
||||
一般来说,我们的微服务在收到一个请求后,执行一段业务逻辑,然后返回响应。这个过程中,会创建一些对象,比如说请求对象、响应对象和处理中间业务逻辑中需要使用的一些对象等等。随着这个请求响应的处理流程结束,我们创建的这些对象也就都没有用了,它们将会在下一次垃圾回收过程中被释放。
|
||||
|
||||
你需要注意的是,直到下一次垃圾回收之前,这些已经没有用的对象会一直占用内存。
|
||||
|
||||
那么,虚拟机是如何决定什么时候来执行垃圾回收呢?这里面的策略非常复杂,也有很多不同的实现,我们不展开来讲,但是无论是什么策略,如果内存不够用了,那肯定要执行一次垃圾回收的,否则程序就没法继续运行了。
|
||||
|
||||
在低并发情况下,单位时间内需要处理的请求不多,创建的对象数量不会很多,自动垃圾回收机制可以很好地发挥作用,它可以选择在系统不太忙的时候来执行垃圾回收,每次垃圾回收的对象数量也不多,相应的,程序暂停的时间非常短,短到我们都无法感知到这个暂停。这是一个良性的循环。
|
||||
|
||||
在高并发的情况下,一切都变得不一样了。
|
||||
|
||||
我们的程序会非常繁忙,短时间内就会创建大量的对象,这些对象将会迅速占满内存,这时候,由于没有内存可以使用了,垃圾回收被迫开始启动,并且,这次被迫执行的垃圾回收面临的是占满整个内存的海量对象,它执行的时间也会比较长,相应的,这个回收过程会导致进程长时间暂停。
|
||||
|
||||
进程长时间暂停,又会导致大量的请求积压等待处理,垃圾回收刚刚结束,更多的请求立刻涌进来,迅速占满内存,再次被迫执行垃圾回收,进入了一个恶性循环。如果垃圾回收的速度跟不上创建对象的速度,还可能会产生内存溢出的现象。
|
||||
|
||||
于是,就出现了我在这节课开始提到的那个情况:一到大促,大量请求过来,我们的服务就卡死了。
|
||||
|
||||
## 高并发下的内存管理技巧
|
||||
|
||||
对于开发者来说,垃圾回收是不可控的,而且是无法避免的。但是,我们还是可以通过一些方法来降低垃圾回收的频率,减少进程暂停的时长。
|
||||
|
||||
我们知道,只有使用过被丢弃的对象才是垃圾回收的目标,所以,我们需要想办法在处理大量请求的同时,尽量少的产生这种一次性对象。
|
||||
|
||||
最有效的方法就是,优化你的代码中处理请求的业务逻辑,尽量少的创建一次性对象,特别是占用内存较大的对象。比如说,我们可以把收到请求的Request对象在业务流程中一直传递下去,而不是每执行一个步骤,就创建一个内容和Request对象差不多的新对象。这里面没有多少通用的优化方法,你需要根据我告诉你的这个原则,针对你的业务逻辑来想办法进行优化。
|
||||
|
||||
对于需要频繁使用,占用内存较大的一次性对象,我们可以考虑自行回收并重用这些对象。实现的方法是这样的:我们可以为这些对象建立一个对象池。收到请求后,在对象池内申请一个对象,使用完后再放回到对象池中,这样就可以反复地重用这些对象,非常有效地避免频繁触发垃圾回收。
|
||||
|
||||
如果可能的话,使用更大内存的服务器,也可以非常有效地缓解这个问题。
|
||||
|
||||
以上这些方法,都可以在一定程度上缓解由于垃圾回收导致的进程暂停,如果你优化的好,是可以达到一个还不错的效果的。
|
||||
|
||||
当然,要从根本上来解决这个问题,办法只有一个,那就是绕开自动垃圾回收机制,自己来实现内存管理。但是,自行管理内存将会带来非常多的问题,比如说极大增加了程序的复杂度,可能会引起内存泄漏等等。
|
||||
|
||||
流计算平台Flink,就是自行实现了一套内存管理机制,一定程度上缓解了处理大量数据时垃圾回收的问题,但是也带来了一些问题和Bug,总体看来,效果并不是特别好。因此,一般情况下我并不推荐你这样做,具体还是要根据你的应用情况,综合权衡做出一个相对最优的选择。
|
||||
|
||||
## 小结
|
||||
|
||||
现代的编程语言,大多采用自动内存管理机制,虚拟机会不定期执行垃圾回收,自动释放我们不再使用的内存,但是执行垃圾回收的过程会导致进程暂停。
|
||||
|
||||
在高并发的场景下,会产生大量的待回收的对象,需要频繁地执行垃圾回收,导致程序长时间暂停,我们的程序看起来就像卡死了一样。为了缓解这个问题,我们需要尽量少地使用一次性对象,对于需要频繁使用,占用内存较大的一次性对象,我们可以考虑自行回收并重用这些对象,来减轻垃圾回收的压力。
|
||||
|
||||
## 思考题
|
||||
|
||||
如果我们的微服务的需求是处理大量的文本,比如说,每次请求会传入一个10KB左右的文本,在高并发的情况下,你会如何来优化这个程序,来尽量避免由于垃圾回收导致的进程卡死问题?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
115
极客时间专栏/消息队列高手课/进阶篇/15 | Kafka如何实现高性能IO?.md
Normal file
115
极客时间专栏/消息队列高手课/进阶篇/15 | Kafka如何实现高性能IO?.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="15 | Kafka如何实现高性能IO?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/5b/b45b5e2cbe3a6a7509536b053428ff5b.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
Apache Kafka是一个高性能的消息队列,在众多消息队列产品中,Kafka的性能绝对是处于第一梯队的。我曾经在一台配置比较好的服务器上,对Kafka做过极限的性能压测,Kafka单个节点的极限处理能力接近每秒钟2000万条消息,吞吐量达到每秒钟600MB。
|
||||
|
||||
你可能会问,Kafka是如何做到这么高的性能的?
|
||||
|
||||
我们在专栏“进阶篇”的前几节课,讲的知识点一直围绕着同一个主题:怎么开发一个高性能的网络应用程序。其中提到了像全异步化的线程模型、高性能的异步网络传输、自定义的私有传输协议和序列化、反序列化等等,这些方法和优化技巧,你都可以在Kafka的源代码中找到对应的实现。
|
||||
|
||||
在性能优化方面,除了这些通用的性能优化手段之外,Kafka还有哪些“独门绝技”呢?
|
||||
|
||||
这节课,我来为你一一揭晓这些绝技。
|
||||
|
||||
## 使用批量消息提升服务端处理能力
|
||||
|
||||
我们知道,批量处理是一种非常有效的提升系统吞吐量的方法。在Kafka内部,消息都是以“批”为单位处理的。一批消息从发送端到接收端,是如何在Kafka中流转的呢?
|
||||
|
||||
我们先来看发送端,也就是Producer这一端。
|
||||
|
||||
在Kafka的客户端SDK(软件开发工具包)中,Kafka的Producer只提供了单条发送的send()方法,并没有提供任何批量发送的接口。原因是,Kafka根本就没有提供单条发送的功能,是的,你没有看错,虽然它提供的API每次只能发送一条消息,但实际上,Kafka的客户端SDK在实现消息发送逻辑的时候,采用了异步批量发送的机制。
|
||||
|
||||
当你调用send()方法发送一条消息之后,无论你是同步发送还是异步发送,Kafka都不会立即就把这条消息发送出去。它会先把这条消息,存放在内存中缓存起来,然后选择合适的时机把缓存中的所有消息组成一批,一次性发给Broker。简单地说,就是攒一波一起发。
|
||||
|
||||
在Kafka的服务端,也就是Broker这一端,又是如何处理这一批一批的消息呢?
|
||||
|
||||
在服务端,Kafka不会把一批消息再还原成多条消息,再一条一条地处理,这样太慢了。Kafka这块儿处理的非常聪明,每批消息都会被当做一个“批消息”来处理。也就是说,在Broker整个处理流程中,无论是写入磁盘、从磁盘读出来、还是复制到其他副本这些流程中,**批消息都不会被解开,一直是作为一条“批消息”来进行处理的。**
|
||||
|
||||
在消费时,消息同样是以批为单位进行传递的,Consumer从Broker拉到一批消息后,在客户端把批消息解开,再一条一条交给用户代码处理。
|
||||
|
||||
比如说,你在客户端发送30条消息,在业务程序看来,是发送了30条消息,而对于Kafka的Broker来说,它其实就是处理了1条包含30条消息的“批消息”而已。显然处理1次请求要比处理30次请求要快得多。
|
||||
|
||||
构建批消息和解开批消息分别在发送端和消费端的客户端完成,不仅减轻了Broker的压力,最重要的是减少了Broker处理请求的次数,提升了总体的处理能力。
|
||||
|
||||
这就是Kafka用批量消息提升性能的方法。
|
||||
|
||||
我们知道,相比于网络传输和内存,磁盘IO的速度是比较慢的。对于消息队列的服务端来说,性能的瓶颈主要在磁盘IO这一块。接下来我们看一下,Kafka在磁盘IO这块儿做了哪些优化。
|
||||
|
||||
## 使用顺序读写提升磁盘IO性能
|
||||
|
||||
对于磁盘来说,它有一个特性,就是顺序读写的性能要远远好于随机读写。在SSD(固态硬盘)上,顺序读写的性能要比随机读写快几倍,如果是机械硬盘,这个差距会达到几十倍。为什么呢?
|
||||
|
||||
操作系统每次从磁盘读写数据的时候,需要先寻址,也就是先要找到数据在磁盘上的物理位置,然后再进行数据读写。如果是机械硬盘,这个寻址需要比较长的时间,因为它要移动磁头,这是个机械运动,机械硬盘工作的时候会发出咔咔的声音,就是移动磁头发出的声音。
|
||||
|
||||
顺序读写相比随机读写省去了大部分的寻址时间,它只要寻址一次,就可以连续地读写下去,所以说,性能要比随机读写要好很多。
|
||||
|
||||
Kafka就是充分利用了磁盘的这个特性。它的存储设计非常简单,对于每个分区,它把从Producer收到的消息,顺序地写入对应的log文件中,一个文件写满了,就开启一个新的文件这样顺序写下去。消费的时候,也是从某个全局的位置开始,也就是某一个log文件中的某个位置开始,顺序地把消息读出来。
|
||||
|
||||
这样一个简单的设计,充分利用了顺序读写这个特性,极大提升了Kafka在使用磁盘时的IO性能。
|
||||
|
||||
接下来我们说一下Kafka是如何实现缓存的。
|
||||
|
||||
## 利用PageCache加速消息读写
|
||||
|
||||
在Kafka中,它会利用PageCache加速消息读写。PageCache是现代操作系统都具有的一项基本特性。通俗地说,PageCache就是操作系统在内存中给磁盘上的文件建立的缓存。无论我们使用什么语言编写的程序,在调用系统的API读写文件的时候,并不会直接去读写磁盘上的文件,应用程序实际操作的都是PageCache,也就是文件在内存中缓存的副本。
|
||||
|
||||
应用程序在写入文件的时候,操作系统会先把数据写入到内存中的PageCache,然后再一批一批地写到磁盘上。读取文件的时候,也是从PageCache中来读取数据,这时候会出现两种可能情况。
|
||||
|
||||
一种是PageCache中有数据,那就直接读取,这样就节省了从磁盘上读取数据的时间;另一种情况是,PageCache中没有数据,这时候操作系统会引发一个缺页中断,应用程序的读取线程会被阻塞,操作系统把数据从文件中复制到PageCache中,然后应用程序再从PageCache中继续把数据读出来,这时会真正读一次磁盘上的文件,这个读的过程就会比较慢。
|
||||
|
||||
用户的应用程序在使用完某块PageCache后,操作系统并不会立刻就清除这个PageCache,而是尽可能地利用空闲的物理内存保存这些PageCache,除非系统内存不够用,操作系统才会清理掉一部分PageCache。清理的策略一般是LRU或它的变种算法,这个算法我们不展开讲,它保留PageCache的逻辑是:优先保留最近一段时间最常使用的那些PageCache。
|
||||
|
||||
Kafka在读写消息文件的时候,充分利用了PageCache的特性。一般来说,消息刚刚写入到服务端就会被消费,按照LRU的“优先清除最近最少使用的页”这种策略,读取的时候,对于这种刚刚写入的PageCache,命中的几率会非常高。
|
||||
|
||||
也就是说,大部分情况下,消费读消息都会命中PageCache,带来的好处有两个:一个是读取的速度会非常快,另外一个是,给写入消息让出磁盘的IO资源,间接也提升了写入的性能。
|
||||
|
||||
## ZeroCopy:零拷贝技术
|
||||
|
||||
Kafka的服务端在消费过程中,还使用了一种“零拷贝”的操作系统特性来进一步提升消费的性能。
|
||||
|
||||
我们知道,在服务端,处理消费的大致逻辑是这样的:
|
||||
|
||||
- 首先,从文件中找到消息数据,读到内存中;
|
||||
- 然后,把消息通过网络发给客户端。
|
||||
|
||||
这个过程中,数据实际上做了2次或者3次复制:
|
||||
|
||||
1. 从文件复制数据到PageCache中,如果命中PageCache,这一步可以省掉;
|
||||
1. 从PageCache复制到应用程序的内存空间中,也就是我们可以操作的对象所在的内存;
|
||||
1. 从应用程序的内存空间复制到Socket的缓冲区,这个过程就是我们调用网络应用框架的API发送数据的过程。
|
||||
|
||||
Kafka使用零拷贝技术可以把这个复制次数减少一次,上面的2、3步骤两次复制合并成一次复制。直接从PageCache中把数据复制到Socket缓冲区中,这样不仅减少一次数据复制,更重要的是,由于不用把数据复制到用户内存空间,DMA控制器可以直接完成数据复制,不需要CPU参与,速度更快。
|
||||
|
||||
下面是这个零拷贝对应的系统调用:
|
||||
|
||||
```
|
||||
#include <sys/socket.h>
|
||||
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
|
||||
|
||||
```
|
||||
|
||||
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
|
||||
|
||||
如果你遇到这种从文件读出数据后再通过网络发送出去的场景,并且这个过程中你不需要对这些数据进行处理,那一定要使用这个零拷贝的方法,可以有效地提升性能。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们总结了Kafka的高性能设计中的几个关键的技术点:
|
||||
|
||||
- 使用批量处理的方式来提升系统吞吐能力。
|
||||
- 基于磁盘文件高性能顺序读写的特性来设计的存储结构。
|
||||
- 利用操作系统的PageCache来缓存数据,减少IO并提升读性能。
|
||||
- 使用零拷贝技术加速消费流程。
|
||||
|
||||
以上这些,就是Kafka之所以能做到如此高性能的关键技术点。你可以看到,要真正实现一个高性能的消息队列,是非常不容易的,你需要熟练掌握非常多的编程语言和操作系统的底层技术。
|
||||
|
||||
这些优化的方法和技术,同样可以用在其他适合的场景和应用程序中。我希望你能充分理解这几项优化技术的原理,知道它们在什么情况下适用,什么情况下不适用。这样,当你遇到合适场景的时候,再深入去学习它的细节用法,最终就能把它真正地用到你开发的程序中。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后,我希望你去读一读Kafka的源代码,从我们这节课中找一两个技术点,找到对应的代码部分,真正去看一下,我们说的这些优化技术,是如何落地到代码上的。在分析源代码的过程中,如果有任何问题,也欢迎你在留言区和我一起讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
148
极客时间专栏/消息队列高手课/进阶篇/16 | 缓存策略:如何使用缓存来减少磁盘IO?.md
Normal file
148
极客时间专栏/消息队列高手课/进阶篇/16 | 缓存策略:如何使用缓存来减少磁盘IO?.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="16 | 缓存策略:如何使用缓存来减少磁盘IO?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/43/dfdcbdef9cdd8860d088f94c5a790a43.mp3"></audio>
|
||||
|
||||
你好,我是李玥。这节课,我们一起来聊一聊缓存策略。
|
||||
|
||||
现代的消息队列,都使用磁盘文件来存储消息。因为磁盘是一个持久化的存储,即使服务器掉电也不会丢失数据。绝大多数用于生产系统的服务器,都会使用多块儿磁盘组成磁盘阵列,这样不仅服务器掉电不会丢失数据,即使其中的一块儿磁盘发生故障,也可以把数据从其他磁盘中恢复出来。
|
||||
|
||||
使用磁盘的另外一个原因是,磁盘很便宜,这样我们就可以用比较低的成本,来存储海量的消息。所以,不仅仅是消息队列,几乎所有的存储系统的数据,都需要保存到磁盘上。
|
||||
|
||||
但是,磁盘它有一个致命的问题,就是读写速度很慢。它有多慢呢?一般来说SSD(固态硬盘)每秒钟可以读写几千次,如果说我们的程序在处理业务请求的时候直接来读写磁盘,假设处理每次请求需要读写3~5次,即使每次请求的数据量不大,你的程序最多每秒也就能处理1000次左右的请求。
|
||||
|
||||
而内存的随机读写速度是磁盘的10万倍!所以,**使用内存作为缓存来加速应用程序的访问速度,是几乎所有高性能系统都会采用的方法。**
|
||||
|
||||
缓存的思想很简单,就是把低速存储的数据,复制一份副本放到高速的存储中,用来加速数据的访问。缓存使用起来也非常简单,很多同学在做一些业务系统的时候,在一些执行比较慢的方法上加上一个@Cacheable的注解,就可以使用缓存来提升它的访问性能了。
|
||||
|
||||
但是,你是否考虑过,采用@Cacheable注解的方式缓存的命中率如何?或者说怎样才能提高缓存的命中率?缓存是否总能返回最新的数据?如果缓存返回了过期的数据该怎么办?接下来,我们一起来通过学习设计、使用缓存的最佳实践,找到这些问题的答案。
|
||||
|
||||
## 选择只读缓存还是读写缓存?
|
||||
|
||||
使用缓存,首先你就会面临选择读缓存还是读写缓存的问题。他们唯一的区别就是,在更新数据的时候,是否经过缓存。
|
||||
|
||||
我们之前的课中讲到Kafka使用的PageCache,它就是一个非常典型的读写缓存。操作系统会利用系统空闲的物理内存来给文件读写做缓存,这个缓存叫做PageCache。应用程序在写文件的时候,操作系统会先把数据写入到PageCache中,数据在成功写到PageCache之后,对于用户代码来说,写入就结束了。
|
||||
|
||||
然后,操作系统再异步地把数据更新到磁盘的文件中。应用程序在读文件的时候,操作系统也是先尝试从PageCache中寻找数据,如果找到就直接返回数据,找不到会触发一个缺页中断,然后操作系统把数据从文件读取到PageCache中,再返回给应用程序。
|
||||
|
||||
我们可以看到,在数据写到PageCache中后,它并不是同时就写到磁盘上了,这中间是有一个延迟的。操作系统可以保证,即使是应用程序意外退出了,操作系统也会把这部分数据同步到磁盘上。但是,如果服务器突然掉电了,这部分数据就丢失了。
|
||||
|
||||
你需要知道,**读写缓存的这种设计,它天然就是不可靠的,是一种牺牲数据一致性换取性能的设计。**当然,应用程序可以调用sync等系统调用,强制操作系统立即把缓存数据同步到磁盘文件中去,但是这个同步的过程是很慢的,也就失去了缓存的意义。
|
||||
|
||||
另外,写缓存的实现是非常复杂的。应用程序不停地更新PageCache中的数据,操作系统需要记录哪些数据有变化,同时还要在另外一个线程中,把缓存中变化的数据更新到磁盘文件中。在提供并发读写的同时来异步更新数据,这个过程中要保证数据的一致性,并且有非常好的性能,实现这些真不是一件容易的事儿。
|
||||
|
||||
所以说,一般情况下,不推荐你来使用读写缓存。
|
||||
|
||||
那为什么Kafka可以使用PageCache来提升它的性能呢?这是由消息队列的一些特点决定的。
|
||||
|
||||
首先,消息队列它的读写比例大致是1:1,因为,大部分我们用消息队列都是一收一发这样使用。这种读写比例,只读缓存既无法给写加速,读的加速效果也有限,并不能提升多少性能。
|
||||
|
||||
另外,Kafka它并不是只靠磁盘来保证数据的可靠性,它更依赖的是,在不同节点上的多副本来解决数据可靠性问题,这样即使某个服务器掉电丢失一部分文件内容,它也可以从其他节点上找到正确的数据,不会丢消息。
|
||||
|
||||
而且,PageCache这个读写缓存是操作系统实现的,Kafka只要按照正确的姿势来使用就好了,不涉及到实现复杂度的问题。所以,Kafka其实在设计上,充分利用了PageCache这种读写缓存的优势,并且规避了PageCache的一些劣势,达到了一个非常好的效果。
|
||||
|
||||
和Kafka一样,大部分其他的消息队列,同样也会采用读写缓存来加速消息写入的过程,只是实现的方式都不一样。
|
||||
|
||||
不同于消息队列,我们开发的大部分业务类应用程序,读写比都是严重不均衡的,一般读的数据的频次会都会远高于写数据的频次。从经验值来看,读次数一般都是写次数的几倍到几十倍。这种情况下,使用只读缓存来加速系统才是非常明智的选择。
|
||||
|
||||
接下来,我们一起来看一下,在构建一个只读缓存时,应该侧重考虑哪些问题。
|
||||
|
||||
## 保持缓存数据新鲜
|
||||
|
||||
对于只读缓存来说,缓存中的数据来源只有一个途径,就是从磁盘上来。当数据需要更新的时候,磁盘中的数据和缓存中的副本都需要进行更新。我们知道,在分布式系统中,除非是使用事务或者一些分布式一致性算法来保证数据一致性,否则,由于节点宕机、网络传输故障等情况的存在,我们是无法保证缓存中的数据和磁盘中的数据是完全一致的。
|
||||
|
||||
如果出现数据不一致的情况,数据一定是以磁盘上的那份拷贝为准。我们需要解决的问题就是,尽量让缓存中的数据与磁盘上的数据保持同步。
|
||||
|
||||
那选择什么时候来更新缓存中的数据呢?比较自然的想法是,我在更新磁盘中数据的同时,更新一下缓存中的数据不就可以了?这个想法是没有任何问题的,缓存中的数据会一直保持最新。但是,在并发的环境中,实现起来还是不太容易的。
|
||||
|
||||
你是选择同步还是异步来更新缓存呢?如果是同步更新,更新磁盘成功了,但是更新缓存失败了,你是不是要反复重试来保证更新成功?如果多次重试都失败,那这次更新是算成功还是失败呢?如果是异步更新缓存,怎么保证更新的时序?
|
||||
|
||||
比如,我先把一个文件中的某个数据设置成0,然后又设为1,这个时候文件中的数据肯定是1,但是缓存中的数据可不一定就是1了。因为把缓存中的数据更新为0,和更新为1是两个并发的异步操作,不一定谁会先执行。
|
||||
|
||||
这些问题都会导致缓存的数据和磁盘中的数据不一致,而且,在下次更新这条数据之前,这个不一致的问题它是一直存在的。当然,这些问题也不是不能解决的,比如,你可以使用分布式事务来解决,只是付出的性能、实现复杂度等代价比较大。
|
||||
|
||||
另外一种比较简单的方法就是,定时将磁盘上的数据同步到缓存中。一般的情况下,每次同步时直接全量更新就可以了,因为是在异步的线程中更新数据,同步的速度即使慢一些也不是什么大问题。如果缓存的数据太大,更新速度慢到无法接受,也可以选择增量更新,每次只更新从上次缓存同步至今这段时间内变化的数据,代价是实现起来会稍微有些复杂。
|
||||
|
||||
如果说,某次同步过程中发生了错误,等到下一个同步周期也会自动把数据纠正过来。这种定时同步缓存的方法,缺点是缓存更新不那么及时,优点是实现起来非常简单,鲁棒性非常好。
|
||||
|
||||
还有一种更简单的方法,我们从来不去更新缓存中的数据,而是给缓存中的每条数据设置一个比较短的过期时间,数据过期以后即使它还存在缓存中,我们也认为它不再有效,需要从磁盘上再次加载这条数据,这样就变相地实现了数据更新。
|
||||
|
||||
很多情况下,缓存的数据更新不那么及时,我们的系统也是能够接受的。比如说,你刚刚发了一封邮件,收件人过了一会儿才收到。或者说,你改了自己的微信头像,在一段时间内,你的好友看到的你还是旧的头像,这些都是可以接受的。这种对数据一致性没有那么敏感的场景下,你一定要选择后面两种方法。
|
||||
|
||||
而像交易类的系统,它对数据的一致性非常敏感。比如,你给别人转了一笔钱,别人查询自己余额却没有变化,这种情况肯定是无法接受的。对于这样的系统,一般来说,都不使用缓存或者使用我们提到的第一种方法,在更新数据的时候同时来更新缓存。
|
||||
|
||||
## 缓存置换策略
|
||||
|
||||
在使用缓存的过程中,除了要考虑数据一致性的问题,你还需要关注的另一个重要的问题是,在内存有限的情况下,要优先缓存哪些数据,让缓存的命中率最高。
|
||||
|
||||
当应用程序要访问某些数据的时候,如果这些数据在缓存中,那直接访问缓存中的数据就可以了,这次访问的速度是很快的,这种情况我们称为一次缓存命中;如果这些数据不在缓存中,那只能去磁盘中访问数据,就会比较慢。这种情况我们称为“缓存穿透”。显然,缓存的命中率越高,应用程序的总体性能就越好。
|
||||
|
||||
那用什么样的策略来选择缓存的数据,能使得缓存的命中率尽量高一些呢?
|
||||
|
||||
如果你的系统是那种可以预测未来访问哪些数据的系统,比如说,有的系统它会定期做数据同步,每次同步的数据范围都是一样的,像这样的系统,缓存策略很简单,就是你要访问什么数据,就缓存什么数据,甚至可以做到百分之百的命中。
|
||||
|
||||
但是,大部分系统,它并没有办法准确地预测未来会有哪些数据会被访问到,所以只能使用一些策略来尽可能地提高缓存命中率。
|
||||
|
||||
一般来说,我们都会在数据首次被访问的时候,顺便把这条数据放到缓存中。随着访问的数据越来越多,总有把缓存占满的时刻,这个时候就需要把缓存中的一些数据删除掉,以便存放新的数据,这个过程称为缓存置换。
|
||||
|
||||
到这里,问题就变成了:当缓存满了的时候,删除哪些数据,才能会使缓存的命中率更高一些,也就是采用什么置换策略的问题。
|
||||
|
||||
**命中率最高的置换策略,一定是根据你的业务逻辑,定制化的策略。**比如,你如果知道某些数据已经删除了,永远不会再被访问到,那优先置换这些数据肯定是没问题的。再比如,你的系统是一个有会话的系统,你知道现在哪些用户是在线的,哪些用户已经离线,那优先置换那些已经离线用户的数据,尽量保留在线用户的数据也是一个非常好的策略。
|
||||
|
||||
另外一个选择,就是使用通用的置换算法。一个最经典也是最实用的算法就是LRU算法,也叫最近最少使用算法。这个算法它的思想是,最近刚刚被访问的数据,它在将来被访问的可能性也很大,而很久都没被访问过的数据,未来再被访问的几率也不大。
|
||||
|
||||
基于这个思想,**LRU的算法原理非常简单,它总是把最长时间未被访问的数据置换出去。**你别看这个LRU算法这么简单,它的效果是非常非常好的。
|
||||
|
||||
Kafka使用的PageCache,是由Linux内核实现的,它的置换算法的就是一种LRU的变种算法<br>
|
||||
:LRU 2Q。我在设计JMQ的缓存策略时,也是采用一种改进的LRU算法。LRU淘汰最近最少使用的页,JMQ根据消息这种流数据存储的特点,在淘汰时增加了一个考量维度:页面位置与尾部的距离。因为越是靠近尾部的数据,被访问的概率越大。
|
||||
|
||||
这样综合考虑下的淘汰算法,不仅命中率更高,还能有效地避免“挖坟”问题:例如某个客户端正在从很旧的位置开始向后读取一批历史数据,内存中的缓存很快都会被替换成这些历史数据,相当于大部分缓存资源都被消耗掉了,这样会导致其他客户端的访问命中率下降。加入位置权重后,比较旧的页面会很快被淘汰掉,减少“挖坟”对系统的影响。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我们主要聊了一下,如何使用缓存来加速你的系统,减少磁盘IO。按照读写性质,可以分为读写缓存和只读缓存,读写缓存实现起来非常复杂,并且只在消息队列等少数情况下适用。只读缓存适用的范围更广,实现起来也更简单。
|
||||
|
||||
在实现只读缓存的时候,你需要考虑的第一个问题是如何来更新缓存。这里面有三种方法,第一种是在更新数据的同时去更新缓存,第二种是定期来更新全部缓存,第三种是给缓存中的每个数据设置一个有效期,让它自然过期以达到更新的目的。这三种方法在更新的及时性上和实现的复杂度这两方面,都是依次递减的,你可以按需选择。
|
||||
|
||||
对于缓存的置换策略,最优的策略一定是你根据业务来设计的定制化的置换策略,当然你也可以考虑LRU这样通用的缓存置换算法。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后来写点儿代码吧,实现一个采用LRU置换算法的缓存。
|
||||
|
||||
```
|
||||
/**
|
||||
* KV存储抽象
|
||||
*/
|
||||
public interface Storage<K,V> {
|
||||
/**
|
||||
* 根据提供的key来访问数据
|
||||
* @param key 数据Key
|
||||
* @return 数据值
|
||||
*/
|
||||
V get(K key);
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU缓存。你需要继承这个抽象类来实现LRU缓存。
|
||||
* @param <K> 数据Key
|
||||
* @param <V> 数据值
|
||||
*/
|
||||
public abstract class LruCache<K, V> implements Storage<K,V>{
|
||||
// 缓存容量
|
||||
protected final int capacity;
|
||||
// 低速存储,所有的数据都可以从这里读到
|
||||
protected final Storage<K,V> lowSpeedStorage;
|
||||
|
||||
public LruCache(int capacity, Storage<K,V> lowSpeedStorage) {
|
||||
this.capacity = capacity;
|
||||
this.lowSpeedStorage = lowSpeedStorage;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你需要继承LruCache这个抽象类,实现你自己的LRU缓存。lowSpeedStorage是提供给你可用的低速存储,你不需要实现它。
|
||||
|
||||
欢迎你把代码上传到GitHub上,然后在评论区给出访问链接。大家来比一下,谁的算法性能更好。如果你有任何问题,也可以在评论区留言与我交流。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
239
极客时间专栏/消息队列高手课/进阶篇/17 | 如何正确使用锁保护共享数据,协调异步线程?.md
Normal file
239
极客时间专栏/消息队列高手课/进阶篇/17 | 如何正确使用锁保护共享数据,协调异步线程?.md
Normal file
@@ -0,0 +1,239 @@
|
||||
<audio id="audio" title="17 | 如何正确使用锁保护共享数据,协调异步线程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/e0/6b0032f33ba54e3ea3f59abb8abe52e0.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
在前几天的加餐文章中我讲到,JMQ为了提升整个流程的处理性能,使用了一个“近乎无锁”的设计,这里面其实隐含着两个信息点。第一个是,在消息队列中,“锁”是一个必须要使用的技术。第二个是,使用锁其实会降低系统的性能。
|
||||
|
||||
那么,如何正确使用锁,又需要注意哪些事项呢?今天我们就来聊一聊这个问题。
|
||||
|
||||
我们知道,使用异步和并发的设计可以大幅提升程序的性能,但我们为此付出的代价是,程序比原来更加复杂了,多线程在并行执行的时候,带来了很多不确定性。特别是对于一些需要多个线程并发读写的共享数据,如果处理不好,很可能会产出不可预期的结果,这肯定不是我们想要的。
|
||||
|
||||
我给你举个例子来说明一下,大家应该都参与过微信群投票吧?比如,群主说:“今晚儿咱们聚餐,能来的都回消息报一下名,顺便统计一下人数。都按我这个格式来报名。”然后,群主发了一条消息:“群主,1人”。
|
||||
|
||||
这时候小六和无双都要报名,过一会儿,他俩几乎同时各发了一条消息,“小六,2人”“无双,2人”,每个人发的消息都只统计了群主和他们自己,一共2人,而这时候,其实已经有3个人报名了,并且,在最后发消息的无双的名单中,小六的报名被覆盖了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/e7/87ac82860fe52434dee843c8e710b2e7.jpg" alt="">
|
||||
|
||||
这就是一个非常典型的由于并发读写导致的数据错误。使用锁可以非常有效地解决这个问题。锁的原理是这样的:**任何时间都只能有一个线程持有锁,只有持有锁的线程才能访问被锁保护的资源。**
|
||||
|
||||
在上面微信群报名的例子中,如果说我们的微信群中有一把锁,想要报名的人必须先拿到锁,然后才能更新报名名单。这样,就避免了多个人同时更新消息,报名名单也就不会出错了。
|
||||
|
||||
## 避免滥用锁
|
||||
|
||||
那是不是遇到这种情况都要用锁解决呢?我分享一下我个人使用锁的第一条原则:**如果能不用锁,就不用锁;如果你不确定是不是应该用锁,那也不要用锁。**为什么这么说呢?因为,虽然说使用锁可以保护共享资源,但是代价还是不小的。
|
||||
|
||||
第一,加锁和解锁过程都是需要CPU时间的,这是一个性能的损失。另外,使用锁就有可能导致线程等待锁,等待锁过程中线程是阻塞的状态,过多的锁等待会显著降低程序的性能。
|
||||
|
||||
第二,如果对锁使用不当,很容易造成死锁,导致整个程序“卡死”,这是非常严重的问题。本来多线程的程序就非常难于调试,如果再加上锁,出现并发问题或者死锁问题,你的程序将更加难调试。
|
||||
|
||||
所以,你在使用锁以前,一定要非常清楚明确地知道,这个问题必须要用一把锁来解决。切忌看到一个共享数据,也搞不清它在并发环境中会不会出现争用问题,就“为了保险,给它加个锁吧。”**千万不能有这种不负责任的想法,否则你将会付出惨痛的代价!**我曾经遇到过的严重线上事故,其中有几次就是由于不当地使用锁导致的。
|
||||
|
||||
**只有在并发环境中,共享资源不支持并发访问,或者说并发访问共享资源会导致系统错误的情况下,才需要使用锁。**
|
||||
|
||||
## 锁的用法
|
||||
|
||||
锁的用法一般是这样的:
|
||||
|
||||
1. 在访问共享资源之前,先获取锁。
|
||||
1. 如果获取锁成功,就可以访问共享资源了。
|
||||
1. 最后,需要释放锁,以便其他线程继续访问共享资源。
|
||||
|
||||
在Java语言中,使用锁的例子:
|
||||
|
||||
```
|
||||
private Lock lock = new ReentrantLock();
|
||||
|
||||
public void visitShareResWithLock() {
|
||||
lock.lock();
|
||||
try {
|
||||
// 在这里安全的访问共享资源
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也可以使用synchronized关键字,它的效果和锁是一样的:
|
||||
|
||||
```
|
||||
private Object lock = new Object();
|
||||
|
||||
public void visitShareResWithLock() {
|
||||
synchronized (lock) {
|
||||
// 在这里安全的访问共享资源
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用锁的时候,你需要注意几个问题:
|
||||
|
||||
第一个,也是最重要的问题就是,**使用完锁,一定要释放它**。比较容易出现状况的地方是,很多语言都有异常机制,当抛出异常的时候,不再执行后面的代码。如果在访问共享资源时抛出异常,那后面释放锁的代码就不会被执行,这样,锁就一直无法释放,形成死锁。所以,你要考虑到代码可能走到的所有正常和异常的分支,确保所有情况下,锁都能被释放。
|
||||
|
||||
有些语言提供了try-with的机制,不需要显式地获取和释放锁,可以简化编程,有效避免这种问题,推荐你使用。
|
||||
|
||||
比如在Python中:
|
||||
|
||||
```
|
||||
lock = threading.RLock()
|
||||
|
||||
def visitShareResWithLock():
|
||||
with lock:
|
||||
# 注意缩进
|
||||
# 在这里安全的访问共享资源
|
||||
|
||||
# 锁会在with代码段执行完成后自动释放
|
||||
|
||||
```
|
||||
|
||||
接下来我们说一下,使用锁的时候,遇到的最常见的问题:死锁。
|
||||
|
||||
## 如何避免死锁?
|
||||
|
||||
死锁是指,由于某种原因,锁一直没有释放,后续需要获取锁的线程都将处于等待锁的状态,这样程序就卡死了。
|
||||
|
||||
导致死锁的原因并不多,第一种原因就是我在刚刚讲的,获取了锁之后没有释放,有经验的程序员很少会犯这种错误,即使出现这种错误,也很容易通过查看代码找到Bug。
|
||||
|
||||
还有一种是锁的重入问题,我们来看下面这段代码:
|
||||
|
||||
```
|
||||
public void visitShareResWithLock() {
|
||||
lock.lock(); // 获取锁
|
||||
try {
|
||||
lock.lock(); // 再次获取锁,会导致死锁吗?
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,当前的线程获取到了锁lock,然后在持有这把锁的情况下,再次去尝试获取这把锁,这样会导致死锁吗?
|
||||
|
||||
答案是,不一定。**会不会死锁取决于,你获取的这把锁它是不是可重入锁。**如果是可重入锁,那就没有问题,否则就会死锁。
|
||||
|
||||
大部分编程语言都提供了可重入锁,如果没有特别的要求,你要尽量使用可重入锁。有的同学可能会问,“既然已经获取到锁了,我干嘛还要再次获取同一把锁呢?”
|
||||
|
||||
其实,如果你的程序足够复杂,调用栈很深,很多情况下,当你需要获取一把锁的时候,你是不太好判断在n层调用之外的某个地方,是不是已经获取过这把锁了,这个时候,获取可重入锁就有意义了。
|
||||
|
||||
最后一种死锁的情况是最复杂的,也是最难解决的。如果你的程序中存在多把锁,就有可能出现这些锁互相锁住的情况。我们一起来看下面这段Python代码:
|
||||
|
||||
```
|
||||
import threading
|
||||
|
||||
def func1(lockA, lockB):
|
||||
while True:
|
||||
print("Thread1: Try to accquire lockA...")
|
||||
with lockA:
|
||||
print("Thread1: lockA accquired. Try to accquire lockB...")
|
||||
with lockB:
|
||||
print("Thread1: Both lockA and LockB accrquired.")
|
||||
|
||||
|
||||
def func2(lockA, lockB):
|
||||
while True:
|
||||
print("Thread2: Try to accquire lockB...")
|
||||
with lockB:
|
||||
print("Thread2: lockB accquired. Try to accquire lockA...")
|
||||
with lockA:
|
||||
print("Thread2: Both lockA and LockB accrquired.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
lockA = threading.RLock();
|
||||
lockB = threading.RLock()
|
||||
t1 = threading.Thread(target=func1, args=(lockA, lockB,))
|
||||
t2 = threading.Thread(target=func2, args=(lockA, lockB,))
|
||||
t1.start()
|
||||
t2.start()
|
||||
|
||||
```
|
||||
|
||||
这个代码模拟了一个最简单最典型的死锁情况。在这个程序里面,我们有两把锁:lockA和lockB,然后我们定义了两个线程,这两个线程反复地去获取这两把锁,然后释放。我们执行以下这段代码,看看会出现什么情况:
|
||||
|
||||
```
|
||||
$ python3 DeadLock.py
|
||||
Thread1: Try to accquire lockA...
|
||||
Thread1: lockA accquired. Try to accquire lockB...
|
||||
Thread1: Both lockA and LockB accrquired.
|
||||
Thread1: Try to accquire lockA...
|
||||
... ...
|
||||
Thread1: Try to accquire lockA...
|
||||
Thread2: Try to accquire lockB...
|
||||
Thread1: lockA accquired. Try to accquire lockB...
|
||||
Thread2: lockB accquired. Try to accquire lockA...
|
||||
|
||||
```
|
||||
|
||||
可以看到,程序执行一会儿就卡住了,发生了死锁。那死锁的原因是什么呢?请注意看代码,这两个线程,他们获取锁的顺序是不一样的。第一个线程,先获取lockA,再获取lockB,而第二个线程正好相反,先获取lockB,再获取lockA。
|
||||
|
||||
然后,你再看一下死锁前的最后两行日志,线程1持有了lockA,现在尝试获取lockB,而线程2持有了lockB,尝试获取lockA。你可以想一下这个场景,两个线程,各持有一把锁,都等着对方手里的另外一把锁,这样就僵持住了。
|
||||
|
||||
这是最简单的两把锁两个线程死锁的情况,我们还可以分析清楚,你想想如果你的程序中有十几把锁,几十处加锁解锁,几百的线程,如果出现死锁你还能分析清楚是什么情况吗?
|
||||
|
||||
关于避免死锁,我在这里给你几点建议。
|
||||
|
||||
1. 再次强调一下,避免滥用锁,程序里用的锁少,写出死锁Bug的几率自然就低。
|
||||
1. 对于同一把锁,加锁和解锁必须要放在同一个方法中,这样一次加锁对应一次解锁,代码清晰简单,便于分析问题。
|
||||
1. 尽量避免在持有一把锁的情况下,去获取另外一把锁,就是要尽量避免同时持有多把锁。
|
||||
1. 如果需要持有多把锁,一定要注意加解锁的顺序,解锁的顺序要和加锁顺序相反。比如,获取三把锁的顺序是A、B、C,释放锁的顺序必须是C、B、A。
|
||||
1. 给你程序中所有的锁排一个顺序,在所有需要加锁的地方,按照同样的顺序加解锁。比如我刚刚举的那个例子,如果两个线程都按照先获取lockA再获取lockB的顺序加锁,就不会产生死锁。
|
||||
|
||||
最后,你需要知道,即使你完全遵从我这些建议,我也无法完全保证你写出的程序就没有死锁,只能说,会降低一些犯错误的概率。
|
||||
|
||||
## 使用读写锁要兼顾性能和安全性
|
||||
|
||||
对于共享数据来说,如果说某个方法在访问它的时候,只是去读取,并不更新数据,那是不是就不需要加锁呢?还是需要的,因为如果一个线程读数据的同时,另外一个线程同时在更新数据,那么你读到的数据有可能是更新到一半的数据,这肯定是不符合预期的。所以,无论是只读访问,还是读写访问,都是需要加锁的。
|
||||
|
||||
如果给数据简单地加一把锁,虽然解决了安全性的问题,但是牺牲了性能,因为,那无论读还是写,都无法并发了,跟单线程的程序性能是一样。
|
||||
|
||||
实际上,如果没有线程在更新数据,那即使多个线程都在并发读,也是没有问题的。我在上节课跟你讲过,大部分情况下,数据的读写比是不均衡的,读要远远多于写,所以,我们希望的是:
|
||||
|
||||
- 读访问可以并发执行。
|
||||
- 写的同时不能并发读,也不能并发写。
|
||||
|
||||
这样就兼顾了性能和安全性。读写锁就是为这一需求设计的。我们来看一下Java中提供的读写锁:
|
||||
|
||||
```
|
||||
ReadWriteLock rwlock = new ReentrantReadWriteLock();
|
||||
|
||||
public void read() {
|
||||
rwlock.readLock().lock();
|
||||
try {
|
||||
// 在这儿读取共享数据
|
||||
} finally {
|
||||
rwlock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
public void write() {
|
||||
rwlock.writeLock().lock();
|
||||
try {
|
||||
// 在这儿更新共享数据
|
||||
} finally {
|
||||
rwlock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,需要读数据的时候,我们获取读锁,获取到的读锁不是一个互斥锁,也就是说read()方法是可以多个线程并行执行的,这样使得读数据的性能依然很好。写数据的时候,我们获取写锁,当一个线程持有写锁的时候,其他线程既无法获取读锁,也不能获取写锁,达到保护共享数据的目的。
|
||||
|
||||
这样,使用读写锁就兼顾了性能和安全。
|
||||
|
||||
## 小结
|
||||
|
||||
锁可以保护共享资源,避免并发更新造成的数据错误。只有持有锁的线程才能访问被保护资源。线程在访问资源之前必须获取锁,访问完成后一定要记得释放锁。
|
||||
|
||||
一定不要滥用锁,否则容易导致死锁。死锁的原因,主要由于多个线程中多把锁相互争用导致的。一般来说,如果程序中使用的锁比较多,很难分析死锁的原因,所以需要尽量少的使用锁,并且保持程序的结构尽量简单、清晰。
|
||||
|
||||
最后,我们介绍了读写锁,在某些场景下,使用读写锁可以兼顾性能和安全性,是非常好的选择。
|
||||
|
||||
## 思考题
|
||||
|
||||
我刚刚讲到,Python中提供了try-with-lock,不需要显式地获取和释放锁,非常方便。遗憾的是,在Java中并没有这样的机制,那你能不能自己在Java中实现一个try-with-lock呢?
|
||||
|
||||
欢迎你把代码上传到GitHub上,然后在评论区给出访问链接。如果你有任何问题,也可以在评论区留言与我交流。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
189
极客时间专栏/消息队列高手课/进阶篇/18 | 如何用硬件同步原语(CAS)替代锁?.md
Normal file
189
极客时间专栏/消息队列高手课/进阶篇/18 | 如何用硬件同步原语(CAS)替代锁?.md
Normal file
@@ -0,0 +1,189 @@
|
||||
<audio id="audio" title="18 | 如何用硬件同步原语(CAS)替代锁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/49/7535b08b3e3c2ae76998afc5b43a6349.mp3"></audio>
|
||||
|
||||
你好,我是李玥。上节课,我们一起学习了如何使用锁来保护共享资源,你也了解到,使用锁是有一定性能损失的,并且,如果发生了过多的锁等待,将会非常影响程序的性能。
|
||||
|
||||
在一些特定的情况下,我们可以使用硬件同步原语来替代锁,可以保证和锁一样的数据安全性,同时具有更好的性能。
|
||||
|
||||
在今年的NSDI(NSDI是USENIX组织开办的关于网络系统设计的著名学术会议)上,伯克利大学发表了一篇论文《[Confluo: Distributed Monitoring and Diagnosis Stack for High-speed Networks](http://www.usenix.org/conference/nsdi19/presentation/khandelwal)》,这个论文中提到的Confluo,也是一个类似于消息队列的流数据存储,它的吞吐量号称是Kafka的4~10倍。对于这个实验结论我个人不是很认同,因为它设计的实验条件对Kafka来说不太公平。但不可否认的是,Confluo它的这个设计思路是一个创新,并且实际上它的性能也非常好。
|
||||
|
||||
Confluo是如何做到这么高的吞吐量的呢?这里面非常重要的一个创新的设计就是,它使用硬件同步原语来代替锁,在一个日志上(你可以理解为消息队列中的一个队列或者分区),保证严格顺序的前提下,实现了多线程并发写入。
|
||||
|
||||
今天,我们就来学习一下,如何用硬件同步原语(CAS)替代锁?
|
||||
|
||||
## 什么是硬件同步原语?
|
||||
|
||||
为什么硬件同步原语可以替代锁呢?要理解这个问题,你要首先知道硬件同步原语是什么。
|
||||
|
||||
硬件同步原语(Atomic Hardware Primitives)是由计算机硬件提供的一组原子操作,我们比较常用的原语主要是CAS和FAA这两种。
|
||||
|
||||
CAS(Compare and Swap),它的字面意思是:先比较,再交换。我们看一下CAS实现的伪代码:
|
||||
|
||||
```
|
||||
<< atomic >>
|
||||
function cas(p : pointer to int, old : int, new : int) returns bool {
|
||||
if *p ≠ old {
|
||||
return false
|
||||
}
|
||||
*p ← new
|
||||
return true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它的输入参数一共有三个,分别是:
|
||||
|
||||
- p: 要修改的变量的指针。
|
||||
- old: 旧值。
|
||||
- new: 新值。
|
||||
|
||||
返回的是一个布尔值,标识是否赋值成功。
|
||||
|
||||
通过这个伪代码,你就可以看出CAS原语的逻辑,非常简单,就是先比较一下变量p当前的值是不是等于old,如果等于,那就把变量p赋值为new,并返回true,否则就不改变变量p,并返回false。
|
||||
|
||||
这是CAS这个原语的语义,接下来我们看一下FAA原语(Fetch and Add):
|
||||
|
||||
```
|
||||
<< atomic >>
|
||||
function faa(p : pointer to int, inc : int) returns int {
|
||||
int value <- *location
|
||||
*p <- value + inc
|
||||
return value
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
FAA原语的语义是,先获取变量p当前的值value,然后给变量p增加inc,最后返回变量p之前的值value。
|
||||
|
||||
讲到这儿估计你会问,这两个原语到底有什么特殊的呢?
|
||||
|
||||
上面的这两段伪代码,如果我们用编程语言来实现,肯定是无法保证原子性的。而原语的特殊之处就是,它们都是由计算机硬件,具体说就是CPU提供的实现,可以保证操作的原子性。
|
||||
|
||||
我们知道,**原子操作具有不可分割性,也就不存在并发的问题**。所以在某些情况下,原语可以用来替代锁,实现一些即安全又高效的并发操作。
|
||||
|
||||
CAS和FAA在各种编程语言中,都有相应的实现,可以来直接使用,无论你是使用哪种编程语言,它们底层的实现是一样的,效果也是一样的。
|
||||
|
||||
接下来,还是拿我们熟悉的账户服务来举例说明一下,看看如何使用CAS原语来替代锁,实现同样的安全性。
|
||||
|
||||
## CAS版本的账户服务
|
||||
|
||||
假设我们有一个共享变量balance,它保存的是当前账户余额,然后我们模拟多个线程并发转账的情况,看一下如何使用CAS原语来保证数据的安全性。
|
||||
|
||||
这次我们使用Go语言来实现这个转账服务。先看一下使用锁实现的版本:
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 账户初始值为0元
|
||||
var balance int32
|
||||
balance = int32(0)
|
||||
done := make(chan bool)
|
||||
// 执行10000次转账,每次转入1元
|
||||
count := 10000
|
||||
|
||||
var lock sync.Mutex
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
// 这里模拟异步并发转账
|
||||
go transfer(&balance, 1, done, &lock)
|
||||
}
|
||||
// 等待所有转账都完成
|
||||
for i := 0; i < count; i++ {
|
||||
<-done
|
||||
}
|
||||
// 打印账户余额
|
||||
fmt.Printf("balance = %d \n", balance)
|
||||
}
|
||||
// 转账服务
|
||||
func transfer(balance *int32, amount int, done chan bool, lock *sync.Mutex) {
|
||||
lock.Lock()
|
||||
*balance = *balance + int32(amount)
|
||||
lock.Unlock()
|
||||
done <- true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个例子中,我们让账户的初始值为0,然后启动多个协程来并发执行10000次转账,每次往账户中转入1元,全部转账执行完成后,账户中的余额应该正好是10000元。
|
||||
|
||||
如果你没接触过Go语言,不了解协程也没关系,你可以简单地把它理解为进程或者线程都可以,这里我们只是希望能异步并发执行转账,我们并不关心这几种“程”他们之间细微的差别。
|
||||
|
||||
这个使用锁的版本,反复多次执行,每次balance的结果都正好是10000,那这段代码的安全性是没问题的。接下来我们看一下,使用CAS原语的版本。
|
||||
|
||||
```
|
||||
func transferCas(balance *int32, amount int, done chan bool) {
|
||||
for {
|
||||
old := atomic.LoadInt32(balance)
|
||||
new := old + int32(amount)
|
||||
if atomic.CompareAndSwapInt32(balance, old, new) {
|
||||
break
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个CAS版本的转账服务和上面使用锁的版本,程序的总体结构是一样的,主要的区别就在于,“异步给账户余额+1”这一小块儿代码的实现。
|
||||
|
||||
那在使用锁的版本中,需要先获取锁,然后变更账户的值,最后释放锁,完成一次转账。我们可以看一下使用CAS原语的实现:
|
||||
|
||||
首先,它用for来做了一个没有退出条件的循环。在这个循环的内部,反复地调用CAS原语,来尝试给账户的余额+1。先取得账户当前的余额,暂时存放在变量old中,再计算转账之后的余额,保存在变量new中,然后调用CAS原语来尝试给变量balance赋值。我们刚刚讲过,CAS原语它的赋值操作是有前置条件的,只有变量balance的值等于old时,才会将balance赋值为new。
|
||||
|
||||
我们在for循环中执行了3条语句,在并发的环境中执行,这里面会有两种可能情况:
|
||||
|
||||
一种情况是,执行到第3条CAS原语时,没有其他线程同时改变了账户余额,那我们是可以安全变更账户余额的,这个时候执行CAS的返回值一定是true,转账成功,就可以退出循环了。并且,CAS这一条语句,它是一个原子操作,赋值的安全性是可以保证的。
|
||||
|
||||
另外一种情况,那就是在这个过程中,有其他线程改变了账户余额,这个时候是无法保证数据安全的,不能再进行赋值。执行CAS原语时,由于无法通过比较的步骤,所以不会执行赋值操作。本次尝试转账失败,当前线程并没有对账户余额做任何变更。由于返回值为false,不会退出循环,所以会继续重试,直到转账成功退出循环。
|
||||
|
||||
这样,每一次转账操作,都可以通过若干次重试,在保证安全性的前提下,完成并发转账操作。
|
||||
|
||||
其实,对于这个例子,还有更简单、性能更好的方式:那就是,直接使用FAA原语。
|
||||
|
||||
```
|
||||
func transferFaa(balance *int32, amount int, done chan bool) {
|
||||
atomic.AddInt32(balance, int32(amount))
|
||||
done <- true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
FAA原语它的操作是,获取变量当前的值,然后把它做一个加法,并且保证这个操作的原子性,一行代码就可以搞定了。看到这儿,你可能会想,那CAS原语还有什么意义呢?
|
||||
|
||||
在这个例子里面,肯定是使用FAA原语更合适,但是我们上面介绍的,使用CAS原语的方法,它的适用范围更加广泛一些。类似于这样的逻辑:先读取数据,做计算,然后更新数据,无论这个计算是什么样的,都可以使用CAS原语来保护数据安全,但是FAA原语,这个计算的逻辑只能局限于简单的加减法。所以,我们上面讲的这种使用CAS原语的方法并不是没有意义的。
|
||||
|
||||
另外,你需要知道的是,这种使用CAS原语反复重试赋值的方法,它是比较耗费CPU资源的,因为在for循环中,如果赋值不成功,是会立即进入下一次循环没有等待的。如果线程之间的碰撞非常频繁,经常性的反复重试,这个重试的线程会占用大量的CPU时间,随之系统的整体性能就会下降。
|
||||
|
||||
缓解这个问题的一个方法是使用Yield(), 大部分编程语言都支持Yield()这个系统调用,Yield()的作用是,告诉操作系统,让出当前线程占用的CPU给其他线程使用。每次循环结束前调用一下Yield()方法,可以在一定程度上减少CPU的使用率,缓解这个问题。你也可以在每次循环结束之后,Sleep()一小段时间,但是这样做的代价是,性能会严重下降。
|
||||
|
||||
所以,这种方法它只适合于线程之间碰撞不太频繁,也就是说绝大部分情况下,执行CAS原语不需要重试这样的场景。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我们一起学习了CAS和FAA这两个原语。这些原语,是由CPU提供的原子操作,在并发环境中,单独使用这些原语不用担心数据安全问题。在特定的场景中,CAS原语可以替代锁,在保证安全性的同时,提供比锁更好的性能。
|
||||
|
||||
接下来,我们用转账服务这个例子,分别演示了CAS和FAA这两个原语是如何替代锁来使用的。对于类似:“先读取数据,做计算,然后再更新数据”这样的业务逻辑,可以使用CAS原语+反复重试的方式来保证数据安全,前提是,线程之间的碰撞不能太频繁,否则太多重试会消耗大量的CPU资源,反而得不偿失。
|
||||
|
||||
## 思考题
|
||||
|
||||
这节课的课后作业,依然需要你去动手来写代码。你需要把我们这节课中的讲到的账户服务这个例子,用你熟悉的语言,用锁、CAS和FAA这三种方法,都完整地实现一遍。每种实现方法都要求是完整的,可以执行的程序。
|
||||
|
||||
因为,对于并发和数据安全这块儿,你不仅要明白原理,熟悉相关的API,会正确地使用,是非常重要的。在这部分写出的Bug,都比较诡异,不好重现,而且很难调试。你会发现,你的数据一会儿是对的,一会儿又错了。或者在你开发的电脑上都正确,部署到服务器上又错了等等。所以,熟练掌握,一次性写出正确的代码,这样会帮你省出很多找Bug的时间。
|
||||
|
||||
验证作业是否正确的方法是,你反复多次执行你的程序,应该每次打印的结果都是:
|
||||
|
||||
```
|
||||
balance = 10000
|
||||
|
||||
```
|
||||
|
||||
欢迎你把代码上传到GitHub上,然后在评论区给出访问链接。如果你有任何问题,也可以在评论区留言与我交流。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
114
极客时间专栏/消息队列高手课/进阶篇/19 | 数据压缩:时间换空间的游戏.md
Normal file
114
极客时间专栏/消息队列高手课/进阶篇/19 | 数据压缩:时间换空间的游戏.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<audio id="audio" title="19 | 数据压缩:时间换空间的游戏" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/b3/06836fa9e432ca1250d9490eb22c6eb3.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
这节课我们一起来聊一聊数据压缩。我在前面文章中提到过,我曾经在一台配置比较高的服务器上,对Kafka做过一个极限的性能压测,想验证一下Kafka到底有多快。我使用的种子消息大小为1KB,只要是并发数量足够多,不开启压缩时,可以打满万兆网卡的全部带宽,TPS接近100万。开启压缩时,TPS可以达到2000万左右,吞吐量提升了大约20倍!
|
||||
|
||||
算术好的同学可能会立刻反驳我说,2000万TPS乘以1KB的消息大小,再把字节Byte转换成比特bit,换算成网络传输的带宽是200Gb/s,服务器网卡根本达不到这么大的传输带宽!
|
||||
|
||||
我们的测试服务器的网卡就是普通的万兆网卡,极限带宽也就是10Gb/s,压测时候的实际网络流量大概在7Gb/s左右。这里面,最重要的原因就是,我在测试的时候开启了Kafka的压缩功能。可以看到,对于Kafka来说,使用数据压缩,提升了大概几十倍的吞吐量。当然,在实际生产时,不太可能达到这么高的压缩率,但是合理地使用数据压缩,仍然可以做到提升数倍的吞吐量。
|
||||
|
||||
所以,**数据压缩不仅能节省存储空间,还可以用于提升网络传输性能。**这种使用压缩来提升系统性能的方法,不仅限于在消息队列中使用,我们日常开发的应用程序也可以使用。比如,我们的程序要传输大量的数据,或者要在磁盘、数据库中存储比较大的数据,这些情况下,都可以考虑使用数据压缩来提升性能,还能节省网络带宽和存储空间。
|
||||
|
||||
那如何在你的程序中使用压缩?应该选择什么样的压缩算法更适合我们的系统呢?这节课,我带你一起学习一下,使用数据压缩来提升系统性能的方法。
|
||||
|
||||
## 什么情况适合使用数据压缩?
|
||||
|
||||
在使用压缩之前,首先你需要考虑,当前这个场景是不是真的适合使用数据压缩。
|
||||
|
||||
比如,进程之间通过网络传输数据,这个数据是不是需要压缩呢?我和你一起来对比一下:
|
||||
|
||||
- 不压缩直接传输需要的时间是: 传输**未压缩**数据的耗时。
|
||||
- 使用数据压缩需要的时间是: 压缩耗时 + 传输**压缩**数据耗时 + 解压耗时。
|
||||
|
||||
到底是压缩快,还是不压缩快呢?其实不好说。影响的因素非常多,比如数据的压缩率、网络带宽、收发两端服务器的繁忙程度等等。
|
||||
|
||||
压缩和解压的操作都是计算密集型的操作,非常耗费CPU资源。如果你的应用处理业务逻辑就需要耗费大量的CPU资源,就不太适合再进行压缩和解压。
|
||||
|
||||
又比如说,如果你的系统的瓶颈是磁盘的IO性能,CPU资源又很闲,这种情况就非常适合在把数据写入磁盘前先进行压缩。
|
||||
|
||||
但是,如果你的系统读写比严重不均衡,你还要考虑,每读一次数据就要解压一次是不是划算。
|
||||
|
||||
**压缩它的本质是资源的置换,是一个时间换空间,或者说是CPU资源换存储资源的游戏。**
|
||||
|
||||
就像木桶的那个短板一样,每一个系统它都有一个性能瓶颈资源,可能是磁盘IO,网络带宽,也可能是CPU。如果使用压缩,能用长板来换一些短板,那总体上就能提升性能,这样就是划算的。如果用了压缩之后,短板更短了,那就不划算了,不如不用。
|
||||
|
||||
如果通过权衡,使用数据压缩确实可以提升系统的性能,接下来就需要选择合适的压缩算法。
|
||||
|
||||
## 应该选择什么压缩算法?
|
||||
|
||||
压缩算法可以分为有损压缩和无损压缩。有损压缩主要是用来压缩音视频,它压缩之后是会丢失信息的。我们这里讨论的全都是无损压缩,也就是说,数据经过压缩和解压过程之后,与压缩之前相比,是100%相同的。
|
||||
|
||||
数据为什么可以被压缩呢?各种各样的压缩算法又是怎么去压缩数据的呢?我举个例子来简单说明一下。
|
||||
|
||||
比如说,下面这段数据:
|
||||
|
||||
>
|
||||
00000000000000000000
|
||||
|
||||
|
||||
我来给你人肉压缩一下:
|
||||
|
||||
>
|
||||
20个0
|
||||
|
||||
|
||||
20个字符就被压缩成了4个字符,并且是可以无损还原的。当然,我举的例子比较极端,我的压缩算法也几乎没什么实用性,但是,这确实是一个压缩算法,并且和其他的压缩算法本质是没什么区别的。
|
||||
|
||||
目前常用的压缩算法包括:ZIP,GZIP,SNAPPY,LZ4等等。选择压缩算法的时候,主要需要考虑数据的压缩率和压缩耗时。一般来说,压缩率越高的算法,压缩耗时也越高。如果是对性能要求高的系统,可以选择压缩速度快的算法,比如LZ4;如果需要更高的压缩比,可以考虑GZIP或者压缩率更高的XZ等算法。
|
||||
|
||||
压缩样本对压缩速度和压缩比的影响也是比较大的,同样大小的一段数字和一段新闻的文本,即使是使用相同的压缩算法,压缩率和压缩时间的差异也是比较大的。所以,有的时候在选择压缩算法的之前,用系统的样例业务数据做一个测试,可以帮助你找到最合适的压缩算法。
|
||||
|
||||
在这里,我不会去给你讲某一种压缩算法,因为压缩算法都很复杂,一般来说也不需要我们来实现某种压缩算法,如果你感兴趣的话,可以去学习一下最经典压缩算法:哈夫曼编码(也叫霍夫曼编码,Huffman Coding)。
|
||||
|
||||
## 如何选择合适的压缩分段?
|
||||
|
||||
大部分的压缩算法,他们的区别主要是,对数据进行编码的算法,压缩的流程和压缩包的结构大致一样的。而在压缩过程中,你最需要了解的就是如何选择合适的压缩分段大小。
|
||||
|
||||
在压缩时,给定的被压缩数据它必须有确定的长度,或者说,是有头有尾的,不能是一个无限的数据流,**如果要对流数据进行压缩,那必须把流数据划分成多个帧,一帧一帧的分段压缩。**
|
||||
|
||||
主要原因是,压缩算法在开始压缩之前,一般都需要对被压缩数据从头到尾进行一次扫描,扫描的目的是确定如何对数据进行划分和编码,一般的原则是重复次数多、占用空间大的内容,使用尽量短的编码,这样压缩率会更高。
|
||||
|
||||
另外,被压缩的数据长度越大,重码率会更高,压缩比也就越高。这个很好理解,比如我们这篇文章,可能出现了几十次“压缩”这个词,如果将整篇文章压缩,这个词的重复率是几十次,但如果我们按照每个自然段来压缩,那每段中这个词的重复率只有二三次。显然全文压缩的压缩率肯定高于分段压缩。
|
||||
|
||||
当然,分段也不是越大越好,实际上分段大小超过一定长度之后,再增加长度对压缩率的贡献就不太大了,这是一个原因。另外,过大的分段长度,在解压缩的时候,会有更多的解压浪费。比如,一个1MB大小的压缩文件,即使你只是需要读其中很短的几个字节,也不得不把整个文件全部解压缩,造成很大的解压浪费。
|
||||
|
||||
所以,你需要根据你的业务,选择合适的压缩分段,在压缩率、压缩速度和解压浪费之间找到一个合适的平衡。
|
||||
|
||||
确定了如何对数据进行划分和压缩算法之后,就可以进行压缩了,压缩的过程就是用编码来替换原始数据的过程。压缩之后的压缩包就是由这个编码字典和用编码替换之后的数据组成的。
|
||||
|
||||
这就是数据压缩的过程。解压的时候,先读取编码字典,然后按照字典把压缩编码还原成原始的数据就可以了。
|
||||
|
||||
## Kafka是如何处理消息压缩的?
|
||||
|
||||
回过头来,我们再看一下Kafka它是如何来处理数据压缩的。
|
||||
|
||||
首先,Kafka是否开启压缩,这是可以配置,它也支持配置使用哪一种压缩算法。原因我们在上面说过,不同的业务场景是否需要开启压缩,选择哪种压缩算法是不能一概而论的。所以,Kafka的设计者把这个选择权交给使用者。
|
||||
|
||||
在开启压缩时,Kafka选择一批消息一起压缩,每一个批消息就是一个压缩分段。使用者也可以通过参数来控制每批消息的大小。
|
||||
|
||||
我们之前讲过,在Kafka中,生产者生成一个批消息发给服务端,在服务端中是不会拆分批消息的。那按照批来压缩,意味着,在服务端也不用对这批消息进行解压,可以整批直接存储,然后整批发送给消费者。最后,批消息由消费者进行解压。
|
||||
|
||||
在服务端不用解压,就不会耗费服务端宝贵的CPU资源,同时还能获得压缩后,占用传输带宽小,占用存储空间小的这些好处,这是一个非常聪明的设计。
|
||||
|
||||
在使用Kafka时,如果生产者和消费者的CPU资源不是特别吃紧,开启压缩后,可以节省网络带宽和服务端的存储空间,提升总体的吞吐量,一般都是个不错的选择。
|
||||
|
||||
## 小结
|
||||
|
||||
数据压缩,它本质上是用CPU资源换取存储资源,或者说是用压缩解压的时间来换取存储的空间,这个买卖是不是划算,需要你根据自己的情况先衡量一下。
|
||||
|
||||
在选择压缩算法的时候,需要综合考虑压缩时间和压缩率两个因素,被压缩数据的内容也是影响压缩时间和压缩率的重要因素,必要的时候可以先用业务数据做一个压缩测试,这样有助于选择最合适的压缩算法。
|
||||
|
||||
另外一个影响压缩率的重要因素是压缩分段的大小,你需要根据业务情况选择一个合适的分段策略,在保证不错的压缩率的前提下,尽量减少解压浪费。
|
||||
|
||||
最后,我们讲了一下Kafka它是如何处理消息压缩的。Kafka在生产者上,对每批消息进行压缩,批消息在服务端不解压,消费者在收到消息之后再进行解压。简单地说,Kafka的压缩和解压都是在客户端完成的。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后,你可以去看一下RocketMQ的文档或者源代码,看一下,RocketMQ是怎么处理消息压缩的。然后和Kafka的压缩方式对比一下,想一想哪种处理方式更适合你的系统?
|
||||
|
||||
欢迎你在留言区把你的思考分享出来,如果有任何问题也欢迎你在留言区提问。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
308
极客时间专栏/消息队列高手课/进阶篇/20 | RocketMQ Producer源码分析:消息生产的实现过程.md
Normal file
308
极客时间专栏/消息队列高手课/进阶篇/20 | RocketMQ Producer源码分析:消息生产的实现过程.md
Normal file
@@ -0,0 +1,308 @@
|
||||
<audio id="audio" title="20 | RocketMQ Producer源码分析:消息生产的实现过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/bd/0949c8248d152f6aaec6b1dfec7386bd.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
对于消息队列来说,它最核心的功能就是收发消息。也就是消息生产和消费这两个流程。我们在之前的课程中提到了消息队列一些常见问题,比如,“如何保证消息不会丢失?”“为什么会收到重复消息?”“消费时为什么要先执行消费业务逻辑再确认消费?”,针对这些问题,我讲过它们的实现原理,这些最终落地到代码上,都包含在这一收一发两个流程中。
|
||||
|
||||
在接下来的两节课中,我会带你一起通过分析源码的方式,详细学习一下这两个流程到底是如何实现的。你在日常使用消息队列的时候,遇到的大部分问题,更多的是跟Producer和Consumer,也就是消息队列的客户端,关联更紧密。搞清楚客户端的实现原理和代码中的细节,将对你日后使用消息队列时进行问题排查有非常大的帮助。所以,我们这两节课的重点,也将放在分析客户端的源代码上。
|
||||
|
||||
秉着先易后难的原则,我们选择代码风格比较简明易懂的RocketMQ作为分析对象。一起分析RocketMQ的Producer的源代码,学习消息生产的实现过程。
|
||||
|
||||
在分析源代码的过程中,我们的首要目的就是搞清楚功能的实现原理,另外,最好能有敏锐的嗅觉,善于发现代码中优秀的设计和巧妙构思,学习、总结并记住这些方法。在日常开发中,再遇到类似场景,你就可以直接拿来使用。
|
||||
|
||||
我们使用当前最新的release版本release-4.5.1进行分析,使用Git在GitHub上直接下载源码到本地:
|
||||
|
||||
```
|
||||
git clone git@github.com:apache/rocketmq.git
|
||||
cd rocketmq
|
||||
git checkout release-4.5.1
|
||||
|
||||
```
|
||||
|
||||
客户端是一个单独的Module,在rocketmq/client目录中。
|
||||
|
||||
## 从单元测试看Producer API的使用
|
||||
|
||||
在专栏之前的课程《[09 | 学习开源代码该如何入手?](https://time.geekbang.org/column/article/115519)》中我和你讲过,不建议你从main()方法入手去分析源码,而是带着问题去分析。我们本节课的问题是非常清晰的,就是要搞清楚Producer是如何发消息的。带着这个问题,接下来我们该如何分析源码呢?
|
||||
|
||||
我的建议是,先看一下单元测试用例。因为,一般单元测试中,每一个用例就是测试代码中的一个局部或者说是一个小流程。那对于一些比较完善的开源软件,它们的单元测试覆盖率都非常高,很容易找到我们关心的那个流程所对应的测试用例。我们的源码分析,就可以从这些测试用例入手,一步一步跟踪其方法调用链路,理清实现过程。
|
||||
|
||||
首先我们先分析一下RocketMQ客户端的单元测试,看看Producer提供哪些API,更重要的是了解这些API应该如何使用。
|
||||
|
||||
Producer的所有测试用例都在同一个测试类"org.apache.rocketmq.client.producer.DefaultMQProducerTest"中,看一下这个测试类中的所有单元测试方法,大致可以了解到Producer的主要功能。
|
||||
|
||||
这个测试类的主要测试方法如下:
|
||||
|
||||
>
|
||||
<p>init<br>
|
||||
terminate<br>
|
||||
testSendMessage_ZeroMessage<br>
|
||||
testSendMessage_NoNameSrv<br>
|
||||
testSendMessage_NoRoute<br>
|
||||
testSendMessageSync_Success<br>
|
||||
testSendMessageSync_WithBodyCompressed<br>
|
||||
testSendMessageAsync_Success<br>
|
||||
testSendMessageAsync<br>
|
||||
testSendMessageAsync_BodyCompressed<br>
|
||||
testSendMessageSync_SuccessWithHook</p>
|
||||
|
||||
|
||||
其中init和terminate是测试开始初始化和测试结束销毁时需要执行的代码,其他以testSendMessage开头的方法都是在各种情况和各种场景下发送消息的测试用例,通过这些用例的名字,你可以大致看出测试的功能。
|
||||
|
||||
比如,testSendMessageSync和testSendMessageAsync分别是测试同步发送和异步发送的用例,testSendMessageSync_WithBodyCompressed是压缩消息发送的测试用例,等等。
|
||||
|
||||
像RocketMQ这种开源项目,前期花费大量时间去编写测试用例,看似浪费时间,实际上会节省非常多后期联调测试、集成测试、以及上线后出现问题解决问题的时间,并且能够有效降低线上故障的概率,总体来说是非常划算的。强烈建议你在日常进行开发的过程中,也多写一些测试用例,尽量把单元测试的覆盖率做到50%以上。
|
||||
|
||||
RockectMQ的Producer入口类为“org.apache.rocketmq.client.producer.DefaultMQProducer”,大致浏览一下代码和类的继承关系,我整理出Producer相关的几个核心类和接口如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/09/ee719ca65c6fb1d43c10c60512913209.png" alt="">
|
||||
|
||||
这里面RocketMQ使用了一个设计模式:门面模式(Facade Pattern)。
|
||||
|
||||
>
|
||||
门面模式主要的作用是给客户端提供了一个可以访问系统的接口,隐藏系统内部的复杂性。
|
||||
|
||||
|
||||
接口MQProducer就是这个模式中的门面,客户端只要使用这个接口就可以访问Producer实现消息发送的相关功能,从使用层面上来说,不必再与其他复杂的实现类打交道了。
|
||||
|
||||
类DefaultMQProducer实现了接口MQProducer,它里面的方法实现大多没有任何的业务逻辑,只是封装了对其他实现类的方法调用,也可以理解为是门面的一部分。Producer的大部分业务逻辑的实现都在类DefaultMQProducerImpl中,这个类我们会在后面重点分析其实现。
|
||||
|
||||
有的时候,我们的实现分散在很多的内部类中,不方便用接口来对外提供服务,你就可以仿照RocketMQ的这种方式,使用门面模式来隐藏内部实现,对外提供服务。
|
||||
|
||||
接口MQAdmin定义了一些元数据管理的方法,在消息发送过程中会用到。
|
||||
|
||||
## 启动过程
|
||||
|
||||
通过单元测试中的代码可以看到,在init()和terminate()这两个测试方法中,分别执行了Producer的start和shutdown方法,说明在RocketMQ中,Producer是一个有状态的服务,在发送消息之前需要先启动Producer。这个启动过程,实际上就是为了发消息做的准备工作,所以,在分析发消息流程之前,我们需要先理清Producer中维护了哪些状态,在启动过程中,Producer都做了哪些初始化的工作。有了这个基础才能分析其发消息的实现流程。
|
||||
|
||||
首先从测试用例的方法init()入手:
|
||||
|
||||
```
|
||||
@Before
|
||||
public void init() throws Exception {
|
||||
String producerGroupTemp = producerGroupPrefix + System.currentTimeMillis();
|
||||
producer = new DefaultMQProducer(producerGroupTemp);
|
||||
producer.setNamesrvAddr("127.0.0.1:9876");
|
||||
producer.setCompressMsgBodyOverHowmuch(16);
|
||||
|
||||
//省略构造测试消息的代码
|
||||
|
||||
producer.start();
|
||||
|
||||
//省略用于测试构造mock的代码
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段初始化代码的逻辑非常简单,就是创建了一个DefaultMQProducer的实例,为它初始化一些参数,然后调用start方法启动它。接下来我们跟进start方法的实现,继续分析其初始化过程。
|
||||
|
||||
DefaultMQProducer#start()方法中直接调用了DefaultMQProducerImpl#start()方法,我们直接来看这个方法的代码:
|
||||
|
||||
```
|
||||
public void start(final boolean startFactory) throws MQClientException {
|
||||
switch (this.serviceState) {
|
||||
case CREATE_JUST:
|
||||
this.serviceState = ServiceState.START_FAILED;
|
||||
|
||||
// 省略参数检查和异常情况处理的代码
|
||||
|
||||
// 获取MQClientInstance的实例mQClientFactory,没有则自动创建新的实例
|
||||
this.mQClientFactory = MQClientManager.getInstance().getAndCreateMQClientInstance(this.defaultMQProducer, rpcHook);
|
||||
// 在mQClientFactory中注册自己
|
||||
boolean registerOK = mQClientFactory.registerProducer(this.defaultMQProducer.getProducerGroup(), this);
|
||||
// 省略异常处理代码
|
||||
|
||||
// 启动mQClientFactory
|
||||
if (startFactory) {
|
||||
mQClientFactory.start();
|
||||
}
|
||||
this.serviceState = ServiceState.RUNNING;
|
||||
break;
|
||||
case RUNNING:
|
||||
case START_FAILED:
|
||||
case SHUTDOWN_ALREADY:
|
||||
// 省略异常处理代码
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// 给所有Broker发送心跳
|
||||
this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,RocketMQ使用一个成员变量serviceState来记录和管理自身的服务状态,这实际上是状态模式(State Pattern)这种设计模式的变种实现。
|
||||
|
||||
>
|
||||
状态模式允许一个对象在其内部状态改变时改变它的行为,对象看起来就像是改变了它的类。
|
||||
|
||||
|
||||
与标准的状态模式不同的是,它没有使用状态子类,而是使用分支流程(switch-case)来实现不同状态下的不同行为,在管理比较简单的状态时,使用这种设计会让代码更加简洁。这种模式非常广泛地用于管理有状态的类,推荐你在日常开发中使用。
|
||||
|
||||
在设计状态的时候,有两个要点是需要注意的,第一是,不仅要设计正常的状态,还要设计中间状态和异常状态,否则,一旦系统出现异常,你的状态就不准确了,你也就很难处理这种异常状态。比如在这段代码中,RUNNING和SHUTDOWN_ALREADY是正常状态,CREATE_JUST是一个中间状态,START_FAILED是一个异常状态。
|
||||
|
||||
第二个要点是,将这些状态之间的转换路径考虑清楚,并在进行状态转换的时候,检查上一个状态是否能转换到下一个状态。比如,在这里,只有处于CREATE_JUST状态才能转换为RUNNING状态,这样就可以确保这个服务是一次性的,只能启动一次。从而避免了多次启动服务而导致的各种问题。
|
||||
|
||||
接下来看一下启动过程的实现:
|
||||
|
||||
1. 通过一个单例模式(Singleton Pattern)的MQClientManager获取MQClientInstance的实例mQClientFactory,没有则自动创建新的实例;
|
||||
1. 在mQClientFactory中注册自己;
|
||||
1. 启动mQClientFactory;
|
||||
1. 给所有Broker发送心跳。
|
||||
|
||||
这里面又使用了一个最简单的设计模式:单例模式。我们在这儿给出单例模式的定义,不再详细说明了,不会的同学需要自我反省一下,然后赶紧去复习设计模式基础去。
|
||||
|
||||
>
|
||||
单例模式涉及一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
|
||||
|
||||
|
||||
其中实例mQClientFactory对应的类MQClientInstance是RocketMQ客户端中的顶层类,大多数情况下,可以简单地理解为每个客户端对应类MQClientInstance的一个实例。这个实例维护着客户端的大部分状态信息,以及所有的Producer、Consumer和各种服务的实例,想要学习客户端整体结构的同学可以从分析这个类入手,逐步细化分析下去。
|
||||
|
||||
我们进一步分析一下MQClientInstance#start()中的代码:
|
||||
|
||||
```
|
||||
// 启动请求响应通道
|
||||
this.mQClientAPIImpl.start();
|
||||
// 启动各种定时任务
|
||||
this.startScheduledTask();
|
||||
// 启动拉消息服务
|
||||
this.pullMessageService.start();
|
||||
// 启动Rebalance服务
|
||||
this.rebalanceService.start();
|
||||
// 启动Producer服务
|
||||
this.defaultMQProducer.getDefaultMQProducerImpl().start(false);
|
||||
|
||||
```
|
||||
|
||||
这一部分代码的注释比较清楚,流程是这样的:
|
||||
|
||||
1. 启动实例mQClientAPIImpl,其中mQClientAPIImpl是类MQClientAPIImpl的实例,封装了客户端与Broker通信的方法;
|
||||
1. 启动各种定时任务,包括与Broker之间的定时心跳,定时与NameServer同步数据等任务;
|
||||
1. 启动拉取消息服务;
|
||||
1. 启动Rebalance服务;
|
||||
1. 启动默认的Producer服务。
|
||||
|
||||
以上是Producer的启动流程。这里面有几个重要的类,你需要清楚它们的各自的职责。后续你在使用RocketMQ时,如果遇到问题需要调试代码,了解这几个重要类的职责会对你有非常大的帮助。
|
||||
|
||||
1. DefaultMQProducerImpl:Producer的内部实现类,大部分Producer的业务逻辑,也就是发消息的逻辑,都在这个类中。
|
||||
1. MQClientInstance:这个类中封装了客户端一些通用的业务逻辑,无论是Producer还是Consumer,最终需要与服务端交互时,都需要调用这个类中的方法;
|
||||
1. MQClientAPIImpl:这个类中封装了客户端服务端的RPC,对调用者隐藏了真正网络通信部分的具体实现;
|
||||
1. NettyRemotingClient:RocketMQ各进程之间网络通信的底层实现类。
|
||||
|
||||
## 消息发送过程
|
||||
|
||||
接下来我们一起分析Producer发送消息的流程。
|
||||
|
||||
在Producer的接口MQProducer中,定义了19个不同参数的发消息的方法,按照发送方式不同可以分成三类:
|
||||
|
||||
- 单向发送(Oneway):发送消息后立即返回,不处理响应,不关心是否发送成功;
|
||||
- 同步发送(Sync):发送消息后等待响应;
|
||||
- 异步发送(Async):发送消息后立即返回,在提供的回调方法中处理响应。
|
||||
|
||||
这三类发送实现基本上是相同的,异步发送稍微有一点儿区别,我们看一下异步发送的实现方法"DefaultMQProducerImpl#send()"(对应源码中的1132行):
|
||||
|
||||
```
|
||||
@Deprecated
|
||||
public void send(final Message msg, final MessageQueueSelector selector, final Object arg, final SendCallback sendCallback, final long timeout)
|
||||
throws MQClientException, RemotingException, InterruptedException {
|
||||
final long beginStartTime = System.currentTimeMillis();
|
||||
ExecutorService executor = this.getAsyncSenderExecutor();
|
||||
try {
|
||||
executor.submit(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
long costTime = System.currentTimeMillis() - beginStartTime;
|
||||
if (timeout > costTime) {
|
||||
try {
|
||||
try {
|
||||
sendSelectImpl(msg, selector, arg, CommunicationMode.ASYNC, sendCallback,
|
||||
timeout - costTime);
|
||||
} catch (MQBrokerException e) {
|
||||
throw new MQClientException("unknownn exception", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sendCallback.onException(e);
|
||||
}
|
||||
} else {
|
||||
sendCallback.onException(new RemotingTooMuchRequestException("call timeout"));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
} catch (RejectedExecutionException e) {
|
||||
throw new MQClientException("exector rejected ", e);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,RocketMQ使用了一个ExecutorService来实现异步发送:使用asyncSenderExecutor的线程池,异步调用方法sendSelectImpl(),继续发送消息的后续工作,当前线程把发送任务提交给asyncSenderExecutor就可以返回了。单向发送和同步发送的实现则是直接在当前线程中调用方法sendSelectImpl()。
|
||||
|
||||
我们来继续看方法sendSelectImpl()的实现:
|
||||
|
||||
```
|
||||
// 省略部分代码
|
||||
MessageQueue mq = null;
|
||||
|
||||
// 选择将消息发送到哪个队列(Queue)中
|
||||
try {
|
||||
List<MessageQueue> messageQueueList =
|
||||
mQClientFactory.getMQAdminImpl().parsePublishMessageQueues(topicPublishInfo.getMessageQueueList());
|
||||
Message userMessage = MessageAccessor.cloneMessage(msg);
|
||||
String userTopic = NamespaceUtil.withoutNamespace(userMessage.getTopic(), mQClientFactory.getClientConfig().getNamespace());
|
||||
userMessage.setTopic(userTopic);
|
||||
|
||||
mq = mQClientFactory.getClientConfig().queueWithNamespace(selector.select(messageQueueList, userMessage, arg));
|
||||
} catch (Throwable e) {
|
||||
throw new MQClientException("select message queue throwed exception.", e);
|
||||
}
|
||||
|
||||
// 省略部分代码
|
||||
|
||||
// 发送消息
|
||||
if (mq != null) {
|
||||
return this.sendKernelImpl(msg, mq, communicationMode, sendCallback, null, timeout - costTime);
|
||||
} else {
|
||||
throw new MQClientException("select message queue return null.", null);
|
||||
}
|
||||
// 省略部分代码
|
||||
|
||||
```
|
||||
|
||||
方法sendSelectImpl()中主要的功能就是选定要发送的队列,然后调用方法sendKernelImpl()发送消息。
|
||||
|
||||
选择哪个队列发送由MessageQueueSelector#select方法决定。在这里RocketMQ使用了策略模式(Strategy Pattern),来解决不同场景下需要使用不同的队列选择算法问题。
|
||||
|
||||
>
|
||||
策略模式:定义一系列算法,将每一个算法封装起来,并让它们可以相互替换。策略模式让算法独立于使用它的客户而变化。
|
||||
|
||||
|
||||
RocketMQ提供了很多MessageQueueSelector的实现,例如随机选择策略,哈希选择策略和同机房选择策略等,如果需要,你也可以自己实现选择策略。之前我们的课程中提到过,如果要保证相同key消息的严格顺序,你需要使用哈希选择策略,或者提供一个自己实现的选择策略。
|
||||
|
||||
接下来我们再看一下方法sendKernelImpl()。这个方法的代码非常多,大约有200行,但逻辑比较简单,主要功能就是构建发送消息的头RequestHeader和上下文SendMessageContext,然后调用方法MQClientAPIImpl#sendMessage(),将消息发送给队列所在的Broker。
|
||||
|
||||
至此,消息被发送给远程调用的封装类MQClientAPIImpl,完成后续序列化和网络传输等步骤。
|
||||
|
||||
可以看到,RocketMQ的Producer整个发消息的流程,无论是同步发送还是异步发送,都统一到了同一个流程中。包括异步发送消息的实现,实际上也是通过一个线程池,在异步线程执行的调用和同步发送相同的底层方法来实现的。
|
||||
|
||||
在底层方法的代码中,依靠方法的一个参数来区分同步还是异步发送。这样实现的好处是,整个流程是统一的,很多同步异步共同的逻辑,代码可以复用,并且代码结构清晰简单,便于维护。
|
||||
|
||||
使用同步发送的时候,当前线程会阻塞等待服务端的响应,直到收到响应或者超时方法才会返回,所以在业务代码调用同步发送的时候,只要返回成功,消息就一定发送成功了。异步发送的时候,发送的逻辑都是在Executor的异步线程中执行的,所以不会阻塞当前线程,当服务端返回响应或者超时之后,Producer会调用Callback方法来给业务代码返回结果。业务代码需要在Callback中来判断发送结果。这和我们在之前的课程《[05 | 如何确保消息不会丢失?](https://time.geekbang.org/column/article/111488)》讲到的发送流程是完全一样的。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我带你分析了RocketMQ客户端消息生产的实现过程,包括Producer初始化和发送消息的主流程。Producer中包含的几个核心的服务都是有状态的,在Producer启动时,在MQClientInstance这个类中来统一来启动。在发送消息的流程中,RocketMQ分了三种发送方式:单向、同步和异步,这三种发送方式对应的发送流程基本是相同的,同步和异步发送是由已经封装好的MQClientAPIImpl类来分别实现的。
|
||||
|
||||
对于我们在分析代码中提到的几个重要的业务逻辑实现类,你最好能记住这几个类和它的功能,包括 :DefaultMQProducerImpl封装了大部分Producer的业务逻辑,MQClientInstance封装了客户端一些通用的业务逻辑,MQClientAPIImpl封装了客户端与服务端的RPC,NettyRemotingClient实现了底层网络通信。
|
||||
|
||||
我在课程中,只能带你把主干流程分析清楚,但是很多细节并没有涉及,课后请你一定要按照流程把源代码仔细看一遍,仔细消化一下没有提及到的分支流程,将这两个流程绘制成详细的流程图或者时序图。
|
||||
|
||||
分析过程中提到的几个设计模式,是非常实用且常用的设计模式,希望你能充分理解并熟练运用。
|
||||
|
||||
## 思考题
|
||||
|
||||
你有没有注意到,在源码中,异步发送消息方法DefaultMQProducerImpl#send()(1132行)被开发者加了@Deprecated(弃用)注解,显然开发者也意识到了这种异步的实现存在一些问题,需要改进。请你结合我们专栏文章《[10 | 如何使用异步设计提升系统性能?](https://time.geekbang.org/column/article/117272)》中讲到的异步设计方法想一想,应该如何改进这个异步发送的流程?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
196
极客时间专栏/消息队列高手课/进阶篇/21 | Kafka Consumer源码分析:消息消费的实现过程.md
Normal file
196
极客时间专栏/消息队列高手课/进阶篇/21 | Kafka Consumer源码分析:消息消费的实现过程.md
Normal file
@@ -0,0 +1,196 @@
|
||||
<audio id="audio" title="21 | Kafka Consumer源码分析:消息消费的实现过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/36/476b77f5b0d112d5d932aaf851075236.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们在上节课中提到过,用于解决消息队列一些常见问题的知识和原理,最终落地到代码上,都包含在收、发消息这两个流程中。对于消息队列的生产和消费这两个核心流程,在大部分消息队列中,它实现的主要流程都是一样的,所以,通过这两节课的学习之后,掌握了这两个流程的实现过程。无论你使用的是哪种消息队列,遇到收发消息的问题,你都可以用同样的思路去分析和解决问题。
|
||||
|
||||
上一节课我和你一起通过分析源代码学习了RocketMQ消息生产的实现过程,本节课我们来看一下Kafka消费者的源代码,理清Kafka消费的实现过程,并且能从中学习到一些Kafka的优秀设计思路和编码技巧。
|
||||
|
||||
在开始分析源码之前,我们一起来回顾一下Kafka消费模型的几个要点:
|
||||
|
||||
- Kafka的每个Consumer(消费者)实例属于一个ConsumerGroup(消费组);
|
||||
- 在消费时,ConsumerGroup中的每个Consumer独占一个或多个Partition(分区);
|
||||
- 对于每个ConsumerGroup,在任意时刻,每个Partition至多有1个Consumer在消费;
|
||||
- 每个ConsumerGroup都有一个Coordinator(协调者)负责分配Consumer和Partition的对应关系,当Partition或是Consumer发生变更时,会触发rebalance(重新分配)过程,重新分配Consumer与Partition的对应关系;
|
||||
- Consumer维护与Coordinator之间的心跳,这样Coordinator就能感知到Consumer的状态,在Consumer故障的时候及时触发rebalance。
|
||||
|
||||
掌握并理解Kafka的消费模型,对于接下来理解其消费的实现过程是至关重要的,如果你对上面的这些要点还有不清楚的地方,建议回顾一下之前的课程或者看一下Kafka相关的文档,然后再继续接下来的内容。
|
||||
|
||||
我们使用当前最新的版本2.2进行分析,使用Git在GitHub上直接下载源码到本地:
|
||||
|
||||
```
|
||||
git clone git@github.com:apache/kafka.git
|
||||
cd kafka
|
||||
git checkout 2.2
|
||||
|
||||
```
|
||||
|
||||
在《[09 | 学习开源代码该如何入手?](https://time.geekbang.org/column/article/115519)》这节课中,我讲过,分析国外源码最好的方式就是从文档入手,接下来我们就找一下Kafka的文档,看看从哪儿来入手开启我们的分析流程。
|
||||
|
||||
Kafka的Consumer入口类[KafkaConsumer的JavaDoc](https://kafka.apache.org/10/javadoc/?org/apache/kafka/clients/consumer/KafkaConsumer.html),给出了关于如何使用KafkaConsumer非常详细的说明文档,并且给出了一个使用Consumer消费的最简代码示例:
|
||||
|
||||
```
|
||||
// 设置必要的配置信息
|
||||
Properties props = new Properties();
|
||||
props.put("bootstrap.servers", "localhost:9092");
|
||||
props.put("group.id", "test");
|
||||
props.put("enable.auto.commit", "true");
|
||||
props.put("auto.commit.interval.ms", "1000");
|
||||
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
|
||||
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
|
||||
|
||||
// 创建Consumer实例
|
||||
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
|
||||
|
||||
// 订阅Topic
|
||||
consumer.subscribe(Arrays.asList("foo", "bar"));
|
||||
|
||||
// 循环拉消息
|
||||
while (true) {
|
||||
ConsumerRecords<String, String> records = consumer.poll(100);
|
||||
for (ConsumerRecord<String, String> record : records)
|
||||
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码主要的主要流程是:
|
||||
|
||||
1. 设置必要的配置信息,包括:起始连接的Broker地址,Consumer Group的ID,自动提交消费位置的配置和序列化配置;
|
||||
1. 创建Consumer实例;
|
||||
1. 订阅了2个Topic:foo 和 bar;
|
||||
1. 循环拉取消息并打印在控制台上。
|
||||
|
||||
通过上面的代码实例我们可以看到,消费这个大的流程,在Kafka中实际上是被分成了“订阅”和“拉取消息”这两个小的流程。另外,我在之前的课程中反复提到过,Kafka在消费过程中,每个Consumer实例是绑定到一个分区上的,那Consumer是如何确定,绑定到哪一个分区上的呢?这个问题也是可以通过分析消费流程来找到答案的。所以,我们分析整个消费流程主要聚焦在三个问题上:
|
||||
|
||||
1. 订阅过程是如何实现的?
|
||||
1. Consumer是如何与Coordinator协商,确定消费哪些Partition的?
|
||||
1. 拉取消息的过程是如何实现的?
|
||||
|
||||
了解前两个问题,有助于你充分理解Kafka的元数据模型,以及Kafka是如何在客户端和服务端之间来交换元数据的。最后一个问题,拉取消息的实现过程,实际上就是消费的主要流程,我们上节课讲过,这是消息队列最核心的两个流程之一,也是必须重点掌握的。我们就带着这三个问题,来分析Kafka的订阅和拉取消息的过程如何实现。
|
||||
|
||||
## 订阅过程如何实现?
|
||||
|
||||
我们先来看看订阅的实现流程。从上面的例子跟踪到订阅的主流程方法:
|
||||
|
||||
```
|
||||
public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener) {
|
||||
acquireAndEnsureOpen();
|
||||
try {
|
||||
// 省略部分代码
|
||||
|
||||
// 重置订阅状态
|
||||
this.subscriptions.subscribe(new HashSet<>(topics), listener);
|
||||
|
||||
// 更新元数据
|
||||
metadata.setTopics(subscriptions.groupSubscription());
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个代码中,我们先忽略掉各种参数和状态检查的分支代码,订阅的主流程主要更新了两个属性:一个是订阅状态subscriptions,另一个是更新元数据中的topic信息。订阅状态subscriptions主要维护了订阅的topic和patition的消费位置等状态信息。属性metadata中维护了Kafka集群元数据的一个子集,包括集群的Broker节点、Topic和Partition在节点上分布,以及我们聚焦的第二个问题:Coordinator给Consumer分配的Partition信息。
|
||||
|
||||
请注意一下,这个subscribe()方法的实现有一个非常值得大家学习的地方:就是开始的acquireAndEnsureOpen()和try-finally release(),作用就是保护这个方法只能单线程调用。
|
||||
|
||||
Kafka在文档中明确地注明了Consumer不是线程安全的,意味着Consumer被并发调用时会出现不可预期的结果。为了避免这种情况发生,Kafka做了主动的检测并抛出异常,而不是放任系统产生不可预期的情况。
|
||||
|
||||
Kafka“**主动检测不支持的情况并抛出异常,避免系统产生不可预期的行为**”这种模式,对于增强的系统的健壮性是一种非常有效的做法。如果你的系统不支持用户的某种操作,正确的做法是,检测不支持的操作,直接拒绝用户操作,并给出明确的错误提示,而不应该只是在文档中写上“不要这样做”,却放任用户错误的操作,产生一些不可预期的、奇怪的错误结果。
|
||||
|
||||
具体Kafka是如何实现的并发检测,大家可以看一下方法acquireAndEnsureOpen()的实现,很简单也很经典,我们就不再展开讲解了。
|
||||
|
||||
继续跟进到更新元数据的方法metadata.setTopics()里面,这个方法的实现除了更新元数据类Metadata中的topic相关的一些属性以外,还调用了Metadata.requestUpdate()方法请求更新元数据。
|
||||
|
||||
```
|
||||
public synchronized int requestUpdate() {
|
||||
this.needUpdate = true;
|
||||
return this.updateVersion;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
跟进到requestUpdate()的方法里面我们会发现,这里面并没有真正发送更新元数据的请求,只是将需要更新元数据的标志位needUpdate设置为true就结束了。Kafka必须确保在第一次拉消息之前元数据是可用的,也就是说在第一次拉消息之前必须更新一次元数据,否则Consumer就不知道它应该去哪个Broker上去拉哪个Partition的消息。
|
||||
|
||||
分析完订阅相关的代码,我们来总结一下:在订阅的实现过程中,Kafka更新了订阅状态subscriptions和元数据metadata中的相关topic的一些属性,将元数据状态置为“需要立即更新”,但是并没有真正发送更新元数据的请求,整个过程没有和集群有任何网络数据交换。
|
||||
|
||||
那这个元数据会在什么时候真正做一次更新呢?我们可以先带着这个问题接着看代码。
|
||||
|
||||
## 拉取消息的过程如何实现?
|
||||
|
||||
接下来,我们分析拉取消息的流程。这个流程的时序图如下(点击图片可放大查看):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/96/4be9e6aa9890e66bc4e26f0c318f8d96.png" alt="">
|
||||
|
||||
我们对着时序图来分析它的实现流程。在KafkaConsumer.poll()方法(对应源码1179行)的实现里面,可以看到主要是先后调用了2个私有方法:
|
||||
|
||||
1. updateAssignmentMetadataIfNeeded(): 更新元数据。
|
||||
1. pollForFetches():拉取消息。
|
||||
|
||||
方法updateAssignmentMetadataIfNeeded()中,调用了coordinator.poll()方法,poll()方法里面又调用了client.ensureFreshMetadata()方法,在client.ensureFreshMetadata()方法中又调用了client.poll()方法,实现了与Cluster通信,在Coordinator上注册Consumer并拉取和更新元数据。至此,“元数据会在什么时候真正做一次更新”这个问题也有了答案。
|
||||
|
||||
类ConsumerNetworkClient封装了Consumer和Cluster之间所有的网络通信的实现,这个类是一个非常彻底的异步实现。它没有维护任何的线程,所有待发送的Request都存放在属性unsent中,返回的Response存放在属性pendingCompletion中。每次调用poll()方法的时候,在当前线程中发送所有待发送的Request,处理所有收到的Response。
|
||||
|
||||
我们在之前的课程中讲到过,这种异步设计的优势就是用很少的线程实现高吞吐量,劣势也非常明显,极大增加了代码的复杂度。对比上节课我们分析的RocketMQ的代码,Producer和Consumer在主要收发消息流程上功能的复杂度是差不多的,但是你可以很明显地感受到Kafka的代码实现要比RocketMQ的代码实现更加的复杂难于理解。
|
||||
|
||||
我们继续分析方法pollForFetches()的实现。
|
||||
|
||||
```
|
||||
private Map<TopicPartition, List<ConsumerRecord<K, V>>> pollForFetches(Timer timer) {
|
||||
// 省略部分代码
|
||||
// 如果缓存里面有未读取的消息,直接返回这些消息
|
||||
final Map<TopicPartition, List<ConsumerRecord<K, V>>> records = fetcher.fetchedRecords();
|
||||
if (!records.isEmpty()) {
|
||||
return records;
|
||||
}
|
||||
// 构造拉取消息请求,并发送
|
||||
fetcher.sendFetches();
|
||||
// 省略部分代码
|
||||
// 发送网络请求拉取消息,等待直到有消息返回或者超时
|
||||
client.poll(pollTimer, () -> {
|
||||
return !fetcher.hasCompletedFetches();
|
||||
});
|
||||
// 省略部分代码
|
||||
// 返回拉到的消息
|
||||
return fetcher.fetchedRecords();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的主要实现逻辑是:
|
||||
|
||||
1. 如果缓存里面有未读取的消息,直接返回这些消息;
|
||||
1. 构造拉取消息请求,并发送;
|
||||
1. 发送网络请求并拉取消息,等待直到有消息返回或者超时;
|
||||
1. 返回拉到的消息。
|
||||
|
||||
在方法fetcher.sendFetches()的实现里面,Kafka根据元数据的信息,构造到所有需要的Broker的拉消息的Request,然后调用client.Send()方法将这些请求异步发送出去。并且,注册了一个回调类来处理返回的Response,所有返回的Response被暂时存放在Fetcher.completedFetches中。需要注意的是,这时的Request并没有被真正发给各个Broker,而是被暂存在了client.unsend中等待被发送。
|
||||
|
||||
然后,在调用client.poll()方法时,会真正将之前构造的所有Request发送出去,并处理收到的Response。
|
||||
|
||||
最后,fetcher.fetchedRecords()方法中,将返回的Response反序列化后转换为消息列表,返回给调用者。
|
||||
|
||||
综合上面的实现分析,我在这里给出整个拉取消息的流程涉及到的相关类的类图,在这个类图中,为了便于你理解,我并没有把所有类都绘制上去,只是把本节课两个流程相关的主要类和这些类里的关键属性画在了图中。你可以配合这个类图和上面的时序图进行代码阅读。
|
||||
|
||||
类图(点击图片可放大查看):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/a2/7b9e26b50308a377c62e741f844c0fa2.png" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
本节课我们一起分析了Kafka Consumer消费消息的实现过程。大家来分析代码过程中,不仅仅是要掌握Kafka整个消费的流程是是如何实现的,更重要的是理解它这种完全异步的设计思想。
|
||||
|
||||
发送请求时,构建Request对象,暂存入发送队列,但不立即发送,而是等待合适的时机批量发送。并且,用回调或者RequestFeuture方式,预先定义好如何处理响应的逻辑。在收到Broker返回的响应之后,也不会立即处理,而是暂存在队列中,择机处理。那这个择机策略就比较复杂了,有可能是需要读取响应的时候,也有可能是缓冲区满了或是时间到了,都有可能触发一次真正的网络请求,也就是在poll()方法中发送所有待发送Request并处理所有Response。
|
||||
|
||||
这种设计的好处是,不需要维护用于异步发送的和处理响应的线程,并且能充分发挥批量处理的优势,这也是Kafka的性能非常好的原因之一。这种设计的缺点也非常的明显,就是实现的复杂度太大了,如果没有深厚的代码功力,很难驾驭这么复杂的设计,并且后续维护的成本也很高。
|
||||
|
||||
总体来说,不推荐大家把代码设计得这么复杂。代码结构简单、清晰、易维护是是我们在设计过程中需要考虑的一个非常重要的因素。很多时候,为了获得较好的代码结构,在可接受的范围内,去牺牲一些性能,也是划算的。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们知道,Kafka Consumer在消费过程中是需要维护消费位置的,Consumer每次从当前消费位置拉取一批消息,这些消息都被正常消费后,Consumer会给Coordinator发一个提交位置的请求,然后消费位置会向后移动,完成一批消费过程。那kafka Consumer是如何维护和提交这个消费位置的呢?请你带着这个问题再回顾一下Consumer的代码,尝试独立分析代码并找到答案。欢迎在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
101
极客时间专栏/消息队列高手课/进阶篇/22 | Kafka和RocketMQ的消息复制实现的差异点在哪?.md
Normal file
101
极客时间专栏/消息队列高手课/进阶篇/22 | Kafka和RocketMQ的消息复制实现的差异点在哪?.md
Normal file
@@ -0,0 +1,101 @@
|
||||
<audio id="audio" title="22 | Kafka和RocketMQ的消息复制实现的差异点在哪?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/9b/934bbe94d0e56802433c80b80730509b.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
之前我在《[05 | 如何确保消息不会丢失?](https://time.geekbang.org/column/article/111488)》那节课中讲过,消息队列在收发两端,主要是依靠业务代码,配合请求确认的机制,来保证消息不会丢失的。而在服务端,一般采用持久化和复制的方式来保证不丢消息。
|
||||
|
||||
把消息复制到多个节点上,不仅可以解决丢消息的问题,还可以保证消息服务的高可用。即使某一个节点宕机了,还可以继续使用其他节点来收发消息。所以大部分生产系统,都会把消息队列配置成集群模式,并开启消息复制,来保证系统的高可用和数据可靠性。
|
||||
|
||||
这节课我们来讲一下,消息复制需要解决的一些问题,以及RocketMQ和Kafka都是如何应对这些问题来实现复制的。
|
||||
|
||||
## 消息复制面临什么问题?
|
||||
|
||||
我们希望消息队列最好能兼具高性能、高可用并且还能提供数据一致性的保证。虽然很多消息队列产品宣称三个特性全都支持,但你需要知道,这都是有前置条件的。
|
||||
|
||||
首先来说性能。任何的复制实现方式,数据的写入性能一定是不如单节点的。这个很好理解,因为无论采用哪种复制实现方式,都需要数据被写入到多个节点之后再返回,性能一定是不如只写入一个节点的。
|
||||
|
||||
**需要写入的节点数量越多,可用性和数据可靠性就越好,但是写入性能就越低,这是一个天然的矛盾。**不过,复制对消费的性能影响不大,不管采用哪种复制方式,消费消息的时候,都只是选择多副本中一个节点去读数据而已,这和单节点消费并没有差别。
|
||||
|
||||
再来说一致性,消息队列对数据一致性的要求,既包括了“不丢消息”这个要求,也包括“严格顺序”的要求。如果要确保数据一致性,必须采用“主-从”的复制方式,这个结论是有严格的数学论证的,大家只要记住就可以了。
|
||||
|
||||
在“主-从”模式下,数据先写入到主节点上,从节点只从主节点上复制数据,如果出现主从数据不一致的情况,必须以主节点上的数据为准。这里面需要注意一下,这里面的主节点它并不是不可变的,在很多的复制实现中,当主节点出现问题的时候,其他节点可以通过选举的方式,变成主节点。只要保证,在任何一个时刻,集群的主节点数不能超过1个,就可以确保数据一致性。
|
||||
|
||||
最后说一下高可用。既然必须要采用主从的复制方式,高可用需要解决的就是,当某个主节点宕机的时候,尽快再选出一个主节点来接替宕机的主节点。
|
||||
|
||||
比较快速的实现方式是,使用一个第三方的管理服务来管理这些节点,发现某个主节点宕机的时候,由管理服务来指定一个新的主节点。但引入管理服务会带来一系列问题,比如管理服务本身的高可用、数据一致性如何保证?
|
||||
|
||||
有的消息队列选择自选举的方式,由还存活的这些节点通过投票,来选出一个新的主节点,这种投票的实现方式,它的优点是没有外部依赖,可以实现自我管理。缺点就是投票的实现都比较复杂,并且选举的过程是比较慢的,几秒至几十秒都有可能,在选出新的主节点前,服务一直是不可用的。
|
||||
|
||||
大部分复制的实现,都不会选择把消息写入全部副本再返回确认,因为这样虽然可以保证数据一致性,但是,一旦这些副本中有任何一个副本宕机,写入就会卡死了。如果只把消息写入到一部分副本就认为写入成功并返回确认,就可以解决卡死的问题,并且性能也会比写全部副本好很多。
|
||||
|
||||
到底写入多少个副本算写入成功呢?这又是一个非常难抉择的问题。
|
||||
|
||||
假设我们的集群采用“一主二从三副本”的模式,如果只要消息写入到两个副本就算是写入成功了,那这三个节点最多允许宕机一个节点,否则就没法提供服务了。如果说我们把要求写入的副本数量降到1,只要消息写入到主节点就算成功了,那三个节点中,可以允许宕机两个节点,系统依然可以提供服务,这个可用性就更好一些。但是,有可能出现一种情况:主节点有一部分消息还没来得复制到任何一个从节点上,主节点就宕机了,这时候就会丢消息,数据一致性又没有办法保证了。
|
||||
|
||||
以上我讲的这些内容,还没有涉及到任何复制或者选举的方法和算法,都是最朴素,最基本的原理。你可以看出,这里面是有很多天然的矛盾,所以,**目前并没有一种完美的实现方案能够兼顾高性能、高可用和一致性。**
|
||||
|
||||
不同的消息队列选择了不同的复制实现方式,这些实现方式都有各自的优缺点,在高性能、高可用和一致性方面提供的能力也是各有高低。接下来我们一起来看一下RocketMQ和Kafka分别是如何来实现复制的。
|
||||
|
||||
## RocketMQ如何实现复制?
|
||||
|
||||
RocketMQ在2018年底迎来了一次重大的更新,引入Deldger,增加了一种全新的复制方式。我们先来说一下传统的复制方式。
|
||||
|
||||
在RocketMQ中,复制的基本单位是Broker,也就是服务端的进程。复制采用的也是主从方式,通常情况下配置成一主一从,也可以支持一主多从。
|
||||
|
||||
RocketMQ提供了两种复制方式,一种是异步复制,消息先发送到主节点上,就返回“写入成功”,然后消息再异步复制到从节点上。另外一种方式是同步双写,消息同步双写到主从节点上,主从都写成功,才返回“写入成功”。这两种方式本质上的区别是,写入多少个副本再返回“写入成功”的问题,异步复制需要的副本数是1,同步双写需要的副本数是2。
|
||||
|
||||
我刚刚讲过,如果在返回“写入成功”前,需要写入的副本数不够多,那就会丢消息。对RocketMQ来说,如果采用异步复制的方式会不会丢消息呢?答案是,并不会丢消息。
|
||||
|
||||
我来跟你说一下为什么不会丢消息。
|
||||
|
||||
在RocketMQ中,Broker的主从关系是通过配置固定的,不支持动态切换。如果主节点宕机,生产者就不能再生产消息了,消费者可以自动切换到从节点继续进行消费。这时候,即使有一些消息没有来得及复制到从节点上,这些消息依然躺在主节点的磁盘上,除非是主节点的磁盘坏了,否则等主节点重新恢复服务的时候,这些消息依然可以继续复制到从节点上,也可以继续消费,不会丢消息,消息的顺序也是没有问题的。
|
||||
|
||||
从设计上来讲,**RocketMQ的这种主从复制方式,牺牲了可用性,换取了比较好的性能和数据一致性。**
|
||||
|
||||
那RocketMQ又是如何解决可用性的问题的呢?一对儿主从节点可用性不行,多来几对儿主从节点不就解决了?RocketMQ支持把一个主题分布到多对主从节点上去,每对主从节点中承担主题中的一部分队列,如果某个主节点宕机了,会自动切换到其他主节点上继续发消息,这样既解决了可用性的问题,还可以通过水平扩容来提升Topic总体的性能。
|
||||
|
||||
这种复制方式在大多数场景下都可以很好的工作,但也面临一些问题。
|
||||
|
||||
比如,在需要保证消息严格顺序的场景下,由于在主题层面无法保证严格顺序,所以必须指定队列来发送消息,对于任何一个队列,它一定是落在一组特定的主从节点上,如果这个主节点宕机,其他的主节点是无法替代这个主节点的,否则就无法保证严格顺序。在这种复制模式下,严格顺序和高可用只能选择一个。
|
||||
|
||||
RocketMQ引入Dledger,使用新的复制方式,可以很好地解决这个问题。我们来看一下Dledger是怎么来复制的。
|
||||
|
||||
Dledger在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客户端返回写入成功,并且它是支持通过选举来动态切换主节点的。
|
||||
|
||||
同样拿3个节点举例说明一下。当主节点宕机的时候,2个从节点会通过投票选出一个新的主节点来继续提供服务,相比主从的复制模式,解决了可用性的问题。由于消息要至少复制到2个节点上才会返回写入成功,即使主节点宕机了,也至少有一个节点上的消息是和主节点一样的。Dledger在选举时,总会把数据和主节点一样的从节点选为新的主节点,这样就保证了数据的一致性,既不会丢消息,还可以保证严格顺序。
|
||||
|
||||
当然,Dledger的复制方式也不是完美的,依然存在一些不足:比如,选举过程中不能提供服务。最少需要3个节点才能保证数据一致性,3节点时,只能保证1个节点宕机时可用,如果2个节点同时宕机,即使还有1个节点存活也无法提供服务,资源的利用率比较低。另外,由于至少要复制到半数以上的节点才返回写入成功,性能上也不如主从异步复制的方式快。
|
||||
|
||||
讲完了RocketMQ,我们再来看看Kafka是怎么来实现复制的。
|
||||
|
||||
## Kafka是如何实现复制的?
|
||||
|
||||
Kafka中,复制的基本单位是分区。每个分区的几个副本之间,构成一个小的复制集群,Broker只是这些分区副本的容器,所以Kafka的Broker是不分主从的。
|
||||
|
||||
分区的多个副本中也是采用一主多从的方式。Kafka在写入消息的时候,采用的也是异步复制的方式。消息在写入到主节点之后,并不会马上返回写入成功,而是等待足够多的节点都复制成功后再返回。在Kafka中这个“足够多”是多少呢?Kafka的设计哲学是,让用户自己来决定。
|
||||
|
||||
Kafka为这个“足够多”创造了一个专有名词:ISR(In Sync Replicas),翻译过来就是“保持数据同步的副本”。ISR的数量是可配的,但需要注意的是,这个ISR中是包含主节点的。
|
||||
|
||||
Kafka使用ZooKeeper来监控每个分区的多个节点,如果发现某个分区的主节点宕机了,Kafka会利用ZooKeeper来选出一个新的主节点,这样解决了可用性的问题。ZooKeeper是一个分布式协调服务,后面,我会专门用一节课来介绍ZooKeeper。选举的时候,会从所有ISR节点中来选新的主节点,这样可以保证数据一致性。
|
||||
|
||||
默认情况下,如果所有的ISR节点都宕机了,分区就无法提供服务了。你也可以选择配置成让分区继续提供服务,这样只要有一个节点还活着,就可以提供服务,代价是无法保证数据一致性,会丢消息。
|
||||
|
||||
Kafka的这种高度可配置的复制方式,优点是非常灵活,你可以通过配置这些复制参数,在可用性、性能和一致性这几方面做灵活的取舍,缺点就是学习成本比较高。
|
||||
|
||||
## 总结
|
||||
|
||||
这节课我们主要来讲了一下,消息复制需要面临的问题以及RocketMQ和Kafka都是如何应对这些问题来实现复制的。
|
||||
|
||||
RocketMQ提供新、老两种复制方式:传统的主从模式和新的基于Dledger的复制方式。传统的主从模式性能更好,但灵活性和可用性稍差,而基于Dledger的复制方式,在Broker故障的时候可以自动选举出新节点,可用性更好,性能稍差,并且资源利用率更低一些。Kafka提供了基于ISR的更加灵活可配置的复制方式,用户可以自行配置,在可用性、性能和一致性这几方面根据系统的情况来做取舍。但是,这种灵活的配置方式学习成本较高。
|
||||
|
||||
并没有一种完美的复制方案,可以同时能够兼顾高性能、高可用和一致性。你需要根据你实际的业务需求,先做出取舍,然后再去配置消息队列的复制方式。
|
||||
|
||||
## 思考题
|
||||
|
||||
假设我们有一个5节点的RocketMQ集群,采用Dledger5副本的复制方式,集群中只有一个主题,50个队列均匀地分布到5个Broker上。
|
||||
|
||||
如果需要你来配置一套Kafka集群,要求达到和这个RocketMQ集群一样的性能(不考虑Kafka和RocketMQ本身的性能差异)、可用性和数据一致性,该如何配置?欢迎在留言区与我分享讨论。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
356
极客时间专栏/消息队列高手课/进阶篇/23 | RocketMQ客户端如何在集群中找到正确的节点?.md
Normal file
356
极客时间专栏/消息队列高手课/进阶篇/23 | RocketMQ客户端如何在集群中找到正确的节点?.md
Normal file
@@ -0,0 +1,356 @@
|
||||
<audio id="audio" title="23 | RocketMQ客户端如何在集群中找到正确的节点?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/c4/101d5b238d6e34039294281d2b91ddc4.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们在《[21 | RocketMQ Producer源码分析:消息生产的实现过程](https://time.geekbang.org/column/article/135120)》这节课中,讲解RocketMQ的生产者启动流程时提到过,生产者只要配置一个接入地址,就可以访问整个集群,并不需要客户端配置每个Broker的地址。RocketMQ会自动根据要访问的主题名称和队列序号,找到对应的Broker地址。如果Broker发生宕机,客户端还会自动切换到新的Broker节点上,这些对于用户代码来说都是透明的。
|
||||
|
||||
这些功能都是由NameServer协调Broker和客户端共同实现的,其中NameServer的作用是最关键的。
|
||||
|
||||
展开来讲,不仅仅是RocketMQ,任何一个弹性分布式集群,都需要一个类似于NameServer服务,来帮助访问集群的客户端寻找集群中的节点,这个服务一般称为NamingService。比如,像Dubbo这种RPC框架,它的注册中心就承担了NamingService的职责。在Flink中,则是JobManager承担了NamingService的职责。
|
||||
|
||||
也就是说,这种使用NamingService服务来协调集群的设计,在分布式集群的架构设计中,是一种非常通用的方法。你在学习这节课之后,不仅要掌握RocketMQ的NameServer是如何实现的,还要能总结出通用的NamingService的设计思想,并能应用于其他分布式系统的设计中。
|
||||
|
||||
这节课,我们一起来分析一下NameServer的源代码,看一下NameServer是如何协调集群中众多的Broker和客户端的。
|
||||
|
||||
## NameServer是如何提供服务的?
|
||||
|
||||
在RocketMQ中,NameServer是一个独立的进程,为Broker、生产者和消费者提供服务。NameServer最主要的功能就是,为客户端提供寻址服务,协助客户端找到主题对应的Broker地址。此外,NameServer还负责监控每个Broker的存活状态。
|
||||
|
||||
NameServer支持只部署一个节点,也支持部署多个节点组成一个集群,这样可以避免单点故障。在集群模式下,NameServer各节点之间是不需要任何通信的,也不会通过任何方式互相感知,每个节点都可以独立提供全部服务。
|
||||
|
||||
我们一起通过这个图来看一下,在RocketMQ集群中,NameServer是如何配合Broker、生产者和消费者一起工作的。这个图来自[RocketMQ的官方文档](https://github.com/apache/rocketmq/tree/master/docs)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/5e/53baeb70d388de042f7347d137b9d35e.jpeg" alt="">
|
||||
|
||||
每个Broker都需要和所有的NameServer节点进行通信。当Broker保存的Topic信息发生变化的时候,它会主动通知所有的NameServer更新路由信息,为了保证数据一致性,Broker还会定时给所有的NameServer节点上报路由信息。这个上报路由信息的RPC请求,也同时起到Broker与NameServer之间的心跳作用,NameServer依靠这个心跳来确定Broker的健康状态。
|
||||
|
||||
因为每个NameServer节点都可以独立提供完整的服务,所以,对于客户端来说,包括生产者和消费者,只需要选择任意一个NameServer节点来查询路由信息就可以了。客户端在生产或消费某个主题的消息之前,会先从NameServer上查询这个主题的路由信息,然后根据路由信息获取到当前主题和队列对应的Broker物理地址,再连接到Broker节点上进行生产或消费。
|
||||
|
||||
如果NameServer检测到与Broker的连接中断了,NameServer会认为这个Broker不再能提供服务。NameServer会立即把这个Broker从路由信息中移除掉,避免客户端连接到一个不可用的Broker上去。而客户端在与Broker通信失败之后,会重新去NameServer上拉取路由信息,然后连接到其他Broker上继续生产或消费消息,这样就实现了自动切换失效Broker的功能。
|
||||
|
||||
此外,NameServer还提供一个类似Redis的KV读写服务,这个不是主要的流程,我们不展开讲。
|
||||
|
||||
接下来我带你一起分析NameServer的源代码,看一下这些服务都是如何实现的。
|
||||
|
||||
## NameServer的总体结构
|
||||
|
||||
由于NameServer的结构非常简单,排除KV读写相关的类之后,一共只有6个类,这里面直接给出这6个类的说明:
|
||||
|
||||
- **NamesrvStartup**:程序入口。
|
||||
- **NamesrvController**:NameServer的总控制器,负责所有服务的生命周期管理。
|
||||
- **RouteInfoManager**:NameServer最核心的实现类,负责保存和管理集群路由信息。
|
||||
- **BrokerHousekeepingService**:监控Broker连接状态的代理类。
|
||||
- **DefaultRequestProcessor**:负责处理客户端和Broker发送过来的RPC请求的处理器。
|
||||
- **ClusterTestRequestProcessor**:用于测试的请求处理器。
|
||||
|
||||
RouteInfoManager这个类中保存了所有的路由信息,这些路由信息都是保存在内存中,并且没有持久化的。在代码中,这些路由信息保存在RouteInfoManager的几个成员变量中:
|
||||
|
||||
```
|
||||
public class BrokerData implements Comparable<BrokerData> {
|
||||
// ...
|
||||
private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
|
||||
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
|
||||
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
|
||||
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
|
||||
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
以上代码中的这5个Map对象,保存了集群所有的Broker和主题的路由信息。
|
||||
|
||||
topicQueueTable保存的是主题和队列信息,其中每个队列信息对应的类QueueData中,还保存了brokerName。需要注意的是,这个brokerName并不真正是某个Broker的物理地址,它对应的一组Broker节点,包括一个主节点和若干个从节点。
|
||||
|
||||
brokerAddrTable中保存了集群中每个brokerName对应Broker信息,每个Broker信息用一个BrokerData对象表示:
|
||||
|
||||
```
|
||||
public class BrokerData implements Comparable<BrokerData> {
|
||||
private String cluster;
|
||||
private String brokerName;
|
||||
private HashMap<Long/* brokerId */, String/* broker address */> brokerAddrs;
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
BrokerData中保存了集群名称cluster,brokerName和一个保存Broker物理地址的Map:brokerAddrs,它的Key是BrokerID,Value就是这个BrokerID对应的Broker的物理地址。
|
||||
|
||||
下面这三个map相对没那么重要,简单说明如下:
|
||||
|
||||
- brokerLiveTable中,保存了每个Broker当前的动态信息,包括心跳更新时间,路由数据版本等等。
|
||||
- clusterAddrTable中,保存的是集群名称与BrokerName的对应关系。
|
||||
- filterServerTable中,保存了每个Broker对应的消息过滤服务的地址,用于服务端消息过滤。
|
||||
|
||||
可以看到,在NameServer的RouteInfoManager中,主要的路由信息就是由topicQueueTable和brokerAddrTable这两个Map来保存的。
|
||||
|
||||
在了解了总体结构和数据结构之后,我们再来看一下实现的流程。
|
||||
|
||||
## NameServer如何处理Broker注册的路由信息?
|
||||
|
||||
首先来看一下,NameServer是如何处理Broker注册的路由信息的。
|
||||
|
||||
NameServer处理Broker和客户端所有RPC请求的入口方法是:“DefaultRequestProcessor#processRequest”,其中处理Broker注册请求的代码如下:
|
||||
|
||||
```
|
||||
public class DefaultRequestProcessor implements NettyRequestProcessor {
|
||||
// ...
|
||||
@Override
|
||||
public RemotingCommand processRequest(ChannelHandlerContext ctx,
|
||||
RemotingCommand request) throws RemotingCommandException {
|
||||
// ...
|
||||
switch (request.getCode()) {
|
||||
// ...
|
||||
case RequestCode.REGISTER_BROKER:
|
||||
Version brokerVersion = MQVersion.value2Version(request.getVersion());
|
||||
if (brokerVersion.ordinal() >= MQVersion.Version.V3_0_11.ordinal()) {
|
||||
return this.registerBrokerWithFilterServer(ctx, request);
|
||||
} else {
|
||||
return this.registerBroker(ctx, request);
|
||||
}
|
||||
// ...
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是一个非常典型的处理Request的路由分发器,根据request.getCode()来分发请求到对应的处理器中。Broker发给NameServer注册请求的Code为REGISTER_BROKER,在代码中根据Broker的版本号不同,分别有两个不同的处理实现方法:“registerBrokerWithFilterServer”和"registerBroker"。这两个方法实现的流程是差不多的,实际上都是调用了"RouteInfoManager#registerBroker"方法,我们直接看这个方法的代码:
|
||||
|
||||
```
|
||||
public RegisterBrokerResult registerBroker(
|
||||
final String clusterName,
|
||||
final String brokerAddr,
|
||||
final String brokerName,
|
||||
final long brokerId,
|
||||
final String haServerAddr,
|
||||
final TopicConfigSerializeWrapper topicConfigWrapper,
|
||||
final List<String> filterServerList,
|
||||
final Channel channel) {
|
||||
RegisterBrokerResult result = new RegisterBrokerResult();
|
||||
try {
|
||||
try {
|
||||
// 加写锁,防止并发修改数据
|
||||
this.lock.writeLock().lockInterruptibly();
|
||||
|
||||
// 更新clusterAddrTable
|
||||
Set<String> brokerNames = this.clusterAddrTable.get(clusterName);
|
||||
if (null == brokerNames) {
|
||||
brokerNames = new HashSet<String>();
|
||||
this.clusterAddrTable.put(clusterName, brokerNames);
|
||||
}
|
||||
brokerNames.add(brokerName);
|
||||
|
||||
// 更新brokerAddrTable
|
||||
boolean registerFirst = false;
|
||||
|
||||
BrokerData brokerData = this.brokerAddrTable.get(brokerName);
|
||||
if (null == brokerData) {
|
||||
registerFirst = true; // 标识需要先注册
|
||||
brokerData = new BrokerData(clusterName, brokerName, new HashMap<Long, String>());
|
||||
this.brokerAddrTable.put(brokerName, brokerData);
|
||||
}
|
||||
Map<Long, String> brokerAddrsMap = brokerData.getBrokerAddrs();
|
||||
// 更新brokerAddrTable中的brokerData
|
||||
Iterator<Entry<Long, String>> it = brokerAddrsMap.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Entry<Long, String> item = it.next();
|
||||
if (null != brokerAddr && brokerAddr.equals(item.getValue()) && brokerId != item.getKey()) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是新注册的Master Broker,或者Broker中的路由信息变了,需要更新topicQueueTable
|
||||
String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
|
||||
registerFirst = registerFirst || (null == oldAddr);
|
||||
|
||||
if (null != topicConfigWrapper
|
||||
&& MixAll.MASTER_ID == brokerId) {
|
||||
if (this.isBrokerTopicConfigChanged(brokerAddr, topicConfigWrapper.getDataVersion())
|
||||
|| registerFirst) {
|
||||
ConcurrentMap<String, TopicConfig> tcTable =
|
||||
topicConfigWrapper.getTopicConfigTable();
|
||||
if (tcTable != null) {
|
||||
for (Map.Entry<String, TopicConfig> entry : tcTable.entrySet()) {
|
||||
this.createAndUpdateQueueData(brokerName, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新brokerLiveTable
|
||||
BrokerLiveInfo prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr,
|
||||
new BrokerLiveInfo(
|
||||
System.currentTimeMillis(),
|
||||
topicConfigWrapper.getDataVersion(),
|
||||
channel,
|
||||
haServerAddr));
|
||||
if (null == prevBrokerLiveInfo) {
|
||||
log.info("new broker registered, {} HAServer: {}", brokerAddr, haServerAddr);
|
||||
}
|
||||
|
||||
// 更新filterServerTable
|
||||
if (filterServerList != null) {
|
||||
if (filterServerList.isEmpty()) {
|
||||
this.filterServerTable.remove(brokerAddr);
|
||||
} else {
|
||||
this.filterServerTable.put(brokerAddr, filterServerList);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是Slave Broker,需要在返回的信息中带上master的相关信息
|
||||
if (MixAll.MASTER_ID != brokerId) {
|
||||
String masterAddr = brokerData.getBrokerAddrs().get(MixAll.MASTER_ID);
|
||||
if (masterAddr != null) {
|
||||
BrokerLiveInfo brokerLiveInfo = this.brokerLiveTable.get(masterAddr);
|
||||
if (brokerLiveInfo != null) {
|
||||
result.setHaServerAddr(brokerLiveInfo.getHaServerAddr());
|
||||
result.setMasterAddr(masterAddr);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 释放写锁
|
||||
this.lock.writeLock().unlock();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("registerBroker Exception", e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码比较长,但总体结构很简单,就是根据Broker请求过来的路由信息,依次对比并更新clusterAddrTable、brokerAddrTable、topicQueueTable、brokerLiveTable和filterServerTable这5个保存集群信息和路由信息的Map对象中的数据。
|
||||
|
||||
另外,在RouteInfoManager中,这5个Map作为一个整体资源,使用了一个读写锁来做并发控制,避免并发更新和更新过程中读到不一致的数据问题。这个读写锁的使用方法,和我们在之前的课程《[17 | 如何正确使用锁保护共享数据,协调异步线程?](https://time.geekbang.org/column/article/129333)》中讲到的方法是一样的。
|
||||
|
||||
## 客户端如何寻找Broker?
|
||||
|
||||
下面我们来看一下,NameServer如何帮助客户端来找到对应的Broker。对于客户端来说,无论是生产者还是消费者,通过主题来寻找Broker的流程是一样的,使用的也是同一份实现。客户端在启动后,会启动一个定时器,定期从NameServer上拉取相关主题的路由信息,然后缓存在本地内存中,在需要的时候使用。每个主题的路由信息用一个TopicRouteData对象来表示:
|
||||
|
||||
```
|
||||
public class TopicRouteData extends RemotingSerializable {
|
||||
// ...
|
||||
private List<QueueData> queueDatas;
|
||||
private List<BrokerData> brokerDatas;
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中,queueDatas保存了主题中的所有队列信息,brokerDatas中保存了主题相关的所有Broker信息。客户端选定了队列后,可以在对应的QueueData中找到对应的BrokerName,然后用这个BrokerName找到对应的BrokerData对象,最终找到对应的Master Broker的物理地址。这部分代码在org.apache.rocketmq.client.impl.factory.MQClientInstance这个类中,你可以自行查看。
|
||||
|
||||
下面我们看一下在NameServer中,是如何实现根据主题来查询TopicRouteData的。
|
||||
|
||||
NameServer处理客户端请求和处理Broker请求的流程是一样的,都是通过路由分发器将请求分发的对应的处理方法中,我们直接看具体的实现方法RouteInfoManager#pickupTopicRouteData:
|
||||
|
||||
```
|
||||
public TopicRouteData pickupTopicRouteData(final String topic) {
|
||||
|
||||
// 初始化返回数据topicRouteData
|
||||
TopicRouteData topicRouteData = new TopicRouteData();
|
||||
boolean foundQueueData = false;
|
||||
boolean foundBrokerData = false;
|
||||
Set<String> brokerNameSet = new HashSet<String>();
|
||||
List<BrokerData> brokerDataList = new LinkedList<BrokerData>();
|
||||
topicRouteData.setBrokerDatas(brokerDataList);
|
||||
|
||||
HashMap<String, List<String>> filterServerMap = new HashMap<String, List<String>>();
|
||||
topicRouteData.setFilterServerTable(filterServerMap);
|
||||
|
||||
try {
|
||||
try {
|
||||
|
||||
// 加读锁
|
||||
this.lock.readLock().lockInterruptibly();
|
||||
|
||||
//先获取主题对应的队列信息
|
||||
List<QueueData> queueDataList = this.topicQueueTable.get(topic);
|
||||
if (queueDataList != null) {
|
||||
|
||||
// 把队列信息返回值中
|
||||
topicRouteData.setQueueDatas(queueDataList);
|
||||
foundQueueData = true;
|
||||
|
||||
// 遍历队列,找出相关的所有BrokerName
|
||||
Iterator<QueueData> it = queueDataList.iterator();
|
||||
while (it.hasNext()) {
|
||||
QueueData qd = it.next();
|
||||
brokerNameSet.add(qd.getBrokerName());
|
||||
}
|
||||
|
||||
// 遍历这些BrokerName,找到对应的BrokerData,并写入返回结果中
|
||||
for (String brokerName : brokerNameSet) {
|
||||
BrokerData brokerData = this.brokerAddrTable.get(brokerName);
|
||||
if (null != brokerData) {
|
||||
BrokerData brokerDataClone = new BrokerData(brokerData.getCluster(), brokerData.getBrokerName(), (HashMap<Long, String>) brokerData
|
||||
.getBrokerAddrs().clone());
|
||||
brokerDataList.add(brokerDataClone);
|
||||
foundBrokerData = true;
|
||||
for (final String brokerAddr : brokerDataClone.getBrokerAddrs().values()) {
|
||||
List<String> filterServerList = this.filterServerTable.get(brokerAddr);
|
||||
filterServerMap.put(brokerAddr, filterServerList);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 释放读锁
|
||||
this.lock.readLock().unlock();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("pickupTopicRouteData Exception", e);
|
||||
}
|
||||
|
||||
log.debug("pickupTopicRouteData {} {}", topic, topicRouteData);
|
||||
|
||||
if (foundBrokerData && foundQueueData) {
|
||||
return topicRouteData;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个方法的实现流程是这样的:
|
||||
|
||||
1. 初始化返回的topicRouteData后,获取读锁。
|
||||
1. 在topicQueueTable中获取主题对应的队列信息,并写入返回结果中。
|
||||
1. 遍历队列,找出相关的所有BrokerName。
|
||||
1. 遍历这些BrokerName,从brokerAddrTable中找到对应的BrokerData,并写入返回结果中。
|
||||
1. 释放读锁并返回结果。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我们一起分析了RocketMQ NameServer的源代码,NameServer在集群中起到的一个核心作用就是,为客户端提供路由信息,帮助客户端找到对应的Broker。
|
||||
|
||||
每个NameServer节点上都保存了集群所有Broker的路由信息,可以独立提供服务。Broker会与所有NameServer节点建立长连接,定期上报Broker的路由信息。客户端会选择连接某一个NameServer节点,定期获取订阅主题的路由信息,用于Broker寻址。
|
||||
|
||||
NameServer的所有核心功能都是在RouteInfoManager这个类中实现的,这类中使用了几个Map来在内存中保存集群中所有Broker的路由信息。
|
||||
|
||||
我们还一起分析了RouteInfoManager中的两个比较关键的方法:注册Broker路由信息的方法registerBroker,以及查询Broker路由信息的方法pickupTopicRouteData。
|
||||
|
||||
建议你仔细读一下这两个方法的代码,结合保存路由信息的几个Map的数据结构,体会一下RocketMQ NameServer这种简洁的设计。
|
||||
|
||||
把以上的这些NameServer的设计和实现方法抽象一下,我们就可以总结出通用的NamingService的设计思想。
|
||||
|
||||
NamingService负责保存集群内所有节点的路由信息,NamingService本身也是一个小集群,由多个NamingService节点组成。这里我们所说的“路由信息”也是一种通用的抽象,含义是:“客户端需要访问的某个特定服务在哪个节点上”。
|
||||
|
||||
集群中的节点主动连接NamingService服务,注册自身的路由信息。给客户端提供路由寻址服务的方式可以有两种,一种是客户端直接连接NamingService服务查询路由信息,另一种是,客户端连接集群内任意节点查询路由信息,节点再从自身的缓存或者从NamingService上进行查询。
|
||||
|
||||
掌握了以上这些NamingService的设计方法,将会非常有助于你理解其他分布式系统的架构,当然,你也可以把这些方法应用到分布式系统的设计中去。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是这样的,在RocketMQ的NameServer集群中,各节点之间不需要互相通信,每个节点都可以独立的提供服务。课后请你想一想,这种独特的集群架构有什么优势,又有什么不足?欢迎在评论区留言写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
177
极客时间专栏/消息队列高手课/进阶篇/24 | Kafka的协调服务ZooKeeper:实现分布式系统的“瑞士军刀”.md
Normal file
177
极客时间专栏/消息队列高手课/进阶篇/24 | Kafka的协调服务ZooKeeper:实现分布式系统的“瑞士军刀”.md
Normal file
@@ -0,0 +1,177 @@
|
||||
<audio id="audio" title="24 | Kafka的协调服务ZooKeeper:实现分布式系统的“瑞士军刀”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c7/48/c7bf054fc68cabc13c86340ea2a08f48.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
上节课我带你一起学习了RocketMQ NameServer的源代码,RocketMQ的NameServer虽然设计非常简洁,但很好地解决了路由寻址的问题。
|
||||
|
||||
而Kafka却采用了完全不同的设计思路,它选择使用ZooKeeper这样一个分布式协调服务来实现和RocketMQ的NameServer差不多的功能。
|
||||
|
||||
这节课我先带大家简单了解一下ZooKeeper,然后再来一起学习一下Kafka是如何借助ZooKeeper来构建集群,实现路由寻址的。
|
||||
|
||||
## ZooKeeper的作用是什么?
|
||||
|
||||
Apache ZooKeeper它是一个非常特殊的中间件,为什么这么说呢?一般来说,像中间件类的开源产品,大多遵循“做一件事,并做好它。”这样的UNIX哲学,每个软件都专注于一种功能上。而ZooKeeper更像是一个“瑞士军刀”,它提供了很多基本的操作,能实现什么样的功能更多取决于使用者如何来使用它。
|
||||
|
||||
ZooKeeper 作为一个分布式的协调服务框架,主要用来解决分布式集群中,应用系统需要面对的各种通用的一致性问题。ZooKeeper本身可以部署为一个集群,集群的各个节点之间可以通过选举来产生一个Leader,选举遵循半数以上的原则,所以一般集群需要部署奇数个节点。
|
||||
|
||||
ZooKeeper最核心的功能是,它提供了一个分布式的存储系统,数据的组织方式类似于UNIX文件系统的树形结构。由于这是一个可以保证一致性的存储系统,所以你可以放心地在你的应用集群中读写ZooKeeper的数据,而不用担心数据一致性的问题。分布式系统中一些需要整个集群所有节点都访问的元数据,比如集群节点信息、公共配置信息等,特别适合保存在ZooKeeper中。
|
||||
|
||||
在这个树形的存储结构中,每个节点被称为一个“ZNode”。ZooKeeper提供了一种特殊的ZNode类型:临时节点。这种临时节点有一个特性:如果创建临时节点的客户端与ZooKeeper集群失去连接,这个临时节点就会自动消失。在ZooKeeper内部,它维护了ZooKeeper集群与所有客户端的心跳,通过判断心跳的状态,来确定是否需要删除客户端创建的临时节点。
|
||||
|
||||
ZooKeeper还提供了一种订阅ZNode状态变化的通知机制:Watcher,一旦ZNode或者它的子节点状态发生了变化,订阅的客户端会立即收到通知。
|
||||
|
||||
利用ZooKeeper临时节点和Watcher机制,我们很容易随时来获取业务集群中每个节点的存活状态,并且可以监控业务集群的节点变化情况,当有节点上下线时,都可以收到来自ZooKeeper的通知。
|
||||
|
||||
此外,我们还可以用ZooKeeper来实现业务集群的快速选举、节点间的简单通信、分布式锁等很多功能。
|
||||
|
||||
下面我带你一起来看一下Kafka是如何来使用ZooKeeper的。
|
||||
|
||||
## Kafka在ZooKeeper中保存了哪些信息?
|
||||
|
||||
首先我们来看一下Kafka在ZooKeeper都保存了哪些信息,我把这些ZNode整理了一张图方便你来学习。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/b3/806ac0fc52ccbf50506e3b5d269b81b3.jpg" alt=""><br>
|
||||
你可能在网上看到过和这个图类似的其他版本的图,这些图中绘制的ZNode比我们这张图要多一些,这些图大都是描述的0.8.x的旧版本的情况,最新版本的Kafka已经将消费位置管理等一些原本依赖ZooKeeper实现的功能,替换成了其他的实现方式。
|
||||
|
||||
图中圆角的矩形是临时节点,直角矩形是持久化的节点。
|
||||
|
||||
我们从左往右来看,左侧这棵树保存的是Kafka的Broker信息,/brokers/ids/[0…N],每个临时节点对应着一个在线的Broker,Broker启动后会创建一个临时节点,代表Broker已经加入集群可以提供服务了,节点名称就是BrokerID,节点内保存了包括Broker的地址、版本号、启动时间等等一些Broker的基本信息。如果Broker宕机或者与ZooKeeper集群失联了,这个临时节点也会随之消失。
|
||||
|
||||
右侧部分的这棵树保存的就是主题和分区的信息。/brokers/topics/节点下面的每个子节点都是一个主题,节点的名称就是主题名称。每个主题节点下面都包含一个固定的partitions节点,pattitions节点的子节点就是主题下的所有分区,节点名称就是分区编号。
|
||||
|
||||
每个分区节点下面是一个名为state的临时节点,节点中保存着分区当前的leader和所有的ISR的BrokerID。这个state临时节点是由这个分区当前的Leader Broker创建的。如果这个分区的Leader Broker宕机了,对应的这个state临时节点也会消失,直到新的Leader被选举出来,再次创建state临时节点。
|
||||
|
||||
## Kafka客户端如何找到对应的Broker?
|
||||
|
||||
那Kafka客户端如何找到主题、队列对应的Broker呢?其实,通过上面ZooKeeper中的数据结构,你应该已经可以猜的八九不离十了。是的,先根据主题和队列,在右边的树中找到分区对应的state临时节点,我们刚刚说过,state节点中保存了这个分区Leader的BrokerID。拿到这个Leader的BrokerID后,再去左侧的树中,找到BrokerID对应的临时节点,就可以获取到Broker真正的访问地址了。
|
||||
|
||||
在《[21 | Kafka Consumer源码分析:消息消费的实现过程](https://time.geekbang.org/column/article/135120)》这一节课中,我讲过,Kafka的客户端并不会去直接连接ZooKeeper,它只会和Broker进行远程通信,那我们可以合理推测一下,ZooKeeper上的元数据应该是通过Broker中转给每个客户端的。
|
||||
|
||||
下面我们一起看一下Kafka的源代码,来验证一下我们的猜测是不是正确的。
|
||||
|
||||
在之前的课程中,我和大家讲过,客户端真正与服务端发生网络传输是在org.apache.kafka.clients.NetworkClient#poll方法中实现的,我们一直跟踪这个调用链:
|
||||
|
||||
```
|
||||
NetworkClient#poll() -> DefaultMetadataUpdater#maybeUpdate(long) -> DefaultMetadataUpdater#maybeUpdate(long, Node)
|
||||
|
||||
```
|
||||
|
||||
直到maybeUpdate(long, Node)这个方法,在这个方法里面,Kafka构造了一个更新元数据的请求:
|
||||
|
||||
```
|
||||
private long maybeUpdate(long now, Node node) {
|
||||
String nodeConnectionId = node.idString();
|
||||
|
||||
if (canSendRequest(nodeConnectionId, now)) {
|
||||
// 构建一个更新元数据的请求的构造器
|
||||
Metadata.MetadataRequestAndVersion metadataRequestAndVersion = metadata.newMetadataRequestAndVersion();
|
||||
inProgressRequestVersion = metadataRequestAndVersion.requestVersion;
|
||||
MetadataRequest.Builder metadataRequest = metadataRequestAndVersion.requestBuilder;
|
||||
log.debug("Sending metadata request {} to node {}", metadataRequest, node);
|
||||
// 发送更新元数据的请求
|
||||
sendInternalMetadataRequest(metadataRequest, nodeConnectionId, now);
|
||||
return defaultRequestTimeoutMs;
|
||||
}
|
||||
|
||||
//...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码先构造了更新元数据的请求的构造器,然后调用sendInternalMetadataRequest()把这个请求放到待发送的队列中。这里面有两个地方我需要特别说明一下。
|
||||
|
||||
第一点是,在这个方法里面创建的并不是一个真正的更新元数据的MetadataRequest,而是一个用于构造MetadataRequest的构造器MetadataRequest.Builder,等到真正要发送请求之前,Kafka才会调用Builder.buid()方法把这个MetadataRequest构建出来然后发送出去。而且,不仅是元数据的请求,所有的请求都是这样来处理的。
|
||||
|
||||
第二点是,调用sendInternalMetadataRequest()方法时,这个请求也并没有被真正发出去,依然是保存在待发送的队列中,然后择机来异步批量发送。
|
||||
|
||||
请求的具体内容封装在org.apache.kafka.common.requests.MetadataRequest这个对象中,它包含的信息很简单,只有一个主题的列表,来表明需要获取哪些主题的元数据,另外还有一个布尔类型的字段allowAutoTopicCreation,表示是否允许自动创建主题。
|
||||
|
||||
然后我们再来看下,在Broker中,Kafka是怎么来处理这个更新元数据的请求的。
|
||||
|
||||
Broker处理所有RPC请求的入口类在kafka.server.KafkaApis#handle这个方法里面,我们找到对应处理更新元数据的方法handleTopicMetadataRequest(RequestChannel.Request),这段代码是用Scala语言编写的:
|
||||
|
||||
```
|
||||
def handleTopicMetadataRequest(request: RequestChannel.Request) {
|
||||
val metadataRequest = request.body[MetadataRequest]
|
||||
val requestVersion = request.header.apiVersion
|
||||
|
||||
// 计算需要获取哪些主题的元数据
|
||||
val topics =
|
||||
// 在旧版本的协议中,每次都获取所有主题的元数据
|
||||
if (requestVersion == 0) {
|
||||
if (metadataRequest.topics() == null || metadataRequest.topics.isEmpty)
|
||||
metadataCache.getAllTopics()
|
||||
else
|
||||
metadataRequest.topics.asScala.toSet
|
||||
} else {
|
||||
if (metadataRequest.isAllTopics)
|
||||
metadataCache.getAllTopics()
|
||||
else
|
||||
metadataRequest.topics.asScala.toSet
|
||||
}
|
||||
|
||||
// 省略掉鉴权相关代码
|
||||
// ...
|
||||
|
||||
val topicMetadata =
|
||||
if (authorizedTopics.isEmpty)
|
||||
Seq.empty[MetadataResponse.TopicMetadata]
|
||||
else
|
||||
// 从元数据缓存过滤出相关主题的元数据
|
||||
getTopicMetadata(metadataRequest.allowAutoTopicCreation, authorizedTopics, request.context.listenerName,
|
||||
errorUnavailableEndpoints, errorUnavailableListeners)
|
||||
|
||||
// ...
|
||||
// 获取所有Broker列表
|
||||
val brokers = metadataCache.getAliveBrokers
|
||||
|
||||
trace("Sending topic metadata %s and brokers %s for correlation id %d to client %s".format(completeTopicMetadata.mkString(","),
|
||||
brokers.mkString(","), request.header.correlationId, request.header.clientId))
|
||||
|
||||
// 构建Response并发送
|
||||
sendResponseMaybeThrottle(request, requestThrottleMs =>
|
||||
new MetadataResponse(
|
||||
requestThrottleMs,
|
||||
brokers.flatMap(_.getNode(request.context.listenerName)).asJava,
|
||||
clusterId,
|
||||
metadataCache.getControllerId.getOrElse(MetadataResponse.NO_CONTROLLER_ID),
|
||||
completeTopicMetadata.asJava
|
||||
))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的主要逻辑是,先根据请求中的主题列表,去本地的元数据缓存MetadataCache中过滤出相应主题的元数据,也就是我们上面那张图中,右半部分的那棵树的子集,然后再去本地元数据缓存中获取所有Broker的集合,也就是上图中左半部分那棵树,最后把这两部分合在一起,作为响应返回给客户端。
|
||||
|
||||
Kafka在每个Broker中都维护了一份和ZooKeeper中一样的元数据缓存,并不是每次客户端请求元数据就去读一次ZooKeeper。由于ZooKeeper提供了Watcher这种监控机制,Kafka可以感知到ZooKeeper中的元数据变化,从而及时更新Broker中的元数据缓存。
|
||||
|
||||
这样就完成了一次完整的更新元数据的流程。通过分析代码,可以证实,我们开始的猜测都是没有问题的。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们对这节课的内容做一个总结。
|
||||
|
||||
首先,我们简单的介绍了ZooKeeper,它是一个分布式的协调服务,它的核心服务是一个高可用、高可靠的一致性存储,在此基础上,提供了包括读写元数据、节点监控、选举、节点间通信和分布式锁等很多功能,**这些功能可以极大方便我们快速开发一个分布式的集群系统。**
|
||||
|
||||
但是,ZooKeeper也并不是完美的,在使用的时候你需要注意几个问题:
|
||||
|
||||
1. 不要往ZooKeeper里面写入大量数据,它不是一个真正意义上的存储系统,只适合存放少量的数据。依据服务器配置的不同,ZooKeeper在写入超过几百MB数据之后,性能和稳定性都会严重下降。
|
||||
1. 不要让业务集群的可用性依赖于ZooKeeper的可用性,什么意思呢?你的系统可以使用Zookeeper,但你要留一手,要考虑如果Zookeeper集群宕机了,你的业务集群最好还能提供服务。因为ZooKeeper的选举过程是比较慢的,而它对网络的抖动又比较敏感,一旦触发选举,这段时间内的ZooKeeper是不能提供任何服务的。
|
||||
|
||||
Kafka主要使用ZooKeeper来保存它的元数据、监控Broker和分区的存活状态,并利用ZooKeeper来进行选举。
|
||||
|
||||
Kafka在ZooKeeper中保存的元数据,主要就是Broker的列表和主题分区信息两棵树。这份元数据同时也被缓存到每一个Broker中。客户端并不直接和ZooKeeper来通信,而是在需要的时候,通过RPC请求去Broker上拉取它关心的主题的元数据,然后保存到客户端的元数据缓存中,以便支撑客户端生产和消费。
|
||||
|
||||
可以看到,目前Kafka的这种设计,集群的可用性是严重依赖ZooKeeper的,也就是说,如果ZooKeeper集群不能提供服务,那整个Kafka集群也就不能提供服务了,这其实是一个不太好的设计。
|
||||
|
||||
如果你需要要部署大规模的Kafka集群,建议的方式是,拆分成多个互相独立的小集群部署,每个小集群都使用一组独立的ZooKeeper提供服务。这样,每个ZooKeeper中存储的数据相对比较少,并且如果某个ZooKeeper集群故障,只会影响到一个小的Kafka集群,故障的影响面相对小一些。
|
||||
|
||||
Kafka的开发者也意识到了这个问题,目前正在讨论开发一个元数据服务来替代ZooKeeper,感兴趣的同学可以看一下他们的[Proposal](https://cwiki.apache.org/confluence/display/KAFKA/KIP-500%3A+Replace+ZooKeeper+with+a+Self-Managed+Metadata+Quorum)。
|
||||
|
||||
## 思考题
|
||||
|
||||
本节课的思考题是这样的,请你顺着我们这节课源码分析的思路继续深挖进去,看一下Broker中的元数据缓存,又是如何与ZooKeeper中的元数据保持同步的呢?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
287
极客时间专栏/消息队列高手课/进阶篇/25 | RocketMQ与Kafka中如何实现事务?.md
Normal file
287
极客时间专栏/消息队列高手课/进阶篇/25 | RocketMQ与Kafka中如何实现事务?.md
Normal file
@@ -0,0 +1,287 @@
|
||||
<audio id="audio" title="25 | RocketMQ与Kafka中如何实现事务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/d1/c065d58825289cfdf89ee294cee0d3d1.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
在之前《[04 | 如何利用事务消息实现分布式事务?](https://time.geekbang.org/column/article/111269)》这节课中,我通过一个小例子来和大家讲解了如何来使用事务消息。在这节课的评论区,很多同学都提出来,非常想了解一下事务消息到底是怎么实现的。不仅要会使用,还要掌握实现原理,这种学习态度,一直是我们非常提倡的,这节课,我们就一起来学习一下,在RocketMQ和Kafka中,事务消息分别是如何来实现的?
|
||||
|
||||
## RocketMQ的事务是如何实现的?
|
||||
|
||||
首先我们来看RocketMQ的事务。我在之前的课程中,已经给大家讲解过RocketMQ事务的大致流程,这里我们再一起通过代码,重温一下这个流程。
|
||||
|
||||
```
|
||||
public class CreateOrderService {
|
||||
|
||||
@Inject
|
||||
private OrderDao orderDao; // 注入订单表的DAO
|
||||
@Inject
|
||||
private ExecutorService executorService; //注入一个ExecutorService
|
||||
|
||||
private TransactionMQProducer producer;
|
||||
|
||||
// 初始化transactionListener 和 producer
|
||||
@Init
|
||||
public void init() throws MQClientException {
|
||||
TransactionListener transactionListener = createTransactionListener();
|
||||
producer = new TransactionMQProducer("myGroup");
|
||||
producer.setExecutorService(executorService);
|
||||
producer.setTransactionListener(transactionListener);
|
||||
producer.start();
|
||||
}
|
||||
|
||||
// 创建订单服务的请求入口
|
||||
@PUT
|
||||
@RequestMapping(...)
|
||||
public boolean createOrder(@RequestBody CreateOrderRequest request) {
|
||||
// 根据创建订单请求创建一条消息
|
||||
Message msg = createMessage(request);
|
||||
// 发送事务消息
|
||||
SendResult sendResult = producer.sendMessageInTransaction(msg, request);
|
||||
// 返回:事务是否成功
|
||||
return sendResult.getSendStatus() == SendStatus.SEND_OK;
|
||||
}
|
||||
|
||||
private TransactionListener createTransactionListener() {
|
||||
return new TransactionListener() {
|
||||
@Override
|
||||
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
|
||||
CreateOrderRequest request = (CreateOrderRequest ) arg;
|
||||
try {
|
||||
// 执行本地事务创建订单
|
||||
orderDao.createOrderInDB(request);
|
||||
// 如果没抛异常说明执行成功,提交事务消息
|
||||
return LocalTransactionState.COMMIT_MESSAGE;
|
||||
} catch (Throwable t) {
|
||||
// 失败则直接回滚事务消息
|
||||
return LocalTransactionState.ROLLBACK_MESSAGE;
|
||||
}
|
||||
}
|
||||
// 反查本地事务
|
||||
@Override
|
||||
public LocalTransactionState checkLocalTransaction(MessageExt msg) {、
|
||||
// 从消息中获得订单ID
|
||||
String orderId = msg.getUserProperty("orderId");
|
||||
|
||||
// 去数据库中查询订单号是否存在,如果存在则提交事务;
|
||||
// 如果不存在,可能是本地事务失败了,也可能是本地事务还在执行,所以返回UNKNOW
|
||||
//(PS:这里RocketMQ有个拼写错误:UNKNOW)
|
||||
return orderDao.isOrderIdExistsInDB(orderId)?
|
||||
LocalTransactionState.COMMIT_MESSAGE: LocalTransactionState.UNKNOW;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//....
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个流程中,我们提供一个创建订单的服务,功能就是在数据库中插入一条订单记录,并发送一条创建订单的消息,要求写数据库和发消息这两个操作在一个事务内执行,要么都成功,要么都失败。在这段代码中,我们首先在init()方法中初始化了transactionListener和发生RocketMQ事务消息的变量producer。真正提供创建订单服务的方法是createOrder(),在这个方法里面,我们根据请求的参数创建一条消息,然后调用RocketMQ producer发送事务消息,并返回事务执行结果。
|
||||
|
||||
之后的createTransactionListener()方法是在init()方法中调用的,这里面直接构造一个匿名类,来实现RocketMQ的TransactionListener接口,这个接口需要实现两个方法:
|
||||
|
||||
- executeLocalTransaction:执行本地事务,在这里我们直接把订单数据插入到数据库中,并返回本地事务的执行结果。
|
||||
- checkLocalTransaction:反查本地事务,在这里我们的处理是,在数据库中查询订单号是否存在,如果存在则提交事务,如果不存在,可能是本地事务失败了,也可能是本地事务还在执行,所以返回UNKNOW。
|
||||
|
||||
这样,就使用RocketMQ的事务消息功能实现了一个创建订单的分布式事务。接下来我们一起通过RocketMQ的源代码来看一下,它的事务消息是如何实现的。
|
||||
|
||||
首先看一下在producer中,是如何来发送事务消息的:
|
||||
|
||||
```
|
||||
public TransactionSendResult sendMessageInTransaction(final Message msg,
|
||||
final LocalTransactionExecuter localTransactionExecuter, final Object arg)
|
||||
throws MQClientException {
|
||||
TransactionListener transactionListener = getCheckListener();
|
||||
if (null == localTransactionExecuter && null == transactionListener) {
|
||||
throw new MQClientException("tranExecutor is null", null);
|
||||
}
|
||||
Validators.checkMessage(msg, this.defaultMQProducer);
|
||||
|
||||
SendResult sendResult = null;
|
||||
|
||||
// 这里给消息添加了属性,标明这是一个事务消息,也就是半消息
|
||||
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_TRANSACTION_PREPARED, "true");
|
||||
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_PRODUCER_GROUP, this.defaultMQProducer.getProducerGroup());
|
||||
|
||||
// 调用发送普通消息的方法,发送这条半消息
|
||||
try {
|
||||
sendResult = this.send(msg);
|
||||
} catch (Exception e) {
|
||||
throw new MQClientException("send message Exception", e);
|
||||
}
|
||||
|
||||
LocalTransactionState localTransactionState = LocalTransactionState.UNKNOW;
|
||||
Throwable localException = null;
|
||||
switch (sendResult.getSendStatus()) {
|
||||
case SEND_OK: {
|
||||
try {
|
||||
if (sendResult.getTransactionId() != null) {
|
||||
msg.putUserProperty("__transactionId__", sendResult.getTransactionId());
|
||||
}
|
||||
String transactionId = msg.getProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX);
|
||||
if (null != transactionId && !"".equals(transactionId)) {
|
||||
msg.setTransactionId(transactionId);
|
||||
}
|
||||
|
||||
// 执行本地事务
|
||||
if (null != localTransactionExecuter) {
|
||||
localTransactionState = localTransactionExecuter.executeLocalTransactionBranch(msg, arg);
|
||||
} else if (transactionListener != null) {
|
||||
log.debug("Used new transaction API");
|
||||
localTransactionState = transactionListener.executeLocalTransaction(msg, arg);
|
||||
}
|
||||
if (null == localTransactionState) {
|
||||
localTransactionState = LocalTransactionState.UNKNOW;
|
||||
}
|
||||
|
||||
if (localTransactionState != LocalTransactionState.COMMIT_MESSAGE) {
|
||||
log.info("executeLocalTransactionBranch return {}", localTransactionState);
|
||||
log.info(msg.toString());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
log.info("executeLocalTransactionBranch exception", e);
|
||||
log.info(msg.toString());
|
||||
localException = e;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case FLUSH_DISK_TIMEOUT:
|
||||
case FLUSH_SLAVE_TIMEOUT:
|
||||
case SLAVE_NOT_AVAILABLE:
|
||||
localTransactionState = LocalTransactionState.ROLLBACK_MESSAGE;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// 根据事务消息和本地事务的执行结果localTransactionState,决定提交或回滚事务消息
|
||||
// 这里给Broker发送提交或回滚事务的RPC请求。
|
||||
try {
|
||||
this.endTransaction(sendResult, localTransactionState, localException);
|
||||
} catch (Exception e) {
|
||||
log.warn("local transaction execute " + localTransactionState + ", but end broker transaction failed", e);
|
||||
}
|
||||
|
||||
TransactionSendResult transactionSendResult = new TransactionSendResult();
|
||||
transactionSendResult.setSendStatus(sendResult.getSendStatus());
|
||||
transactionSendResult.setMessageQueue(sendResult.getMessageQueue());
|
||||
transactionSendResult.setMsgId(sendResult.getMsgId());
|
||||
transactionSendResult.setQueueOffset(sendResult.getQueueOffset());
|
||||
transactionSendResult.setTransactionId(sendResult.getTransactionId());
|
||||
transactionSendResult.setLocalTransactionState(localTransactionState);
|
||||
return transactionSendResult;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的实现逻辑是这样的:首先给待发送消息添加了一个属性PROPERTY_TRANSACTION_PREPARED,标明这是一个事务消息,也就是半消息,然后会像发送普通消息一样去把这条消息发送到Broker上。如果发送成功了,就开始调用我们之前提供的接口TransactionListener的实现类中,执行本地事务的方法executeLocalTransaction()来执行本地事务,在我们的例子中就是在数据库中插入一条订单记录。
|
||||
|
||||
最后,根据半消息发送的结果和本地事务执行的结果,来决定提交或者回滚事务。在实现方法endTransaction()中,producer就是给Broker发送了一个单向的RPC请求,告知Broker完成事务的提交或者回滚。由于有事务反查的机制来兜底,这个RPC请求即使失败或者丢失,也都不会影响事务最终的结果。最后构建事务消息的发送结果,并返回。
|
||||
|
||||
以上,就是RocketMQ在Producer这一端事务消息的实现,然后我们再看一下Broker这一端,它是怎么来处理事务消息和进行事务反查的。
|
||||
|
||||
Broker在处理Producer发送消息的请求时,会根据消息中的属性判断一下,这条消息是普通消息还是半消息:
|
||||
|
||||
```
|
||||
// ...
|
||||
if (traFlag != null && Boolean.parseBoolean(traFlag)) {
|
||||
// ...
|
||||
putMessageResult = this.brokerController.getTransactionalMessageService().prepareMessage(msgInner);
|
||||
} else {
|
||||
putMessageResult = this.brokerController.getMessageStore().putMessage(msgInner);
|
||||
}
|
||||
// ...
|
||||
|
||||
```
|
||||
|
||||
这段代码在org.apache.rocketmq.broker.processor.SendMessageProcessor#sendMessage方法中,然后我们跟进去看看真正处理半消息的业务逻辑,这段处理逻辑在类org.apache.rocketmq.broker.transaction.queue.TransactionalMessageBridge中:
|
||||
|
||||
```
|
||||
public PutMessageResult putHalfMessage(MessageExtBrokerInner messageInner) {
|
||||
return store.putMessage(parseHalfMessageInner(messageInner));
|
||||
}
|
||||
|
||||
private MessageExtBrokerInner parseHalfMessageInner(MessageExtBrokerInner msgInner) {
|
||||
|
||||
// 记录消息的主题和队列,到新的属性中
|
||||
MessageAccessor.putProperty(msgInner, MessageConst.PROPERTY_REAL_TOPIC, msgInner.getTopic());
|
||||
MessageAccessor.putProperty(msgInner, MessageConst.PROPERTY_REAL_QUEUE_ID,
|
||||
String.valueOf(msgInner.getQueueId()));
|
||||
msgInner.setSysFlag(
|
||||
MessageSysFlag.resetTransactionValue(msgInner.getSysFlag(), MessageSysFlag.TRANSACTION_NOT_TYPE));
|
||||
// 替换消息的主题和队列为:RMQ_SYS_TRANS_HALF_TOPIC,0
|
||||
msgInner.setTopic(TransactionalMessageUtil.buildHalfTopic());
|
||||
msgInner.setQueueId(0);
|
||||
msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgInner.getProperties()));
|
||||
return msgInner;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,在这段代码中,RocketMQ并没有把半消息保存到消息中客户端指定的那个队列中,而是记录了原始的主题队列后,把这个半消息保存在了一个特殊的内部主题RMQ_SYS_TRANS_HALF_TOPIC中,使用的队列号固定为0。这个主题和队列对消费者是不可见的,所以里面的消息永远不会被消费。这样,就保证了在事务提交成功之前,这个半消息对消费者来说是消费不到的。
|
||||
|
||||
然后我们再看一下,RocketMQ是如何进行事务反查的:在Broker的TransactionalMessageCheckService服务中启动了一个定时器,定时从半消息队列中读出所有待反查的半消息,针对每个需要反查的半消息,Broker会给对应的Producer发一个要求执行事务状态反查的RPC请求,这部分的逻辑在方法org.apache.rocketmq.broker.transaction.AbstractTransactionalMessageCheckListener#sendCheckMessage中,根据RPC返回响应中的反查结果,来决定这个半消息是需要提交还是回滚,或者后续继续来反查。
|
||||
|
||||
最后,提交或者回滚事务实现的逻辑是差不多的,首先把半消息标记为已处理,如果是提交事务,那就把半消息从半消息队列中复制到这个消息真正的主题和队列中去,如果要回滚事务,这一步什么都不需要做,最后结束这个事务。这部分逻辑的实现在org.apache.rocketmq.broker.processor.EndTransactionProcessor这个类中。
|
||||
|
||||
## Kafka的事务和Exactly Once可以解决什么问题?
|
||||
|
||||
接下来我们再说一下Kafka的事务。之前我们讲事务的时候说过,Kafka的事务解决的问题和RocketMQ是不太一样的。RocketMQ中的事务,它解决的问题是,确保执行本地事务和发消息这两个操作,要么都成功,要么都失败。并且,RocketMQ增加了一个事务反查的机制,来尽量提高事务执行的成功率和数据一致性。
|
||||
|
||||
而Kafka中的事务,它解决的问题是,确保在一个事务中发送的多条消息,要么都成功,要么都失败。注意,这里面的多条消息不一定要在同一个主题和分区中,可以是发往多个主题和分区的消息。当然,你可以在Kafka的事务执行过程中,加入本地事务,来实现和RocketMQ中事务类似的效果,但是Kafka是没有事务反查机制的。
|
||||
|
||||
Kafka的这种事务机制,单独来使用的场景不多。更多的情况下被用来配合Kafka的幂等机制来实现Kafka 的Exactly Once语义。我在之前的课程中也强调过,这里面的Exactly Once,和我们通常理解的消息队列的服务水平中的Exactly Once是不一样的。
|
||||
|
||||
我们通常理解消息队列的服务水平中的Exactly Once,它指的是,消息从生产者发送到Broker,然后消费者再从Broker拉取消息,然后进行消费。这个过程中,确保每一条消息恰好传输一次,不重不丢。我们之前说过,包括Kafka在内的几个常见的开源消息队列,都只能做到At Least Once,也就是至少一次,保证消息不丢,但有可能会重复。做不到Exactly Once。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/40/ac330e3e22b0114f5642491889510940.png" alt="">
|
||||
|
||||
那Kafka中的Exactly Once又是解决的什么问题呢?它解决的是,在流计算中,用Kafka作为数据源,并且将计算结果保存到Kafka这种场景下,数据从Kafka的某个主题中消费,在计算集群中计算,再把计算结果保存在Kafka的其他主题中。这样的过程中,保证每条消息都被恰好计算一次,确保计算结果正确。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/ff/15f8776de71b79cc232d8b60c3c24bff.png" alt="">
|
||||
|
||||
举个例子,比如,我们把所有订单消息保存在一个Kafka的主题Order中,在Flink集群中运行一个计算任务,统计每分钟的订单收入,然后把结果保存在另一个Kafka的主题Income里面。要保证计算结果准确,就要确保,无论是Kafka集群还是Flink集群中任何节点发生故障,每条消息都只能被计算一次,不能重复计算,否则计算结果就错了。这里面有一个很重要的限制条件,就是数据必须来自Kafka并且计算结果都必须保存到Kafka中,才可以享受到Kafka的Excactly Once机制。
|
||||
|
||||
可以看到,Kafka的Exactly Once机制,是为了解决在“读数据-计算-保存结果”这样的计算过程中数据不重不丢,而不是我们通常理解的使用消息队列进行消息生产消费过程中的Exactly Once。
|
||||
|
||||
## Kafka的事务是如何实现的?
|
||||
|
||||
那Kafka的事务又是怎么实现的呢?它的实现原理和RocketMQ的事务是差不多的,都是基于两阶段提交来实现的,但是实现的过程更加复杂。
|
||||
|
||||
首先说一下,参与Kafka事务的几个角色,或者说是模块。为了解决分布式事务问题,Kafka引入了事务协调者这个角色,负责在服务端协调整个事务。这个协调者并不是一个独立的进程,而是Broker进程的一部分,协调者和分区一样通过选举来保证自身的可用性。
|
||||
|
||||
和RocketMQ类似,Kafka集群中也有一个特殊的用于记录事务日志的主题,这个事务日志主题的实现和普通的主题是一样的,里面记录的数据就是类似于“开启事务”“提交事务”这样的事务日志。日志主题同样也包含了很多的分区。在Kafka集群中,可以存在多个协调者,每个协调者负责管理和使用事务日志中的几个分区。这样设计,其实就是为了能并行执行多个事务,提升性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/f4/c8c286a5e32ee324c8a32ba967d1f2f4.png" alt=""><br>
|
||||
(图片来源:[Kafka官方](https://www.confluent.io/blog/transactions-apache-kafka/))
|
||||
|
||||
下面说一下Kafka事务的实现流程。
|
||||
|
||||
首先,当我们开启事务的时候,生产者会给协调者发一个请求来开启事务,协调者在事务日志中记录下事务ID。
|
||||
|
||||
然后,生产者在发送消息之前,还要给协调者发送请求,告知发送的消息属于哪个主题和分区,这个信息也会被协调者记录在事务日志中。接下来,生产者就可以像发送普通消息一样来发送事务消息,这里和RocketMQ不同的是,RocketMQ选择把未提交的事务消息保存在特殊的队列中,而Kafka在处理未提交的事务消息时,和普通消息是一样的,直接发给Broker,保存在这些消息对应的分区中,Kafka会在客户端的消费者中,暂时过滤未提交的事务消息。
|
||||
|
||||
消息发送完成后,生产者给协调者发送提交或回滚事务的请求,由协调者来开始两阶段提交,完成事务。第一阶段,协调者把事务的状态设置为“预提交”,并写入事务日志。到这里,实际上事务已经成功了,无论接下来发生什么情况,事务最终都会被提交。
|
||||
|
||||
之后便开始第二阶段,协调者在事务相关的所有分区中,都会写一条“事务结束”的特殊消息,当Kafka的消费者,也就是客户端,读到这个事务结束的特殊消息之后,它就可以把之前暂时过滤的那些未提交的事务消息,放行给业务代码进行消费了。最后,协调者记录最后一条事务日志,标识这个事务已经结束了。
|
||||
|
||||
我把整个事务的实现流程,绘制成一个简单的时序图放在这里,便于你理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/fe/27742f00f70ead8c7194ef1aab503efe.png" alt="">
|
||||
|
||||
总结一下Kafka这个两阶段的流程,准备阶段,生产者发消息给协调者开启事务,然后消息发送到每个分区上。提交阶段,生产者发消息给协调者提交事务,协调者给每个分区发一条“事务结束”的消息,完成分布式事务提交。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我分别讲解了Kafka和RocketMQ是如何来实现事务的。你可以看到,它们在实现事务过程中的一些共同的地方,它们都是基于两阶段提交来实现的事务,都利用了特殊的主题中的队列和分区来记录事务日志。
|
||||
|
||||
不同之处在于对处于事务中的消息的处理方式,RocketMQ是把这些消息暂存在一个特殊的队列中,待事务提交后再移动到业务队列中;而Kafka直接把消息放到对应的业务分区中,配合客户端过滤来暂时屏蔽进行中的事务消息。
|
||||
|
||||
同时你需要了解,RocketMQ和Kafka的事务,它们的适用场景是不一样的,RocketMQ的事务适用于解决本地事务和发消息的数据一致性问题,而Kafka的事务则是用于实现它的Exactly Once机制,应用于实时计算的场景中。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后,请你根据我们课程中讲到的Kafka事务的实现流程,去Kafka的源代码中把这个事务的实现流程分析出来,将我们上面这个时序图进一步细化,绘制一个粒度到类和方法调用的时序图。然后请你想一下,如果事务进行过程中,协调者宕机了,那事务又是如何恢复的呢?欢迎你在评论区留言,写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
90
极客时间专栏/消息队列高手课/进阶篇/26 | MQTT协议:如何支持海量的在线IoT设备?.md
Normal file
90
极客时间专栏/消息队列高手课/进阶篇/26 | MQTT协议:如何支持海量的在线IoT设备?.md
Normal file
@@ -0,0 +1,90 @@
|
||||
<audio id="audio" title="26 | MQTT协议:如何支持海量的在线IoT设备?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/9f/a9f926666d6d009231d61f173aad209f.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
IoT,也就是物联网,一直是最近几年技术圈非常火的一个概念,并且,随着5G大规模商用,IoT还将持续火下去。
|
||||
|
||||
那到底什么是物联网呢?物联网这个词儿,它的含义还不那么直观,但你看它的英文:IoT,也就是Internet of Things的缩写,Things这个单词,我们知道,它在英语里面几乎可以指代一切。翻译成中文,我个人觉得,“东西”这个词儿比较贴切。那物联网,就可以理解为把所有东西都用互联网给连接起来。
|
||||
|
||||
这里面不仅仅包括像电脑、手机这样的智能设备,还包括一些已经智能化的传统设备,比如汽车、冰箱、路边的摄像头等等,将来还将包括更多的、各种各样的物品:比如水杯、衣服、工业用的各种设备和工具等等,也就是所谓的万物互联。所以,IoT它的未来绝对是大有可期的。
|
||||
|
||||
那这些物联网设备,它要实现互相通信,也必须有一套标准的通信协议,MQTT就是专门为物联网设备设计的一套标准的消息队列通信协议。使用MQTT协议的IoT设备,可以连接到任何支持MQTT协议的消息队列上,进行通信。
|
||||
|
||||
这节课,我们就一起来聊一聊MQTT协议,以及如何把MQTT应用到生产系统中去。
|
||||
|
||||
## MQTT和其他消息队列的传输协议有什么不同?
|
||||
|
||||
从宏观上来说,MQTT和其他消息队列采用的传输协议是差不多的。它采用的也是“发布-订阅”的消息模型。网络结构上,也是C/S架构,IoT设备是客户端,Broker是服务端,客户端与Broker通信进行收发消息。
|
||||
|
||||
虽然MQTT和普通的消息队列相比,在消息模型、功能和网络结构上都是差不多的,但由于他们面对的使用场景是不一样的,所以,MQTT和普通的消息队列相比,还是有很多区别的。我们看一下MQTT的使用场景有什么样的特点?
|
||||
|
||||
首先,它的客户端都是运行在IoT设备上。IoT设备它有什么特点?最大的特点就是便宜,一个水杯才卖几十块钱,它上面的智能模块的成本十块钱最多了,再贵就卖不出去了。十块钱的智能设备内存都是按照KB来计算的,可能都没有CPU,也不一定有操作系统,整个设备就一个SoC。这样的设备就需要通信协议不能太复杂,功能不能太多。另外,IoT设备一般都采用无线连接,很多设备都是经常移动的,这就导致,IoT设备的网络连接不稳定,并且这种不稳定的网络是一个常态。
|
||||
|
||||
MQTT协议在设计上,充分考虑了上面的这些特点。在协议的报文设计上极其的精简,可以说是惜字如金。协议的功能也非常简单,基本上就只有发布订阅主题和收发消息这两个最核心的功能。另外,为了应对网络连接不稳定的问题,MQTT增加了心跳和会话的机制。加入心跳机制可以让客户端和服务端双方都能随时掌握当前连接状态,一旦发现连接中断,可以尽快地重连。MQTT还加入了会话的机制,在服务端来保存会话状态,客户端重连之后就可以恢复之前的会话,继续来收发消息。这样,把复杂度转移到了服务端,客户端的实现就会更简单。
|
||||
|
||||
MQTT面临的使用场景中,另外一个很重要的特点就是,服务端需要支撑海量的IoT设备同时在线。对于普通的消息队列集群,服务的客户端都运行在性能强大的服务器上,所以客户端的数量不会特别多。比如京东的JMQ集群,日常在线的客户端数量大概是十万左右这样的规模,就足够支撑全国人民在京东上买买买。这个规模已经是这个地球上少有的,几个超大规模的消息队列集群之一了。
|
||||
|
||||
而MQTT的使用场景中,需要支撑的客户端数量,远不止几万几十万。比如,北京交通委如果要把全市的车辆都接入进来,这是就一个几百万客户端的规模。路边随处可见的摄像头,每家每户都有的电视、冰箱,每个人随身携带的各种穿戴设备,这些设备的规模都是百万、千万级甚至是上亿的级别。
|
||||
|
||||
另外,MQTT它是不支持点对点通信的,一般的做法都是,每个客户端都创建一个以自己ID为名字的主题,然后客户端来订阅自己的专属主题,用于接收专门发给这个客户端的消息。这就意味着,在MQTT的集群中,主题的数量是和客户端的数量基本是同一个量级的。
|
||||
|
||||
## 如何选择MQTT产品?
|
||||
|
||||
如何能支持海量在线的IoT设备和海量的主题,是每一个支持MQTT协议的消息队列面临的最大挑战。也是你在做MQTT服务端技术选型时,需要重点考察的技术点。
|
||||
|
||||
目前开源的MQTT产品中,有些是传统的消息队列,通过官方或者非官方的扩展,实现了MQTT协议的支持。也有一些专门的MQTT Server产品,这些MQTT Server在协议支持层面,大多数是没有问题的,性能和稳定性方面也都可以满足要求。但是,我还没有发现能很好支撑海量客户端和主题的开源产品。为什么呢?
|
||||
|
||||
传统的消息队列,虽然它可以通过扩展来支持MQTT协议,但是它的整体架构在设计之初,并没有考虑能支撑海量客户端和主题。比如,之前我们讲过,RocketMQ它的元数据是保存在NameServer的内存中,Kafka是保存在ZooKeeper中,这些存储都不擅长保存大量数据,所以也支撑不了太多的客户端和主题。
|
||||
|
||||
另外一些开源的MQTT Server,很多根本就没有集群功能,或者集群功能做的不太完善。集群功能做的好的产品,它们的开发者大多都把集群功能放到企业版中拿去卖钱了。
|
||||
|
||||
所以在做MQTT Server技术选型的时,如果你接入IoT设备数量在十万以内,是可以选择开源产品的,选型的原则和选择普通消息队列是一样的,我在《[02 | 该如何选择消息队列?](https://time.geekbang.org/column/article/109750)》这节课中讲过的选型原则都是适用的,优先选择一个流行的、你熟悉的开源产品就可以了。
|
||||
|
||||
如果说客户端的规模超过十万的量级,需要支撑这么大规模的客户端数量,服务端只有单个节点肯定是不够的,必须用一个集群来支持,并且这个集群是要能支持水平扩容的,这些都是最基本的要求。这个时候就几乎没什么可供选择的开源产品了。这种情况建议选择一些云平台厂商提供的MQTT云服务,价格相对比较低,当然你可以选择价格更高的商业版MQTT Server。
|
||||
|
||||
另外一个选择就是,基于已有的开源MQTT Server,通过一些集成和开发,来自行构建MQTT集群。接下来,我跟你说一下,构建一个支持海量客户端的MQTT集群,应该如何来设计。
|
||||
|
||||
## MQTT集群如何支持海量在线的IoT设备?
|
||||
|
||||
一般来说,一个MQTT集群它的架构应该是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/f1/deae9d8d95484a2d2499a47beeaebbf1.jpg" alt="">
|
||||
|
||||
这个图从左向右看,首先接入的地址最好是一个域名,这样域名的后面可以配置多个IP地址做负载均衡,当然这个域名不是必需的。也可以直接连接负载均衡器。负载均衡可以选择像F5这种专用的负载均衡硬件,也可以使用Nginx这样的软件,只要是四层或者支持MQTT协议的七层负载均衡设备,都可以选择。
|
||||
|
||||
负载均衡器的后面,需要部署一个Proxy集群,这个Proxy集群承担了三个重要的作用:第一个作用是来承接海量IoT设备的连接,第二个作用是来维护与客户端的会话,第三个作用是作为代理,在客户端和Broker之间进行消息转发。
|
||||
|
||||
在Proxy集群的后面是Broker集群,负责保存和收发消息。
|
||||
|
||||
有的MQTT Server它的集群架构是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/ef/6a8fdaed5ba598a734691aa725e03bef.jpg" alt="">
|
||||
|
||||
它的架构中没有Proxy。实际上,它只是把Proxy和Broker的功能集成到了一个进程中,这两种架构它本质上没有太大的区别。所以这两种架构我们可以认为是同一种架构,一起来分析。
|
||||
|
||||
前置Proxy的方式很容易解决海量连接的问题,由于Proxy是可以水平扩展的,只要用足够多数量的Proxy节点,就可以抗住海量客户端同时连接。每个Proxy和每个Broker只用一个连接通信就可以了,这样对于每个Broker来说,它的连接数量最多不会超过Proxy节点的数量。
|
||||
|
||||
Proxy对于会话的处理方式,可以借鉴Tomcat处理会话的方式。一种方式是,将会话保存在Proxy本地,每个Proxy节点都只维护连接到自己的这些客户端的会话。但是,这种方式需要配合负载均衡来使用,负载均衡设备需要支持sticky session,保证将相同会话的连接总是转发到同一个Proxy节点上。另一种方式是,将会话保存在一个外置的存储集群中,比如一个Redis集群或者MySQL集群。这样Proxy就可以设计成完全无状态的,对于负载均衡设备也没有特殊的要求。但这种方式要求外置存储集群具备存储千万级数据的能力,同时具有很好的性能。
|
||||
|
||||
对于如何支持海量的主题,比较可行的解决方案是,在Proxy集群的后端,部署多组Broker小集群,比如说,可以是多组Kafka小集群,每个小集群只负责存储一部分主题。这样对于每个Broker小集群,主题的数量就可以控制在可接受的范围内。由于消息是通过Proxy来进行转发的,我们可以在Proxy中采用一些像一致性哈希等分片算法,根据主题名称找到对应的Broker小集群。这样就解决了支持海量主题的问题。
|
||||
|
||||
## 总结
|
||||
|
||||
MQTT是专门为物联网设备设计的一套标准的通信协议。这套协议在消息模型和功能上与普通的消息队列协议是差不多的,最大的区别在于应用场景不同。在物联网应用场景中,IoT设备性能差,网络连接不稳定。服务端面临的挑战主要是,需要支撑海量的客户端和主题。
|
||||
|
||||
已有的开源的MQTT产品,对于协议的支持都不错,在客户端数量小于十万级别的情况下,可以选择。对于海量客户端的场景,服务端必须使用集群来支撑,可以选择收费的云服务和企业版产品。也可以选择自行来构建MQTT集群。
|
||||
|
||||
自行构建集群,最关键的技术点就是,通过前置的Proxy集群来解决海量连接、会话管理和海量主题这三个问题。前置Proxy负责在Broker和客户端之间转发消息,通过这种方式,将海量客户端连接收敛为少量的Proxy与Broker之间的连接,解决了海量客户端连接数的问题。维护会话的实现原理,和Tomcat维护HTTP会话是一样的。对于海量主题,可以在后端部署多组Broker小集群,每个小集群分担一部分主题这样的方式来解决。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你针对我们这节课讲到的Proxy,做一个详细设计,不用写文档,只要画出如下三个UML图即可:
|
||||
|
||||
Proxy的类图(UML Class Diagram):粒度要包含到下面两个时序图用到的主要的类和方法;<br>
|
||||
Proxy收发消息的时序图(UML Sequence Diagram):分别绘制出Proxy生产消息和消费消息这两个流程的时序图。
|
||||
|
||||
欢迎你将绘制好的设计图上传到GitHub上,然后在评论区给出链接。在设计过程中,如果你有任何问题,也欢迎在评论区与我交流。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
91
极客时间专栏/消息队列高手课/进阶篇/27 | Pulsar的存储计算分离设计:全新的消息队列设计思路.md
Normal file
91
极客时间专栏/消息队列高手课/进阶篇/27 | Pulsar的存储计算分离设计:全新的消息队列设计思路.md
Normal file
@@ -0,0 +1,91 @@
|
||||
<audio id="audio" title="27 | Pulsar的存储计算分离设计:全新的消息队列设计思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/8d/766246b97bff902fb602bcdecc95c68d.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
之前的课程,我们大部分时间都在以RocketMQ、Kafka和RabbitMQ为例,通过分析源码的方式,来讲解消息队列的实现原理。原因是,这三种消息队列在国内的受众群体非常庞大,大家在工作中会经常用到。这节课,我给你介绍一个不太一样的开源消息队列产品:Apache Pulsar。
|
||||
|
||||
Pulsar也是一个开源的分布式消息队列产品,最早是由Yahoo开发,现在是Apache基金会旗下的开源项目。你可能会觉得好奇,我们的课程中为什么要花一节课来讲Pulsar这个产品呢?原因是,Pulsar在架构设计上,和其他的消息队列产品有非常显著的区别。我个人的观点是,Pulsar的这种全新的架构设计,很可能是消息队列这类中间件产品未来架构的发展方向。
|
||||
|
||||
接下来我们一起看一下,Pulsar到底有什么不同?
|
||||
|
||||
## Pulsar的架构和其他消息队列有什么不同?
|
||||
|
||||
我们知道,无论是RocketMQ、RabbitMQ还是Kafka,消息都是存储在Broker的磁盘或者内存中。客户端在访问某个主题分区之前,必须先找到这个分区所在Broker,然后连接到这个Broker上进行生产和消费。
|
||||
|
||||
在集群模式下,为了避免单点故障导致丢消息,Broker在保存消息的时候,必须也把消息复制到其他的Broker上。当某个Broker节点故障的时候,并不是集群中任意一个节点都能替代这个故障的节点,只有那些“和这个故障节点拥有相同数据的节点”才能替代这个故障的节点。原因就是,每一个Broker存储的消息数据是不一样的,或者说,每个节点上都存储了状态(数据)。这种节点称为“有状态的节点(Stateful Node)”。
|
||||
|
||||
Pulsar与其他消息队列在架构上,最大的不同在于,它的Broker是无状态的(Stateless)。也就是说,**在Pulsar的Broker中既不保存元数据,也不存储消息**。那Pulsar的消息存储在哪儿呢?我们来看一下Pulsar的架构是什么样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/39/c6d87dbd3ef911f3581b8e51681d3339.png" alt="">
|
||||
|
||||
这张Pulsar的架构图来自Pulsar的官方文档,如果你想了解这张架构图的细节,可以去看官方文档中的[Architecture Overview](https://pulsar.apache.org/docs/en/concepts-architecture-overview/)。我来给你解读一下这张图中我们感兴趣的重点内容。
|
||||
|
||||
先来看图中右侧的Bookie和ZK这两个方框,这两个方框分别代表了BookKeeper集群和ZooKeeper集群。ZooKeeper集群的作用,我在《[24 | Kafka的协调服务ZooKeeper:实现分布式系统的“瑞士军刀](https://time.geekbang.org/column/article/137655)》这节课中专门讲过,在Pulsar中,ZooKeeper集群的作用和在Kafka中是一样的,都是被用来存储元数据。BookKeeper集群则被用来存储消息数据。
|
||||
|
||||
那这个BookKeeper又是什么呢?BookKeeper有点儿类似HDFS,是一个分布式的存储集群,只不过它的存储单元和HDFS不一样,在HDFS中存储单元就是文件,这个很好理解。而BookKeeper的存储单元是Ledger。这个Ledger又是什么呢?
|
||||
|
||||
这里再次吐槽一下国外程序员喜欢发明概念、增加学习成本这个坏习惯。其实Ledger就是一段WAL(Write Ahead Log),或者你可以简单地理解为某个主题队列的一段,它包含了连续的若干条消息,消息在Ledger中称为Entry。为了保证Ledger中的Entry的严格顺序,Pulsar为Ledger增加一次性的写入限制,Broker创建一个Ledger后,只有这个Broker可以往Ledger中写入Entry,一旦Ledger关闭后,无论是Broker主动关闭,还是因为Broker宕机异常关闭,这个Ledger就永远只能读取不能写入了。如果需要继续写入Entry,只能新建另外一个Ledger。
|
||||
|
||||
请你注意一下,这种“一次性写入”的设计,它的主要目的是为了解决并发写入控制的问题,我在之前课程中讲过,对于共享资源数据的并发写一般都是需要加锁的,否则很难保证数据的一致性。对于分布式存储来说,就需要加“分布式锁”。
|
||||
|
||||
但我们知道,分布式锁本身就很难实现,使用分布式锁对性能也会有比较大的损失。这种“一次性写入”的设计,只有创建Ledger的进程可以写入数据,Ledger这个资源不共享,也就不需要加锁,是一种很巧妙的设计,你在遇到类似场景的时候可以借鉴。
|
||||
|
||||
消息数据由BookKeeper集群负责存储,元数据由ZooKeeper集群负责存储,Pulsar的Broker上就不需要存储任何数据了,这样Broker就成为了无状态的节点。
|
||||
|
||||
虽然Broker是无状态的,不存储任何的数据,但是,在一个特定的时刻,每一个主题的分区,还是要落在某个具体的Broker上。不能说多个Broker同时读写同一个分区,因为这样是没有办法保证消息的顺序的,也没有办法来管理消费位置。
|
||||
|
||||
再来看图中左侧最大的那个Broker方框,在Broker中包含了几个重要的模块。Load Balancer负责动态的分配,哪些Broker管理哪些主题分区。Managed Ledger这个模块负责管理本节点需要用到的那些Ledger,当然这些Ledger都是保存在BookKeeper集群中的。为了提升性能,Pulsar同样采用用了一个Cache模块,来缓存一部分Ledger。
|
||||
|
||||
Pulsar的客户端要读写某个主题分区上的数据之前,依然要在元数据中找到分区当前所在的那个Broker,这一点是和其他消息队列的实现是一样的。不一样的地方是,其他的消息队列,分区与Broker的对应关系是相对稳定的,只要不发生故障,这个关系是不会变的。而在Pulsar中,这个对应关系是动态的,它可以根据Broker的负载情况进行动态调整,而且由于Broker是无状态的,分区可以调整到集群中任意一个Broker上,这个负载均衡策略就可以做得非常简单并且灵活。如果某一个Broker发生故障,可以立即用任何一个Broker来替代它。
|
||||
|
||||
那在这种架构下,Pulsar又是如何来完成消息收发的呢?客户端在收发消息之前,需要先连接Service Discovery模块,获取当前主题分区与Broker的对应关系,然后再连接到相应Broker上进行消息收发。客户端收发消息的整体流程,和其他的消息队列是差不多的。比较显著的一个区别就是,消息是保存在BookKeeper集群中的,而不是本机上。数据的可靠性保证也是BookKeeper集群提供的,所以Broker就不需要再往其他的Broker上复制消息了。
|
||||
|
||||
图中的Global replicators模块虽然也会复制消息,但是复制的目的是为了在不同的集群之间共享数据,而不是为了保证数据的可靠性。集群间数据复制是Pulsar提供的一个特色功能,具体可以看一下Pulsar文档中的[geo-replication](https://pulsar.apache.org/docs/en/administration-geo/)这部分。
|
||||
|
||||
## 存储计算分离的设计有哪些优点?
|
||||
|
||||
在Pulsar这种架构下,消息数据保存在BookKeeper中,元数据保存在ZooKeeper中,Broker的数据存储的职责被完全被剥离出去,只保留了处理收发消息等计算的职责,这就是一个非常典型的“存储计算分离”的设计。
|
||||
|
||||
什么是存储计算分离呢?顾名思义,就是将系统的存储职责和计算职责分离开,存储节点只负责数据存储,而计算节点只负责计算,也就是执行业务逻辑。这样一种设计,称为存储计算分离。存储计算分离设计并不新鲜,它的应用其实是非常广泛的。
|
||||
|
||||
比如说,所有的大数据系统,包括Map Reduce这种传统的批量计算,和现在比较流行的Spark、Flink这种流计算,它们都采用的存储计算分离设计。数据保存在HDFS中,也就是说HDFS负责存储,而负责计算的节点,无论是用YARN调度还是Kubernetes调度,都只负责“读取-计算-写入”这样一种通用的计算逻辑,不保存任何数据。
|
||||
|
||||
更普遍的,**我们每天都在开发的各种Web应用和微服务应用,绝大多数也采用的是存储计算分离的设计**。数据保存在数据库中,微服务节点只负责响应请求,执行业务逻辑。也就是说,数据库负责存储,微服务节点负责计算。
|
||||
|
||||
那存储计算分离有什么优点呢?我们分两方面来看。
|
||||
|
||||
对于计算节点来说,它不需要存储数据,节点就变成了无状态的(Stateless)节点。一个由无状态节点组成的集群,管理、调度都变得非常简单了。集群中每个节点都是一样的,天然就支持水平扩展。任意一个请求都可以路由到集群中任意一个节点上,负载均衡策略可以做得非常灵活,可以随机分配,可以轮询,也可以根据节点负载动态分配等等。故障转移(Failover)也更加简单快速,如果某个节点故障了,直接把请求分配给其他节点就可以了。
|
||||
|
||||
对比一下,像ZooKeeper这样存储计算不分离的系统,它们的故障转移就非常麻烦,一般需要用复杂的选举算法,选出新的leader,提供服务之前,可能还需要进行数据同步,确保新的节点上的数据和故障节点是完全一致之后,才可以继续提供服务。这个过程是非常复杂而且漫长的。
|
||||
|
||||
对于计算节点的开发者来说,可以专注于计算业务逻辑开发,而不需要关注像数据一致性、数据可靠性、故障恢复和数据读写性能等等这些比较麻烦的存储问题,极大地降低了开发难度,提升了开发效率。
|
||||
|
||||
而对于存储系统来说,它需要实现的功能就很简单,系统的开发者只需要专注于解决一件事就可以了,那就是“如何安全高效地存储数据?”并且,存储系统的功能是非常稳定的,比如像ZooKeeper、HDFS、MySQL这些存储系统,从它们诞生到现在,功能几乎就没有变过。每次升级都是在优化存储引擎,提升性能、数据可靠性、可用性等等。
|
||||
|
||||
接下来说存储计算分离这种设计的缺点。
|
||||
|
||||
俗话说,背着抱着一样沉。对于一个系统来说,无论存储和计算是不是分离的,它需要完成的功能和解决的问题是一样的。就像我刚刚讲到的,Pulsar的Broker相比于其他消息队列的Broker,各方面都变的很简单。这并不是说,存储计算分离的设计能把系统面临的各种复杂的问题都解决了,其实一个问题都没解决,只是把这些问题转移到了BookKeeper这个存储集群上了而已。
|
||||
|
||||
**BookKeeper依然要解决数据一致性、节点故障转移、选举、数据复制等等这些问题。**并且,存储计算分离之后,原来一个集群变成了两个集群,整个系统其实变得更加复杂了。
|
||||
|
||||
另外,存储计算分离之后,系统的性能也会有一些损失。比如,从Pulsar的Broker上消费一条消息,Broker还需要去请求BookKeeper集群读取数据,然后返回给客户端,这个过程至少增加了一次网络传输和n次内存拷贝。相比于直接读本地磁盘,性能肯定是要差一些的。
|
||||
|
||||
不过,对于业务系统来说,采用存储计算分离的设计,它并不需要自己开发一个数据库或者HDFS,只要用现有的成熟的存储系统就可以了,所以相当于系统的复杂度还是降低了。相比于存储计算分离带来的各种优点,损失一些性能也是可以接受的。
|
||||
|
||||
因此,对于大部分业务系统来说,采用存储计算分离设计,都是非常划算的。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我们一起分析了Apache Pulsar的架构,然后一起学习了一下存储计算分离的这种设计思想。
|
||||
|
||||
Pulsar和其他消息队列最大的区别是,它采用了存储计算分离的设计。存储消息的职责从Broker中分离出来,交给专门的BookKeeper存储集群。这样Broker就变成了无状态的节点,在集群调度和故障恢复方面更加简单灵活。
|
||||
|
||||
存储计算分离是一种设计思想,它将系统的存储职责和计算职责分离开,存储节点只负责数据存储,而计算节点只负责计算,计算节点是无状态的。无状态的计算节点,具有易于开发、调度灵活的优点,故障转移和恢复也更加简单快速。这种设计的缺点是,系统总体的复杂度更高,性能也更差。不过对于大部分分布式的业务系统来说,由于它不需要自己开发存储系统,采用存储计算分离的设计,既可以充分利用这种设计的优点,整个系统也不会因此变得过于复杂,综合评估优缺点,利大于弊,更加划算。
|
||||
|
||||
## 思考题
|
||||
|
||||
课后请你想一下,既然存储计算分离这种设计有这么多的优点,那为什么除了Pulsar以外,大多数的消息队列都没有采用存储计算分离的设计呢?欢迎在评论区留言,写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
94
极客时间专栏/消息队列高手课/进阶篇/28 | 答疑解惑(二):我的100元哪儿去了?.md
Normal file
94
极客时间专栏/消息队列高手课/进阶篇/28 | 答疑解惑(二):我的100元哪儿去了?.md
Normal file
@@ -0,0 +1,94 @@
|
||||
<audio id="audio" title="28 | 答疑解惑(二):我的100元哪儿去了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/57/b0/578177fffa4d13bd98bb883f395cc3b0.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
今天这节课,是我们的“消息队列高手课第二阶段进阶篇的最后一节课,照例,我们在每一阶段的最后,安排一节课进行热点问题的答疑,针对同学们遇到的一些共同的问题,统一来进行详细的解答。
|
||||
|
||||
## 1. 我的100元哪儿去了?聊聊并发调用情况下的幂等性
|
||||
|
||||
在期中测试中,有这样一道题。
|
||||
|
||||
如果可以保证以下这些操作的原子性,哪些操作在并发调用的情况下具备幂等性?
|
||||
|
||||
- A. f(n, a):给账户n转入a元
|
||||
- B. f(n, a):将账户n的余额更新为a元
|
||||
- C. f(n, b, a):如果账户n当前的余额为b元,那就将账户的余额更新为n元
|
||||
- D. f(n, v, a):如果账户n当前的流水号等于v,那么给账户的余额加a元,并将流水号加一
|
||||
|
||||
这道题的正确答案是D。很多同学都留言提问,选项B中,将账户n的余额更新为a元,这个操作不具备幂等性吗?
|
||||
|
||||
如果单单只是考虑这个操作,执行一次和执行多次,对系统的影响是一样的,账户n的余额都是a元。所以,这个操作确实是幂等的。但请你注意审题,我们的题目中说的是:“哪些操作**在并发调用的情况下**具备幂等性?”在并发调用的情况下,我们再来看一下B这个选项的操作是否还具备幂等性。
|
||||
|
||||
假设,账户余额100元,依次执行2次转账:
|
||||
|
||||
1. 将账户余额设为200元;
|
||||
1. 将账户余额设为300元;
|
||||
|
||||
经过两次转账后,账户余额应该是300元。
|
||||
|
||||
再次注意,我们的题目中说的是在并发调用的情况下。
|
||||
|
||||
按照时间顺序,就有可能会出现下面这种情况:
|
||||
|
||||
1. t0时刻客户端发送请求:将账户余额设为200元。
|
||||
1. t1时刻服务端收到请求,账户余额由100元变为200元,然后服务端发出给客户端操作成功的响应,但是这个响应在网络传输过程中丢失了。
|
||||
1. t2时刻客户端发送请求:将账户余额设为300元。
|
||||
1. t3时刻服务端收到请求,账户余额由200元变为300元,然后服务端发出给客户端操作成功的响应。
|
||||
1. t4时刻客户端:收到“将账户余额设为300元”这个请求的成功响应,本次调用成功。
|
||||
1. t5时刻客户端由于没收到“将账户余额设为200元”这个请求的成功响应,重新发送请求:将账户余额设为200元。
|
||||
1. t6时刻服务端收到请求,账户余额由300元变为200元,然后服务端给客户端发出操作成功的响应。
|
||||
1. t7时刻客户端收到响应,本次重试调用成功。
|
||||
|
||||
结果,账户余额错误地变成了200元。
|
||||
|
||||
同学,请把我的100块钱还给我!通过这个题,我们可以总结出来,**一个操作是否幂等,还跟调用顺序有关系**,在线性调用情况下,具备幂等性的操作,在并发调用时,就不一定具备幂等性了。如果你在设计系统的时候,没有注意到这个细节,那系统就有可能出现我们上面这个例子中的错误,在生产系中,这是非常危险的。
|
||||
|
||||
## 2. Kafka和RocketMQ如何通过选举来产生新的Leader?
|
||||
|
||||
在《[22 | Kafka和RocketMQ的消息复制实现的差异点在哪?](https://time.geekbang.org/column/article/136030)》这节课中,我给你讲了这两个消息队列是如何通过复制来保证数据一致性的。当Broker节点发生故障时,它们都是通过选举机制,来选出新的Leader来继续提供服务。当时限于篇幅,我们并没有深入进去来讲选举的实现原理。那Kafka和RocketMQ(Dledger)都是怎么来实现的选举呢?
|
||||
|
||||
先来说Kafka的选举,因为Kafka的选举实现比较简单。严格地说,Kafka分区的Leader并不是选举出来的,而是Controller指定的。Kafka使用ZooKeeper来监控每个分区的多个副本,如果发现某个分区的主节点宕机了,Controller会收到ZooKeeper的通知,这个时候,Controller会从ISR节点中选择一个节点,指定为新的Leader。
|
||||
|
||||
在Kafka中Controller本身也是通过ZooKeeper选举产生的。接下来我要讲的,Kafka Controller利用ZooKeeper选举的方法,你一定要记住并学会,因为这种方法非常简单实用,并且适用性非常广泛,在设计很多分布式系统中都可以用到。
|
||||
|
||||
这种选举方法严格来说也不是真正的“选举”,而是一种抢占模式。实现也很简单,每个Broker在启动后,都会尝试在ZooKeeper中创建同一个临时节点:/controller,并把自身的信息写入到这个节点中。由于ZooKeeper它是一个**可以保证数据一致性的分布式存储**,所以,集群中只会有一个Broker抢到这个临时节点,那它就是Leader节点。其他没抢到Leader的节点,会Watch这个临时节点,如果当前的Leader节点宕机,所有其他节点都会收到通知,它们会开始新一轮的抢Leader游戏。
|
||||
|
||||
这就好比有个玉玺,也就是皇帝用的那个上面雕着龙纹的大印章,谁都可以抢这个玉玺,谁抢到谁做皇帝,其他没抢到的人也不甘心,时刻盯着这个玉玺,一旦现在这个皇帝驾崩了,所有人一哄而上,再“抢”出一个新皇帝。这个算法虽然不怎么优雅,但胜在简单直接,并且快速公平,是非常不错的选举方法。
|
||||
|
||||
但是这个算法它依赖一个“玉玺”,也就是一个**可以保证数据一致性的分布式存储**,这个分布式存储不一定非得是ZooKeeper,可以是Redis,可以是MySQL,也可以是HDFS,只要是可以保证数据一致性的分布式存储,都可以充当这个“玉玺”,所以这个选举方法的适用场景也是非常广泛的。
|
||||
|
||||
再来说RocketMQ/Dledger的选举,在Dledger中的Leader真的是通过投票选举出来的,所以它不需要借助于任何外部的系统,仅靠集群的节点间投票来达成一致,选举出Leader。一般这种自我选举的算法,为了保证数据一致性、避免集群分裂,算法设计的都非常非常复杂,我们不太可能自己来实现这样一个选举算法,所以我在这里不展开讲。Dledger采用的是[Raft一致性算法](https://raft.github.io),感兴趣的同学可以读一下这篇[经典的论文](https://raft.github.io/raft.pdf)。
|
||||
|
||||
像Raft这种自我选举的算法,相比于上面介绍的抢占式选举,优点是不需要借助外部系统,完全可以实现自我选举。缺点也非常明显,就是算法实在是太复杂了,非常难实现。并且,往往集群中的节点要通过多轮投票才能达成一致,这个选举过程是比较慢的,一次选举耗时几秒甚至几十秒都有可能。
|
||||
|
||||
我们日常在设计一些分布式的业务系统时,如果需要选举Leader,还是采用Kafka的这种“抢玉玺”的方法更加简单实用。
|
||||
|
||||
## 3. 为什么说Pulsar存储计算分离的架构是未来消息队列的发展方向?
|
||||
|
||||
在上节课《[27 | Pulsar的存储计算分离设计:全新的消息队列设计思路](https://time.geekbang.org/column/article/140913)》中,我给你留的思考题是:为什么除了Pulsar以外,大多数的消息队列都没有采用存储计算分离的设计呢?这个问题其实是一个发散性的问题,并没有什么标准答案。因为,本来架构设计就是在权衡各种利弊,做出取舍和选择,并没有绝对的对错之分。
|
||||
|
||||
很多同学在课后的留言中,都已经给出了自己的思路和想法,而且有些同学的想法和我个人的观点不谋而合。在这里我也和你分享一下我对这个问题的理解和看法。
|
||||
|
||||
早期的消息队列,主要被用来在系统之间异步交换数据,大部分消息队列的存储能力都比较弱,不支持消息持久化,不提倡在消息队列中堆积大量的消息,这个时期的消息队列,本质上是一个数据的管道。
|
||||
|
||||
现代的消息队列,功能上看似没有太多变化,依然是收发消息,但是用途更加广泛,数据被持久化到磁盘中,大多数消息队列具备了强大的消息堆积能力,只要磁盘空间足够,可以存储无限量的消息,而且不会影响生产和消费的性能。这些消息队列,本质上已经演变成为分布式的存储系统。
|
||||
|
||||
理解了这一点,你就会明白,为什么大部分消息队列产品,都不使用存储计算分离的设计。为一个“分布式存储系统”做存储计算分离,计算节点就没什么业务逻辑需要计算的了。而且,消息队列又不像其他的业务系统,可以直接使用一些成熟的分布式存储系统来存储消息,因为性能达不到要求。分离后的存储节点承担了之前绝大部分功能,并且增加了系统的复杂度,还降低了性能,显然是不划算的。
|
||||
|
||||
那为什么Pulsar还要采用这种存储和计算分离的设计呢?我们还是需要用发展的眼光看问题。我在上节课说过,Pulsar的这种架构,很可能代表了未来消息队列的发展方向。为什么这么说呢?你可以看一下现在各大消息队列的Roadmap(发展路线图),Kafka在做Kafka Streams,Pulsar在做Pulsar Functions,其实大家都在不约而同的做同一件事儿,就是流计算。
|
||||
|
||||
原因是什么呢?现有的流计算平台,包括Storm、Flink和Spark,它们的节点都是无状态的纯计算节点,是没有数据存储能力的。所以,现在的流计算平台,它很难做大量数据的聚合,并且在数据可靠性保证、数据一致性、故障恢复等方面,也做得不太好。
|
||||
|
||||
而消息队列正好相反,它很好地保证了数据的可靠性、一致性,但是Broker只具备存储能力,没有计算的功能,数据流进去什么样,流出来还是什么样。同样是处理实时数据流的系统,一个只能计算不能存储,一个只能存储不能计算,那未来如果出现一个新的系统,既能计算也能存储,如果还能有不错的性能,是不是就会把现在的消息队列和流计算平台都给替代了?这是很有可能的。
|
||||
|
||||
对于一个“带计算功能的消息队列”来说,采用存储计算分离的设计,计算节点负责流计算,存储节点负责存储消息,这个设计就非常和谐了。
|
||||
|
||||
到这里,我们课程的第二个模块–进阶篇,也就全部结束了。进阶篇的中讲解知识有一定的难度,特别是后半部分的几节源码分析课,从评论区同学们的留言中,我也能感受到,有些同学学习起来会有些吃力。
|
||||
|
||||
我给同学们的建议是,除了上课时听音频和读文稿之外,课后还要自己去把源代码下载下来,每一个流程从头到尾读一遍源码,最好是打开单步调试模式,一步一步地跟踪一下执行过程。读完源码之后,还要把类图、流程图或者时序图画出来,只有这样才能真正理解实现过程。
|
||||
|
||||
从下节课开始,我们的课程就进入最后一个模块:案例篇。在这个模块中,我会带你一起动手来写代码,运用我们在课程中所学的知识,来做一些实践的案例。首先我会带你一起做一个消息队列和流计算的案例,你可以来体会一下现在的流计算平台它是什么样的。然后,我们还会用进阶篇中所学到的知识,来一起实现一个类似Dubbo的RPC框架。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
318
极客时间专栏/消息队列高手课/进阶篇/加餐 | JMQ的Broker是如何异步处理消息的?.md
Normal file
318
极客时间专栏/消息队列高手课/进阶篇/加餐 | JMQ的Broker是如何异步处理消息的?.md
Normal file
@@ -0,0 +1,318 @@
|
||||
<audio id="audio" title="加餐 | JMQ的Broker是如何异步处理消息的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/c3/381b2f7171557ed827a742c5fad481c3.mp3"></audio>
|
||||
|
||||
你好,我是李玥。
|
||||
|
||||
我们的课程更新到进阶篇之后,通过评论区的留言,我看到有一些同学不太理解,为什么在进阶篇中要讲这些“看起来和消息队列关系不大的”内容呢?
|
||||
|
||||
在这里,我跟你分享一下这门课程的设计思路。我们这门课程的名称是“消息队列高手课”,我希望你在学习完这门课程之后,不仅仅只是成为一个使用消息队列的高手,而是**设计和实现**消息队列的高手。所以我们在设计课程的时候,分了基础篇、进阶篇和案例篇三部分。
|
||||
|
||||
基础篇中我们给大家讲解消息队列的原理和一些使用方法,重点是让大家学会使用消息队列。
|
||||
|
||||
你在进阶篇中,我们课程设计的重点是讲解实现消息队列必备的技术知识,通过分析源码讲解消息队列的实现原理。**希望你通过进阶篇的学习能够掌握到设计、实现消息队列所必备的知识和技术,这些知识和技术也是设计所有高性能、高可靠的分布式系统都需要具备的。**
|
||||
|
||||
进阶篇的上半部分,我们每一节课一个专题,来讲解设计实现一个高性能消息队列,必备的技术和知识。这里面每节课中讲解的技术点,不仅可以用来设计消息队列,同学们在设计日常的应用系统中也一定会用得到。
|
||||
|
||||
前几天我在极客时间直播的时候也跟大家透露过,由我所在的京东基础架构团队开发的消息队列JMQ,它的综合性能要显著优于目前公认性能非常好的Kafka。虽然在开发JMQ的过程中有很多的创新,但是对于性能的优化这块,并没有什么全新的划时代的新技术,JMQ之所以能做到这样的极致性能,靠的就是合理地设计和正确地使用已有的这些通用的底层技术和优化技巧。我把这些技术和知识点加以提炼和总结,放在进阶篇的上半部分中。
|
||||
|
||||
进阶篇的下半部分,我们主要通过分析源码的方式,来学习优秀开源消息队列产品中的一些实现原理和它们的设计思想。
|
||||
|
||||
在最后的案例篇,我会和大家一起,利用进阶篇中学习的知识,一起来开发一个简单的RPC框架。为什么我们要开发一个RPC框架,而不是一个消息队列?这里面就是希望大家不只是机械的去学习,仅仅是我告诉这个问题怎么解决,你就只是学会了这个问题怎么解决,而是能做到真正理解原理,掌握知识和技术,并且能融会贯通,灵活地去使用。只有这样,你才是真的“学会了”。
|
||||
|
||||
有的同学在看了进阶篇中已更新的这几节课程之后,觉得只讲技术原理不过瘾,希望能看到这些技术是如何在消息队列中应用并落地的,看到具体的实现和代码,所以我以京东JMQ为例,将这些基础技术点在消息队列实现中的应用讲解一下。
|
||||
|
||||
## JMQ的Broker是如何异步处理消息的?
|
||||
|
||||
对于消息队列的Broker,它最核心的两个流程就是接收生产者发来的消息,以及给消费者发送消息。后者的业务逻辑相对比较简单,影响消息队列性能的关键,就是消息生产的这个业务流程。在JMQ中,经过优化后的消息生产流程,实测它每秒钟可以处理超过100万次请求。
|
||||
|
||||
我们在之前的课程中首先讲了异步的设计思想,这里给你分享一下我在设计这个流程时,是如何来将异步的设计落地的。
|
||||
|
||||
消息生产的流程需要完成的功能是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/ba/a7589a7b4525e107f9b82de133bc43ba.jpg" alt="">
|
||||
|
||||
- 首先,生产者发送一批消息给Broker的主节点;
|
||||
- Broker收到消息之后,会对消息做一系列的解析、检查等处理;
|
||||
- 然后,把消息复制给所有的Broker从节点,并且需要把消息写入到磁盘中;
|
||||
- 主节点收到大多数从节点的复制成功确认后,给生产者回响应告知消息发送成功。
|
||||
|
||||
由于使用各种异步框架或多或少都会有一些性能损失,所以我在设计这个流程的时候,没有使用任何的异步框架,而是自行设计一组互相配合的处理线程来实现,但使用的异步设计思想和我们之前课程中所讲的是一样的。
|
||||
|
||||
对于这个流程,我们设计的线程模型是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/fc/c9bf75cafc246f4ace9d36831e95e1fc.png" alt="">
|
||||
|
||||
图中白色的细箭头是数据流,蓝色的箭头是控制流,白色的粗箭头代表远程调用。蓝白相间的方框代表的是处理的步骤,我在蓝色方框中标注了这个步骤是在什么线程中执行的。圆角矩形代表的是流程中需要使用的一些关键的数据结构。
|
||||
|
||||
这里我们设计了6组线程,将一个大的流程拆成了6个小流程。并且整个过程完全是异步化的。
|
||||
|
||||
流程的入口在图中的左上角,Broker在收到来自生产者的发消息请求后,会在一个Handler中处理这些请求,这和我们在普通的业务系统中,用Handler接收HTTP请求是一样的,执行Handler中业务逻辑使用的是Netty的IO线程。
|
||||
|
||||
收到请求后,我们在Handler中不做过多的处理,执行必要的检查后,将请求放到一个内存队列中,也就是图中的Requests Queue。请求被放入队列后,Handler的方法就结束了。可以看到,在Handler中只是把请求放到了队列中,没有太多的业务逻辑,这个执行过程是非常快的,所以即使是处理海量的请求,也不会过多的占用IO线程。
|
||||
|
||||
由于要保证消息的有序性,整个流程的大部分过程是不能并发的,只能单线程执行。所以,接下来我们使用一个线程WriteThread从请求队列中按照顺序来获取请求,依次进行解析请求等其他的处理逻辑,最后将消息序列化并写入存储。序列化后的消息会被写入到一个内存缓存中,就是图中的JournalCache,等待后续的处理。
|
||||
|
||||
执行到这里,一条一条的消息已经被转换成一个连续的字节流,每一条消息都在这个字节流中有一个全局唯一起止位置,也就是这条消息的Offset。后续的处理就不用关心字节流中的内容了,只要确保这个字节流能快速正确的被保存和复制就可以了。
|
||||
|
||||
这里面还有一个工作需要完成,就是给生产者回响应,但在这一步,消息既没有落盘,也没有完成复制,还不能给客户端返回响应,所以我们把待返回的响应按照顺序放到一个内存的链表Pending Callbacks中,并记录每个请求中的消息对应的Offset。
|
||||
|
||||
然后,我们有2个线程,FlushThread和ReplicationThread,这两个线程是并行执行的,分别负责批量异步进行刷盘和复制,刷盘和复制又分别是2个比较复杂的流程,我们暂时不展开讲。刷盘线程不停地将新写入Journal Cache的字节流写到磁盘上,完成一批数据的刷盘,它就会更新一个刷盘位置的内存变量,确保这个刷盘位置之前数据都已经安全的写入磁盘中。复制线程的逻辑也是类似的,同样维护了一个复制位置的内存变量。
|
||||
|
||||
最后,我们设计了一组专门用于发送响应的线程ReponseThreads,在刷盘位置或者复制位置更新后,去检查待返回的响应链表Pending Callbacks,根据QOS级别的设置(因为不同QOS基本对发送成功的定义不一样,有的设置需要消息写入磁盘才算成功,有的需要复制完成才算成功),将刷盘位置或者复制位置之前所有响应,以及已经超时的响应,利用这组线程ReponseThreads异步并行的发送给各个客户端。
|
||||
|
||||
这样就完成了消息生产这个流程。整个流程中,除了JournalCache的加载和卸载需要对文件加锁以外,没有用到其他的锁。每个小流程都不会等待其他流程的共享资源,也就不用互相等待资源(没有数据需要处理时等待上游流程提供数据的情况除外),并且只要有数据就能第一时间处理。
|
||||
|
||||
这个流程中,最核心的部分在于WriteThread执行处理的这个步骤,对每条消息进行处理的这些业务逻辑,都只能在WriteThread中单线程执行,虽然这里面干了很多的事儿,但是我们确保这些逻辑中,没有缓慢的磁盘和网络IO,也没有使用任何的锁来等待资源,全部都是内存操作,这样即使单线程可以非常快速地执行所有的业务逻辑。
|
||||
|
||||
**这个里面有很重要的几点优化:**
|
||||
|
||||
- 一是我们使用异步设计,把刷盘和复制这两部分比较慢的操作从这个流程中分离出去异步执行;
|
||||
- 第二是,我们使用了一个写缓存Journal Cache将一个写磁盘的操作,转换成了一个写内存的操作,来提升数据写入的性能,关于如何使用缓存,后面我会专门用一节课来讲;
|
||||
- 第三是,这个处理的全流程是近乎无锁的设计,避免了线程因为等待锁导致的阻塞;
|
||||
- 第四是,我们把回复响应这个需要等待资源的操作,也异步放到其他的线程中去执行。
|
||||
|
||||
你看,一个看起来很简单的接收请求写入数据并回响应的流程,需要涉及的技术包括:**异步的设计、缓存设计、锁的正确使用、线程协调、序列化和内存管理**,等等。你需要对这些技术都有深入的理解,并合理地使用,才能在确保逻辑正确、数据准确的前提下,做到极致的性能。这也是为什么我们在课程的进阶篇中,用这么多节课来逐一讲解这些“看起来和消息队列没什么关系”的知识点和技术。
|
||||
|
||||
我也希望同学们在学习这些知识点的时候,不仅仅只是记住了,能说出来,用于回答面试问题,还要能真正理解这些知识点和技术背后深刻的思想,并使用在日常的设计和开发过程中。
|
||||
|
||||
比如说,在面试的时候,很多同学都可以很轻松地讲JVM内存结构,也知道怎么用jstat、jmap、jstack这些工具来查看虚拟机的状态。但是,当我给出一个有内存溢出的问题程序和源代码,让他来分析原因并改正的时候,却很少有人能给出正确的答案。在我看来,对于JVM这些基础知识,这样的同学他以为自己已经掌握了,但是,无法领会技术背后的思想,做不到学以致用,那还只是别人知识,不是你的。
|
||||
|
||||
再比如,我下面要说的这个俩大爷的作业,你是真的花时间把代码写出来了,还只是在脑子想了想怎么做,就算完成了呢?
|
||||
|
||||
## 俩大爷的思考题
|
||||
|
||||
我们在进阶篇的开始,花了4节课的内容,来讲解如何实现高性能的异步网络通信,在《[13 | 传输协议:应用程序之间对话的语言](http://time.geekbang.org/column/article/119988)》中,我给大家留了一个思考题:写一个程序,让俩大爷在胡同口遇见10万次并记录下耗时。
|
||||
|
||||
有几个同学在留言区分享了自己的代码,每一个同学分享的代码我都仔细读过,有的作业实现了异步的网络通信,有的作业序列化和协议设计实现得很好,但很遗憾的是,没有一份作业能在序列化、协议设计和异步网络传输这几方面都做到我期望的水平。
|
||||
|
||||
在这个作业中,应用到了我们进阶篇中前四节课讲到的几个知识点:
|
||||
|
||||
- 使用异步设计的方法;
|
||||
- 异步网络IO;
|
||||
- 专用序列化、反序列化方法;
|
||||
- 设计良好的传输协议;
|
||||
- 双工通信。
|
||||
|
||||
这里面特别是双工通信的方法,大家都没能正确的实现。所以,这些作业的实际执行性能也没能达到一个应有的水平。
|
||||
|
||||
这里,我也给出一个作业的参考实现,我们用Go语言来实现这个作业:
|
||||
|
||||
我们用Go语言来实现这个作业:
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var zRecvCount = uint32(0) // 张大爷听到了多少句话
|
||||
var lRecvCount = uint32(0) // 李大爷听到了多少句话
|
||||
var total = uint32(100000) // 总共需要遇见多少次
|
||||
|
||||
var z0 = "吃了没,您吶?"
|
||||
var z3 = "嗨!吃饱了溜溜弯儿。"
|
||||
var z5 = "回头去给老太太请安!"
|
||||
var l1 = "刚吃。"
|
||||
var l2 = "您这,嘛去?"
|
||||
var l4 = "有空家里坐坐啊。"
|
||||
|
||||
var liWriteLock sync.Mutex // 李大爷的写锁
|
||||
var zhangWriteLock sync.Mutex // 张大爷的写锁
|
||||
|
||||
type RequestResponse struct {
|
||||
Serial uint32 // 序号
|
||||
Payload string // 内容
|
||||
}
|
||||
|
||||
// 序列化RequestResponse,并发送
|
||||
// 序列化后的结构如下:
|
||||
// 长度 4字节
|
||||
// Serial 4字节
|
||||
// PayLoad 变长
|
||||
func writeTo(r *RequestResponse, conn *net.TCPConn, lock *sync.Mutex) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
payloadBytes := []byte(r.Payload)
|
||||
serialBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(serialBytes, r.Serial)
|
||||
length := uint32(len(payloadBytes) + len(serialBytes))
|
||||
lengthByte := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lengthByte, length)
|
||||
|
||||
conn.Write(lengthByte)
|
||||
conn.Write(serialBytes)
|
||||
conn.Write(payloadBytes)
|
||||
// fmt.Println("发送: " + r.Payload)
|
||||
}
|
||||
|
||||
// 接收数据,反序列化成RequestResponse
|
||||
func readFrom(conn *net.TCPConn) (*RequestResponse, error) {
|
||||
ret := &RequestResponse{}
|
||||
buf := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("读长度故障:%s", err.Error())
|
||||
}
|
||||
length := binary.BigEndian.Uint32(buf)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("读Serial故障:%s", err.Error())
|
||||
}
|
||||
ret.Serial = binary.BigEndian.Uint32(buf)
|
||||
payloadBytes := make([]byte, length-4)
|
||||
if _, err := io.ReadFull(conn, payloadBytes); err != nil {
|
||||
return nil, fmt.Errorf("读Payload故障:%s", err.Error())
|
||||
}
|
||||
ret.Payload = string(payloadBytes)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// 张大爷的耳朵
|
||||
func zhangDaYeListen(conn *net.TCPConn, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
for zRecvCount < total*3 {
|
||||
r, err := readFrom(conn)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
break
|
||||
}
|
||||
// fmt.Println("张大爷收到:" + r.Payload)
|
||||
if r.Payload == l2 { // 如果收到:您这,嘛去?
|
||||
go writeTo(&RequestResponse{r.Serial, z3}, conn, &zhangWriteLock) // 回复:嗨!吃饱了溜溜弯儿。
|
||||
} else if r.Payload == l4 { // 如果收到:有空家里坐坐啊。
|
||||
go writeTo(&RequestResponse{r.Serial, z5}, conn, &zhangWriteLock) // 回复:回头去给老太太请安!
|
||||
} else if r.Payload == l1 { // 如果收到:刚吃。
|
||||
// 不用回复
|
||||
} else {
|
||||
fmt.Println("张大爷听不懂:" + r.Payload)
|
||||
break
|
||||
}
|
||||
zRecvCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 张大爷的嘴
|
||||
func zhangDaYeSay(conn *net.TCPConn) {
|
||||
nextSerial := uint32(0)
|
||||
for i := uint32(0); i < total; i++ {
|
||||
writeTo(&RequestResponse{nextSerial, z0}, conn, &zhangWriteLock)
|
||||
nextSerial++
|
||||
}
|
||||
}
|
||||
|
||||
// 李大爷的耳朵,实现是和张大爷类似的
|
||||
func liDaYeListen(conn *net.TCPConn, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
for lRecvCount < total*3 {
|
||||
r, err := readFrom(conn)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
break
|
||||
}
|
||||
// fmt.Println("李大爷收到:" + r.Payload)
|
||||
if r.Payload == z0 { // 如果收到:吃了没,您吶?
|
||||
writeTo(&RequestResponse{r.Serial, l1}, conn, &liWriteLock) // 回复:刚吃。
|
||||
} else if r.Payload == z3 {
|
||||
// do nothing
|
||||
} else if r.Payload == z5 {
|
||||
// do nothing
|
||||
} else {
|
||||
fmt.Println("李大爷听不懂:" + r.Payload)
|
||||
break
|
||||
}
|
||||
lRecvCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 李大爷的嘴
|
||||
func liDaYeSay(conn *net.TCPConn) {
|
||||
nextSerial := uint32(0)
|
||||
for i := uint32(0); i < total; i++ {
|
||||
writeTo(&RequestResponse{nextSerial, l2}, conn, &liWriteLock)
|
||||
nextSerial++
|
||||
writeTo(&RequestResponse{nextSerial, l4}, conn, &liWriteLock)
|
||||
nextSerial++
|
||||
}
|
||||
}
|
||||
|
||||
func startServer(wg *sync.WaitGroup) {
|
||||
tcpAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:9999")
|
||||
tcpListener, _ := net.ListenTCP("tcp", tcpAddr)
|
||||
defer tcpListener.Close()
|
||||
fmt.Println("张大爷在胡同口等着 ...")
|
||||
for {
|
||||
conn, err := tcpListener.AcceptTCP()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
break
|
||||
}
|
||||
|
||||
fmt.Println("碰见一个李大爷:" + conn.RemoteAddr().String())
|
||||
go zhangDaYeListen(conn, wg)
|
||||
go zhangDaYeSay(conn)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func startClient(wg *sync.WaitGroup) *net.TCPConn {
|
||||
var tcpAddr *net.TCPAddr
|
||||
tcpAddr, _ = net.ResolveTCPAddr("tcp", "127.0.0.1:9999")
|
||||
conn, _ := net.DialTCP("tcp", nil, tcpAddr)
|
||||
go liDaYeListen(conn, wg)
|
||||
go liDaYeSay(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go startServer(&wg)
|
||||
time.Sleep(time.Second)
|
||||
conn := startClient(&wg)
|
||||
t1 := time.Now()
|
||||
wg.Wait()
|
||||
elapsed := time.Since(t1)
|
||||
conn.Close()
|
||||
fmt.Println("耗时: ", elapsed)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在我的Mac执行10万次大约需要不到5秒钟:
|
||||
|
||||
```
|
||||
go run hutong.go
|
||||
张大爷在胡同口等着 ...
|
||||
碰见一个李大爷:127.0.0.1:50136
|
||||
耗时: 4.962786896s
|
||||
|
||||
```
|
||||
|
||||
在这段程序里面,**我没有对程序做任何特殊的性能优化,只是使用了我们之前四节课中讲到的,上面列出来的那些知识点,完成了一个基本的实现。**
|
||||
|
||||
在这段程序中,我们首先定义了RequestResponse这个结构体,代表请求或响应,它包括序号和内容两个字段。readFrom方法的功能是,接收数据,反序列化成RequestResponse。
|
||||
|
||||
协议的设计是这样的:首先用4个字节来标明这个请求的长度,然后用4个字节来保存序号,最后变长的部分就是大爷说的话。这里面用到了使用前置长度的方式来进行断句,这种断句的方式我在之前的课程中专门讲到过。
|
||||
|
||||
这里面我们使用了专有的序列化方法,原因我在之前的课程中重点讲过,专有的序列化方法具备最好的性能,序列化出来的字节数也更少,而我们这个作业比拼的就是性能,所以在这个作业中采用这种序列化方式是最合适的选择。
|
||||
|
||||
zhangDaYeListen和liDaYeListen这两个方法,它们的实现是差不多的,就是接收对方发出的请求,然后给出正确的响应。zhangDaYeSay和liDaYeSay这两个方法的实现也是差不多的,当俩大爷遇见后,就开始不停地说各自的请求,**并不等待对方的响应**,连续说10万次。
|
||||
|
||||
这4个方法,分别在4个不同的协程中并行运行,两收两发,实现了全双工的通信。在这个地方,不少同学还是摆脱不了“一问一答,再问再答”这种人类交流的自然方式对思维的影响,写出来的依然是单工通信的程序,单工通信的性能是远远不如双工通信的,所以,要想做到比较好的网络传输性能,双工通信的方式才是最佳的选择。
|
||||
|
||||
为了避免并发向同一个socket中写入造成数据混乱,我们给俩大爷分别定义了一个写锁,确保每个大爷同一时刻只能有一个协程在发送数据。后面的课程中,我们会专门来讲,如何正确地使用锁。
|
||||
|
||||
最后,我们给张大爷定义为服务端,李大爷定义为客户端,连接建立后,分别开启两个大爷的耳朵和嘴,来完成这10万次遇见。
|
||||
|
||||
## 思考题
|
||||
|
||||
在我给出这个俩大爷作业的实现中,我们可以计算一下,10万次遇见耗时约5秒,平均每秒可以遇见约2万次,**考虑到数据量的大小,这里面仍然有非常大的优化空间。**
|
||||
|
||||
请你在充分理解这段代码之后,想一想,还有哪些地方是可以优化的,然后一定要动手把代码改出来,并运行,验证一下你的改进是否真的达到了效果。欢迎你在留言区提出你的改进意见。
|
||||
|
||||
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
|
||||
|
Reference in New Issue
Block a user