2022-03-06_星期日

消息队列高手课

RocketMQ 和 Kafka 事务适用场景不同

  • RocketMQ 的事务适用于解决本地事务和发消息的数据一致性问题
  • Kafka 的事务则是用于实现它的 Exactly Once 机制,应用于实时计算的场景中

RocketMQ 和 Kafka 事务实现方式不同

  • RocketMQ 是把这些消息暂存在一个特殊的队列中,待事务提交后再移动到业务队列中
  • 而 Kafka 直接把消息放到对应的业务分区中,配合客户端过滤来暂时屏蔽进行中的事务消息。

RocketMQ 并没有把半消息保存到消息中客户端指定的那个队列中,而是记录了原始的主题队列后,把这个半消息保存在了一个特殊的内部主题 RMQ_SYS_TRANS_HALF_TOPIC 中,使用的队列号固定为 0。这个主题和队列对消费者是不可见的,所以里面的消息永远不会被消费。这样,就保证了在事务提交成功之前,这个半消息对消费者来说是消费不到的。

RocketMQ 是如何进行事务反查的:

在 Broker 的 TransactionalMessageCheckService 服务中启动了一个定时器,定时从半消息队列中读出所有待反查的半消息,针对每个需要反查的半消息,Broker 会给对应的 Producer 发一个要求执行事务状态反查的 RPC 请求

RocketMQ 回滚或提交事务:

提交或者回滚事务实现的逻辑是差不多的:

  • 首先把半消息标记为已处理
  • 如果是提交事务,那就把半消息从半消息队列中复制到这个消息真正的主题和队列中去
  • 如果要回滚事务,这一步什么都不需要做,最后结束这个事务

Kafka 的事务解决的问题和 RocketMQ 是不太一样的:

  • RocketMQ 中的事务,它解决的问题是,确保执行本地事务和发消息这两个操作,要么都成功,要么都失败。并且,RocketMQ 增加了一个事务反查的机制,来尽量提高事务执行的成功率和数据一致性
  • Kafka 中的事务,它解决的问题是,确保在一个事务中发送的多条消息,要么都成功,要么都失败。Kafka 是没有事务反查机制的。
    • Kafka 的这种事务机制,单独来使用的场景不多。更多的情况下被用来配合 Kafka 的幂等机制来实现 Kafka 的 Exactly Once 语义
    • kafka 事物解决的是,在流计算中,用 Kafka 作为数据源,并且将计算结果保存到 Kafka 这种场景下,数据从 Kafka 的某个主题中消费,在计算集群中计算,再把计算结果保存在 Kafka 的其他主题中。这样的过程中,保证每条消息都被恰好计算一次,确保计算结果正确
    • 20220306111326.png

包括 Kafka 在内的几个常见的开源消息队列,消费者再从 Broker 拉取消息进行消费时,都只能做到 At Least Once,也就是至少一次,保证消息不丢,但有可能会重复。

Kafka 事务的实现流程:

20220306110208.png

  1. 当我们开启事务的时候,生产者会给协调者发一个请求来开启事务,协调者在事务日志中记录下事务 ID
  2. 生产者在发送消息之前,还要给协调者发送请求,告知发送的消息属于哪个主题和分区,这个信息也会被协调者记录在事务日志中
  3. 生产者就可以像发送普通消息一样来发送事务消息
    • 这里和 RocketMQ 不同的是:
      • RocketMQ 选择把未提交的事务消息保存在特殊的队列中
      • Kafka 在处理未提交的事务消息时,和普通消息是一样的,直接发给 Broker,保存在这些消息对应的分区中,Kafka 会在客户端的消费者中,暂时过滤未提交的事务消息。
  4. 消息发送完成后,生产者给协调者发送提交或回滚事务的请求,由协调者来开始两阶段提交,完成事务
    1. 第一阶段,协调者把事务的状态设置为“预提交”,并写入事务日志。到这里,实际上事务已经成功了,无论接下来发生什么情况,事务最终都会被提交
    2. 第二阶段,协调者在事务相关的所有分区中,都会写一条“事务结束”的特殊消息,当 Kafka 的消费者,也就是客户端,读到这个事务结束的特殊消息之后,它就可以把之前暂时过滤的那些未提交的事务消息,放行给业务代码进行消费了
  5. 最后,协调者记录最后一条事务日志,标识这个事务已经结束了。

