JAVA数据结构与算法之延迟队列(二):HashedWheelTimer

Netty HashedWheelTimer详解
深入解析Netty中的HashedWheelTimer类,基于时间轮算法实现高效定时任务调度。探讨其成员变量、构造函数、核心逻辑及关键方法,如newTimeout、waitForNextTick等。

HashedWheelTimer

NettyHashedWheelTimer类基于时间轮算法实现

主要成员变量
private final Worker worker = new Worker(); // 任务类,继承Runnable接口
private final Thread workerThread; // 工作线程,用来执行worker任务
private volatile int workerState; // 任务状态,cas自旋和volatile保证原子操作,0-初始化中, 1-开启, 2-关闭
private final long tickDuration; // wheel上每个时间片执行的时间间隔
private final HashedWheelBucket[] wheel; // 时间轮,HashedWheelBucket内部由双向队列实现
private final int mask;
private final CountDownLatch startTimeInitialized = new CountDownLatch(1); // 用于等待任务启动完成
private final Queue<HashedWheelTimeout> timeouts = PlatformDependent.newMpscQueue(); // 添加的任务
private final Queue<HashedWheelTimeout> cancelledTimeouts = PlatformDependent.newMpscQueue(); // 取消的任务
private final AtomicLong pendingTimeouts = new AtomicLong(0); // 等待的任务数
private final long maxPendingTimeouts; // 最大等待任务数

private volatile long startTime; // 延迟任务的开始时间
构造函数
	public HashedWheelTimer(
            ThreadFactory threadFactory,
            long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
            long maxPendingTimeouts) {
        // 线程工厂,用来创建worker线程
        if (threadFactory == null) {
            throw new NullPointerException("threadFactory");
        }
        // 每个tick运行的时间间隔单位
        if (unit == null) {
            throw new NullPointerException("unit");
        }
        // 每个tick运行的时间间隔
        if (tickDuration <= 0) {
            throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration);
        }
        // wheel数组即时间轮的大小
        if (ticksPerWheel <= 0) {
            throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel);
        }
        // 将wheel数组大小标准化为2的n次方,同时初始化wheel数组
        wheel = createWheel(ticksPerWheel);
        // 掩码
        mask = wheel.length - 1;

        // Convert tickDuration to nanos.
        this.tickDuration = unit.toNanos(tickDuration);

        // 防止溢出
        if (this.tickDuration >= Long.MAX_VALUE / wheel.length) {
            throw new IllegalArgumentException(String.format(
                    "tickDuration: %d (expected: 0 < tickDuration in nanos < %d",
                    tickDuration, Long.MAX_VALUE / wheel.length));
        }
        // 创建工作线程
        workerThread = threadFactory.newThread(worker);
        // 是否泄漏监控
        leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null;
        // 最大等待任务数
        this.maxPendingTimeouts = maxPendingTimeouts;
        // 警告,限制HashedWheelTimer实例数为INSTANCE_COUNT_LIMIT个即64个,预防过多的线程影响性能
        if (INSTANCE_COUNTER.incrementAndGet() > INSTANCE_COUNT_LIMIT &&
            WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)) {
            reportTooManyInstances();
        }
    }
创建时间轮函数createWheel()
	private static HashedWheelBucket[] createWheel(int ticksPerWheel) {
        if (ticksPerWheel <= 0) {
            throw new IllegalArgumentException(
                    "ticksPerWheel must be greater than 0: " + ticksPerWheel);
        }
        if (ticksPerWheel > 1073741824) {
            throw new IllegalArgumentException(
                    "ticksPerWheel may not be greater than 2^30: " + ticksPerWheel);
        }
        // 严格控制时间轮的大小为2的n次方
        ticksPerWheel = normalizeTicksPerWheel(ticksPerWheel);
        // 初始化时间轮数组并赋值
        HashedWheelBucket[] wheel = new HashedWheelBucket[ticksPerWheel];
        for (int i = 0; i < wheel.length; i ++) {
            wheel[i] = new HashedWheelBucket();
        }
        return wheel;
    }
    // 大于指定值的最小2的n次方
    private static int normalizeTicksPerWheel(int ticksPerWheel) {
        int normalizedTicksPerWheel = 1;
        while (normalizedTicksPerWheel < ticksPerWheel) {
            normalizedTicksPerWheel <<= 1;
        }
        return normalizedTicksPerWheel;
    }
