Delay - 如何用 Redis 打造一个延迟队列、广播(软件架构的设计)

Delay - 如何用 Redis 打造一个延迟队列、广播(软件架构的设计)

文章1:设计概述
文章2:风险、问题、方案
通过前两篇文章的描述,将数据结构的使用和将会出现的问题及解决方案有所阐述,那么在本文中将会继续软件架构的设计,软件架构设计要满足设计原则,那么在这里就将从以下几个方面开始对架构的设计进行阐述:

1. 面向用户

1.1. 消息添加入口
/**
 * 生产者定义
 *
 * @author zyred
 * @since v 0.1
 * @since v 1.0 更名
 */
public abstract class ProviderDelayJob<Q extends QueueJob> {
    /**
     * 添加任务
     *
     * @param job       任务
     * @param topic     主题
     * @param radio     是否需要广播
     * @update v 1.0    新增 radio 参数
     */
    public abstract void addJob (String topic, Q job, boolean radio) throws DelayQueueException;
    /**
     * 删除消息
     *
     * @param jobId 消息ID
     * @param topic 主题
     */
    public abstract Q delete (String topic, Long jobId) throws DelayQueueException;
}

此类为抽象类,实现类则是由延迟队列内部提供,本项目启动阶段会自动将所有依赖的 Spring bean 注入到系统 Spring 内,不需要用户手动添加,如果用户需要使用此类,直接在对应的业务类中是用 @Autowride 注入此抽象类即可。

1.2. 消费端
1.2.1 队列消费
public interface Callback<Q extends QueueJob> {
    /**
     *  使用:
     *      该抽象方法需要开发者自主实现, 此方法是消息过期后调用本方法进行消费消息
     *  功能介绍:
     *      1. 超时策略机制: 本方法自动调用超时, 会有会立即进行重试, 不会重新等待,
     *          yml 中 delay.queue.consume_wait_time 进行设置超时时间,默认 5秒
     *      2. 数据回滚机制: 回滚为执行本方法之前的数据
     *          2.1 超时重试会回滚数据
     *          2.2 异常会回滚数据
     *          2.3 暂时还没发现
     *
     *      3. ack 机制: 开发者非特殊情况下, 消费完毕消息后,必须执行 channel.ack(true) 方法
     *          如果不执行 ack 方法, 数据会进入 un_ack_table 中进行保存, 会被定期清理
     *      4. rejoin 机制:
     *          什么是 rejoin ?
     *            答: 开发者实现 delayListener 方法, 发现过期回调的时候, 并不是此刻该处理的, 需要重新放入到队列中
     *          解决了什么问题 ?
     *            答: 重新放入消息, 我们下意识的会想起来调用 ProviderDelayQueue#addJob() 重新放入消息, 但是这样存在一个问题
     *              放入的新消息,会覆盖调旧的消息, 此时 delayListener() 认为消费成功了,该删除 redis 中的消息了, 然后你的最新的
     *              消息就被删除了 ........
     *          怎么使用 rejoin 机制 ?
     *            答: channel.rejoin(true)
     * 
     *  切记: 该方法内,请勿对线程做中断等操作,否则将会失去一个TOPIC的监听.............
     *
     * @param job   被消费的消息体
     * @param channel   ack rejoin
     */
    void delayListener (Q job, Channel channel);

    /**
     * 最终重试
     *
     * 该方法算是一种兜底方案, 如果用户的消息被消费 N
     * 次后依然失败, 那么将会调用本方法, 来兜底的处理,
     * 具体实现由用户自己处理. N (delay.queue.retry)
     *
     * @param job   消息体
     * @param topic TOPIC-TEST
     */
    void finalRetry (String topic, Q job);
}


// 用户来继承本抽象类,实现 getTopic(), finalRetry(), delayListener(Q, Channel) 方法
public abstract class AbsDelayQueueListener<Q extends QueueJob> implements Callback<Q> {

    /** topic 注册 **/
    public abstract String getTopic ();


    public AbsDelayQueueListener() {
        DelayQueueContextFactory.addListener(this);
    }

    @Override
    public void finalRetry(String topic, Q job) {
        log.warn("[self retry] 默认重试被调用:{}", JSON.toJSONString(job));
    }

