【pulsar】pulsar的定时发送实现原理

背景

在我们的项目中,业务中常常需要解决定时提醒,定时发布等需求,而我们会常常使用pulsar的定时消息作为我们的定时处理的方案,而pulsar天然支持定时消息的发送。Pulsar 原生支持定时消息发送,因此在有空时,我就想深入研究一下它内部是如何实现定时消息机制的,写本文的初衷就是因为好奇。

本文将结合源码以及图示介绍pulsar定时消息的处理流程。

结合源码分析

我们首先必须要明确一个概念:pulsar的延迟消息,是延迟投递到消费者。也就是说Producer 在发送消息时立即写入 Broker ,然后存储到 BookKeeper,延迟逻辑完全发生在 Broker 的消费路径。

第二个概念: 我们知道,当我们订阅一个topic时,我们会在broker上存储我们的订阅信息。

每个 Subscription 都有自己:

  • Cursor(订阅消费指针)
  • Dispatcher(调度器)
  • 延迟消息调度器 DelayedDeliveryTracker(按需创建,懒加载的,下文有源码)

如何发送一个消息,如果我们自己使用pursal实现一个定时发布,会写出以下代码

java 复制代码
producer.newMessage()
    .deliverAfter(10, TimeUnit.SECONDS)
    .value(data)
    .send();

但其实本质上是 now + 10s,计算出过期时间,然后写入消息的deliverAtTimeStamp字段上

java 复制代码
  @Override
    public TypedMessageBuilder<T> deliverAt(long timestamp) {
        msgMetadata.setDeliverAtTime(timestamp);
        return this;
    }

源码位置: github.com/apache/puls...

然后消息在发送的时候会去checkAndStartPublish

这个代码会初始化delayedDeliveryTracker,然后将消息放到delayedDeliveryTracker里面

java 复制代码
    @Override
    public boolean trackDelayedDelivery(long ledgerId, long entryId, MessageMetadata msgMetadata) {
        if (!topic.isDelayedDeliveryEnabled()) {
            // If broker has the feature disabled, always deliver messages immediately
            return false;
        }

        synchronized (this) {
            if (delayedDeliveryTracker.isEmpty()) {
                if (!msgMetadata.hasDeliverAtTime()) {
                    // No need to initialize the tracker here
                    return false;
                }

                // Initialize the tracker the first time we need to use it
                delayedDeliveryTracker = Optional.of(
                        topic.getBrokerService().getDelayedDeliveryTrackerFactory().newTracker(this));
            }

            delayedDeliveryTracker.get().resetTickTime(topic.getDelayedDeliveryTickTimeMillis());

            // 没有设置延时时间,则将延时时间设置为-1
            long deliverAtTime = msgMetadata.hasDeliverAtTime() ? msgMetadata.getDeliverAtTime() : -1L;
            return delayedDeliveryTracker.get().addMessage(ledgerId, entryId, deliverAtTime);
        }
    }

那么就有一个问题了,为什么没有延时的消息也会放在delayedDeliveryTracker 中呢?

继续往下看,我们到addMessage里面

这个地方,我们发现如果延时消息小于0,即当前没有延时消息,或者延时消息已经过期,则不放入,返回false,回到上一层

源码位置:github.com/apache/puls...

java 复制代码
 @Override
    public boolean addMessage(long ledgerId, long entryId, long deliverAt) {
    
            // 这一步判断,是否添加message(也就是ledgerId 和 entryId)到tracker
        if (deliverAt < 0 || deliverAt <= getCutoffTime()) {
            messagesHaveFixedDelay = false;
            return false;
        }

        if (log.isDebugEnabled()) {
            log.debug("[{}] Add message {}:{} -- Delivery in {} ms ", dispatcher.getName(), ledgerId, entryId,
                    deliverAt - clock.millis());
        }

        long timestamp = trimLowerBit(deliverAt, timestampPrecisionBitCnt);
        delayedMessageMap.computeIfAbsent(timestamp, k -> new Long2ObjectRBTreeMap<>())
                .computeIfAbsent(ledgerId, k -> new Roaring64Bitmap())
                .add(entryId);
        delayedMessagesCount.incrementAndGet();

        // 这一步下面就是调用定时器,即时间轮
        updateTimer();

        checkAndUpdateHighest(deliverAt);

        return true;
    }

pulsar使用ledgerId + entryId定位一条消息,entryId是自增的,ledgerId是不重复的

接着上述方法的调用时间器的代码深入