添加任务函数newTimeout()

添加任务时,如果worker线程没启动则启动,同时向timeouts队列中生产timeout任务

	public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
        if (task == null) {
            throw new NullPointerException("task");
        }
        if (unit == null) {
            throw new NullPointerException("unit");
        }
        // 等待任务数+1
        long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();
        // 超过最大等待任务数,拒绝加入
        if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
            pendingTimeouts.decrementAndGet();
            throw new RejectedExecutionException("Number of pending timeouts ("
                + pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending "
                + "timeouts (" + maxPendingTimeouts + ")");
        }
        // 开始任务
        start();
        // 延迟执行的时间
        long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
        if (delay > 0 && deadline < 0) {
            deadline = Long.MAX_VALUE;
        }
        // 添加到timeouts队列,这是一个MPSC队列,MPSC队列是多生产者单消费者无锁的并发队列,worker线程会对队列进行消费
        HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
        timeouts.add(timeout);
        return timeout;
    }

这里看下start()函数如何保证启动一次

	public void start() {
		// 这里使用CAS算法确保原子操作,因此多个线程并发执行只有一次会执行成功
        switch (WORKER_STATE_UPDATER.get(this)) {
            case WORKER_STATE_INIT:
                if (WORKER_STATE_UPDATER.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) {
                    workerThread.start();
                }
                break;
            case WORKER_STATE_STARTED:
                break;
            case WORKER_STATE_SHUTDOWN:
                throw new IllegalStateException("cannot be started once stopped");
            default:
                throw new Error("Invalid WorkerState");
        }

        // 等待worker线程初始化一些启动工作
        while (startTime == 0) {
            try {
                startTimeInitialized.await();
            } catch (InterruptedException ignore) {
                // Ignore - it will be ready very soon.
            }
        }
    }

核心逻辑在Worker内部类中

private final Set<Timeout> unprocessedTimeouts = new HashSet<Timeout>();
private long tick; // tick计数
run方法

核心逻辑

	public void run() {
        // 初始化startTime变量
        startTime = System.nanoTime();
        if (startTime == 0) {
            // 初始化失败时的值
            startTime = 1;
        }
        // 通知其他线程继续执行
        startTimeInitialized.countDown();
        do {
        	// 等待直到当前tick时间点到
            final long deadline = waitForNextTick();
            // 当前tick时间点到了
            if (deadline > 0) {
          		// 对应时间轮中的索引
                int idx = (int) (tick & mask);
                // 处理取消的任务
                processCancelledTasks();
                // 当前tick需要处理的bucket
                HashedWheelBucket bucket =
                        wheel[idx];
                // 将timeouts队列移到bucket中
                transferTimeoutsToBuckets();
                // 处理掉当前bucket时间到或过期的任务
                bucket.expireTimeouts(deadline);
                // tick数+1,继续等待执行下一个tick时间点
                tick++;
            }
        } while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);

        // Fill the unprocessedTimeouts so we can return them from stop() method.
        for (HashedWheelBucket bucket: wheel) {
            bucket.clearTimeouts(unprocessedTimeouts);
        }
        for (;;) {
            HashedWheelTimeout timeout = timeouts.poll();
            if (timeout == null) {
                break;
            }
            if (!timeout.isCancelled()) {
                unprocessedTimeouts.add(timeout);
            }
        }
        processCancelledTasks();
    }
waitForNextTick()

等待下一个tick时间点到来

	private long waitForNextTick() {
// 当前tick需要等待时间
        long deadline = tickDuration * (tick + 1);
        for (;;) {
        	// 当前时间与开始时间的时间间隔
            final long currentTime = System.nanoTime() - startTime;
            // 剩余需要等待的时间
            long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;
            // 当前时间已经超过当前tick执行所需等待的时间了,则返回,否则休眠后再判断
            if (sleepTimeMs <= 0) {
                if (currentTime == Long.MIN_VALUE) {
                    return -Long.MAX_VALUE;
                } else {
                    return currentTime;
                }
            }
            // windows系统bug
            if (PlatformDependent.isWindows()) {
                sleepTimeMs = sleepTimeMs / 10 * 10;
            }
            // 休眠
            try {
                Thread.sleep(sleepTimeMs);
            } catch (InterruptedException ignored) {
            	// HashedWheelTimer shutdown导致的线程中断
                if (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
                    return Long.MIN_VALUE;
                }
            }
        }
    }
