关于 Apache Pulsar

Apache Pulsar 是 Apache 软件基金会顶级项目,是下一代云原生分布式消息流平台,集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐以及低延时的高可扩展流数据存储特性。

本文来自社区用户投稿,作者侯盛鑫,来自伴鱼。

在很多在线的业务系统中,由于业务逻辑处理出现异常,一条消息没有被确认,我们需要尽可能准备好优雅地处理故障。重试是我们的常用做法,一般我们从以下三方面入手进行重试:

  • 设置重新投递。若需要允许重新消费失败的消息,我们可以配置消费者同时允许消费消息从业务主题和重试主题,并配置了允许消费者自动重试。
  • 设置重试队列。如果消息没有被消费成功,它将被保存到重试主题当中。并可以指定延时时间,自动重新消费重试主题里面的消费失败消息。
  • 重试的次数限制。默认情况下,如果消费者没有成功消费一条消息(也就是说消费者无法 ack ),它将重试同一条消息。

那么,难道我们不能简单地让这种默认行为接管一切,然后重试消息直到成功吗?问题是这条消息可能永远不会成功。至少没有某种形式的手动干预它是不会成功的。于是乎,消费者就永远不会继续处理后续的任何消息,并且我们的消息处理将陷入困境,所以在重试一定次数后将采取死信队列的方法存储为确认成功消息。

如上图,Pulsar 采用非阻塞请求重试队列和死信队列(DLQ) 来扩展现有事件驱动架构作用,通过这样处理我们就可以在不中断实时流量的情况下实现解耦、可观察的错误处理。

但是 Pulsar 默认情况下,自动重试这个选项是关闭的,我们可以设置 enableRetry 选项为 true,这样可以在这个消费者中进行重试。如下例子所示,消费者会从重试主题消费消息:

 package main
 
 
import (
    "context"
    "fmt"
    "github.com/apache/pulsar-client-go/pulsar"
    "time"
)
 
func main() {
 
 
    cp := pulsar.ClientOptions{
        URL: "pulsar://xxx.xxx.xxx.xxx:6650",
        OperationTimeout: 30 * time.Second,
    }
 
    client, err := pulsar.NewClient(cp)
    if err != nil {
        return
    }
    defer client.Close()
 
    d := &pulsar.DLQPolicy{
        MaxDeliveries: 3,
        RetryLetterTopic: "persistent://group/server/xxx-RETRY",
        DeadLetterTopic: "persistent://group/server/xxx-DLQ",
    }
 
    consumer, err := client.Subscribe(pulsar.ConsumerOptions{
        Topic: "persistent://group/server/xxx",
        SubscriptionName: "test",
        Type: pulsar.Failover,
        RetryEnable: true,
        DLQ: d,
        NackRedeliveryDelay: time.Second * 3,
    })
    if err != nil {
        return
    }
 
    ctx := context.Background()
    for {
        msg, err := consumer.Receive(ctx)
        if err != nil {
            return
        }
        if msg.Key() == 0 {
            // 确认的处理
            consumer.Ack(msg)
        } else {
            // 不确认,等 NackRedeliveryDelay 后将被重新投递到主队列进行消费
consumer.Nack(msg)
 
            // 稍后处理,等 xx 秒后将被重新投递到重试队列
consumer.ReconsumeLater(msg, time.Second * 5)
 
            // 以上方法二选其一
        }
    }
}

重试队列

首先,如上样例自动创建了一个重试队列,产生重试消息需要两个条件其中一个:

  • Nack() 函数,消费者的 Nack() 函数用于确认处理单个消息失败。一旦消息被“否定确认”时,它将被标记为在之后重新传递。投递对象是当前的主 topic ,投递次数不受影响,投递时间受 NackRedeliveryDelay 控制。
  • AckTimeout 参数,由于网络抖动,服务 Down 机等原因,未能及时 Nack,Pulsar 为了完善重试机制设置了 Acktimeout 默认为0(不开启的)的参数,consumer 处理一旦超过 Acktimeout 将被投递重试。(在 golang sdk v0.6.0 以及之前并没有实现设置 Acktimeout 的相关功能,之后请持续关注)

重试行为中的重试队列的重试行为是和时间相关的。目前主要通过 consumer.ReconsumeLater() 方法触发,一旦触发到重试队列,重试次数会相应在重试中减少,这里的 DLQPolicy 结构中的 RetryLetterTopic 是 Pulsar 为了进行重试在原本基础上新建的 topic,默认情况下是:{TopicName}-{Subscription}-RETRY ,这是为了最大程度不干扰主 topic 的数据的做法。

Golang 的 sdk 并没有完成 java sdk 中那样丰富多样的重试机制,但是却简单粗暴直接开放了 NackRedeliveryDelay 原始延迟时间的参数,这样方便了各种策略的定制化开发。

其中 DLQPolicy.MaxDeliveries 这个参数在消息出错时,将决定最多继续尝试发送多少次,如到用户设置的最大值,消息还没有成功发送,此时 Pulsar 会将消息推送到死信队列中,也就是 DLQPolicy.DeadLetterTopic 。

注意:⚠️RLQ 是一个延迟队列,消费用 shared 模式!

死信队列

当重试次数用完时,信息将被路由到死信队列中,注意⚠️:此时消息状态会变成已确认。死信队列是一个不分区的持久化队列,用户可以根据自己的需求对信息消息做相应的处理。sdk 提供 DLQPolicy.DeadLetterTopic 参数来设置 “死信队列” 的名字。默认情况下死信队列名称是 :{TopicName}-{Subscription}-DLQ 。

总结

到此为止,我们梳理一下流程:
1、除了正常消费写入的 topic 外重试还会增加一个重试队列,sdk 中会自动订阅重试队列;
2、重试队列实际上是一个延迟队列,未确认消息将维护一个时间相关的优先级队列;

3、当重试用完时,消息将进入死信队列,消息状态变为已确认,用户消费死信队列处理死信消息。

作者简介

我叫侯盛鑫,也可以我叫大云,目前就职于伴鱼基础架构,负责消息队列的维护与相关开发,Rust 日报小组中的菜鸡成员,喜欢研究存储,服务治理等方向。初次接触 Pulsar 就对存储和计算分离的结构所吸引,顺滑的生产者消费者接入和高吞吐让我好奇这个项目的实现,期望之后能在 Pulsar 的相关功能中做些贡献。

推荐阅读

•  博文推荐|深入解析Apache Pulsar 中的事务•  Pulsar 2.8.0 新增特性概览:独占 Producer、事务等
•  博文推荐|有效管理数据安全性—— Pulsar Schema 管理


ApachePulsar
192 声望939 粉丝

Apache软件基金会顶级项目,下一代云原生分布式消息系统