本文档说明在从「私有化部署(每客户独立库、独立服务器)」转为 SaaS 后,fs-quartz(管理端可配置定时任务)与 fs-qw-task(企微等固定节奏定时任务)的改造思路与落地要点。与整体 SaaS 方案的关系见 SaaS改造方案.md。
私有化时:每个客户独立库、独立服务器,定时任务跑在该客户自己的进程里,天然只操作本库,无需考虑多租户。
SaaS 化后:一套进程、多租户多库,定时任务需要「按租户执行」——即同一任务逻辑要对每个启用租户各执行一遍,且每次执行时:
sys_config(TenantConfigContext)。本方案围绕 fs-quartz 和 fs-qw-task 两个模块,说明如何在不改变业务逻辑的前提下,通过「租户分发 / 按租户遍历」实现上述目标。
SysJobServiceImpl.init() 从该库 selectJobAll() 加载任务并注册到 Quartz。LoginUser,无租户上下文;JobInvokeUtil.invokeMethod(sysJob) 调用的 Bean 方法访问的也是「当前默认数据源」。若仍用「一个 DataSource」且用主库,则读不到各租户的 sys_job;若用某一租户库,则只能跑该租户任务。因此需要显式按租户调度 + 按租户执行。
思路:Quartz 只保留一个定时任务(例如每分钟执行一次),该任务不直接执行业务,而是做「租户分发」:
tenant_info)。TenantDataSourceManager.switchTenant(tenantInfo) 切到该租户库;sys_job 中状态为「正常」且本分钟应触发的任务(或查全部,在内存里用 Cron 判断是否到点);JobInvokeUtil.invokeMethod(sysJob)(此时线程上下文已是该租户库);sys_job_log 时仍在当前租户库,无需改表。DynamicDataSourceContextHolder、TenantConfigContext。优点:各租户的 sys_job / sys_job_log 仍保留在各自库,表结构、管理端「定时任务」功能无需大改;只需新增一个「分发器」Job 和对应的 cron(如 0 * * * * ? 每分钟)。
需要改的点:
| 位置 | 改动内容 |
|---|---|
| fs-admin(或 fs-framework) | 增加一个 Bean,例如 TenantJobDispatcherJob,实现 Quartz Job,内部逻辑为上述 1~3;该 Job 需在启动时就注册到 Scheduler(可写死在代码里,或从主库「系统级任务表」读唯一一条「分发器」配置);分发器从主库查租户列表时需使用主库数据源(查 tenant_info 时先切到 MASTER,循环内再切到各租户)。 |
| fs-quartz | 若任务配置仍在各租户库,则 SysJobServiceImpl.init() 不再从当前库 selectJobAll 注册所有任务,改为只注册「租户分发器」这一条;或保留 init() 但仅当检测到是「主库」时只注册分发器。 |
| 数据源 | fs-admin 的 Quartz 使用的 DataSource 需为 DynamicDataSource(主库 + 各租户动态库),且调度线程执行分发器前要能访问主库与 TenantDataSourceManager。 |
思路:把 sys_job(和可选 sys_job_log)迁到主库,表增加 tenant_id 字段;Quartz 从主库加载全部任务,JobKey 用 tenantId + jobId 保证唯一;执行时从 JobDataMap 取出 tenantId,先 switchTenant + 设置 TenantConfigContext,再执行 invokeMethod。
优点:一个 Quartz 调度器、一套触发逻辑,与现有 Quartz 模型一致。
缺点:要迁移表结构、数据;管理端「定时任务」界面需改为按租户过滤(且操作的是主库表);若希望「租户只能看自己的任务」需在权限上控制。
建议:优先采用 方案 A,对现有私有化表结构零侵入,仅增加一个分发器 Job 和按租户循环执行的逻辑。
同一套代码、同一进程,但每个定时任务要对每个启用租户各执行一遍,且执行时该租户的库、企微配置等来自该租户的 sys_config(TenantConfigContext)。
引入多租户能力
tenant_info 的 Mapper)。封装「按租户执行」工具
TenantTaskRunner.runForEachTenant(Consumer<TenantInfo> action)TenantDataSourceManager.switchTenant(tenantInfo);sys_config.projectConfig 写入 TenantConfigContext(可复用现有 ProjectConfig.loadTenantConfigsFromContext 逻辑);action.accept(tenantInfo);DynamicDataSourceContextHolder、TenantConfigContext。改造每个 @Scheduled 方法
tenantTaskRunner.runForEachTenant(tenantInfo -> { 原有逻辑 });qwCheckSopRuleTime() → tenantTaskRunner.runForEachTenant(t -> qwSopService.checkSopRuleTime());addTag() → tenantTaskRunner.runForEachTenant(t -> qwSopTagService.addTag());wxSop()、sendQwGroupMsgTask() 等同理。Redis
当租户数量多、定时任务多时,若仍按租户顺序执行,总耗时会随租户数线性增加,可能影响执行效率。
建议与实现:
application.yml):
saas.task.parallel:为 true 时,TenantTaskRunner 使用线程池按租户并行执行同一任务(每个租户一个子任务,子任务内仍先切库、加载配置再执行业务)。saas.task.parallel.threads:并行时线程池大小(默认 4),建议不大于租户数,并考虑数据源连接池与机器负载。TenantTaskRunner 中根据 saas.task.parallel 选择顺序执行或并行执行;并行时用固定大小线程池提交「单租户」任务,子任务内设置该租户数据源与 TenantConfigContext,执行完毕清理上下文;整体用 CountDownLatch 或 invokeAll 等待所有租户完成,超时时间建议 30 分钟。cachedCourseConfig),需改为按租户缓存(如 Map<Long, Config>),否则多租户下会错乱。小结:默认顺序执行,与原有行为一致;数据量大、租户多时可开启 saas.task.parallel 提升整体执行效率。
| 所在类 | 方法名 | Cron/频率 | 说明 |
|---|---|---|---|
| qwTask | qwCheckSopRuleTime | 每天 1:10 | 检查 SOP 规则时间 |
| qwTask | addTag | 每 20 分钟 | 添加标签 |
| qwTask | selectSopUserLogsListByTime | 每小时第 5 分钟 | 按营期生成 sopLogs 待发记录 |
| qwTask | wxSop | 每小时第 5 分钟 | 微信 SOP 处理 |
| qwTask | SendQwApiSopLogTimer | 每天 1:20 | 企微官方接口群发(单链) |
| qwTask | SendQwApiSopLogTimerNew | 每天 0:10、1:10 | 企微官方接口群发(新版) |
| qwTask | GetQwApiSopLogResultTimerNew | 每天 8:00 | 获取企微群发反馈结果 |
| qwTask | sendQwGroupMsgTask | 每 10 分钟 | 群发消息任务 |
| qwTask | sendQwBySop | 每天 8:00 | 按 SOP 发送转换消息 |
| qwTask | qwExternalErrRetryTimer | 每 3 分钟 | 企微打标签/备注补偿 |
| qwTask | updateQwSopLogsByCancel | 每小时整点 | 补发过期完课消息 |
| qwTask | batchProcessingExpiredMessages | 每 8 分钟 | 批量处理过期 SOP 待发记录 |
| qwTask | deleteQwSopLogsByDate | 每天 0:10 | 清除 2 天前 SOP 记录 |
| qwTask | processRepairQwSopLogsTimer | 每 3 小时 30 分 | 修复营期异常数据 |
| qwTask | processSopUserLogsInfoByIsDaysNotStudy | 每天 2:35 | E 级客户看课恢复 |
| qwTask | processQwSopExternalContactRatingTimer | 每天 3:45 | 客户分级评级 |
| qwTask | processQwSopExternalContactRatingMoreSevenDaysTimer | 每天 3:30 | 超 7 天未看课 E 级标记 |
| qwTask | updateQwSopLogsDayBefore | 每天 0:03 | 更新前一日待发送 |
| qwTask | updateQwExternalContactUnionid | 每 2 天 0:01 | 同步外部联系人 UnionId |
| qwTask | autoPullGroup | 每天 16:00 | 定时拉人进群 |
| UserCourseWatchCountTask | userCourseCountTask | 每 20 分钟 | 会员看课统计 |
| CourseWatchLogScheduler | checkWatchStatus | 每 1 分钟 | 检查看课状态(含整 5 分钟创建完课消息) |
| CourseWatchLogScheduler | createCourseFinishMsg | 每 5 分钟 | 创建完课消息 |
| CourseWatchLogScheduler | delCourseExpireLink | 每天 0:00 | 删除过期短链 |
| CourseWatchLogScheduler | checkFsUserWatchStatus | 每 30 秒 | WXH5 检查会员看课状态 |
| QwExternalContactRatingServiceImpl | refreshRatingConfig | 每 6 小时 50 分 | 刷新评级配置 qwRating:config |
| QwExternalContactRatingMoreSevenDaysServiceImpl | refreshRatingConfig | 每 6 小时 50 分 | 刷新超 7 天评级配置 |
| SopLogsTaskServiceImpl | refreshCourseConfig | 每 60 秒 | 刷新课程配置 course.config |
上述任务在 saas.task.enabled=true 时均通过 TenantTaskRunner.runForEachTenant(或并行模式)按租户执行。其中 SopLogsTaskServiceImpl.refreshCourseConfig 使用的实例级缓存 cachedCourseConfig 若在多个租户任务中共用,建议改为按租户缓存(如 Map<租户ID, CourseConfig>),否则仅最后执行到的租户配置生效。
| 模块 | 改动要点 |
|---|---|
| fs-framework | 可选:提供 TenantTaskRunner(或放在 fs-qw-task 内),依赖 TenantDataSourceManager、TenantInfoService/Mapper、TenantConfigContext。 |
| fs-admin | Quartz 使用 DynamicDataSource;新增 TenantJobDispatcherJob;init() 只注册分发器或按策略注册。 |
| fs-quartz | 若采用方案 A,可不改表与 Mapper,仅被分发器通过 JobInvokeUtil 调用;若采用方案 B,需 sys_job 增加 tenant_id 及迁移脚本。 |
| fs-qw-task | 依赖 fs-framework;DataSource 改为 DynamicDataSource + 租户;新增 TenantTaskRunner;各 @Scheduled 改为 runForEachTenant(原有逻辑)。 |
着手处理:1) 提出并行执行建议并实现可配置的按租户并行;2) 查找 fs-qw-task 中所有 @Scheduled 并补全租户化执行。
[3 tools called]
正在读取相关任务类与配置,以便实现并行方案并补全未做租户化的定时任务。
[4 tools called]
查看带 @Scheduled 的 Service 实现类,确定如何接入租户执行:
[4 tools called]
正在实现:先为 TenantTaskRunner 增加可配置的按租户并行执行,并补全所有未做租户化的定时任务。
[10 tools called]
正在为三处 Service 的 refresh 定时任务添加租户化支持:
[11 tools called]
移除 QwExternalContactRatingServiceImpl 中可能重复的 javax.annotation 导入,并为 SopLogsTaskServiceImpl 添加租户支持。
[7 tools called]
检查 SopLogsTaskServiceImpl 中 cachedCourseConfig 的用法,确认是否需按租户缓存:
[3 tools called]
SopLogsTaskServiceImpl 的 refresh 已按租户调用;cachedCourseConfig 为单例缓存,多租户下需改为按租户缓存,已记录在文档。正在更新 application.yml 和文档。
[9 tools called]
单独编译 fs-qw-task 时未解析到其他模块的依赖。正在从根目录编译整个项目:
[2 tools called]
已按你的要求做完「多租户下执行效率」的改进,并补全了 fs-qw-task 里所有定时任务的租户化,修改总结如下。
问题:租户多、任务多时,若仍按租户顺序执行,总耗时会随租户数线性增加。
做法:在 TenantTaskRunner 中增加可配置的按租户并行,由配置决定是顺序还是并行。
fs-qw-task/application.yml):
saas.task.parallel:为 true 时按租户并行执行;默认 false,保持原顺序行为。saas.task.parallel.threads:并行时线程池大小(默认 4),可按租户数和连接池适当调大。CountDownLatch 等待(超时 30 分钟),单租户异常只打日志不中断其他租户。fs-qw-task)saas.task.parallel、saas.task.parallel.threads 的读取与线程池初始化/销毁。runForEachTenant 内部根据配置在「顺序」与「并行」两种实现间切换;对外接口不变,现有调用无需改。| 类 | 方法 | 说明 |
|---|---|---|
| UserCourseWatchCountTask | userCourseCountTask | 会员看课统计(每 20 分钟) |
| CourseWatchLogScheduler | checkWatchStatus | 检查看课状态(每 1 分钟,含整 5 分钟创建完课消息) |
| CourseWatchLogScheduler | createCourseFinishMsg | 创建完课消息(每 5 分钟) |
| CourseWatchLogScheduler | delCourseExpireLink | 删除过期短链(每天 0 点) |
| CourseWatchLogScheduler | checkFsUserWatchStatus | WXH5 检查会员看课(每 30 秒) |
| QwExternalContactRatingServiceImpl | refreshRatingConfig | 刷新评级配置 qwRating:config(每 6 小时 50 分) |
| QwExternalContactRatingMoreSevenDaysServiceImpl | refreshRatingConfig | 刷新超 7 天评级配置(每 6 小时 50 分) |
| SopLogsTaskServiceImpl | refreshCourseConfig | 刷新课程配置 course.config(每 60 秒) |
qwTask 里原有 20 个定时任务本身已按租户执行,本次未改逻辑,仅在文档中做了统一清单。
saas.task.parallel、saas.task.parallel.threads 的注释说明。cachedCourseConfig 若在多租户下共用,建议改为按租户缓存(如 Map<租户ID, CourseConfig>)。在项目根目录执行:mvn compile -pl fs-qw-task -am,编译已通过。
使用建议:
saas.task.parallel=false 即可。saas.task.parallel=true,并适当调大 saas.task.parallel.threads 与数据源连接池,同时注意 SopLogsTaskServiceImpl 的 cachedCourseConfig 若在多租户下共用,需改为按租户缓存。