transferTimeoutsToBuckets()

每次提取最多10000个任务,避免过多的时间消耗在这里影响定时任务,然后将任务放到对应的tick所在的wheel数组中

	private void transferTimeoutsToBuckets() {
        for (int i = 0; i < 100000; i++) {
            HashedWheelTimeout timeout = timeouts.poll();
            if (timeout == null) {
                // all processed
                break;
            }
            if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
                // Was cancelled in the meantime.
                continue;
            }
            // 需要的tick数
            long calculated = timeout.deadline / tickDuration;
            // 执行该任务的时间轮数
            timeout.remainingRounds = (calculated - tick) / wheel.length;
            // 如果需要的tick数小于当前tick,说明时间点已经过了,直接在当前tick执行
            final long ticks = Math.max(calculated, tick);
            // 对应的wheel位置 
            int stopIndex = (int) (ticks & mask);
            // 加入到双向队列
            HashedWheelBucket bucket = wheel[stopIndex];
            bucket.addTimeout(timeout);
        }
    }
HashedWheelBucket.expireTimeouts()

执行过期任务

 	public void expireTimeouts(long deadline) {
        HashedWheelTimeout timeout = head;

        // process all timeouts
        while (timeout != null) {
            HashedWheelTimeout next = timeout.next;
            if (timeout.remainingRounds <= 0) {
                next = remove(timeout);
                // 移除并执行
                if (timeout.deadline <= deadline) {
                	// 这里会执行该任务
                    timeout.expire();
                } else {
                    // The timeout was placed into a wrong slot. This should never happen.
                    throw new IllegalStateException(String.format(
                            "timeout.deadline (%d) > deadline (%d)", timeout.deadline, deadline));
                }
            } else if (timeout.isCancelled()) {
                next = remove(timeout);
            } else {
                timeout.remainingRounds --;
            }
            timeout = next;
        }
    }
processCancelledTasks()

移除取消的任务

	private void processCancelledTasks() {
       for (;;) {
            HashedWheelTimeout timeout = cancelledTimeouts.poll();
            if (timeout == null) {
                // all processed
                break;
            }
            try {
                timeout.remove();
            } catch (Throwable t) {
                if (logger.isWarnEnabled()) {
                    logger.warn("An exception was thrown while process a cancellation task", t);
                }
            }
        }
    }
    void remove() {
        HashedWheelBucket bucket = this.bucket;
        if (bucket != null) {
            bucket.remove(this);
        } else {
            timer.pendingTimeouts.decrementAndGet();
        }
    }
        
