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

View File

@@ -0,0 +1,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 datas 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都是有固定格式的一般要说明为什么需要增加这个功能会对系统产生那些影响和改变还有我们最关心的设计和实现原理的简述。
你读完讲解实现的文档再去看源代码,也就是我刚刚说的,不只是带着问题去读,而是带着答案去读源码。这样你在读源码的时候,不仅仅是更容易理解源代码,还可以把更多的精力放在一些实现细节上,这样阅读源码的效果会更好。
使用这种以问题为阅读单元的方式来读源代码,你每次只要花很短的时间,阅读很少的一部分源码,就能解决一个问题,得到一些收获。这种方式其实是通过一个一个的问题,在网状的源代码中,每次去读几个点组成的那一两条线。随着你通过阅读源码了解的问题越来越多,你对项目源码的理解也会越来越全面和深入。
## 小结
如果你想了解一个开源项目,学习它的代码,最佳的切入点就是去读它的官方文档,这些文档里面,最重要的灵魂就是项目背后的那篇论文,它一般是这个开源项目的理论基础。
在阅读源码的时候呢,最佳的方式是带着问题去阅读,最好是带着问题的答案去读,这样难度低、周期短、收获快。不要想着一定要从总体上去全面掌握一个项目的所有源代码,也没有必要。
## 思考题
课后,建议你找一个你熟悉的开源项目,可以是消息相关的,也可以是无关的开源项目,确定一个问题,用这节课中讲到的“带着问题和答案去读源码”的方法,去读一点源码。然后,最重要的是,把主要的流程用流程图或者时序图画出来,把重点的算法、原理用文字写出来。
欢迎你在留言区分享你的阅读源码的笔记,在这个过程中,如果遇到任何问题,你也可以在留言区提问。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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&lt;Void&gt; add(int account, int amount);
}
```
```
/**
* 转账服务
*/
public interface TransferService {
/**
* 异步转账服务
* @param fromAccount 转出账户
* @param toAccount 转入账户
* @param amount 转账金额,单位分
*/
CompletableFuture&lt;Void&gt; transfer(int fromAccount, int toAccount, int amount);
}
```
可以看到这两个接口中定义的方法的返回类型都是一个带泛型的CompletableFuture尖括号中的泛型类型就是真正方法需要返回数据的类型我们这两个服务不需要返回数据所以直接用Void类型就可以。
然后我们来实现转账服务:
```
/**
* 转账服务的实现
*/
public class TransferServiceImpl implements TransferService {
@Inject
private AccountService accountService; // 使用依赖注入获取账户服务的实例
@Override
public CompletableFuture&lt;Void&gt; transfer(int fromAccount, int toAccount, int amount) {
// 异步调用add方法从fromAccount扣减相应金额
return accountService.add(fromAccount, -1 * amount)
// 然后调用add方法给toAccount增加相应金额
.thenCompose(v -&gt; 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(&quot;转账完成!&quot;);
}
public void asyncInvoke() {
// 异步调用
transferService.transfer(A, B, 100)
.thenRun(() -&gt; System.out.println(&quot;转账完成!&quot;));
}
}
```
在调用异步方法获得返回值CompletableFuture对象后既可以调用CompletableFuture的get方法像调用同步方法那样等待调用的方法执行结束并获得返回值也可以像异步回调的方式一样调用CompletableFuture那些以then开头的一系列方法为CompletableFuture定义异步方法结束之后的后续操作。比如像上面这个例子中我们调用thenRun()方法,参数就是将转账完成打印在控台上这个操作,这样就可以实现在转账完成后,在控制台打印“转账完成!”了。
## 小结
简单的说,异步思想就是,**当我们要执行一项比较耗时的操作时,不去等待操作结束,而是给这个操作一个命令:“当操作完成后,接下来去执行什么。”**
使用异步编程模型,虽然并不能加快程序本身的速度,但可以减少或者避免线程等待,只用很少的线程就可以达到超高的吞吐能力。
同时我们也需要注意到异步模型的问题:相比于同步实现,异步实现的复杂度要大很多,代码的可读性和可维护性都会显著的下降。虽然使用一些异步编程框架会在一定程度上简化异步开发,但是并不能解决异步模型高复杂度的问题。
异步性能虽好,但一定不要滥用,只有类似在像消息队列这种业务逻辑简单并且需要超高吞吐量的场景下,或者必须长时间等待资源的地方,才考虑使用异步模型。如果系统的业务逻辑比较复杂,在性能足够满足业务需求的情况下,采用符合人类自然的思路且易于开发和维护的同步模型是更加明智的选择。
## 思考题
课后给你留2个思考题
第一个思考题是我们实现转账服务时并没有考虑处理失败的情况。你回去可以想一下在异步实现中如果调用账户服务失败时如何将错误报告给客户端在两次调用账户服务的Add方法时如果某一次调用失败了该如何处理才能保证账户数据是平的
第二个思考题是在异步实现中回调方法OnComplete()是在什么线程中运行的?我们是否能控制回调方法的执行线程数?该如何做?欢迎在留言区写下你的想法。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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(&quot;localhost&quot;, 9999));
// 设置收到数据后的处理的Handler
serverBootstrap.childHandler(new ChannelInitializer&lt;SocketChannel&gt;() {
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来实现的呢欢迎在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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: &quot;zhangsan&quot;
age: 23
married: true
```
使用JSON序列化后
```
{&quot;name&quot;:&quot;zhangsan&quot;,&quot;age&quot;:&quot;23&quot;,&quot;married&quot;:&quot;true&quot;}
```
这里面的数据我们不需要借助工具,是直接可以看懂的。
序列化的代码也比较简单直接调用JSON序列化框架提供的方法就可以了
```
byte [] serializedUser = JsonConvert.SerializeObject(user).getBytes(&quot;UTF-8&quot;);
```
如果JSON序列化的性能达不到你系统的要求可以采用性能更好的二进制序列化实现实现的复杂度和JSON序列化是差不多的都很简单但是序列化性能更好信息密度也更高代价就是失去了可读性。
比如我们用Kryo来序列化User对象它的代码如下
```
kryo.register(User.class);
Output output = new Output(new FileOutputStream(&quot;file.bin&quot;));
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这种可读性好的序列化方法。
如果说我们需要超高的性能,或者是带宽有限的情况下,可以使用专用的序列化方法,来提升序列化性能,节省传输流量。不过实现起来很复杂,大部分情况下并不划算。
## 思考题
课后,你可以想一下这个问题:在内存里存放的任何数据,它最基础的存储单元也是二进制比特,也就是说,我们应用程序操作的对象,它在内存中也是使用二进制存储的,既然都是二进制,为什么不能直接把内存中,对象对应的二进制数据直接通过网络发送出去,或者保存在文件中呢?为什么还需要序列化和反序列化呢?欢迎在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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框架都是用这种方式来实现它们自己的私有应用层传输协议。
## 思考题
课后,我希望你能真正动手去写代码,用我们这四节课讲到的方法,来实现一个简单的高性能通信程序。功能就是上面两个大爷那三组对话,服务端是张大爷,客户端是李大爷,我们让俩人在胡同口碰见一百万次,记录下总共的耗时。欢迎你在评论区秀出你的总耗时。
在实现过程中,有任何问题,也欢迎你在评论区留言来提问。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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左右的文本在高并发的情况下你会如何来优化这个程序来尽量避免由于垃圾回收导致的进程卡死问题欢迎你在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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 &lt;sys/socket.h&gt;
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
```
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
如果你遇到这种从文件读出数据后再通过网络发送出去的场景,并且这个过程中你不需要对这些数据进行处理,那一定要使用这个零拷贝的方法,可以有效地提升性能。
## 小结
这节课我们总结了Kafka的高性能设计中的几个关键的技术点
- 使用批量处理的方式来提升系统吞吐能力。
- 基于磁盘文件高性能顺序读写的特性来设计的存储结构。
- 利用操作系统的PageCache来缓存数据减少IO并提升读性能。
- 使用零拷贝技术加速消费流程。
以上这些就是Kafka之所以能做到如此高性能的关键技术点。你可以看到要真正实现一个高性能的消息队列是非常不容易的你需要熟练掌握非常多的编程语言和操作系统的底层技术。
这些优化的方法和技术,同样可以用在其他适合的场景和应用程序中。我希望你能充分理解这几项优化技术的原理,知道它们在什么情况下适用,什么情况下不适用。这样,当你遇到合适场景的时候,再深入去学习它的细节用法,最终就能把它真正地用到你开发的程序中。
## 思考题
课后我希望你去读一读Kafka的源代码从我们这节课中找一两个技术点找到对应的代码部分真正去看一下我们说的这些优化技术是如何落地到代码上的。在分析源代码的过程中如果有任何问题也欢迎你在留言区和我一起讨论。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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固态硬盘每秒钟可以读写几千次如果说我们的程序在处理业务请求的时候直接来读写磁盘假设处理每次请求需要读写35次即使每次请求的数据量不大你的程序最多每秒也就能处理1000次左右的请求。
而内存的随机读写速度是磁盘的10万倍所以**使用内存作为缓存来加速应用程序的访问速度,是几乎所有高性能系统都会采用的方法。**
缓存的思想很简单,就是把低速存储的数据,复制一份副本放到高速的存储中,用来加速数据的访问。缓存使用起来也非常简单,很多同学在做一些业务系统的时候,在一些执行比较慢的方法上加上一个@Cacheable的注解,就可以使用缓存来提升它的访问性能了。
但是,你是否考虑过,采用@Cacheable注解的方式缓存的命中率如何?或者说怎样才能提高缓存的命中率?缓存是否总能返回最新的数据?如果缓存返回了过期的数据该怎么办?接下来,我们一起来通过学习设计、使用缓存的最佳实践,找到这些问题的答案。
## 选择只读缓存还是读写缓存?
使用缓存,首先你就会面临选择读缓存还是读写缓存的问题。他们唯一的区别就是,在更新数据的时候,是否经过缓存。
我们之前的课中讲到Kafka使用的PageCache它就是一个非常典型的读写缓存。操作系统会利用系统空闲的物理内存来给文件读写做缓存这个缓存叫做PageCache。应用程序在写文件的时候操作系统会先把数据写入到PageCache中数据在成功写到PageCache之后对于用户代码来说写入就结束了。
然后操作系统再异步地把数据更新到磁盘的文件中。应用程序在读文件的时候操作系统也是先尝试从PageCache中寻找数据如果找到就直接返回数据找不到会触发一个缺页中断然后操作系统把数据从文件读取到PageCache中再返回给应用程序。
我们可以看到在数据写到PageCache中后它并不是同时就写到磁盘上了这中间是有一个延迟的。操作系统可以保证即使是应用程序意外退出了操作系统也会把这部分数据同步到磁盘上。但是如果服务器突然掉电了这部分数据就丢失了。
你需要知道,**读写缓存的这种设计,它天然就是不可靠的,是一种牺牲数据一致性换取性能的设计。**当然应用程序可以调用sync等系统调用强制操作系统立即把缓存数据同步到磁盘文件中去但是这个同步的过程是很慢的也就失去了缓存的意义。
另外写缓存的实现是非常复杂的。应用程序不停地更新PageCache中的数据操作系统需要记录哪些数据有变化同时还要在另外一个线程中把缓存中变化的数据更新到磁盘文件中。在提供并发读写的同时来异步更新数据这个过程中要保证数据的一致性并且有非常好的性能实现这些真不是一件容易的事儿。
所以说,一般情况下,不推荐你来使用读写缓存。
那为什么Kafka可以使用PageCache来提升它的性能呢这是由消息队列的一些特点决定的。
首先消息队列它的读写比例大致是11因为大部分我们用消息队列都是一收一发这样使用。这种读写比例只读缓存既无法给写加速读的加速效果也有限并不能提升多少性能。
另外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&lt;K,V&gt; {
/**
* 根据提供的key来访问数据
* @param key 数据Key
* @return 数据值
*/
V get(K key);
}
/**
* LRU缓存。你需要继承这个抽象类来实现LRU缓存。
* @param &lt;K&gt; 数据Key
* @param &lt;V&gt; 数据值
*/
public abstract class LruCache&lt;K, V&gt; implements Storage&lt;K,V&gt;{
// 缓存容量
protected final int capacity;
// 低速存储,所有的数据都可以从这里读到
protected final Storage&lt;K,V&gt; lowSpeedStorage;
public LruCache(int capacity, Storage&lt;K,V&gt; lowSpeedStorage) {
this.capacity = capacity;
this.lowSpeedStorage = lowSpeedStorage;
}
}
```
你需要继承LruCache这个抽象类实现你自己的LRU缓存。lowSpeedStorage是提供给你可用的低速存储你不需要实现它。
欢迎你把代码上传到GitHub上然后在评论区给出访问链接。大家来比一下谁的算法性能更好。如果你有任何问题也可以在评论区留言与我交流。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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(&quot;Thread1: Try to accquire lockA...&quot;)
with lockA:
print(&quot;Thread1: lockA accquired. Try to accquire lockB...&quot;)
with lockB:
print(&quot;Thread1: Both lockA and LockB accrquired.&quot;)
def func2(lockA, lockB):
while True:
print(&quot;Thread2: Try to accquire lockB...&quot;)
with lockB:
print(&quot;Thread2: lockB accquired. Try to accquire lockA...&quot;)
with lockA:
print(&quot;Thread2: Both lockA and LockB accrquired.&quot;)
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上然后在评论区给出访问链接。如果你有任何问题也可以在评论区留言与我交流。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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>
你好,我是李玥。上节课,我们一起学习了如何使用锁来保护共享资源,你也了解到,使用锁是有一定性能损失的,并且,如果发生了过多的锁等待,将会非常影响程序的性能。
在一些特定的情况下,我们可以使用硬件同步原语来替代锁,可以保证和锁一样的数据安全性,同时具有更好的性能。
在今年的NSDINSDI是USENIX组织开办的关于网络系统设计的著名学术会议伯克利大学发表了一篇论文《[Confluo: Distributed Monitoring and Diagnosis Stack for High-speed Networks](http://www.usenix.org/conference/nsdi19/presentation/khandelwal)》这个论文中提到的Confluo也是一个类似于消息队列的流数据存储它的吞吐量号称是Kafka的410倍。对于这个实验结论我个人不是很认同因为它设计的实验条件对Kafka来说不太公平。但不可否认的是Confluo它的这个设计思路是一个创新并且实际上它的性能也非常好。
Confluo是如何做到这么高的吞吐量的呢这里面非常重要的一个创新的设计就是它使用硬件同步原语来代替锁在一个日志上你可以理解为消息队列中的一个队列或者分区保证严格顺序的前提下实现了多线程并发写入。
今天我们就来学习一下如何用硬件同步原语CAS替代锁
## 什么是硬件同步原语?
为什么硬件同步原语可以替代锁呢?要理解这个问题,你要首先知道硬件同步原语是什么。
硬件同步原语Atomic Hardware Primitives是由计算机硬件提供的一组原子操作我们比较常用的原语主要是CAS和FAA这两种。
CASCompare and Swap它的字面意思是先比较再交换。我们看一下CAS实现的伪代码
```
&lt;&lt; atomic &gt;&gt;
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
```
&lt;&lt; atomic &gt;&gt;
function faa(p : pointer to int, inc : int) returns int {
int value &lt;- *location
*p &lt;- value + inc
return value
}
```
FAA原语的语义是先获取变量p当前的值value然后给变量p增加inc最后返回变量p之前的值value。
讲到这儿估计你会问,这两个原语到底有什么特殊的呢?
上面的这两段伪代码如果我们用编程语言来实现肯定是无法保证原子性的。而原语的特殊之处就是它们都是由计算机硬件具体说就是CPU提供的实现可以保证操作的原子性。
我们知道,**原子操作具有不可分割性,也就不存在并发的问题**。所以在某些情况下,原语可以用来替代锁,实现一些即安全又高效的并发操作。
CAS和FAA在各种编程语言中都有相应的实现可以来直接使用无论你是使用哪种编程语言它们底层的实现是一样的效果也是一样的。
接下来还是拿我们熟悉的账户服务来举例说明一下看看如何使用CAS原语来替代锁实现同样的安全性。
## CAS版本的账户服务
假设我们有一个共享变量balance它保存的是当前账户余额然后我们模拟多个线程并发转账的情况看一下如何使用CAS原语来保证数据的安全性。
这次我们使用Go语言来实现这个转账服务。先看一下使用锁实现的版本
```
package main
import (
&quot;fmt&quot;
&quot;sync&quot;
)
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 &lt; count; i++ {
// 这里模拟异步并发转账
go transfer(&amp;balance, 1, done, &amp;lock)
}
// 等待所有转账都完成
for i := 0; i &lt; count; i++ {
&lt;-done
}
// 打印账户余额
fmt.Printf(&quot;balance = %d \n&quot;, balance)
}
// 转账服务
func transfer(balance *int32, amount int, done chan bool, lock *sync.Mutex) {
lock.Lock()
*balance = *balance + int32(amount)
lock.Unlock()
done &lt;- 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 &lt;- 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 &lt;- 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上然后在评论区给出访问链接。如果你有任何问题也可以在评论区留言与我交流。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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个字符并且是可以无损还原的。当然我举的例子比较极端我的压缩算法也几乎没什么实用性但是这确实是一个压缩算法并且和其他的压缩算法本质是没什么区别的。
目前常用的压缩算法包括ZIPGZIPSNAPPYLZ4等等。选择压缩算法的时候主要需要考虑数据的压缩率和压缩耗时。一般来说压缩率越高的算法压缩耗时也越高。如果是对性能要求高的系统可以选择压缩速度快的算法比如LZ4如果需要更高的压缩比可以考虑GZIP或者压缩率更高的XZ等算法。
压缩样本对压缩速度和压缩比的影响也是比较大的,同样大小的一段数字和一段新闻的文本,即使是使用相同的压缩算法,压缩率和压缩时间的差异也是比较大的。所以,有的时候在选择压缩算法的之前,用系统的样例业务数据做一个测试,可以帮助你找到最合适的压缩算法。
在这里我不会去给你讲某一种压缩算法因为压缩算法都很复杂一般来说也不需要我们来实现某种压缩算法如果你感兴趣的话可以去学习一下最经典压缩算法哈夫曼编码也叫霍夫曼编码Huffman Coding
## 如何选择合适的压缩分段?
大部分的压缩算法,他们的区别主要是,对数据进行编码的算法,压缩的流程和压缩包的结构大致一样的。而在压缩过程中,你最需要了解的就是如何选择合适的压缩分段大小。
在压缩时,给定的被压缩数据它必须有确定的长度,或者说,是有头有尾的,不能是一个无限的数据流,**如果要对流数据进行压缩,那必须把流数据划分成多个帧,一帧一帧的分段压缩。**
主要原因是,压缩算法在开始压缩之前,一般都需要对被压缩数据从头到尾进行一次扫描,扫描的目的是确定如何对数据进行划分和编码,一般的原则是重复次数多、占用空间大的内容,使用尽量短的编码,这样压缩率会更高。
另外,被压缩的数据长度越大,重码率会更高,压缩比也就越高。这个很好理解,比如我们这篇文章,可能出现了几十次“压缩”这个词,如果将整篇文章压缩,这个词的重复率是几十次,但如果我们按照每个自然段来压缩,那每段中这个词的重复率只有二三次。显然全文压缩的压缩率肯定高于分段压缩。
当然分段也不是越大越好实际上分段大小超过一定长度之后再增加长度对压缩率的贡献就不太大了这是一个原因。另外过大的分段长度在解压缩的时候会有更多的解压浪费。比如一个1MB大小的压缩文件即使你只是需要读其中很短的几个字节也不得不把整个文件全部解压缩造成很大的解压浪费。
所以,你需要根据你的业务,选择合适的压缩分段,在压缩率、压缩速度和解压浪费之间找到一个合适的平衡。
确定了如何对数据进行划分和压缩算法之后,就可以进行压缩了,压缩的过程就是用编码来替换原始数据的过程。压缩之后的压缩包就是由这个编码字典和用编码替换之后的数据组成的。
这就是数据压缩的过程。解压的时候,先读取编码字典,然后按照字典把压缩编码还原成原始的数据就可以了。
## Kafka是如何处理消息压缩的
回过头来我们再看一下Kafka它是如何来处理数据压缩的。
首先Kafka是否开启压缩这是可以配置它也支持配置使用哪一种压缩算法。原因我们在上面说过不同的业务场景是否需要开启压缩选择哪种压缩算法是不能一概而论的。所以Kafka的设计者把这个选择权交给使用者。
在开启压缩时Kafka选择一批消息一起压缩每一个批消息就是一个压缩分段。使用者也可以通过参数来控制每批消息的大小。
我们之前讲过在Kafka中生产者生成一个批消息发给服务端在服务端中是不会拆分批消息的。那按照批来压缩意味着在服务端也不用对这批消息进行解压可以整批直接存储然后整批发送给消费者。最后批消息由消费者进行解压。
在服务端不用解压就不会耗费服务端宝贵的CPU资源同时还能获得压缩后占用传输带宽小占用存储空间小的这些好处这是一个非常聪明的设计。
在使用Kafka时如果生产者和消费者的CPU资源不是特别吃紧开启压缩后可以节省网络带宽和服务端的存储空间提升总体的吞吐量一般都是个不错的选择。
## 小结
数据压缩它本质上是用CPU资源换取存储资源或者说是用压缩解压的时间来换取存储的空间这个买卖是不是划算需要你根据自己的情况先衡量一下。
在选择压缩算法的时候,需要综合考虑压缩时间和压缩率两个因素,被压缩数据的内容也是影响压缩时间和压缩率的重要因素,必要的时候可以先用业务数据做一个压缩测试,这样有助于选择最合适的压缩算法。
另外一个影响压缩率的重要因素是压缩分段的大小,你需要根据业务情况选择一个合适的分段策略,在保证不错的压缩率的前提下,尽量减少解压浪费。
最后我们讲了一下Kafka它是如何处理消息压缩的。Kafka在生产者上对每批消息进行压缩批消息在服务端不解压消费者在收到消息之后再进行解压。简单地说Kafka的压缩和解压都是在客户端完成的。
## 思考题
课后你可以去看一下RocketMQ的文档或者源代码看一下RocketMQ是怎么处理消息压缩的。然后和Kafka的压缩方式对比一下想一想哪种处理方式更适合你的系统
欢迎你在留言区把你的思考分享出来,如果有任何问题也欢迎你在留言区提问。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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(&quot;127.0.0.1:9876&quot;);
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. DefaultMQProducerImplProducer的内部实现类大部分Producer的业务逻辑也就是发消息的逻辑都在这个类中。
1. MQClientInstance这个类中封装了客户端一些通用的业务逻辑无论是Producer还是Consumer最终需要与服务端交互时都需要调用这个类中的方法
1. MQClientAPIImpl这个类中封装了客户端服务端的RPC对调用者隐藏了真正网络通信部分的具体实现
1. NettyRemotingClientRocketMQ各进程之间网络通信的底层实现类。
## 消息发送过程
接下来我们一起分析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 &gt; costTime) {
try {
try {
sendSelectImpl(msg, selector, arg, CommunicationMode.ASYNC, sendCallback,
timeout - costTime);
} catch (MQBrokerException e) {
throw new MQClientException(&quot;unknownn exception&quot;, e);
}
} catch (Exception e) {
sendCallback.onException(e);
}
} else {
sendCallback.onException(new RemotingTooMuchRequestException(&quot;call timeout&quot;));
}
}
});
} catch (RejectedExecutionException e) {
throw new MQClientException(&quot;exector rejected &quot;, e);
}
}
```
我们可以看到RocketMQ使用了一个ExecutorService来实现异步发送使用asyncSenderExecutor的线程池异步调用方法sendSelectImpl()继续发送消息的后续工作当前线程把发送任务提交给asyncSenderExecutor就可以返回了。单向发送和同步发送的实现则是直接在当前线程中调用方法sendSelectImpl()。
我们来继续看方法sendSelectImpl()的实现:
```
// 省略部分代码
MessageQueue mq = null;
// 选择将消息发送到哪个队列Queue
try {
List&lt;MessageQueue&gt; 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(&quot;select message queue throwed exception.&quot;, e);
}
// 省略部分代码
// 发送消息
if (mq != null) {
return this.sendKernelImpl(msg, mq, communicationMode, sendCallback, null, timeout - costTime);
} else {
throw new MQClientException(&quot;select message queue return null.&quot;, 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封装了客户端与服务端的RPCNettyRemotingClient实现了底层网络通信。
我在课程中,只能带你把主干流程分析清楚,但是很多细节并没有涉及,课后请你一定要按照流程把源代码仔细看一遍,仔细消化一下没有提及到的分支流程,将这两个流程绘制成详细的流程图或者时序图。
分析过程中提到的几个设计模式,是非常实用且常用的设计模式,希望你能充分理解并熟练运用。
## 思考题
你有没有注意到在源码中异步发送消息方法DefaultMQProducerImpl#send()(1132行)被开发者加了@Deprecated(弃用)注解,显然开发者也意识到了这种异步的实现存在一些问题,需要改进。请你结合我们专栏文章《[10 | 如何使用异步设计提升系统性能?](https://time.geekbang.org/column/article/117272)》中讲到的异步设计方法想一想,应该如何改进这个异步发送的流程?欢迎在留言区写下你的想法。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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(&quot;bootstrap.servers&quot;, &quot;localhost:9092&quot;);
props.put(&quot;group.id&quot;, &quot;test&quot;);
props.put(&quot;enable.auto.commit&quot;, &quot;true&quot;);
props.put(&quot;auto.commit.interval.ms&quot;, &quot;1000&quot;);
props.put(&quot;key.deserializer&quot;, &quot;org.apache.kafka.common.serialization.StringDeserializer&quot;);
props.put(&quot;value.deserializer&quot;, &quot;org.apache.kafka.common.serialization.StringDeserializer&quot;);
// 创建Consumer实例
KafkaConsumer&lt;String, String&gt; consumer = new KafkaConsumer&lt;&gt;(props);
// 订阅Topic
consumer.subscribe(Arrays.asList(&quot;foo&quot;, &quot;bar&quot;));
// 循环拉消息
while (true) {
ConsumerRecords&lt;String, String&gt; records = consumer.poll(100);
for (ConsumerRecord&lt;String, String&gt; record : records)
System.out.printf(&quot;offset = %d, key = %s, value = %s%n&quot;, record.offset(), record.key(), record.value());
}
```
这段代码主要的主要流程是:
1. 设置必要的配置信息包括起始连接的Broker地址Consumer Group的ID自动提交消费位置的配置和序列化配置
1. 创建Consumer实例
1. 订阅了2个Topicfoo 和 bar
1. 循环拉取消息并打印在控制台上。
通过上面的代码实例我们可以看到消费这个大的流程在Kafka中实际上是被分成了“订阅”和“拉取消息”这两个小的流程。另外我在之前的课程中反复提到过Kafka在消费过程中每个Consumer实例是绑定到一个分区上的那Consumer是如何确定绑定到哪一个分区上的呢这个问题也是可以通过分析消费流程来找到答案的。所以我们分析整个消费流程主要聚焦在三个问题上
1. 订阅过程是如何实现的?
1. Consumer是如何与Coordinator协商确定消费哪些Partition的
1. 拉取消息的过程是如何实现的?
了解前两个问题有助于你充分理解Kafka的元数据模型以及Kafka是如何在客户端和服务端之间来交换元数据的。最后一个问题拉取消息的实现过程实际上就是消费的主要流程我们上节课讲过这是消息队列最核心的两个流程之一也是必须重点掌握的。我们就带着这三个问题来分析Kafka的订阅和拉取消息的过程如何实现。
## 订阅过程如何实现?
我们先来看看订阅的实现流程。从上面的例子跟踪到订阅的主流程方法:
```
public void subscribe(Collection&lt;String&gt; topics, ConsumerRebalanceListener listener) {
acquireAndEnsureOpen();
try {
// 省略部分代码
// 重置订阅状态
this.subscriptions.subscribe(new HashSet&lt;&gt;(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&lt;TopicPartition, List&lt;ConsumerRecord&lt;K, V&gt;&gt;&gt; pollForFetches(Timer timer) {
// 省略部分代码
// 如果缓存里面有未读取的消息,直接返回这些消息
final Map&lt;TopicPartition, List&lt;ConsumerRecord&lt;K, V&gt;&gt;&gt; records = fetcher.fetchedRecords();
if (!records.isEmpty()) {
return records;
}
// 构造拉取消息请求,并发送
fetcher.sendFetches();
// 省略部分代码
// 发送网络请求拉取消息,等待直到有消息返回或者超时
client.poll(pollTimer, () -&gt; {
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的代码尝试独立分析代码并找到答案。欢迎在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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为这个“足够多”创造了一个专有名词ISRIn 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本身的性能差异、可用性和数据一致性该如何配置欢迎在留言区与我分享讨论。
感谢阅读,如果你觉得这篇文章对你有帮助,也欢迎把它分享给你的朋友。

View 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&lt;BrokerData&gt; {
// ...
private final HashMap&lt;String/* topic */, List&lt;QueueData&gt;&gt; topicQueueTable;
private final HashMap&lt;String/* brokerName */, BrokerData&gt; brokerAddrTable;
private final HashMap&lt;String/* clusterName */, Set&lt;String/* brokerName */&gt;&gt; clusterAddrTable;
private final HashMap&lt;String/* brokerAddr */, BrokerLiveInfo&gt; brokerLiveTable;
private final HashMap&lt;String/* brokerAddr */, List&lt;String&gt;/* Filter Server */&gt; filterServerTable;
// ...
}
```
以上代码中的这5个Map对象保存了集群所有的Broker和主题的路由信息。
topicQueueTable保存的是主题和队列信息其中每个队列信息对应的类QueueData中还保存了brokerName。需要注意的是这个brokerName并不真正是某个Broker的物理地址它对应的一组Broker节点包括一个主节点和若干个从节点。
brokerAddrTable中保存了集群中每个brokerName对应Broker信息每个Broker信息用一个BrokerData对象表示
```
public class BrokerData implements Comparable&lt;BrokerData&gt; {
private String cluster;
private String brokerName;
private HashMap&lt;Long/* brokerId */, String/* broker address */&gt; brokerAddrs;
// ...
}
```
BrokerData中保存了集群名称clusterbrokerName和一个保存Broker物理地址的MapbrokerAddrs它的Key是BrokerIDValue就是这个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() &gt;= 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&lt;String&gt; filterServerList,
final Channel channel) {
RegisterBrokerResult result = new RegisterBrokerResult();
try {
try {
// 加写锁,防止并发修改数据
this.lock.writeLock().lockInterruptibly();
// 更新clusterAddrTable
Set&lt;String&gt; brokerNames = this.clusterAddrTable.get(clusterName);
if (null == brokerNames) {
brokerNames = new HashSet&lt;String&gt;();
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&lt;Long, String&gt;());
this.brokerAddrTable.put(brokerName, brokerData);
}
Map&lt;Long, String&gt; brokerAddrsMap = brokerData.getBrokerAddrs();
// 更新brokerAddrTable中的brokerData
Iterator&lt;Entry&lt;Long, String&gt;&gt; it = brokerAddrsMap.entrySet().iterator();
while (it.hasNext()) {
Entry&lt;Long, String&gt; item = it.next();
if (null != brokerAddr &amp;&amp; brokerAddr.equals(item.getValue()) &amp;&amp; brokerId != item.getKey()) {
it.remove();
}
}
// 如果是新注册的Master Broker或者Broker中的路由信息变了需要更新topicQueueTable
String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
registerFirst = registerFirst || (null == oldAddr);
if (null != topicConfigWrapper
&amp;&amp; MixAll.MASTER_ID == brokerId) {
if (this.isBrokerTopicConfigChanged(brokerAddr, topicConfigWrapper.getDataVersion())
|| registerFirst) {
ConcurrentMap&lt;String, TopicConfig&gt; tcTable =
topicConfigWrapper.getTopicConfigTable();
if (tcTable != null) {
for (Map.Entry&lt;String, TopicConfig&gt; 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(&quot;new broker registered, {} HAServer: {}&quot;, 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(&quot;registerBroker Exception&quot;, 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&lt;QueueData&gt; queueDatas;
private List&lt;BrokerData&gt; 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&lt;String&gt; brokerNameSet = new HashSet&lt;String&gt;();
List&lt;BrokerData&gt; brokerDataList = new LinkedList&lt;BrokerData&gt;();
topicRouteData.setBrokerDatas(brokerDataList);
HashMap&lt;String, List&lt;String&gt;&gt; filterServerMap = new HashMap&lt;String, List&lt;String&gt;&gt;();
topicRouteData.setFilterServerTable(filterServerMap);
try {
try {
// 加读锁
this.lock.readLock().lockInterruptibly();
//先获取主题对应的队列信息
List&lt;QueueData&gt; queueDataList = this.topicQueueTable.get(topic);
if (queueDataList != null) {
// 把队列信息返回值中
topicRouteData.setQueueDatas(queueDataList);
foundQueueData = true;
// 遍历队列找出相关的所有BrokerName
Iterator&lt;QueueData&gt; 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&lt;Long, String&gt;) brokerData
.getBrokerAddrs().clone());
brokerDataList.add(brokerDataClone);
foundBrokerData = true;
for (final String brokerAddr : brokerDataClone.getBrokerAddrs().values()) {
List&lt;String&gt; filterServerList = this.filterServerTable.get(brokerAddr);
filterServerMap.put(brokerAddr, filterServerList);
}
}
}
}
} finally {
// 释放读锁
this.lock.readLock().unlock();
}
} catch (Exception e) {
log.error(&quot;pickupTopicRouteData Exception&quot;, e);
}
log.debug(&quot;pickupTopicRouteData {} {}&quot;, topic, topicRouteData);
if (foundBrokerData &amp;&amp; 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集群中各节点之间不需要互相通信每个节点都可以独立的提供服务。课后请你想一想这种独特的集群架构有什么优势又有什么不足欢迎在评论区留言写下你的想法。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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]每个临时节点对应着一个在线的BrokerBroker启动后会创建一个临时节点代表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() -&gt; DefaultMetadataUpdater#maybeUpdate(long) -&gt; 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(&quot;Sending metadata request {} to node {}&quot;, 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(&quot;Sending topic metadata %s and brokers %s for correlation id %d to client %s&quot;.format(completeTopicMetadata.mkString(&quot;,&quot;),
brokers.mkString(&quot;,&quot;), request.header.correlationId, request.header.clientId))
// 构建Response并发送
sendResponseMaybeThrottle(request, requestThrottleMs =&gt;
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中的元数据保持同步的呢欢迎在留言区写下你的想法。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。

View 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(&quot;myGroup&quot;);
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(&quot;orderId&quot;);
// 去数据库中查询订单号是否存在,如果存在则提交事务;
// 如果不存在可能是本地事务失败了也可能是本地事务还在执行所以返回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 &amp;&amp; null == transactionListener) {
throw new MQClientException(&quot;tranExecutor is null&quot;, null);
}
Validators.checkMessage(msg, this.defaultMQProducer);
SendResult sendResult = null;
// 这里给消息添加了属性,标明这是一个事务消息,也就是半消息
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_TRANSACTION_PREPARED, &quot;true&quot;);
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_PRODUCER_GROUP, this.defaultMQProducer.getProducerGroup());
// 调用发送普通消息的方法,发送这条半消息
try {
sendResult = this.send(msg);
} catch (Exception e) {
throw new MQClientException(&quot;send message Exception&quot;, e);
}
LocalTransactionState localTransactionState = LocalTransactionState.UNKNOW;
Throwable localException = null;
switch (sendResult.getSendStatus()) {
case SEND_OK: {
try {
if (sendResult.getTransactionId() != null) {
msg.putUserProperty(&quot;__transactionId__&quot;, sendResult.getTransactionId());
}
String transactionId = msg.getProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX);
if (null != transactionId &amp;&amp; !&quot;&quot;.equals(transactionId)) {
msg.setTransactionId(transactionId);
}
// 执行本地事务
if (null != localTransactionExecuter) {
localTransactionState = localTransactionExecuter.executeLocalTransactionBranch(msg, arg);
} else if (transactionListener != null) {
log.debug(&quot;Used new transaction API&quot;);
localTransactionState = transactionListener.executeLocalTransaction(msg, arg);
}
if (null == localTransactionState) {
localTransactionState = LocalTransactionState.UNKNOW;
}
if (localTransactionState != LocalTransactionState.COMMIT_MESSAGE) {
log.info(&quot;executeLocalTransactionBranch return {}&quot;, localTransactionState);
log.info(msg.toString());
}
} catch (Throwable e) {
log.info(&quot;executeLocalTransactionBranch exception&quot;, 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(&quot;local transaction execute &quot; + localTransactionState + &quot;, but end broker transaction failed&quot;, 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 &amp;&amp; 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_TOPIC0
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的源代码中把这个事务的实现流程分析出来将我们上面这个时序图进一步细化绘制一个粒度到类和方法调用的时序图。然后请你想一下如果事务进行过程中协调者宕机了那事务又是如何恢复的呢欢迎你在评论区留言写下你的想法。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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上然后在评论区给出链接。在设计过程中如果你有任何问题也欢迎在评论区与我交流。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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就是一段WALWrite 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以外大多数的消息队列都没有采用存储计算分离的设计呢欢迎在评论区留言写下你的想法。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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和RocketMQDledger都是怎么来实现的选举呢
先来说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 StreamsPulsar在做Pulsar Functions其实大家都在不约而同的做同一件事儿就是流计算。
原因是什么呢现有的流计算平台包括Storm、Flink和Spark它们的节点都是无状态的纯计算节点是没有数据存储能力的。所以现在的流计算平台它很难做大量数据的聚合并且在数据可靠性保证、数据一致性、故障恢复等方面也做得不太好。
而消息队列正好相反它很好地保证了数据的可靠性、一致性但是Broker只具备存储能力没有计算的功能数据流进去什么样流出来还是什么样。同样是处理实时数据流的系统一个只能计算不能存储一个只能存储不能计算那未来如果出现一个新的系统既能计算也能存储如果还能有不错的性能是不是就会把现在的消息队列和流计算平台都给替代了这是很有可能的。
对于一个“带计算功能的消息队列”来说,采用存储计算分离的设计,计算节点负责流计算,存储节点负责存储消息,这个设计就非常和谐了。
到这里,我们课程的第二个模块–进阶篇,也就全部结束了。进阶篇的中讲解知识有一定的难度,特别是后半部分的几节源码分析课,从评论区同学们的留言中,我也能感受到,有些同学学习起来会有些吃力。
我给同学们的建议是,除了上课时听音频和读文稿之外,课后还要自己去把源代码下载下来,每一个流程从头到尾读一遍源码,最好是打开单步调试模式,一步一步地跟踪一下执行过程。读完源码之后,还要把类图、流程图或者时序图画出来,只有这样才能真正理解实现过程。
从下节课开始我们的课程就进入最后一个模块案例篇。在这个模块中我会带你一起动手来写代码运用我们在课程中所学的知识来做一些实践的案例。首先我会带你一起做一个消息队列和流计算的案例你可以来体会一下现在的流计算平台它是什么样的。然后我们还会用进阶篇中所学到的知识来一起实现一个类似Dubbo的RPC框架。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。

View 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 (
&quot;encoding/binary&quot;
&quot;fmt&quot;
&quot;io&quot;
&quot;net&quot;
&quot;sync&quot;
&quot;time&quot;
)
var zRecvCount = uint32(0) // 张大爷听到了多少句话
var lRecvCount = uint32(0) // 李大爷听到了多少句话
var total = uint32(100000) // 总共需要遇见多少次
var z0 = &quot;吃了没,您吶?&quot;
var z3 = &quot;嗨!吃饱了溜溜弯儿。&quot;
var z5 = &quot;回头去给老太太请安!&quot;
var l1 = &quot;刚吃。&quot;
var l2 = &quot;您这,嘛去?&quot;
var l4 = &quot;有空家里坐坐啊。&quot;
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(&quot;发送: &quot; + r.Payload)
}
// 接收数据反序列化成RequestResponse
func readFrom(conn *net.TCPConn) (*RequestResponse, error) {
ret := &amp;RequestResponse{}
buf := make([]byte, 4)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf(&quot;读长度故障:%s&quot;, err.Error())
}
length := binary.BigEndian.Uint32(buf)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf(&quot;读Serial故障%s&quot;, 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(&quot;读Payload故障%s&quot;, err.Error())
}
ret.Payload = string(payloadBytes)
return ret, nil
}
// 张大爷的耳朵
func zhangDaYeListen(conn *net.TCPConn, wg *sync.WaitGroup) {
defer wg.Done()
for zRecvCount &lt; total*3 {
r, err := readFrom(conn)
if err != nil {
fmt.Println(err.Error())
break
}
// fmt.Println(&quot;张大爷收到:&quot; + r.Payload)
if r.Payload == l2 { // 如果收到:您这,嘛去?
go writeTo(&amp;RequestResponse{r.Serial, z3}, conn, &amp;zhangWriteLock) // 回复:嗨!吃饱了溜溜弯儿。
} else if r.Payload == l4 { // 如果收到:有空家里坐坐啊。
go writeTo(&amp;RequestResponse{r.Serial, z5}, conn, &amp;zhangWriteLock) // 回复:回头去给老太太请安!
} else if r.Payload == l1 { // 如果收到:刚吃。
// 不用回复
} else {
fmt.Println(&quot;张大爷听不懂:&quot; + r.Payload)
break
}
zRecvCount++
}
}
// 张大爷的嘴
func zhangDaYeSay(conn *net.TCPConn) {
nextSerial := uint32(0)
for i := uint32(0); i &lt; total; i++ {
writeTo(&amp;RequestResponse{nextSerial, z0}, conn, &amp;zhangWriteLock)
nextSerial++
}
}
// 李大爷的耳朵,实现是和张大爷类似的
func liDaYeListen(conn *net.TCPConn, wg *sync.WaitGroup) {
defer wg.Done()
for lRecvCount &lt; total*3 {
r, err := readFrom(conn)
if err != nil {
fmt.Println(err.Error())
break
}
// fmt.Println(&quot;李大爷收到:&quot; + r.Payload)
if r.Payload == z0 { // 如果收到:吃了没,您吶?
writeTo(&amp;RequestResponse{r.Serial, l1}, conn, &amp;liWriteLock) // 回复:刚吃。
} else if r.Payload == z3 {
// do nothing
} else if r.Payload == z5 {
// do nothing
} else {
fmt.Println(&quot;李大爷听不懂:&quot; + r.Payload)
break
}
lRecvCount++
}
}
// 李大爷的嘴
func liDaYeSay(conn *net.TCPConn) {
nextSerial := uint32(0)
for i := uint32(0); i &lt; total; i++ {
writeTo(&amp;RequestResponse{nextSerial, l2}, conn, &amp;liWriteLock)
nextSerial++
writeTo(&amp;RequestResponse{nextSerial, l4}, conn, &amp;liWriteLock)
nextSerial++
}
}
func startServer(wg *sync.WaitGroup) {
tcpAddr, _ := net.ResolveTCPAddr(&quot;tcp&quot;, &quot;127.0.0.1:9999&quot;)
tcpListener, _ := net.ListenTCP(&quot;tcp&quot;, tcpAddr)
defer tcpListener.Close()
fmt.Println(&quot;张大爷在胡同口等着 ...&quot;)
for {
conn, err := tcpListener.AcceptTCP()
if err != nil {
fmt.Println(err)
break
}
fmt.Println(&quot;碰见一个李大爷:&quot; + conn.RemoteAddr().String())
go zhangDaYeListen(conn, wg)
go zhangDaYeSay(conn)
}
}
func startClient(wg *sync.WaitGroup) *net.TCPConn {
var tcpAddr *net.TCPAddr
tcpAddr, _ = net.ResolveTCPAddr(&quot;tcp&quot;, &quot;127.0.0.1:9999&quot;)
conn, _ := net.DialTCP(&quot;tcp&quot;, nil, tcpAddr)
go liDaYeListen(conn, wg)
go liDaYeSay(conn)
return conn
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go startServer(&amp;wg)
time.Sleep(time.Second)
conn := startClient(&amp;wg)
t1 := time.Now()
wg.Wait()
elapsed := time.Since(t1)
conn.Close()
fmt.Println(&quot;耗时: &quot;, 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万次**考虑到数据量的大小,这里面仍然有非常大的优化空间。**
请你在充分理解这段代码之后,想一想,还有哪些地方是可以优化的,然后一定要动手把代码改出来,并运行,验证一下你的改进是否真的达到了效果。欢迎你在留言区提出你的改进意见。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。