    /**
     * 针对没有进行 ack 的数据进行特殊处理
     *
     * 默认处理方案是根据请求的参数进行删除 un_ack 消息
     * 客户端可以进行重写,对自己的逻辑进行完善
     */
    public QueueJob processorUnAck (String topic, Long jobId) {
        return DelayQueueContextFactory.processorUnAck(topic, jobId);
    }
}

用户使用到延迟队列中队列功能,那么就需要对添加的消息进行消费,要消费消息就必须继承 AbsDelayQueueListener 抽象类,并且实现 getTopic()finalRetry()delayListener(Q, Channel) 三个抽象方法,当然 finalRetry() 方法是可以不用重写的,根据业务条件来。

1.2.2 广播消费
public abstract class AbsDelayRadioListener<Q extends QueueJob> implements MessageListener<Q> {
    /**
     * 客户端订阅多个频道
     *
     * @return  多个频道
     * @since v 1.0
     */
    public abstract Set<String> getChannels ();
    /**
     * 获取消息体的 class 对象
     *
     * @return  class 对象
     * @since v 1.0
     */
    public abstract Class<Q> getClazz ();
}

该抽象类是广播的监听器,若开启了广播功能,且要使用广播功能,那么就必须继承本抽象类重写 getChannels ()getClazz ()onMessage(CharSequence, DelayQueueJob) 三个方法,且 getClazz () 是返回消息体的 class 对象,onMessage() 则是消费广播的方法,提供给用户处理业务逻辑。

2. 系统内架构设计

在这里插入图片描述

2.1 ContextFactory 上下文工厂

上下文工厂,顾名思义,是来创建上下文的一个工厂,是一个静态工厂,该工厂主要负责

  • 类中所有监听器的扫描和注册(将监听器与线程做绑定关系)
 private void loadListeners() {
    // 每一个启动一个线程去执行
    Set<Map.Entry<String, AbsDelayQueueListener<QueueJob>>> entries = LISTENER_HOLDER.entrySet();
    for (Map.Entry<String, AbsDelayQueueListener<QueueJob>> entry : entries) {
        if (entry.getKey().length() > KEY_MAX_LENGTH) {
            this.shutdown();
            this.queueThreadPoolFactory.getListenersThreadPool().shutdown();
            throw new DelayQueueException("TOPIC [" + entry.getKey() + "] 长度超过 " + KEY_MAX_LENGTH + " 的限制!");
        }
        // 每循环一次,将创建一个线程调用 doLoadListener() 
        this.queueThreadPoolFactory.getListenersThreadPool()
        		// 见 2.2.1 `ListenerContext` 监听上下文代码块
                .execute(() -> this.listenerContext.doLoadListener(entry));
    }
}
  • 初始化所有的子 context ,包含 ListenerContextTransferContextRetryContextAckContextCrashContext 不同的 context 所做的事情不同,职责划分明确。
2.1.1 ListenerContext 监听上下文

系统中任何实现了 AbsSelayQueueListener 的接口都被成为监听器,想要所有的监听器都能消费自己监听的 TOPIC 的消息,那么都要被统一管理起来,后续有消息触发消费的时候,可以准确的找到监听器的实现类,从而调用 delayListener 方法进行业务处理。

// ContextFactory 调用, 2.1 进入
public <Q extends QueueJob> void doLoadListener(Map.Entry<String, AbsDelayQueueListener<Q>> entry) {
    final String self = Constant.colon.concat(entry.getKey());
    final String transferLockKey = RedisKeyUtil.getLockKey(this.properties.getAppName(), RedisKeyUtil.IN_TRANSIT_KEY).concat(self);
    final String readyKey = RedisKeyUtil.getReadyKey(properties, entry.getKey());
    
    while (shutdown) {
        // 从下层 QueueOperation 从读取超时的消息
        Set<String> selfTopics = this.delayQueueOperation.distributedSafeReadReadySelfJob(transferLockKey, readyKey);
        if (CollectionUtils.isEmpty(selfTopics)) {
        	// 任何一个线程进入后,没有消费的 TOPIC 的时候就进入睡眠
            TransferListenSynchronizer.await(entry.getKey());
            continue;
        }
        // 有消息,则调用本类中的处理方法, 代码如下
        this.processSelfMetadata(selfTopics, entry.getValue());
    }
    log.info("[listen] {} 线程结束延迟队列的监听.....", Thread.currentThread());
}

处理自己 TOPIC 超时的消息

