28 答疑解惑(二):我的100元哪儿去了? 你好,我是李玥。

今天这节课,是我们的“消息队列高手课第二阶段进阶篇的最后一节课,照例,我们在每一阶段的最后,安排一节课进行热点问题的答疑,针对同学们遇到的一些共同的问题,统一来进行详细的解答。

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 次转账:

  • 将账户余额设为 200 元;
  • 将账户余额设为 300 元;

经过两次转账后,账户余额应该是 300 元。

再次注意,我们的题目中说的是在并发调用的情况下。

按照时间顺序,就有可能会出现下面这种情况:

  • t0 时刻客户端发送请求:将账户余额设为 200 元。
  • t1 时刻服务端收到请求,账户余额由 100 元变为 200 元,然后服务端发出给客户端操作成功的响应,但是这个响应在网络传输过程中丢失了。
  • t2 时刻客户端发送请求:将账户余额设为 300 元。
  • t3 时刻服务端收到请求,账户余额由 200 元变为 300 元,然后服务端发出给客户端操作成功的响应。
  • t4 时刻客户端:收到“将账户余额设为 300 元”这个请求的成功响应,本次调用成功。
  • t5 时刻客户端由于没收到“将账户余额设为 300 元”这个请求的成功响应,重新发送请求:将账户余额设为 200 元。
  • t6 时刻服务端收到请求,账户余额由 300 元变为 200 元,然后服务端给客户端发出操作成功的响应。
  • t7 时刻客户端收到响应,本次重试调用成功。

结果,账户余额错误地变成了 200 元。

同学,请把我的 100 块钱还给我!通过这个题,我们可以总结出来,一个操作是否幂等,还跟调用顺序有关系,在线性调用情况下,具备幂等性的操作,在并发调用时,就不一定具备幂等性了。如果你在设计系统的时候,没有注意到这个细节,那系统就有可能出现我们上面这个例子中的错误,在生产系中,这是非常危险的。

2. Kafka 和 RocketMQ 如何通过选举来产生新的 Leader?

在《[22 Kafka 和 RocketMQ 的消息复制实现的差异点在哪?]》这节课中,我给你讲了这两个消息队列是如何通过复制来保证数据一致性的。当 Broker 节点发生故障时,它们都是通过选举机制,来选出新的 Leader 来继续提供服务。当时限于篇幅,我们并没有深入进去来讲选举的实现原理。那 Kafka 和 RocketMQ(Dledger)都是怎么来实现的选举呢?

先来说 Kafka 的选举,因为 Kafka 的选举实现比较简单。严格地说,Kafka 分区的 Leader 并不是选举出来的,而是 Controller 指定的。Kafka 使用 ZooKeeper 来监控每个分区的多个副本,如果发现某个分区的主节点宕机了,Controller 会收到 ZooKeeper 的通知,这个时候,Controller 会从 ISR 节点中选择一个节点,指定为新的 Leader。

在 Kafka 中 Controller 本身也是通过 ZooKeeper 选举产生的。接下来我要讲的,Kafka Controller 利用 ZooKeeper 选举的方法,你一定要记住并学会,因为这种方法非常简单实用,并且适用性非常广泛,在设计很多分布式系统中都可以用到。

这种选举方法严格来说也不是真正的“选举”,而是一种抢占模式。实现也很简单,每个 Broker 在启动后,都会尝试在 ZooKeeper 中创建同一个临时节点:/controller,并把自身的信息写入到这个节点中。由于 ZooKeeper 它是一个可以保证数据一致性的分布式存储,所以,集群中只会有一个 Broker 抢到这个临时节点,那它就是 Leader 节点。其他没抢到 Leader 的节点,会 Watch 这个临时节点,如果当前的 Leader 节点宕机,所有其他节点都会收到通知,它们会开始新一轮的抢 Leader 游戏。

这就好比有个玉玺,也就是皇帝用的那个上面雕着龙纹的大印章,谁都可以抢这个玉玺,谁抢到谁做皇帝,其他没抢到的人也不甘心,时刻盯着这个玉玺,一旦现在这个皇帝驾崩了,所有人一哄而上,再“抢”出一个新皇帝。这个算法虽然不怎么优雅,但胜在简单直接,并且快速公平,是非常不错的选举方法。

但是这个算法它依赖一个“玉玺”,也就是一个可以保证数据一致性的分布式存储,这个分布式存储不一定非得是 ZooKeeper,可以是 Redis,可以是 MySQL,也可以是 HDFS,只要是可以保证数据一致性的分布式存储,都可以充当这个“玉玺”,所以这个选举方法的适用场景也是非常广泛的。

再来说 RocketMQ/Dledger 的选举,在 Dledger 中的 Leader 真的是通过投票选举出来的,所以它不需要借助于任何外部的系统,仅靠集群的节点间投票来达成一致,选举出 Leader。一般这种自我选举的算法,为了保证数据一致性、避免集群分裂,算法设计的都非常非常复杂,我们不太可能自己来实现这样一个选举算法,所以我在这里不展开讲。Dledger 采用的是Raft 一致性算法,感兴趣的同学可以读一下这篇经典的论文

