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
,包含ListenerContext
,TransferContext
,RetryContext
,AckContext
,CrashContext
不同的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. 总结:
通过对各大核心上下文的源码展示,相信读者心中已经有了一定的思路了,如果有兴趣的话,也可以自己写一个,由于此项目是贡献给公司的,所以很遗憾不能开源。