private <Q extends QueueJob> void processSelfMetadata(Set<String> selfTopics,
														  // 一直持有实现类的引用,从而可以轻易调用实现方法
                                                          AbsDelayQueueListener<Q> self) {
    Iterator<String> iterator = selfTopics.iterator();
    while (iterator.hasNext()){
        // 宕机等待善后,不往下走
        if (!shutdown) {
            break;
        }
        String selfTopic = iterator.next();
        // 读取元数据 metadata
        Q job = (Q) this.delayQueueOperation.readQueueMetadataJob(selfTopic);
        if (Objects.isNull(job)) {
        	// 读不到数据,说明有误,直接删除错误的数据
            this.delayQueueOperation.delNotFountMetadata(selfTopic, self.getTopic());
            continue;
        }
        // 深克隆,防止用户在失败的方法中操作内容覆盖掉正确的内容
        Q deep = (Q) new DeepCopyUtils().copy(job);
        try {
            // 创建 ack rejoin 标识对象
            Channel channel = new Channel(QueueAckContext.getAckTag());
            // 执行用户回调
			this.distributedSafeTimeoutExecuteCallbackMethod(self, channel, job);
            if (log.isDebugEnabled()) {
                log.debug("[listen] topic:{} 消费了消息:{}", selfTopic, JSON.toJSONString(job));
            }
            // 处理成功后处理 ack 和 rejoin 逻辑
            this.ackContext.processAck(self, deep, job, selfTopic);
            iterator.remove();
        } catch (Exception ex) {
            log.error("[listen] 回调业务执行异常:{}", ex.getMessage(), ex);
            // 上面调用业务失败后,直接调用重试机制
            this.retryContext.retry(selfTopic, deep, self, job.getRetryInterval());
        }
    }
}

回调继承 AbsDelayQueueListener#delayListener 的方法

private <Q extends QueueJob> void distributedSafeTimeoutExecuteCallbackMethod(AbsDelayQueueListener<Q> self,
                                                                                     final Channel channel, final Q job) throws Exception {
    // ack 标识上推
    final ThreadLocal<Integer> listenThread = QueueAckContext.getAckTag();
    final Duration waitTime = this.properties.getConsumeWaitTime();
    final long lockWaitTime = this.properties.getLockWaitTime();

    // 等待时间为 0 的时候, 不走超时
    if (waitTime.getSeconds() <= 0) {
        try {
            self.delayListener(job, channel);
        } catch (Exception ex) {
            log.error("[listen] 业务执行失败, 异常:{}", ex.getMessage(), ex);
            throw ex;
        }
    }

    // 设置超时执行的时间走超时逻辑
    try {
        FutureTask<Integer> ft = new FutureTask<>(() -> {
            self.delayListener(job, channel);
            // 返回被执行线程所拥有的状态码,放入主线程中
            return QueueAckContext.removeAck();
        });
        // 异步执行
        this.queueThreadPoolFactory.getTimeoutThreadPool().execute(ft);
        // 超时线程 ack 标识指向监听线程
        listenThread.set(ft.get(waitTime.getSeconds(), TimeUnit.SECONDS));
    } catch (Exception ex) {
        log.error("[listen] 超时业务执行失败, 异常:{}", ex.getMessage(), ex);
        throw ex;
    }
}
2.1.2 TransferContext 搬运上下文

结合第一篇文档的数据结构剖析中,所谓的搬运,就是将消息 zset 中超时的数据,搬运到不同机器所指向的 set 队列内

/**
 * 1. 智能睡眠
 * 2. 分布式安全搬运
 * 3. 搬运时不打断消费
 * 4. 搬运失败回滚 (失败一个, 批量回滚)
 * 5. 批量搬运 (扫描全部即将过期的)
 * 6. 单线程搬运, 线程安全 (单服务内只有一个线程)
 * 7. 分布式机器隔离搬运
 */
private void transfer() {
    // {redis-delay-queue}:timeout_table
    final String timeoutKey = RedisKeyUtil.getTopKey(this.properties.getAppName(), RedisKeyUtil.SET_KEY_PREFIX);
    //  {redis-delay-queue}:ready_table
    final String readyKey = RedisKeyUtil.getTopKey(this.properties.getAppName(), RedisKeyUtil.READY_KEY_PREFIX);
    
    this.queueThreadPoolFactory.getTransferThreadPool().execute(() -> {
        while (shutdown) {
            try {
            	// 搬运完毕一次后,扫描以下还有多少剩余未搬运的,通过剩余的来计算睡眠的时间
                long remain = this.delayQueueOperation.distributedSafeTransfer(readyKey, timeoutKey);
                // 智能睡眠
                SmartSleepUtil.sleep(remain);
            } catch (Exception ex) {
                log.error("[transfer] 搬运发生异常, ex: {}", ex.getMessage(), ex);
            }
        }
        log.info("[transfer] 停止.........");
    });
}
2.1.3 RetryContext 重试上下文