kafka 事务的实现流程

20220306110238.png

后端技术面试 38 讲

负载均衡是互联网系统架构中必不可少的一个技术。通过负载均衡,可以将高并发的用户请求分发到多台应用服务器组成的一个服务器集群上,利用更多的服务器资源处理高并发下的计算压力。

HTTP 重定向负载均衡

20220306112859.png

一个互联网系统通常只将负载均衡服务器的 IP 地址对外暴露,供用户访问,而应用服务器则只是用内网 IP,外部访问者无法直接连接应用服务器。

DNS 负载均衡

20220306112933.png

大型网互联网应用需要两次负载均衡:

  • 一次通过 DNS 负载均衡,用户请求访问数据中心负载均衡服务器集群的某台机器
  • 然后这台负载均衡服务器再进行一次负载均衡,将用户请求分发到应用服务器集群的某台服务器上

反向代理负载均衡

20220306113035.png 反向代理服务器是工作在 HTTP 协议层之上的,所以它代理的也是 HTTP 的请求和响应。作为互联网应用层的一个协议,HTTP 协议相对说来比较重,效率比较低,所以反向代理负载均衡通常用在小规模的互联网系统上,只有几台或者十几台服务器的规模。

IP 负载均衡

20220306113105.png 它依然有一个缺陷,不管是请求还是响应的数据包,都要通过负载均衡服务器进行 IP 地址转换,才能够正确地把请求数据分发到应用服务器,或者正确地将响应数据包发送到用户端程序。请求的数据通常比较小,一个 URL 或者是一个简单的表单,但是响应的数据不管是 HTML 还是图片,或者是 JS、CSS 这样的资源文件通常都会比较大,因此负载均衡服务器会成为响应数据的流量瓶颈

数据链路层负载均衡

20220306113202.png

链路层负载均衡避免响应数据再经过负载均衡服务器,因而可以承受较大的数据传输压力,所以,目前大型互联网应用基本都使用链路层负载均衡

Linux 上实现 IP 负载均衡和链路层负载均衡的技术是 LVS,目前 LVS 的功能已经集成到 Linux 中了,通过 Linux 可以直接配置实现这两种负载均衡。

Rust 权威指南

Send trait

只有实现了 Send trait 的类型才可以安全地在线程间转移所有权。

除了 Rc<T> 等极少数的类型,几乎所有的 Rust 类型都实现了 Send trait

  • 如果你将克隆后的 Rc<T> 值的所有权转移到了另外一个线程中,那么两个线程就有可能同时更新引用计数值并进而导致计数错误。因此,Rc<T> 只被设计在单线程场景中使用,它也无须为线程安全付出额外的性能开销。

任何完全由 Send 类型组成的复合类型都会被自动标记为 Send。

Sync trait

只有实现了 Sync trait 的类型才可以安全地被多个线程引用。换句话说,对于任何类型 T,如果&T(也就是 T 的引用)满足约束 Send,那么 T 就是满足 Sync 的。这意味着 T 的引用能够被安全地传递至另外的线程中。与 Send 类似,所有原生类型都满足 Sync 约束

完全由满足 Sync 的类型组成的复合类型也都会被自动识别为满足 Sync 的类型。

当你构建的自定义并发类型包含了没有实现 Send 或 Sync 的类型时,你必须要非常谨慎地确保设计能够满足线程间的安全性要求。

只要我们的代码能够顺利通过编译,你就可以相信它能够正确地运行在多线程环境中,而不会出现其他语言中常见的那些难以解决的 bug。

面向对象编程(Object-Oriented Programming,OOP)是一种程序建模的方法。对象这个概念最初来源于 20 世纪 60 年代的 Simula 语言。

我们认为面向对象的语言通常都包含以下这些特性:命名对象、封装及继承。

命名对象