java 复制代码
protected void updateTimer() {

    // 如果当前没有任何延迟消息
    if (getNumberOfDelayedMessages() == 0) {

        // 如果之前已经设置了一个 timeout(计时任务),现在不需要了,取消掉
        if (timeout != null) {
            currentTimeoutTarget = -1;  
            timeout.cancel();          
            timeout = null;
        }
        return; 
    }

    // 找到当前延迟队列中"下一条要被投递的消息"的 deliverAt 时间(最小时间戳)
    long timestamp = nextDeliveryTime();

    // 如果这个 timestamp 和之前设定的目标时间一样,
    // 说明 timer 已经为这个时间点设置好了,不需要重复调度
    if (timestamp == currentTimeoutTarget) {
        return;
    }

    // 如果之前已经设置了定时任务,但现在发现需要重新设置(如出现更早的延迟消息, 就先取消旧的 timeout
    if (timeout != null) {
        timeout.cancel();
    }

    long now = clock.millis();

    // 距离目标投递时间还有多久
    long delayMillis = timestamp - now;

    // 如果 delay < 0,说明消息已经到期,但仍未被发送给消费者, 所以直接return,dispatcher会处理掉
    if (delayMillis < 0) {
        return;
    }

    long remainingTickDelayMillis = lastTickRun + tickTimeMillis - now;

    // 应该设置的 delay = max(消息实际 delay, tick 间隔 delay)
    long calculatedDelayMillis = Math.max(delayMillis, remainingTickDelayMillis);

    if (log.isDebugEnabled()) {
        log.debug("[{}] Start timer in {} millis",
                  dispatcher.getName(), calculatedDelayMillis);
    }

    currentTimeoutTarget = timestamp;

    // 注册新的 timeout(即定时任务)到时间轮中
    // 到 calculatedDelayMillis 毫秒后,时间轮会回调 this.run() 或 checkAndTrigger()
    timeout = timer.newTimeout(this, calculatedDelayMillis, TimeUnit.MILLISECONDS);
}

当我们定位到timer时,我们会发现,其实就是用的netty的timer

netty的时间轮在很多java相关的框架都有使用,比如redisson在实现看门狗的时候也是用的NettyHashedWheelTimer, 还有dubbo。

时间轮每个broker是只有一个,这个我们可以看到timer在TrackerFactory中,即所有的Tracker队列 共享一个时间轮。

时间轮

到底什么是时间轮呢? 我看了有一篇文章写的很好,非常通俗易懂,建议收藏。 zhuanlan.zhihu.com/p/609284043

完整处理流程

按照上述源码流程,我画了一个图来描述pulsar定时消息的图。

整个流程描述如下:

  1. Producer 发送消息时,会在消息的 metadata 中写入 deliverAt 时间(延迟投递时间)。
    消息到达 Broker 后,Broker 会像普通消息一样立即将其持久化到 BookKeeper。
  2. 每个 Subscription 拥有自己的 Cursor。Cursor 会按顺序从 BookKeeper 读取该 Topic 的消息,并将读取到的消息交给 Dispatcher 处理。
  3. Dispatcher 在处理消息时会解析 metadata:如果 deliverAt 大于当前时间,说明消息尚未到期。此时 Dispatcher 不会将消息投递给消费者,而是将其加入该订阅对应的延迟队列(DelayedDeliveryTracker)。
  4. DelayedDeliveryTracker 会向 Broker 的全局时间轮(HashedWheelTimer)注册一个定时检查任务。Tracker 本身负责管理所有延迟消息的时间索引。
  5. 时间轮在每一次 tick 时会回调 Tracker,让 Tracker 自行判断哪些延迟消息已经到达投递时间。
  6. 一旦某条消息到期,Tracker 会将其调度回 Dispatcher,由 Dispatcher 再次从 BookKeeper 读取该消息,并按照订阅策略正常投递给消费者。

参考

相关推荐
奈斯先生Vector30 分钟前
OpenScience 安装失败怎么办?Windows 环境、API 配置与本地服务排错指南
人工智能·windows·架构·开源·aigc·midjourney
小蒜学长33 分钟前
vue旅游攻略网站(代码+数据库+LW)
java·数据库·vue.js·spring boot·后端·旅游
凤山老林41 分钟前
轻量级规则引擎落地:Spring Boot 集成 LiteFlow 实现动态业务编排与热更新
java·spring boot·后端·规则引擎·liteflow
IT_陈寒1 小时前
Python装饰器把我坑惨了,原来这样用才不掉链子
前端·人工智能·后端
吃旺旺雪饼的小男孩1 小时前
自动驾驶图像分割开源数据集指南(2026)
人工智能·开源·自动驾驶
deepseek231 小时前
商汤开源SenseNova U1.5 Lite拆解:8B装下原生统一多模态,NEO-Unify架构与4K图像生成的三笔工程账
开源·多模态大模型·ai agent
程序员爱钓鱼1 小时前
Rust Associated Type关联类型详解:为Trait定义内部类型
前端·后端·rust
程序员爱钓鱼1 小时前
Go 编程实战:匿名函数 Anonymous Function——没有名字的函数与灵活回调
后端·面试·go
卷无止境2 小时前
FastAPI 后台任务的边界,以及 Celery、Redis 与自建调度系统的选择
后端·python
卷无止境2 小时前
Linux + Docker + FastAPI 工程化实践指南
后端·python·fastapi