重试,当业务处理失败后会进入到 RetryContext 中处理重试,在 ListenerContext 中调用 distributedSafeTimeoutExecuteCallbackMethod 方法失败的情况,会进入重试,重试代码如下:

/**
 * 分布式重试
 *
 * @param <Q>               消息内容泛型
 * @param selfTopic         TEST-TOPIC:2324234234
 * @param deep              深克隆的对象
 * @param self              监听器本身
 * @param retryInterval     重试间隔时间
 */
public <Q extends QueueJob> void retry(String selfTopic, Q deep, AbsDelayQueueListener<Q> self, int retryInterval) {

    // 重试间隔时间不允许小于 0
    if (retryInterval <= 0) {
        log.warn("[retry] 重试间隔时间 {} 小于或等于 0,取默认值:{}", retryInterval, deep.getRetryInterval());
        retryInterval = deep.getRetryInterval();
    }

    if (deep.getRetry() >= (this.properties.getRetry())) {
        log.warn("[retry] TOPIC: {} 已经被重试 {} 次,不再重试", self.getTopic(), deep.getRetry());
        // 删除消息 ready metadata un_transfer
        this.delayQueueOperation.retryFailDelete(self.getTopic(), deep.getJobId());
        // 调用用户提供的最终通知
        self.finalRetry(self.getTopic(), deep);
        return;
    }

    // 设置重试次数 + 1, 并保存到 metadata 中
    deep.setRetry(deep.getRetry() + 1);
    boolean updateJob = this.delayQueueOperation.updateMetadataJob(selfTopic, deep);
    if (updateJob) {
        log.info("[retry] topic:{} 重试次数:{},metadata:{}", selfTopic, deep.getRetry(), JSON.toJSONString(deep));
        deep.setDelayTime(System.currentTimeMillis() + retryInterval);
        this.delayQueueOperation.consumeFailRollback(self.getTopic(), deep);
    }
}
2.1.4 AckContext 消息确认上下文

消息确认机制,保证消息能成功被消费,如果没有 ack,那么消息将会进入 un_ack 队列中进行保存,处理逻辑如下:

/**
 * 主要包含:
 * 1. 业务无异常且 ack
 * 2. 业务无异常无 ack
 * 3. 业务无异常,ack 且 rejoin
 * 4. 业务无异常,无 ack 无 rejoin
 * 5. 业务无异常,无 ack 且 rejoin
 *
 * @param self      监听器本身
 * @param deep      处理之前的深拷贝 job 对象
 * @param job       未深拷贝的对象
 * @param selfTopic TOPIC:JobId
 */
public void processAck (AbsDelayQueueListener<Q> self, final Q deep, final Q job, final String selfTopic) {
    final int tag = QueueAckContext.removeAck();
    final boolean ack = (tag & 2) == 2, rejoin = (tag & 4) == 4;
    // 啥也没处理, 需要 un_ack
    if (ack) {
        // ack 后, 删除元数据 与 消费队列
        this.delayQueueOperation.postConsumerDelete(self.getTopic(), deep.getJobId());
        if (log.isDebugEnabled()) {
            log.debug("[ack] TOPIC: {} 业务回调成功, JOB: {}", self.getTopic(), deep.getJobId());
        }
    } else {
        // 只做了 ack, 不需要重入, 才 put un ack
        if (!rejoin) {
            log.warn("[ack] 消息未被 ack, topic: {}, JOB:{}", self.getTopic(), JSON.toJSONString(deep));
            // 从待消费的队列移动到未确认的队列
            this.delayQueueOperation.putUnAckJob(selfTopic, self.getTopic(), deep);
        }
    }
    // 需要重新入队列
    if (rejoin) {
        job.setRejoin(true);
        // 如果用户设置了大于当前时间, 就使用用户的, 如果没有, 就使用自己的
        if (job.getDelayTime() < System.currentTimeMillis()) {
            job.setDelayTime(Constant.expire);
        }
        String newKey = RedisKeyUtil.getSecondKey(self.getTopic(), job.getJobId());
        if (!Objects.equals(newKey, selfTopic)) {
            throw new DelayQueueException("[rejoin] 请勿修改jobId后重新入队,原始TOPIC:"
                    + selfTopic + ", 当前TOPIC:" + newKey);
        }
        // 添加到主队列,尝试分布式重试
        this.delayQueueOperation.addJob(self.getTopic(), job);
        // 删除 un ack
        this.delayQueueOperation.deleteUnAckJob(self.getTopic(), job.getJobId());
        log.info("[rejoin] 消息重新入队, 等待过期消费:topic:{}, JOB:{}", selfTopic, JSON.toJSONString(job));
        // 重入队列后,需要立马唤醒搬运线程检查消息的过期情况
        SmartSleepUtil.immediatelyArouse();
    }
}

