专栏名称: 高可用架构
高可用架构公众号。
目录
相关文章推荐
架构师之路  ·  美团的产品经理,麻烦您进来看一下... ·  5 天前  
架构师之路  ·  数据库架构,1个github宝藏项目,3个小 ... ·  4 天前  
架构师之路  ·  为什么程序员的社会地位不高? ·  3 天前  
51好读  ›  专栏  ›  高可用架构

Redis作者又一大作:Disque分布式内存队列(一)

高可用架构  · 公众号  · 架构  · 2016-11-25 09:34

正文

导读:Disque Redis 之父 Salvatore Sanfilippo 新开源的一个分布式内存消息队列。本文是作者编写的该软件设计和使用的说明。


Disque 是一个正在进行的实验性的,分布式内存消息代理。 它的目标是捕获“ Redis 作为消息队列”的用例,通常使用阻塞列表操作来实现。将其移植到 adhoc,自包含的,可扩展的,容错的设计,但在简单性,性能和实现方面仍然类似于 Redis, 并使用 C 语言实现非阻塞服务器。


目前( 2016 年 1 月 2 日)该项目处于 release candidate 状态。 我们现在鼓励人们开始评估该版本,报告 bug 和使用经验。



提醒:目前是测试版代码,可能不适合生产环境使用。 尽管 API 已经稳定,但是在下一个 Release candidate 会有细节上的改动。这是全新代码,所以请小心使用!(高可用架构小编:但如果想学习及研究消息队列这是一个好去处)


什么是消息队列


(高可用架构小编提醒:熟悉消息队列的老司机请跳过此部分)


你知道人们如何使用短信来沟通,我可以给我妻子发信息“班顺路买一斤包子带回来,如果看到卖西瓜的,买一个”,她也许会回答“好,消息收到,我会回家买1个”。


消息队列与短信类似,但是作用于计算机程序。例如,当用户订阅消息时, Web 应用可以发送消息给处理发送电子邮件的应用,“请发送确认电子邮件到 [email protected]”。


诸如 Disque 的消息系统允许进程通过不同消息队列进行通信。因此,进程可以向具有给定名称的队列发送消息,并且只有从该队列获取消息的进程才会响应这些消息。此外,多个进程可以侦听同一队列中的消息,并且多个进程可以向同一队列发送消息。


消息队列的重点是能够保证到达,使得即使在失败时也能最终传递消息。 因此,即使在理论上实现消息队列是很容易的,实际写一个非常鲁棒和可扩展的消息队列也很困难。


功能细节介绍


Disque 是一个分布式容错消息代理,可以作为想要交换消息的进程之间的中间层。


消息生产者投放消息。由于消息队列通常用于处理延迟的任务, Disque 经常在 API 和文档中使用术语“任务”,然而任务实际上只是以字符串形式的消息,因此 Disque 可以用于其他用例。在本文档中,“任务”和“消息”以可互换的方式使用。


具有生产者 - 消费者模型的队列在业界相当普遍的,所以细节很重要。 Disque 的一些细节如下:


Disque 是同步复制的任务队列。默认情况下,添加新任务时,会在客户端收到确认之前将其复制到 W 个节点。 W-1 个节点可能会失败,但仍会传递消息。


Disque 同时支持至少一次投递语义和最多一次投递语义。至少一次投递语义在设计和实现中最为费事,而最多一次投递的语义是设置消息重试时间为 0(这意味着不再将消息重新排队),并且消息的复制因子为 1(不是严格需要的,但是如果它将被最多传送一次,则具有多个消息副本是没有意义的),因此实现相对容易。你可以在同一队列和节点中使用至少一次和最多一次任务,因为这是每个消息上设置的属性。


至少一次投递被设计为尽量接近单次投递,即使在某些类型的故障期间也是如此。这意味着,尽管 Disque 只能保证等于或大于 1 的消息投递,但是尽可能避免多次投递。


Disque 是一个分布式系统,其中所有节点都具有相同的角色(又称为多主节点)。生产者和消费者可以使用他们喜欢的任何节点,并且不需要同一队列的生产者和消费者连接到同一节点。节点将根据负载和客户端请求自动交换消息。


Disque 是高可用的(它是一个最终一致的 AP 系统):哪怕一个单节点可用,生产者和消费者就可以交换消息。


