早期需要延迟处理的业务场景,更多的是通过定时任务扫表,然后执行满足条件的记录,具有频率高、命中低、资源消耗大的缺点。随着消息中间件的普及,延迟消息可以很好的处理这种场景,本文主要介绍延迟消息的使用场景以及基于常见的消息中间件如何实现延迟队列,最后给出了一个在网易公开课使用延迟队列的实践。
一、使用场景
1、有效期:限时活动、拼团。。。
2、超时处理:取消超时未支付订单、超时自动确认收货。。。
3、延迟处理:机器人点赞/观看数/评论/关注、等待依赖条件。。。
4、重试:网络异常重试、打车派单、依赖条件未满足重试。。。
5、定时任务:智能设备定时启动。。。
二、常见延迟队列对比
1、RabbitMQ
1)简介:基于AMQP协议,使用Erlang编写,实现了一个Broker框架
a、Broker:接收和分发消息的代理服务器
b、Virtual Host:虚拟主机之间相互隔离,可理解为一个虚拟主机对应一个消息服务
c、Exchange:交换机,消息发送到指定虚拟机的交换机上
d、Binding:交换机与队列绑定,并通过路由策略和routingKey将消息投递到一个或多个队列中
e、Queue:存放消息的队列,FIFO,可持久化
f、Channel:信道,消费者通过信道消费消息,一个TCP连接上可同时创建成百上千个信道,作为消息隔离
2)延迟队列实现:RabbitMQ的延迟队列基于消息的存活时间TTL(Time To Live)和死信交换机DLE(Dead Letter Exchanges)实现
a、TTL:RabbitMQ支持对队列和消息各自设置存活时间,取二者中较小的值,即队列无消费者连接或消息在队列中一直未被消费的过期时间
b、DLE:过期的消息通过绑定的死信交换机,路由到指定的死信队列,消费者实际上消费的是死信队列上的消息
3)缺点:
a、配置麻烦,额外增加一个死信交换机和一个死信队列的配置
b、脆弱性,配置错误或者生产者消费者连接的队列错误都有可能造成延迟失效
2、RocketMQ
1)简介:来源于阿里,目前为Apache顶级开源项目,使用Java编写,基于长轮询的拉取方式,支持事务消息,并解决了顺序消息和海量堆积的问题
a、Broker:存放Topic并根据读取Producer的提交日志,将逻辑上的一个Topic分多个Queue存储,每个Queue上存储消息在提交日志上的位置
b、Name Server:无状态的节点,维护Topic与Broker的对应关系以及Broker的主从关系
2)延迟队列实现:RocketMQ发送延时消息时先把消息按照延迟时间段发送到指定的队列中(rocketmq把每种延迟时间段的消息都存放到同一个队列中),然后通过一个定时器进行轮训这些队列,查看消息是否到期,如果到期就把这个消息发送到指定topic的队列中
3)缺点:延迟时间粒度受限制(1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h)
3、Kafka
1)简介:来源于Linkedin,目前为Apache顶级开源项目,使用Scala和Java编写,基于zookeeper协调的分布式、流处理的日志系统,升级版为Jafka
2)延迟队列实现:Kafka支持延时生产、延时拉取、延时删除等,其基于时间轮和JDK的DelayQueue实现
a、时间轮(TimingWheel):是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表
b、定时任务列表(TimerTaskList):是一个环形的双向链表,链表中的每一项表示的都是定时任务项
c、定时任务项(TimerTaskEntry):封装了真正的定时任务TimerTask
d、层级时间轮:当任务的到期时间超过了当前时间轮所表示的时间范围时,就会尝试添加到上层时间轮中,类似于钟表就是一个三级时间轮
e、JDK DelayQueue:存储TimerTaskList,并根据其expiration来推进时间轮的时间,每推进一次除执行相应任务列表外,层级时间轮也会进行相应调整
3)缺点:
a、延迟精度取决于时间格设置
b、延迟任务除由超时触发还可能被外部事件触发而执行
4、ActiveMQ
1)简介:基于JMS协议,Java编写的Apache顶级开源项目,支持点对点和发布订阅两种模式。
a、点对点(point-to-point):消息发送到指定的队列,每条消息只有一个消费者能够消费,基于拉模型
b、发布订阅(publish/subscribe):消息发送到主题Topic上,每条消息会被订阅该Topic的所有消费者各自消费,基于推模型
2)延迟队列实现:需要延迟的消息会先存储在JobStore中,通过异步线程任务JobScheduler将到达投递时间的消息投递到相应队列上
a、Broker Filter:Broker中定义了一系列BrokerFilter的子类构成拦截器链,按顺序对消息进行相应处理
b、ScheduleBroker:当消息中指定了延迟相关属性,并且jobId为空时,会生成调度任务存储到JobStore中,此时消息不会进入到队列
c、JobStore:基于BTree存储,key为任务执行的时间戳,value为该时间戳下需要执行的任务列表
d、JobScheduler:取JobStore中最小的key执行(调度时间最早的),执行时间<=当前时间,将该任务列表依次投递到所属的队列,对于需要重复投递和投递失败的会再次存入JobStore中。
注:此处JobScheduler的执行时间间隔可动态变化,默认0.5s,有新任务时会立即执行(Object->notifyAll())并设置时间间隔为0.1s,没有新任务后,下次执行时间为最近任务的调度执行时间
3)缺点:投递到队列失败,将消息重新存入JobStore,消息调度执行时间=系统当前时间+延迟时间,会导致消息被真实投递的时间可能为设置的延迟时间的整数倍
5、Redis
1)简介:基于Key-Value的NoSQL数据库,由于其极高的性能常被当作缓存来使用,其数据结构支持:字符串、哈希、列表、集合、有序集合
2)延迟队列实现:Redis的延迟队列基于有序集合,score为执行时间戳,value为任务实体或任务实体引用
3)缺点:
a、实现复杂,本身不支持
b、完全基于内存,延迟时间长浪费内存资源
6、消息队列对比
三、网易公开课使用ActiveMQ作为延迟队列
1、公开课延迟队列技术选型
1)业务场景:关闭超时未支付订单、限时优惠活动、拼团
2)性能要求:订单、活动、拼团 数据量可控,上述MQ均能满足要求
3)可靠性:使用ActiveMQ、RabbitMQ、RocketMQ作为延迟队列更普遍
4)可用性:ActiveMQ、RocketMQ自身支持延迟队列功能,且目前公开课业务中使用的中间件为ActiveMQ和Kafka
5)延迟时间灵活:活动的开始和结束时间比较灵活,而RocketMQ时间粒度较粗,Kafka会依赖时间格有精度缺失
结论:最终选择ActiveMQ来作为延迟队列
2、业务场景:关闭未支付订单
1)关闭微信未支付订单
2)关闭IOS未支付订单
3、ActiveMQ使用方式
1)activemq.xml中支持调度任务
2)发送消息时,设置message的延迟属性
其中:
a、延迟处理
AMQ_SCHEDULED_DELAY:设置多长时间后,投递给消费者(毫秒)
b、重复投递
AMQ_SCHEDULED_PERIOD:重复投递时间间隔(毫秒)
AMQ_SCHEDULED_REPEAT:重复投递次数
c、指定调度计划
AMQ_SCHEDULED_CRON:corn正则表达式
4、公开课使用中进行的优化
1)可靠性:针对实际投递时间可能翻倍的问题,结合ActiveMQ的重复投递,在消费者逻辑中做幂等处理来保证延迟时间的准确性
2)可追溯性:延迟消息及消费情况做数据库冗余存储
3)易用性:业务上定义好延迟枚举类型,直接使用JmsDelayTemplate发送,无需关心数据备份和参数等细节
5、具体代码
1)定义延迟消息
package com.netease.open.common.jms;
import com.netease.open.common.utils.UUIDUtils;
/**
* 延迟消息
* @param
*/
public class DelayMessage extends CommonMessage {
/**
* 自定义延迟消息ID
*/
private String ownMessageId;
public DelayMessage() {}
public DelayMessage(String ownMessageId, T t) {
super(t);
this.ownMessageId = ownMessageId;
}
public DelayMessage(String ownMessageId, String messageType, T messageBody) {
super(messageType, messageBody);
this.ownMessageId = ownMessageId;
}
/**
* 获取延迟消息唯一标识
* @return
*/
public static String generateOwnMessageId() {
return UUIDUtils.get16UUIDString();
}
public String getOwnMessageId() {
return ownMessageId;
}
public void setOwnMessageId(String ownMessageId) {
this.ownMessageId = ownMessageId;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("DelayMessage{");
sb.append(""ownMessageId":"")
.append(ownMessageId).append('"');
sb.append('}');
sb.append(super.toString());
return sb.toString();
}
}
2)定义延迟业务类型
package com.netease.open.common.jms;
/**
* JMS 延迟类型枚举
* 1)动态延迟时间:不应该在此处定义延迟时间@delayTime,应该在调用发送方法时指定延迟时间
* 2)延迟消息投递失败,会在下一个延迟时间@delayTime后重新投递,即实际延迟时间可能是指定延迟时间@delayTime的整数倍(>=1);
* 若要保证消息在延迟时间@delayTime到达后被及时消费,应结合重复投递@periodTime+@repeat来实现该功能(消费者要做幂等处理)
*/
public enum JmsDelayEnum {
CLOSE_WX_UNPAY_ORDER(1, 2 * 60 * 60 * 1000, null, null), // 延迟2h关闭微信未支付订单,无需重复投递
CLOSE_APPLE_UNPAY_ORDER(2, 4 * 60 * 60 * 1000, null, null), // 延迟4h关闭苹果未支付订单,无需重复投递
DISCOUNT_ACTIVITY_START(3, 0, null, null); // 优惠活动开始时逻辑处理(延迟时间根据活动自定义),无需重复投递
private int type; // 业务类型
private long delayTime; // 延迟时间
private Long periodTime; // 重复投递时间间隔
private Integer repeat; // 重复投递次数,例:共投递3次,此处值设置2
JmsDelayEnum(int type, long delayTime, Long periodTime, Integer repeat) {
this.type = type;
this.delayTime = delayTime;
this.periodTime = periodTime;
this.repeat = repeat;
}
/**
* 获取延迟时间(单位:毫秒)
* @return
*/
public long getDelayTime() {
return this.delayTime;
}
/**
* 获取延迟队列业务类型
* @return
*/
public int getType() {
return this.type;
}
/**
* 获取重复投递时间间隔(单位:毫秒)
* @return
*/
public Long getPeriodTime() {
return periodTime;
}
/**
* 获取重复投递次数(实际投递次数-1)
* @return
*/
public Integer getRepeat() {
return repeat;
}
}
3)定义延迟消息模版
package com.netease.open.common.jms;
import com.netease.open.common.jms.dao.JmsDelayRecordDao;
import com.netease.open.common.jms.domain.JmsDelayRecord;
import java.io.Serializable;
import java.util.Date;
import javax.jms.Destination;
import javax.jms.JMSException;
import javax.jms.ObjectMessage;
import javax.jms.Session;
import org.apache.activemq.ScheduledMessage;
import org.apache.activemq.command.ActiveMQQueue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jms.core.JmsTemplate;
/**
* JMS 延迟队列消息发送模版
* 延迟队列三种玩法:1)延迟指定时间投递;2)指定重复投递的次数和时间间隔;3)配置调度执行计划corn
* 参考 http://activemq.apache.org/delay-and-schedule-message-delivery.html
*/
public class JmsDelayTemplate {
private Logger logger = LoggerFactory.getLogger(JmsDelayTemplate.class);
private JmsTemplate jmsTemplate;
@Autowired
private JmsDelayRecordDao jmsDelayRecordDao;
/**
* 发送消息到延迟队列:延迟时间取自延迟类型
* @param destination 目标队列
* @param msg 消息内容
* @param jmsDelayEnum 延迟投递类型
*/
public void send(Destination destination, Serializable msg, JmsDelayEnum jmsDelayEnum) {
this.send(destination, msg, jmsDelayEnum, jmsDelayEnum.getDelayTime());
}
/**
* 发送消息到延迟队列:延迟时间取自参数
* @param destination 目标队列
* @param msg 消息内容
* @param jmsDelayEnum 延迟投递类型
* @param delayTime 延迟时间(单位:毫秒)
*/
public void send(Destination destination, Serializable msg, JmsDelayEnum jmsDelayEnum, long delayTime) {
try {
DelayMessage delayMessage = new DelayMessage<>(DelayMessage.generateOwnMessageId(), msg);
jmsTemplate.send(destination, (Session session) -> {
ObjectMessage message = session.createObjectMessage(delayMessage);
message.setLongProperty(ScheduledMessage.AMQ_SCHEDULED_DELAY, delayTime);
if (null != jmsDelayEnum.getPeriodTime() && null != jmsDelayEnum.getRepeat()) {
message.setLongProperty(ScheduledMessage.AMQ_SCHEDULED_PERIOD, jmsDelayEnum.getPeriodTime());
message.setIntProperty(ScheduledMessage.AMQ_SCHEDULED_REPEAT, jmsDelayEnum.getRepeat());
}
return message;
});
JmsDelayRecord jmsDelayRecord = buildJmsDelayRecord(destination, delayMessage, jmsDelayEnum.getType(), delayTime);
jmsDelayRecordDao.insert(jmsDelayRecord);
} catch (Exception e) {
logger.error("jms延迟消息发送失败", e);
}
}
/**
* 构建延迟队列消息记录
* @param destination 目标队列
* @param delayMessage 延迟消息
* @param delayType 延迟投递类型
* @param delayTime 延迟时间(单位:毫秒)
* @return
*/
private JmsDelayRecord buildJmsDelayRecord(Destination destination, DelayMessage delayMessage,
Integer delayType, long delayTime) {
JmsDelayRecord record = new JmsDelayRecord();
record.setDelayType(delayType);
record.setOwnMessageId(delayMessage.getOwnMessageId());
String queueName = "";
try {
queueName = ((ActiveMQQueue) destination).getQueueName();
} catch (JMSException e) {
logger.error("buildJmsDelayRecord error! " + delayMessage, e);
}
record.setQueueName(queueName);
record.setDelayTime(delayTime);
record.setContent(null != delayMessage.getMessageBody() ? delayMessage.getMessageBody().toString() : "");
record.setStatus(JmsDelayRecord.STATUS_DEFAULT);
record.setDbCreateTime(new Date());
record.setDbUpdateTime(new Date());
return record;
}
public void setJmsTemplate(JmsTemplate jmsTemplate) {
this.jmsTemplate = jmsTemplate;
}
}
4)注册延迟消息模版到Spring中
四、小结
1、无论是基于死信队列还是基于数据先存储后投递,本质上都是将延迟待发送的消息数据与正常订阅的队列分开存储,从而降低耦合度
2、无论是检查队头消息TTL还是调度存储的延迟数据,本质上都是通过定时任务来完成的,但是定时任务的触发策略以及延迟数据的存储方式决定了不同中间件之间的性能优劣
觉得不错的朋友希望请持续关注我哦,每周定期会分享3到4篇精选干货!