Channel 对象

/**
 * ack、rejoin 标识的传递
 *
 * ack:f    rejoin: f   ->  ackTag = 0
 * ack:t    rejoin: f   ->  ackTag = 2
 * ack:f    rejoin: t   ->  ackTag = 4
 * ack:t    rejoin: t   ->  ackTag = 6
 *
 * 对于系统而言, 有效标识则是: 2、4
 *
 * @author zyred
 * @since v 0.1
 */
public class Channel {

    /** ack、rejoin 线程私有的标识 **/
    private final ThreadLocal<Integer> ackTag;

    public Channel(ThreadLocal<Integer> ackTag) {
        this.ackTag = ackTag;
    }

    public void ack () {
        this.ack(true);
    }

    /**
     * ack 后的消息将会被删除,表明该消息被消费者成功消费
     *
     * 未 ack 的消息将会进入 un_ack_table redis set 集合中
     * 用户可以统一调用 AbsDelayQueueListener#processorUnAck()
     * 对未 ack 的消息进行处理
     *
     * @param ack   true: ack, false:未 ack
     */
    // 1 << 1           0000 0010   2
    // 1 << 1 | 1 << 2  0000 0110   6
    public void ack (boolean ack) {
        if (!ack) {
            return;
        }
        ackTag.set((ackTag.get() == 1 << 2) ? (1 << 2 | 1 << 1) : (1 << 1));
    }

    @SuppressWarnings("all")
    public void unack () {
        ack(false);
    }


    public void rejoin() {
        rejoin(true);
    }

    /**
     * 重新入队,调用此方法后,在监听器 Callback#delayListener() 方法对 QueueJob 对象修改的任何字段将会失效
     * 换句话说就是,消费者在消费的方法内对 QueueJob 对象的任何字段做的修改,将会无效,重新进入队列的 QueueJob
     * 对象将会是第一次 add 的原生对象
     *
     * @param rejoin    true: 重新入队列, false:不入队(等同于不调用此方法)
     */
    // 1 << 2           0000 0100   4
    // 1 << 2 | 1 << 1  0000 0110   6
    public void rejoin (boolean rejoin) {
        if (!rejoin) {
            return;
        }
        ackTag.set((ackTag.get() == 1 << 1) ? (1 << 1 | 1 << 2) : (1 << 2));
    }
}
2.1.5 CrashContext 关机善后处理上下文

在文章 《风险、问题、方案》 中,提及到 单机宕机后消息如何回滚 的问题,那么这里就通过 CrashContext 完成的,实现原理主要是依赖于 spring boot 中关机前发布的 ContextCloseEvent 事件,监听这个事件来对自己未消费的小重新放入到 Zset 集合中

public class CrashHandlerContext<Q extends QueueJob> implements ApplicationListener<ContextClosedEvent> {

    private final DelayQueueContextFactory factory;
    private final RedisDelayQueueProperties properties;

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        this.factory.shutdown();
        if (properties.getEnableCrash()) {
            factory.crashRollback();
        }
    }
}

public void crashRollback () {
    // 处理所有消费者所在的队列
    Set<String> listeners = LISTENER_HOLDER.keySet();
    this.delayQueueOperation.listenerAftermathRestore(listeners);
}

3. 总结:

通过对各大核心上下文的源码展示,相信读者心中已经有了一定的思路了,如果有兴趣的话,也可以自己写一个,由于此项目是贡献给公司的,所以很遗憾不能开源。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值