---
url: /notes/知识库/多租户定时任务管理架构/index.md
---
# wsd-mes-master 多租户定时任务管理架构

## 一、概述

本项目基于 **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 配置参数

```java
// 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 容错机制

```java
// 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 数据源路由原理

```java
// 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 核心数据结构

```java
@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。

```java
// 双重保障
factory.setApplicationContext(applicationContext);                // ① SchedulerFactoryBean 内部传递
scheduler.getContext().put("applicationContext", applicationContext); // ② 存入Scheduler上下文
```

此外，`application.yml` 中禁用了 DevTools 热部署：

```yaml
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

```xml
<!-- 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

```yaml
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: xxxxxx
```

### 10.2 application.yml（关键配置）

```yaml
spring:
  devtools:
    restart:
      enabled: false       # 禁用热部署，避免 Quartz ClassLoader 冲突

token:
  header: Authorization    # JWT 提取标识

mybatis:
  mapper-locations: classpath*:mapper/**/*Mapper.xml
```

***

## 十一、扩展与运维

### 11.1 新增租户

1. 在 `application-tenant.yml` 中添加租户配置
2. 创建对应 MySQL 库，执行 `quartz.sql`（11 张 QRTZ\_ 表）
3. 重启服务（启动时自动预初始化）或触发用户登录（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 |