前言 1.1. 背景和目标 VMS Cloud Event模块作为承载海量设备事件上报的核心组件,负责事件过滤、转发、存储等复杂处理逻辑。当前依赖的公共组件EventCenter仅支持基础订阅消费能力,存在以下瓶颈: 异常事件处理缺失:失败事件直接丢弃,无重试/隔离机制; 时效性不足:缺乏延迟触发能力(如定时删除事件); 扩展性弱:无法适配新增的复杂业务场景(如设备批量操作或批处理)。 本模块在项目中处于事件处理链路的核心地位,直接影响设备事件的可靠性时效性。本需求分析文档的撰写目标是明确死信队列延迟队列的功能需求、非功能性需求及实现方案,为后续开发提供指导。 1.2. 定义 死信队列(Dead Letter Queue, DLQ):存储无法被正常消费的异常消息(如重试耗尽、消费失败),用于错误隔离问题排查。 延迟队列(Delayed Queue):支持消息在指定延迟时间后被消费,用于异步定时任务(如定时删除事件)。 EventCenter:现有公共组件,提供事件发送、处理器注册等基础能力,支持Kafka、Local实现。 指数退避原则:设置初始等待时间,发生可重试错误时重试,再次发生错误时等待时间会以指数级别增长。 1.3. 参考资料 kafka官方文档(Apache Kafka Documentation):用于死信队列自定义拦截器实现参考。 EventCenter现有设计文档(Event Center 使用说明文档 - CRD_EP_Software_Service - Confluence)及EventCenter学习报告(EventCenter组件学习报告 - CRD_EP_Software_Service - Confluence):包含模块结构、核心接口及消息处理模式说明。 kafka消息范式调研报告(kafka消息范式扩展调研报告 - CRD_EP_Software_Service - Confluence):包含kafka消息范式扩展概述,死信队列及延迟队列设计实现。 2. 调研结果 2.1 死信队列(Dead Letter Queue, DLQ)设计实现 2.1.1 死信队列核心价值 死信队列是存储“无法被正常消费的消息”的特殊队列,核心作用: 错误隔离:避免异常消息阻塞主消费流程,保障主队列吞吐量。 问题追踪:集中存储失败消息(含上下文、错误日志),便于定位根因。 数据补偿:支持人工/自动修复后重新投递,减少数据丢失风险。 2.1.2死信队列常见设计方案 方案1:基于Kafka消费者拦截器(Consumer Interceptor) 实现原理: 在Kafka消费者端实现ConsumerInterceptor接口,拦截poll()返回的消息。当消费逻辑(如EventHandler.handle())抛出异常时,拦截器捕获失败消息,通过独立生产者将其发送至死信Topic(如vms_dlq_topic),并提交原消息的偏移量(避免重复消费)。 关键流程: 消费者从Kafka拉取消息(poll())。 拦截器预处理消息(如记录元数据)。 业务逻辑消费消息(调用handleEvent())。 若消费成功,正常提交偏移量;若失败,拦截器: 记录失败原因(异常堆栈、重试次数)。 通过独立生产者将消息发送至死信Topic。 提交原消息偏移量(避免重复消费)。 worddavc4f46eb0b4de0b1c28abbc3921c9884a.png 优点: 完全基于Kafka原生API,无需引入外部中间件。 灵活控制重试策略(如最大重试次数、间隔)。 业务代码解耦(拦截器逻辑独立)。 缺点: 需开发拦截器逻辑,增加代码复杂度。 独立生产者需处理线程隔离(避免阻塞主消费线程)。 依赖消费者端配置(需为每个消费者组启用拦截器)。 方案2:基于Kafka生产者回调(Producer Callback) 实现原理: 在消息发送阶段,通过生产者的Callback接口捕获发送失败的消息(如网络异常、Broker不可用),将其直接发送至死信Topic。此方案主要处理“发送失败”的消息,而非“消费失败”的消息。 关键流程: 生产者调用send()发送消息,附加Callback。 若消息成功写入Kafka(RecordMetadata返回),流程结束。 若发送失败(Exception抛出),Callback捕获异常,将原消息+异常信息封装后发送至死信Topic。 优点: 直接捕获发送阶段的失败消息,避免未达Broker的消息丢失。 实现简单(仅需在生产者端添加回调逻辑)。 缺点: 仅覆盖“发送失败”场景,无法处理“消费失败”的消息。 死信Topic需主Topic同步扩容,增加运维成本。 方案3:Confluent平台DLQ支持(Kafka生态扩展) 实现原理: Confluent平台(Kafka商业发行版)提供内置DLQ功能,通过在消费者配置中指定dead.letter.topic.name,当消息消费失败(如反序列化异常、处理超时)时,Confluent客户端自动将消息转发至死信Topic。 优点: 零代码开发(仅需配置),集成成本低。 自动处理反序列化失败、消费超时等异常场景。 缺点: 依赖Confluent商业组件(需评估License成本)。 仅支持Confluent客户端(原生Kafka客户端不兼容)。 无法自定义重试策略(依赖默认逻辑)。 其他中间件对比 RabbitMQ DLX:通过绑定“死信交换器”实现,支持消息拒绝、超时、队列满等场景自动路由。优点是原生支持,缺点是Kafka技术栈不兼容。 RocketMQ DLQ:为每个消费者组自动创建死信队列(%DLQ%+组名),重试耗尽后自动存储。优点是无需开发,缺点是死信无法自动消费(需人工干预)。 2.1.3 死信队列方案对比总结 消费者拦截器 消费失败消息隔离 自主可控、兼容原生Kafka 需开发拦截器,线程隔离复杂 生产者回调 发送失败消息隔离 实现简单、覆盖发送阶段 不处理消费失败场景 Confluent DLQ 快速集成、低代码场景 零开发、自动转发 依赖商业组件,License成本高 2.2 延迟队列(Delayed Queue)设计实现 2.2.1 延迟队列核心价值 延迟队列支持消息在指定延迟时间后被消费,典型场景包括: 定时任务触发(如设备事件30分钟后删除)。 失败重试(如消费失败后5分钟重试)。 订单超时取消(如未支付订单30分钟后自动关闭)。 2.2.2 延迟队列常见设计方案 方案1:基于Redis有序集合(ZSet)的Kafka扩展 实现原理: 结合KafkaRedis,将延迟消息暂存于Redis ZSet(以到期时间戳为score),通过定时任务扫描ZSet,将到期消息发送至Kafka目标Topic。 关键流程: 消息生产:生产者将延迟消息(含事件内容、延迟时间)存入Redis ZSet(Key:vms_delay_queue,score=当前时间+延迟时间)。 扫描触发:定时任务(如每1秒)执行ZRANGEBYSCORE vms_delay_queue 0 <当前时间戳>,获取到期消息。 消息投递:将到期消息通过Kafka生产者发送至目标Topic(如vms_delete_event_topic)。 异常处理:若投递失败,重新设置消息的score(当前时间+10秒)并重新插入ZSet,等待下次扫描。 worddav39d7de94833eb9c9b2016e420c5fb318.png 优点: 支持任意延迟时间(毫秒级精度)。 复用现有Redis资源(如VMS缓存集群),无需引入新中间件。 分布式友好(通过Redis主从复制保障高可用)。 缺点: 需开发定时扫描逻辑(需处理并发扫描、消息去重)。 依赖Redis持久化(如AOF)保障消息不丢失。 扫描间隔精度权衡(间隔过小增加Redis压力,过大导致延迟误差)。 方案2:基于时间轮算法的Kafka内部扩展 实现原理: 时间轮(Time Wheel)是一种高效的延迟任务调度算法,通过“轮盘槽位”管理延迟任务。Kafka的KafkaDelayedMessage和Netty的HashedWheelTimer均基于此原理。在Kafka中,可扩展消费者端实现时间轮,将延迟消息按到期时间分配至不同槽位,轮盘转动时触发消息投递。 关键设计: 时间轮结构:轮盘分为多个槽位(如100个),每个槽位代表1秒。 消息入轮:计算消息到期时间当前时间的差值,分配至对应槽位(如延迟5秒的消息放入槽位5)。 轮盘转动:每秒移动一个槽位,触发当前槽位的消息投递。 优点: 时间复杂度O(1),高吞吐量下延迟低(百万级消息/秒)。 无需外部存储(依赖内存),响应速度快。 缺点: 需深度修改Kafka客户端源码(开发难度大)。 内存限制(槽位数量消息容量需平衡,大延迟消息可能跨多轮)。 消息持久化困难(内存数据易丢失,需结合日志备份)。 方案3:基于Kafka分区消费者暂停的分桶策略 实现原理: 利用Kafka的分区特性,为不同延迟时间创建独立分区(或Topic),消费者通过pause()和resume()控制分区消费时机。例如,为延迟5秒、30秒、5分钟的消息分别创建分区,消费者启动时暂停所有分区,根据当前时间计算各分区的恢复时间(如5秒后恢复延迟5秒的分区)。 关键流程: 消息生产:根据延迟时间将消息发送至对应分区(如topic-delay-5s、topic-delay-30s)。 消费者初始化:订阅所有延迟分区,调用pause()暂停消费。 定时恢复:定时任务检查当前时间,对到期的分区调用resume(),触发消息消费。 优点: 完全基于Kafka原生功能,无需外部组件。 分区隔离保障不同延迟消息的独立性。 缺点: 仅支持预设延迟等级(如5s、30s),无法动态调整。 分区数量随延迟等级增加而膨胀(如支持10种延迟需10个分区)。 消费者需维护复杂的分区恢复逻辑(易出错)。 其他中间件对比 RabbitMQ延迟交换器:通过x-delayed-message交换器实现,消息设置x-delay字段。优点是毫秒级精度,缺点是依赖插件且Kafka不兼容。 RocketMQ延迟消息:支持18级预设延迟(1s~2h),Broker暂存后转发。优点是原生支持,缺点是延迟等级固定。 2.2.3 延迟队列方案对比总结 Redis ZSet 自定义延迟、资源复用场景 灵活、复用现有资源 需开发扫描逻辑,依赖Redis 时间轮算法 高吞吐量、低延迟场景 高效、低延迟 开发难度大,内存依赖 Kafka分区分桶 预设延迟、原生依赖场景 无需外部组件 延迟等级固定,分区膨胀 3. 功能需求 3.1. 总体描述 本模块为VMS系统事件相关组件EventCenter的扩展模块,聚焦死信队列(DLQ)延迟队列的功能实现,解决现有EventCenter的异常事件处理不完善、时效性不足问题。模块通过低侵入式扩展(最小化修改EventCenter原生代码)提供配置化管理、灵活的重试策略及定时触发机制,支持开发人员通过EventCenter原生接口调用扩展功能(如发送延迟消息、启用死信队列),运维人员通过配置平台管理策略(如重试次数、延迟等级)。 扩展模块架构如下图所示: 3.1.1. 需求主体 开发人员 VMS业务系统开发者,调用EventCenter的接口开发消息队列相关功能(如设备事件上报),需通过扩展接口启用死信队列、发送延迟消息以及配置相关策略。 运维人员 VMS系统运维人员,负责监控死信堆积量。 3.1.2. 功能模块划分 配置管理模块 01 管理死信队列(开关、重试策略)的全局配置,支持开发人员通过EventCenter接口传递配置。 死信处理模块 02 基于EventCenter消费者拦截器扩展,捕获消费失败消息,执行重试逻辑,发送至DLQ并记录日志,EventCenter原生消费流程解耦。 延迟消息管理模块 03 两种延迟方案:Redis ZSet/Kafka分区分桶(目前选用redis zset方案,可调整); 支持发送延迟消息,用延迟队列时通过调用相关方法选择延迟时间 监控告警模块 04 监控死信堆积量、延迟消息触发成功率。 3.1.3. 用例图 3.2. 功能模块1:配置管理模块(模块编号01) MQE-01-0001 支持死信队列开关配置(默认关闭) 开发人员通过EventCenter的相关接口启用,避免非必要资源消耗。 开发人员 高 MQE-01-0002 支持自定义重试次数(默认3次) 开发人员通过接口设置,某些业务需修改重试次数。 开发人员 高 MQE-01-0003 支持重试间隔策略配置(默认指数退避,可选固定间隔、自定义/预设) 开发人员通过setRetryPolicy(topic, policy)接口设置,默认指数退避(如1s→2s→4s)。 开发人员 高 MQE-01-0004 支持死信名称配置(默认业务名_dlq_topic), 开发人员开启死信队列时需要设置对应业务的死信名称,未自定义业务使用默认。 开发人员 中 配置管理流程图如下图所示: 3.3. 功能模块2:死信处理模块(模块编号02) 典型场景:事件消费时,因业务逻辑异常导致消费失败,重试3次(默认值)后仍失败,消息需进入死信队列,避免阻塞主流程。 MQE-02-0001 基于EventCenter消费者拦截器扩展,无侵入式集成 拦截器实现ConsummerInceptor接口,不修改原生消费逻辑。 开发人员 高 MQE-02-0002 捕获消费失败消息 拦截器监听EventHandler.handleEvent()的异常抛出。 系统 高 MQE-02-0003 执行重试逻辑(基于配置的重试次数和间隔),重试时间通过延迟队列实现 重试期间记录重试次数,避免无限重试。 系统 高 MQE-02-0004 重试耗尽后,将消息发送至DLQ(含原消息体、异常堆栈、重试次数) 死信消息通过独立Kafka生产者发送,不阻塞主消费线程。 系统 高 MQE-02-0005 提交原消息偏移量(仅在死信发送成功后) 避免重复消费(通过KafkaConsumer.commitSync(offset)实现)。 系统 高 MQE-02-0006 记录死信日志(含消息ID、Topic、业务标识、失败时间、错误原因) 运维人员订阅死信topic,通过其中日志追溯上下文 运维人员 中 3.4. 功能模块3:延迟消息管理模块(模块编号03) 典型场景:VMS设备删除时,需先删除事件上报,然后延迟一定时间后删除设备,避免设备删除后仍有事件上报导致数据不一致。方案还未确定,将写出两个方案的需求。 3.4.1 Redis ZSet方案需求 MQE-03-0001 支持开发人员通过sendDelayedEvent(topic, event, delayS)方法发送自定义延迟消息 延迟时间单位为秒(如30分钟=1800s),兼容任意延迟需求。(如需要扩展,添加时间转换或设置不同时间单位的参数) 开发人员 高 MQE-03-0002 消息存储至Redis ZSet(Key格式:vms_delay_) 按Topic隔离数据,避免不同业务消息混淆(如设备事件订单事件)。 系统 高 MQE-03-0003 定时扫描ZSet(间隔可配置,默认1秒)获取到期消息 扫描线程独立于EventCenter主线程,避免资源竞争。 系统 高 MQE-03-0004 到期消息通过EventCenter的send()接口发送至目标Topic(默认原Topic) 开发人员可通过参数指定目标Topic(如sendDelayedEvent(topic, event, delayS, targetTopic))。 开发人员 高 MQE-03-0005 发送失败时重新插入ZSet(新到期时间=当前时间+重试间隔,默认10秒) 重试间隔可通过setDelayRetryInterval(interval)接口配置。 系统 高 3.4.2 Kafka分区延迟方案需求(备选方案) MQE-03-0011 支持开发人员通过sendDelayedEvent(topic, event, delayLevel)接口选择预设延迟等级 延迟等级对应Kafka分区(如等级1→5s分区,等级2→30s分区),需运维配置的等级匹配。 开发人员 高 MQE-03-0012 EventCenter自动根据延迟等级将消息发送至对应分区(如topic-delay-5s) 分区由运维人员提前创建(需满足Kafka分区数≥最大等级数)。 运维人员 高 MQE-03-0013 消费者订阅所有延迟分区,启动时暂停消费 消费者通过pause()暂停分区消费,避免提前拉取未到期消息。 系统 高 MQE-03-0014 定时任务根据当前时间恢复到期分区的消费(如5秒后恢复topic-delay-5s分区) 恢复逻辑通过resume()接口实现,触发消息消费。 系统 高 目前根据调研结果,我的选型是Redis Zset方案,可以提供自定义延迟时间的功能,但是主要的缺点是要引入redis,kafka分区延迟方案不用引入外部组件。(具体方案还需要考虑业务延迟队列在业务中使用次数,在业务中有无需要自定义延迟时间的需求)。 3.5 功能模块4:监控告警模块(模块编号04) MQE-04-0001 监控死信Topic堆积量(按Topic统计) 运维人员订阅死信topic查看,记录死信堆积量。 运维人员 高 MQE-04-0002 统计延迟消息触发成功率(成功数/总到期数) 支持开发人员评估方案效果。基于Redis ZSet方案统计延迟消息触发成功率,如通过Redis计数器实现 开发人员 中 4. 非功能性需求 4.1. UI需求 无独立UI需求 4.2. 模块接口需求 enableDeadLetter(topic, enable) EventCenter 开发人员调用,启用/禁用指定Topic的死信队列(低侵入,不修改原生send()逻辑)。 setRetryCount(topic, count) EventCenter 开发人员调用,重试次数设置(默认为3次) setRetryPolicy(topic, policy) EventCenter 开发人员调用,重试策略设置(默认为指数退避) sendDelayedEvent(topic, event, delayS) EventCenter 开发人员调用,发送Redis ZSet方案的延迟消息(兼容原生send()的序列化配置)。 sendDelayedEvent(topic, event, delayLevel) EventCenter 开发人员调用,发送kafka分区方案的延迟消息(自动路由至对应分区)。 4.3. 性能需求 死信消息处理 并发处理能力≥1000条/秒(单消费者组),响应时间≤200ms(不影响主消费流程)。 Redis ZSet延迟消息方案 扫描间隔误差≤1秒(1秒间隔场景),单线程扫描最大处理1000条/次(可配置)。 kafka分区延迟消息方案(备选方案) 分区恢复延迟≤500ms(确保到期消息及时消费),支持10个预设等级(分区数≥10)。 4.4. 用户体验需求 开发人员调用扩展接口 :接口文档完整率100%,示例代码覆盖90%以上常用场景(如死信启用、延迟发送)。 4.5. 用户技术支持需求 死信消息追溯 运维人员支持通过消息ID查询原消息内容、异常堆栈、重试记录。 延迟消息状态查询 开发人员可通过queryDelayedEventStatus(eventId)接口查看消息状态(待触发/已发送)。 告警日志导出 支持导出一定时间内的告警记录(含触发时间、处理人、解决方案),用于复盘优化。 4.6 单元测试覆盖率 配置管理模块 80% 覆盖配置解析、接口调用校验逻辑。 死信处理模块 80% 覆盖拦截器逻辑、重试策略、死信发送等核心流程。模拟消费失败场景,验证重试次数、死信消息是否包含完整上下文。 延迟消息管理模块 80% 覆盖ZSet操作/分区分桶路由、扫描触发等关键逻辑。测试不同延迟时间的消息是否准时触发,Redis扫描间隔配置是否生效。 5. 可行性分析 配置管理模块开发 MQE-01-0001~0004 中 高 EventCenter接口层。 死信处理模块开发 MQE-02-0001~0006 高 高 Kafka消费者拦截器、EventCenter事件上下文。 延迟消息管理模块开发 MQE-03-0001~0005、MQE-03-0011~0014(备选) 高 高 Redis客户端、Kafka分区路由逻辑(备选)、EventCentersend()接口扩展。 监控告警模块开发 MQE-04-0001~0002 低 中 死信队列消费者。 说明: 核心功能(死信处理、延迟消息管理)需优先实现,确保解决设备删除数据不一致、消费失败丢失问题。 低侵入性设计通过接口扩展实现(如拦截器、sendDelayedEvent()),避免修改EventCenter原生代码(如send()、registerBroadcast()的核心逻辑)。 附录 无 已补充内容:根据现有redis实现代码分析的弃用原因: 单线程消费模型:每个Topic的消费者任务(RedisUnicastConsumerTask/RedisBroadcastConsumerTask)由独立线程池(ThreadPoolExecutor(1,1,...))驱动; 资源消耗高:每个Topic需独立维护线程池(topicExecutorServiceMap)和消费者任务(topicTaskMap),随着Topic数量增长(如百个业务Topic),线程资源和内存占用将显著增加。 监控运维工具缺失:Kafka有成熟的监控工具,而Redis Stream的消息堆积、消费延迟等指标需自定义采集。 而本方案使用Redis将延迟消息存入Redis Zset,一个定时任务线程对集合进行扫描,会避免起过多线程的问题;请根据以下评审意见对以上需求分析报告进行修改:1.eventcenter中redis实现消息队列的方案为何弃用,延迟队列目前选用redis zset方案需要写清楚这部分;2.评判延迟队列redis方案和kafka分区方案吞吐量上大概的值,在何种吞吐量下,redis方案需要扩容,扩容的方案,带来的延时误差;kafka分区方案能实现多大的吞吐量,超过后的策略;3.修改延迟队列redis方案失败逻辑,如何判断成功/失败,为什么会出现从redis拉出来可能会失败的情况,应该消费成功后删除吗,(现有的redis zset方案重新插入redis机制重新设置时间后已经不满足该队列想要的延迟,所以是不是考虑直接使用死信队列处理,重试策略及逻辑均通过死信队列处实现)4.延迟队列kafka分区方案消费者是转发,有专门的消费者订阅专门的延迟队列,利用定时任务唤醒,然后转发至真实消费者,评估一下redis方案中每秒轮询和kafka方案中定时任务的资源消耗,(kafka是队列,先进先出所以订阅相应延时分区的消费者每次只需要pause队列最前面的事件需要等待的时间,到达目标时间后resume后在进行转发),最前同时评估两个方案整体的资源消耗5.请不要使用enableDeadletter接口,可以通过配置或者重载方法来实现是否开启死信队列,优化使用。
最新发布
09-23
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值