多租户定时任务管理架构
约 2309 字大约 8 分钟
2026-04-04
一、概述
本项目基于 Spring Boot 2.x + Quartz + MyBatis + Druid 实现多租户定时任务管理。核心设计思路:每租户独立 Scheduler 实例 + 独立数据库,通过动态数据源路由和程序化 Scheduler 创建,实现租户间完全的调度隔离。
技术选型
| 组件 | 版本 | 用途 |
|---|---|---|
| Quartz | 2.3.x | 定时任务调度引擎 |
| Spring Scheduling | Spring Boot 2.5 | SchedulerFactoryBean 程序化创建 |
| Druid | 1.2.x | 数据库连接池(主库 + 每租户独立连接池) |
| MyBatis | 2.x | 数据访问层 |
| MySQL | 8.0 | 各租户独立库存储任务定义和调度状态 |
| JWT | - | 租户标识认证透传 |
二、整体架构
┌─────────────────────────────────┐
│ HTTP Request │
│ Authorization: Bearer <JWT> │
└───────────────┬─────────────────┘
▼
┌─────────────────────────────────┐
│ TenantContextFilter │
│ 从 JWT 提取租户标识 │
│ ThreadLocal 设置数据源上下文 │
└───────────────┬─────────────────┘
▼
┌─────────────────────────────────┐
│ DynamicDataSourceContextHolder │
│ ThreadLocal<String> │
│ = "T001" │
└───────────────┬─────────────────┘
▼
┌───────────────────────────┴───────────────────────────┐
│ DynamicDataSource │
│ (AbstractRoutingDataSource) │
│ │
│ "T001" → DruidDS → jdbc:mysql://.../tenant_db_1 │
│ "T002" → DruidDS → jdbc:mysql://.../tenant_db_2 │
│ "T003" → DruidDS → jdbc:mysql://.../tenant_db_3 │
│ MASTER → DruidDS → jdbc:mysql://.../master │
│ TDENGINE → DruidDS → jdbc:TAOS-RS://.../test │
└───────────────────────────┬───────────────────────────┘
▼
┌───────────────────────────┴───────────────────────────┐
│ SchedulerManager │
│ ConcurrentHashMap<String, Scheduler> │
│ │
│ "T001" → Scheduler_1 ─→ 租户1库 QRTZ_* 表 │
│ "T002" → Scheduler_2 ─→ 租户2库 QRTZ_* 表 │
│ "T003" → Scheduler_3 ─→ 租户3库 QRTZ_* 表 │
└───────────────────────────────────────────────────────┘三、隔离策略:一租户一 Scheduler
3.1 隔离维度
| 维度 | 实现方式 |
|---|---|
| Scheduler 实例 | 每租户独立 SchedulerFactoryBean,程序化创建,非 Spring 单例 Bean |
| 数据库 | 每租户独立 MySQL 库,各有完整的 QRTZ_*(11 张表)、sys_job、sys_job_log |
| 连接池 | 每租户独立 DruidDataSource,通过 DruidConfig.registerTenantDataSource() 创建 |
| 集群 | 不启用(isClustered = false),租户间通过数据库天然隔离 |
| 线程池 | 每 Scheduler 独立 SimpleThreadPool,20 个工作线程 |
3.2 Scheduler 配置参数
// SchedulerManager.buildQuartzProperties()
prop.put("org.quartz.scheduler.instanceName", "Scheduler_" + tenantId);
prop.put("org.quartz.scheduler.instanceId", "AUTO");
prop.put("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool");
prop.put("org.quartz.threadPool.threadCount", "20");
prop.put("org.quartz.threadPool.threadPriority", "5");
prop.put("org.quartz.jobStore.class", "org.springframework.scheduling.quartz.LocalDataSourceJobStore");
prop.put("org.quartz.jobStore.isClustered", "false");
prop.put("org.quartz.jobStore.misfireThreshold", "12000");
prop.put("org.quartz.jobStore.tablePrefix", "QRTZ_");关键点:
- 使用
LocalDataSourceJobStore(Spring JDBC JobStore),需要注入DataSource - 禁用集群模式,避免跨库调度冲突
misfireThreshold = 12000ms:12 秒内的延迟视为正常,不触发 misfire 补偿
四、启动流程(全量预初始化)
4.1 启动时序
Spring Boot 启动
│
├─ DruidConfig.dataSource() → 创建 DynamicDataSource(主库、TDengine)
│
└─ ApplicationReadyEvent 触发
│
▼
SchedulerManager.initAll()
│
├─ 读取 application-tenant.yml 所有租户配置
│
├─ 遍历每个租户标识:
│ ├─ DruidConfig.registerTenantDataSource(id, config)
│ │ ├─ createTenantDataSource() → 创建 DruidDataSource
│ │ ├─ dynamicDS.addTargetDataSource() → 注册到路由表
│ │ └─ eventPublisher.publishEvent(TenantDataSourceRegisteredEvent)
│ │
│ └─ SchedulerManager.onTenantDataSourceRegistered(event)
│ └─ registerScheduler(id, dataSource)
│ ├─ new SchedulerFactoryBean()
│ ├─ factory.setDataSource(dataSource) // 直连租户库
│ ├─ factory.setApplicationContext(ctx) // 注入Spring容器
│ ├─ factory.afterPropertiesSet()
│ ├─ scheduler = factory.getScheduler()
│ ├─ scheduler.getContext().put("applicationContext", ctx)
│ └─ scheduler.start()
│
└─ 日志:已注册 N 个调度器4.2 容错机制
// SchedulerManager.initAll() 中的异常处理
try {
druidConfig.registerTenantDataSource(id, config);
} catch (Exception e) {
log.warn("[SchedulerMgr] 预初始化租户 [{}] 失败(数据库不可达或配置错误),将在首次登录时重试: {}",
id, e.getMessage());
}- 启动时某租户库不可达 → 跳过,不影响其他租户
- 用户首次登录时,
TenantContextFilter检测到数据源未注册 → 调用DruidConfig.registerTenantDataSource()兜底重试 - 重试成功 → 发布
TenantDataSourceRegisteredEvent→ 自动创建 Scheduler
五、HTTP 请求路由(数据源切换)
5.1 过滤器链路
HTTP 请求进入
│
▼
TenantContextFilter (@Order(1), 最高优先级)
│
├─ 读取 Authorization 头 → 提取 Bearer token
│ (若无则尝试 Admin-Token Cookie)
│
├─ JWT 解析 → 获取租户标识
│ tokenService.getTenantIdFromToken(token)
│
├─ 校验租户标识在 application-tenant.yml 中存在
│
├─ 若数据源未注册(启动时跳过):
│ DruidConfig.registerTenantDataSource() → 兜底注册
│ → 发布事件 → SchedulerManager 自动创建 Scheduler
│
├─ DynamicDataSourceContextHolder.setDataSourceType(租户标识)
│
└─ chain.doFilter() → 进入业务处理
│
finally {
DynamicDataSourceContextHolder.clearDataSourceType() // 防泄漏
}5.2 数据源路由原理
// DynamicDataSource.determineCurrentLookupKey()
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceType();
}Spring AbstractRoutingDataSource 在每次获取连接时调用此方法,根据 ThreadLocal 中的租户标识返回对应的 Druid 连接池。
六、定时任务完整生命周期
6.1 任务创建流程
SysJobController.add()
↓
SysJobServiceImpl.insertJob()
├─ jobMapper.insertJob(job) // 写入 sys_job 表(当前租户库)
└─ ScheduleUtils.createScheduleJob(scheduler, job)
├─ 构建 JobDetail(QuartzJobExecution 或 QuartzDisallowConcurrentExecution)
├─ 构建 CronTrigger(cron表达式 + misfire策略)
├─ jobDetail.getJobDataMap().put("TASK_PROPERTIES", job) // 存入任务属性
├─ jobDetail.getJobDataMap().put("TENANT_ID", 租户标识) // ★存入租户标识
└─ scheduler.scheduleJob(jobDetail, trigger)6.2 任务执行流程(跨线程上下文恢复)
Quartz 工作线程(非 HTTP 线程,无 ThreadLocal 数据源上下文)
│
▼
AbstractQuartzJob.execute(context)
│
├─ before(context, sysJob)
│ ├─ 记录开始时间
│ └─ ★从 JobDataMap 恢复租户标识:
│ context.getMergedJobDataMap().get("TENANT_ID")
│ → DynamicDataSourceContextHolder.setDataSourceType(dsKey)
│
├─ doExecute(context, sysJob) // 子类实现:反射调用业务方法
│ └─ JobInvokeUtil.invokeMethod(sysJob)
│ └─ 反射调用 Bean.method() → 此时 MyBatis 自动路由到正确的租户库
│
└─ after(context, sysJob, exception)
├─ 计算耗时
├─ ★写入任务日志(此时 ThreadLocal 仍持有租户标识,日志入正确的租户库)
│ appCtx = context.getScheduler().getContext().get("applicationContext")
│ appCtx.getBean(ISysJobLogService.class).addJobLog(sysJobLog)
│
└─ DynamicDataSourceContextHolder.clearDataSourceType() // 清理6.3 任务状态管理
| 操作 | 数据库(sys_job) | Quartz Scheduler |
|---|---|---|
| 新增 | INSERT → status=PAUSE | scheduler.scheduleJob() + scheduler.pauseJob() |
| 暂停 | UPDATE → status=PAUSE | scheduler.pauseJob(jobKey) |
| 恢复 | UPDATE → status=NORMAL | scheduler.resumeJob(jobKey) |
| 删除 | DELETE | scheduler.deleteJob(jobKey) |
| 立即执行 | - | scheduler.triggerJob(jobKey, dataMap) |
| 修改 cron | UPDATE | scheduler.deleteJob() + createScheduleJob() |
七、Scheduler 生命周期管理
7.1 SchedulerManager 核心数据结构
@Component
public class SchedulerManager {
// 租户标识 → Scheduler
private final Map<String, Scheduler> schedulers = new ConcurrentHashMap<>();
// 获取调度器
public Scheduler getScheduler(String tenantId);
// 检查是否存在且未关闭
public boolean hasScheduler(String tenantId);
// 注册并启动
public Scheduler registerScheduler(String tenantId, DataSource dataSource);
}7.2 生命周期事件
| 事件 | 触发时机 | 处理逻辑 |
|---|---|---|
ApplicationReadyEvent | Spring Boot 启动完成 | 遍历配置,预初始化所有租户的数据源 + Scheduler |
TenantDataSourceRegisteredEvent | 数据源首次注册 / 运行时动态注册 | 自动创建并启动对应 Scheduler |
7.3 ClassLoader 兼容保障
背景:程序化创建的
SchedulerFactoryBean,Quartz 工作线程可能使用不同的 ClassLoader,导致SpringUtils.getBean()找不到 Bean。
// 双重保障
factory.setApplicationContext(applicationContext); // ① SchedulerFactoryBean 内部传递
scheduler.getContext().put("applicationContext", applicationContext); // ② 存入Scheduler上下文此外,application.yml 中禁用了 DevTools 热部署:
spring:
devtools:
restart:
enabled: false # 消除 restart classloader,避免 Quartz 工作线程 Class 不匹配八、数据库表结构
8.1 Quartz 内置表(每租户库各一份)
| 表名 | 用途 |
|---|---|
QRTZ_JOB_DETAILS | 存储 Job 详细信息 |
QRTZ_TRIGGERS | 存储 Trigger 信息 |
QRTZ_CRON_TRIGGERS | Cron 表达式触发器 |
QRTZ_SIMPLE_TRIGGERS | 简单触发器(重复次数、间隔) |
QRTZ_SIMPROP_TRIGGERS | 扩展属性触发器 |
QRTZ_BLOB_TRIGGERS | Blob 存储的自定义触发器 |
QRTZ_CALENDARS | 日历信息(排除特定时间段) |
QRTZ_PAUSED_TRIGGER_GRPS | 已暂停的 Trigger 组 |
QRTZ_FIRED_TRIGGERS | 已触发的 Trigger 状态 |
QRTZ_SCHEDULER_STATE | Scheduler 实例状态 |
QRTZ_LOCKS | 悲观锁(集群模式同步用) |
8.2 业务表(每租户库各一份)
| 表名 | 对应实体 | 用途 |
|---|---|---|
sys_job | SysJob.java | 定时任务定义(名称、cron、调用目标、状态) |
sys_job_log | SysJobLog.java | 任务执行日志(开始时间、耗时、异常信息) |
8.3 MyBatis Mapper
<!-- wsd-quartz/src/main/resources/mapper/quartz/SysJobMapper.xml -->
<select id="selectJobList" resultType="SysJob">
SELECT * FROM sys_job
<where>
<if test="jobName != null">AND job_name LIKE CONCAT('%', #{jobName}, '%')</if>
<if test="status != null">AND status = #{status}</if>
</where>
</select>
<!-- wsd-quartz/src/main/resources/mapper/quartz/SysJobLogMapper.xml -->
<insert id="insertJobLog">
INSERT INTO sys_job_log (...) VALUES (...)
</insert>九、关键类职责总结
9.1 基础设施层(wsd-framework)
| 类 | 路径 | 职责 |
|---|---|---|
DynamicDataSource | framework/datasource/ | 继承 AbstractRoutingDataSource,根据 ThreadLocal 路由数据源;支持运行时 addTargetDataSource() |
DynamicDataSourceContextHolder | framework/datasource/ | ThreadLocal<String> 持有当前租户标识 |
TenantContextFilter | framework/datasource/ | HTTP 过滤器(@Order(1)),JWT→租户标识→ThreadLocal |
DruidConfig | framework/config/ | 创建主库/租户库 Druid 连接池;registerTenantDataSource() 动态注册;发布注册事件 |
TenantProperties | framework/config/properties/ | 映射 application-tenant.yml 配置 |
DruidProperties | framework/config/properties/ | Druid 连接池通用参数注入 |
TenantDataSourceRegisteredEvent | framework/event/ | Spring 事件,携带租户标识 + DataSource |
9.2 调度层(wsd-quartz)
| 类 | 路径 | 职责 |
|---|---|---|
SchedulerManager | quartz/config/ | 核心管理器:维护 Map<租户标识, Scheduler>;启动预初始化;监听注册事件创建 Scheduler |
SysJobServiceImpl | quartz/service/impl/ | 任务 CRUD 业务实现;通过 currentScheduler() 定位当前租户 Scheduler |
AbstractQuartzJob | quartz/util/ | Quartz Job 基类:before() 恢复租户上下文,after() 清理 + 写日志 |
ScheduleUtils | quartz/util/ | 创建 Quartz JobDetail/Trigger;将租户标识写入 JobDataMap |
JobInvokeUtil | quartz/util/ | 反射调用目标 Bean 方法(beanName.method(args)) |
CronUtils | quartz/util/ | Cron 表达式校验、下次执行时间计算 |
9.3 领域层(wsd-quartz)
| 类 | 路径 | 职责 |
|---|---|---|
SysJob | quartz/domain/ | 定时任务实体(jobId, jobName, cronExpression, invokeTarget, status...) |
SysJobLog | quartz/domain/ | 任务执行日志实体(jobName, startTime, stopTime, status, exceptionInfo...) |
十、配置示例
10.1 application-tenant.yml
tenant:
default: tenant-a
datasources:
T001:
name: 租户一
url: jdbc:mysql://your-db-host:3306/tenant_db_1?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
username: root
password: xxxxxx
T002:
name: 租户二
url: jdbc:mysql://your-db-host:3306/tenant_db_2?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
username: root
password: xxxxxx10.2 application.yml(关键配置)
spring:
devtools:
restart:
enabled: false # 禁用热部署,避免 Quartz ClassLoader 冲突
token:
header: Authorization # JWT 提取标识
mybatis:
mapper-locations: classpath*:mapper/**/*Mapper.xml十一、扩展与运维
11.1 新增租户
- 在
application-tenant.yml中添加租户配置 - 创建对应 MySQL 库,执行
quartz.sql(11 张 QRTZ_ 表) - 重启服务(启动时自动预初始化)或触发用户登录(Filter 兜底注册)
11.2 故障恢复
- 启动时库不可达:跳过该租户,其他租户正常服务;用户首次登录触发重试
- 运行时库断连:Druid 连接池自动重连;Scheduler 的 misfire 机制补偿错过的任务
- misfire 策略:
maxMisfiresToHandleAtATime = 1,每次最多补一个,避免积压
11.3 监控要点
- Druid 监控页面:
/druid/(查看各数据源连接池状态) - 任务日志:查询各租户库的
sys_job_log表 - Scheduler 状态:
SchedulerManager.hasScheduler(tenantId)检查调度器是否存活 - 日志关键字:
[SchedulerMgr]、[Tenant]、[DynamicDS]
十二、架构要点回顾
| 要点 | 说明 |
|---|---|
| Scheduler 隔离 | 每租户独立 Scheduler 实例 + 独立连接池 + 独立 MySQL 库 |
| 数据源路由 | Spring AbstractRoutingDataSource + ThreadLocal<String> |
| 启动策略 | ApplicationReadyEvent 触发全量预初始化,失败跳过等兜底 |
| 任务上下文 | 创建时 JobDataMap 存租户标识,执行前恢复 ThreadLocal,执行后清理 |
| ClassLoader | 双重保障(setApplicationContext + scheduler.getContext().put)+ 禁用 DevTools |
| 日志写入 | 通过 Scheduler 上下文获取 ApplicationContext → 获取 ISysJobLogService → 写入租户库 |
| 动态扩展 | 支持运行时注册新租户数据源 + 自动创建 Scheduler |
贡献者
更新日志
2026/7/21 17:13
查看所有更新日志
b8db2-更新多租户定时任务集成配置于