新闻详情

新闻详情

首页 / 资讯中心 / 详情

Egg.js定时任务与扩展机制实战:从缓存刷新到框架深度定制

发布时间:2026/9/24 22:18:58来源:尧图网络
Egg.js定时任务与扩展机制实战:从缓存刷新到框架深度定制
1. 第11天为什么把定时任务和扩展机制放在一起学1.1 15天学习计划的前10天都干了什么先交代一下我这15天计划的节奏。今天是第11天计划已经过去三分之二。前10天我没有按文档目录一章一章啃而是按一条请求从进来到返回的链路去推进第1天把Egg.js环境跑通、初始化项目第2天处理路由和控制器第3天把业务逻辑下沉到Service层第4天写登录鉴权中间件第5天用模板渲染页面第6天接上数据库ORM第7天做参数校验和统一异常处理第8天研究插件机制第9天把日志和调试手段补齐第10天给接口补单元测试。前10天解决的问题本质上是用户主动发起请求时应用如何正确响应。但真实业务里还有另外一类需求它们不是用户点击触发的而是系统在某个时间点自动执行的比如每天凌晨清一次过期数据、每5分钟刷一次配置缓存、每周一统计上周报表。这类需求如果写在控制器里就得有人一直访问接口才可能触发非常不靠谱。所以我今天把egg-schedule 定时任务和框架扩展机制放在一起学。前者解决到点自动干活的问题后者解决在框架内置对象上补充自定义能力的问题。两个内容单独看都不复杂但放在一起正好能把Egg.js约定式目录设计的精髓串起来。1.2 这两个主题在框架里的位置Egg.js最让我舒服的一点是约定大于配置。app/schedule目录下放定时任务app/extend目录下放扩展文件只要文件路径和导出方式正确框架会自动加载。我在第11天之前其实已经无意中用过扩展了比如在app/extend/context.js里加统一响应方法只是没有系统理解它的加载机制。从框架设计角度看定时任务和扩展机制其实是互补的定时任务需要拿着ctx和app去访问Service、模型和配置但单独的定时任务跑完就结束了如果它产生的数据要暴露给接口使用就必须把数据放到某个能跨模块访问的地方这时候扩展机制就有用了。比如定时任务每隔5分钟把数据库里的配置表拉取到内存缓存接口层通过app.getConfigCache(key)读取不需要每次请求都查库。这个组合在真实项目中几乎是标配。2. 定时任务实战从每天清理过期数据开始2.1 一个真实的需求我第11天练习用的项目是一个订单系统订单表里会有很多状态为已支付但超时未处理的订单。这类数据如果不定期清理会越积越多影响查询性能。需求很直接每天凌晨3点把创建时间超过24小时且状态为已超时的订单标记为已取消。这个需求特别适合拿来做定时任务入门因为它的触发时间点是固定的不依赖任何用户请求任务内部要访问Service和数据库逻辑完整。2.2 最小可用任务在Egg.js项目里新建app/schedule/cleanExpired.js文件代码如下// app/schedule/cleanExpired.js const Subscription require(egg).Subscription; class CleanExpired extends Subscription { static get schedule() { return { cron: 0 0 3 * * *, type: all, }; } async subscribe() { const { ctx } this; ctx.logger.info([schedule] clean expired orders start); const removedCount await ctx.service.order.removeExpired(); ctx.logger.info([schedule] clean expired orders finished, removed: %d, removedCount); } } module.exports CleanExpired;定时任务有两种写法一种是上面这种继承Subscription类的方式另一种是直接导出一个包含schedule和task字段的对象。我推荐用类的方式原因有两点一是和Service、Controller的写法风格统一二是后面如果想要抽公共逻辑类继承更好处理。subscribe()方法里的this.ctx是框架自动注入的匿名上下文可以直接访问ctx.service、ctx.logger、ctx.app等对象所以写任务和写控制器内的逻辑几乎没有区别。2.3 cron表达式、interval和type到底怎么配schedule的配置项网上有很多资料但真正项目里我基本只用下面这几个列个表方便对照配置项作用示例值说明interval固定时间间隔触发5m、30s、1h适合周期固定的刷新类任务croncron表达式触发0 0 3 * * *适合指定时刻执行的任务type执行范围worker、all决定在多少个Worker进程里跑env指定环境执行local、prod空数组表示所有环境都执行immediate应用启动后是否立刻执行一次true、false默认false配合开发调试用disable是否禁用该任务true、false临时关掉任务时很实用必须提醒一个容易搞混的地方Egg.js的cron表达式是6位的顺序是秒 分 时 日 月 周。0 0 3 * * *表示每天的3点0分0秒执行。很多从Linux crontab转过来的人习惯写5位比如0 3 * * *在Egg里这个表达式的含义会变成每小时的第3分钟第0秒执行和预期的每天3点差了十万八千里。我第一次写就踩了这个坑后来凡是遇到cron配置我都会先数一下位数。2.4 任务里访问Service和数据库定时任务的subscribe()方法里拿到的ctx和一次请求中的ctx在使用方式上没有差别。比如ctx.service.order.removeExpired()会正常走Service层ctx.model.Order也能正常访问模型。有一点需要注意因为这不是真实HTTP请求触发的ctx上并没有ctx.request.body、ctx.params这类请求参数但ctx.DB、ctx.service、ctx.logger、ctx.app这些核心能力全都在。所以任务逻辑里不要依赖任何请求态数据如果需要读取配置或数据库直接通过全局对象去拿。我用到的removeExpiredService方法大概是这样的// app/service/order.js const Service require(egg).Service; class OrderService extends Service { async removeExpired() { const { ctx } this; const { Op } this.app.Sequelize; const [updatedCount] await ctx.model.Order.update( { status: cancelled }, { where: { status: timeout, created_at: { [Op.lt]: new Date(Date.now() - 24 * 60 * 60 * 1000), }, }, } ); return updatedCount; } } module.exports OrderService;2.5 多实例部署下任务重复执行的坑type配置是我认为今天最值得警惕的一个点。type: all表示所有Worker进程都会执行这个任务type: worker表示只随机选一个Worker执行。在本地开发时Egg.js默认会启动多个Worker进程和CPU核心数相关如果你用type: all就会看到定时任务在多个进程里同时跑。日志里同样的输出出现好几次这是预期行为不是Bug。但到了多机部署场景情况就变了。type: worker只能保证单台机器内部只有一个Worker执行无法避免多台机器同时执行。假设你有3台实例那么这个任务每天凌晨会在3台机器上各跑一次。如果你的任务是只读或幂等的问题不大如果是清理过期数据这类写操作重复执行可能导致重复处理或数据竞争。我处理这个问题的经验是优先保证任务幂等。比如清理任务里UPDATE语句先通过status timeout条件过滤第一次执行后状态已经变成cancelled第二次执行时筛选条件匹配不到数据自然不会重复处理。如果确实需要严格单机执行可以用Redis的SET key value NX EX seconds实现分布式锁锁的key用任务名拿到锁才执行。2.6 手动触发与调试定时任务在开发阶段最烦的是要等时间点到了才能看到效果。我学egg-schedule时发现框架提供了手动触发方法在任意地方调用await app.runSchedule(cleanExpired);这个方法可以直接在app.js生命周期里调用也可以临时写在一个调试路由里。更方便的方式是在schedule配置里临时加上immediate: true这样应用启动后任务会立刻执行一次适合验证逻辑。但我建议immediate只在开发环境用而且记得配合env: [local]避免线上每次发布重启都跑一遍全量刷新任务。3. 框架扩展机制给Application和Context加方法3.1 扩展机制到底扩展了什么学完定时任务我马上遇到一个新问题任务和接口之间怎么共享数据。比如定时任务把配置表刷新到了内存缓存但接口层去哪取这个缓存最笨的办法是放到全局变量里但Egg.js有更规范的做法——扩展机制。Egg.js允许在app/extend目录下创建多个固定名称的文件每个文件里的方法或属性会被挂载到对应的内置对象上文件挂载对象使用场景application.jsapp全局方法、全局缓存、应用级工具context.jsctx请求级工具、统一响应方法request.jsctx.request请求参数处理response.jsctx.response响应处理helper.jsctx.helper模板和逻辑中通用的格式化方法可以把它理解为给框架原厂对象加装自定义零件。框架本身提供了ctx.body、ctx.service等能力但不可能为每个业务都内置好所有方法扩展机制就是留出来让你自己补的地方。3.2 实际代码统一响应、缓存和格式化时间我最常用的扩展是context.js里的统一成功响应方法。之前没系统学扩展时我每个控制器里都写ctx.body { code: 0, message: ok, data }代码重复得厉害。用扩展改造后变成这样// app/extend/context.js module.exports { success(data, message ok) { this.body { code: 0, message, data, }; }, };注意这里必须用普通函数的写法不能用箭头函数。箭头函数的this指向的是定义时的上下文而不是当前的ctx会导致this.body报错。同理在application.js扩展中方法内要拿app实例也依赖普通函数形式的this。再来看application.js。我今天的项目里要维护一份全局配置缓存用扩展实现// app/extend/application.js module.exports { getConfigCache(key) { if (!this._configCache) { this._configCache new Map(); } if (key undefined) { return this._configCache; } return this._configCache.get(key); }, setConfigCache(list) { this._configCache new Map(list.map(item [item.key, item.value])); }, };这里的this._configCache是挂在app实例上的自定义属性通过扩展方法操作它就能让所有地方共享同一份缓存。扩展文件会挂载到原型上所以每次访问app.getConfigCache拿到的都是同一个实例上的方法符合预期。格式化时间的场景适合放到helper.js因为模板里也能直接调用// app/extend/helper.js module.exports { formatTime(date, format YYYY-MM-DD HH:mm:ss) { if (!date) return ; const d new Date(date); const pad num String(num).padStart(2, 0); return format .replace(YYYY, d.getFullYear()) .replace(MM, pad(d.getMonth() 1)) .replace(DD, pad(d.getDate())) .replace(HH, pad(d.getHours())) .replace(mm, pad(d.getMinutes())) .replace(ss, pad(d.getSeconds())); }, };模板里这样用p{{ helper.formatTime(order.created_at) }}/p3.3 生命周期钩子初始化时机比方法本身更重要扩展方法只是定义能力真正要使用前必须确认初始化时机。Egg.js在app.js里提供了一套完整的启动生命周期钩子顺序是configWillLoad、configDidLoad、didLoad、willReady、didReady、serverDidReady最后是beforeClose。我在第11天第一次有原来启动流程是这样的感觉就是在didReady里写缓存预热逻辑的时候。didReady表示应用已经完成配置加载所有Service、模型、插件都已就绪这时候去数据库拉数据初始化缓存是安全的。// app.js class AppBootHook { constructor(app) { this.app app; } async didReady() { const { app } this; const ctx app.createAnonymousContext(); const configList await ctx.service.config.findAll(); app.setConfigCache(configList); ctx.logger.info([app] config cache initialized, total: %d, configList.length); } } module.exports AppBootHook;app.createAnonymousContext()是这里的关键它会在没有真实HTTP请求的情况下创建一个独立的ctx实例让我们能访问Service。如果只用app本身拿不到ctx.service所以在初始化阶段想用Service一定要先创建匿名上下文。还有一个生命周期是beforeClose适合做资源释放比如关闭数据库连接、断开Redis。我这次项目里没用到复杂连接但把beforeClose这个方法记下了真实项目里优雅退出很重要。4. 组合实战本地内存缓存 定时刷新 接口读取4.1 需求与设计现在我试着把两个主题合并成一个完整功能。这个功能在很多中后台系统里都有系统有一批配置项比如网站名称、营业时间、某功能开关配置项存在数据库里但接口读取时希望走内存缓存避免每次请求都查数据库。缓存需要每隔5分钟刷新一次保证配置变更后最多延迟5分钟生效。整体设计思路分三层app/extend/application.js提供getConfigCache(key)和setConfigCache(list)两个扩展方法。app/schedule/refreshConfig.js定时任务每隔5分钟从数据库拉取全量配置写入缓存。控制器通过app.getConfigCache(key)读取配置返回给前端。因为type: all会让每个Worker进程都执行任务所以每个Worker进程的内存缓存都会被刷新接口层无论命中哪个Worker读到的都是最新缓存。4.2 完整代码实现首先是Service这里为了避免引入数据库依赖直接用静态数据演示实际项目里把findAll换成数据库查询即可// app/service/config.js const Service require(egg).Service; class ConfigService extends Service { async findAll() { // 实际项目中从数据库读取这里用静态数据便于演示 return [ { key: site_name, value: eggjs 15day demo }, { key: opening_hours, value: 09:00-22:00 }, ]; } } module.exports ConfigService;然后是扩展方法和第3章里写的一样。接着是定时任务// app/schedule/refreshConfig.js const Subscription require(egg).Subscription; class RefreshConfig extends Subscription { static get schedule() { return { interval: 5m, type: all, }; } async subscribe() { const { app, ctx } this; const configList await ctx.service.config.findAll(); app.setConfigCache(configList); ctx.logger.info([schedule] config refreshed, total: %d, configList.length); } } module.exports RefreshConfig;注意这里我同时解构了app和ctx。app来自this.appctx来自this.ctx。定时任务继承Subscription后this上已经绑定了这两个对象可以直接用。然后是控制器和路由// app/controller/config.js const Controller require(egg).Controller; class ConfigController extends Controller { async get() { const { ctx, app } this; const key ctx.params.key; const value app.getConfigCache(key); if (value undefined) { ctx.body { code: 404, message: config not found }; return; } ctx.success(value); } } module.exports ConfigController;这里为了展示第3章扩展机制的成果用了ctx.success(value)而不是手工写ctx.body。// app/router.js module.exports app { const { router, controller } app; router.get(/config/:key, controller.config.get); };最后是启动预热在app.js里保证应用启动后立刻就有缓存不用干等第一个5分钟// app.js class AppBootHook { constructor(app) { this.app app; } async didReady() { const { app } this; const ctx app.createAnonymousContext(); const configList await ctx.service.config.findAll(); app.setConfigCache(configList); ctx.logger.info([app] config cache initialized, total: %d, configList.length); } } module.exports AppBootHook;4.3 验证步骤写完代码我在本地跑起来验证了一下。先启动开发模式npm run dev然后请求接口curl http://127.0.0.1:7001/config/site_name如果一切正常会返回{ code: 0, message: ok, data: eggjs 15day demo }为了确认定时任务确实在刷新缓存我把Service的findAll改成每次返回带时间戳的值等5分钟后再请求一次发现数据变了证明定时任务生效。开发时如果不想等5分钟可以临时把interval改成30s或者调用app.runSchedule(refreshConfig)手动跑一次。4.4 组合使用的注意点这个组合方案在我自己项目里跑得很稳但有几点必须心里有数内存缓存是每个Worker进程独立的。type: all保证了所有Worker都刷新但如果应用进程重启缓存会丢失所以app.js里的预热逻辑不能省。缓存数据量不能无限膨胀。setConfigCache每次用新的Map整体替换旧数据所以配置表多大缓存就多大。如果配置表有几十万条这种全量刷新策略就不太合适需要改成按key粒度的增量缓存。千万避免在定时任务里做重DB操作。比如每天凌晨统计报表如果数据量很大任务执行会占用大量数据库连接影响正常请求。这种情况建议把任务拆小或者通过消息队列异步处理。5. 第11天踩坑记录与学习建议5.1 第一个坑cron时区不一致我调试定时任务时遇到了一个非常迷惑的现象本地设置cron: 0 0 9 * * *每天上午9点执行但日志显示实际执行时间是下午5点整整差了8个小时。排查后发现这是因为我的开发机系统时区是Asia/Shanghai而部署的测试服务器默认时区是UTC。Egg.js的定时任务调度器解析cron表达式时基于的是当前进程所在环境的时区不是自动切换成北京时间。这个坑的解决方案一般有两个一是在服务器上统一设置时区比如Docker容器里设置环境变量TZAsia/Shanghai二是确保应用代码里所有时间统一按UTC存储、展示时再转换。我现在的习惯是所有数据库时间字段统一存UTC定时任务如果依赖北京时间凌晨3点这种业务时间就明确用带时区偏移的表达式或者干脆在调度器外层计算好下一次执行时间。5.2 第二个坑任务日志莫名出现多次本地跑定时任务日志里明明只写了一个任务却出现了两条相同输出。我第一反应是任务重复注册了查了目录没问题又怀疑是Egg框架自带重试机制查了文档也没这个功能。最后在schedule配置里发现type默认值是all。我的本地开发环境有两个Worker进程这个任务在每个Worker里各执行一次自然就有两条日志。解决方案很简单改成type: worker就只跑一个Worker。但我也说了多机部署时type: worker依然可能重复执行所以真正要避免重复副作用靠的还是任务幂等或者分布式锁。5.3 第11天之后剩余4天怎么安排到第11天整个15天计划里框架主链路相关的内容基本学完了。接下来4天我不打算继续加新功能而是往回补工程化深度第12天把项目用TypeScript重写一遍重点体会Egg.js的声明文件和各模块的类型约束第13天给核心接口做压测看看并发下的瓶颈在哪顺带调优数据库连接池和缓存策略第14天做部署用Docker打包项目配上CI自动化发布第15天把所有笔记整理成一份完整项目文档把15天里踩过的坑按目录归档。为什么要这样安排因为纯学框架语法是最容易的一层真正难的是把项目跑起来之后的那堆事情。Egg.js能让你快速把项目写得像模像样但生产环境对稳定性的要求往往和框架本身无关而是对时区、进程模型、部署方案的把握。这些不花时间实战光看文档是学不会的。我这第11天的经验是定时任务和扩展机制不要分开学把它们串成定时刷新缓存、接口读缓存这个完整功能一个下午就能形成很深的记忆。接下来几天我打算把所有功能都按这个思路收拢到一个小项目里最后一天拿出来复盘。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

