最近做的头条项目其中有个功能是创作者发表的文章可以设置在未来某个时间发表,在实现这个功能的时候就在想该怎么实现呢?刚开始想的是利用Spring的定时任务定时的去数据库中查询,可以这个查询频率该怎么设置,每次从数据库中需要查询文章延迟发布表的全部信息,未免有点太消耗时间了,况且MySQL还是存在本地磁盘的,读取成本过高。
这个时候我就想既然遇到了读写速度问题,就要找缓存来解决了,利用Redis存储在内存的特性,将马上就要发布的文章信息存进Redis中,利用定时任务一分钟查询一次缓存,查看是否有要发布的文章,拉取对应的文章信息进行审核发布
我在redis中使用两种数据结构来存储文章发布信息
list
是一个简单的字符串列表,按照插入顺序排序。你可以在列表的头部(左边)或尾部(右边)插入元素。
使用list存储发布时间小于等于当前时间也就是立刻就要发布的文章,每次都从列表的左边插入文章信息,定时任务消费的时候从右边拉取数据,以形成一定的时间顺序
zset
是一种特殊的集合,它和普通集合一样,成员都是唯一的,但每个成员都会关联一个分数(score),Redis 会根据分数对成员进行从小到大的排序。
zset和list的区别就是元素唯一,并且每个元素绑定一个分数, 该集合会根据分数的大小进行排序,正好就可以把文章的预发布时间当作score,这样每次取score最小的文章也就是最早要发布的文章,符合业务逻辑
延迟发布任务的存储处理
处理流程如上图所示,当有用户发起文章发布请求时
1 先将文章相关的信息存入本地数据库中做备份(防止因为系统或断电导致缓存丢失)
2 然后判断文章的预发布时间
如果小于等于当前时间,直接放入list中等待定时任务消费
否则如果发布时间在未来五分钟以内,放入zset中
public long addTask(Task task) {
//先存储进本地数据库
boolean success=addTaskToDb(task);
//存进缓存
if(success){
addTaskToCache(task);
}
return task.getTaskId();
}
/**
* 把任务添加到redis中
*
* @param task
*/
private void addTaskToCache(Task task) {
String key = task.getTaskType() + "_" + task.getPriority();
//获取5分钟之后的时间 毫秒值
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 5);
long nextScheduleTime = calendar.getTimeInMillis();
//2.1 如果任务的执行时间小于等于当前时间,存入list
if (task.getExecuteTime() <= System.currentTimeMillis()) {
cacheService.lLeftPush(ScheduleConstants.TOPIC + key, JSON.toJSONString(task));
} else if (task.getExecuteTime() <= nextScheduleTime) {
//2.2 如果任务的执行时间大于当前时间 && 小于等于预设时间(未来5分钟) 存入zset中
cacheService.zAdd(ScheduleConstants.FUTURE + key, JSON.toJSONString(task), task.getExecuteTime());
}
}
//MySQL数据
private boolean addTaskToDb(Task task) {
//用来标记是否存储成功
boolean flag = false;
try {
//保存任务表
Taskinfo taskinfo = new Taskinfo();
BeanUtils.copyProperties(task, taskinfo);
taskinfo.setExecuteTime(new Date(task.getExecuteTime()));
taskinfoMapper.insert(taskinfo);
//设置taskID
task.setTaskId(taskinfo.getTaskId());
//保存任务日志数据
TaskinfoLogs taskinfoLogs = new TaskinfoLogs();
BeanUtils.copyProperties(taskinfo, taskinfoLogs);
taskinfoLogs.setVersion(1);
taskinfoLogs.setStatus(ScheduleConstants.SCHEDULED);
taskinfoLogsMapper.insert(taskinfoLogs);
flag = true;
} catch (Exception e) {
e.printStackTrace();
}
return flag;
}
以上仅仅只是请求到来后初步的处理,当消费列表list中的文章发布任务处理完毕后怎么办呢?
Redis数据处理
针对Redis中zset的数据(未来五分钟内发布的文章),需要每分钟查询是否有发布时间小于等于当前时间的,然后从zset中移动到list中
这就涉及到Redis列表的搜索算法了,目前常用的匹配对应元素的方法有keys 的模糊匹配、Scan扫描,由于keys模糊匹配非常占用CPU的时间,所以一般使用SCAN扫描符合要求的数据
当用户数据量较大是,如果从zset中一条一条的将文章发布任务移动到list中也很占用时间,恰好Redis提供了Pipeline请求服务,可以一次传送大量数据,大大节省时间
(同时,基于分布式的软件架构下可能有多个端同时处理文章预发布信息,这里使用redis的分布式锁,占用时间三十秒)
定时任务代码如下
@Scheduled(cron = "0 */1 * * * ?")
public void refresh() {
//使用redis的分布式锁,三十秒后结束
String token= cacheService.tryLock("FUTURE_TASK_SYNC", 1000 * 30);
if(StringUtils.isNotBlank(token)){
System.out.println(System.currentTimeMillis() / 1000 + "执行了定时任务");
// 获取所有未来数据集合的key值,使用scan而非keys
Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");// future_*
for (String futureKey : futureKeys) { // future_250_250
String topicKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];
//获取该组key下当前需要消费的任务数据
Set<String> tasks = cacheService.zRangeByScore(futureKey, 0, System.currentTimeMillis());
if (!tasks.isEmpty()) {
//将这些任务数据添加到消费者队列中
cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
System.out.println("成功的将" + futureKey + "下的当前需要执行的任务数据刷新到" + topicKey + "下");
}
}
}
}
普通redis客户端和服务器交互模式
Pipeline请求模型
官方测试结果数据对比
延迟发布任务的消费
上面已经解决了文章预发布任务的处理,下面就是从缓存中定时的拉取任务进行文章发布了
在自媒体段使用Feign接口远程调用任务模块的poll方法拉取缓存中的任务
@Scheduled(fixedRate = 1000)
@SneakyThrows
@Override
public void scanNewsByTask() {
log.info("文章审核---消费任务执行---begin---");
//从缓存中拉取文章发布任务
ResponseResult responseResult = scheduleClient.poll(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType(),
TaskTypeEnum.NEWS_SCAN_TIME.getPriority());
if(responseResult.getCode().equals(200) && responseResult.getData() != null){
String json_str = JSON.toJSONString(responseResult.getData());
Task task = JSON.parseObject(json_str, Task.class);
byte[] parameters = task.getParameters();
WmNews wmNews = ProtostuffUtil.deserialize(parameters, WmNews.class);
//审核文章内容
wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
}
log.info("文章审核---消费任务执行---end---");
}
任务模块的poll
public Task poll(int type,int priority) {
Task task = null;
try {
String key = type+"_"+priority;
String task_json = cacheService.lRightPop(ScheduleConstants.TOPIC + key);
if(StringUtils.isNotBlank(task_json)){
task = JSON.parseObject(task_json, Task.class);
//更新数据库信息
updateDb(task.getTaskId(),ScheduleConstants.EXECUTED);
}
}catch (Exception e){
e.printStackTrace();
log.error("poll task exception");
}
return task;
}