初级篇
定时任务栗子
const Subscription = require('egg').Subscription;
class UpdateCache extends Subscription {
// 通过 schedule 属性来设置定时任务的执行间隔等配置
static get schedule() {
return {
interval: '1m', // 1 分钟间隔
type: 'all', // 指定所有的 worker 都需要执行
};
}
// subscribe 是真正定时任务执行时被运行的函数
async subscribe() {
const res = await this.ctx.curl('http://www.api.com/cache', {
dataType: 'json',
});
this.ctx.app.cache = res.data;
}
}
module.exports = UpdateCache;
egg-schedule 实现原理
定时任务架构中基本都会把调度和执行分离,egg-schedule 同样如此,它把调度schedule放到 Agent进程 里,执行subscribe放在Worker进程 中。
在 egg agent 挂载 egg-schedule 的时候,先是初始化一个 schedule 的实例挂到 agent 上,然后等待egg 所有东西加载完成,正式启动egg 之前再进行 schedule 里面任务调度初始化。
为啥不直接在初始化 schedule 的时候把任务调度也顺便初始化呢?
因为 egg-schedule 调度采用的是策略模式,你可以选择它提供的两种策略all和worker,通过schedule里面的type指定,你也可以自己指定一种自定义策略,当然自定义策略必须要通过agent.schedule.use()先注册一下。如果一开始就初始策略,但是自定义的策略还没告诉egg-schedule 岂不是会很尴尬。
接讲任务调度,就是需要执行的具体时间。先说系统默认两种策略,你可以在 schedule 中设置 immediate: true 、 interval: '1m' 、 cron: '*/10 * * * * *' 这些都会解析,然后算出距离当前最近的一个时间点,如果需要立即执行则调用系统setImmediate ,如果需要延迟则调用 setTimeout。时间达到之后,执行不同策略定义的函数,比如 worker 策略的处理就是
module.exports = class WorkerStrategy extends Strategy {
handler() {
this.sendOne(); //通过Egg IPC 随机通知一个 Worker进程 让它执行 `subscribe`
}
};
处理完成之后,再次计算下一次需要执行时间点,进入循环。
流程如下:
分布式之旅
由于流量增加,老板决定增加服务器来应对,简单有效。但是所有服务器都会运行相同定时脚本,浪费资源都是小事了,有些只能跑一次的 岂不是凉了。
- 方案一: 手动删了其他服务器代码,只保留一台有。
- 方案二: 升级定时任务。
方案一简单,能最快见效,只适合hotfix。
升级方案:
新建一个消息服务messageServer,专门用来管理 调度。然后到时间了 通知其他服务器去执行。
具体实施:
messageServer 定时文件的调度(schedule函数内容)设置成原来定时任务的调度,执行(subscribe函数内容)设置成去通知其他服务器执行原任务的subscribe。
升级后: - 只有messageServer执行调动
- 只有一个业务服务器能抢占到任务
- 保证原来服务器代码最小改动,业务块不动
方案一: 利用一个指定 url 最终只有一个对应业务服务器做出响应这种框架特点,直接发出http请求,然后改造业务服务。
方案二: 用redis 进行通信,相同业务服务监听相对 redis channel 然后抢占从channel 获取到的锁。
messageServer 处理代码如下:
class xxxSubscription extends Subscription {
static get schedule() {
return {
interval: '30s',
immediate: true,
type: 'worker',
};
}
async subscribe() {
const { logger } = this;
const { helper } = this.ctx;
const run = async () => {
const filePath = "D:\\egg\\app\\schedule\\test.js"; // 需要处理的函数标识
const key = this.config.env + '&' + filePath; // 消除环境影响
this.app.redis.clients.get('default').publish(this.config.env + ':Account', key); // Account 是业务服务标识
this.app.redis.clients.get('default').lpush(key, 'lock'); // 放出锁
this.logger.info('发送Account Schedule', key);
};
await run().catch(err => {
logger.error(err);
});
}
}
module.exports = xxxSubscription;
业务服务改造, 在Agent进程监控redis伪代码:
module.exports = agent => {
agent.redis.clients
.get('subscribe')
.subscribe(agent.config.env + ':Account', (err, result) => {
if (err) {
throw err;
}
});
agent.redis.clients.get('subscribe').on('message', (channel, message) => {
switch (channel) {
case agent.config.env + ':Account':
const queueMessage = await agent.redis.clients.get('default').rpop(message);
// 获取锁
if (queueMessage && queueMessage === 'lock') {
const key = message.split('&')[1]; // 获取需要执行的任务
// egg-schedule 通知worker 执行函数
const info = {
key: key,
id: randomId(),
args: [],
};
agent.messenger.sendRandom('egg-schedule', info);
} else {
logger.info('已在其他客户端处理');
}
this.sendOne(message);
break;
default:
agent.logger.info(
'未处理的订阅事件:channel-【' + channel + '】,message-【' + message + '】',
);
}
});
};
动态定点执行
随着时间慢慢推移,我们有了一个很常见 egg-schedule 确很难满足的一个需求,那就是:动态设置一个定点任务!
Java: 你用 Quartz呀?
Egg: ....
老板:等不起 既然Egg 现在没有这样一个插件,那我们可不可以直接拓展下 egg-schedule 呢? egg-schedule 现有逻辑是 在启动时候 就会把所有需要执行的任务 以文件所在绝对路径作为key,调度当成value,用map 方式存储起来,如果需求多次触发同一个key, egg-schedule 原架构就不支持了。
于是放弃egg-schedule Agent 端现有代码,但是Worker 进程的执行方式可以保留,这样可以减少不少开发量。如此一来业务流程改动很小
技术要点:
- 调度统一管理,即调度还是要放到Agent 进程实现。
- 可以在本机Worker进程执行,也可以通过其他方式通知其他服务
- 满足定时 、定点执行
- 可以取消已触发、未执行的任务 流程如下:
主要代码如下:
const TRIGGER_FN = Symbol("dynamic_strategy_fn");
const RUN_FN = Symbol("dynamic_run_fn");
const TRIGGER_INSTANCE = Symbol("dynamic_all_triggers");
const assert = require("assert");
class DynamicSchedule {
constructor(agent) {
this.agent = agent;
this.logger = agent.getLogger("dynamicScheduleLogger");
this.config = agent.config;
this[TRIGGER_FN] = {};
this[RUN_FN] = {};
// 单线程 不会产生 读写 同时事件
this[TRIGGER_INSTANCE] = [];
}
loadTrigger(type, triggerFn) {
if (this[TRIGGER_FN][type]) {
return;
}
this[TRIGGER_FN][type] = triggerFn;
}
loadRunFn(type, fn) {
this[RUN_FN][type] = fn;
}
// { triggerType: 'once', runType: 'localOne', triggerAt: '2021-09-15 17:05', key: 'runAway'}
/**
* 根据 task 创建不同类型 trigger
*
*/
enqueue(task) {
// 定时任务 还是定点任务
const triggerFn = this[TRIGGER_FN][task.triggerType];
assert.ok(triggerFn, "trigger type not registered");
// 通知本地worker 执行还是其他服务器执行
const runFn = this[RUN_FN][task.runType];
assert.ok(runFn, "run type not registered");
const trigger = new triggerFn(task, this.agent, task.key);
this[TRIGGER_INSTANCE].push(trigger);
trigger.bindHandler(runFn);
trigger.start();
trigger.on("job_work", () => {
this.agent.messenger.sendRandom("task_finish", {
id: trigger.id
});
});
// 完成任务 释放资源
trigger.on("job_finish", () => {
const triggerIndex = this[TRIGGER_INSTANCE].findIndex(
item => item.id === trigger.id
);
this[TRIGGER_INSTANCE].splice(triggerIndex, 1);
});
const info = {
id: trigger.id,
key: trigger.key,
triggerAt: task.triggerAt
};
this.agent.messenger.sendRandom("record_task", info);
}
//
cancel(triggerId) {
const trigger = this[TRIGGER_INSTANCE].find(item => item.id === triggerId);
if (trigger && trigger.timeoutId) {
trigger.stop();
}
}
}
module.exports = DynamicSchedule;
总结
该实践是单体架构开始转向多服务器的中途的一个插曲,希望能给遇到同样问题的同学一点启发。