Disque 支持异步命令,异步命令对客户端来说是低延迟操作,然而提供较低的投递保证。例如,生产者可以将复制因子为 3 的任务添加到队列中,但是可能想要复制完成之前结束调用。节点将尽可能在后台复制消息。


在过了消息的重试时间之后, Disque 会自动将未确认的消息由消费者重新排队。如果消息未被处理,则不需要重新排队消息。


Disque 使用显式确认,以便消费者以信号形式通知消息已经投递(使用其他的术语来说就是通知任务已经处理)。


Disque 队列仅尽力保证投递顺序。每个队列基于任务创建时间对任务进行排序,任务创建时间是使用创建消息节点的时钟(加上在同一毫秒中创建的消息的增量计数器)获得的,因此在同一节点中创建的消息通常以创建顺序交付。然而这不是因果顺序,因为在一些情况下不保证正确顺序:当消息因为没有被投递而重新发布时,节点本地时钟漂移,并且消息被移动到其他节点以用于负载平衡和联合(在这种情况下您以具有不同节点的任务发起的队列结束与不同的壁钟)时无法保证顺序。然而,所有这也意味着通常消息有序传递的,而是按时间顺序。


注意,由于 Disque 不提供严格的 FIFO 语义,从技术上讲,它不应该被称为消息队列,而应该被称为消息代理。 然而,我 认为在 IT 行业里,消息队列通常用于可能或不能在所有情况下保证消息投递的通用代理。 鉴于我们非常清楚这一语义,我认为 Disque 可以叫做消息队列。


Disque 为用户提供了使用三个时间相关参数和一个复制参数来对每个任务做细粒度控制。 对于每个任务,用户可以控制:


  1. 复制因子(有多少节点有一个副本)。

  2. 延迟时间(在将消息放入队列之前,Disque将等待投递的最小时间)。

  3. 重试时间(自从排队开始并且没有对确认任务任务重新排队传送之前应该消耗多长时间)。

  4. 到期时间(任务被删除所需的时间,无论是否已成功传送,例如是否已确认)。


最后,Disque 支持磁盘持久化,默认情况下不启用,但在单个数据中心初始化和重新启动期间可以使用。

其他特性如下:


  1. 阻塞队列。

  2. 有关队列活动的统计信息。

  3. 队列和任务的无状态迭代器。

  4. 控制单个任务可见性的命令。

  5. 容易调整集群的大小(添加节点易如反掌)。

  6. 在不丢失任务副本的情况下适度删除节点。


队列的 ACK 和重试


实现 Disque 的至少一次投递(at-least-once)语义是为了避免在某些故障期间的多次投递。它不能保证不会发生多次投递。


然而,存在许多至少一次投递的情况下,其中重复投递是可接受的(或可以明确处理的),但消费者不希望如此。一个简单的例子是向用户发送重复电子邮件(用户收到重复的电子邮件不是很大的问题,但应该尽可能避免)相比执行代价很高的幂等操作更容易接受。

为了尽可能避免多次投递,Disque 使用客户端 ACK。当消费者正确处理消息时,它应该通知 Disque。 ACK 被复制到多个节点,并且一旦系统认为消息不可能在集群中的更多节点(ACK 指向的)具有活动副本,则被垃圾收集。在内存压力下或在某些故障情况下,ACK 最终被丢弃。更明确的情况如下:


  1. 任务被复制到多个节点,但通常只在单个节点中排队。 在内存中的任务和将其排队等待传送之间有区别。

  2. 具有消息副本的节点,如果在没有获得消息的ACK的情况下已经过去了一定的时间,将对其进行重排队。 节点将尽力避免消息多次重新排队。

  3. ACK 被复制,并在集群中进行垃圾回收,以便已处理的消息被清理(如果没有故障或网络分区,这种情况很快发生)。


例如,如果在任务被消费者确认的时间内,具有任务副本的节点被分区,则很可能当它返回时(在合理的时间量中,即在达到重试时间之前) ),它将收到ACK 并且将避免对消息重新排队。类似地,任务在分区期间内被确认为仅仅单个可用节点,并且当分区修复ACK时,传播到可能具有消息副本的其他节点。

因此,ACK 仅是投递的证明,其被复制并保留一段时间,以便尽量减少多次投递。

如前所述,为了控制复制和重试,Disque 任务具有以下相关属性:副本数,延迟,重试和过期。

