Delay - 如何用 Redis 打造一个延迟队列、广播(风险、问题、方案)
从 上一篇文章中 的描述,知道了数据结构与线程之间的关系,那么就针对延迟队列暴露的问题进行探讨和解决方案。
1. 消息确认机制
使用过消息队列的同学都知道消息队列都是需要消息确认的,那么消息确认的目的就是为了告诉服务器延迟队列中,这个消息已经被成功消费,可以进行删除消息了,对于 Redis
而言,所有的数据都是存储在内存中,而内存是非常稀缺的资源,数据不能一直占用内存,从而影响其他的应用程序正常的运行
在 Redis
延迟队列中,采用的是如下方式进行消息确认的:
@Override
public void delayListener (DelayQueueJob job, Channel channel) {
// todo ......
channel.ack();
}
只有当开发者调用了 channel.ack()
这个方法后,延迟队列才会认为消息被消费了
2. 消息失败重试
当消费者消费消息时,一旦出现了任何异常,那么就意味着业务处理失败,需要对失败的消息进行重试,但是重试又不能无限制的重试,所以得给重试设置一个阈值,一旦重试次数超过了这个限制,就不再重试,Redis
延迟队列重试机制代码如下:
/**
* 分布式重试
* @param <Q> 消息内容泛型
* @param selfTopic TEST-TOPIC:messageId
* @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);
}
}
在 1.0
版本中,重试间隔时间由系统全局属性,任何的一个消息的重试间隔时间都是从配置文件中读取,在 1.1
版本中进行了优化,考虑到每个场景的重试时间不同,所有将重试的间隔时间放入到了消息体内,默认 3000ms
。
3. 消息重新入队
所谓的消息重新入队,这个概念也比较抽象,这是我在工作中遇到的一个问题产生的一个解决方案:
例如:小明在窗口办理手机卡,办理完毕后客服人员让小明先30分钟,30分钟后小明去问客服,客服告诉小明还没弄好,还要等30分钟,于此往复,一直等待卡办好为止。
通过以上的生活中实际的场景,即便是将某个消息放入到消息队列后,消息过期了但是依然没有到消费的时机,此时这个消息需要重新进入到队列中。按照 RabbitMQ
的做法就是通过业务判断后,再调用 rabbitTemplate.send()
,然而在 Redis
延迟队列中只需要在业务中调用 channel.rejoin()
即可。
客户端逻辑
@Override
public void delayListener (DelayQueueJob job, Channel channel) {
// todo ..........
channel.rejoin();
}
redis 延迟队列针对 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());
4. 重试次数超过阈值后的兜底方案设计
业务逻辑处理失败后,消息重试次数到达阈值后,将不会再次重试。在 Redis
延迟队列中,会主动调用用户重写的 finalRetry
方法。提供此方法的目的是为了处理失败后,有一个兜底的处理方案,而不需要用户主动开发兜底方案的处理逻辑。
@Override
public void finalRetry(String topic, DelayQueueJob job) {
// todo .......
}
5. 搬运线程性能问题
通过上一篇文章中的描述,小明是消费线程,而班主任则是搬运线程。想要做到精准无误的唤醒某个线程,那么就需要将线程进行统一维护,且每个线程有一个特殊标识。通过 AQS
源码的设计思路,我们在设计这个线程模型的时候也可以借鉴此思路。采用双向链表来存储消费线程,并将每个消费线程所消费的 TOPIC
与线程进行绑定,存储到双向链表中,一旦有就绪待消费得消息,就通过搬运线程拿到 topic
后唤醒指定 topic
的线程,代码如下:
Node 双向链表
public class Node {
volatile Node next;
/** 设计有变动, 所以该字段未使用 **/
volatile Node prev;
Thread thread;
/** 线程所在监听器的topic **/
String topic;
/** 唤醒标识 **/
boolean wakeUp = false;
Node(Thread thread, String topic) {
this.thread = thread;
this.topic = topic;
}
}
同步器
public class TransferListenSynchronizer {
private static final Set<String> listener_container = new ConcurrentSkipListSet<>();
private static final AtomicBoolean park = new AtomicBoolean(false);
private volatile static Node head = null;
private volatile static Node next = null;
/**
* 唤醒指定消费线程
* @param wakeUps 允许唤醒多个线程
*/
private static int restart (Set<String> wakeUps) {
int count = 0;
Node t = head;
// 从头开始遍历链表,只要符合条件的就唤醒
while (t != null) {
if (wakeUps.contains(t.topic) && !t.wakeUp) {
LockSupport.unpark(t.thread);
t.wakeUp = true;
count ++;
if (log.isDebugEnabled()) {
log.info("[synchronizer] 线程 [{}] 被唤醒, topic: {}", t.thread.getName(), t.topic);
}
}
t = t.next;
}
return count;
}
/**
* 消费者线程调用等待方法
*
* @param topic topic
*/
public static void await(String topic) {
for (;;) {
if (park.compareAndSet(false, true)) {
Thread thread = Thread.currentThread();
if (!listener_container.contains(topic)) {
if (head == null) {
head = next = new Node(thread, topic);
} else {
Node node = new Node(thread, topic);
next.next = node;
node.prev = next;
next = node;
}
listener_container.add(topic);
} else {
Node t = head;
while (t != null) {
if (t.thread == Thread.currentThread()) {
t.wakeUp = false;
break;
}
t = t.next;
}
}
if (log.isDebugEnabled()) {
log.debug("[synchronizer] 线程睡眠: [{}] topic: {}", thread.getName(), topic);
}
// 睡眠之前,先释放掉 cas
park.compareAndSet(true, false);
// 进入睡眠
LockSupport.park();
// 唤醒后,开始在这里执行
if (log.isDebugEnabled()) {
log.debug("[synchronizer] 线程唤醒 [{}] topic: {}", thread.getName(), topic);
}
break;
}
}
}
/**
* 将准备完毕的 topic 去重后唤醒线程
*
* @param readyTopics 准备完毕的 topic
*/
public static int assignTasks (Collection<String> readyTopics) {
if (CollectionUtils.isEmpty(readyTopics)) {
return 0;
}
Set<String> wakeUp = new HashSet<>();
for (String readyTopic : readyTopics) {
String topic = readyTopic.split(Constant.colon)[0];
wakeUp.add(topic);
}
// 唤醒线程
return restart(wakeUp);
}
}
6. 分布式消息重复消费
想要解决分布式的问题,就先要了解分布式的本质。分布式无非就是N台机器来同时搬运,且N台机器同时消费 set
集合里面已经就绪的数据。思考一下每台机器的能直观拿到的区别是什么?是的,就是 IP+PORT
,在设计 set
集合的 key
的时候,只需要在 key
上加载 IP
和 PORT
即可标明不同的机器所绑定的队列,用简单通俗易懂的话来说就是每台机器只消费自己机器所绑定的队列即可,这样就不会产生消息被其他机器消费的问题。效果图如下:
下图为4台机器,不同的机器所对应的队列不同
7. 单机宕机后消息如何回滚
通过第6点中分布式重复消费的问题解决,这样会带来一个非常严重的问题,一旦某一台机器宕机了,那么这台机器对应的消息将不能被其他机器消费,等同于绑定这个队列的机器一直不重启,消息就将永远的停留在队列中不能被消息。为了解决这个棘手的问题,我开始从 JVM
入手,找到 JVM
关机前的钩子函数,将关机前处理的事务放到钩子函数中,这样就完美解决这个问题。由于 spring
源码中也注册了关机时间,那就直接利用 spring
关机事件来做回调,这样会更加的灵活。
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);
}
public void listenerAftermathRestore(Set<String> listenersTopic) {
if (CollectionUtils.isEmpty(listenersTopic)) {
return;
}
StringBuilder readyTableKeyBuilder = new StringBuilder();
int i = 0;
for (String listenerTopic : listenersTopic) {
readyTableKeyBuilder.append(RedisKeyUtil.getReadyKey(this.properties, listenerTopic));
if ((i + 1) != listenersTopic.size()) {
readyTableKeyBuilder.append(Constant.comma);
}
++ i;
}
final String finalListenersTopic = readyTableKeyBuilder.toString();
final String timeoutKey = RedisKeyUtil.getTopKey(this.properties.getAppName(), RedisKeyUtil.SET_KEY_PREFIX);
final String shutdownAftermathRestoreScript = LuaConstant.getShutdownAftermathRestoreScript();
List<Object> keys = Arrays.asList(timeoutKey);
try {
RScript script = this.redissonClient.getScript(new StringCodec());
Object result = script.eval(RScript.Mode.READ_ONLY, shutdownAftermathRestoreScript,
RScript.ReturnType.VALUE, keys, System.currentTimeMillis(), finalListenersTopic);
log.info("[善后] 处理结果:{}", result);
} catch (Exception ex) {
log.error("[善后] 执行失败 exception:{}", ex.getMessage(), ex);
}
}
8. 吞吐量和性能
最初设计的延迟队列是使用 list
数据结构来充当消费队列,消费者每次消费的时候去拿一个来消费即可,但是这样吞吐量是真的低下,于是将 list
更换为 set
集合,每次批量拿取待消费的消息,处理完毕一个,就删除一个,这样吞吐量很快就上去了。
总结
通过上述的描述,提出了一些问题,并给了一些解决方案,通过这些打底的理论知识就可以对软件架构进行设计,那么进入下一篇文章 《软件架构的设计》