面向对象的程序由对象组成。对象包装了数据和操作这些数据的流程。这些流程通常被称作方法或操作。——《设计模式:可复用面向对象软件的基础》

  • 基于这个定义,Rust 是面向对象的:结构体和枚举包含数据,而 impl 块则提供了可用于结构体和枚举的方法。

封装

另外一个常常伴随着面向对象编程的思想便是封装 (encapsulation):调用对象的外部代码无法直接访问对象内部的实现细节,而唯一可以与对象进行交互的方法便是通过它公开的接口。使用对象的代码不应当深入对象的内部去改变数据或行为。封装使得开发者在修改或重构对象的内部实现时无须改变调用这个对象的外部代码。

如果封装是考察一门语言是否能够被算作面向对象语言的必要条件,那么 Rust 就是满足要求的。

我们可以使用 pub 关键字来决定代码中哪些模块、类型、函数和方法是公开的,而默认情况下其他所有内容都是私有的。

继承

继承(inheritance)机制使得对象可以沿用另一个对象的数据与行为,而无须重复定义代码。

如果一门语言必须拥有继承才能算作面向对象语言,那么 Rust 就不是。你无法在 Rust 中定义一个继承父结构体字段和方法实现的子结构体。

选择使用继承有两个主要原因:

  1. 实现代码复用:你可以为某个类型实现某种行为,并接着通过继承来让另一个类型直接复用这一实现
  2. 另外一个使用继承的原因与类型系统有关:希望子类型能够被应用在一个需要父类型的地方。
    • 这也就是所谓的多态 (polymorphism):如果一些对象具有某些共同的特性,那么这些对象就可以在运行时相互替换使用

你可以在 Rust 中使用泛型来构建不同类型的抽象,并使用 trait 约束来决定类型必须提供的具体特性。

  • 这一技术有时也被称作限定参数化多态(bounded parametric polymorphism)

trait 对象 能够指向实现了指定 trait 的类型实例,以及一个用于在运行时查找 trait 方法的表。我们可以通过选用一种指针,例如 & 引用或 Box<T> 智能指针等,并添加 dyn 关键字与指定相关 trait 来创建 trait 对象。

无论我们在哪里使用 trait 对象,Rust 类型系统都会在编译时确保出现在相应位置上的值实现 trait 对象指定的 trait。

Rust 有意避免将结构体和枚举称为“对象”,以便与其他语言中的对象概念区分开来。

  • 对于结构体或枚举而言,它们字段中的数据与 impl 块中的行为是分开的
  • 在其他语言中,数据和行为往往被组合在名为对象的概念中

trait 对象则有些类似于其他语言中的对象,因为它也在某种程度上组合了数据与行为。但 trait 对象与传统对象不同的地方在于,我们无法为 trait 对象添加数据。由于 trait 对象被专门用于抽象某些共有行为,所以它没有其他语言中的对象那么通用。

trait 更新其他语言中的接口(interface)的概念

动态派发下的编译器无法在编译过程中确定你调用的究竟是哪一个方法。在进行动态派发的场景中,编译器会生成一些额外的代码以便在运行时找出我们希望调用的方法。

Rust 必然会在我们使用 trait 对象时执行动态派发。因为编译器无法知晓所有能够用于 trait 对象的具体类型,所以它无法在编译时确定需要调用哪个类型的哪个具体方法。不过,Rust 会在运行时通过 trait 对象内部的指针去定位具体调用哪个方法

需要注意的是,你只能把满足对象安全(object-safe)的 trait 转换为 trait 对象。

如果一个trait中定义的所有方法满足下面两条规则,那么这个trait就是对象安全的:

  • 方法的返回类型不是 Self
  • 方法中不包含任何泛型参数

关键字 Self 是一个别名,它指向了实现当前 trait 或方法的具体类型。

标准库中的 Clone trait 就是一个不符合对象安全的例子:

1
2
3
pub trait Clone {
    fn clone(&self) -> Self;
}

Stackoverflow

If an object can be used safely by two threads at the same time (Sync) then it can be used safely by two threads at different times (Send). Hence, Sync usually implies Send.

updatedupdated2022-03-092022-03-09