如果一个任务的重试时间设置为 0,它将被排队一次(在这种情况下大于1的复制因子是无用的,并且作为错误发送给用户),因此它将被投递一次或永远不会投递。虽然任务可以持久化磁盘上,但队列不是如此,因此在崩溃后节点重新启动时,无论持久化配置是什么,这种行为(重试时间为0)都得到保证。但是,当 sysadmin 手动重新启动节点时,例如升级时,队列会保存并在启动时重新加载,因为在这种情况下存储/加载操作是原子操作,并且没有竞态条件(不可能是任务已传送到客户端,并在磁盘上持久保存,同时排队)。


快速确认


Disque 支持通过 FASTACK 命令更快地确认处理的消息。从节点之间交换的消息的角度来看,确认消息是非常昂贵的,以下是在正常确认期间流程:


  1. 客户端向一个节点发送 ACKJOB。

  2. 该节点向其认为有副本的每个节点发送一个 SETACK 消息。

  3. SETACK 的接收器用 GOTACK 进行确认。

  4. 该节点最终向所有节点发送 DELJOB。


注意:实际的垃圾收集在失败的情况下更复杂,将在稍后的状态机中解释。上面是 99% 的情况下发生了什么的流程。

如果消息被复制到 3 个节点,则确认需要 1 + 2 + 2 + 2 个消息,在确认消息无法到达某些节点时会保留确认消息。这使得会减少该消息的多次递送的可能性。

然而 fast ack 虽然不太可靠,但快得多,并且节点间交换的消息更少。以下是快速确认的工作原理:


  1. 客户端将 FASTACK 发送到一个节点。

  2. 节点废弃该任务,并且尽力向可能具有副本的所有节点发送 DELJOB,或者如果节点不知道该任务的副本信息则向所有的节点尽力发送 DELJOB。


如果在快速确认期间,具有消息副本的节点不可达,则节点将再次投递消息,这是因为该节点具有消息的未确认副本,并且当分区愈合时没有人能够通知它消息已被确认。

如果你使用的网络相当可靠,并且你非常关注性能,同时在你的应用程序上下文中多次投递并非大问题,那么 FASTACK 可能是合适之选。


死消息队列


许多消息队列实现称为死消息队列的功能。它是一个特殊的队列,用于累积由于某种原因而无法处理的消息。常见原因可能是:


  1. 消息传送的次数太多,但从未正确处理。

  2. 在处理之前,消息生存时间已达到零。

  3. 一些处理者明确将消息标记为有问题。


这种想法用于系统管理员检查(通常通过自动系统)在死信队列中是否存在某些东西,以便了解是否存在某些软件错误或其他类型的错误从而导致消息没有被处理。

由于 Disque 是一个内存系统,消息生存时间是一个重要的属性。当到发生上述情况时,我们希望消息消失,因为使用 TTL 的原因,在一段时间之后,处理错误消息不再有意义。在这样的系统中,为了响应错误而存储并创建队列并非最佳。此外,由于 Disque 的分布式特性,死信可能最终产生在多个节点并且具有重复的条目。

所以 Disque 使用不同的做法。每个节点具有两个计数器:nack 计数器和附加投递计数器。计数器在具有相同消息副本的节点之间不一致,它们是一种在网络分裂期间的一种计数尝试,可能不在某个节点中递增。

这两个计数器的想法是,每当消费者使用 NACK 命令告诉队列消息未被正确处理时,就加 1,并且应该将其重新放回队列。另一个则在需要将消息再次放回队列的每个其他条件(不同于 NACK 调用)满足时递增。条件包括丢失并再次入队的消息或者在分区的一侧入队的消息(因为消息在另一侧被处理)等等。

使用带有 WITHCOUNTERS 选项的 GETJOB 命令或使用 SHOW 命令来检查任务,可以将这两个计数器与其他任务信息一起检索,因此如果 worker 在处理消息之前发现计数器超过一些应用程序定义的限制,它可以通过多种方式通知操作人员:


  1. 它可能会发送电子邮件。

  2. 在监视系统中设置标志。

  3. 将消息放在特殊队列中(模拟死信特征)。

  4. 尝试处理消息并报告错误的堆栈(如果有)。


基本上,本功能的使用取决于应用程序。注意,计数器在面对故障或网络分区时不需要不需要保证一致性:如果最终消息有问题,计数器将增加到足够的值从而达到警告阈值(由应用程序设定)。

具有两个不同计数器的原因是,由于超时或消息丢失,应用可能希望通过 NACK 而非多次投递来处理显式否定确认。


