Egg 定时任务实践

3,552 阅读5分钟

初级篇

定时任务栗子

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 调度采用的是策略模式,你可以选择它提供的两种策略 allworker,通过 schedule 里面的 type 指定,你也可以自己指定一种自定义策略,当然自定义策略必须要通过 agent.schedule.use() 先注册一下。如果一开始就初始策略,但是自定义的策略还没告诉egg-schedule 岂不是会很尴尬。

接讲任务调度,就是需要执行的具体时间。先说系统默认两种策略,你可以在 schedule 中设置 immediate: trueinterval: '1m'cron: '*/10 * * * * *' 这些都会解析,然后算出距离当前最近的一个时间点,如果需要立即执行则调用系统setImmediate ,如果需要延迟则调用 setTimeout。时间达到之后,执行不同策略定义的函数,比如 worker 策略的处理就是

module.exports = class WorkerStrategy extends Strategy {
  handler() {
    this.sendOne(); //通过Egg IPC 随机通知一个 Worker进程 让它执行 `subscribe`
  }
};

处理完成之后,再次计算下一次需要执行时间点,进入循环。
流程如下:
schedule.png

分布式之旅

由于流量增加,老板决定增加服务器来应对,简单有效。但是所有服务器都会运行相同定时脚本,浪费资源都是小事了,有些只能跑一次的 岂不是凉了。

  • 方案一: 手动删了其他服务器代码,只保留一台有。
  • 方案二: 升级定时任务。 方案一简单,能最快见效,只适合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进程执行,也可以通过其他方式通知其他服务
  • 满足定时 、定点执行
  • 可以取消已触发、未执行的任务 流程如下:

Class Diagram.png 主要代码如下:

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;

其他代码 github.com/zhangfei56/…

总结

该实践是单体架构开始转向多服务器的中途的一个插曲,希望能给遇到同样问题的同学一点启发。