像 Raft 这种自我选举的算法,相比于上面介绍的抢占式选举,优点是不需要借助外部系统,完全可以实现自我选举。缺点也非常明显,就是算法实在是太复杂了,非常难实现。并且,往往集群中的节点要通过多轮投票才能达成一致,这个选举过程是比较慢的,一次选举耗时几秒甚至几十秒都有可能。

我们日常在设计一些分布式的业务系统时,如果需要选举 Leader,还是采用 Kafka 的这种“抢玉玺”的方法更加简单实用。

3. 为什么说 Pulsar 存储计算分离的架构是未来消息队列的发展方向?

在上节课《[27 Pulsar 的存储计算分离设计:全新的消息队列设计思路]》中,我给你留的思考题是:为什么除了 Pulsar 以外,大多数的消息队列都没有采用存储计算分离的设计呢?这个问题其实是一个发散性的问题,并没有什么标准答案。因为,本来架构设计就是在权衡各种利弊,做出取舍和选择,并没有绝对的对错之分。

很多同学在课后的留言中,都已经给出了自己的思路和想法,而且有些同学的想法和我个人的观点不谋而合。在这里我也和你分享一下我对这个问题的理解和看法。

早期的消息队列,主要被用来在系统之间异步交换数据,大部分消息队列的存储能力都比较弱,不支持消息持久化,不提倡在消息队列中堆积大量的消息,这个时期的消息队列,本质上是一个数据的管道。

现代的消息队列,功能上看似没有太多变化,依然是收发消息,但是用途更加广泛,数据被持久化到磁盘中,大多数消息队列具备了强大的消息堆积能力,只要磁盘空间足够,可以存储无限量的消息,而且不会影响生产和消费的性能。这些消息队列,本质上已经演变成为分布式的存储系统。

理解了这一点,你就会明白,为什么大部分消息队列产品,都不使用存储计算分离的设计。为一个“分布式存储系统”做存储计算分离,计算节点就没什么业务逻辑需要计算的了。而且,消息队列又不像其他的业务系统,可以直接使用一些成熟的分布式存储系统来存储消息,因为性能达不到要求。分离后的存储节点承担了之前绝大部分功能,并且增加了系统的复杂度,还降低了性能,显然是不划算的。

那为什么 Pulsar 还要采用这种存储和计算分离的设计呢?我们还是需要用发展的眼光看问题。我在上节课说过,Pulsar 的这种架构,很可能代表了未来消息队列的发展方向。为什么这么说呢?你可以看一下现在各大消息队列的 Roadmap(发展路线图),Kafka 在做 Kafka Streams,Pulsar 在做 Pulsar Functions,其实大家都在不约而同的做同一件事儿,就是流计算。

原因是什么呢?现有的流计算平台,包括 Storm、Flink 和 Spark,它们的节点都是无状态的纯计算节点,是没有数据存储能力的。所以,现在的流计算平台,它很难做大量数据的聚合,并且在数据可靠性保证、数据一致性、故障恢复等方面,也做得不太好。

而消息队列正好相反,它很好地保证了数据的可靠性、一致性,但是 Broker 只具备存储能力,没有计算的功能,数据流进去什么样,流出来还是什么样。同样是处理实时数据流的系统,一个只能计算不能存储,一个只能存储不能计算,那未来如果出现一个新的系统,既能计算也能存储,如果还能有不错的性能,是不是就会把现在的消息队列和流计算平台都给替代了?这是很有可能的。

对于一个“带计算功能的消息队列”来说,采用存储计算分离的设计,计算节点负责流计算,存储节点负责存储消息,这个设计就非常和谐了。

到这里,我们课程的第二个模块–进阶篇,也就全部结束了。进阶篇的中讲解知识有一定的难度,特别是后半部分的几节源码分析课,从评论区同学们的留言中,我也能感受到,有些同学学习起来会有些吃力。

我给同学们的建议是,除了上课时听音频和读文稿之外,课后还要自己去把源代码下载下来,每一个流程从头到尾读一遍源码,最好是打开单步调试模式,一步一步地跟踪一下执行过程。读完源码之后,还要把类图、流程图或者时序图画出来,只有这样才能真正理解实现过程。

从下节课开始,我们的课程就进入最后一个模块:案例篇。在这个模块中,我会带你一起动手来写代码,运用我们在课程中所学的知识,来做一些实践的案例。首先我会带你一起做一个消息队列和流计算的案例,你可以来体会一下现在的流计算平台它是什么样的。然后,我们还会用进阶篇中所学到的知识,来一起实现一个类似 Dubbo 的 RPC 框架。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e6%b6%88%e6%81%af%e9%98%9f%e5%88%97%e9%ab%98%e6%89%8b%e8%af%be/28%20%20%e7%ad%94%e7%96%91%e8%a7%a3%e6%83%91%ef%bc%88%e4%ba%8c%ef%bc%89%ef%bc%9a%e6%88%91%e7%9a%84100%e5%85%83%e5%93%aa%e5%84%bf%e5%8e%bb%e4%ba%86%ef%bc%9f.md