Disque 的磁盘持久化


Disque 只能在内存中操作,使用同步复制作为可靠性保证,或者可以使用AOF(Append Only File)方式,将任务创建和废弃在磁盘上进行记录(使用可配置的 fsync 策略),并在重新启动时重新加载。

建议使用 AOF,尤其是在单个可用区中运行时。

通常 Disque 仅在内存中重新加载任务数据,而不填充队列,因为未确认的任务需要重建。此外,在具有设置为 0 的重试值的最多一次任务的情况下,重新加载队列数据是不安全的。然而,我们提供特殊选项以便从 AOF 重新加载完整状态。与某选项(该选项允许在从头生成 AOF 之后关闭服务器)可以一起使用,以便重新加载任务的重试属性被设置为 0,因为 AOF 是在服务器不再接受从客户端发出命令时生成的,所以没有竞争条件。

即使在仅运行内存时,Disque 也能够将其内存转储到磁盘上,并在受控重新启动时从磁盘重新加载,例如为了升级软件。

以下是如何执行受控重启的流程,无论 AOF 是否启用:


  1. 设置 aof-enqueue-jobs-once 为 yes

  2. 配置 REWRITE

  3. 停止 REWRITE-AOF


此时,我们在磁盘上新生成AOF,并且服务器配置为仅在下次重新启动时加载完整状态(aof-enqueue-jobs-once在重新启动后自动关闭)。

我们可以使用新软件或在新服务器中重新启动。注意,aof-enqueue-jobs- 意味着加载 AOF,即使 AOF 支持被关闭,因此不需要为仅用内存的服务器的升级启用 AOF。


任务 ID


任务 ID(如下所示)是其唯一标识:


D-dcb833cf-8YL1NT17e9+wsA/09NqxscQI-05a1


任务 ID 由 40 个字符组成,并以前缀 D- 开头。我们可以将 ID 拆分为多个部分:

D- | dcb833cf | 8YL1NT17e9+wsA/09NqxscQI | 05a1
  1. D- 是前缀。

  2. dcb833cf 是生成消息的节点 ID 的前 8 个字节。

  3. 8YL1NT17e9 + wsA / 09NqxscQI 是以 base64 编码的 144 位 ID 伪随机部分。

  4. 05a1 是以分钟为单位的任务 TTL。因此,即使没有任务描述,消息 ID 也可以安全失效。


当成功创建任务时,ADDJOB 返回 ID,它们是 GETJOB 输出的一部分,用于确认任务由 worker 正确处理。

节点ID的一部分被包含在消息中,这样用于处理给定队列消息的woker可以容易地猜测创建任务的节点是什么,并且直接移动到该节点以提高效率,而不是监听节点中的消息。

消息中仅包括原始节点ID的32位,然而在具有100个Disque节点的集群中,两个节点具有相同的32位ID前缀的概率由生日悖论给出:


P(100,2^32) = .000001164


在发生碰撞的情况下,worker 可能只是做出一个无效的选择。144 位随机部分中发生冲突是不可能的,因为它是如下计算的。


  • seed 是在启动时通过 /dev/urandom 生成的种子。

  • 计数器是 64 位计数器并且在每次 ID 生成时递增。


所以有 22300745198530623141535718272648361505980416 可能的 ID 以供选择。 虽然冲突的概率在数学上是存在的,但工程上每个 ID 可以被认为是唯一的。

以分钟为单位的编码 TTL 有一个特殊的属性:对至多一次投递的任务它总是偶数(任务重试值设置为 0),否则始终为奇数。 这将编码的 TTL 精度变为 2 分钟,但可以通过任务 ID 判断任务是否需要保证投递。 注意,这个事实并不意味着 Disque 任务 TTL 的精度为 2 分钟。 TTL 字段仅用于将给定节点没有副本任务的 job ID 过期。


本文由高可用架构志愿者翻译及首发,英文来源:


https://github.com/antirez/disque


关注高可用架构公众号,了解后续 disque 更多介绍。


欢迎通过公众号菜单「联系我们」进行投稿,也欢迎最新优秀技术文章的译稿。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。


高可用架构

改变互联网的构建方式


长按二维码 关注「高可用架构」公众号


高可用架构主办 GIAC 全球互联网架构大会,李智慧、左耳朵耗子、来炜等技术专家已经加入出品人,本周购买双日套票仅需 900 元起,点击阅读原文了解详细议程。