背景
有一个客户有给云音箱推送临时广播的需求。应用场景:商场、店铺节日大促活动通知等。
要求
1.支持批量推送;
2.可按指定时间播报;
3.新设置的临时广播覆盖旧设置的;
4.支持取消尚未播报的临时广播;
方案选型
延时消息的实现方案有很多(本文不做介绍,自行查找),考虑到现有项目中没有集成可用于延时队列的消息中间件等因素,决定采用Redis实现延时消息。
方案设计
1.借助SortedSet的score特性,将消息指定播报的时间timestamp减基线时间N作为socre进行排序(N为2020-01-01 00:00:00对应的秒数),member=DelayMessge({"msg":"广播内容", "timestamp":"指定播报的时间", "appId":"云音箱设备所属的应用ID"}),key=DelayMessageSet。
2.考虑到延时消息(DelayMessge)与设备(sn)是一对多的关系,sn需要单独存储,使用数据结构list;list的key=timestamp_appID_message,value=sn;(key由DelayMessage计算而来,保证唯一性);
3.考虑到需要支持覆盖(要求3)和取消操作(要求4),本方案增加一个redis缓存,存储hash对象;
key=DelayMessageHash_(appID), filed=sn value={"score": "对应SortedSet中延时消息的分数", ..., "arg":"拓展字段"}。
注:“要求3”和“要求4”是客户后期提的需求,但客户制定的接口传参中未设置消息id这个概念,消息和sn又是一对多的关系。覆盖或取消操作时,我们后台服务无法通过客户的接口传参找到之前推送的临时广播。所以采用hash存储,覆盖操作时,将hash表中对应sn的score重新hset即可;同理,取消操作时,将hash表中对应的sn删除即可。
4.定时器Timer,每隔N分钟检查一次DelayMessageSet,如果有N分钟内需要处理的延时消息DelayMessage,则添加延时任务,一条DelayMessage对应一个延时任务执行。
5.延时任务执行时,计算list的key,调用lpop取出sn,再根据sn调用hget取到score,检查对应score是否一致。
方案实施
一、生产延时消息
接收到客户发起的延时推送广播请求后,生产延时消息,如果timestamp - currentTIme > 定时器时间间隔,则直接将添加延时任务。
/**
* 延时推送
*/
private void pushDelay(int currentTime, DelayMessage delayMessage) {
//此处不管
DelayTaskProducer producer = new DelayTaskProducer(delayMessage);
producer.product();
long delay = delayMessage.getScore() + BASELINE_TIME - currentTime;
int reloadInterval = SystemProperty.getIntProperty("config_delay_message_interval");
if(reloadInterval == 0) {
//默认十分钟
reloadInterval = 600;
}
if (delay <= reloadInterval){
logger.debug("消息延时时间小于定时检查时间,直接添加延时任务");
try {
String element = JSONObject.toJSONString(delayMessage);
PushBatchExecutor.getScheduledThreadPoolExecutor().schedule(new DelayTaskConsumer(element), delay, TimeUnit.SECONDS);
} catch (Exception e) {
logger.error("直接添加延时任务出错", e);
}
}
/**
* 生产延时消息
*/
public void product() {
try {
long score = delayMessage.getScore();
int companyId = delayMessage.getCompanyId();
String data = JSONObject.toJSONString(delayMessage);
//将DelayMessage存入SortedSet
RedisManager.zadd("DelayMessageSet", score, data);
//listkey:(timestamp)_(companyId)_(message)
String listKey = delayMessage.getRedisKey();
//hashKey:DelayMessageHash_(companyId)
String hashKey = "DelayMessageHash_" + companyId;
//存储json,后期可加入拓展字段
JSONObject valueJson = new JSONObject();
//设置有效时间
long expiredTime = score + DelayTaskConstant.BASELINE_TIME - System.currentTimeMillis() / 1000 + 60;
//map
Map map = delayMessage.getDeviceMap();
for (String sn: map.keySet()) {
//调用rpush,从列表右边开始放入元素
RedisManager.addListItem(redisKey, sn, (int) expiredTime);
valueJson.put("score", score);
//将设备号对应的score存入hash表中,便于延时消息覆盖及取消等操作。
RedisManager.hset(hashKey, sn, valueJson.toJSONString());
}
}catch (Exception e) {
logger.error("延时消息生产失败:", e);
}
}
二、定时器
包含过期消息清除
public class DelayMessageTimer implements Runnable{
private static final Logger logger = LoggerFactory.getLogger(DelayMessageTimer.class);
private int interval;
public DelayMessageTimer(int interval) {
this.interval = interval;
}
@Override
public void run() {
try {
logger.debug("开始延时消息检查");
int currentTime = (int) (System.currentTimeMillis() / 1000);
int startTime = currentTime - DelayTaskConstant.BASELINE_TIME;
int endTime = startTime + interval;
logger.debug("range:{}to:{}", startTime, endTime);
//取出范围内的数据zrangeByScoreWithScores
Set tupleSet = RedisManager.zrangeByScoreWithScores("DelayMessageSet", startTime, endTime);
if (tupleSet == null || tupleSet.isEmpty()) {
logger.debug("当前没有{}秒内需要执行的延时消息", interval);
return;
}
logger.debug("取出{}秒内待处理延迟消息{}个", interval, tupleSet.size());
tupleSet.forEach(tuple -> {
String element = tuple.getElement();
double delay = tuple.getScore() + DelayTaskConstant.BASELINE_TIME - currentTime ;
logger.debug("{}秒后消费延时消息{}", delay, element);
//执行延时任务
PushBatchExecutor.getScheduledThreadPoolExecutor().schedule(new DelayTaskConsumer(element), (long) delay, TimeUnit.SECONDS);
});
//检查过期的消息,需要删除
Set expiredSet = RedisManager.zrangeByScoreWithScores("DelayMessageSet", 0d, startTime);
if (expiredSet == null || expiredSet.isEmpty()) {
logger.debug("没有过期的延时消息");
} else {
logger.debug("取出{}个过期消息,执行删除element操作", expiredSet.size());
expiredSet.forEach(tuple -> RedisManager.zrem("DelayMessageSet", tuple.getElement()));
}
logger.debug("延时消息检查结束");
} catch (Exception e) {
logger.error("检查延时消息出现错误", e);
}
}
三、消费延时消息
延时消息消费时,需要判断hash中sn对应的score与DelayMessage对应的score的一致性。
public class DelayTaskConsumer implements Runnable{
private static final Logger logger = LoggerFactory.getLogger(DelayTaskConsumer.class);
private String element;
private DelayMessage delayMessage;
public DelayTaskConsumer(String element) {
this.element = element;
}
@Override
public void run() {
logger.debug("开始消费延时消息");
delayMessage = JSONObject.toJavaObject(JSON.parseObject(element), DelayMessage.class);
//需要查询设备
String key = delayMessage.getRedisKey();
String hashKey = "DelayMessageHash_" + delayMessage.getCompanyId();
while (true) {
//移出并获取列表的第一个元素,当列表不存在或者为空时,返回Null
//延时消息生产时,已进行设备号去重,此处从列表左边一个一个的取出。
String sn= RedisManager.lpop(key);
if (null == sn|| "nil".equals(sn)) {
break;
}
//从hash表中取出filed为sn的数据
String value = RedisManager.hget(hashKey, sn);
if (null == value || "nil".equals(value)) {
//表名执行过取消操作,跳过当前设备
continue;
}
//取出value,判断score是否一致
JSONObject valueJson = JSON.parseObject(value);
long score = valueJson.getLong("score");
if (delayMessage.getScore() != score) {
//表名执行过覆盖操作,跳过当前设备
logger.debug("score:{}不一致,跳过当前设备:{}", score, sn);
continue;
}
//删除filed
RedisManager.hdel(hashKey, sn);
//执行推送
pushAndCreateMessage(sn);
}
//将消费完的消息从redis中剔除
RedisManager.zrem("DelayMessageSet", element);
logger.debug("消费延时消息完成");
}
}
延时消息方案流程图
总结
由于客户要求的特殊性,本文在实现延时消息时,同时使用了sortedset(用于排序、范围查取)、list(利用lpop特性,避免分布式场景中的重复消费)、hash(用于过滤设备)三种数据结构。虽说方便覆盖(hset设置新的消息对应的score)和取消(hdel对应的filed)未消费的延时消息,但数据存储显得冗余。本方案完全依赖Redis,暂未做消息持久化存贮。如果大家对方案优化有啥建议,还望不吝赐教。