I2C通信故障排查全流程:从万用表到示波器与ACK解码 2026/9/25 4:56:56

I2C通信故障排查全流程:从万用表到示波器与ACK解码

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
大电流H桥驱动方案:IR2104+LR7843自举电路设计与实战 2026/9/25 4:56:56

大电流H桥驱动方案:IR2104+LR7843自举电路设计与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
计量芯片封装选型:尺寸不是关键,热-电-机械耦合才是精度核心 2026/9/25 4:56:56

计量芯片封装选型:尺寸不是关键,热-电-机械耦合才是精度核心

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
高考志愿填报智能辅助系统:数据模型与推荐算法落地拆解 2026/9/25 4:56:56

高考志愿填报智能辅助系统:数据模型与推荐算法落地拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
GPU SIMT指令依赖检测:解锁CUDA性能瓶颈的核心技术 2026/9/25 4:56:56

GPU SIMT指令依赖检测:解锁CUDA性能瓶颈的核心技术

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
节后康复学习日志:肩关节复合体与SOAP记录的7小时高效实践 2026/9/25 4:56:50

节后康复学习日志:肩关节复合体与SOAP记录的7小时高效实践

春节回来第一周,我最怕的不是肠胃,是书桌。Day1坐在桌前两个小时,光是翻目录就翻了四十分钟,脑子里全是年夜饭的油香和亲戚家小孩的哭声。到了Day2,我干脆不跟生物钟较劲了,把这一天的康复学习定在12:30到2…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